Compare commits

...

5 Commits

Author SHA1 Message Date
MarcoE 3b04702665 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.
2026-09-16 18:07:43 +02:00
MarcoE 0174316bb7 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) <noreply@anthropic.com>
2026-09-16 17:57:41 +02:00
MarcoE d076a4b77a 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) <noreply@anthropic.com>
2026-09-16 13:24:35 +02:00
MarcoE e3942d00cc 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) <noreply@anthropic.com>
2026-09-16 13:23:34 +02:00
MarcoE 92b60deac0 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) <noreply@anthropic.com>
2026-09-16 11:33:36 +02:00
14 changed files with 564 additions and 33 deletions
@@ -0,0 +1,157 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("CodMdep")
.HasColumnType("TEXT");
b.Property<DateTime>("Data")
.HasColumnType("TEXT");
b.Property<string>("Rilevatore")
.HasColumnType("TEXT");
b.Property<string>("ActivityId")
.HasColumnType("TEXT");
b.Property<int>("Stato")
.HasColumnType("INTEGER");
b.HasKey("CodMdep", "Data", "Rilevatore");
b.ToTable("Ispezioni");
});
modelBuilder.Entity("SteUp.Shared.Core.Entities.Scheda", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActivityId")
.HasColumnType("TEXT");
b.Property<string>("ActivityTypeId")
.HasColumnType("TEXT");
b.Property<bool>("Bozza")
.HasColumnType("INTEGER");
b.Property<string>("CodJfas")
.HasColumnType("TEXT");
b.Property<string>("CodMdep")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("Data")
.HasColumnType("TEXT");
b.Property<string>("DescrizioneReparto")
.HasColumnType("TEXT");
b.Property<string>("ImageNames")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Responsabile")
.HasColumnType("TEXT");
b.Property<string>("Rilevatore")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Scadenza")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CodMdep", "Data", "Rilevatore");
b.ToTable("Schede");
});
modelBuilder.Entity("SteUp.Shared.Core.Entities.SchedaArticolo", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Barcode")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Descrizione")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("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
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SteUp.Data.Migrations
{
/// <inheritdoc />
public partial class AddBozzaScheda : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Bozza",
table: "Schede",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Bozza",
table: "Schede");
}
}
}
@@ -15,7 +15,7 @@ namespace SteUp.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #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 => modelBuilder.Entity("SteUp.Shared.Core.Entities.Ispezione", b =>
{ {
@@ -51,6 +51,9 @@ namespace SteUp.Data.Migrations
b.Property<string>("ActivityTypeId") b.Property<string>("ActivityTypeId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("Bozza")
.HasColumnType("INTEGER");
b.Property<string>("CodJfas") b.Property<string>("CodJfas")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
+2
View File
@@ -20,6 +20,7 @@ using SteUp.Shared.Core.Interface.System.Network;
using SteUp.Shared.Core.Messages.Ispezione; using SteUp.Shared.Core.Messages.Ispezione;
using SteUp.Shared.Core.Messages.Scanner; using SteUp.Shared.Core.Messages.Scanner;
using SteUp.Shared.Core.Messages.Scheda; using SteUp.Shared.Core.Messages.Scheda;
using SteUp.Shared.Core.Messages.System;
using SteUp.Shared.Core.Services; using SteUp.Shared.Core.Services;
namespace SteUp.Maui.Core; namespace SteUp.Maui.Core;
@@ -65,6 +66,7 @@ public static class CoreModule
builder.Services.AddSingleton<NewSchedaService>(); builder.Services.AddSingleton<NewSchedaService>();
builder.Services.AddSingleton<OnScannerService>(); builder.Services.AddSingleton<OnScannerService>();
builder.Services.AddSingleton<CompleteInspectionService>(); builder.Services.AddSingleton<CompleteInspectionService>();
builder.Services.AddSingleton<BackButtonService>();
} }
public void RegisterDbServices() public void RegisterDbServices()
@@ -164,7 +164,14 @@ public partial class HoneywellScannerService
: Java.Lang.Object, BarcodeReader.IBarcodeListener : Java.Lang.Object, BarcodeReader.IBarcodeListener
{ {
public void OnBarcodeEvent(BarcodeReadEvent? e) => service.DispatchEvent(e!); 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)
{
}
} }
} }
@@ -1,14 +1,61 @@
using Android.App; using Android.App;
using Android.Content.PM; 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 namespace SteUp.Maui
{ {
[Activity( [Activity(
Theme = "@style/Maui.SplashTheme", Theme = "@style/Maui.SplashTheme",
MainLauncher = true, MainLauncher = true,
WindowSoftInputMode = SoftInput.AdjustResize | SoftInput.StateHidden,
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode |
ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity 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());
}
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<BackButtonService>();
if (backButton?.TryHandleBackPressed() == true) return;
base.OnBackPressed();
}
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;
}
}
} }
} }
@@ -40,6 +40,7 @@
<TitleContent> <TitleContent>
@{ @{
var daInviare = group.Value.CountDaInviare(); var daInviare = group.Value.CountDaInviare();
var daCompletare = group.Value.CountBozze();
} }
<div class="header-scheda-group"> <div class="header-scheda-group">
<div class="title"> <div class="title">
@@ -54,6 +55,13 @@
@($"{daInviare} da inviare") @($"{daInviare} da inviare")
</MudChip> </MudChip>
} }
@if (daCompletare > 0)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled"
Color="Color.Warning" Icon="@Icons.Material.Rounded.EditNote">
@($"{daCompletare} da completare")
</MudChip>
}
</div> </div>
@if (SteupDataService.InspectionPageState.Ispezione.Stato != StatusEnum.Completata) @if (SteupDataService.InspectionPageState.Ispezione.Stato != StatusEnum.Completata)
{ {
@@ -156,7 +164,25 @@
var ispezione = SteupDataService.InspectionPageState.Ispezione; 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 totale = daInviare.Count;
var indice = 0; var indice = 0;
var fallite = 0; var fallite = 0;
@@ -341,7 +367,16 @@
{ {
var ispezione = SteupDataService.InspectionPageState.Ispezione; 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 return new SaveRequestDto
{ {
@@ -375,7 +410,7 @@
if (!apiResponse.ActivityIdSchedaList.IsNullOrEmpty()) if (!apiResponse.ActivityIdSchedaList.IsNullOrEmpty())
{ {
foreach (var scheda in SchedeGrouped[jtbFasi]) foreach (var scheda in daEsportare)
{ {
var activityId = apiResponse.ActivityIdSchedaList!.Find(x => var activityId = apiResponse.ActivityIdSchedaList!.Find(x =>
x.LocalId != null && x.LocalId == scheda.Id x.LocalId != null && x.LocalId == scheda.Id
@@ -1,6 +1,9 @@
<MudMessageBox @ref="_confirmSave" Title="Attenzione!" CancelText="Non salvare"> @* NoText -> false (salva bozza), YesText -> true (salva), chiusura senza scelta -> null.
Niente CancelText: da qui non si esce buttando via il lavoro. *@
<MudMessageBox @ref="_confirmSave" Title="Lavoro non salvato" NoText="Salva come bozza">
<MessageContent> <MessageContent>
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.
</MessageContent> </MessageContent>
<YesButton> <YesButton>
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"> <MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary">
@@ -11,6 +11,8 @@
@using SteUp.Shared.Core.Interface.LocalDb @using SteUp.Shared.Core.Interface.LocalDb
@using SteUp.Shared.Core.Interface.System @using SteUp.Shared.Core.Interface.System
@using SteUp.Shared.Core.Messages.Scanner @using SteUp.Shared.Core.Messages.Scanner
@using SteUp.Shared.Core.Messages.System
@inject BackButtonService BackButton
@inject INetworkService NetworkService @inject INetworkService NetworkService
@inject IDialogService Dialog @inject IDialogService Dialog
@inject IIntegryApiService IntegryApiService @inject IIntegryApiService IntegryApiService
@@ -20,6 +22,7 @@
@inject OnScannerService OnScannerService @inject OnScannerService OnScannerService
@inject IAttachedService AttachedService @inject IAttachedService AttachedService
@inject ILogger<ModalFormScheda> Logger @inject ILogger<ModalFormScheda> Logger
@implements IDisposable
<MudDialog Class="customDialog-form"> <MudDialog Class="customDialog-form">
<DialogContent> <DialogContent>
@@ -230,8 +233,41 @@
private int _noteFieldKey; private int _noteFieldKey;
private bool IsDirty { get; set; } private bool IsDirty { get; set; }
private Scheda _originalScheda = null!; 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<string> _originalBarcodes = [];
// Gli allegati vivono in AttachedList finche' non si salva: li tracciamo con un flag.
private bool _attachmentsDirty;
private List<AttachedDto>? AttachedList { get; set; } private List<AttachedDto>? 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<T> WithChildDialog<T>(Func<Task<T>> open)
{
_childDialogs++;
try
{
return await open();
}
finally
{
_childDialogs--;
}
}
protected override void OnInitialized() protected override void OnInitialized()
{ {
Snackbar.Configuration.PositionClass = Defaults.Classes.Position.TopCenter; Snackbar.Configuration.PositionClass = Defaults.Classes.Position.TopCenter;
@@ -240,8 +276,13 @@
OnScannerService.OnErrorScan += OnErrorScan; OnScannerService.OnErrorScan += OnErrorScan;
_originalScheda = Scheda.Clone(); _originalScheda = Scheda.Clone();
_originalBarcodes = Scheda.Articoli.ConvertAll(x => x.Barcode);
Snackbar.Configuration.PositionClass = Defaults.Classes.Position.TopCenter; 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(); LoadAttached();
} }
@@ -279,6 +320,9 @@
VisibleOverlay = true; VisibleOverlay = true;
StateHasChanged(); StateHasChanged();
// Salvataggio completo: la scheda non è più una bozza.
Scheda.Bozza = false;
SaveSchedaResponseDto? apiResponse = null; SaveSchedaResponseDto? apiResponse = null;
try try
{ {
@@ -363,7 +407,7 @@
await IspezioniService.AddSchedaAsync(CodMdep, Data, UserSession.User.Username, Scheda); 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()) if (!AttachedList.IsNullOrEmpty())
{ {
@@ -393,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); await IspezioniService.UpdateSchedaAsync(Scheda);
if (apiResponse != null) if (apiResponse != null)
@@ -433,6 +480,11 @@
} }
private async Task Cancel() private async Task Cancel()
{
if (_closing) return;
_closing = true;
try
{ {
if (await CheckSavePreAction()) if (await CheckSavePreAction())
{ {
@@ -441,6 +493,11 @@
MudDialog.Cancel(); MudDialog.Cancel();
} }
} }
finally
{
_closing = false;
}
}
#region Form #region Form
@@ -456,14 +513,29 @@
StateHasChanged(); 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"; if (IsDirty) LabelSave = !IsNew ? "Aggiorna" : "Salva";
else LabelSave = null; 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 private static class ValueComparer
{ {
public static bool AreEqual(Scheda? a, Scheda? b) public static bool AreEqual(Scheda? a, Scheda? b)
@@ -480,17 +552,25 @@
} }
} }
/// <summary>
/// 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.
/// </summary>
private async Task<bool> CheckSavePreAction() private async Task<bool> CheckSavePreAction()
{ {
if (!IsDirty) return true; if (!IsDirty) return true;
var resul = await _confirmUpdateMessage.ShowAsync(); var result = await WithChildDialog(() => _confirmUpdateMessage.ShowAsync());
if (resul is not true) return true;
// Prompt chiuso senza scegliere (tap fuori / indietro): si resta sulla scheda.
if (result is null) return false;
VisibleOverlay = true; VisibleOverlay = true;
StateHasChanged(); StateHasChanged();
await Submit(); if (result is true) await Submit();
else await SaveDraft();
VisibleOverlay = false; VisibleOverlay = false;
StateHasChanged(); StateHasChanged();
@@ -498,10 +578,48 @@
return false; return false;
} }
/// <summary>
/// Salva il lavoro in corso solo sul dispositivo, marcato come da completare.
/// Niente chiamate al server e niente validazione: una bozza può essere incompleta.
/// </summary>
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() private async Task Submit()
{ {
await _form.Validate(); 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 #endregion
@@ -526,7 +644,8 @@
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
RecalcDirty(true); _attachmentsDirty = true;
RecalcDirty();
// Processa in background e aggiorna UI man mano (o a blocchi) // Processa in background e aggiorna UI man mano (o a blocchi)
_ = Task.Run(async () => _ = Task.Run(async () =>
@@ -599,7 +718,7 @@
return; return;
} }
var modal = await Dialog.OpenSelectArt(articoli); var modal = await WithChildDialog(() => Dialog.OpenSelectArt(articoli));
await InvokeAsync(() => await InvokeAsync(() =>
{ {
@@ -618,7 +737,11 @@
}) })
); );
await InvokeAsync(StateHasChanged); await InvokeAsync(() =>
{
RecalcDirty();
StateHasChanged();
});
} }
}); });
} }
@@ -631,7 +754,8 @@
AttachedList[index].ToRemove = true; AttachedList[index].ToRemove = true;
AttachedList.RemoveAt(index); AttachedList.RemoveAt(index);
RecalcDirty(true); _attachmentsDirty = true;
RecalcDirty();
StateHasChanged(); StateHasChanged();
} }
@@ -652,7 +776,7 @@
{ {
var activityDescriptions = await IntegryApiService.SuggestActivityDescription(Scheda.ActivityTypeId); 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)) if (modal is { Canceled: false, Data: string descrizione } && !string.IsNullOrWhiteSpace(descrizione))
{ {
@@ -701,14 +825,13 @@
if (art != null) if (art != null)
{ {
RecalcDirty(true);
Scheda.Articoli.Insert(0, new SchedaArticolo Scheda.Articoli.Insert(0, new SchedaArticolo
{ {
Barcode = art.Barcode, Barcode = art.Barcode,
Descrizione = art.Descrizione Descrizione = art.Descrizione
}); });
RecalcDirty();
StateHasChanged(); StateHasChanged();
} }
else else
@@ -748,8 +871,8 @@
var index = Scheda.Articoli.FindIndex(x => x.Barcode.Equals(barcode)); var index = Scheda.Articoli.FindIndex(x => x.Barcode.Equals(barcode));
if (index < 0) return; if (index < 0) return;
RecalcDirty(true);
Scheda.Articoli.RemoveAt(index); Scheda.Articoli.RemoveAt(index);
RecalcDirty();
StateHasChanged(); StateHasChanged();
} }
@@ -757,6 +880,16 @@
{ {
OnScannerService.OnNewScanSuccessful -= HandleNewScanSuccessful; OnScannerService.OnNewScanSuccessful -= HandleNewScanSuccessful;
OnScannerService.OnErrorScan -= OnErrorScan; 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();
} }
} }
@@ -26,6 +26,8 @@ public class BarcodeManager(
if (IsEanPeso(dto)) if (IsEanPeso(dto))
stringValue = DecodeEanPeso(stringValue); stringValue = DecodeEanPeso(stringValue);
else if (IsPlu(dto))
stringValue = DecodePlu(stringValue);
messenger.Send(new NewScannerMessage(stringValue)); messenger.Send(new NewScannerMessage(stringValue));
}, },
@@ -39,14 +41,29 @@ public class BarcodeManager(
(IsEtichetta128(barcodeScan) || IsEan13(barcodeScan)) && barcodeScan.StringValue is { Length: 13 } && (IsEtichetta128(barcodeScan) || IsEan13(barcodeScan)) && barcodeScan.StringValue is { Length: 13 } &&
barcodeScan.StringValue.StartsWith('2'); 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) => private static bool IsEtichetta128(BarcodeScanDto barcodeScan) =>
barcodeScan.Type is BarcodeType.CODE128 or BarcodeType.EAN128; barcodeScan.Type is BarcodeType.CODE128 or BarcodeType.EAN128;
private static bool IsCode128(BarcodeScanDto barcodeScan) =>
barcodeScan.Type == BarcodeType.CODE128;
private static bool IsEan13(BarcodeScanDto barcodeScan) => private static bool IsEan13(BarcodeScanDto barcodeScan) =>
barcodeScan.Type == BarcodeType.EAN13; barcodeScan.Type == BarcodeType.EAN13;
private static string DecodeEanPeso(string? barcode) 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);
} }
} }
+6
View File
@@ -21,6 +21,12 @@ public class Scheda : EntityBase<Scheda>
public List<SchedaArticolo> Articoli { get; set; } = []; public List<SchedaArticolo> Articoli { get; set; } = [];
public List<string>? ImageNames { get; set; } public List<string>? ImageNames { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool Bozza { get; set; }
public string? DescrizioneReparto { get; set; } public string? DescrizioneReparto { get; set; }
public string? ActivityTypeId { get; set; } public string? ActivityTypeId { get; set; }
public string? Note { get; set; } public string? Note { get; set; }
+18 -3
View File
@@ -14,19 +14,27 @@ public enum SyncState
DaInviare = 0, DaInviare = 0,
/// Inviata al server (ha un ActivityId). /// 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 class SchedaSyncHelper
{ {
public static SyncState GetSyncState(this Scheda scheda) => 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) => public static string ToLabel(this SyncState state) =>
state switch state switch
{ {
SyncState.DaInviare => "Da inviare", SyncState.DaInviare => "Da inviare",
SyncState.Inviato => "Inviato", SyncState.Inviato => "Inviato",
SyncState.Bozza => "Da completare",
_ => string.Empty _ => string.Empty
}; };
@@ -39,6 +47,7 @@ public static class SchedaSyncHelper
{ {
SyncState.DaInviare => Color.Primary, SyncState.DaInviare => Color.Primary,
SyncState.Inviato => Color.Success, SyncState.Inviato => Color.Success,
SyncState.Bozza => Color.Warning,
_ => Color.Default _ => Color.Default
}; };
@@ -47,10 +56,16 @@ public static class SchedaSyncHelper
{ {
SyncState.DaInviare => Icons.Material.Rounded.PhonelinkOff, SyncState.DaInviare => Icons.Material.Rounded.PhonelinkOff,
SyncState.Inviato => Icons.Material.Rounded.CloudDone, SyncState.Inviato => Icons.Material.Rounded.CloudDone,
SyncState.Bozza => Icons.Material.Rounded.EditNote,
_ => string.Empty _ => 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<Scheda> schede) => public static int CountDaInviare(this IEnumerable<Scheda> schede) =>
schede.Count(s => s.GetSyncState() == SyncState.DaInviare); schede.Count(s => s.GetSyncState() == SyncState.DaInviare);
/// Numero di schede lasciate a metà, da riprendere.
public static int CountBozze(this IEnumerable<Scheda> schede) =>
schede.Count(s => s.GetSyncState() == SyncState.Bozza);
} }
@@ -0,0 +1,42 @@
namespace SteUp.Shared.Core.Messages.System;
/// <summary>
/// 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.
/// </summary>
public class BackButtonService
{
private Func<Task>? _handler;
/// Registra il gestore corrente. Ritorna l'azione di deregistrazione.
public IDisposable Register(Func<Task> handler)
{
_handler = handler;
return new Registration(this, handler);
}
/// <summary>
/// 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.
/// </summary>
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<Task> handler) : IDisposable
{
public void Dispose()
{
// Non azzerare se nel frattempo si è registrato qualcun altro.
if (ReferenceEquals(owner._handler, handler)) owner._handler = null;
}
}
}
+35
View File
@@ -95,3 +95,38 @@ document.addEventListener("DOMContentLoaded", () => {
}, 600); }, 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);
}
})();