From 92b60deac0caca45c8b05f3724671345b9ab7c46 Mon Sep 17 00:00:00 2001 From: MarcoE Date: Wed, 16 Sep 2026 11:33:36 +0200 Subject: [PATCH 01/27] Fix tastiera che copre il campo di scrittura Da Android 15 (API 35+) l'edge-to-edge e' forzato e adjustResize non ridimensiona piu' la finestra: la tastiera si limita a coprire la BlazorWebView. Con il layout rigido (html overflow:hidden, #app 100vh, page/main height:100% overflow:hidden) nemmeno il browser riesce a portare in vista il campo col focus. - MainActivity: WindowSoftInputMode AdjustResize|StateHidden e listener sugli WindowInsets che applica al content view il padding bottom pari all'inset IME (meno quello delle system bars, gia' coperto da SafeAreaEdges in MainPage), cosi' il 100vh si ricalcola. - main.js: su focusin e visualViewport resize porta il campo attivo al centro con scrollIntoView, necessario per i contenitori scrollabili. Co-Authored-By: Claude Opus 5 (1M context) --- SteUp.Maui/Platforms/Android/MainActivity.cs | 35 ++++++++++++++++++++ SteUp.Shared/wwwroot/js/main.js | 35 ++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/SteUp.Maui/Platforms/Android/MainActivity.cs b/SteUp.Maui/Platforms/Android/MainActivity.cs index 9e73182..9d5aaa8 100644 --- a/SteUp.Maui/Platforms/Android/MainActivity.cs +++ b/SteUp.Maui/Platforms/Android/MainActivity.cs @@ -1,14 +1,49 @@ using Android.App; using Android.Content.PM; +using Android.OS; +using Android.Views; +using AndroidX.Core.View; namespace SteUp.Maui { [Activity( Theme = "@style/Maui.SplashTheme", MainLauncher = true, + WindowSoftInputMode = SoftInput.AdjustResize | SoftInput.StateHidden, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] public class MainActivity : MauiAppCompatActivity { + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + + // Da Android 15 (API 35) il edge-to-edge e' forzato e adjustResize non ridimensiona + // piu' la finestra: la tastiera si limita a coprire la WebView. Riduciamo noi il + // content view dell'altezza dell'IME cosi' il layout Blazor (100vh) si ricalcola. + if (FindViewById(Android.Resource.Id.Content) is { } content) + ViewCompat.SetOnApplyWindowInsetsListener(content, new ImeInsetsListener()); + } + + private sealed class ImeInsetsListener : Java.Lang.Object, IOnApplyWindowInsetsListener + { + public WindowInsetsCompat OnApplyWindowInsets(Android.Views.View? v, WindowInsetsCompat? insets) + { + if (v is null || insets is null) + return insets!; + + var ime = insets.GetInsets(WindowInsetsCompat.Type.Ime()).Bottom; + var systemBars = insets.GetInsets(WindowInsetsCompat.Type.SystemBars()).Bottom; + + // La barra di navigazione e' gia' gestita da SafeAreaEdges in MainPage: + // teniamo solo la parte di tastiera che la eccede, per non sommare due volte. + var bottom = System.Math.Max(0, ime - systemBars); + + if (v.PaddingBottom != bottom) + v.SetPadding(v.PaddingLeft, v.PaddingTop, v.PaddingRight, bottom); + + return insets; + } + } } } diff --git a/SteUp.Shared/wwwroot/js/main.js b/SteUp.Shared/wwwroot/js/main.js index fbd9b20..ea1c5d7 100644 --- a/SteUp.Shared/wwwroot/js/main.js +++ b/SteUp.Shared/wwwroot/js/main.js @@ -95,3 +95,38 @@ document.addEventListener("DOMContentLoaded", () => { }, 600); } })(); + + +// Tastiera: porta il campo attivo dentro l'area visibile. +// Il layout e' a 100vh con overflow:hidden, quindi quando l'host riduce la +// WebView per la tastiera il browser non scrolla da solo sul campo col focus. +(function () { + const SELECTOR = 'input, textarea, [contenteditable="true"]'; + let pending = null; + + function scrollFocusedIntoView() { + const el = document.activeElement; + if (!el || !el.matches || !el.matches(SELECTOR)) return; + el.scrollIntoView({block: 'center', behavior: 'smooth'}); + } + + function schedule() { + clearTimeout(pending); + // attende la fine dell'animazione di apertura della tastiera e del relayout + pending = setTimeout(scrollFocusedIntoView, 300); + } + + document.addEventListener('focusin', function (event) { + if (event.target && event.target.matches && event.target.matches(SELECTOR)) { + schedule(); + } + }); + + // Il resize arriva quando la tastiera si apre/chiude o cambia altezza + // (es. passaggio a tastiera emoji o suggerimenti). + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', schedule); + } else { + window.addEventListener('resize', schedule); + } +})(); From e3942d00cc4f59c7e5f0225955fef46cb031f5c0 Mon Sep 17 00:00:00 2001 From: MarcoE Date: Wed, 16 Sep 2026 13:23:34 +0200 Subject: [PATCH 02/27] Fix rilevamento modifiche nella scheda: articoli e allegati Uscendo dalla scheda il prompt di salvataggio non compariva e il pulsante "Salva" restava nascosto, perche' IsDirty non vedeva il lavoro fatto: - gli articoli scelti dalla griglia (OpenSelectArt) non marcavano mai la scheda come modificata; - ValueComparer confronta solo i campi del form, quindi ogni RecalcDirty() successivo azzerava il flag alzato con forceTrue da scanner e allegati. Non bastava confrontare Articoli: Clone() e' un MemberwiseClone, quindi _originalScheda condivide la stessa lista. Ora il confronto usa uno snapshot dei barcode preso all'apertura piu' un flag per gli allegati (che vivono in AttachedList fino al salvataggio), e RecalcDirty() perde forceTrue: non puo' piu' essere azzerato. Le chiamate su insert/remove degli articoli erano anche fatte prima della modifica della lista, quindi valutavano lo stato precedente. Co-Authored-By: Claude Opus 5 (1M context) --- .../Modal/ModalFormScheda.razor | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/SteUp.Shared/Components/SingleElements/Modal/ModalFormScheda.razor b/SteUp.Shared/Components/SingleElements/Modal/ModalFormScheda.razor index 1d12fb1..f06ce21 100644 --- a/SteUp.Shared/Components/SingleElements/Modal/ModalFormScheda.razor +++ b/SteUp.Shared/Components/SingleElements/Modal/ModalFormScheda.razor @@ -230,6 +230,11 @@ private int _noteFieldKey; private bool IsDirty { get; set; } private Scheda _originalScheda = null!; + // Clone() e' un MemberwiseClone, quindi _originalScheda condivide la stessa lista + // Articoli: per accorgerci degli articoli aggiunti/rimossi serve uno snapshot a parte. + private List _originalBarcodes = []; + // Gli allegati vivono in AttachedList finche' non si salva: li tracciamo con un flag. + private bool _attachmentsDirty; private List? AttachedList { get; set; } protected override void OnInitialized() @@ -240,6 +245,7 @@ OnScannerService.OnErrorScan += OnErrorScan; _originalScheda = Scheda.Clone(); + _originalBarcodes = Scheda.Articoli.ConvertAll(x => x.Barcode); Snackbar.Configuration.PositionClass = Defaults.Classes.Position.TopCenter; LoadAttached(); @@ -456,14 +462,29 @@ StateHasChanged(); } - private void RecalcDirty(bool forceTrue = false) + private void RecalcDirty() { - IsDirty = forceTrue || !ValueComparer.AreEqual(Scheda, _originalScheda); + IsDirty = _attachmentsDirty + || !ValueComparer.AreEqual(Scheda, _originalScheda) + || !BarcodesUnchanged(); if (IsDirty) LabelSave = !IsNew ? "Aggiorna" : "Salva"; else LabelSave = null; } + private bool BarcodesUnchanged() + { + if (Scheda.Articoli.Count != _originalBarcodes.Count) return false; + + for (var i = 0; i < _originalBarcodes.Count; i++) + { + if (!Scheda.Articoli[i].Barcode.Equals(_originalBarcodes[i], StringComparison.Ordinal)) + return false; + } + + return true; + } + private static class ValueComparer { public static bool AreEqual(Scheda? a, Scheda? b) @@ -526,7 +547,8 @@ await InvokeAsync(StateHasChanged); - RecalcDirty(true); + _attachmentsDirty = true; + RecalcDirty(); // Processa in background e aggiorna UI man mano (o a blocchi) _ = Task.Run(async () => @@ -618,7 +640,11 @@ }) ); - await InvokeAsync(StateHasChanged); + await InvokeAsync(() => + { + RecalcDirty(); + StateHasChanged(); + }); } }); } @@ -631,7 +657,8 @@ AttachedList[index].ToRemove = true; AttachedList.RemoveAt(index); - RecalcDirty(true); + _attachmentsDirty = true; + RecalcDirty(); StateHasChanged(); } @@ -701,14 +728,13 @@ if (art != null) { - RecalcDirty(true); - Scheda.Articoli.Insert(0, new SchedaArticolo { Barcode = art.Barcode, Descrizione = art.Descrizione }); + RecalcDirty(); StateHasChanged(); } else @@ -748,8 +774,8 @@ var index = Scheda.Articoli.FindIndex(x => x.Barcode.Equals(barcode)); if (index < 0) return; - RecalcDirty(true); Scheda.Articoli.RemoveAt(index); + RecalcDirty(); StateHasChanged(); } From d076a4b77ad44c2766b00fa440bc3da230d81f29 Mon Sep 17 00:00:00 2001 From: MarcoE Date: Wed, 16 Sep 2026 13:24:35 +0200 Subject: [PATCH 03/27] Salvataggio come bozza: il lavoro in corso non si perde piu' Uscendo da una scheda a meta' (tasto indietro del dispositivo o "Annulla") il lavoro veniva scartato. Il tasto indietro non era nemmeno gestito: non arriva alla WebView e non c'e' stack di navigazione, quindi mandava l'app in background con i dati solo in memoria. Nuovo stato Bozza ("Da completare"): salvata in locale, mai inviabile finche' non viene conclusa. - Scheda.Bozza + migration additiva (default false: le schede esistenti non cambiano stato). - SchedaSyncHelper: terzo SyncState, CountBozze(). CountDaInviare() esclude le bozze, quindi il badge "da inviare" resta corretto. - ModalFormScheda: SaveDraft() salva solo in locale senza validazione; Save() azzera il flag; Submit() avvisa quando il form e' invalido (prima il tocco su "Salva" sembrava non fare nulla se i campi mancanti erano fuori schermo); Update() prende preserveActivityId cosi' una bozza di scheda gia' inviata mantiene il link al server e al completamento lo aggiorna invece di duplicarlo. - ConfirmUpdateActivity: il pulsante bozza sta su NoText, non CancelText (CancelText ritorna null, NoText ritorna false). - BackButtonService + MainActivity.OnBackPressed: il tasto indietro passa dallo stesso percorso di "Annulla". Ignorato mentre e' aperto un dialog figlio, per non mostrare il prompt sotto un altro dialog. - IspezionePage: "Concludi ispezione" si blocca se ci sono bozze, "Esporta reparto" le salta, chip "N da completare" per reparto. Co-Authored-By: Claude Opus 5 (1M context) --- .../20260916111422_AddBozzaScheda.Designer.cs | 157 ++++++++++++++++++ .../20260916111422_AddBozzaScheda.cs | 29 ++++ .../Migrations/AppDbContextModelSnapshot.cs | 5 +- SteUp.Maui/Core/CoreModule.cs | 2 + SteUp.Maui/Platforms/Android/MainActivity.cs | 12 ++ .../Components/Pages/IspezionePage.razor | 41 ++++- .../MessageBox/ConfirmUpdateActivity.razor | 7 +- .../Modal/ModalFormScheda.razor | 133 +++++++++++++-- SteUp.Shared/Core/Entities/Scheda.cs | 6 + SteUp.Shared/Core/Helpers/SchedaSyncHelper.cs | 21 ++- .../Core/Messages/System/BackButtonService.cs | 42 +++++ 11 files changed, 433 insertions(+), 22 deletions(-) create mode 100644 SteUp.Data/Migrations/20260916111422_AddBozzaScheda.Designer.cs create mode 100644 SteUp.Data/Migrations/20260916111422_AddBozzaScheda.cs create mode 100644 SteUp.Shared/Core/Messages/System/BackButtonService.cs diff --git a/SteUp.Data/Migrations/20260916111422_AddBozzaScheda.Designer.cs b/SteUp.Data/Migrations/20260916111422_AddBozzaScheda.Designer.cs new file mode 100644 index 0000000..3b4ea07 --- /dev/null +++ b/SteUp.Data/Migrations/20260916111422_AddBozzaScheda.Designer.cs @@ -0,0 +1,157 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SteUp.Data.LocalDb; + +#nullable disable + +namespace SteUp.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260916111422_AddBozzaScheda")] + partial class AddBozzaScheda + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("SteUp.Shared.Core.Entities.Ispezione", b => + { + b.Property("CodMdep") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Rilevatore") + .HasColumnType("TEXT"); + + b.Property("ActivityId") + .HasColumnType("TEXT"); + + b.Property("Stato") + .HasColumnType("INTEGER"); + + b.HasKey("CodMdep", "Data", "Rilevatore"); + + b.ToTable("Ispezioni"); + }); + + modelBuilder.Entity("SteUp.Shared.Core.Entities.Scheda", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityId") + .HasColumnType("TEXT"); + + b.Property("ActivityTypeId") + .HasColumnType("TEXT"); + + b.Property("Bozza") + .HasColumnType("INTEGER"); + + b.Property("CodJfas") + .HasColumnType("TEXT"); + + b.Property("CodMdep") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DescrizioneReparto") + .HasColumnType("TEXT"); + + b.Property("ImageNames") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Responsabile") + .HasColumnType("TEXT"); + + b.Property("Rilevatore") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Scadenza") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CodMdep", "Data", "Rilevatore"); + + b.ToTable("Schede"); + }); + + modelBuilder.Entity("SteUp.Shared.Core.Entities.SchedaArticolo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Barcode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Descrizione") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("SchedaId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SchedaId", "Barcode") + .IsUnique(); + + b.ToTable("SchedaArticoli"); + }); + + modelBuilder.Entity("SteUp.Shared.Core.Entities.Scheda", b => + { + b.HasOne("SteUp.Shared.Core.Entities.Ispezione", "Ispezione") + .WithMany("Schede") + .HasForeignKey("CodMdep", "Data", "Rilevatore") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ispezione"); + }); + + modelBuilder.Entity("SteUp.Shared.Core.Entities.SchedaArticolo", b => + { + b.HasOne("SteUp.Shared.Core.Entities.Scheda", "Scheda") + .WithMany("Articoli") + .HasForeignKey("SchedaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scheda"); + }); + + modelBuilder.Entity("SteUp.Shared.Core.Entities.Ispezione", b => + { + b.Navigation("Schede"); + }); + + modelBuilder.Entity("SteUp.Shared.Core.Entities.Scheda", b => + { + b.Navigation("Articoli"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SteUp.Data/Migrations/20260916111422_AddBozzaScheda.cs b/SteUp.Data/Migrations/20260916111422_AddBozzaScheda.cs new file mode 100644 index 0000000..5fda41e --- /dev/null +++ b/SteUp.Data/Migrations/20260916111422_AddBozzaScheda.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SteUp.Data.Migrations +{ + /// + public partial class AddBozzaScheda : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Bozza", + table: "Schede", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Bozza", + table: "Schede"); + } + } +} diff --git a/SteUp.Data/Migrations/AppDbContextModelSnapshot.cs b/SteUp.Data/Migrations/AppDbContextModelSnapshot.cs index 21aa4ae..f268281 100644 --- a/SteUp.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/SteUp.Data/Migrations/AppDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace SteUp.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); modelBuilder.Entity("SteUp.Shared.Core.Entities.Ispezione", b => { @@ -51,6 +51,9 @@ namespace SteUp.Data.Migrations b.Property("ActivityTypeId") .HasColumnType("TEXT"); + b.Property("Bozza") + .HasColumnType("INTEGER"); + b.Property("CodJfas") .HasColumnType("TEXT"); diff --git a/SteUp.Maui/Core/CoreModule.cs b/SteUp.Maui/Core/CoreModule.cs index 139ee4d..ab9cf4f 100644 --- a/SteUp.Maui/Core/CoreModule.cs +++ b/SteUp.Maui/Core/CoreModule.cs @@ -20,6 +20,7 @@ using SteUp.Shared.Core.Interface.System.Network; using SteUp.Shared.Core.Messages.Ispezione; using SteUp.Shared.Core.Messages.Scanner; using SteUp.Shared.Core.Messages.Scheda; +using SteUp.Shared.Core.Messages.System; using SteUp.Shared.Core.Services; namespace SteUp.Maui.Core; @@ -65,6 +66,7 @@ public static class CoreModule builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); } public void RegisterDbServices() diff --git a/SteUp.Maui/Platforms/Android/MainActivity.cs b/SteUp.Maui/Platforms/Android/MainActivity.cs index 9d5aaa8..3d1cafb 100644 --- a/SteUp.Maui/Platforms/Android/MainActivity.cs +++ b/SteUp.Maui/Platforms/Android/MainActivity.cs @@ -3,6 +3,8 @@ using Android.Content.PM; using Android.OS; using Android.Views; using AndroidX.Core.View; +using Microsoft.Extensions.DependencyInjection; +using SteUp.Shared.Core.Messages.System; namespace SteUp.Maui { @@ -25,6 +27,16 @@ namespace SteUp.Maui ViewCompat.SetOnApplyWindowInsetsListener(content, new ImeInsetsListener()); } + public override void OnBackPressed() + { + // Se la UI Blazor ha un form aperto se ne occupa lei (propone il salvataggio + // come bozza); altrimenti comportamento standard. + var backButton = IPlatformApplication.Current?.Services.GetService(); + if (backButton?.TryHandleBackPressed() == true) return; + + base.OnBackPressed(); + } + private sealed class ImeInsetsListener : Java.Lang.Object, IOnApplyWindowInsetsListener { public WindowInsetsCompat OnApplyWindowInsets(Android.Views.View? v, WindowInsetsCompat? insets) diff --git a/SteUp.Shared/Components/Pages/IspezionePage.razor b/SteUp.Shared/Components/Pages/IspezionePage.razor index d8b7f0e..0c8c211 100644 --- a/SteUp.Shared/Components/Pages/IspezionePage.razor +++ b/SteUp.Shared/Components/Pages/IspezionePage.razor @@ -40,6 +40,7 @@ @{ var daInviare = group.Value.CountDaInviare(); + var daCompletare = group.Value.CountBozze(); }
@@ -54,6 +55,13 @@ @($"{daInviare} da inviare") } + @if (daCompletare > 0) + { + + @($"{daCompletare} da completare") + + }
@if (SteupDataService.InspectionPageState.Ispezione.Stato != StatusEnum.Completata) { @@ -156,7 +164,25 @@ var ispezione = SteupDataService.InspectionPageState.Ispezione; - var daInviare = ispezione.Schede.Where(x => x.ActivityId == null).ToList(); + // Le bozze non sono inviabili: l'ispezione non si chiude finché ci sono. + var bozze = SchedeList.CountBozze(); + if (bozze > 0) + { + await InvokeAsync(() => + { + VisibleOverlay = false; + StateHasChanged(); + }); + + await Dialog.ShowWarning( + bozze == 1 + ? "C'è 1 scheda da completare. Completala o eliminala prima di concludere l'ispezione." + : $"Ci sono {bozze} schede da completare. Completale o eliminale prima di concludere l'ispezione." + ); + return; + } + + var daInviare = ispezione.Schede.Where(x => x.ActivityId == null && !x.Bozza).ToList(); var totale = daInviare.Count; var indice = 0; var fallite = 0; @@ -341,7 +367,16 @@ { var ispezione = SteupDataService.InspectionPageState.Ispezione; - var saveRequest = SchedeGrouped[jtbFasi].ConvertAll(x => + // Le bozze restano sul dispositivo: si esporta solo ciò che è completo. + var daEsportare = SchedeGrouped[jtbFasi].FindAll(x => !x.Bozza); + + if (daEsportare.Count == 0) + { + Snackbar.Add("Nessuna scheda completa da esportare in questo reparto", Severity.Info); + return; + } + + var saveRequest = daEsportare.ConvertAll(x => { return new SaveRequestDto { @@ -375,7 +410,7 @@ if (!apiResponse.ActivityIdSchedaList.IsNullOrEmpty()) { - foreach (var scheda in SchedeGrouped[jtbFasi]) + foreach (var scheda in daEsportare) { var activityId = apiResponse.ActivityIdSchedaList!.Find(x => x.LocalId != null && x.LocalId == scheda.Id diff --git a/SteUp.Shared/Components/SingleElements/MessageBox/ConfirmUpdateActivity.razor b/SteUp.Shared/Components/SingleElements/MessageBox/ConfirmUpdateActivity.razor index 9291311..75895d0 100644 --- a/SteUp.Shared/Components/SingleElements/MessageBox/ConfirmUpdateActivity.razor +++ b/SteUp.Shared/Components/SingleElements/MessageBox/ConfirmUpdateActivity.razor @@ -1,6 +1,9 @@ - +@* NoText -> false (salva bozza), YesText -> true (salva), chiusura senza scelta -> null. + Niente CancelText: da qui non si esce buttando via il lavoro. *@ + - Sono state apportate delle modifiche. Vuoi salvarle prima di continuare? + Hai delle modifiche non salvate. Salva la scheda, oppure tienila come bozza + da completare più tardi: in ogni caso non perdi nulla. diff --git a/SteUp.Shared/Components/SingleElements/Modal/ModalFormScheda.razor b/SteUp.Shared/Components/SingleElements/Modal/ModalFormScheda.razor index f06ce21..a8e07b8 100644 --- a/SteUp.Shared/Components/SingleElements/Modal/ModalFormScheda.razor +++ b/SteUp.Shared/Components/SingleElements/Modal/ModalFormScheda.razor @@ -11,6 +11,8 @@ @using SteUp.Shared.Core.Interface.LocalDb @using SteUp.Shared.Core.Interface.System @using SteUp.Shared.Core.Messages.Scanner +@using SteUp.Shared.Core.Messages.System +@inject BackButtonService BackButton @inject INetworkService NetworkService @inject IDialogService Dialog @inject IIntegryApiService IntegryApiService @@ -20,6 +22,7 @@ @inject OnScannerService OnScannerService @inject IAttachedService AttachedService @inject ILogger Logger +@implements IDisposable @@ -237,6 +240,34 @@ private bool _attachmentsDirty; private List? AttachedList { get; set; } + private IDisposable? _backRegistration; + // Il tasto indietro puo' arrivare mentre il prompt di uscita e' gia' a video. + private bool _closing; + // Dialog aperti sopra al form (articoli, suggerimenti, errori). + private int _childDialogs; + + private Task HandleBackPressed() + { + // Con un dialog figlio a video il back non deve agire sul form sottostante: + // meglio ignorarlo che far comparire il prompt di uscita sotto un altro dialog. + if (_childDialogs > 0) return Task.CompletedTask; + + return Cancel(); + } + + private async Task WithChildDialog(Func> open) + { + _childDialogs++; + try + { + return await open(); + } + finally + { + _childDialogs--; + } + } + protected override void OnInitialized() { Snackbar.Configuration.PositionClass = Defaults.Classes.Position.TopCenter; @@ -248,6 +279,10 @@ _originalBarcodes = Scheda.Articoli.ConvertAll(x => x.Barcode); Snackbar.Configuration.PositionClass = Defaults.Classes.Position.TopCenter; + // Il tasto indietro del dispositivo deve passare da qui, non mandare l'app + // in background lasciando il lavoro non salvato solo in memoria. + _backRegistration = BackButton.Register(() => InvokeAsync(HandleBackPressed)); + LoadAttached(); } @@ -284,7 +319,10 @@ { VisibleOverlay = true; StateHasChanged(); - + + // Salvataggio completo: la scheda non è più una bozza. + Scheda.Bozza = false; + SaveSchedaResponseDto? apiResponse = null; try { @@ -369,7 +407,7 @@ await IspezioniService.AddSchedaAsync(CodMdep, Data, UserSession.User.Username, Scheda); } - private async Task Update(SaveSchedaResponseDto? apiResponse) + private async Task Update(SaveSchedaResponseDto? apiResponse, bool preserveActivityId = false) { if (!AttachedList.IsNullOrEmpty()) { @@ -399,7 +437,10 @@ } } - Scheda.ActivityId = apiResponse?.ActivityIdScheda; + // Salvando una bozza non tocchiamo l'ActivityId: se la scheda era già sul server, + // al completamento va aggiornata, non ricreata. + if (!preserveActivityId) Scheda.ActivityId = apiResponse?.ActivityIdScheda; + await IspezioniService.UpdateSchedaAsync(Scheda); if (apiResponse != null) @@ -440,11 +481,21 @@ private async Task Cancel() { - if (await CheckSavePreAction()) + if (_closing) return; + _closing = true; + + try { - await FileManager.CleanTempStorageAsync(); - DisposeMessage(); - MudDialog.Cancel(); + if (await CheckSavePreAction()) + { + await FileManager.CleanTempStorageAsync(); + DisposeMessage(); + MudDialog.Cancel(); + } + } + finally + { + _closing = false; } } @@ -501,17 +552,25 @@ } } + /// + /// Ritorna true se il form può chiudersi scartando (nessuna modifica in sospeso). + /// Con del lavoro in corso non si perde mai nulla: o si salva per intero, o si + /// salva come bozza da riprendere. + /// private async Task CheckSavePreAction() { if (!IsDirty) return true; - var resul = await _confirmUpdateMessage.ShowAsync(); - if (resul is not true) return true; + var result = await WithChildDialog(() => _confirmUpdateMessage.ShowAsync()); + + // Prompt chiuso senza scegliere (tap fuori / indietro): si resta sulla scheda. + if (result is null) return false; VisibleOverlay = true; StateHasChanged(); - await Submit(); + if (result is true) await Submit(); + else await SaveDraft(); VisibleOverlay = false; StateHasChanged(); @@ -519,10 +578,48 @@ return false; } + /// + /// Salva il lavoro in corso solo sul dispositivo, marcato come da completare. + /// Niente chiamate al server e niente validazione: una bozza può essere incompleta. + /// + private async Task SaveDraft() + { + try + { + Scheda.Bozza = true; + + if (IsNew) await NewSave(null); + else await Update(null, preserveActivityId: true); + + await FileManager.CleanTempStorageAsync(); + + Snackbar.Clear(); + Snackbar.Add("Scheda salvata come bozza, la ritrovi nell'ispezione", Severity.Success); + + DisposeMessage(); + MudDialog.Close(Scheda); + } + catch (Exception e) + { + Scheda.Bozza = false; + OnError(e, e.Message); + } + } + private async Task Submit() { await _form.Validate(); - if (_form.IsValid) await Save(); + + if (_form.IsValid) + { + await Save(); + return; + } + + // Il form resta aperto sugli errori: senza un avviso il tocco su "Salva" + // sembra non fare nulla se i campi mancanti sono fuori schermo. + Snackbar.Clear(); + Snackbar.Add("Compila i campi obbligatori, oppure esci salvando come bozza", Severity.Warning); } #endregion @@ -621,7 +718,7 @@ return; } - var modal = await Dialog.OpenSelectArt(articoli); + var modal = await WithChildDialog(() => Dialog.OpenSelectArt(articoli)); await InvokeAsync(() => { @@ -679,7 +776,7 @@ { var activityDescriptions = await IntegryApiService.SuggestActivityDescription(Scheda.ActivityTypeId); - var modal = await Dialog.OpenSuggestActivityDescription(activityDescriptions); + var modal = await WithChildDialog(() => Dialog.OpenSuggestActivityDescription(activityDescriptions)); if (modal is { Canceled: false, Data: string descrizione } && !string.IsNullOrWhiteSpace(descrizione)) { @@ -783,6 +880,16 @@ { OnScannerService.OnNewScanSuccessful -= HandleNewScanSuccessful; OnScannerService.OnErrorScan -= OnErrorScan; + + _backRegistration?.Dispose(); + _backRegistration = null; + } + + // Rete di sicurezza: se il dialog viene chiuso per altre vie, il tasto indietro + // non deve restare agganciato a un form che non esiste più. + void IDisposable.Dispose() + { + DisposeMessage(); } } \ No newline at end of file diff --git a/SteUp.Shared/Core/Entities/Scheda.cs b/SteUp.Shared/Core/Entities/Scheda.cs index 2aba30a..7e75863 100644 --- a/SteUp.Shared/Core/Entities/Scheda.cs +++ b/SteUp.Shared/Core/Entities/Scheda.cs @@ -20,6 +20,12 @@ public class Scheda : EntityBase public List Articoli { get; set; } = []; public List? ImageNames { get; set; } + + /// + /// Scheda salvata in locale ma non ancora completata (es. uscita dal form con lavoro in corso). + /// Non viene mai inviata al server finché il rilevatore non la conclude. + /// + public bool Bozza { get; set; } public string? DescrizioneReparto { get; set; } public string? ActivityTypeId { get; set; } diff --git a/SteUp.Shared/Core/Helpers/SchedaSyncHelper.cs b/SteUp.Shared/Core/Helpers/SchedaSyncHelper.cs index 5268910..c720d58 100644 --- a/SteUp.Shared/Core/Helpers/SchedaSyncHelper.cs +++ b/SteUp.Shared/Core/Helpers/SchedaSyncHelper.cs @@ -14,19 +14,27 @@ public enum SyncState DaInviare = 0, /// Inviata al server (ha un ActivityId). - Inviato = 1 + Inviato = 1, + + /// Salvata in locale ma non completata: da riprendere, non inviabile. + Bozza = 2 } public static class SchedaSyncHelper { public static SyncState GetSyncState(this Scheda scheda) => - string.IsNullOrEmpty(scheda.ActivityId) ? SyncState.DaInviare : SyncState.Inviato; + scheda.Bozza + ? SyncState.Bozza + : string.IsNullOrEmpty(scheda.ActivityId) + ? SyncState.DaInviare + : SyncState.Inviato; public static string ToLabel(this SyncState state) => state switch { SyncState.DaInviare => "Da inviare", SyncState.Inviato => "Inviato", + SyncState.Bozza => "Da completare", _ => string.Empty }; @@ -39,6 +47,7 @@ public static class SchedaSyncHelper { SyncState.DaInviare => Color.Primary, SyncState.Inviato => Color.Success, + SyncState.Bozza => Color.Warning, _ => Color.Default }; @@ -47,10 +56,16 @@ public static class SchedaSyncHelper { SyncState.DaInviare => Icons.Material.Rounded.PhonelinkOff, SyncState.Inviato => Icons.Material.Rounded.CloudDone, + SyncState.Bozza => Icons.Material.Rounded.EditNote, _ => string.Empty }; - /// Numero di schede non ancora inviate in una lista. + /// Numero di schede non ancora inviate in una lista. Le bozze non contano: + /// non sono inviabili finché non vengono completate. public static int CountDaInviare(this IEnumerable schede) => schede.Count(s => s.GetSyncState() == SyncState.DaInviare); + + /// Numero di schede lasciate a metà, da riprendere. + public static int CountBozze(this IEnumerable schede) => + schede.Count(s => s.GetSyncState() == SyncState.Bozza); } diff --git a/SteUp.Shared/Core/Messages/System/BackButtonService.cs b/SteUp.Shared/Core/Messages/System/BackButtonService.cs new file mode 100644 index 0000000..711a02c --- /dev/null +++ b/SteUp.Shared/Core/Messages/System/BackButtonService.cs @@ -0,0 +1,42 @@ +namespace SteUp.Shared.Core.Messages.System; + +/// +/// Ponte fra il tasto indietro del dispositivo (host nativo) e la UI Blazor. +/// Il form scheda si registra mentre è aperto così il back non manda l'app in +/// background buttando via il lavoro non salvato: passa invece dallo stesso +/// percorso del pulsante "Annulla", che propone il salvataggio come bozza. +/// +public class BackButtonService +{ + private Func? _handler; + + /// Registra il gestore corrente. Ritorna l'azione di deregistrazione. + public IDisposable Register(Func handler) + { + _handler = handler; + return new Registration(this, handler); + } + + /// + /// Invocato dall'host nativo. Ritorna true se la UI Blazor ha preso in carico + /// il tasto indietro: in quel caso l'host non deve fare altro. + /// + public bool TryHandleBackPressed() + { + var handler = _handler; + if (handler is null) return false; + + // Fire-and-forget: il gestore rientra nel dispatcher Blazor da solo. + _ = handler(); + return true; + } + + private sealed class Registration(BackButtonService owner, Func handler) : IDisposable + { + public void Dispose() + { + // Non azzerare se nel frattempo si è registrato qualcun altro. + if (ReferenceEquals(owner._handler, handler)) owner._handler = null; + } + } +} From 0174316bb7b42998a2811ce3d02794af65586f0b Mon Sep 17 00:00:00 2001 From: MarcoE Date: Wed, 16 Sep 2026 17:57:41 +0200 Subject: [PATCH 04/27] Niente errore quando il trigger viene premuto a vuoto Honeywell emette BarcodeFailureEvent anche solo premendo e rilasciando il trigger senza inquadrare nulla: e' la normale mancata lettura, non un guasto. Finiva pero' in OnScanFailed insieme ai guasti veri, quindi ogni pressione a vuoto fermava il rilevatore con un dialog bloccante contenente il toString() Java grezzo: Scan failure: com.honeywell.aidc.BarcodeFailureEvent[source=...] Ora l'evento e' ignorato: chi preme il trigger sa gia' di non aver letto niente, non serve alcun riscontro. OnScanFailed resta riservato ai guasti reali (ScannerUnavailableException alla Claim, init di AidcManager fallito). Co-Authored-By: Claude Opus 5 (1M context) --- .../Platforms/Android/Core/HoneywellScannerService.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/SteUp.Maui/Platforms/Android/Core/HoneywellScannerService.cs b/SteUp.Maui/Platforms/Android/Core/HoneywellScannerService.cs index 70ad78a..7432e7c 100644 --- a/SteUp.Maui/Platforms/Android/Core/HoneywellScannerService.cs +++ b/SteUp.Maui/Platforms/Android/Core/HoneywellScannerService.cs @@ -164,7 +164,14 @@ public partial class HoneywellScannerService : Java.Lang.Object, BarcodeReader.IBarcodeListener { public void OnBarcodeEvent(BarcodeReadEvent? e) => service.DispatchEvent(e!); - public void OnFailureEvent(BarcodeFailureEvent? e) => - service.OnScanFailed?.Invoke(new Exception($"Scan failure: {e}")); + + // Honeywell emette BarcodeFailureEvent anche solo premendo e rilasciando il + // trigger senza inquadrare nulla: e' la normale mancata lettura, non un guasto. + // Trattarlo come eccezione faceva comparire un dialog bloccante col toString() + // Java a ogni pressione a vuoto. Non c'e' nulla da comunicare: chi ha premuto + // il trigger sa gia' di non aver letto niente e sta ripuntando lo scanner. + public void OnFailureEvent(BarcodeFailureEvent? e) + { + } } } \ No newline at end of file From 3b0470266523398eba67b8a552315e0e3689c266 Mon Sep 17 00:00:00 2001 From: MarcoE Date: Wed, 16 Sep 2026 18:07:43 +0200 Subject: [PATCH 05/27] Decodifica PLU dai barcode CODE128 a 7 cifre Oltre alle etichette a peso variabile (EAN13/CODE128 a 13 cifre), ora vengono riconosciuti i CODE128 di 7 cifre che iniziano con '2': si scarta il prefisso e restano le 6 cifre del PLU, come per DecodeEanPeso. --- .../Core/BarcodeReader/BarcodeManager.cs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/SteUp.Shared/Core/BarcodeReader/BarcodeManager.cs b/SteUp.Shared/Core/BarcodeReader/BarcodeManager.cs index ea9009e..51db2bf 100644 --- a/SteUp.Shared/Core/BarcodeReader/BarcodeManager.cs +++ b/SteUp.Shared/Core/BarcodeReader/BarcodeManager.cs @@ -26,6 +26,8 @@ public class BarcodeManager( if (IsEanPeso(dto)) stringValue = DecodeEanPeso(stringValue); + else if (IsPlu(dto)) + stringValue = DecodePlu(stringValue); messenger.Send(new NewScannerMessage(stringValue)); }, @@ -39,14 +41,29 @@ public class BarcodeManager( (IsEtichetta128(barcodeScan) || IsEan13(barcodeScan)) && barcodeScan.StringValue is { Length: 13 } && barcodeScan.StringValue.StartsWith('2'); + private static bool IsPlu(BarcodeScanDto barcodeScan) => + IsCode128(barcodeScan) && barcodeScan.StringValue is { Length: 7 } && barcodeScan.StringValue.StartsWith('2'); + private static bool IsEtichetta128(BarcodeScanDto barcodeScan) => barcodeScan.Type is BarcodeType.CODE128 or BarcodeType.EAN128; + private static bool IsCode128(BarcodeScanDto barcodeScan) => + barcodeScan.Type == BarcodeType.CODE128; + private static bool IsEan13(BarcodeScanDto barcodeScan) => barcodeScan.Type == BarcodeType.EAN13; private static string DecodeEanPeso(string? barcode) { - return barcode is not { Length: 13 } ? throw new Exception("Errore durante il parse del barcode (" + barcode + ")") : barcode.Substring(1, 6); + return barcode is not { Length: 13 } + ? throw new Exception("Errore durante il parse del barcode (" + barcode + ")") + : barcode.Substring(1, 6); + } + + private static string DecodePlu(string? barcode) + { + return barcode is not { Length: 7 } + ? throw new Exception("Errore durante il parse del barcode (" + barcode + ")") + : barcode.Substring(1, 6); } } \ No newline at end of file From 3a9174f6fcfd3fb2f79b541f7195a354943165bb Mon Sep 17 00:00:00 2001 From: MarcoE Date: Thu, 17 Sep 2026 10:32:41 +0200 Subject: [PATCH 06/27] Raddrizza le foto applicando l'orientamento EXIF La fotocamera di sistema non ruota lo scatto: salva i pixel come li legge il sensore e annota la rotazione in EXIF. SkiaSharp (SKBitmap.Decode) ignora quel tag, quindi le foto scattate ruotate finivano storte nelle miniature e venivano inviate cosi' al server. - ImageOrientation: applica l'orientamento ai pixel e ricodifica senza tag residui, mantenendo il formato di partenza. Immagine gia' dritta: nessuna ricodifica, per non perdere qualita' inutilmente. In caso di errore restituisce i byte originali, meglio una foto storta che nessuna foto. - UtilityFile.ConvertToDto: normalizza li', unico punto di passaggio di fotocamera e galleria, cosi' miniature, salvataggio locale e upload lavorano su pixel gia' dritti. Corretto anche DimensionBytes. - ImageThumb: applica l'orientamento anche in fase di miniatura, per raddrizzare le foto gia' presenti sul dispositivo. Nessun ritratto forzato: costringere una foto orizzontale in verticale vorrebbe dire ruotare il contenuto o ritagliarlo, e queste foto sono prove di ispezione. Co-Authored-By: Claude Opus 5 (1M context) --- SteUp.Maui/Core/Services/ImageOrientation.cs | 106 +++++++++++++++++++ SteUp.Maui/Core/Services/ImageThumb.cs | 10 +- SteUp.Maui/Core/Utility/UtilityFile.cs | 12 ++- 3 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 SteUp.Maui/Core/Services/ImageOrientation.cs diff --git a/SteUp.Maui/Core/Services/ImageOrientation.cs b/SteUp.Maui/Core/Services/ImageOrientation.cs new file mode 100644 index 0000000..5f816ab --- /dev/null +++ b/SteUp.Maui/Core/Services/ImageOrientation.cs @@ -0,0 +1,106 @@ +using SkiaSharp; + +namespace SteUp.Maui.Core.Services; + +/// +/// Raddrizza le immagini applicando l'orientamento EXIF ai pixel. +/// La fotocamera di sistema non ruota lo scatto: salva i pixel come li ha letti il +/// sensore e annota la rotazione in EXIF. SkiaSharp () +/// ignora quel tag, quindi senza questo passaggio le foto scattate ruotate finiscono +/// storte nelle miniature e sul server. +/// +public static class ImageOrientation +{ + /// + /// Restituisce i byte dell'immagine con i pixel già nel verso giusto e senza tag + /// di orientamento residui. In caso di formato non gestito o errore restituisce + /// l'originale: meglio una foto storta che perdere lo scatto del rilevatore. + /// + public static byte[] Normalize(byte[] bytes, int quality = 92) + { + try + { + using var codec = SKCodec.Create(new SKMemoryStream(bytes)); + if (codec is null) return bytes; + + var origin = codec.EncodedOrigin; + + // Già dritta: non ricodifichiamo, così non si perde qualità inutilmente. + if (origin == SKEncodedOrigin.TopLeft) return bytes; + + using var decoded = SKBitmap.Decode(codec); + if (decoded is null) return bytes; + + using var upright = ApplyOrigin(decoded, origin); + using var image = SKImage.FromBitmap(upright); + + // Manteniamo il formato di partenza: il nome file conserva la sua estensione. + var format = codec.EncodedFormat == SKEncodedImageFormat.Png + ? SKEncodedImageFormat.Png + : SKEncodedImageFormat.Jpeg; + + using var encoded = image.Encode(format, quality); + return encoded?.ToArray() ?? bytes; + } + catch + { + return bytes; + } + } + + /// + /// Versione dritta di una bitmap già decodificata. Restituisce sempre una nuova + /// bitmap, anche quando non serve ruotare, così chi chiama ne possiede una sola. + /// + public static SKBitmap Upright(SKBitmap src, SKEncodedOrigin origin) => + origin == SKEncodedOrigin.TopLeft ? src.Copy() : ApplyOrigin(src, origin); + + private static SKBitmap ApplyOrigin(SKBitmap src, SKEncodedOrigin origin) + { + // Gli orientamenti "di lato" scambiano larghezza e altezza. + var sideways = origin is SKEncodedOrigin.LeftTop or SKEncodedOrigin.RightTop + or SKEncodedOrigin.RightBottom or SKEncodedOrigin.LeftBottom; + + var w = sideways ? src.Height : src.Width; + var h = sideways ? src.Width : src.Height; + + var dst = new SKBitmap(w, h, src.ColorType, src.AlphaType); + using var canvas = new SKCanvas(dst); + + switch (origin) + { + case SKEncodedOrigin.TopRight: // specchiata in orizzontale + canvas.Translate(w, 0); + canvas.Scale(-1, 1); + break; + case SKEncodedOrigin.BottomRight: // ruotata di 180° + canvas.Translate(w, h); + canvas.RotateDegrees(180); + break; + case SKEncodedOrigin.BottomLeft: // specchiata in verticale + canvas.Translate(0, h); + canvas.Scale(1, -1); + break; + case SKEncodedOrigin.LeftTop: // trasposta + canvas.RotateDegrees(90); + canvas.Scale(1, -1); + break; + case SKEncodedOrigin.RightTop: // ruotata di 90° in senso orario + canvas.Translate(w, 0); + canvas.RotateDegrees(90); + break; + case SKEncodedOrigin.RightBottom: // trasversa + canvas.Translate(w, h); + canvas.RotateDegrees(270); + canvas.Scale(1, -1); + break; + case SKEncodedOrigin.LeftBottom: // ruotata di 90° in senso antiorario + canvas.Translate(0, h); + canvas.RotateDegrees(-90); + break; + } + + canvas.DrawBitmap(src, 0, 0); + return dst; + } +} diff --git a/SteUp.Maui/Core/Services/ImageThumb.cs b/SteUp.Maui/Core/Services/ImageThumb.cs index 2ba4ff0..bc552e9 100644 --- a/SteUp.Maui/Core/Services/ImageThumb.cs +++ b/SteUp.Maui/Core/Services/ImageThumb.cs @@ -19,11 +19,15 @@ public static class ImageThumb throw new InvalidOperationException("Formato immagine non supportato o file corrotto."); // Decodifica - var info = codec.Info; - using var bitmap = SKBitmap.Decode(codec); - if (bitmap is null) + using var decoded = SKBitmap.Decode(codec); + if (decoded is null) throw new InvalidOperationException("Impossibile decodificare l'immagine."); + // SKBitmap.Decode non applica l'orientamento EXIF: senza questo passaggio le + // miniature delle foto scattate ruotate escono storte (vale anche per gli + // scatti già sul dispositivo, che non passano da UtilityFile.ConvertToDto). + using var bitmap = ImageOrientation.Upright(decoded, codec.EncodedOrigin); + // Calcola resize mantenendo aspect ratio var w = bitmap.Width; var h = bitmap.Height; diff --git a/SteUp.Maui/Core/Utility/UtilityFile.cs b/SteUp.Maui/Core/Utility/UtilityFile.cs index 83665e7..c4b65d8 100644 --- a/SteUp.Maui/Core/Utility/UtilityFile.cs +++ b/SteUp.Maui/Core/Utility/UtilityFile.cs @@ -1,3 +1,4 @@ +using SteUp.Maui.Core.Services; using SteUp.Shared.Core.Dto; namespace SteUp.Maui.Core.Utility; @@ -10,13 +11,20 @@ public static class UtilityFile using var ms = new MemoryStream(); await stream.CopyToAsync(ms); + var bytes = ms.ToArray(); + + // Unico punto di passaggio di fotocamera e galleria: raddrizziamo qui, cosi' + // miniature, salvataggio locale e upload lavorano tutti su pixel gia' dritti. + if (type == AttachedDto.TypeAttached.Image) + bytes = ImageOrientation.Normalize(bytes); + return new AttachedDto { Name = file.FileName, Path = file.FullPath, MimeType = file.ContentType, - DimensionBytes = ms.Length, - FileBytes = ms.ToArray(), + DimensionBytes = bytes.Length, + FileBytes = bytes, Type = type }; } From 2a0054aa30833ade3f0a27bad56574d6fd81a744 Mon Sep 17 00:00:00 2001 From: MarcoE Date: Thu, 17 Sep 2026 11:09:26 +0200 Subject: [PATCH 07/27] Rifacimento della UI secondo STILE_UI.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adotta lo stile condiviso delle app Integry: superfici piatte, raggi ampi, spaziature a scala fissa, gerarchia data dallo spazio prima che dalla decorazione. La palette storica di SteUP resta intatta; sono stati aggiunti solo i ruoli che mancavano (workspace, surfaceMuted, divider, inkMuted e le triplette dei toni semantici). Fondamenta - tokens.css: ruoli colore, scale di spazio/raggi/tipografia, curve di movimento e scala z-index semantica. Unica fonte di verita': pilotano anche le variabili CSS di MudBlazor. - Inter variabile ospitata in locale (133KB) al posto di Nunito da Google Fonts: l'app lavora offline, un font che arriva dalla rete in campo non arriva. - base.css: shell applicativa e comportamenti da app nativa — niente selezione testo, niente menu al tocco lungo, niente lampo grigio sul tap, niente rimbalzo elastico, niente zoom a doppio tocco, scroll con inerzia. - mudblazor-overrides.css: MudBlazor appiattito (elevation 0, nessun tint, target 44/48, etichette in caso naturale). - Rimossi Bootstrap (CSS, icone e JS) e i 996 righe di CSS con ambito per componente del vecchio sistema: erano tutti inutilizzati. Struttura - Navigazione flottante in basso con icona piena sulla voce attiva; da 720px diventa colonna laterale. Il MudFabMenu e' sostituito da un bottom sheet: stessa funzione, bersagli molto piu' grandi. - Testata alta col titolo a sinistra, fusa con la pagina. - Nel form scheda il salvataggio sta in una barra ancorata in fondo, non in coda a uno scroll lungo. - Scelta di punto vendita e articoli a schermo intero con ricerca; la selezione multipla usa righe da 56px invece di un menu a tendina. - Urgenza scelta a pillole invece che da una select. Stato e riscontro - StatusBadgeHelper: tono, icona ed etichetta di ogni stato in un punto solo, cosi' lo stesso stato si presenta identico ovunque. - Scheletri al posto degli spinner; l'overlay bloccante dice sempre cosa sta facendo; gli stati vuoti suggeriscono la mossa successiva. - Rimosso il ritardo artificiale di 250ms in InspectionCard, che non attendeva nulla. DESIGN.md riscritto sul nuovo sistema. Co-Authored-By: Claude Opus 5 (1M context) --- .impeccable/config.json | 14 + DESIGN.md | 334 +++-- STILE_UI.md | 304 +++++ SteUp.Maui/MainPage.xaml | 2 +- SteUp.Maui/wwwroot/index.html | 67 +- .../Components/Layout/HeaderLayout.razor | 100 +- .../Components/Layout/HeaderLayout.razor.css | 31 - .../Components/Layout/MainLayout.razor | 84 +- .../Components/Layout/MainLayout.razor.css | 77 -- SteUp.Shared/Components/Layout/NavMenu.razor | 252 ++-- .../Components/Layout/NavMenu.razor.css | 115 -- .../Layout/Overlay/SpinnerOverlay.razor | 61 +- .../Layout/Overlay/SpinnerOverlay.razor.css | 184 --- .../Components/Layout/Sheet/ActionSheet.razor | 51 + .../Layout/Spinner/LoaderLayout.razor | 13 - .../Layout/Spinner/LoaderLayout.razor.css | 18 - .../Layout/Spinner/SpinnerLayout.razor | 9 +- .../Layout/Spinner/SpinnerLayout.razor.css | 43 - .../Components/Pages/IspezionePage.razor | 244 ++-- .../Components/Pages/IspezionePage.razor.css | 47 - .../Components/Pages/IspezioniPage.razor | 76 +- .../Components/Pages/IspezioniPage.razor.css | 5 - SteUp.Shared/Components/Pages/LoginPage.razor | 158 +-- .../Components/Pages/LoginPage.razor.css | 54 - SteUp.Shared/Components/Pages/NotFound.razor | 18 +- SteUp.Shared/Components/Pages/UserPage.razor | 226 ++-- .../Components/Pages/UserPage.razor.css | 83 -- .../SingleElements/AppVersion.razor | 6 - .../SingleElements/AppVersion.razor.css | 11 - .../Components/SingleElements/Badge.razor | 20 + .../SingleElements/Card/InspectionCard.razor | 144 +-- .../Card/InspectionCard.razor.css | 86 -- .../Card/ModalForm/CardFormModal.razor | 34 +- .../Card/ModalForm/CardFormModal.razor.css | 23 - .../SingleElements/Card/SchedaCard.razor | 210 ++-- .../SingleElements/Card/SchedaCard.razor.css | 48 - .../SingleElements/Card/ShopCard.razor | 67 +- .../SingleElements/Card/ShopCard.razor.css | 5 - .../SingleElements/ConnectionState.razor | 49 +- .../Modal/ExceptionModal/ExceptionModal.razor | 46 +- .../ExceptionModal/ExceptionModal.razor.css | 61 - .../Modal/ExceptionModal/ModalError.razor | 48 +- .../Modal/ExceptionModal/ModalError.razor.css | 37 - .../Modal/ModalAddAttached.razor | 34 +- .../Modal/ModalAddAttached.razor.css | 9 - .../Modal/ModalFormScheda.razor | 398 +++--- .../Modal/ModalFormScheda.razor.css | 49 - .../SingleElements/Modal/ModalSelectArt.razor | 157 ++- .../Modal/ModalSelectShop.razor | 131 +- .../Modal/ModalSelectShop.razor.css | 18 - .../Modal/ModalSuggestDescription.razor | 26 +- .../SingleElements/NoDataAvailable.razor | 17 +- .../SingleElements/NoDataAvailable.razor.css | 13 - SteUp.Shared/Core/Helpers/ModalHelper.cs | 10 +- .../Core/Helpers/StatusBadgeHelper.cs | 59 + SteUp.Shared/wwwroot/css/app.css | 335 ----- SteUp.Shared/wwwroot/css/base.css | 276 ++++ .../css/bootstrap/bootstrap-icons.min.css | 5 - .../wwwroot/css/bootstrap/bootstrap.min.css | 6 - .../css/bootstrap/bootstrap.min.css.map | 1 - .../css/bootstrap/fonts/bootstrap-icons.woff | Bin 176032 -> 0 bytes .../css/bootstrap/fonts/bootstrap-icons.woff2 | Bin 130396 -> 0 bytes SteUp.Shared/wwwroot/css/components.css | 1117 +++++++++++++++++ SteUp.Shared/wwwroot/css/custom-mudBlazor.css | 54 - SteUp.Shared/wwwroot/css/default-theme.css | 15 - SteUp.Shared/wwwroot/css/fonts.css | 28 + SteUp.Shared/wwwroot/css/form.css | 218 ---- SteUp.Shared/wwwroot/css/forms.css | 392 ++++++ .../wwwroot/css/mudblazor-overrides.css | 409 ++++++ SteUp.Shared/wwwroot/css/tokens.css | 167 +++ .../wwwroot/fonts/inter-latin-ext.woff2 | Bin 0 -> 85068 bytes SteUp.Shared/wwwroot/fonts/inter-latin.woff2 | Bin 0 -> 48256 bytes .../wwwroot/js/bootstrap/Sortable.min.js | 2 - .../js/bootstrap/bootstrap.bundle.min.js | 7 - .../wwwroot/js/bootstrap/chart.umd.js | 14 - .../chartjs-plugin-datalabels.min.js | 7 - SteUp.Shared/wwwroot/js/main.js | 116 +- 77 files changed, 4595 insertions(+), 3060 deletions(-) create mode 100644 .impeccable/config.json create mode 100644 STILE_UI.md delete mode 100644 SteUp.Shared/Components/Layout/HeaderLayout.razor.css delete mode 100644 SteUp.Shared/Components/Layout/MainLayout.razor.css delete mode 100644 SteUp.Shared/Components/Layout/NavMenu.razor.css delete mode 100644 SteUp.Shared/Components/Layout/Overlay/SpinnerOverlay.razor.css create mode 100644 SteUp.Shared/Components/Layout/Sheet/ActionSheet.razor delete mode 100644 SteUp.Shared/Components/Layout/Spinner/LoaderLayout.razor delete mode 100644 SteUp.Shared/Components/Layout/Spinner/LoaderLayout.razor.css delete mode 100644 SteUp.Shared/Components/Layout/Spinner/SpinnerLayout.razor.css delete mode 100644 SteUp.Shared/Components/Pages/IspezionePage.razor.css delete mode 100644 SteUp.Shared/Components/Pages/IspezioniPage.razor.css delete mode 100644 SteUp.Shared/Components/Pages/LoginPage.razor.css delete mode 100644 SteUp.Shared/Components/Pages/UserPage.razor.css delete mode 100644 SteUp.Shared/Components/SingleElements/AppVersion.razor delete mode 100644 SteUp.Shared/Components/SingleElements/AppVersion.razor.css create mode 100644 SteUp.Shared/Components/SingleElements/Badge.razor delete mode 100644 SteUp.Shared/Components/SingleElements/Card/InspectionCard.razor.css delete mode 100644 SteUp.Shared/Components/SingleElements/Card/ModalForm/CardFormModal.razor.css delete mode 100644 SteUp.Shared/Components/SingleElements/Card/SchedaCard.razor.css delete mode 100644 SteUp.Shared/Components/SingleElements/Card/ShopCard.razor.css delete mode 100644 SteUp.Shared/Components/SingleElements/Modal/ExceptionModal/ExceptionModal.razor.css delete mode 100644 SteUp.Shared/Components/SingleElements/Modal/ExceptionModal/ModalError.razor.css delete mode 100644 SteUp.Shared/Components/SingleElements/Modal/ModalAddAttached.razor.css delete mode 100644 SteUp.Shared/Components/SingleElements/Modal/ModalFormScheda.razor.css delete mode 100644 SteUp.Shared/Components/SingleElements/Modal/ModalSelectShop.razor.css delete mode 100644 SteUp.Shared/Components/SingleElements/NoDataAvailable.razor.css create mode 100644 SteUp.Shared/Core/Helpers/StatusBadgeHelper.cs delete mode 100644 SteUp.Shared/wwwroot/css/app.css create mode 100644 SteUp.Shared/wwwroot/css/base.css delete mode 100644 SteUp.Shared/wwwroot/css/bootstrap/bootstrap-icons.min.css delete mode 100644 SteUp.Shared/wwwroot/css/bootstrap/bootstrap.min.css delete mode 100644 SteUp.Shared/wwwroot/css/bootstrap/bootstrap.min.css.map delete mode 100644 SteUp.Shared/wwwroot/css/bootstrap/fonts/bootstrap-icons.woff delete mode 100644 SteUp.Shared/wwwroot/css/bootstrap/fonts/bootstrap-icons.woff2 create mode 100644 SteUp.Shared/wwwroot/css/components.css delete mode 100644 SteUp.Shared/wwwroot/css/custom-mudBlazor.css delete mode 100644 SteUp.Shared/wwwroot/css/default-theme.css create mode 100644 SteUp.Shared/wwwroot/css/fonts.css delete mode 100644 SteUp.Shared/wwwroot/css/form.css create mode 100644 SteUp.Shared/wwwroot/css/forms.css create mode 100644 SteUp.Shared/wwwroot/css/mudblazor-overrides.css create mode 100644 SteUp.Shared/wwwroot/css/tokens.css create mode 100644 SteUp.Shared/wwwroot/fonts/inter-latin-ext.woff2 create mode 100644 SteUp.Shared/wwwroot/fonts/inter-latin.woff2 delete mode 100644 SteUp.Shared/wwwroot/js/bootstrap/Sortable.min.js delete mode 100644 SteUp.Shared/wwwroot/js/bootstrap/bootstrap.bundle.min.js delete mode 100644 SteUp.Shared/wwwroot/js/bootstrap/chart.umd.js delete mode 100644 SteUp.Shared/wwwroot/js/bootstrap/chartjs-plugin-datalabels.min.js diff --git a/.impeccable/config.json b/.impeccable/config.json new file mode 100644 index 0000000..fdcefed --- /dev/null +++ b/.impeccable/config.json @@ -0,0 +1,14 @@ +{ + "detector": { + "ignoreRules": [], + "ignoreFiles": [], + "ignoreValues": [ + { + "rule": "overused-font", + "value": "inter", + "reason": "Scelta esplicita dell'utente: STILE_UI.md prescrive Inter per uniformare le app Integry; il register product della skill ammette Inter per le UI di prodotto", + "createdAt": "2026-09-17T08:51:01.079Z" + } + ] + } +} diff --git a/DESIGN.md b/DESIGN.md index e7a7e26..d54c483 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -3,69 +3,150 @@ name: SteUP description: Strumento da campo per ispezioni nei punti vendita — solido, chiaro, offline-first. colors: primary: "#ec4c41" - secondary: "#002339" - tertiary: "#dff2ff" + primary-pressed: "#d23a30" + primary-dark: "#002339" + on-primary: "#ffffff" + workspace: "#e9edf2" + surface: "#ffffff" + surface-muted: "#f4f6f9" + divider: "#d9e0e8" ink: "#000000" - paper: "#ffffff" - surface-dark: "#000406" - success: "#26b050" - error: "#e50000" + ink-muted: "#465563" + danger-ink: "#b3120c" + danger-surface: "#fdeceb" + danger-solid: "#e50000" + warning-ink: "#8a4f00" + warning-surface: "#fdf0db" + warning-solid: "#b26a00" + success-ink: "#10702f" + success-surface: "#e4f5e9" + success-solid: "#26b050" + info-ink: "#002339" + info-surface: "#dff2ff" + info-solid: "#1877d8" + neutral-ink: "#465563" + neutral-surface: "#f4f6f9" focus-ring: "#258cfb" typography: - page-title: - fontFamily: "Nunito, sans-serif" - fontSize: "1.5rem" - fontWeight: 800 + headline-medium: + fontFamily: "Inter, sans-serif" + fontSize: "34px" + fontWeight: 700 lineHeight: 1.2 - letterSpacing: "normal" - title: - fontFamily: "Nunito, sans-serif" - fontSize: "1.1rem" + letterSpacing: "-0.02em" + headline-small: + fontFamily: "Inter, sans-serif" + fontSize: "28px" fontWeight: 700 - lineHeight: 1.4 + lineHeight: 1.25 + letterSpacing: "-0.02em" + title-large: + fontFamily: "Inter, sans-serif" + fontSize: "22px" + fontWeight: 700 + lineHeight: 1.3 + letterSpacing: "-0.01em" + title-medium: + fontFamily: "Inter, sans-serif" + fontSize: "18px" + fontWeight: 700 + lineHeight: 1.35 letterSpacing: "normal" - body: - fontFamily: "Nunito, sans-serif" - fontSize: "0.875rem" + body-large: + fontFamily: "Inter, sans-serif" + fontSize: "16px" fontWeight: 400 - lineHeight: 1.8 + lineHeight: 1.5 letterSpacing: "normal" - label: - fontFamily: "Nunito, sans-serif" - fontSize: "0.875rem" + body-medium: + fontFamily: "Inter, sans-serif" + fontSize: "15px" + fontWeight: 400 + lineHeight: 1.45 + letterSpacing: "normal" + label-large: + fontFamily: "Inter, sans-serif" + fontSize: "14px" + fontWeight: 600 + lineHeight: 1.25 + letterSpacing: "normal" + label-medium: + fontFamily: "Inter, sans-serif" + fontSize: "13px" + fontWeight: 600 + lineHeight: 1.25 + letterSpacing: "normal" + label-small: + fontFamily: "Inter, sans-serif" + fontSize: "12px" fontWeight: 700 - lineHeight: 1.4 + lineHeight: 1.3 + letterSpacing: "0.3px" + icon-sm: + fontFamily: "remixicon" + fontSize: "15px" + fontWeight: 400 + lineHeight: 1 + letterSpacing: "normal" + icon-md: + fontFamily: "remixicon" + fontSize: "20px" + fontWeight: 400 + lineHeight: 1 + letterSpacing: "normal" + icon-lg: + fontFamily: "remixicon" + fontSize: "26px" + fontWeight: 400 + lineHeight: 1 + letterSpacing: "normal" + icon-xl: + fontFamily: "remixicon" + fontSize: "44px" + fontWeight: 400 + lineHeight: 1 letterSpacing: "normal" rounded: - input: "9px" - panel: "1em" - container: "20px" - skeleton: "0.5em" + compact: "8px" + control: "16px" + surface: "20px" + sheet: "24px" + pill: "999px" spacing: - page-x: "1rem" - header-h: "4rem" + xs: "8px" + sm: "12px" + md: "16px" + lg: "24px" + xl: "32px" touch-min: "44px" + button-height: "48px" + appbar-height: "80px" + navbar-height: "68px" + content-max: "920px" components: + panel: + backgroundColor: "{colors.surface}" + textColor: "{colors.ink}" + rounded: "{rounded.surface}" + padding: "16px" button-primary: backgroundColor: "{colors.primary}" - textColor: "{colors.paper}" - rounded: "{rounded.container}" - typography: "{typography.label}" - padding: "0.5rem 1.25rem" - button-primary-active: - backgroundColor: "{colors.primary}" - textColor: "{colors.paper}" - rounded: "{rounded.container}" - input-card: - backgroundColor: "#f5f5f5" + textColor: "{colors.on-primary}" + rounded: "{rounded.control}" + typography: "{typography.label-large}" + padding: "0 24px" + button-outlined: + backgroundColor: "{colors.surface}" textColor: "{colors.ink}" - rounded: "{rounded.input}" - padding: "0.5rem 1rem" - panel: - backgroundColor: "{colors.paper}" - textColor: "{colors.ink}" - rounded: "{rounded.panel}" - padding: "1rem" + rounded: "{rounded.control}" + typography: "{typography.label-large}" + badge: + rounded: "{rounded.pill}" + typography: "{typography.label-small}" + padding: "5px 9px" + navbar: + backgroundColor: "{colors.surface}" + rounded: "{rounded.sheet}" --- # Design System: SteUP @@ -74,119 +155,116 @@ components: **Creative North Star: "Il taccuino da campo"** -SteUP è lo strumento che il rilevatore tiene in mano mentre gira i punti vendita: deve funzionare come carta e inchiostro — sempre, anche senza rete, leggibile in pieno sole, comprensibile con un'occhiata. La chiarezza viene prima di qualsiasi ornamento. L'interfaccia poggia su MudBlazor con un'unica famiglia tipografica (Nunito) e una palette essenziale: un solo accento caldo per l'azione, un blu profondo come base stabile, superfici bianche e pulite con angoli generosamente arrotondati. +SteUP è lo strumento che il rilevatore tiene in mano mentre gira i punti vendita: deve funzionare come carta e inchiostro — sempre, anche senza rete, leggibile in pieno sole, comprensibile con un'occhiata. -Il sistema è **tattile e sicuro**: bersagli ampi pensati per il pollice, una sola mano e i guanti; feedback fisico immediato a ogni tocco e a ogni scansione (effetto ripple, cambi di stato evidenti). Lo stato — salvato in locale, in attesa di sync, inviato al server, completato — non è mai ambiguo: è la funzione più importante che la UI comunica. +Il sistema visivo segue **`STILE_UI.md`**, lo stile condiviso delle app Integry (riferimento: Fixiy). Quel documento definisce forma, ritmo, tipografia e composizione; questo documento fissa i valori concreti di SteUP su quei ruoli. -Questo sistema rifiuta esplicitamente due cose. Non è un **gestionale datato e denso**: niente tabelle fittissime, testo minuscolo, grigio ovunque, densità che non serve al lavoro da campo. E non è un'**app consumer social/giocosa**: niente colore ludico, niente decorazione fine a sé stessa. È uno strumento professionale. - -**Key Characteristics:** -- Offline-first: lo stato di salvataggio e sincronizzazione è sempre esplicito -- Mobile-first, uso a una mano/guanti: tocchi ≥44px, azioni a portata di pollice -- Alto contrasto e testo generoso per la leggibilità all'aperto -- Un solo accento (Arancio-Rosso Allerta), usato per azione e stato, mai per decorazione -- Un'unica famiglia tipografica, angoli arrotondati coerenti (9px → 20px → 1em) +**Caratteristiche portanti:** +- **Superfici piatte.** Nessuna ombra costruisce gerarchia: la separazione viene dal contrasto di superficie (sfondo pagina più freddo delle superfici) e dallo spazio. L'unica ombra del sistema è quella del toast. +- **Offline-first.** Lo stato di salvataggio e sincronizzazione è sempre esplicito. Il carattere è ospitato in locale: un font che arriva dalla rete è un font che in campo non arriva. +- **Il pollice comanda.** Tocchi ≥44px, azioni primarie in basso: navigazione flottante, barra azioni ancorata in fondo ai form. +- **Alto contrasto.** Inchiostro pieno sul corpo del testo, per la luce diretta del sole. +- **Un solo accento.** Il corallo indica azione e stato attivo, mai decorazione. ## 2. Colors -Una palette essenziale: un accento caldo che chiama l'azione, un blu profondo come ancora, il resto neutro e pulito. +I ruoli sono quelli di `STILE_UI.md` §2, riempiti con la palette storica di SteUP. Sono stati aggiunti solo i ruoli che mancavano: `workspace`, `surface-muted`, `divider`, `ink-muted` e le triplette dei toni semantici. -### Primary -- **Arancio-Rosso Allerta** (#ec4c41): il colore dell'azione e dello stato attivo. Pulsanti primari, FAB, selezione corrente, icone d'azione, indicatori di stato che richiedono attenzione. Riservato: la sua presenza segnala "qui si agisce". +### Accento e ancora +- **Corallo** (`#ec4c41`) — `primary`: il colore dell'azione. Pulsanti primari, selezione, progresso, FAB. La sua rarità è ciò che lo rende un segnale. +- **Blu Profondo** (`#002339`) — `primary-dark`: ancora della struttura. Testo su superfici informative, icone metrica, valori in evidenza, bottone di accesso. -### Secondary -- **Blu Profondo** (#002339): base stabile e di struttura. Testo forte, intestazioni, elementi strutturali, superfici scure in dark mode. È l'ancora visiva contro cui l'accento risalta. +### Superfici — tre gradini netti +- `workspace` (`#e9edf2`): sfondo dell'applicazione, più freddo delle superfici di lavoro. +- `surface` (`#ffffff`): card, pannelli, testate, barra di navigazione, dialog. +- `surface-muted` (`#f4f6f9`): superficie secondaria dentro una superficie (campo di ricerca, chip a riposo, riquadri interni). +- `divider` (`#d9e0e8`): bordi 1px e contorni dei controlli a riposo. -### Tertiary -- **Azzurro Tenue** (#dff2ff): tocco freddo e leggero per evidenziazioni informative e superfici secondarie, dove serve distinguere senza gridare. +### Testo +- `ink` (`#000000`): corpo del testo. **La Regola del Contrasto da Sole**: il grigio chiaro "per eleganza" è vietato sul testo leggibile. +- `ink-muted` (`#465563`): testo secondario, metadati, icone non attive. 8.3:1 su `surface`, 7.4:1 su `workspace`. Il testo secondario si distingue per **colore**, mai per opacità o peso sotto 400. -### Neutral -- **Inchiostro** (#000000): corpo del testo su fondo chiaro; massimo contrasto per la lettura al sole. -- **Carta** (#ffffff): superficie di contenuto primaria (pannelli, card, dialog). -- **Superficie Scura** (#000406): sfondo della dark mode (attualmente predisposta ma disattivata). -- **Grigio Campo** (~#f5f5f5): fondo delle input-card e delle superfici tonali secondarie; distingue per tono, non per bordo. +### Toni semantici — la regola delle triplette +Ogni tono è una tripletta *(ink, surface, solid)*: +- `-ink` colora testo e icone **sopra la superficie tenue** (sempre ≥4.5:1 su quella superficie); +- `-surface` riempie badge, pannelli di feedback e cerchi-icona dei dialog; +- `-solid` è il riempimento pieno dei toast, con testo bianco sopra. -### Semantic -- **Verde OK** (#26b050): validazione positiva, esito riuscito. -- **Rosso Errore** (#e50000): validazione fallita, messaggi d'errore, connessione/servizio KO. -- **Blu Focus** (#258cfb): anello di focus per la navigazione da tastiera. +I colori "puri" della palette storica (`#e50000`, `#26b050`) vivono come `-solid`: sono giusti per riempimenti e icone, non abbastanza contrastati per il testo. Per questo esistono `danger-ink` e `success-ink`. + +| Tono | Uso | +|---|---| +| danger | errori, azioni distruttive, schede da inviare | +| warning | attenzione, dati incompleti, bozze da completare | +| success | conferme, completamenti, sincronizzato | +| info | stato informativo, pannelli di riepilogo, selezione | +| neutral | stati senza carica semantica | ### Named Rules -**La Regola dell'Unico Accento.** L'Arancio-Rosso Allerta è l'unico accento del sistema e appare solo su azione o stato attivo, mai come decorazione. La sua rarità è ciò che lo rende un segnale. +**La Regola dell'Unico Accento.** Il corallo appare solo su azione o stato attivo, mai come decorazione. -**La Regola del Contrasto da Sole.** Il testo di corpo è inchiostro (#000) su carta (#fff). Il grigio chiaro "per eleganza" è vietato sul testo leggibile: si progetta per lo schermo colpito dal sole, non per lo screenshot. +**Mai il solo colore.** Ogni stato porta sempre icona **e** etichetta testuale. Le mappature stanno in `StatusBadgeHelper`: uno stato si presenta identico ovunque, e uno nuovo si aggiunge in un punto solo. ## 3. Typography -**Display / Body / Label Font:** Nunito (variable 200–1000, con fallback `sans-serif`). Caricata da Google Fonts; usata su tutta l'interfaccia. +**Famiglia unica: Inter** (variabile 100–900), **ospitata in locale** in `wwwroot/fonts`. Scala fissa, pensata per la scansione rapida, non per l'effetto editoriale. La gerarchia si fa con peso e dimensione. -**Character:** un'unica famiglia sans humanist e morbida per tutto — titoli, etichette, pulsanti, corpo, dati. Gli angoli tondeggianti di Nunito si accordano con i raggi ampi dei componenti e rafforzano il tono solido ma accessibile del "taccuino da campo". Nessun accostamento display/body: la gerarchia si fa con peso e dimensione. - -### Hierarchy -- **Page Title** (peso 800, ~1.5rem/x-large, line-height stretta): titolo di schermata; ancora l'utente al compito corrente. -- **Title** (peso 700, ~1.1rem): intestazioni di sezione, titoli di dialog e message-box. -- **Body** (peso 400, 0.875rem/14px, line-height 1.8): testo corrente e dati. Per la prosa mantenere 65–75ch; dati e UI compatta possono essere più densi. -- **Label** (peso 700, 0.875rem): etichette dei pulsanti e dei controlli di form. Il grassetto dà presenza al tocco senza ricorrere al maiuscolo. +Le cifre sono tabellari (`font-feature-settings: "tnum"`), così contatori e codici non "ballano" mentre si aggiornano. ### Named Rules -**La Regola del Peso, non del Maiuscolo.** L'enfasi si ottiene con il peso (700/800) e la dimensione, non con il maiuscolo spaziato. Niente eyebrow maiuscoli tracciati sopra le sezioni. +**La Regola del Peso, non del Maiuscolo.** L'enfasi si ottiene con il peso (600/700/800) e la dimensione. Niente maiuscoletto spaziato, niente eyebrow sopra le sezioni. + +**Il titolo è l'entità.** Nelle card di elenco il titolo è il punto vendita o il motivo, non il tipo di record. L'identificativo tecnico sta sopra, in 12px `ink-muted`. + +**Le righe di metadato** si compongono con separatore ` · `. ## 4. Elevation -Sistema prevalentemente **piatto con ombre funzionali leggere**. La profondità è tonale (Carta su Grigio Campo) più un'ombra morbida e diffusa dove un elemento deve staccarsi davvero (card in rilievo, overlay). MudBlazor fornisce la scala di elevazione; sopra di essa il progetto definisce due ombre custom soffuse. Le superfici a riposo sono piatte; l'ombra è una risposta al ruolo (galleggiamento, overlay), non decorazione. +**Zero.** Le superfici sono piatte: nessuna ombra, nessun `surface-tint`. La profondità è tonale (`surface` su `workspace`) più lo spazio. -### Shadow Vocabulary -- **Ombra Custom** (`box-shadow: 1px 2px 5px hsl(from var(--mud-palette-overlay-dark) h s 40%)`): stacco morbido per card ed elementi che devono galleggiare sul contenuto. -- **Ombra Eccezione** (`box-shadow: 1px 2px 5px rgba(0,0,0,0.3)`): rilievo leggermente più marcato per box di errore/eccezione. +Le uniche eccezioni: il **toast**, che deve leggersi sopra qualunque contenuto, e i livelli modali di sistema (scrim del bottom sheet e degli overlay). Popover e menu si staccano con un bordo 1px `divider`, non con un'ombra. -### Named Rules -**La Regola del Piatto-di-Default.** Le superfici sono piatte a riposo. L'ombra compare solo quando un elemento deve galleggiare (overlay, dialog, card sollevata) o rispondere a uno stato. Se sembra un'app del 2014, l'ombra è troppo scura e troppo stretta. +La profondità di impilamento usa una **scala semantica** (`--z-sticky` → `--z-navbar` → `--z-connection` → `--z-overlay` → `--z-modal` → `--z-toast`), mai valori arbitrari. ## 5. Components -Componenti **tattili e sicuri**: bersagli generosi e arrotondati, feedback fisico, presenza netta, pensati per il pollice in campo. - -### Buttons -- **Shape:** angoli molto arrotondati (raggio 20px sul contenitore `container-button`; i pulsanti MudBlazor ereditano `--mud-default-borderradius: 20px`). -- **Primary:** fondo Arancio-Rosso Allerta (#ec4c41), testo Carta (#fff), etichetta in peso 700; padding compatto verticale ma area di tocco ampia. -- **Hover / Focus / Active:** effetto **ripple** al tocco (chiaro su fondo scuro, scuro su fondo chiaro); focus da tastiera con doppio anello (bianco + Blu Focus #258cfb). Nei contenitori `ripple-container` il ripple nativo Mud è disattivato in favore di quello custom. -- **Settings buttons:** riga con icona in "pill" arrotondata (raggio 6px) tinta per ruolo — grigio (neutro), primary (azione), verde (successo), rosso (distruttivo) — su fondo tenue della stessa tinta. -- **FAB (`custom-mudfab`):** azione primaria fissa in basso a destra (bottom 4rem, right 16px), a portata di pollice; rispetta la safe-area inferiore. - -### Cards / Containers -- **Corner Style:** pannelli e dialog a 1em; contenitori d'azione a 20px; input-card a 9px. -- **Background:** Carta (#fff) per il contenuto; Grigio Campo (~#f5f5f5) per input-card e superfici tonali secondarie. -- **Shadow Strategy:** piatte di default; Ombra Custom solo quando devono galleggiare (vedi Elevation). -- **Border:** preferire lo stacco tonale al bordo; quando serve, bordo pieno sottile (`--card-border-color`). Mai bordo-laterale colorato come accento. -- **Internal Padding:** input-card `.5rem 1rem`; pannelli ~1rem; margine orizzontale di pagina `--m-page-x: 1rem`. - -### Inputs / Fields -- **Style:** input dentro `input-card` su fondo Grigio Campo, raggio 9px; underline Mud rimossa (`:before/:after` a `none`) per un aspetto pulito a "scheda". -- **Layout:** `form-container` a due colonne (etichetta in peso 700 a sinistra, valore a destra), altezza minima 35px per un tocco comodo. -- **Valid / Error:** valido → outline verde 1px (#26b050); invalido → outline rosso 1px (#e50000) con `validation-message` in rosso. - -### Navigation -- **NavMenu** in testa alla pagina; barra di stato connessione (`ConnectionState`) che scorre dall'alto: verde (SystemOk) o rosso (NetworkKo / ServicesIsDown), peso 700, testo bianco. Comunica lo stato di rete/servizio senza rubare spazio al contenuto. +- **Panel** — il mattone di tutto: `surface`, raggio 20, padding 16, senza bordo. Varianti tonali per riepiloghi (`info`) e sotto-riquadri (`muted`). +- **Card di elenco** — identificativo, titolo-entità, riga meta, badge, footer con conteggi, barra azioni interna che passa a colonna sotto i 350px. +- **Badge** — pillola con icona 15px + etichetta 12px peso 700, costruita da funzione di mappatura. +- **Testata** — alta 80px, titolo a sinistra, sfondo uguale alla pagina. Non una AppBar colorata col titolo centrato. +- **Navigazione** — barra flottante in basso (raggio 24, margine 16, bordo 1px), icona outline a riposo e piena da selezionata, pastiglia `info-surface` sulla voce attiva. Da 720px diventa colonna laterale. +- **Bottom sheet** — maniglia, testata, un'unica linea di struttura, corpo scorrevole. Sostituisce i menu flottanti. +- **Barra azioni ancorata** — bordo superiore 1px, bottoni in riga, colonna sotto i 390px. Nei form il salvataggio sta qui, non in fondo allo scroll. +- **Campi** — riempiti, raggio 16, bordo 1px a riposo e 2px in focus. Sottolineatura Material rimossa: i campi sono schede, non righe. +- **Selezione multipla** — righe da 56px con casella 26px, non un menu a tendina: con i guanti si centra un bersaglio grande. +- **Stato vuoto** — icona, titolo, messaggio che **suggerisce la mossa successiva**, azione facoltativa. +- **Caricamento** — scheletri che hanno già la forma del contenuto, non spinner in mezzo alla pagina. L'overlay pieno è riservato alle operazioni bloccanti, e dice sempre cosa sta facendo. ### Signature: Barra di Stato Connessione -Striscia sottile animata (`.Connection`) ancorata in cima che appare/scompare con transizione morbida (translateY) per segnalare rete assente o backend giù. È l'incarnazione visiva del principio "Offline è la verità": lo stato di connettività è sempre onesto e visibile. +Striscia sottile ancorata in cima che scende con una transizione morbida quando la rete o il backend cambiano stato. È l'incarnazione visiva di "Offline è la verità". -## 6. Do's and Don'ts +## 6. Motion + +Curve esponenziali in uscita (`cubic-bezier(0.22, 1, 0.36, 1)`), durate 120/200/280ms. Il movimento **serve allo stato**: risposta al tocco (l'elemento cede sotto il dito), entrata di un articolo scansionato, salita di uno sheet, disegno della spunta di conferma. Niente animazioni di scena al caricamento. + +Nessuno stato dipende dall'hover: su un touch screen non esiste, e lascia elementi accesi dopo il tocco. Gli stili hover sono dietro `@media (hover: hover)`. + +`prefers-reduced-motion` ha un'alternativa per ogni animazione. + +## 7. Do's and Don'ts ### Do: -- **Do** usare l'Arancio-Rosso Allerta (#ec4c41) solo per azione e stato attivo; mantenerlo raro (Regola dell'Unico Accento). -- **Do** tenere il testo di corpo in inchiostro (#000) su carta (#fff); alto contrasto per la lettura al sole. -- **Do** dimensionare i bersagli di tocco ≥44px e collocare le azioni primarie a portata di pollice (FAB in basso a destra), pensando all'uso a una mano/guanti. -- **Do** rendere sempre esplicito lo stato offline/sync (barra di connessione, stati di scheda/ispezione). -- **Do** dare feedback fisico immediato a ogni tocco e scansione (ripple, cambio di stato). -- **Do** usare un'unica famiglia (Nunito) e fare gerarchia con peso e dimensione. -- **Do** rispettare le safe-area iOS/Android e `prefers-reduced-motion`. +- **Do** usare solo i token: un colore, un raggio o una spaziatura scritti a mano in una schermata sono un difetto da correggere. +- **Do** mostrare sempre i conteggi ("12 schede", "3 da inviare"): il rilevatore deve sapere quanto ha davanti senza contare. +- **Do** distinguere "non c'è nulla" da "i filtri non trovano nulla", e nel secondo caso offrire l'azzeramento. +- **Do** dimensionare i bersagli ≥44px e tenere le azioni primarie a portata di pollice. +- **Do** rendere esplicito lo stato offline/sync, sempre con icona più etichetta. +- **Do** rispettare le safe-area e `prefers-reduced-motion`. ### Don't: -- **Don't** costruire un **gestionale datato e denso**: niente tabelle fittissime, testo minuscolo, form infiniti, grigio ovunque. -- **Don't** virare verso un'estetica **consumer social/giocosa**: niente colore ludico o decorazione fine a sé stessa. -- **Don't** usare testo grigio chiaro sul corpo "per eleganza": è la causa numero uno di illeggibilità al sole. -- **Don't** usare bordo-laterale colorato (`border-left/right` > 1px) come accento su card, liste o alert. -- **Don't** usare testo con gradiente (`background-clip: text`), glassmorphism decorativo, griglie di card tutte identiche, o eyebrow maiuscoli tracciati su ogni sezione. -- **Don't** introdurre animazioni di scena orchestrate al caricamento: l'app entra dritta nel compito; il movimento serve allo stato, non allo spettacolo. -- **Don't** reinventare affordance standard (scrollbar strane, controlli di form non convenzionali) per "carattere". +- **Don't** usare ombre per costruire gerarchia, né `surface-tint` sulle superfici. +- **Don't** usare le firme Android di serie: card con ombra, FAB circolare al centro, AppBar colorata col titolo centrato, snackbar di sistema. +- **Don't** usare bordo-laterale colorato (`border-left/right` > 1px) come accento. +- **Don't** usare testo grigio chiaro sul corpo, testo con gradiente, glassmorphism decorativo, griglie di card tutte identiche, eyebrow maiuscoli. +- **Don't** reinventare affordance standard per "carattere". +- **Don't** far dipendere un'informazione dal solo colore. diff --git a/STILE_UI.md b/STILE_UI.md new file mode 100644 index 0000000..0abe20f --- /dev/null +++ b/STILE_UI.md @@ -0,0 +1,304 @@ +# Specifiche grafiche — stile UI condiviso + +Documento di riferimento per **uniformare la grafica delle app Integry** allo stile adottato in Fixiy. + +Descrive *forma, ritmo, tipografia e composizione dei componenti*. **Non contiene una palette**: ogni app mantiene i propri colori e li mappa sui **ruoli semantici** elencati al §2. Lo stile è quello che rimane costante al variare della tinta: superfici piatte, raggi ampi, spaziature a scala fissa, gerarchia data dallo spazio prima che dalla decorazione. + +Riferimento d'implementazione: Flutter + Material 3 (`useMaterial3: true`). I nomi dei token qui sotto sono quelli reali del codice Fixiy (`lib/core/design_system.dart`, `lib/core/feedback.dart`); in un'altra app si rinominano col prefisso dell'app, ma **valori e semantica restano invariati**. + +--- + +## 1. Principi + +1. **Superfici piatte.** Niente ombre per costruire gerarchia: `elevation: 0` ovunque (card, AppBar, NavigationBar, NavigationRail). L'unica ombra ammessa è quella del toast (elevation 6) e dei livelli modali di sistema. La separazione si ottiene con *contrasto di superficie* (sfondo pagina più freddo delle superfici) e con lo **spazio**. +2. **Nessun tint automatico.** `surfaceTintColor: Colors.transparent` su AppBar, Card, Dialog, pannelli: le superfici non devono virare verso il colore primario quando si scrolla. +3. **Colore = significato.** L'accento primario serve solo ad azioni, selezione, focus e progresso — mai a decorare. I colori semantici (urgenza, stato, esito) compaiono sempre accompagnati da **icona + etichetta testuale**, mai da soli. +4. **Target generosi.** Ogni elemento interattivo ha lato minimo **44 px** (`minimumSize: Size(44, 44)`), 48 px d'altezza per i bottoni principali. L'app si usa in piedi, con i guanti, in negozio. +5. **Densità standard.** `visualDensity: VisualDensity.standard`: si rifiuta la densità desktop, anche su schermo largo. +6. **Lo spazio prima della linea.** I divisori si usano solo dove l'informazione cambia natura (testata di uno sheet, righe di una lista di navigazione); altrove separa la spaziatura. +7. **Nulla di stock.** Si evitano le firme visive Android di default: card con ombra, FAB circolare al centro, AppBar colorata con titolo centrato, snackbar di sistema. +8. **Scrivibile a voce alta.** Testi in lingua dell'app, brevi, orientati all'azione; gli enum passano da una mappa di etichette centralizzata (`labels.dart`), mai formattati inline. + +--- + +## 2. Ruoli colore (da riempire con la palette dell'app) + +L'app ospite definisce i **propri** valori per questi ruoli e li usa con la stessa semantica. Nessun valore è fissato qui. + +| Ruolo | Uso | +|---|---| +| `primary` | accento unico di azione: bottone pieno, focus, selezione, indicatori di progresso, icone attive | +| `primaryDark` | testo/icona sopra `primaryContainer`, valori numerici in evidenza | +| `workspace` | sfondo dell'applicazione (`scaffoldBackgroundColor`), leggermente più freddo/scuro di `surface` | +| `surface` | superficie di lavoro: card, pannelli, AppBar, barra di navigazione, dialog | +| `surfaceMuted` | superficie secondaria dentro una superficie (campo di ricerca, chip non selezionato, riquadro informativo interno) | +| `divider` | bordi 1 px, separatori, contorno dei controlli a riposo | +| `ink` | testo principale | +| `inkMuted` | testo secondario, hint, etichette di metadato, icone non attive | +| `emergency` / `errorSurface` | tono **danger**: errori, azioni distruttive, priorità massima | +| `high` / `warningSurface` | tono **warning**: attenzione, dati incompleti, priorità alta | +| `success` / `successSurface` | tono **success**: conferme, completamenti | +| `info` / `infoSurface` | tono **info**: stato informativo, pannello di riepilogo, indicatore di selezione | +| `neutral` / `surfaceMuted` | tono **neutral**: stati senza carica semantica | + +**Regola delle coppie.** Ogni tono semantico è una coppia *(foreground forte, superficie tenue)*. Le superfici tenui riempiono badge, pannelli di feedback e cerchi-icona dei dialog; il foreground forte colora icona e testo sopra di esse. I **toast** invertono la regola: riempimento pieno col colore forte e testo bianco, per essere leggibili sopra qualunque contenuto. + +**Schema colore.** Si genera da un seme (`ColorScheme.fromSeed`) e poi si sovrascrivono esplicitamente `primary`, `onPrimary`, `primaryContainer` (= `infoSurface`), `onPrimaryContainer` (= `primaryDark`), `secondaryContainer` (= `surfaceMuted`), `outline` e `outlineVariant` (= `divider`). Non si lascia al seme la scelta dei contenitori: devono restare tenui e neutri. + +--- + +## 3. Scale di token + +### Spaziatura (`Space`) + +``` +xs = 8 sm = 12 md = 16 lg = 24 xl = 32 +``` + +Tutti i margini e le distanze sono multipli di 2 e, per quanto possibile, uno di questi cinque valori. Valori d'uso frequente derivati: 4 e 6 per le micro-distanze interne (titolo→sottotitolo, icona→testo), 10 tra le card di una lista, 18/20/22 per il padding dei pannelli di testata. + +### Raggi (`Radius`) + +``` +compact = 8 riquadri informativi, barre di progresso, feedback inline +control = 16 campi di testo, bottoni, toast +surface = 20 card, pannelli, dialog, sheet +pill = 999 badge, chip, segmented button, campo di ricerca, bottoni onboarding +``` + +Il contrasto tra i raggi è voluto: **superfici più arrotondate dei controlli**, elementi di stato completamente stondati (pill). + +### Breakpoint + +``` +rail = 720 da qui la navigazione passa da barra inferiore a NavigationRail laterale +twoColumn = 900 soglia per layout a due colonne +contentMax = 1180 larghezza massima assoluta del contenuto +``` + +Larghezza tipica di una pagina: `maxWidth: 920`, centrata in alto (`PageWidth` = `Align(topCenter) + ConstrainedBox`). Su schermi larghi il contenuto **non si allarga all'infinito**: resta una colonna leggibile. + +--- + +## 4. Tipografia + +Famiglia principale **Inter**, fallback `['SF Pro Display', 'Roboto', 'sans-serif']`. Scala fissa, pensata per la scansione rapida, non per l'effetto editoriale: + +| Stile | Dim. | Interlinea | Peso | Uso | +|---|---|---|---|---| +| `headlineMedium` | 34 | 1.2 | 700 | titoli di apertura | +| `headlineSmall` | 28 | 1.25 | 700 | titolo dell'AppBar, valore percentuale in evidenza | +| `titleLarge` | 22 | 1.3 | 700 | titolo di card e di sheet, testata di sezione | +| `titleMedium` | 18 | 1.35 | 700 | sotto-titoli, valori metrica, titolo dialog | +| `bodyLarge` | 16 | 1.5 | 400 | testo corrente, descrizioni | +| `bodyMedium` | 15 | 1.45 | 400 | metadati, righe secondarie | +| `labelLarge` | 14 | 1.25 | 600 | etichette dei bottoni | +| `labelMedium` | 13 | 1.25 | 600 | etichette di navigazione, caption | + +Regole d'uso: + +- Il **titolo di una card è l'entità**, non il tipo di record (es. il negozio, non "Manutenzione"): `titleLarge` con peso 800 e interlinea compressa 1.15. +- Gli **identificativi tecnici** (numero ticket, codice) vanno sopra il titolo, 12 px, peso 700, colore `inkMuted`, `letterSpacing: 0.3`. +- Le **righe di metadato** si compongono con separatore ` · ` (spazio–punto medio–spazio) e colore `inkMuted`. +- Il testo secondario non usa mai un peso inferiore a 400 né opacità: si distingue per **colore** (`inkMuted`), non per trasparenza. +- Nessun maiuscoletto forzato, nessun `letterSpacing` ampio: le etichette restano in caso naturale. + +--- + +## 5. Tema — impostazioni globali + +| Elemento | Configurazione | +|---|---| +| **Scaffold** | sfondo `workspace`; `extendBody: true` quando la barra di navigazione è flottante | +| **AppBar** | `toolbarHeight: 80`, titolo a sinistra (`centerTitle: false`), `titleSpacing` 20 (28 su schermo largo), sfondo `workspace` (si fonde con la pagina), elevation 0, `scrolledUnderElevation: 0` | +| **Card / pannelli** | elevation 0, `margin: EdgeInsets.zero`, sfondo `surface`, raggio `surface`, senza bordo | +| **Campi di testo** | `filled: true` su `surface`, padding 16×16, raggio `control`; bordo a riposo 1 px `divider`, in focus 2 px `primary`, in errore 1 px `emergency`; hint e helper in `inkMuted` | +| **Bottone pieno** | `minimumSize: (44, 48)`, sfondo `primary`, testo bianco `labelLarge`, raggio `control` | +| **Bottone contornato** | `minimumSize: (44, 48)`, testo `ink`, bordo `divider`, raggio `control` | +| **Bottone testuale / icona** | `minimumSize: (44, 44)` | +| **Segmented button** | `StadiumBorder`, min `(52, 44)`; selezionato = riempimento `primary` + testo bianco; non selezionato = `surface` + testo `inkMuted`; bordo `divider` | +| **NavigationBar** | altezza 68, elevation 0, sfondo `surface`, indicatore `infoSurface`, etichette sempre visibili (`alwaysShow`), stile `labelMedium` | +| **NavigationRail** | sfondo `surface`, indicatore `primaryContainer`, etichette su tutte le voci (`labelType: all`), etichetta selezionata in `primary` | +| **Snackbar** | comportamento `floating` (ma nell'app si usa il toast custom, §6.9) | +| **Progress** | colore `primary` | + +**Localizzazione.** Locale unico dichiarato esplicitamente con i tre delegati globali (Material, Widgets, Cupertino), così date, picker e messaggi di sistema parlano la lingua dell'app. + +--- + +## 6. Componenti + +### 6.1 `PageWidth` — contenitore di pagina + +`Align(topCenter)` + `ConstrainedBox(maxWidth: 920)`. Avvolge **ogni** contenuto di pagina. Su telefono è trasparente; su tablet/desktop impedisce righe di testo lunghissime e mantiene la stessa composizione a tutte le larghezze. + +### 6.2 `Panel` — superficie base + +`Material` con `color: surface`, `surfaceTintColor: transparent`, `clipBehavior: antiAlias`, forma arrotondata `surface` (20), padding di default 16. + +È il mattone di tutto: card di lista, riquadri di riepilogo, righe raggruppate, campi compositi. Varianti d'uso: + +- **con colore**: `color: infoSurface` per i pannelli di riepilogo/testata; `color: surfaceMuted` per i sotto-riquadri dentro un form; `color: errorSurface` per una card in stato critico. +- **`padding: EdgeInsets.zero` + `InkWell` interno** quando l'intera superficie è toccabile (raggio dell'ink coerente con quello del pannello). + +### 6.3 `SectionHeader` — testata di sezione + +Riga con, a sinistra, `titleLarge` + eventuale descrizione (`inkMuted`, 4 px sotto), e a destra un widget opzionale (bottone, badge, contatore) separato da 12 px. Apre ogni blocco tematico dentro una pagina lunga. + +### 6.4 `Badge` — stato in pillola + +`DecoratedBox` con raggio 999, superficie tenue del tono, padding 9×5, contenuto = icona 15 px + 5 px + testo 12 px peso 700, entrambi nel foreground del tono. Avvolto in `Semantics(label:)`. + +I badge **si costruiscono da funzioni di mappatura** (`priorityBadge(priority)`, `statusBadge(status)`), non a mano nelle pagine: così ogni schermata mostra lo stesso stato con la stessa icona e lo stesso tono. Ogni nuovo stato si aggiunge a quella funzione. + +### 6.5 Card di elenco + +Composizione canonica, dall'alto: + +1. **Barra di priorità** verticale a tutta altezza sul bordo sinistro, larghezza **6 px**, colore dal tono di urgenza (`Stack` + `Positioned(top/bottom/left: 0)`). La card usa padding asimmetrico `(22, 16, 16, 16)` per far respirare la barra. +2. **Identificativo** (12 px, `inkMuted`, 700) con, a destra, l'eventuale maniglia di trascinamento. +3. **Titolo** = l'entità, `titleLarge` peso 800. +4. **Riga meta** con separatori ` · `, `bodyMedium` peso 600 `inkMuted`. +5. **Badge** (priorità, stato) in un `Wrap(spacing: 8, runSpacing: 8)`. +6. **Descrizione** integrale, `bodyLarge` interlinea 1.35 — niente troncamenti sul contenuto che serve a decidere. +7. **Riga oraria/assegnatario** preceduta da icona 16 px `inkMuted`. +8. **Barra azioni interna**: secondaria contornata + primaria piena, affiancate con `Expanded`; sotto i **350 px** di larghezza della card diventano una colonna a piena larghezza (`LayoutBuilder`). + +La card intera espone un `Semantics(label:)` che riassume entità, priorità e stato. + +### 6.6 Pannello di riepilogo + +Primo elemento della pagina, `Panel` con superficie tenue (`primaryContainer`/`infoSurface`), padding 18: + +- riga di testata: titolo `titleLarge` + data/sottotitolo `inkMuted`, e a destra un **badge di stato** del documento ("Da salvare" / "Salvato" / "Concluso"); +- eventuale fila di **chip** di scelta (`Wrap(spacing: 8, runSpacing: 8)`); +- fila di **metriche** in `Expanded` uguali, ciascuna: icona 20 px in `primaryDark` → valore `titleMedium` → etichetta 12 px `inkMuted` su una riga sola con ellissi; +- eventuale riga di stato in coda (`labelMedium` `inkMuted`), es. l'ora dell'ultimo salvataggio. + +Quando c'è un progresso, si aggiunge una `LinearProgressIndicator` con `minHeight: 8` e `borderRadius: 8`, preceduta dal conteggio in chiaro ("3 di 7 completate") e dalla percentuale in `headlineSmall`. + +### 6.7 Chip e filtri + +- **Filtri**: `ChoiceChip` `StadiumBorder`, `showCheckmark: false`, **l'etichetta include il conteggio** (`Tutte · 12`). A riposo: superficie `surfaceMuted`, bordo `divider`, testo `inkMuted` peso 600. Selezionato: superficie `infoSurface`, bordo `primary`, testo `primaryDark`. Disposti in una riga orizzontale scorrevole alta **44 px**, spaziatura 8. +- **Chip-entità** (selezione di un oggetto): `Material` + `InkWell` con `StadiumBorder`, padding 12×8, icona 16 px + etichetta peso 600 + dato secondario 12 px `inkMuted`. Selezionato = superficie `surface` e bordo `primaryDark`; non selezionato = `surfaceMuted` e bordo `divider`. + +### 6.8 Campo di ricerca + +Non usa il tema dei campi: è **completamente stondato** (raggio 999), riempito in `surfaceMuted`, **senza bordo** a riposo e in enabled, bordo 1.5 px `primary` solo in focus. Padding 18×14, icona di ricerca `inkMuted` come prefisso, pulsante "cancella" come suffisso solo quando c'è testo. + +### 6.9 Toast + +Sostituisce ogni snackbar di sistema. `SnackBar` `floating` con sfondo trasparente, elevation 0, padding zero e margine `(md, 0, md, md)`; il contenuto è un `Material` con **riempimento pieno del tono**, raggio `control`, elevation 6, ombra nera al 24 %, padding 14×12, e dentro: icona bianca 22 px → 12 px → testo bianco peso 600 interlinea 1.35 → eventuale azione testuale bianca peso 700. + +Prima di mostrarlo si chiama `hideCurrentSnackBar()`: mai una coda di toast. Durata 4 s. Si espone come scorciatoie per tono (`success`/`error`/`warning`/`info`) più una funzione generica. + +### 6.10 Dialog + +`Dialog` su `surface`, raggio `surface`, `insetPadding` 24, `maxWidth: 420`, padding interno 24. Composizione verticale allineata a sinistra: + +1. **cerchio-icona** 48×48 riempito con la superficie tenue del tono, icona del tono 26 px; +2. 16 px → **titolo** `titleMedium`; +3. 8 px → **messaggio** `inkMuted` interlinea 1.45; +4. 24 px → **azioni**: due bottoni in `Expanded` (annulla contornato a sinistra, conferma pieno a destra, 12 px di distanza); se non c'è annullamento, un solo bottone a piena larghezza. + +Le azioni distruttive colorano di `emergency` il bottone di conferma. Due sole funzioni di ingresso: conferma (ritorna booleano) e informativo (un solo bottone). + +### 6.11 Feedback inline + +Riquadro non modale per messaggi persistenti dentro la pagina: superficie tenue del tono, raggio `compact` (8), padding 12, icona 20 px del tono + 10 px + testo peso 600 del tono, allineati in alto. Marcato `Semantics(liveRegion: true)`. Si usa per errori di caricamento e avvisi che devono restare visibili, a differenza del toast che è effimero. + +### 6.12 Stato vuoto + +Centrato, padding 28, larghezza massima 420: icona 44 px `inkMuted` → 14 px → titolo `titleMedium` centrato → 6 px → messaggio `inkMuted` centrato → 18 px → azione facoltativa (bottone contornato). Il messaggio **suggerisce la mossa successiva**; se lo stato vuoto dipende da filtri, l'azione li azzera. + +### 6.13 Barra azioni fissa + +Ancorata in fondo alla pagina: `SafeArea(top: false)`, superficie `surface`, **bordo superiore 1 px** `divider`, padding `(16, 12, 16, 16)`. I bottoni sono in riga con `Expanded` e 12 px di distanza; sotto i **390 px** diventano una colonna a piena larghezza con 8 px di distanza. + +### 6.14 Bottom sheet + +Sempre `isScrollControlled: true`, `useSafeArea: true`, `showDragHandle: true`. Altezza **fissata a una frazione dello schermo**: ~0.78 per uno sheet di consultazione, ~0.92 per uno sheet di compilazione. + +Struttura: + +1. **testata**: padding `(20, 0, 8, 12)`, titolo `titleLarge` + sottotitolo `inkMuted` a sinistra, `IconButton` di chiusura a destra; +2. eventuale **segmented button** a piena larghezza (`expandedInsets: EdgeInsets.zero`) quando lo sheet ha due modalità; +3. `Divider(height: 1)` — l'unica linea di struttura; +4. **corpo** in `Expanded`, scrollabile, padding 16. Con più modalità si usa `IndexedStack`, che preserva lo stato dei form. + +### 6.15 Navigazione + +- **Telefono**: `NavigationBar` **flottante** — avvolta in `SafeArea(minimum: (16, 0, 16, 12))` e in un `ClipRRect(borderRadius: 24)`, con `extendBody: true` sullo Scaffold, così il contenuto scorre sotto. Le pagine lasciano ~110–120 px di padding inferiore per non finirci sotto. +- **Da 720 px**: `NavigationRail` a sinistra con etichette su tutte le voci, seguito da `VerticalDivider(width: 1)`. +- Le destinazioni usano **icona outline a riposo e piena da selezionata**, con la stessa etichetta in entrambe le modalità. +- Le sezioni si tengono vive in un `IndexedStack`: cambiare tab non ricostruisce né perde lo scroll. +- Le **notifiche** stanno nelle `actions` dell'AppBar: `IconButton` con icona outline dentro un `Badge` numerico visibile solo se ci sono elementi non letti, e 12 px di margine destro. + +### 6.16 Liste di navigazione (profilo, impostazioni) + +Un `Panel` che contiene `ListTile` con `contentPadding: EdgeInsets.zero`, icona a sinistra, `Icon(chevron_right)` come trailing per le voci che aprono una pagina, e `Divider()` tra una voce e l'altra. Le voci distruttive (uscita, eliminazione) portano l'icona in `emergency` e non hanno chevron. + +L'intestazione del profilo è un `Panel` con padding 22: `CircleAvatar` raggio 30 su superficie tenue con l'iniziale in 22 px peso 800, 16 px, poi nome `titleLarge` e riga secondaria `inkMuted`. + +### 6.17 Campo composito (tappabile) + +Per valori che si scelgono da un picker invece che digitare: `Panel` `surfaceMuted` con padding zero, `InkWell` interno, padding 14, dentro un'etichetta `labelMedium`, 5 px, e una riga icona 19 px + 7 px + valore con ellissi. Si comporta come un campo ma apre un selettore. + +--- + +## 7. Tema di onboarding (login e schermate pre-accesso) + +Le schermate fuori dall'applicazione autenticata usano una **variante derivata** dello stesso tema (`base.copyWith(...)`), non un tema separato: cambia solo il registro, non la struttura. + +- Sfondo pagina uniforme e neutro; primario = colore d'inchiostro (accento cromatico assente: l'attenzione è sul marchio). +- Campi con riempimento bianco e bordo neutro; in focus bordo 2 px del colore d'inchiostro. +- Bottone principale **a pillola** (`StadiumBorder`), altezza minima 52. +- Composizione: `SafeArea` → `Center` → `SingleChildScrollView(padding: 24)` → `ConstrainedBox(maxWidth: 440)` → colonna `stretch`. +- Ordine: logo (altezza 128, dentro `Semantics(image: true, label:)`) → 32 px → campi a 16 px di distanza → 24 px → eventuale messaggio d'errore centrato in `colorScheme.error` e `liveRegion` → 12 px → bottone di invio → 20 px → footer con versione. +- Il bottone di invio mostra il caricamento **al posto dell'icona** (`SizedBox.square(dimension: 18)` con `CircularProgressIndicator(strokeWidth: 2)`), restando della stessa dimensione; durante il caricamento `onPressed: null`. +- I campi dichiarano `autofillHints` dentro un `AutofillGroup` e incatenano `textInputAction: next`, con invio sull'ultimo. + +--- + +## 8. Comportamenti trasversali + +**Caricamento.** Al primo caricamento senza dati: `CircularProgressIndicator` centrato in un'area di almeno 48 px di padding verticale. Con dati già presenti non si sostituisce mai il contenuto con uno spinner: si aggiorna in silenzio. Ogni elenco è avvolto in un `RefreshIndicator` (pull-to-refresh). + +**Errore di caricamento.** Non svuota la pagina: compare un **feedback inline** (§6.11) in tono danger sopra il contenuto, con l'istruzione per riprovare ("Trascina verso il basso per riprovare"). Gli errori di rete non bloccano la navigazione. + +**Vuoto vs. filtrato.** Si distinguono: "non c'è nulla" e "i filtri non trovano nulla" hanno testi diversi, e il secondo offre l'azzeramento dei filtri. + +**Riordino.** Nelle liste riordinabili solo gli elementi *ammissibili* mostrano la maniglia (`ReorderableDragStartListener` con `Icon(drag_handle)`); il riordino è disabilitato quando è attivo un filtro o una ricerca, e un suggerimento testuale compare accanto al conteggio solo sopra i 520 px di larghezza. + +**Conteggi sempre visibili.** Titoli di sezione, filtri e badge portano il numero ("12 attività", "Allegati (3)", "Tutte · 12"): il tecnico deve sapere quanto ha davanti senza contare. + +**Adattamento locale.** L'adattamento non è solo a livello di pagina: i **singoli componenti** misurano il proprio spazio con `LayoutBuilder` e passano da riga a colonna sotto le soglie indicate (350 px per le azioni di una card, 390 px per la barra azioni). Così lo stesso componente funziona in colonna singola e affiancato. + +**Accessibilità.** +- `Semantics(label:)` su card, badge e maniglie di riordino, con una descrizione che riassume l'entità e i suoi stati. +- `Semantics(liveRegion: true)` su toast, feedback inline e messaggi d'errore dei form. +- `tooltip` su ogni `IconButton` senza etichetta. +- Nessuna informazione veicolata dal solo colore: sempre icona e/o testo. +- Target ≥ 44 px anche per le icone in testata. + +**Testi e stati.** Le date estese si scrivono per intero nella lingua dell'app; gli orari in `HH:mm` con zero iniziale, e i valori mancanti come `--:--`; i dati incompleti si dichiarano ("km finali mancanti", "Partenza da compilare") invece di lasciare un vuoto. + +--- + +## 9. Come adottare lo stile in un'altra app + +1. **Copiare la struttura dei token**: quattro classi statiche `Colors`, `Space`, `Radius`, `Breakpoints` con i valori dei §3 (spazi, raggi e breakpoint **identici**) e i propri colori sui ruoli del §2. +2. **Ricostruire il tema** con le impostazioni del §5: sono la parte che rende immediatamente riconoscibile lo stile (AppBar alta e fusa con la pagina, card piatte a raggio 20, controlli a raggio 16, target 44/48). +3. **Portare i componenti condivisi** del §6 — `PageWidth`, `Panel`, `SectionHeader`, `Badge`, `Feedback`, `EmptyState`, `ActionBar`, toast e dialog — rinominandoli col prefisso dell'app. Sono ~600 righe senza dipendenze oltre a Material. +4. **Definire le mappature di stato** dell'app (`priorityBadge`, `statusBadge`, o gli equivalenti del dominio) in un unico punto, accanto ai componenti. +5. **Vietare i valori ad-hoc**: nelle pagine si usano solo i token. Un colore o un raggio scritto a mano in una pagina è un difetto da correggere, non una scorciatoia. +6. **Verificare la lista di controllo**: + - [ ] nessuna ombra sulle superfici (elevation 0) e nessun `surfaceTint`; + - [ ] sfondo pagina diverso dalle superfici; + - [ ] ogni tocco ≥ 44 px; + - [ ] ogni stato = icona + etichetta + tono; + - [ ] contenuto limitato a ~920 px e centrato; + - [ ] barra di navigazione flottante sotto i 720 px, rail sopra; + - [ ] toast custom al posto delle snackbar di sistema; + - [ ] stato vuoto, stato di caricamento e stato d'errore previsti per ogni elenco; + - [ ] componenti che si riorganizzano da soli sotto le soglie di larghezza. diff --git a/SteUp.Maui/MainPage.xaml b/SteUp.Maui/MainPage.xaml index f6025b4..074e039 100644 --- a/SteUp.Maui/MainPage.xaml +++ b/SteUp.Maui/MainPage.xaml @@ -4,7 +4,7 @@ xmlns:shared="clr-namespace:SteUp.Shared;assembly=SteUp.Shared" x:Class="SteUp.Maui.MainPage" SafeAreaEdges="All" - BackgroundColor="{DynamicResource PageBackgroundColor}"> + BackgroundColor="#E9EDF2"> diff --git a/SteUp.Maui/wwwroot/index.html b/SteUp.Maui/wwwroot/index.html index 77f1eb1..2f9e516 100644 --- a/SteUp.Maui/wwwroot/index.html +++ b/SteUp.Maui/wwwroot/index.html @@ -1,53 +1,76 @@ - + - SteUp.Maui + SteUP - - - + + - - - - - - - - + + + + + + + + + -
-
-
- +
+
- An unhandled error has occurred. - Reload + Si è verificato un errore imprevisto. + Ricarica 🗙
- - \ No newline at end of file + diff --git a/SteUp.Shared/Components/Layout/HeaderLayout.razor b/SteUp.Shared/Components/Layout/HeaderLayout.razor index fd5ea3d..e2d8f2b 100644 --- a/SteUp.Shared/Components/Layout/HeaderLayout.razor +++ b/SteUp.Shared/Components/Layout/HeaderLayout.razor @@ -1,67 +1,56 @@ @inject IJSRuntime Js -
-
- @if (!SmallHeader) - { - if (Back) - { -
- - @BackTo - -
- } +@* + Testata di schermata (STILE_UI §5): alta, titolo a sinistra, sfondo uguale + alla pagina. Non e' una AppBar colorata col titolo centrato: quella e' una + firma Android di serie, e il documento la esclude esplicitamente. +*@ -

@Title

+
+ @if (Back || Cancel) + { +
+ +
+ } -
- @if (!LabelSave.IsNullOrEmpty()) - { - - @LabelSave - - } -
- } - else +
+

@Title

+ @if (!string.IsNullOrWhiteSpace(Subtitle)) { -
- - @Title - - -
+

@Subtitle

}
-
-@code{ - [Parameter] public string? Title { get; set; } - [Parameter] public bool Back { get; set; } - [Parameter] public string BackTo { get; set; } = ""; - - [Parameter] public EventCallback OnFilterToggle { get; set; } - - [Parameter] public bool Cancel { get; set; } - [Parameter] public EventCallback OnCancel { get; set; } - [Parameter] public string? LabelSave { get; set; } - [Parameter] public EventCallback OnSave { get; set; } - - [Parameter] public bool SmallHeader { get; set; } - - protected override void OnParametersSet() + @if (Actions is not null) { - Back = !Back ? !Back && Cancel : Back; - BackTo = Cancel ? "Annulla" : BackTo; +
+ @Actions +
} + + +@code { + [Parameter] public string? Title { get; set; } + [Parameter] public string? Subtitle { get; set; } + + /// Mostra la freccia indietro (navigazione tra schermate). + [Parameter] public bool Back { get; set; } + + /// Mostra la X di chiusura e delega a OnCancel (schermate modali). + [Parameter] public bool Cancel { get; set; } + + [Parameter] public EventCallback OnCancel { get; set; } + + /// Testata ridotta per le schermate a compito singolo, dove il titolo + /// non deve rubare spazio al contenuto. + [Parameter] public bool Compact { get; set; } + + [Parameter] public RenderFragment? Actions { get; set; } private async Task GoBack() { @@ -73,5 +62,4 @@ await Js.InvokeVoidAsync("goBack"); } - -} \ No newline at end of file +} diff --git a/SteUp.Shared/Components/Layout/HeaderLayout.razor.css b/SteUp.Shared/Components/Layout/HeaderLayout.razor.css deleted file mode 100644 index 0cfbb55..0000000 --- a/SteUp.Shared/Components/Layout/HeaderLayout.razor.css +++ /dev/null @@ -1,31 +0,0 @@ -.header { - min-height: var(--mh-header); - width: 100%; - display: flex; - align-items: center; -} - -.header-content { - width: 100%; - line-height: normal; - display: flex; - justify-content: space-between; - align-items: center; - position: relative; -} - -.header-content > .title { width: 100%; } - -.header-content.with-back .page-title { - position: absolute; - left: 50%; - transform: translateX(-50%); - margin: 0; - font-size: larger; -} - -.left-section ::deep button, .right-section ::deep button { font-size: 1.1rem; } - -.left-section ::deep .mud-button-icon-start { margin-right: 3px !important; } - -.header-content.no-back .page-title { margin: 0; } \ No newline at end of file diff --git a/SteUp.Shared/Components/Layout/MainLayout.razor b/SteUp.Shared/Components/Layout/MainLayout.razor index bd0b872..059a3b0 100644 --- a/SteUp.Shared/Components/Layout/MainLayout.razor +++ b/SteUp.Shared/Components/Layout/MainLayout.razor @@ -3,30 +3,24 @@ @using SteUp.Shared.Core.Interface.IntegryApi @using SteUp.Shared.Core.Interface.System.Network @inherits LayoutComponentBase +@implements IDisposable @inject INetworkService NetworkService @inject IIntegryApiService IntegryApiService - + - + - + -
- - -
-
- @Body -
-
+
+ @Body
+ + @code { - private MudThemeProvider _mudThemeProvider = null!; - private bool IsDarkMode { get; set; } - private string _mainContentClass = ""; //Connection state private bool _isNetworkAvailable; @@ -71,58 +65,34 @@ } } - private readonly MudTheme _currentTheme = new() + // La palette resta quella di SteUP. Forma, ritmo e tipografia arrivano dai + // token CSS (tokens.css), che pilotano anche le variabili di MudBlazor: + // qui si dichiara solo il minimo che Mud deve conoscere lato C#. + private readonly MudTheme _theme = new() { - PaletteLight = new PaletteLight() + PaletteLight = new PaletteLight { Primary = "#ec4c41", Secondary = "#002339", Tertiary = "#dff2ff", - TextPrimary = "#000" + Success = "#26b050", + Error = "#e50000", + Warning = "#b26a00", + Info = "#1877d8", + Background = "#e9edf2", + Surface = "#ffffff", + TextPrimary = "#000000", + TextSecondary = "#465563", + LinesDefault = "#d9e0e8", + LinesInputs = "#d9e0e8", + Divider = "#d9e0e8" }, - PaletteDark = new PaletteDark + LayoutProperties = new LayoutProperties { - Primary = "#ec4c41", - Secondary = "#002339", - Tertiary = "#dff2ff", - Surface = "#000406", - Background = "#000406", - TextPrimary = "#fff", - GrayDark = "#E0E0E0" + DefaultBorderRadius = "16px" } }; - protected override async Task OnAfterRenderAsync(bool firstRender) - { - // if (firstRender) - // { - // var isDarkMode = LocalStorage.GetString("isDarkMode"); - - // if (isDarkMode == null && _mudThemeProvider != null) - // { - // IsDarkMode = await _mudThemeProvider.GetSystemPreference(); - // await _mudThemeProvider.WatchSystemPreference(OnSystemPreferenceChanged); - // LocalStorage.SetString("isDarkMode", IsDarkMode.ToString()); - // StateHasChanged(); - // } - // else - // { - // IsDarkMode = bool.Parse(isDarkMode!); - // } - - // if (IsDarkMode) - // { - // _mainContentClass += "is-dark"; - // StateHasChanged(); - // } - // } - } - - private async Task OnSystemPreferenceChanged(bool newValue) - { - IsDarkMode = newValue; - } - protected override void OnInitialized() { _cts = new CancellationTokenSource(); @@ -170,4 +140,4 @@ _cts?.Dispose(); } -} \ No newline at end of file +} diff --git a/SteUp.Shared/Components/Layout/MainLayout.razor.css b/SteUp.Shared/Components/Layout/MainLayout.razor.css deleted file mode 100644 index b179f89..0000000 --- a/SteUp.Shared/Components/Layout/MainLayout.razor.css +++ /dev/null @@ -1,77 +0,0 @@ -.page { - position: relative; - display: flex; - flex-direction: column; -} - -main { - flex: 1; -} - -.sidebar { - background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); -} - -.top-row { - background-color: #f7f7f7; - border-bottom: 1px solid #d6d5d5; - justify-content: flex-end; - height: 3.5rem; - display: flex; - align-items: center; -} - -.top-row ::deep a, .top-row ::deep .btn-link { - white-space: nowrap; - margin-left: 1.5rem; - text-decoration: none; -} - -.top-row ::deep a:hover, .top-row ::deep .btn-link:hover { - text-decoration: underline; -} - -.top-row ::deep a:first-child { - overflow: hidden; - text-overflow: ellipsis; -} - -@media (max-width: 640.98px) { - .top-row { - justify-content: space-between; - } - - .top-row ::deep a, .top-row ::deep .btn-link { - margin-left: 0; - } -} - -@media (min-width: 641px) { - .page { - flex-direction: row; - } - - .sidebar { - width: 250px; - height: 100vh; - position: sticky; - top: 0; - } - - .top-row { - position: sticky; - top: 0; - z-index: 1; - } - - .top-row.auth ::deep a:first-child { - flex: 1; - text-align: right; - width: 0; - } - - .top-row, article { - padding-left: 2rem !important; - padding-right: 1.5rem !important; - } -} \ No newline at end of file diff --git a/SteUp.Shared/Components/Layout/NavMenu.razor b/SteUp.Shared/Components/Layout/NavMenu.razor index 0262904..34f3f85 100644 --- a/SteUp.Shared/Components/Layout/NavMenu.razor +++ b/SteUp.Shared/Components/Layout/NavMenu.razor @@ -1,4 +1,5 @@ @using CommunityToolkit.Mvvm.Messaging +@using SteUp.Shared.Components.Layout.Sheet @using SteUp.Shared.Components.SingleElements.MessageBox @using SteUp.Shared.Core.Enum @using SteUp.Shared.Core.Interface.LocalDb @@ -9,154 +10,205 @@ @inject IMessenger Messenger @inject IIspezioniService IspezioniService @inject IJSRuntime Js +@implements IDisposable -
- -
- + @if (!NetworkService.IsNetworkAvailable()) + { + + } +
+ + + @code { private bool IsVisible { get; set; } = true; - private bool PlusVisible { get; set; } = true; + private bool ActionsVisible { get; set; } = true; + private bool OnInspectionPage { get; set; } private bool ShowCompleteInspection { get; set; } - private bool SchedaVisible { get; set; } private bool _isCompleted; + private bool _sheetOpen; private ConfirmMessageBox _messageBox = null!; + private static readonly string[] NavigableRoutes = ["ispezioni", "ispezione", "user"]; + + private string SheetTitle => OnInspectionPage ? "Azioni ispezione" : "Nuova ispezione"; + + private string? SheetSubtitle => OnInspectionPage + ? SteupDataService.InspectionPageState.Ispezione.CodMdep + : null; + + private string CurrentRoute => + NavigationManager.Uri.Remove(0, NavigationManager.BaseUri.Length).Split('?')[0]; + + private bool IsOn(string route) => CurrentRoute.EqualsIgnoreCase(route); + protected override Task OnInitializedAsync() { - NavigationManager.LocationChanged += (_, args) => - { - var location = args.Location.Remove(0, NavigationManager.BaseUri.Length); - - var newIsVisible = new List { "ispezioni", "ispezione", "user" } - .Contains(location); - - var newPlusVisible = new List { "ispezioni", "ispezione", "user" } - .Contains(location); - - if (IsVisible == newIsVisible && PlusVisible == newPlusVisible) return; - - IsVisible = newIsVisible; - PlusVisible = newPlusVisible; - - if (location.EqualsIgnoreCase("ispezione")) - { - var ispezione = SteupDataService.InspectionPageState.Ispezione; - if (ispezione.Stato == StatusEnum.Completata) PlusVisible = false; - } - - StateHasChanged(); - }; + NavigationManager.LocationChanged += OnLocationChanged; + Refresh(); return Task.CompletedTask; } + private void OnLocationChanged(object? sender, LocationChangedEventArgs args) + { + // Cambiando schermata un menu aperto non ha piu' senso. + _sheetOpen = false; + Refresh(); + } + + private void Refresh() + { + var route = CurrentRoute; + + IsVisible = NavigableRoutes.Contains(route, StringComparer.OrdinalIgnoreCase); + OnInspectionPage = route.EqualsIgnoreCase("ispezione"); + ActionsVisible = IsVisible && !route.EqualsIgnoreCase("user"); + + if (OnInspectionPage) + { + var ispezione = SteupDataService.InspectionPageState.Ispezione; + _isCompleted = ispezione.Stato == StatusEnum.Completata; + // "Concludi" non ha senso su un'ispezione mai inviata o gia' conclusa. + ShowCompleteInspection = !ispezione.ActivityId.IsNullOrEmpty() && !_isCompleted; + + // Su un'ispezione conclusa resta solo la consultazione. + if (_isCompleted) ActionsVisible = false; + } + + StateHasChanged(); + } + + private void OpenActions() + { + Refresh(); + _sheetOpen = true; + } + private async Task NewInspection() { + _sheetOpen = false; + if (await SteupDataService.CanOpenNewInspection()) { _ = Dialog.OpenSelectShop(); } else { - await Dialog.ShowWarning("Per aprire una nuova ispezione è necessario concludere prima tutte le ispezioni aperte più vecchie di 20 giorni!"); + await Dialog.ShowWarning( + "Per aprire una nuova ispezione devi prima concludere tutte le ispezioni aperte da piu' di 20 giorni."); } } private async Task CompleteInspection() { - var result = await _messageBox.ShowAsync(); + _sheetOpen = false; - if (result is true) - Messenger.Send(new CompleteInspectionMessage()); + var result = await _messageBox.ShowAsync(); + if (result is true) Messenger.Send(new CompleteInspectionMessage()); } private async Task NewScheda() { + _sheetOpen = false; + var ispezione = SteupDataService.InspectionPageState.Ispezione; var modal = await Dialog.OpenFormScheda(ispezione.CodMdep, ispezione.Data, true); - if (modal is { Canceled: false }) - Messenger.Send(new NewSchedaMessage()); - } - - private void OnOpenMenu() - { - var location = NavigationManager.Uri.Remove(0, NavigationManager.BaseUri.Length); - - SchedaVisible = new List { "ispezione" }.Contains(location); - - var ispezione = SteupDataService.InspectionPageState.Ispezione; - _isCompleted = ispezione.Stato == StatusEnum.Completata; - // Concludi non ha senso su un'ispezione gia completata - ShowCompleteInspection = !ispezione.ActivityId.IsNullOrEmpty() && !_isCompleted; - StateHasChanged(); + if (modal is { Canceled: false }) Messenger.Send(new NewSchedaMessage()); } private async Task DeleteInspection() { + _sheetOpen = false; + try { var ispezione = SteupDataService.InspectionPageState.Ispezione; await IspezioniService.DeleteIspezioneAsync(ispezione.CodMdep, ispezione.Data, ispezione.Rilevatore); - + await Js.InvokeVoidAsync("goBack"); } catch (Exception e) @@ -164,4 +216,6 @@ await Dialog.ShowError(e.Message); } } -} \ No newline at end of file + + public void Dispose() => NavigationManager.LocationChanged -= OnLocationChanged; +} diff --git a/SteUp.Shared/Components/Layout/NavMenu.razor.css b/SteUp.Shared/Components/Layout/NavMenu.razor.css deleted file mode 100644 index 025e4bb..0000000 --- a/SteUp.Shared/Components/Layout/NavMenu.razor.css +++ /dev/null @@ -1,115 +0,0 @@ -.animated-navbar { - background: transparent; - position: fixed; - bottom: 0; - width: 100%; - z-index: 1001; - transition: all 0.3s ease-in-out; -} - -.animated-navbar.show-nav { - transform: translateY(0); -} - -.animated-navbar.hide-nav { - transform: translateY(150%); -} - -.animated-navbar.with-plus { - margin-left: 30px; -} - -.navbar { - padding-bottom: 1rem; - padding-top: 0 !important; - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: end; - transition: all 0.3s ease-in-out; -} - -.navbar.with-plus { - transform: translateX(-30px); -} - -.navbar.without-plus { - transform: translateX(0); - justify-content: center; -} - -.container-navbar { - background: var(--mud-palette-surface); - border-radius: 50px; - padding: 0 10px; - box-shadow: var(--custom-box-shadow); - transition: all 0.3s ease-in-out; -} - -.nav-item { - font-size: 0.9rem; -} - -.nav-item.plus-button { - position: relative; - bottom: 15px; -} - -.navbar ::deep .custom-plus-button { - background: var(--mud-palette-surface); -} - -.navbar ::deep .custom-menu-fab .mud-fab-label { - width: max-content; -} - -.navbar ::deep .custom-menu-fab .mud-button-root.mud-fab { - background: var(--mud-palette-surface); -} - -.nav-item ::deep a { - display: flex; - align-items: center; - line-height: 1.2; - justify-content: center; - padding-top: .25rem !important; - padding-bottom: .25rem !important; -} - -.nav-item ::deep a > div { - -webkit-transition: all .1s ease-out; - transition: all .1s ease-out; - min-width: 75px; -} - -.nav-item ::deep a.active > div { - color: var(--mud-palette-primary); -} - -.nav-item ::deep a.active > div > i { - /*background-color: color-mix(in srgb, var(--mud-palette-primary) 20%, transparent);*/ - border-radius: 10px; -} - -.nav-item ::deep a.active > div > span { - font-weight: 800; -} - -.nav-item ::deep a:not(.active) > div { - color: var(--mud-palette-text-primary); -} - -.nav-item ::deep a i { - font-size: 1.65rem; -} - -.nav-item ::deep a span { - font-size: 0.8rem; - font-weight: 500; -} - -@supports (-webkit-touch-callout: none) { - .navbar { - padding-bottom: env(safe-area-inset-bottom); - } -} \ No newline at end of file diff --git a/SteUp.Shared/Components/Layout/Overlay/SpinnerOverlay.razor b/SteUp.Shared/Components/Layout/Overlay/SpinnerOverlay.razor index ee9445e..2b809e7 100644 --- a/SteUp.Shared/Components/Layout/Overlay/SpinnerOverlay.razor +++ b/SteUp.Shared/Components/Layout/Overlay/SpinnerOverlay.razor @@ -1,39 +1,36 @@ -@using SteUp.Shared.Components.Layout.Spinner +@* + Overlay di operazione bloccante (invio al server, cancellazione). + Mostra sempre cosa sta succedendo: un'attesa muta in campo, con la rete + incerta, e' il momento in cui il rilevatore perde fiducia nello strumento. +*@ - - @if (SuccessAnimation) - { -
-
-
- - -
-
-
+@if (VisibleOverlay) +{ +
+ @if (SuccessAnimation) + { +
+
- @if (!string.IsNullOrWhiteSpace(Message)) - { -

@Message

- } -
- } - else - { -
- - @if (!string.IsNullOrWhiteSpace(Message)) - { -

@Message

- } -
- } - + } + else + { + + } + + @if (!string.IsNullOrWhiteSpace(Message)) + { +

@Message

+ } +
+} @code { - [Parameter] public required bool SuccessAnimation { get; set; } + [Parameter] public bool SuccessAnimation { get; set; } [Parameter] public required bool VisibleOverlay { get; set; } - /// Testo opzionale mostrato sotto lo spinner/spunta (es. "Invio scheda 3 di 7…"). + /// Testo mostrato sotto lo spinner (es. "Invio scheda 3 di 7…"). [Parameter] public string? Message { get; set; } -} \ No newline at end of file +} diff --git a/SteUp.Shared/Components/Layout/Overlay/SpinnerOverlay.razor.css b/SteUp.Shared/Components/Layout/Overlay/SpinnerOverlay.razor.css deleted file mode 100644 index f554981..0000000 --- a/SteUp.Shared/Components/Layout/Overlay/SpinnerOverlay.razor.css +++ /dev/null @@ -1,184 +0,0 @@ -.overlay-content { - display: flex; - flex-direction: column; - align-items: center; - gap: 1.5rem; - padding: 0 2rem; - text-align: center; -} - -.overlay-message { - margin: 0; - max-width: 30ch; - font-weight: 700; - font-size: 1.05rem; - line-height: 1.4; - /* Inchiostro su fondo chiaro dell'overlay: leggibile anche al sole */ - color: var(--mud-palette-text-primary); -} - -.success-checkmark { - width: 80px; - height: 80px; - margin: 0 auto; -} - -/* Reduced motion: mostra la spunta di successo statica, senza coreografia */ -@media (prefers-reduced-motion: reduce) { - .success-checkmark .check-icon::after, - .icon-line.line-tip, - .icon-line.line-long { - animation: none !important; - } -} - -.success-checkmark .check-icon { - width: 80px; - height: 80px; - position: relative; - border-radius: 50%; - box-sizing: content-box; - border: 4px solid var(--mud-palette-success); -} - -.success-checkmark .check-icon::before { - top: 3px; - left: -2px; - width: 30px; - transform-origin: 100% 50%; - border-radius: 100px 0 0 100px; -} - -.success-checkmark .check-icon::after { - top: 0; - left: 30px; - width: 60px; - transform-origin: 0 50%; - border-radius: 0 100px 100px 0; - animation: rotate-circle 4.25s ease-in; -} - -.success-checkmark .check-icon::before, -.success-checkmark .check-icon::after { - content: ''; - height: 100px; - position: absolute; - transform: rotate(-45deg); - z-index: 2; -} - -.icon-line { - height: 5px; - background-color: var(--mud-palette-success); - display: block; - border-radius: 2px; - position: absolute; - z-index: 10; -} - -.icon-line.line-tip { - top: 46px; - left: 14px; - width: 25px; - transform: rotate(45deg); - animation: icon-line-tip 0.75s; -} - -.icon-line.line-long { - top: 38px; - right: 8px; - width: 47px; - transform: rotate(-45deg); - animation: icon-line-long 0.75s; -} - - -.icon-circle { - top: -4px; - left: -4px; - z-index: 10; - width: 80px; - height: 80px; - border-radius: 50%; - position: absolute; - box-sizing: content-box; - border: 4px solid var(--mud-palette-success); -} - -.icon-fix { - top: 8px; - width: 5px; - left: 26px; - z-index: 1; - height: 85px; - position: absolute; - transform: rotate(-45deg); -} - -@keyframes rotate-circle { - 0% { transform: rotate(-45deg); } - - 5% { transform: rotate(-45deg); } - - 12% { transform: rotate(-405deg); } - - 100% { transform: rotate(-405deg); } -} - -@keyframes icon-line-tip { - 0% { - width: 0; - left: 1px; - top: 19px; - } - - 54% { - width: 0; - left: 1px; - top: 19px; - } - - 70% { - width: 50px; - left: -8px; - top: 37px; - } - - 84% { - width: 17px; - left: 21px; - top: 48px; - } - - 100% { - width: 25px; - left: 14px; - top: 45px; - } -} - -@keyframes icon-line-long { - 0% { - width: 0; - right: 46px; - top: 54px; - } - - 65% { - width: 0; - right: 46px; - top: 54px; - } - - 84% { - width: 55px; - right: 0px; - top: 35px; - } - - 100% { - width: 47px; - right: 8px; - top: 38px; - } -} \ No newline at end of file diff --git a/SteUp.Shared/Components/Layout/Sheet/ActionSheet.razor b/SteUp.Shared/Components/Layout/Sheet/ActionSheet.razor new file mode 100644 index 0000000..8f4e0b3 --- /dev/null +++ b/SteUp.Shared/Components/Layout/Sheet/ActionSheet.razor @@ -0,0 +1,51 @@ +@* + Bottom sheet (STILE_UI §6.14): sale dal basso, maniglia di trascinamento, + testata con titolo e chiusura, un'unica linea di struttura, corpo scorrevole. + Sostituisce i menu flottanti: stessa funzione, bersagli molto piu' grandi. +*@ + +@if (Open) +{ + + + +} + +@code { + [Parameter] public bool Open { get; set; } + [Parameter] public EventCallback OpenChanged { get; set; } + [Parameter] public string? Title { get; set; } + [Parameter] public string? Subtitle { get; set; } + + /// Sheet di compilazione (~92% dello schermo) invece che di consultazione. + [Parameter] public bool Tall { get; set; } + + [Parameter] public RenderFragment? ChildContent { get; set; } + + private async Task Close() + { + Open = false; + await OpenChanged.InvokeAsync(false); + } +} diff --git a/SteUp.Shared/Components/Layout/Spinner/LoaderLayout.razor b/SteUp.Shared/Components/Layout/Spinner/LoaderLayout.razor deleted file mode 100644 index dd2b055..0000000 --- a/SteUp.Shared/Components/Layout/Spinner/LoaderLayout.razor +++ /dev/null @@ -1,13 +0,0 @@ - -
- @(Text ?? "Caricamento") - - -
-
- -@code -{ - [Parameter] public bool Visible { get; set; } - [Parameter] public string? Text { get; set; } -} \ No newline at end of file diff --git a/SteUp.Shared/Components/Layout/Spinner/LoaderLayout.razor.css b/SteUp.Shared/Components/Layout/Spinner/LoaderLayout.razor.css deleted file mode 100644 index 6ef8793..0000000 --- a/SteUp.Shared/Components/Layout/Spinner/LoaderLayout.razor.css +++ /dev/null @@ -1,18 +0,0 @@ -.overlay-container { - background: var(--mud-palette-background); - width: 20rem; - height: 6rem; - padding: 0 1rem; - display: flex; - gap: .5rem; - flex-direction: column; - justify-content: center; - border-radius: 20px; - box-shadow: var(--custom-box-shadow); -} - -.overlay-container > span { - text-align: center; - font-size: medium; - font-weight: 600; -} \ No newline at end of file diff --git a/SteUp.Shared/Components/Layout/Spinner/SpinnerLayout.razor b/SteUp.Shared/Components/Layout/Spinner/SpinnerLayout.razor index 89399a9..1194cc7 100644 --- a/SteUp.Shared/Components/Layout/Spinner/SpinnerLayout.razor +++ b/SteUp.Shared/Components/Layout/Spinner/SpinnerLayout.razor @@ -1,8 +1,11 @@ -
- +@* Indicatore di attesa. A schermo pieno solo quando non c'e' ancora nulla da + mostrare; dentro una sezione resta contenuto e non fa saltare il layout. *@ + +
+
@code { [Parameter] public bool FullScreen { get; set; } = true; -} \ No newline at end of file +} diff --git a/SteUp.Shared/Components/Layout/Spinner/SpinnerLayout.razor.css b/SteUp.Shared/Components/Layout/Spinner/SpinnerLayout.razor.css deleted file mode 100644 index 80fb011..0000000 --- a/SteUp.Shared/Components/Layout/Spinner/SpinnerLayout.razor.css +++ /dev/null @@ -1,43 +0,0 @@ -.spinner-container { - display: flex; - justify-content: center; - height: calc(100vh - 10.1rem); - align-items: center; - color: var(--mud-palette-primary); -} - -.not-fullScreen { - height: auto !important; - padding: 2rem 0 !important; -} - -.loader { - width: 50px; - aspect-ratio: 1; - border-radius: 50%; - border: 8px solid #0000; - border-right-color: var(--mud-palette-primary); - position: relative; - animation: l24 1s infinite linear; -} - -.loader:before, -.loader:after { - content: ""; - position: absolute; - inset: -8px; - border-radius: 50%; - border: inherit; - animation: inherit; - animation-duration: 2s; -} - -.loader:after { - animation-duration: 4s; -} - -@keyframes l24 { - 100% { - transform: rotate(1turn) - } -} \ No newline at end of file diff --git a/SteUp.Shared/Components/Pages/IspezionePage.razor b/SteUp.Shared/Components/Pages/IspezionePage.razor index 0c8c211..1b46069 100644 --- a/SteUp.Shared/Components/Pages/IspezionePage.razor +++ b/SteUp.Shared/Components/Pages/IspezionePage.razor @@ -2,11 +2,13 @@ @using Microsoft.Extensions.Logging @using SteUp.Shared.Components.Layout @using SteUp.Shared.Components.Layout.Overlay +@using SteUp.Shared.Components.SingleElements @using SteUp.Shared.Components.SingleElements.Card @using SteUp.Shared.Components.SingleElements.MessageBox @using SteUp.Shared.Core.Dto @using SteUp.Shared.Core.Entities @using SteUp.Shared.Core.Enum +@using SteUp.Shared.Core.Helpers @using SteUp.Shared.Core.Interface.IntegryApi @using SteUp.Shared.Core.Interface.LocalDb @using SteUp.Shared.Core.Interface.System @@ -21,98 +23,133 @@ @inject ILogger Logger @implements IDisposable - + -
- +
+
- @if (!SchedeGrouped.IsNullOrEmpty()) - { - - @{ - var panelIndex = 0; + @* §6.6 Pannello di riepilogo: primo elemento della pagina, superficie + tenue, con lo stato del documento e i conteggi sempre a vista. *@ +
+
+
+

@Titolo

+

@Ispezione.Data.ToString("d MMMM yyyy")

+
+ +
+ +
+
+ +
@SchedeList.Count
+
@(SchedeList.Count == 1 ? "scheda" : "schede")
+
+
+ +
@DaInviare
+
da inviare
+
+
+ +
@Bozze
+
da completare
+
+
+ + @if (SchedeList.Count > 0) + { +
+ @Inviate di @SchedeList.Count inviate + @PercentualeInviate% +
+
+
+
} +
+ + @if (Bozze > 0) + { + @* Feedback inline: resta a video finche' il problema esiste, a + differenza del toast che sparisce. *@ + + } + + @if (SchedeGrouped.Count == 0) + { +
+ +

Nessuna scheda

+ @if (!IsCompleted) + { +

+ Tocca + nella barra in basso per creare la prima scheda di questa ispezione. +

+ } +
+ } + else + { @foreach (var group in SchedeGrouped) { - var isFirstPanel = panelIndex == 0; - panelIndex++; - - - @{ - var daInviare = group.Value.CountDaInviare(); - var daCompletare = group.Value.CountBozze(); - } -
-
- @group.Key.Descrizione - - @($"{group.Value.Count} sched{(group.Value.Count == 1 ? "a" : "e")}") - - @if (daInviare > 0) - { - - @($"{daInviare} da inviare") - - } - @if (daCompletare > 0) - { - - @($"{daCompletare} da completare") - - } -
- @if (SteupDataService.InspectionPageState.Ispezione.Stato != StatusEnum.Completata) - { -
- + var daInviare = group.Value.CountDaInviare(); + var daCompletare = group.Value.CountBozze(); - @if (NetworkService.IsNetworkAvailable() && daInviare > 0) - { - - } -
- } +
+
+
+

@group.Key.Descrizione

+

+ @(group.Value.Count == 1 ? "1 scheda" : $"{group.Value.Count} schede") +

- - + + @if (!IsCompleted && NetworkService.IsNetworkAvailable() && daInviare > 0) + { +
+ + Invia @daInviare + +
+ } +
+ + @if (daCompletare > 0) + { +
+ +
+ } + +
@foreach (var scheda in group.Value) { - + OnBusyChanged="@(b => VisibleOverlay = b)"/> } - - +
+
} - - } - else - { -
- -

Nessuna scheda ancora

- @if (SteupDataService.InspectionPageState.Ispezione.Stato != StatusEnum.Completata) - { -

- Tocca + in basso per creare la prima scheda di questa ispezione. -

- } -
- } + } +
- + @code { private List SchedeList { get; set; } = []; @@ -125,6 +162,28 @@ private ConfirmMessageBox _exportRepartoBox = null!; + private Ispezione Ispezione => SteupDataService.InspectionPageState.Ispezione; + private bool IsCompleted => Ispezione.Stato == StatusEnum.Completata; + + private int DaInviare => SchedeList.CountDaInviare(); + private int Bozze => SchedeList.CountBozze(); + private int Inviate => SchedeList.Count(s => s.GetSyncState() == SyncState.Inviato); + + private int PercentualeInviate => SchedeList.Count == 0 + ? 0 + : (int)Math.Round(Inviate * 100.0 / SchedeList.Count); + + private string Progresso => (PercentualeInviate / 100.0).ToString("0.###", + System.Globalization.CultureInfo.InvariantCulture); + + private string Titolo => SteupDataService.PuntiVenditaList + .Find(x => x.CodMdep != null && x.CodMdep.EqualsIgnoreCase(Ispezione.CodMdep))? + .Descrizione is { Length: > 0 } descrizione + ? descrizione + : Ispezione.CodMdep; + + private string Sottotitolo => Ispezione.CodMdep; + protected override void OnInitialized() { NewScheda.OnNewScheda += LoadSchede; @@ -135,7 +194,7 @@ private void LoadSchede() { - var ispezione = SteupDataService.InspectionPageState.Ispezione; + var ispezione = Ispezione; InvokeAsync(async () => { @@ -162,7 +221,7 @@ StateHasChanged(); }); - var ispezione = SteupDataService.InspectionPageState.Ispezione; + var ispezione = Ispezione; // Le bozze non sono inviabili: l'ispezione non si chiude finché ci sono. var bozze = SchedeList.CountBozze(); @@ -277,7 +336,7 @@ StatusEnum.Completata ); - SteupDataService.InspectionPageState.Ispezione.Stato = StatusEnum.Completata; + ispezione.Stato = StatusEnum.Completata; // Riscontro al momento più importante: la spunta di successo prima di chiudere l'overlay. await InvokeAsync(() => @@ -339,18 +398,6 @@ StateHasChanged(); } - private async Task CreateNewScheda(JtbFasiDto jtbFasi) - { - var modal = await Dialog.OpenFormScheda( - SteupDataService.InspectionPageState.Ispezione.CodMdep, - SteupDataService.InspectionPageState.Ispezione.Data, - true, - new Scheda { Reparto = jtbFasi } - ); - - if (modal is { Canceled: false }) LoadSchede(); - } - private async Task ExportReparto(JtbFasiDto jtbFasi) { // Guardia anti doppio-tap: un secondo tocco durante l'invio non ri-esporta il batch. @@ -365,14 +412,14 @@ try { - var ispezione = SteupDataService.InspectionPageState.Ispezione; + var ispezione = Ispezione; // Le bozze restano sul dispositivo: si esporta solo ciò che è completo. var daEsportare = SchedeGrouped[jtbFasi].FindAll(x => !x.Bozza); if (daEsportare.Count == 0) { - Snackbar.Add("Nessuna scheda completa da esportare in questo reparto", Severity.Info); + Snackbar.Add("Nessuna scheda completa da inviare in questo reparto", Severity.Info); return; } @@ -398,11 +445,11 @@ if (apiResponse == null) { - Snackbar.Add("Esportazione non riuscita. Le schede restano sul dispositivo, riprova.", Severity.Error); + Snackbar.Add("Invio non riuscito. Le schede restano sul dispositivo, riprova.", Severity.Error); return; } - SteupDataService.InspectionPageState.Ispezione.ActivityId = apiResponse.ActivityIdIspezione; + ispezione.ActivityId = apiResponse.ActivityIdIspezione; await IspezioniService.UpdateActivityIdIspezioneAsync(ispezione.CodMdep, ispezione.Data, UserSession.User.Username, apiResponse.ActivityIdIspezione @@ -423,7 +470,8 @@ } } - Snackbar.Add("Reparto esportato", Severity.Success); + Snackbar.Add(daEsportare.Count == 1 ? "Scheda inviata" : $"{daEsportare.Count} schede inviate", + Severity.Success); } catch (Exception e) { @@ -453,4 +501,4 @@ CompleteInspection.OnComplete -= HandleCompleteInspection; } -} \ No newline at end of file +} diff --git a/SteUp.Shared/Components/Pages/IspezionePage.razor.css b/SteUp.Shared/Components/Pages/IspezionePage.razor.css deleted file mode 100644 index 4a3c657..0000000 --- a/SteUp.Shared/Components/Pages/IspezionePage.razor.css +++ /dev/null @@ -1,47 +0,0 @@ -/* Tinta di fondo tenue: le card e i pannelli bianchi (Carta) risaltano sul Grigio Campo */ -.ispezione { - background-color: var(--light-card-background); -} - -.ispezione ::deep .expansion-panel .mud-expand-panel-content { - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.action-scheda-group { - display: flex; - align-items: center; - justify-content: flex-end; - gap: .75rem; -} - -.empty-state { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: .75rem; - margin-top: 3rem; - padding: 0 1.5rem; -} - -.empty-state .empty-state-icon { - color: var(--mud-palette-primary); - opacity: .85; -} - -.empty-state .empty-state-title { - margin: 0; - font-weight: 800; - font-size: 1.25rem; - color: var(--mud-palette-text-primary); -} - -.empty-state .empty-state-text { - margin: 0; - max-width: 34ch; - font-weight: 600; - line-height: 1.5; - color: var(--mud-palette-gray-darker); -} \ No newline at end of file diff --git a/SteUp.Shared/Components/Pages/IspezioniPage.razor b/SteUp.Shared/Components/Pages/IspezioniPage.razor index 3dbc4c8..952db60 100644 --- a/SteUp.Shared/Components/Pages/IspezioniPage.razor +++ b/SteUp.Shared/Components/Pages/IspezioniPage.razor @@ -1,47 +1,68 @@ @page "/ispezioni" @attribute [Authorize] @using SteUp.Shared.Components.Layout -@using SteUp.Shared.Components.Layout.Overlay -@using SteUp.Shared.Components.SingleElements @using SteUp.Shared.Components.SingleElements.Card @using SteUp.Shared.Core.Entities @using SteUp.Shared.Core.Interface.LocalDb @inject IIspezioniService IspezioniService - + -
- @if (Ispezioni.IsNullOrEmpty()) - { - - } - else - { - - - - } +
+
+ @if (_loading) + { + @* Scheletri, non uno spinner in mezzo al contenuto: la pagina ha + gia' la forma che avra', e non salta quando i dati arrivano. *@ + + } + else if (Ispezioni.Count == 0) + { +
+ +

Nessuna ispezione

+

+ Tocca + nella barra in basso per aprire la prima ispezione su un punto vendita. +

+
+ } + else + { +
+ + + +
+ } +
- - @code{ private List Ispezioni { get; set; } = []; + private bool _loading = true; - private bool VisibleOverlay { get; set; } = true; + private string? Subtitle => _loading || Ispezioni.Count == 0 + ? null + : Ispezioni.Count == 1 + ? "1 ispezione" + : $"{Ispezioni.Count} ispezioni"; protected override async Task OnInitializedAsync() - { - await LoadData(); - - VisibleOverlay = false; - StateHasChanged(); - } - - private async Task LoadData() { Ispezioni = await IspezioniService.GetAllIspezioniWithSchedeAsync(UserSession.User.Username); + + _loading = false; + StateHasChanged(); } private void OnClickIspezione(Ispezione ispezione) @@ -49,5 +70,4 @@ SteupDataService.InspectionPageState.Ispezione = ispezione; NavigationManager.NavigateTo("/ispezione"); } - -} \ No newline at end of file +} diff --git a/SteUp.Shared/Components/Pages/IspezioniPage.razor.css b/SteUp.Shared/Components/Pages/IspezioniPage.razor.css deleted file mode 100644 index 0d64851..0000000 --- a/SteUp.Shared/Components/Pages/IspezioniPage.razor.css +++ /dev/null @@ -1,5 +0,0 @@ -.ispezioni{ - display: flex; - flex-direction: column; - gap: 1rem; -} \ No newline at end of file diff --git a/SteUp.Shared/Components/Pages/LoginPage.razor b/SteUp.Shared/Components/Pages/LoginPage.razor index b325120..7baa7f7 100644 --- a/SteUp.Shared/Components/Pages/LoginPage.razor +++ b/SteUp.Shared/Components/Pages/LoginPage.razor @@ -1,6 +1,5 @@ @page "/login" @using Microsoft.Extensions.Logging -@using SteUp.Shared.Components.Layout.Spinner @using SteUp.Shared.Core.BarcodeReader.Contracts @using SteUp.Shared.Core.Interface.System @using SteUp.Shared.Core.Services @@ -10,68 +9,83 @@ @inject IBarcodeManager BarcodeManager @inject ILogger Logger -@if (Spinner) -{ - -} -else -{ -