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; + } + } +}