53 Commits

Author SHA1 Message Date
MarcoE a4e5709be6 Finish v1.2.0 2026-09-18 11:51:17 +02:00
MarcoE b368b8c10e Finish v1.2.0 2026-09-18 11:51:15 +02:00
MarcoE b73a3bc82e -> v1.2.0 (6) 2026-09-18 11:51:10 +02:00
MarcoE 70a0d0f5fa Rimetti i reparti richiudibili e il "+" sul reparto
Le schede di un reparto erano un elenco fisso: con quattro reparti aperti
la pagina diventava un rotolo, e per aggiungere una scheda a un reparto
preciso bisognava passare dal comando generale e ridichiarare il reparto.

La testata del reparto torna a essere un comando: si tocca per richiudere
il gruppo, e la freccia dice da che parte sta. I conteggi salgono nella
testata ("3 schede · 1 da inviare · 1 da completare") invece di stare nel
corpo: un reparto compattato non deve nascondere quello che gli manca.

Accanto torna il "+", che apre la scheda nuova con il reparto gia'
scelto. E' l'unica differenza con il "+" della barra, ed e' quella che
toglie un passaggio a chi sta lavorando su un reparto alla volta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:45:50 +02:00
MarcoE b9de075f45 Rimetti codice e indirizzo nella testata dell'ispezione
La testata mostrava il solo nome del punto vendita, con il codice
relegato a sottotitolo e l'indirizzo da nessuna parte. Torna com'era:
"01 - D11-PRIMO PREZZO - TERLIZZI LILLIUM" e sotto la via. Il codice sta
davanti al nome perche' e' quello che si confronta con il documento
cartaceo; il nome serve a riconoscere il negozio, l'indirizzo a essere
sicuri di stare in quello giusto.

Il titolo cosi' e' lungo, quindi la testata passa alla misura compatta e
puo' andare a capo fino a tre righe invece di troncare: un nome tagliato
a meta' non aiuta nessuno.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:45:50 +02:00
MarcoE c9b3a5034f Togli l'invito a segnalare il dettaglio dell'errore
Il dialog di errore chiedeva al rilevatore di "segnalare il dettaglio qui
sotto": una richiesta che scarica su chi sta lavorando in corsia il
compito di trascrivere una stringa tecnica, senza dirgli a chi. Il
dettaglio resta a video per chi lo deve leggere, ma nessuno gli chiede
piu' di farci qualcosa: quello che serve sapere e' che il lavoro non e'
andato perso e che puo' riprovare.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:21:14 +02:00
MarcoE 1392b0e6d1 Togli l'azzurro dalla palette
Il marchio e' corallo (#ec4c41), rosso profondo e bianco sporco: azzurro
non ce n'e'. Nell'app invece era il colore piu' diffuso — lo stato "in
corso", la voce attiva della barra, l'avatar, le righe scelte, il
pannello di riepilogo — e non stava per niente: era solo il tono che si
usa quando un colore serve e non si sa quale.

Il ruolo "info" passa alla famiglia dell'ancora navy, cioe' allo stesso
colore del testo forte: lo stato informativo smette di portarsi un colore
proprio e i colori restano ai toni che significano qualcosa (verde
inviato, ambra da completare, rosso da inviare) e all'accento.

Il pannello di riepilogo dell'ispezione era l'unico blocco tinto dell'app
e diventa bianco come ogni altro pannello: dentro, l'unico colore e'
adesso la barra di avanzamento.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:54:57 +02:00
MarcoE 13d5400104 Il comando della barra si adatta alla schermata
Il tasto al centro perde l'etichetta: non ne ha una sola, perche' quello
che fa lo decide la schermata sotto. Dentro un'ispezione apre le sue
azioni (nuova scheda, concludi, elimina); altrove apre una nuova
ispezione. Il glifo resta lo stesso e resta nello stesso punto: quello
che cambia e' cosa c'e' dietro, non dove mettere il dito.

Le azioni dell'ispezione lasciano quindi la testata, dove stavano dietro
un menu a tre punti, e tornano sotto il pollice.

Il collegamento passa da PageActionsService: la pagina si registra
finche' e' a video e si sfila uscendo, cosi' la barra non tiene in vita
schermate che non ci sono piu'. Chi non registra niente lascia al
comando il suo significato di default — un'ispezione conclusa non
registra, e il tasto non resta li' a non fare nulla.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:44:55 +02:00
MarcoE 9703fca306 Porta l'azione "nuova ispezione" dentro la barra di navigazione
Il "+" non galleggia piu' sopra la barra e non sta piu' nella testata: e'
la casella centrale della barra, alla stessa altezza delle altre due e
sotto il pollice. La barra guadagna un margine interno, cosi' le voci non
toccano il bordo stondato e la pastiglia d'accento e' concentrica al
guscio invece che incollata sopra.

L'azione vale la stessa cosa in ogni schermata, e senza rete resta al suo
posto spenta: una casella che scompare sposterebbe le altre due sotto il
dito.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:16:52 +02:00
MarcoE e3c9880d86 Porta l'accento sul bottone di accesso
"Accedi" era navy come l'ancora della struttura. E' l'unica azione della
schermata: porta l'accento come ogni azione primaria dell'app, e il navy resta
al testo e alla cornice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:02:08 +02:00
MarcoE 8cfce316fa Compatta i campi di accesso e togli il doppio bordo sul fuoco
Sul fuoco si vedevano due bordi: quello del campo piu' l'anello globale che
l'input riceve per conto suo, con raggio e colore diversi. Dove e' il
contenitore a segnare il fuoco — accesso, ricerca, barcode, gruppi del form —
l'anello sull'input non serve: il fuoco resta visibile dal bordo del campo.

Campi da 74 a 56 punti: padding piu' stretto, riga del valore alta quanto il
testo che contiene. Il bottone che mostra la password sborda nel padding invece
di alzare la riga, cosi' il campo password non e' piu' alto degli altri due e
il bersaglio resta da 44.

"Accedi" disabilitato era navy pieno con testo bianco: sembrava pronto, e il
tocco a vuoto sembrava un guasto. Ora e' chiaramente inerte.

Con la tastiera aperta il marchio finiva tagliato sopra e non si poteva
risalire: il pannello si centra con i margini automatici invece che con
align-items, cosi' lo scroll arriva anche in cima. Via anche la barra di
scorrimento, come nel resto delle schermate.

Verificata sul terminale, a riposo e col campo attivo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:07:49 +02:00
MarcoE 4c1ef3d846 Separa di nuovo i campi della schermata di accesso
Il pannello unico diviso da linee e' la forma di un elenco da consultare. Qui
si compilano tre dati diversi uno dopo l'altro, e lo stacco fra un campo e
l'altro serve a dire dove finisce quello che stai scrivendo.

Ogni campo torna a essere una superficie sua, con il bordo che si accende su
quello attivo. Le etichette restano ferme sopra il campo: e' il salto
dell'etichetta al primo tocco che faceva sembrare la schermata un modulo web,
non il fatto che i campi fossero separati.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:54:51 +02:00
MarcoE 66ed92299b Ridisegna la schermata di accesso
Ogni campo era un riquadro dentro il riquadro, con l'etichetta che saltava
sopra il testo al primo tocco: un modulo web, non una schermata. Ora i tre
campi stanno in un pannello solo diviso da una linea, come le liste del resto
dell'app, e l'etichetta sta ferma sopra il campo.

Lo sfondo passa alla superficie di lavoro, la stessa dello splash e della prima
schermata: l'avvio non cambia colore tre volte. L'errore di accesso usa il
riquadro di avviso gia' in uso dentro l'app invece di uno stile suo.

Non verificata sul terminale: la sessione sul dispositivo e' aperta e non l'ho
chiusa per non far perdere l'accesso.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:47:36 +02:00
MarcoE 4e61dc011f Rivedi la scelta del punto vendita
La schermata era dichiarata a tutto schermo ma usciva dentro un riquadro con lo
sfondo visibile ai lati: la misura da dialog di conferma si applicava anche ai
dialog FullScreen, perche' nulla la escludeva. Ora le schermate a tutto schermo
riempiono il display.

Ogni punto vendita era un <div> con role="button": adesso e' un bottone vero,
raggiungibile da tastiera e annunciato per quello che e', con il chevron che
dice che porta altrove. L'indirizzo si ferma a due righe: serve a distinguere
due negozi con lo stesso nome, non a essere letto per intero, e teneva l'elenco
lungo da scorrere.

I punti vendita su cui c'e' gia' un'ispezione aperta oggi lo dichiarano: quel
tocco riapre l'ispezione esistente invece di crearne una nuova, ed e' meglio
saperlo prima.

(Il CSS della riga e' entrato nel commit precedente, adiacente a quello delle
azioni di testata.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:47:36 +02:00
MarcoE 2e3a318132 Togli il + dalla barra e porta le azioni nella testata
Il "+" stava incastrato a destra nella barra di navigazione, che per il resto
contiene destinazioni. Cambiava significato da una schermata all'altra senza
mai dichiararlo, e su Ispezioni apriva un menu per far scegliere fra una cosa
sola. Il documento di stile esclude esplicitamente il FAB di serie, e la barra
torna a contenere solo le due destinazioni.

Su Ispezioni l'azione e' un bottone con il suo nome nella testata, e porta
dritta alla scelta del punto vendita senza passaggi intermedi. Lo stato vuoto
offre lo stesso comando invece di spiegare dove trovarlo.

Su Ispezione le azioni sono tre e con pesi diversi: restano in un bottom sheet,
aperto dalla testata. Eliminare un'ispezione ora chiede conferma — prima un
tocco cancellava ispezione e schede senza appello.

Le azioni vivono nella pagina che le esegue, quindi i messaggi che le
rimbalzavano dalla barra (NewSchedaService, CompleteInspectionService) non
hanno piu' mittente e se ne vanno.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:47:06 +02:00
MarcoE 101b68123c Sostituisci la tendina di reparto e motivo con un bottom sheet
La MudSelect apriva un riquadro bianco che galleggiava sopra le card bianche,
senza titolo, senza sfondo oscurato, con la barra di scorrimento del desktop e
le righe alte quanto una riga di testo. Era la cosa che piu' di tutte faceva
sembrare l'app una pagina web.

Ora il campo e' una riga tappabile che mostra il valore, e la scelta arriva da
un bottom sheet con il titolo del campo e righe da 56 px con il segno tondo
della scelta singola: la stessa forma degli altri selettori dell'app, usabile
in piedi e con i guanti.

Il confronto per capire quale opzione e' quella corrente passa per l'etichetta:
Scheda.Reparto ricostruisce l'oggetto al volo da codice e descrizione, quindi
non e' mai lo stesso oggetto di quelli in elenco e il confronto di riferimento
non avrebbe segnato mai nulla.

Reparto e motivo non passano piu' dalla validazione di MudForm, perche' non
sono piu' input: il controllo degli obbligatori e' esplicito e l'errore si vede
sul gruppo interessato, non solo nel messaggio in cima. Si accende dopo il primo
tentativo di salvataggio, non prima: un form rosso all'apertura accusa l'utente
di un errore che non ha ancora commesso.

La scelta fatta si segna col tono informativo: un bordo corallo intorno a un
riempimento azzurro si leggeva come un errore.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:46:48 +02:00
MarcoE ac501dc261 Metti l'anello di focus solo sui comandi
La regola valeva per qualunque elemento, e la WebView sposta il fuoco anche su
contenitori non interattivi: dopo la chiusura di un dialog restava un anello
azzurro intorno al titolo della testata, che indicava un bersaglio inesistente.
Ora lo prendono link, bottoni, campi e gli elementi dichiaratamente tappabili.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:46:28 +02:00
MarcoE 2d7e3c591e Dai una gerarchia ai pesi tipografici
Quasi tutto il testo dell'app stava fra 600 e 800: etichette, valori, indirizzi,
conteggi, voci di elenco. Un grassetto ovunque non segnala niente, e la pagina
Profilo era il caso peggiore — ogni riga gridava come un titolo.

La scala ora ha quattro gradini, documentati nei token:
  400  corpo, metadati, valori descrittivi
  500  etichette di campo, voci di lista, testo dei controlli
  600  stati brevi (badge, voce attiva), nomi in una riga di elenco
  700  titoli, uno solo per blocco

Sopra il 700 non si sale: l'800 dei titoli di card e dell'avatar e' sceso a 700.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:46:28 +02:00
MarcoE 7099f02b60 Ridai il titolo ai dialog di conferma
MudDialogProvider aveva NoHeader="true" come valore predefinito di tutti i
dialog. MudMessageBox rende il proprio titolo nella testata, quindi quel
valore lo sopprimeva ovunque: davanti a "Elimina" si leggeva solo il testo
delle conseguenze, senza la domanda.

Le schermate a tutto schermo (form scheda, scelta punto vendita, scelta
articoli) chiedono gia' NoHeader per conto loro nelle DialogOptions, quindi
toglierlo dal provider non le tocca.

I titoli ora dicono cosa sta per succedere invece del generico "Attenzione!":
"Eliminare la scheda?", "Uscire dall'account?", "Inviare le schede del
reparto?".

Le scelte in fondo al dialog si dispongono a destra e, sotto i 380 punti,
diventano una colonna a piena larghezza: con etichette lunghe come "Salva come
bozza" i bersagli non si comprimono.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:45:47 +02:00
MarcoE ca0aaf69b5 Allinea lo splash alla superficie dell'app
Lo splash era rosa (#FDECEA) mentre l'app apre su #E9EDF2: a ogni avvio si
vedeva un lampo di colore prima della prima schermata. Ora e' la stessa
superficie, e il passaggio non si nota.

Il tema dello splash deriva da quello generato da MAUI e chiede le icone di
sistema scure, coerenti con lo sfondo chiaro. Su Android 12+ lo splash e'
disegnato dal sistema, che dell'attributo puo' non tenere conto: sul terminale
di prova le icone restano chiare per la frazione di secondo dello splash.

Marchio da 128 a 112: su 360 punti di larghezza riempiva troppo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:45:34 +02:00
MarcoE 52b2994d3b Intercetta il tasto indietro dove passa davvero
Il salvataggio come bozza non e' mai scattato dal tasto indietro: la UI Blazor
non riceveva l'evento, e uscire da un form con modifiche in corso buttava via
il lavoro esattamente come prima che la bozza esistesse.

Il back non arrivava a OnBackPressed per due motivi diversi:
- da AndroidX Activity 1.6 il gesto passa dall'OnBackPressedDispatcher e il
  metodo dell'Activity non viene piu' invocato;
- con la navigazione a tre tasti il back arriva come evento di tastiera e la
  WebView lo intercetta per conto suo, tornando indietro nella propria
  cronologia, prima che Activity o dispatcher lo vedano.

Ora la chiave si ferma in DispatchKeyEvent, che passa prima della gerarchia di
view, e resta un callback sul dispatcher per la navigazione a gesti, dove
evento di tastiera non ce n'e'. Entrambi restano accesi solo finche' la UI ha
davvero qualcosa da dire: altrimenti il back si comporta come sempre.

I gestori diventano una pila, cosi' sopra il form puo' aprirsi un bottom sheet
e il back chiude prima quello senza portarsi via la registrazione sottostante.
ActionSheet si registra da solo mentre e' aperto.

Verificato sull'EDA52: back sul form con modifiche apre "Lavoro non salvato" e
salva la bozza; back sullo sheet chiude solo lo sheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:45:23 +02:00
MarcoE 811d8a86d8 Correzioni alla UI dopo la verifica sul terminale
Prime correzioni fatte guardando l'app su un Honeywell EDA52 (720x1440
@320dpi, cioe' 360 punti logici di larghezza) invece che a occhio.

- Barra di stato: era tinta di corallo da colorPrimaryDark, cioe' l'accento
  usato come decorazione della cornice di sistema. Ora si fonde con lo
  sfondo dell'app, con icone scure (WindowInsetsController).
- Gradino compatto sotto i 380px: la scala di STILE_UI e' tarata su telefoni
  piu' larghi, e a misura piena i titoli si troncavano e in una schermata
  entrava pochissimo lavoro. Le proporzioni restano, cambia la taglia.
  Sui telefoni normali non cambia nulla.
- Icone delle azioni costruttive: usavano il fondo del tono danger, quindi
  "Nuova scheda" e "Aggiungi una foto" erano identiche a "Elimina
  ispezione". Ora usano il tono info. Un --primary-surface non risolveva:
  il primario di SteUP e' gia' un rosso, quindi un suo fondo tenue resta
  indistinguibile da quello danger.
- Testata: il titolo puo' andare su due righe prima di troncare. Il nome del
  punto vendita e' l'ancora del compito, "D11-PRIMO PREZ..." non aiuta.
- Schermata ispezione: il nome del punto vendita non e' piu' ripetuto nel
  pannello di riepilogo, dove era gia' in testata.
- Padding dei gruppi di form e della barra azioni ridotti su schermo stretto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:52:31 +02:00
MarcoE 2a0054aa30 Rifacimento della UI secondo STILE_UI.md
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) <noreply@anthropic.com>
2026-09-17 11:09:26 +02:00
MarcoE 3a9174f6fc 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) <noreply@anthropic.com>
2026-09-17 10:32:41 +02:00
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
MarcoE 48e55226f3 Finish v1.1.1(5) 2026-07-22 14:50:59 +02:00
MarcoE 963eba1e84 Finish v1.1.1(5) 2026-07-22 14:50:59 +02:00
MarcoE 55c0e539b1 -> v1.1.1 (5) 2026-07-22 14:50:54 +02:00
MarcoE 5ec7468b1c Fix scanner barcode per i plu 2026-07-22 14:50:09 +02:00
MarcoE 74b671586f Fix duplicazione scheda quando si modificava 2026-07-22 13:06:49 +02:00
MarcoE b2c8174d6f Finish v1.1.0(4) 2026-07-14 14:25:56 +02:00
MarcoE ce704fa91b Finish v1.1.0(4) 2026-07-14 14:25:55 +02:00
MarcoE 2fad8557ba -> 1.1.0 (4) 2026-07-14 14:25:48 +02:00
MarcoE a81689bd56 Fix vari 2026-07-14 14:22:26 +02:00
MarcoE 4e13db24f6 Fix: nota descrittiva suggerita ora visibile subito nel campo note
Il MudTextField delle note ha DebounceInterval e non rifletteva una modifica
del Value fatta da codice. Aggiunta una @key sul campo che, incrementata
quando si applica il suggerimento, forza Blazor a ricreare il campo con la
nota selezionata gia impostata: la nota compare subito a video.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:57:02 +02:00
MarcoE c454474f49 Migliora IspezionePage e form scheda (design review impeccable)
Ispezione:
- Stato sync visibile: badge per-scheda (Da inviare/Inviato) e contatore
  "N da inviare" per reparto (nuovo SchedaSyncHelper)
- Completamento: progresso reale nell'overlay + spunta di successo;
  NON completa se una scheda fallisce (resta editabile, schede "Da inviare")
- Export reparto/completamento blindati: conferma + guardia anti doppio-tap,
  try/finally che libera sempre l'overlay
- Accento corallo unico per l'azione "invia"; target di tocco >=44px;
  empty state; solo il primo pannello espanso di default
- Tasto "Invia" nascosto sulle schede gia inviate
- Messaggio d'errore comprensibile in campo (dettaglio tecnico nei log)
- Fix OnSchedaModified (index >= 0)

Aspetto card:
- SchedaCard e InspectionCard: superficie Carta + hairline border + ombra
  morbida (nuovo token --card-box-shadow); pagina con tinta Grigio Campo
- SchedaCard compattata

Form scheda (ModalFormScheda):
- Tasto Salva/Aggiorna full-width
- Fix "Suggerisci note descrittive": la nota selezionata ora viene inserita
  nel campo (metodo async sul thread UI + OnAfterChangeValue)

StatusEnum: etichette in sentence-case

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:39:20 +02:00
MarcoE 9f4088eff3 Init claude e impeccable 2026-07-08 15:10:12 +02:00
MarcoE a04ccde2ec Spostato tasto salva 2026-07-06 15:00:38 +02:00
MarcoE a3d27c9ef8 Rimossa richiesta fotocamera - galleria e implementata apertura automatica fotocamera 2026-07-06 09:34:00 +02:00
MarcoE c2ff6bbbe5 Finish v1.0.2(3) 2026-06-17 10:48:45 +02:00
MarcoE 294c2d1dd0 Finish v1.0.2(3) 2026-06-17 10:48:44 +02:00
MarcoE 2a42036c29 -> v1.0.2 (3) 2026-06-17 10:48:37 +02:00
MarcoE 339828004d Aggiunta compatibilità con Honeywell CT40 2026-06-17 10:47:45 +02:00
MarcoE 76ac8f051e Finish v1.0.1(2) 2026-04-23 14:52:11 +02:00
MarcoE a1b4ff664d Finish v1.0.1(2) 2026-04-23 14:52:07 +02:00
MarcoE bc8582d3f5 -> v1.0.1 (2) 2026-04-23 14:51:59 +02:00
MarcoE 38ef7ebd33 Aggiunta compatibilità con Honeywell CT70-X1 2026-04-23 14:49:00 +02:00
MarcoE 98d2dda780 Aggiornamento librerie 2026-04-23 14:45:48 +02:00
MarcoE f09ea026bc Aggiunta cancellazione ispezione 2026-04-23 11:22:13 +02:00
118 changed files with 7204 additions and 2997 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"impeccable@impeccable": true
}
}
+14
View File
@@ -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"
}
]
}
}
@@ -0,0 +1,98 @@
---
target: IspezionePage
total_score: 18
p0_count: 1
p1_count: 3
timestamp: 2026-07-08T13-15-08Z
slug: steup-shared-components-pages-ispezionepage-razor
---
# Critique: IspezionePage
Method: dual-agent (A: design-review · B: detector+evidence). Browser visualization SKIPPED (MAUI Blazor Hybrid / WASM requires app build + auth + runtime state; no static HTML or dev server to inject into).
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| 1 | Visibility of System Status | 1 | SchedaCard never shows local-only vs already-sent (`ActivityId`); "offline è la verità" is invisible on its central screen |
| 2 | Match System / Real World | 3 | Clean Italian, correct `sched{a/e}` pluralization; but online "+" is icon-only, unlabelled |
| 3 | User Control and Freedom | 1 | ExportReparto and Completa push to server irreversibly, no undo; export has no confirm |
| 4 | Consistency and Standards | 1 | MudBlazor default color rainbow (Warning/Success/Info/Error) violates single-accent; MudFab is both tiny icon and wide labelled button |
| 5 | Error Prevention | 2 | Completa & Delete confirmed; Export has no confirm and no double-tap guard |
| 6 | Recognition Rather Than Recall | 2 | No per-scheda sync badge → user must remember which reparti were exported |
| 7 | Flexibility and Efficiency | 2 | Three overlapping sync paths (per-scheda / per-reparto / Completa), unclear which supersedes |
| 8 | Aesthetic and Minimalist Design | 2 | Multi-color FAB clusters + info chips add chroma noise |
| 9 | Error Recovery | 2 | OnError surfaces raw `e.Message` (+ Console.WriteLine) — technical, not field-friendly |
| 10 | Help and Documentation | 2 | No empty state — empty SchedeGrouped renders a blank body |
| **Total** | | **18/40** | **Poor — major UX work before it delivers its promise** |
## Anti-Patterns Verdict
**LLM assessment:** No gradients/glass/eyebrows/side-stripes. The tell is subtler: the screen wears MudBlazor's default demo palette. Actions form a semantic rainbow — Warning(orange)=Add, Success(green)=Export, Info(blue)=Edit, Error(red)=Delete — none overridden in MudTheme (only Primary/Secondary/Tertiary/TextPrimary are set). DESIGN.md's loudest rule (La Regola dell'Unico Accento — coral #ec4c41 as the sole action color) appears nowhere on the action surface. Reads as "component-library defaults, lightly arranged" — the enterprise-Blazor flavor of AI slop.
**Deterministic scan:** detect.mjs clean (exit 0, 0 findings) on IspezionePage.razor and on InspectionCard + SchedaCard. No false positives. The scanner targets HTML/CSS slop patterns; the real defects here are semantic (color roles, state visibility) which it structurally cannot see — so the LLM review carries this critique.
**Visual overlays:** none — browser visualization not applicable to this target.
## Overall Impression
The bones are good — reparto grouping with live counts, restrained rounded surfaces, honest completion-confirm copy — but the screen fails at the one thing the product exists for: making sync/offline state unambiguous. A rilevatore cannot glance at this screen and know what is still trapped on the phone. On top of that, the signature coral accent is absent from every action, replaced by library-default colors. Biggest single opportunity: make per-scheda sync state visible and route all actions through the single coral accent.
## What's Working
1. **Completion-confirm copy** is plain, honest, consequence-first — the "conferme che prevengono l'errore" principle done right.
2. **Reparto grouping with live count chips** + correct Italian pluralization — thoughtful, task-shaped IA.
3. **Restrained surface design** (1em panels, 20px cards, tonal #f5f5f5, flat-by-default) respects the "taccuino da campo" north star.
## Priority Issues
**[P0] Sync state is invisible on SchedaCard / the whole screen** — violates "Offline è la verità" (#1 principle)
- Why: nothing distinguishes a locally-saved scheda from one on the server (`ActivityId`); no reparto "X da inviare" signal. The core product promise breaks exactly where it matters.
- Fix: persistent per-scheda status badge (Locale / Da inviare / Inviato) — coral for needs-action, success green for inviato; reparto-level unsent counter.
- Command: /impeccable clarify
**[P1] Kill the MudBlazor color rainbow; restore the single accent** — violates Regola dell'Unico Accento
- Why: coral never appears on actions; "Add" literally looks like a warning; reads as default slop and destroys the accent's meaning.
- Fix: coral (Primary) for the single primary action per context; demote secondary actions to neutral/text; reserve red strictly for Delete.
- Command: /impeccable colorize
**[P1] Export reparto is irreversible, unconfirmed, and double-tap-able**
- Why: fires on FAB tap with no confirmation; overlay mounts a frame late, so a fast gloved double-tap can re-send the batch → duplicate server activities.
- Fix: confirm dialog matching Completa; disable/guard the FAB the instant it's tapped, not on next render.
- Command: /impeccable harden
**[P1] Primary actions sit in panel headers, not at thumb reach** — violates "Il pollice comanda"
- Why: Add/Export FABs live in TitleContent at the top of a scrolling list; Size.Small FABs ≈40px (under 44px); Delete sits ~1rem from Export.
- Fix: move primary create to bottom-right custom-mudfab; enforce ≥44px; separate Delete from Export (spacing or overflow menu).
- Command: /impeccable layout
**[P2] Opaque blocking wait + no success payoff at completion**
- Why: Completa loop shows a featureless full-screen spinner through save→upload-photos→complete with zero progress; SpinnerOverlay's built-in success-checkmark is never wired here — the emotional peak has no reassurance.
- Fix: step progress ("Invio scheda 3/7 · foto 2/4"); trigger the existing success animation on completion.
- Command: /impeccable animate
**[P3] All panels Expanded="true" + no empty state**
- Why: every reparto expands by default (scroll purgatory for many reparti); an empty inspection renders blank.
- Fix: expand only the first (or none) with counts visible; add empty state ("Nessuna scheda — tocca + per iniziare").
- Command: /impeccable distill
## Persona Red Flags
- **Casey (distracted mobile):** unlabelled orange "+" online; three different sync verbs; nothing shows what's already sent → taps Export twice or completes without exporting.
- **Sam (accessibility):** Size.Small FABs under 44px; meaning by color alone across a Warning/Info/Success/Error rainbow (color-blind users can't tell Add from Delete by hue); `info-subtitle` (gray-default) and `info-inspection-text` (text-secondary) muted gray violate the sun-contrast rule and likely fail AA.
- **Riley (stress tester):** double-tap Export → duplicate records; errors surface as raw exception strings; 40 schede across 8 reparti all expanded → scroll purgatory.
- **Marco the rilevatore (gloved, offline, sunlight):** offline, the Export FAB silently disappears with no "why"; the Add FAB stays enabled here yet the same action is Disabled in NavMenu (contradictory); in bright sun he cannot tell which schede are still trapped on the device — the exact fear the app exists to remove.
## Minor Observations
- InspectionCard runs an artificial `await Task.Delay(250)` before showing store data — a deliberate stall on a screen whose principle is "feedback immediato".
- scheda action cluster is center-justified — harder to acquire one-handed than edge-anchored.
- OnError keeps a `Console.WriteLine(e.Message)` beside the logger — dev residue.
- Photo/QR chips in SchedaCard add visual weight but aren't tappable.
## Questions to Consider
- If a rilevatore can't glance at this screen and instantly know what's still only on the phone, does the app deliver its single promise?
- Why do three separate "send" affordances exist (per-scheda, per-reparto, complete)? Could one honest sync model with clear per-item state replace all three?
- The success-checkmark animation was built but never wired here — cut or forgotten? Which is worse?
- If coral is "the color of action" but every action button is orange/green/blue, what is coral the color of on this screen?
@@ -0,0 +1,89 @@
---
target: IspezionePage
total_score: 34
p0_count: 0
p1_count: 1
timestamp: 2026-07-08T13-44-46Z
slug: steup-shared-components-pages-ispezionepage-razor
---
# Critique: IspezionePage (re-run post-fix)
Method: dual-agent (A: design-review · B: detector+evidence). Browser visualization SKIPPED (MAUI Blazor Hybrid / WASM requires app build + auth + runtime state).
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| 1 | Visibility of System Status | 4 | Per-scheda sync badge + reparto "da inviare" counter + granular completion progress + success checkmark |
| 2 | Match System / Real World | 3 | Italian & readable, but status shown ALL-CAPS and scheda title is a raw ActivityTypeId code |
| 3 | User Control and Freedom | 3 | Confirmations everywhere; no abort during long upload (sends irreversible by nature) |
| 4 | Consistency and Standards | 3 | coral=send now consistent; undermined by off-palette amber status chip |
| 5 | Error Prevention | 4 | Export confirms + double-tap guards; completion confirmed + _isBusy guard |
| 6 | Recognition Rather Than Recall | 4 | Grouped by reparto, counters, icons, labels |
| 7 | Flexibility and Efficiency | 3 | Three send paths (scheda/reparto/concludi) powerful but diffuse |
| 8 | Aesthetic and Minimalist | 3 | Clean; one expanded panel still stacks several action types |
| 9 | Error Recovery | 4 | OnError copy reassuring, tech detail to logs |
| 10 | Help and Documentation | 3 | Good empty-state; no hint why send buttons vanish offline |
| **Total** | | **34/40** | **Strong / ship-worthy with targeted fixes** |
## Anti-Patterns Verdict
**LLM assessment:** Largely free of AI slop; reads as a genuine field tool. Single-Accent Rule now respected on the action surface — coral is consistently "the send action" (per-scheda Invia, panel Esporta reparto, "da inviare" counter), neutral gray for non-send actions, red reserved for delete. SchedaSyncHelper encodes coral=azione-richiesta / green=fatto. No gradients/glass/side-stripes/identical-grid. One genuine rainbow leak remains: StatusEnumHelper.GetColor() paints the InspectionCard status chip Color.Warning (amber) for InCorso/Verifica — amber is not in the SteUP palette and competes with coral. Status labels are ALL-CAPS (violates Regola del Peso, non del Maiuscolo).
**Deterministic scan:** detect.mjs clean (exit 0) on IspezionePage.razor, SchedaCard.razor, SpinnerOverlay.razor. CSS scan: 3 advisory design-system-radius findings, all in SpinnerOverlay.razor.css (border-radius 100px/2px) — all confirmed false positives (icon geometry of the animated success-checkmark: arc masks + rounded line caps), not surface radii.
**Visual overlays:** none — browser visualization not applicable to this target.
## Overall Impression
Big jump from the first pass (18→34). The screen now delivers its core promise: sync state is unambiguous per-scheda and per-reparto, the completion moment is reassuring (real progress + success checkmark), destructive/irreversible sends are confirmed and double-tap-guarded, and the single coral accent finally means "send." The remaining issues are a real data-integrity bug in the completion loop and a few palette/copy nits — no longer structural.
## What's Working
1. SchedaSyncHelper sync-state system — per-scheda coral "Da inviare" / green "Inviato" badges (icon+text, not color-only) + reparto CountDaInviare() counter. The Offline-è-la-verità principle made concrete.
2. Completion progress + confirmation choreography — SetProgress messages, success checkmark with 1.8s dwell, irreversibility confirm guarded by _isBusy.
3. Guarded, reassuring exports — ExportReparto confirms, guards double-tap, keeps work local on failure with human copy, logs the stack trace.
## Priority Issues (remaining)
**[P1] Silent scheda skip on completion still shows success**
- What: In HandleCompleteInspection, `if (apiResponse == null) continue;` skips a scheda that failed to save, yet the loop still calls CompleteInspection, sets Completata, and shows "Ispezione inviata".
- Why: The inspection becomes non-editable while one or more schede never reached the server — exactly the data loss PRODUCT.md exists to prevent.
- Fix: track failed schede; if any failed, do NOT complete — surface "N schede non inviate, riprova" and keep them "Da inviare". Complete only when all succeed.
- Command: /impeccable harden
**[P2] Off-palette amber status chip**
- What: StatusEnumHelper.GetColor() returns Color.Warning (amber) for InCorso/Verifica (InspectionCard status chip).
- Why: Amber isn't in the DESIGN.md palette; a second attention color dilutes coral (Regola dell'Unico Accento).
- Fix: map InCorso/Verifica to Color.Default (neutral) or navy Secondary; keep green Completata, red Annullata.
- Command: /impeccable colorize
**[P3] ALL-CAPS status labels**
- What: ConvertToHumanReadable returns "IN CORSO", "COMPLETATA".
- Why: Violates Regola del Peso, non del Maiuscolo.
- Fix: sentence case ("In corso", "Completata"); rely on the chip's 700 weight.
**[P3] Scheda title is a raw code**
- What: `<b>@Scheda.ActivityTypeId</b>` as the card headline.
- Why: forces decoding an internal identifier in the field.
- Fix: show a human descrizione, fall back to the code only if none.
## Minor Observations
- OnSchedaModified uses `if (index > 0)` — skips index 0, so modifying the FIRST scheda in SchedeList won't replace it in-list (likely a `>= 0` bug). Pre-existing; now also on the post-export refresh path.
- Sync model collapsed to two states (DaInviare/Inviato) vs the three-state brief (Locale/Da inviare/Inviato) — acceptable, but "local draft" vs "ready to send" no longer distinguished.
- InspectionCard still has an artificial `await Task.Delay(250)` skeleton delay — fights "feedback immediato".
- InspectionCard address (info-subtitle, gray-darker, weight 600) is still gray, not ink — mild sun-legibility compromise (verify AA); verify amber-on-white too.
- Offline, all send buttons disappear with no "torna online per inviare" hint — badges show "da inviare" but no next step.
## Persona Red Flags (delta)
- Casey (mobile): RESOLVED — FABs Size.Medium (≥44px), flex-wrap. Remaining: per-reparto actions sit in panel TitleContent (top of expanded panel), drifting from the thumb zone on long lists.
- Sam (accessibility): RESOLVED — aria-labels on FABs; sync state icon+text not color-only. Remaining: ALL-CAPS status; verify amber-on-white and gray-darker address hit AA.
- Marco (gloved/offline/sun): RESOLVED — sync visibility + reparto counter answer "what hasn't gone yet." Remaining: offline the send buttons vanish with no explanation; address text still gray not ink.
## Questions to Consider
- If completion continues past a failed scheda and still locks the inspection, how does Marco discover which scheda didn't make it once editing is disabled?
- Should the total "da inviare" roll up onto the InspectionCard header, visible before expanding any reparto?
- Three send paths (per-scheda, per-reparto, global concludi) — does that dilute the single primary task? Could per-reparto export be the one canonical action?
+112
View File
@@ -0,0 +1,112 @@
{
"schemaVersion": 2,
"generatedAt": "2026-07-08T00:00:00.000Z",
"title": "Design System: SteUP",
"extensions": {
"colorMeta": {
"primary": { "role": "primary", "displayName": "Arancio-Rosso Allerta", "canonical": "#ec4c41", "tonalRamp": ["#3a0f0b", "#5e1712", "#8a2019", "#b62f24", "#ec4c41", "#f27a72", "#f7a9a3", "#fcdcd9"] },
"secondary": { "role": "secondary", "displayName": "Blu Profondo", "canonical": "#002339", "tonalRamp": ["#000c14", "#001622", "#002339", "#003653", "#0a5480", "#3f7ea8", "#87b3d0", "#d5e6f1"] },
"tertiary": { "role": "tertiary", "displayName": "Azzurro Tenue", "canonical": "#dff2ff", "tonalRamp": ["#12313f", "#1c5570", "#2b7ba3", "#4aa4d1", "#86c8e8", "#b3ddf3", "#dff2ff", "#f2faff"] },
"ink": { "role": "neutral", "displayName": "Inchiostro", "canonical": "#000000", "tonalRamp": ["#000000", "#1a1a1a", "#333333", "#4d4d4d", "#737373", "#999999", "#cccccc", "#f5f5f5"] },
"paper": { "role": "neutral", "displayName": "Carta", "canonical": "#ffffff", "tonalRamp": ["#c2c2c2", "#d1d1d1", "#e0e0e0", "#ededed", "#f5f5f5", "#fafafa", "#ffffff", "#ffffff"] },
"success": { "role": "semantic", "displayName": "Verde OK", "canonical": "#26b050", "tonalRamp": ["#0a3318", "#125025", "#1a7d38", "#26b050", "#4fca72", "#84dc9d", "#b7ecc6", "#e6f9ec"] },
"error": { "role": "semantic", "displayName": "Rosso Errore", "canonical": "#e50000", "tonalRamp": ["#3a0000", "#5e0000", "#8a0000", "#b60000", "#e50000", "#f24d4d", "#f79999", "#fcdcdc"] },
"focus-ring": { "role": "semantic", "displayName": "Blu Focus", "canonical": "#258cfb", "tonalRamp": ["#08213f", "#0c3a70", "#1257b6", "#258cfb", "#5aa9fc", "#8fc4fd", "#c4e0fe", "#eaf4ff"] }
},
"typographyMeta": {
"page-title": { "displayName": "Page Title", "purpose": "Titolo di schermata; ancora l'utente al compito corrente." },
"title": { "displayName": "Title", "purpose": "Intestazioni di sezione, titoli di dialog e message-box." },
"body": { "displayName": "Body", "purpose": "Testo corrente e dati; prosa a 65-75ch." },
"label": { "displayName": "Label", "purpose": "Etichette di pulsanti e controlli di form; peso 700 per presenza al tocco." }
},
"shadows": [
{ "name": "ombra-custom", "value": "1px 2px 5px hsl(from var(--mud-palette-overlay-dark) h s 40%)", "purpose": "Stacco morbido per card ed elementi che galleggiano sul contenuto." },
{ "name": "ombra-eccezione", "value": "1px 2px 5px rgba(0,0,0,0.3)", "purpose": "Rilievo leggermente più marcato per box di errore/eccezione." }
],
"motion": [
{ "name": "connection-slide", "value": "all 0.5s ease", "purpose": "Comparsa/scomparsa della barra di stato connessione (translateY)." },
{ "name": "ripple", "value": "ripple-effect 0.6s linear", "purpose": "Feedback fisico al tocco su pulsanti e contenitori tattili." },
{ "name": "spinner", "value": "l24 1s infinite linear", "purpose": "Loader di caricamento a doppio anello." }
],
"breakpoints": [
{ "name": "mobile", "value": "0px" }
]
},
"components": [
{
"name": "Primary Button",
"kind": "button",
"refersTo": "button-primary",
"description": "Azione primaria: fondo accento, etichetta bianca in grassetto, angoli molto arrotondati.",
"html": "<button class=\"ds-btn-primary\">Salva</button>",
"css": ".ds-btn-primary { font-family: Nunito, sans-serif; background: #ec4c41; color: #fff; font-weight: 700; font-size: 0.9375rem; border: none; border-radius: 20px; padding: 0.65rem 1.5rem; min-height: 44px; cursor: pointer; transition: filter 0.15s ease, transform 0.1s ease; } .ds-btn-primary:hover { filter: brightness(1.05); } .ds-btn-primary:focus-visible { outline: none; box-shadow: 0 0 0 0.1rem #fff, 0 0 0 0.25rem #258cfb; } .ds-btn-primary:active { transform: translateY(1px); filter: brightness(0.95); }"
},
{
"name": "FAB (Floating Action)",
"kind": "button",
"refersTo": "button-primary",
"description": "Azione primaria fissa in basso a destra, a portata di pollice; rispetta la safe-area.",
"html": "<button class=\"ds-fab\" aria-label=\"Nuova scheda\"><svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg></button>",
"css": ".ds-fab { font-family: Nunito, sans-serif; position: fixed; bottom: 4rem; right: 16px; width: 56px; height: 56px; border-radius: 50%; border: none; background: #ec4c41; color: #fff; display: inline-flex; align-items: center; justify-content: center; box-shadow: 1px 2px 5px rgba(0,0,0,0.3); cursor: pointer; transition: filter 0.15s ease, transform 0.1s ease; } .ds-fab:hover { filter: brightness(1.05); } .ds-fab:active { transform: translateY(1px); } .ds-fab:focus-visible { outline: none; box-shadow: 0 0 0 0.1rem #fff, 0 0 0 0.25rem #258cfb; }"
},
{
"name": "Input Card",
"kind": "input",
"refersTo": "input-card",
"description": "Campo a scheda su fondo grigio tonale, senza underline; etichetta a sinistra, valore a destra.",
"html": "<label class=\"ds-input-card\"><span class=\"ds-input-label\">Quantità</span><input class=\"ds-input-field\" type=\"text\" inputmode=\"numeric\" value=\"12\" /></label>",
"css": ".ds-input-card { font-family: Nunito, sans-serif; display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; width: 100%; background: #f5f5f5; border-radius: 9px; padding: 0.5rem 1rem; min-height: 44px; } .ds-input-label { font-weight: 700; color: #000; } .ds-input-field { font-family: Nunito, sans-serif; font-weight: 500; text-align: right; border: none; background: transparent; color: #000; outline: none; min-width: 0; } .ds-input-field:focus { outline: none; }"
},
{
"name": "Connection Status Bar",
"kind": "custom",
"refersTo": null,
"description": "Striscia di stato rete/servizio che scorre dall'alto: verde se OK, rosso se KO. Incarna 'Offline è la verità'.",
"html": "<div class=\"ds-connection ds-connection--ko\">Nessuna connessione — lavoro salvato in locale</div>",
"css": ".ds-connection { font-family: Nunito, sans-serif; display: flex; align-items: center; gap: 1rem; min-height: 35px; padding: 0 0.75rem; font-weight: 700; font-size: 1.05rem; color: #fff; transition: all 0.5s ease; } .ds-connection--ko { background: #e50000; } .ds-connection--ok { background: #26b050; }"
},
{
"name": "Panel / Card",
"kind": "card",
"refersTo": "panel",
"description": "Superficie di contenuto: carta bianca, angoli 1em, piatta di default, ombra solo se galleggia.",
"html": "<div class=\"ds-panel\"><h3 class=\"ds-panel-title\">Scheda articolo</h3><p class=\"ds-panel-body\">Dettagli della rilevazione.</p></div>",
"css": ".ds-panel { font-family: Nunito, sans-serif; background: #fff; color: #000; border-radius: 1em; padding: 1rem; } .ds-panel-title { font-weight: 700; font-size: 1.1rem; margin: 0 0 0.5rem; } .ds-panel-body { font-weight: 400; font-size: 0.875rem; line-height: 1.8; margin: 0; }"
}
],
"narrative": {
"northStar": "Il taccuino da campo",
"overview": "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. Il sistema è tattile e sicuro: bersagli ampi per il pollice, una sola mano e i guanti; feedback fisico immediato a ogni tocco e scansione. Lo stato — salvato in locale, in attesa di sync, inviato, completato — non è mai ambiguo. Rifiuta il gestionale datato e denso e l'app consumer social/giocosa: è uno strumento professionale.",
"keyCharacteristics": [
"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)"
],
"rules": [
{ "name": "La Regola dell'Unico Accento", "body": "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.", "section": "colors" },
{ "name": "La Regola del Contrasto da Sole", "body": "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.", "section": "colors" },
{ "name": "La Regola del Peso, non del Maiuscolo", "body": "L'enfasi si ottiene con il peso (700/800) e la dimensione, non con il maiuscolo spaziato. Niente eyebrow maiuscoli tracciati sopra le sezioni.", "section": "typography" },
{ "name": "La Regola del Piatto-di-Default", "body": "Le superfici sono piatte a riposo. L'ombra compare solo quando un elemento deve galleggiare (overlay, dialog, card sollevata) o rispondere a uno stato.", "section": "elevation" }
],
"dos": [
"Do usare l'Arancio-Rosso Allerta (#ec4c41) solo per azione e stato attivo; mantenerlo raro.",
"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).",
"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."
],
"donts": [
"Don't costruire un gestionale datato e denso: niente tabelle fittissime, testo minuscolo, 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': 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, glassmorphism decorativo, griglie di card tutte identiche, o eyebrow maiuscoli su ogni sezione.",
"Don't introdurre animazioni di scena orchestrate al caricamento: il movimento serve allo stato, non allo spettacolo.",
"Don't reinventare affordance standard (scrollbar strane, controlli di form non convenzionali) per 'carattere'."
]
}
}
+91
View File
@@ -0,0 +1,91 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
SteUP is an Integry .NET MAUI Blazor Hybrid app for field inspections ("ispezioni") of retail stores — the operator scans product barcodes (including via Honeywell handheld scanners), fills in inspection sheets ("schede"), attaches photos, and syncs with the Integry backend. Work is stored locally (offline-first via SQLite) and pushed to the server. The same Blazor UI also runs as a standalone WebAssembly app.
> Note: `README.md` is the leftover Microsoft sample template and does NOT describe this app. Ignore it.
## Design Context
For any UI/design work, read **`PRODUCT.md`** (strategy) and **`DESIGN.md`** (visual system) at the repo root first; they are the source of truth for the impeccable design skill.
- **Register:** product (a field tool; the UI serves the task, it is not a marketing surface).
- **Users:** field inspectors ("rilevatori") in stores — one-handed / gloved, outdoors in sunlight, often offline.
- **North Star:** "Il taccuino da campo" — works like paper and ink: always, offline, readable in full sun.
- **Core principles:** offline state is never ambiguous · thumb-first (touch ≥44px) · high contrast for sunlight · immediate feedback per scan · fewer steps, fewer errors.
- **Visual system:** MudBlazor · single font **Nunito** · accent `#ec4c41` (Arancio-Rosso Allerta, action/state only), anchor `#002339` (Blu Profondo) · rounded (9px/20px/1em) · locale `it-IT`.
- **Anti-references:** no dated/dense back-office look; no playful consumer-social aesthetic.
## Build & run
`global.json` pins SDK 8.0.0 with `rollForward: latestMajor`, but all projects target **.NET 10** (MAUI targets `net10.0-android` / `net10.0-ios`). A .NET 10 SDK must be installed.
NuGet restores from a **private Integry feed only** (`NuGet.Config` clears nuget.org and uses `nuget.studioml.it`). The `IntegryApiClient.*` packages come from there.
```bash
# MAUI app (primary target) — Android
dotnet build SteUp.Maui/SteUp.Maui.csproj -t:Run -f net10.0-android
# MAUI app — iOS (requires Mac build host; codesigning wired in csproj)
dotnet build SteUp.Maui/SteUp.Maui.csproj -f net10.0-ios
# Web (Blazor WASM) app
dotnet run --project SteUp.Web/SteUp.Web.csproj
```
There is **no test project** in the solution. There is no lint config beyond `Nullable`/`ImplicitUsings` enabled per csproj.
## Projects (5)
- **SteUp.Shared** — Razor Class Library holding almost everything: all Blazor pages/components, DTOs, entities, enums, the auth state provider, API service wrappers, messaging, and business orchestration (`SteupDataService`). Both MAUI and Web reference it. Uses MudBlazor + MudExtensions for UI.
- **SteUp.Maui** — the native host. Owns platform services (DI registration in `Core/CoreModule.cs`), the `BlazorWebView` host (`MainPage.xaml.cs`), file/logging/network/scanner implementations, and app config.
- **SteUp.Web** — Blazor WebAssembly host of the same shared UI. Its `Program.cs` mirrors a subset of the MAUI DI setup.
- **SteUp.Data** — EF Core + SQLite local database (`AppDbContext`, migrations, `IspezioniService`). References Shared for the entity types.
- **Steup.HoneywellScanner** — Android-only binding library wrapping Honeywell `DataCollection.aar`. Referenced by MAUI only on the Android target.
## Architecture
### Interface / implementation split (important convention)
Interfaces live in **SteUp.Shared** (`Core/Interface/...`) so the shared UI depends only on abstractions. Concrete platform implementations live in **SteUp.Maui** (`Core/Services`, `Core/System`) and are registered per-host:
- MAUI wires everything in `SteUp.Maui/Core/CoreModule.cs` via `Register*` extension methods called from `MauiProgram.cs`.
- Web wires a smaller subset directly in `SteUp.Web/Program.cs` (only `IFormFactor` + auth; it has no local DB, scanner, or file manager).
When adding a service consumed by the shared UI: define the interface in `SteUp.Shared/Core/Interface`, implement it in the host, and register it in **both** `CoreModule.cs` and (if the Web app needs it) `Program.cs`.
### Auth
`AppAuthenticationStateProvider` (Shared) bridges Integry's `IUserSession`/`IUserAccountService` (from `IntegryApiClient`) to Blazor's `AuthenticationStateProvider`, building `ClaimsPrincipal` from the JWT. Both hosts call `builder.UseIntegry(appToken, useLoginAzienda: true)` — the `AppToken` is the same GUID in `MauiProgram.cs` and `SteUp.Web/Program.cs`.
### Backend calls
Two service wrappers over `IIntegryApiRestClient`:
- `IntegrySteupService` — the app-specific `steup/*` endpoints (retrieve punti vendita, reparti, griglia PLU, barcode lookup, save/complete/delete schede, upload attachments).
- `IntegryApiService` — generic Integry endpoints (system health, activity descriptions, email).
### Offline-first data flow
`SteupDataService` (Shared) is the orchestrator: on `Init()` it runs EF migrations (`DbInitializer.InitializeAsync``db.Database.MigrateAsync()`), loads reference data from the API, reconciles inspection status, and prunes closed inspections older than 60 days. Local reads/writes go through `IspezioniService` (SteUp.Data).
### Local DB model
`AppDbContext`: `Ispezione``Scheda``SchedaArticolo`. `Ispezione` has a **composite key** `(CodMdep, Data, Rilevatore)` which is also the FK from `Scheda`. `Scheda.ImageNames` is a `List<string>` persisted as JSON via a value converter. DB file: `steup_db.db3` in `FileSystem.AppDataDirectory` (see `DbPathProvider`).
Add a migration (from repo root; `AppDbContextFactory` provides the design-time context using a throwaway `design-time.db3`):
```bash
dotnet ef migrations add <Name> --project SteUp.Data
```
Migrations are applied automatically at app startup, not via `database update`.
### Messaging (in-app events)
Uses `CommunityToolkit.Mvvm` `WeakReferenceMessenger`. Singleton "services" in `Shared/Core/Messages` (`NewSchedaService`, `OnScannerService`, `CompleteInspectionService`) publish/relay messages that decouple the barcode scanner and page components.
### Barcode scanning
`IBarcodeReaderService` is implemented by `HoneywellScannerService` (MAUI, partial class with Android-specific parts + the Honeywell binding lib). `IBarcodeManager`/`BarcodeManager` (Shared) is the host-agnostic front the UI talks to. Scans flow to the UI through the messaging services above.
### Attached images
Photos are served into the `BlazorWebView` through a custom scheme: `MainPage.xaml.cs` intercepts `WebResourceRequested` for `https://localfiles/attached/<file>` and streams files from `CacheDirectory/attached` (with path-traversal guards).
## Error tracking
Sentry is configured in both hosts (`.UseSentry(...)` in MAUI, Sentry package in Shared). MAUI also has a `GlobalExceptionHandler` (registered in `App.xaml.cs`) and a `FileLogger`/`FileLoggerProvider` writing to `AppDataDirectory/logs`.
## Versioning
App version lives in `SteUp.Maui.csproj` (`ApplicationDisplayVersion` + `ApplicationVersion`). Commit messages track releases (e.g. "-> v1.0.2 (3)"). Bump both when releasing. `ApplicationId` is `it.integry.SteUp`.
+270
View File
@@ -0,0 +1,270 @@
---
name: SteUP
description: Strumento da campo per ispezioni nei punti vendita — solido, chiaro, offline-first.
colors:
primary: "#ec4c41"
primary-pressed: "#d23a30"
primary-dark: "#002339"
on-primary: "#ffffff"
workspace: "#e9edf2"
surface: "#ffffff"
surface-muted: "#f4f6f9"
divider: "#d9e0e8"
ink: "#000000"
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:
headline-medium:
fontFamily: "Inter, sans-serif"
fontSize: "34px"
fontWeight: 700
lineHeight: 1.2
letterSpacing: "-0.02em"
headline-small:
fontFamily: "Inter, sans-serif"
fontSize: "28px"
fontWeight: 700
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-large:
fontFamily: "Inter, sans-serif"
fontSize: "16px"
fontWeight: 400
lineHeight: 1.5
letterSpacing: "normal"
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.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:
compact: "8px"
control: "16px"
surface: "20px"
sheet: "24px"
pill: "999px"
spacing:
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.on-primary}"
rounded: "{rounded.control}"
typography: "{typography.label-large}"
padding: "0 24px"
button-outlined:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
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
## 1. Overview
**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.
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.
**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
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.
### 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.
### 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.
### 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.
### 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.
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.** Il corallo appare solo su azione o stato attivo, mai come decorazione.
**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
**Famiglia unica: Inter** (variabile 100900), **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.
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 (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
**Zero.** Le superfici sono piatte: nessuna ombra, nessun `surface-tint`. La profondità è tonale (`surface` su `workspace`) più lo spazio.
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.
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
- **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 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. 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 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** 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.
+38
View File
@@ -0,0 +1,38 @@
# Product
## Register
product
## Users
Utente primario: **il rilevatore in campo**. Gira i punti vendita con un telefono o uno scanner Honeywell in mano, spesso in piedi, in movimento, all'aperto con luce forte, a volte con una mano sola o con i guanti, e con connessione di rete incerta o assente. Il suo compito durante il turno: scansionare i barcode degli articoli, compilare le schede di ispezione, allegare foto e chiudere l'ispezione. Strumento a uso interno Integry (non rivolto a clienti esterni), quindi contano più l'efficienza e la fiducia operativa che l'identità di marca.
## Product Purpose
SteUP è lo strumento da campo per le ispezioni dei punti vendita. Esiste per far completare le rilevazioni in modo rapido, accurato e senza perdita di dati, anche quando manca la rete. Il lavoro viene salvato localmente (offline-first, SQLite sul dispositivo) e sincronizzato con il backend Integry quando la connessione torna. Successo = il rilevatore finisce il turno certo che ogni scheda sia stata registrata e inviata, senza mai dover ripetere il lavoro per un dato perso o un dubbio sullo stato.
## Brand Personality
Solida, chiara, senza fronzoli. È uno strumento da lavoro, non una vetrina: nitido, diretto, prevedibile. La fiducia nasce dalla robustezza percepita e dalla leggibilità dello stato, non dalla decorazione. Tono asciutto e concreto nei testi (etichette, conferme, errori). Coerente con l'identità esistente: corallo (`#ec4c41`) come colore d'azione, navy (`#002339`) come ancora, superfici pulite e arrotondate.
## Anti-references
- **Gestionale datato/denso.** Niente look da ERP: tabelle fittissime, testo minuscolo, form infiniti, grigio ovunque, densità che non serve al compito da campo.
- **Consumer social/giocoso.** Niente estetica colorata/ludica da app social. Resta uno strumento professionale.
- Valgono inoltre i divieti trasversali di impeccable: niente gradienti decorativi, niente testo con gradiente, niente glassmorphism di default, niente griglie di card tutte identiche, niente eyebrow maiuscoli su ogni sezione.
## Design Principles
- **Offline è la verità.** Lo stato di salvataggio e sincronizzazione non è mai ambiguo. Il rilevatore deve sempre poter capire, senza pensarci, cosa è salvato in locale e cosa è arrivato al server.
- **Il pollice comanda.** Progettato per l'uso a una mano, con i guanti, in movimento: target di tocco ampi (≥44px), azioni primarie a portata di pollice, poco affollamento, niente bersagli piccoli.
- **Leggibile al sole.** Contrasto alto e testo generoso come default, non come opzione. La leggibilità all'aperto viene prima dell'eleganza del grigio chiaro.
- **Feedback immediato a ogni scan.** Ogni scansione e ogni tocco producono una risposta percepibile e istantanea; l'app conferma, non fa attendere.
- **Meno passaggi, meno errori.** Il percorso più breve per completare una scheda corretta. Ogni schermata ha un compito primario evidente; le conferme prevengono l'errore invece di segnalarlo dopo.
## Accessibility & Inclusion
- **Uso all'aperto:** alto contrasto e dimensioni testo generose per la luce diretta del sole; evitare testo grigio chiaro su fondi tenui.
- **Uso a una mano / con guanti:** target di tocco ≥44px, spaziatura sufficiente tra i controlli, azioni chiave raggiungibili in basso.
- **Base WCAG AA come buona pratica:** corpo testo ≥4.5:1, focus visibili, rispetto di `prefers-reduced-motion`.
- Interfaccia in **italiano** (locale `it-IT`).
+304
View File
@@ -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 ` · ` (spaziopunto mediospazio) 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 ~110120 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.
@@ -117,6 +117,10 @@ public class IspezioniService(AppDbContext db) : IIspezioniService
/// </summary> /// </summary>
public async Task<bool> DeleteIspezioneAsync(string codMdep, DateTime data, string rilevatore) public async Task<bool> DeleteIspezioneAsync(string codMdep, DateTime data, string rilevatore)
{ {
var schede = await GetAllSchedeOfIspezioneAsync(codMdep, data, rilevatore);
if (schede.Count > 0)
throw new Exception("Impossibile cancellare l'ispezione perché ci sono schede collegate.");
var ispezione = await db.Ispezioni var ispezione = await db.Ispezioni
.FirstOrDefaultAsync(x => .FirstOrDefaultAsync(x =>
x.CodMdep == codMdep && x.CodMdep == codMdep &&
@@ -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");
+3 -3
View File
@@ -7,12 +7,12 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.3" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.11" /> <PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.11" />
</ItemGroup> </ItemGroup>
+3 -4
View File
@@ -17,9 +17,8 @@ using SteUp.Shared.Core.Interface.IntegryApi;
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.Interface.System.Network; using SteUp.Shared.Core.Interface.System.Network;
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.System;
using SteUp.Shared.Core.Services; using SteUp.Shared.Core.Services;
namespace SteUp.Maui.Core; namespace SteUp.Maui.Core;
@@ -62,9 +61,9 @@ public static class CoreModule
public void RegisterMessageServices() public void RegisterMessageServices()
{ {
builder.Services.AddSingleton<IMessenger, WeakReferenceMessenger>(); builder.Services.AddSingleton<IMessenger, WeakReferenceMessenger>();
builder.Services.AddSingleton<NewSchedaService>();
builder.Services.AddSingleton<OnScannerService>(); builder.Services.AddSingleton<OnScannerService>();
builder.Services.AddSingleton<CompleteInspectionService>(); builder.Services.AddSingleton<BackButtonService>();
builder.Services.AddSingleton<PageActionsService>();
} }
public void RegisterDbServices() public void RegisterDbServices()
@@ -0,0 +1,106 @@
using SkiaSharp;
namespace SteUp.Maui.Core.Services;
/// <summary>
/// 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 (<see cref="SKBitmap.Decode(SKCodec)"/>)
/// ignora quel tag, quindi senza questo passaggio le foto scattate ruotate finiscono
/// storte nelle miniature e sul server.
/// </summary>
public static class ImageOrientation
{
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// Versione dritta di una bitmap già decodificata. Restituisce sempre una nuova
/// bitmap, anche quando non serve ruotare, così chi chiama ne possiede una sola.
/// </summary>
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;
}
}
+7 -3
View File
@@ -19,11 +19,15 @@ public static class ImageThumb
throw new InvalidOperationException("Formato immagine non supportato o file corrotto."); throw new InvalidOperationException("Formato immagine non supportato o file corrotto.");
// Decodifica // Decodifica
var info = codec.Info; using var decoded = SKBitmap.Decode(codec);
using var bitmap = SKBitmap.Decode(codec); if (decoded is null)
if (bitmap is null)
throw new InvalidOperationException("Impossibile decodificare l'immagine."); 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 // Calcola resize mantenendo aspect ratio
var w = bitmap.Width; var w = bitmap.Width;
var h = bitmap.Height; var h = bitmap.Height;
+10 -2
View File
@@ -1,3 +1,4 @@
using SteUp.Maui.Core.Services;
using SteUp.Shared.Core.Dto; using SteUp.Shared.Core.Dto;
namespace SteUp.Maui.Core.Utility; namespace SteUp.Maui.Core.Utility;
@@ -10,13 +11,20 @@ public static class UtilityFile
using var ms = new MemoryStream(); using var ms = new MemoryStream();
await stream.CopyToAsync(ms); 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 return new AttachedDto
{ {
Name = file.FileName, Name = file.FileName,
Path = file.FullPath, Path = file.FullPath,
MimeType = file.ContentType, MimeType = file.ContentType,
DimensionBytes = ms.Length, DimensionBytes = bytes.Length,
FileBytes = ms.ToArray(), FileBytes = bytes,
Type = type Type = type
}; };
} }
+1 -1
View File
@@ -4,7 +4,7 @@
xmlns:shared="clr-namespace:SteUp.Shared;assembly=SteUp.Shared" xmlns:shared="clr-namespace:SteUp.Shared;assembly=SteUp.Shared"
x:Class="SteUp.Maui.MainPage" x:Class="SteUp.Maui.MainPage"
SafeAreaEdges="All" SafeAreaEdges="All"
BackgroundColor="{DynamicResource PageBackgroundColor}"> BackgroundColor="#E9EDF2">
<BlazorWebView x:Name="BlazorWebView" HostPage="wwwroot/index.html"> <BlazorWebView x:Name="BlazorWebView" HostPage="wwwroot/index.html">
<BlazorWebView.RootComponents> <BlazorWebView.RootComponents>
@@ -1,6 +1,7 @@
using Android.OS; using Android.OS;
using Com.Honeywell.Aidc; using Com.Honeywell.Aidc;
using SteUp.Shared.Core.BarcodeReader.Dto; using SteUp.Shared.Core.BarcodeReader.Dto;
using SteUp.Shared.Core.BarcodeReader.Enum;
namespace SteUp.Maui.Core.System; namespace SteUp.Maui.Core.System;
@@ -9,7 +10,7 @@ public partial class HoneywellScannerService
private AidcManager? _aidcManager; private AidcManager? _aidcManager;
private BarcodeReader? _barcodeReader; private BarcodeReader? _barcodeReader;
private static readonly List<string> CompatibleModels = ["EDA50", "EDA51", "EDA52", "CT60", "CT40XP"]; private static readonly List<string> CompatibleModels = ["EDA50", "EDA51", "EDA52", "CT60", "CT40", "CT40XP", "CT70-X1"];
public partial bool IsRightAdapter() public partial bool IsRightAdapter()
{ {
@@ -87,13 +88,40 @@ public partial class HoneywellScannerService
var dto = new BarcodeScanDto var dto = new BarcodeScanDto
{ {
StringValue = e.BarcodeData, StringValue = e.BarcodeData,
Type = e.CodeId, Type = MapCodeId(e.CodeId),
Name = e.CodeId Name = e.CodeId
}; };
OnScanSuccessful?.Invoke(dto); OnScanSuccessful?.Invoke(dto);
} }
// Honeywell restituisce il simbologia tramite un Code ID di un solo carattere (getCodeId),
// non un enum. Lo mappiamo su BarcodeType. Ai fini dell'app conta distinguere
// EAN13 / CODE128 / EAN128 (vedi BarcodeManager.IsEanPeso); il resto è best-effort.
private static BarcodeType MapCodeId(string? codeId) => codeId switch
{
"a" => BarcodeType.CODABAR,
"b" => BarcodeType.CODE39,
"c" => BarcodeType.UPCA,
"d" => BarcodeType.EAN13,
"D" => BarcodeType.EAN8,
"e" => BarcodeType.INTERLEAVED_2OF5,
"E" => BarcodeType.UPCE,
"g" => BarcodeType.MSI,
"h" => BarcodeType.CODE11,
"i" => BarcodeType.CODE93,
"j" => BarcodeType.CODE128, // Code 128 e GS1-128 condividono il Code ID 'j'
"I" => BarcodeType.EAN128, // alcune generazioni riportano GS1-128 come 'I'
"r" => BarcodeType.PDF417,
"R" => BarcodeType.MICRO_PDF417,
"s" => BarcodeType.QR,
"w" => BarcodeType.DATAMATRIX,
"x" => BarcodeType.MAXICODE,
"y" => BarcodeType.GS1_DATABAR_14,
"z" => BarcodeType.AZTEC,
_ => BarcodeType.NIL
};
private static Dictionary<string, Java.Lang.Object> GetDefaultProperties() => new() private static Dictionary<string, Java.Lang.Object> GetDefaultProperties() => new()
{ {
{ BarcodeReader.PropertyEan8Enabled, true }, { BarcodeReader.PropertyEan8Enabled, true },
@@ -136,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)
{
}
} }
} }
+133 -1
View File
@@ -1,14 +1,146 @@
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/SteUp.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
{ {
private BackButtonService? _backButton;
private BlazorBackCallback? _backCallback;
protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
ApplySystemBarsAppearance();
HookBackButton();
// 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());
}
/// <summary>
/// Le barre di sistema si fondono con lo sfondo chiaro dell'app: icone
/// scure su fondo chiaro. Senza questo la barra di stato resta tinta
/// dell'accento, che nel sistema di design indica l'azione, non la
/// cornice.
/// </summary>
private void ApplySystemBarsAppearance()
{
if (Window is null) return;
var controller = WindowCompat.GetInsetsController(Window, Window.DecorView);
controller.AppearanceLightStatusBars = true;
controller.AppearanceLightNavigationBars = true;
}
/// <summary>
/// Il tasto indietro va intercettato sul dispatcher di AndroidX, non
/// sovrascrivendo OnBackPressed: da AndroidX Activity 1.6 il gesto passa
/// tutto di li' e il metodo dell'Activity non viene piu' invocato — la
/// UI Blazor non riceveva mai il back e il lavoro non salvato del form
/// se ne andava con la navigazione.
///
/// Il callback resta acceso solo finche' c'e' davvero qualcuno in
/// ascolto: quando la UI non ha nulla da dire, il back torna a
/// comportarsi come sempre invece di essere inghiottito.
/// </summary>
private void HookBackButton()
{
_backButton = IPlatformApplication.Current?.Services.GetService<BackButtonService>();
if (_backButton is null) return;
_backCallback = new BlazorBackCallback(_backButton);
OnBackPressedDispatcher.AddCallback(this, _backCallback);
_backCallback.Enabled = _backButton.HasHandlers;
_backButton.HandlersChanged += OnBackHandlersChanged;
}
private void OnBackHandlersChanged(bool hasHandlers)
{
if (_backCallback is null) return;
_backCallback.Enabled = hasHandlers;
if (!hasHandlers) return;
// Il dispatcher consulta i callback dall'ultimo registrato al primo,
// e BlazorWebView registra il suo (che fa tornare indietro la
// cronologia della WebView) quando l'handler viene creato, cioe' dopo
// OnCreate. Ri-registrandoci nel momento in cui la UI ha davvero
// qualcosa da dire torniamo in cima e il back arriva prima a noi.
_backCallback.Remove();
OnBackPressedDispatcher.AddCallback(this, _backCallback);
}
/// <summary>
/// Con la navigazione a tre tasti il back arriva come evento di tastiera
/// e la WebView lo intercetta per conto suo, tornando indietro nella
/// propria cronologia: ne' l'Activity ne' il dispatcher lo vedono mai.
/// Qui la chiave passa prima della gerarchia di view, quindi e' l'unico
/// punto in cui possiamo fermarla mentre un form ha lavoro non salvato.
/// Il callback sul dispatcher resta per la navigazione a gesti, dove
/// evento di tastiera non ce n'e'.
/// </summary>
public override bool DispatchKeyEvent(KeyEvent? e)
{
if (e?.KeyCode == Keycode.Back && _backButton?.HasHandlers == true)
{
// Si consuma anche il DOWN: lasciandolo passare la WebView
// reagirebbe lo stesso.
if (e.Action == KeyEventActions.Up) _backButton.TryHandleBackPressed();
return true;
}
return base.DispatchKeyEvent(e);
}
protected override void OnDestroy()
{
if (_backButton is not null) _backButton.HandlersChanged -= OnBackHandlersChanged;
base.OnDestroy();
}
private sealed class BlazorBackCallback(BackButtonService service)
: AndroidX.Activity.OnBackPressedCallback(false)
{
public override void HandleOnBackPressed() => service.TryHandleBackPressed();
}
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;
}
}
} }
} }
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- windowLightNavigationBar esiste solo da API 27 in su. -->
<style name="SteUp.SplashTheme" parent="Maui.SplashTheme">
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar">true</item>
</style>
</resources>
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="colorPrimary">#ec4c41</color> <color name="colorPrimary">#ec4c41</color>
<color name="colorPrimaryDark">#d02315</color> <!-- Barra di stato: si fonde con lo sfondo dell'app. Il corallo e' il
<color name="colorAccent">#d02315</color> colore dell'azione, non una decorazione della cornice di sistema. -->
<color name="colorPrimaryDark">#e9edf2</color>
<color name="colorAccent">#ec4c41</color>
</resources> </resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Tema dello splash. Deriva da quello generato da MAUI e cambia solo
l'aspetto delle barre di sistema: senza questo lo splash mostra icone
chiare, e all'apparire della UI Blazor - che le vuole scure - si vede
un ribaltamento di colore a ogni avvio.
-->
<style name="SteUp.SplashTheme" parent="Maui.SplashTheme">
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>
+14 -12
View File
@@ -30,8 +30,8 @@
<ApplicationId>it.integry.SteUp</ApplicationId> <ApplicationId>it.integry.SteUp</ApplicationId>
<!-- Versions --> <!-- Versions -->
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion> <ApplicationDisplayVersion>1.2.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion> <ApplicationVersion>6</ApplicationVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion> <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
<!-- <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">--> <!-- <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">-->
@@ -98,7 +98,9 @@
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#FDECEA" /> <MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#FDECEA" />
<!-- Splash Screen --> <!-- Splash Screen -->
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#FDECEA" BaseSize="128,128" /> <!-- Lo splash e' la prima superficie dell'app: stesso fondo del workspace,
cosi' l'avvio non lampeggia di rosa prima di mostrare la lista. -->
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#E9EDF2" BaseSize="112,112" />
<!-- Images --> <!-- Images -->
<MauiImage Include="Resources\Images\*" /> <MauiImage Include="Resources\Images\*" />
@@ -112,15 +114,15 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CommunityToolkit.Maui" Version="14.0.0" /> <PackageReference Include="CommunityToolkit.Maui" Version="14.1.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="IntegryApiClient.MAUI" Version="2.0.0" /> <PackageReference Include="IntegryApiClient.MAUI" Version="2.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.7" />
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.41" /> <PackageReference Include="Microsoft.Maui.Controls" Version="10.0.51" />
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="10.0.41" /> <PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="10.0.51" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.41" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.51" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.3" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.7" />
<PackageReference Include="Sentry.Maui" Version="6.1.0" /> <PackageReference Include="Sentry.Maui" Version="6.4.1" />
<PackageReference Include="SkiaSharp" Version="3.119.2" /> <PackageReference Include="SkiaSharp" Version="3.119.2" />
</ItemGroup> </ItemGroup>
+44 -21
View File
@@ -1,49 +1,72 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="it">
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" <meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"/> content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"/>
<title>SteUp.Maui</title> <title>SteUP</title>
<base href="/"/> <base href="/"/>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="_content/SteUp.Shared/css/fonts.css"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="_content/SteUp.Shared/css/tokens.css"/>
<link href="https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap"
rel="stylesheet">
<link href="_content/SteUp.Shared/css/bootstrap/bootstrap.min.css" rel="stylesheet">
<link href="_content/SteUp.Shared/css/bootstrap/bootstrap-icons.min.css" rel="stylesheet"/>
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet"/> <link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet"/>
<link href="_content/CodeBeam.MudBlazor.Extensions/MudExtensions.min.css" rel="stylesheet"/> <link href="_content/CodeBeam.MudBlazor.Extensions/MudExtensions.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="_content/SteUp.Shared/css/remixicon/remixicon.css"/> <link rel="stylesheet" href="_content/SteUp.Shared/css/remixicon/remixicon.css"/>
<link rel="stylesheet" href="_content/SteUp.Shared/css/app.css"/>
<link rel="stylesheet" href="_content/SteUp.Shared/css/form.css"/> <link rel="stylesheet" href="_content/SteUp.Shared/css/base.css"/>
<link rel="stylesheet" href="_content/SteUp.Shared/css/default-theme.css"/> <link rel="stylesheet" href="_content/SteUp.Shared/css/components.css"/>
<link rel="stylesheet" href="_content/SteUp.Shared/css/custom-mudBlazor.css"/> <link rel="stylesheet" href="_content/SteUp.Shared/css/forms.css"/>
<link rel="stylesheet" href="SteUp.Maui.styles.css"/> <!-- Dopo MudBlazor: riporta i controlli di libreria allo stile condiviso. -->
<link rel="stylesheet" href="_content/SteUp.Shared/css/mudblazor-overrides.css"/>
<link href="SteUp.Maui.styles.css" rel="stylesheet"/>
<link rel="icon" type="image/png" href="favicon.png"/> <link rel="icon" type="image/png" href="favicon.png"/>
<style>
/* Primo fotogramma, prima che Blazor sia pronto: nessun lampo bianco,
nessun salto di layout quando la schermata compare. */
.boot {
display: flex;
align-items: center;
justify-content: center;
height: 100dvh;
background-color: var(--workspace, #e9edf2);
}
.boot__spinner {
width: 34px;
height: 34px;
border-radius: 50%;
border: 3px solid rgba(0, 35, 57, 0.15);
border-top-color: var(--primary, #ec4c41);
animation: boot-spin 0.7s linear infinite;
}
@keyframes boot-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.boot__spinner { animation-duration: 2s; }
}
</style>
</head> </head>
<body> <body>
<div class="status-bar-safe-area"></div>
<div id="app"> <div id="app">
<div class="spinner-container"> <div class="boot">
<span class="loader"></span> <div class="boot__spinner" role="status" aria-label="Caricamento"></div>
</div> </div>
</div> </div>
<div id="blazor-error-ui"> <div id="blazor-error-ui">
An unhandled error has occurred. Si è verificato un errore imprevisto.
<a href="" class="reload">Reload</a> <a href="" class="reload">Ricarica</a>
<a class="dismiss">🗙</a> <a class="dismiss">🗙</a>
</div> </div>
<script src="_framework/blazor.webview.js" autostart="false"></script> <script src="_framework/blazor.webview.js" autostart="false"></script>
<script src="_content/SteUp.Shared/js/bootstrap/bootstrap.bundle.min.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script> <script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="_content/CodeBeam.MudBlazor.Extensions/MudExtensions.min.js"></script> <script src="_content/CodeBeam.MudBlazor.Extensions/MudExtensions.min.js"></script>
<script src="_content/SteUp.Shared/js/main.js"></script> <script src="_content/SteUp.Shared/js/main.js"></script>
@@ -1,68 +1,56 @@
@using SteUp.Shared.Core.Helpers @inject IJSRuntime Js
@inject IJSRuntime Js
<div class="@(Back ? "" : "container") header"> @*
<div class="header-content @(Back ? "with-back" : "no-back")"> Testata di schermata (STILE_UI §5): alta, titolo a sinistra, sfondo uguale
@if (!SmallHeader) alla pagina. Non e' una AppBar colorata col titolo centrato: quella e' una
firma Android di serie, e il documento la esclude esplicitamente.
*@
<header class="appbar @(Compact ? "appbar--compact" : "")">
@if (Back || Cancel)
{ {
if (Back) <div class="appbar__lead">
{ <MudIconButton Icon="@(Cancel ? Icons.Material.Rounded.Close : Icons.Material.Rounded.ArrowBack)"
<div class="left-section"> Color="Color.Inherit"
<MudButton StartIcon="@(!Cancel ? Icons.Material.Outlined.ArrowBackIosNew : "")" aria-label="@(Cancel ? "Chiudi" : "Indietro")"
OnClick="@GoBack" title="@(Cancel ? "Chiudi" : "Indietro")"
Color="Color.Info" OnClick="@GoBack"/>
Style="text-transform: none"
Variant="Variant.Text">
@BackTo
</MudButton>
</div> </div>
} }
<h3 class="page-title">@Title</h3> <div class="appbar__titles">
<h1 class="appbar__title">@Title</h1>
<div class="right-section"> @if (!string.IsNullOrWhiteSpace(Subtitle))
@if (!LabelSave.IsNullOrEmpty())
{ {
<MudButton OnClick="@OnSave" <p class="appbar__subtitle">@Subtitle</p>
Color="Color.Info"
Style="text-transform: none"
Variant="Variant.Text">
@LabelSave
</MudButton>
} }
</div> </div>
}
else
{
<div class="title">
<MudText Typo="Typo.h6">
<b>@Title</b>
</MudText>
<MudIconButton Icon="@Icons.Material.Rounded.Close" OnClick="@GoBack" />
</div>
}
</div>
</div>
@code{ @if (Actions is not null)
{
<div class="appbar__actions">
@Actions
</div>
}
</header>
@code {
[Parameter] public string? Title { get; set; } [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; } [Parameter] public bool Back { get; set; }
[Parameter] public string BackTo { get; set; } = "";
[Parameter] public EventCallback OnFilterToggle { get; set; }
/// Mostra la X di chiusura e delega a OnCancel (schermate modali).
[Parameter] public bool Cancel { get; set; } [Parameter] public bool Cancel { get; set; }
[Parameter] public EventCallback OnCancel { 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; } /// Testata ridotta per le schermate a compito singolo, dove il titolo
/// non deve rubare spazio al contenuto.
[Parameter] public bool Compact { get; set; }
protected override void OnParametersSet() [Parameter] public RenderFragment? Actions { get; set; }
{
Back = !Back ? !Back && Cancel : Back;
BackTo = Cancel ? "Annulla" : BackTo;
}
private async Task GoBack() private async Task GoBack()
{ {
@@ -74,5 +62,4 @@
await Js.InvokeVoidAsync("goBack"); await Js.InvokeVoidAsync("goBack");
} }
} }
@@ -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; }
+30 -56
View File
@@ -3,30 +3,28 @@
@using SteUp.Shared.Core.Interface.IntegryApi @using SteUp.Shared.Core.Interface.IntegryApi
@using SteUp.Shared.Core.Interface.System.Network @using SteUp.Shared.Core.Interface.System.Network
@inherits LayoutComponentBase @inherits LayoutComponentBase
@implements IDisposable
@inject INetworkService NetworkService @inject INetworkService NetworkService
@inject IIntegryApiService IntegryApiService @inject IIntegryApiService IntegryApiService
<MudThemeProvider Theme="_currentTheme" @ref="@_mudThemeProvider" @bind-IsDarkMode="@IsDarkMode" /> <MudThemeProvider Theme="_theme" IsDarkMode="false"/>
<MudPopoverProvider/> <MudPopoverProvider/>
<MudDialogProvider/> @* NoHeader NON va messo qui: e' il valore predefinito di tutti i dialog e
sopprimerebbe il titolo dei MudMessageBox, che lo rendono nella testata.
Le schermate a tutto schermo (form scheda, scelta punto vendita) lo
chiedono gia' per conto loro nelle DialogOptions. *@
<MudDialogProvider CloseButton="false" BackdropClick="false"/>
<MudSnackbarProvider/> <MudSnackbarProvider/>
<ConnectionState IsNetworkAvailable="IsNetworkAvailable" ServicesIsDown="ServicesIsDown" ShowWarning="ShowWarning" /> <ConnectionState IsNetworkAvailable="IsNetworkAvailable" ServicesIsDown="ServicesIsDown" ShowWarning="ShowWarning"/>
<div class="page"> <div class="app-shell">
<NavMenu/>
<main>
<article>
@Body @Body
</article>
</main>
</div> </div>
<NavMenu/>
@code { @code {
private MudThemeProvider _mudThemeProvider = null!;
private bool IsDarkMode { get; set; }
private string _mainContentClass = "";
//Connection state //Connection state
private bool _isNetworkAvailable; private bool _isNetworkAvailable;
@@ -71,58 +69,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", Primary = "#ec4c41",
Secondary = "#002339", Secondary = "#002339",
Tertiary = "#dff2ff", Tertiary = "#dde5ee",
TextPrimary = "#000" Success = "#26b050",
Error = "#e50000",
Warning = "#b26a00",
Info = "#002339",
Background = "#e9edf2",
Surface = "#ffffff",
TextPrimary = "#000000",
TextSecondary = "#465563",
LinesDefault = "#d9e0e8",
LinesInputs = "#d9e0e8",
Divider = "#d9e0e8"
}, },
PaletteDark = new PaletteDark LayoutProperties = new LayoutProperties
{ {
Primary = "#ec4c41", DefaultBorderRadius = "16px"
Secondary = "#002339",
Tertiary = "#dff2ff",
Surface = "#000406",
Background = "#000406",
TextPrimary = "#fff",
GrayDark = "#E0E0E0"
} }
}; };
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() protected override void OnInitialized()
{ {
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
@@ -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;
}
}
+69 -112
View File
@@ -1,138 +1,95 @@
@using CommunityToolkit.Mvvm.Messaging @implements IDisposable
@using SteUp.Shared.Components.SingleElements.MessageBox
@using SteUp.Shared.Core.Enum
@using SteUp.Shared.Core.Messages.Ispezione
@using SteUp.Shared.Core.Messages.Scheda
@inject INetworkService NetworkService
@inject IDialogService Dialog @inject IDialogService Dialog
@inject IMessenger Messenger @inject PageActionsService PageActions
@using SteUp.Shared.Core.Messages.System
<div @*
class="container animated-navbar @(IsVisible ? "show-nav" : "hide-nav") @(IsVisible ? PlusVisible ? "with-plus" : "without-plus" : "with-plus")"> Navigazione flottante in basso (STILE_UI §6.15): il contenuto le scorre
<nav class="navbar @(IsVisible ? PlusVisible ? "with-plus" : "without-plus" : "with-plus")"> sotto, le destinazioni restano a portata di pollice. Icona outline a
<div class="container-navbar"> riposo, piena da selezionata, etichetta sempre visibile in entrambe.
<ul class="navbar-nav flex-row nav-justified align-items-center w-100 text-center"> Da 720px diventa una colonna laterale (vedi components.css).
<li class="nav-item">
<NavLink class="nav-link" href="ispezioni" Match="NavLinkMatch.All"> In mezzo alle due destinazioni sta il comando. Non e' un pulsante che
<div class="d-flex flex-column"> galleggia sopra la barra e ne copre il centro: e' la casella centrale,
<i class="ri-file-list-3-line"></i> alla stessa altezza delle altre due. Sta sempre nello stesso punto, ma
<span>Ispezioni</span> quello che fa lo decide la schermata sotto (vedi PageActionsService):
</div> dentro un'ispezione apre le sue azioni, altrove apre una nuova ispezione.
*@
<nav class="navbar @(IsVisible ? "" : "navbar--hidden")" aria-label="Navigazione principale">
<NavLink class="navbar__item pressable" href="ispezioni" Match="NavLinkMatch.All">
<span class="navbar__icon">
<i class="@(IsOn("ispezioni") ? "ri-file-list-3-fill" : "ri-file-list-3-line")" aria-hidden="true"></i>
</span>
<span class="navbar__label">Ispezioni</span>
</NavLink> </NavLink>
</li>
<li class="nav-item"> <button type="button" class="navbar__action" @onclick="RunAction"
<NavLink class="nav-link" href="user" Match="NavLinkMatch.All"> disabled="@(!NetworkService.IsNetworkAvailable())"
<div class="d-flex flex-column"> aria-label="@ActionLabel"
<i class="ri-user-line"></i> title="@(NetworkService.IsNetworkAvailable() ? ActionLabel : "Serve la connessione")">
<span>Profilo</span> <i class="ri-add-line" aria-hidden="true"></i>
</div> </button>
<NavLink class="navbar__item pressable" href="user" Match="NavLinkMatch.All">
<span class="navbar__icon">
<i class="@(IsOn("user") ? "ri-user-fill" : "ri-user-line")" aria-hidden="true"></i>
</span>
<span class="navbar__label">Profilo</span>
</NavLink> </NavLink>
</li> </nav>
</ul>
</div>
@if (PlusVisible)
{
<MudFabMenu ButtonClass="custom-plus-button" MenuClass="custom-menu-fab" OnClick="@OnOpenMenu"
StartIcon="@Icons.Material.Filled.Add"
IconColor="Color.Primary" Color="Color.Surface" AlignItems="AlignItems.End"
Size="Size.Medium" IconSize="Size.Medium">
@if (SchedaVisible)
{
<MudFabMenuItem Disabled="!NetworkService.IsNetworkAvailable()" OnClick="@NewScheda"
Label="Nuova scheda" Color="Color.Surface"/>
if (ShowCompleteInspection)
{
<MudFabMenuItem Disabled="!NetworkService.IsNetworkAvailable()" OnClick="@CompleteInspection"
Label="Concludi ispezione" Color="Color.Surface"/>
}
}
else
{
<MudFabMenuItem Disabled="!NetworkService.IsNetworkAvailable()" OnClick="@NewInspection"
Label="Nuova ispezione" Color="Color.Surface"/>
}
</MudFabMenu>
}
</nav>
</div>
<ConfirmMessageBox @ref="_messageBox" NoText="No" YesText="Si" YesColor="Color.Primary"
Message="Completando l'ispezione non sarà più possibile editarla o aggiungere ulteriori segnalazioni. Procedere?"/>
@code @code
{ {
private bool IsVisible { get; set; } = true; private bool IsVisible { get; set; } = true;
private bool PlusVisible { get; set; } = true;
private bool ShowCompleteInspection { get; set; }
private bool SchedaVisible { get; set; }
private ConfirmMessageBox _messageBox = null!; private static readonly string[] NavigableRoutes = ["ispezioni", "ispezione", "user"];
protected override Task OnInitializedAsync() private string CurrentRoute =>
NavigationManager.Uri.Remove(0, NavigationManager.BaseUri.Length).Split('?')[0];
private bool IsOn(string route) => CurrentRoute.EqualsIgnoreCase(route);
private string ActionLabel => PageActions.HasActions ? "Azioni" : "Nuova ispezione";
protected override void OnInitialized()
{ {
NavigationManager.LocationChanged += (_, args) => NavigationManager.LocationChanged += OnLocationChanged;
{ PageActions.Changed += OnPageActionsChanged;
var location = args.Location.Remove(0, NavigationManager.BaseUri.Length); Refresh();
var newIsVisible = new List<string> { "ispezioni", "ispezione", "user" }
.Contains(location);
var newPlusVisible = new List<string> { "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;
} }
private void OnPageActionsChanged() => InvokeAsync(StateHasChanged);
private void OnLocationChanged(object? sender, LocationChangedEventArgs args) => Refresh();
private void Refresh()
{
IsVisible = NavigableRoutes.Contains(CurrentRoute, StringComparer.OrdinalIgnoreCase);
StateHasChanged(); StateHasChanged();
};
return Task.CompletedTask;
} }
/// Il comando vale quello che la schermata gli ha dato; se non gliel'ha
/// dato nessuno, vale la cosa che l'app fa piu' spesso.
private Task RunAction() => PageActions.HasActions ? PageActions.Invoke() : NewInspection();
private async Task NewInspection() private async Task NewInspection()
{ {
if (await SteupDataService.CanOpenNewInspection()) if (!await SteupDataService.CanOpenNewInspection())
{ {
_ = Dialog.OpenSelectShop(); await Dialog.ShowWarning(
} "Per aprire una nuova ispezione devi prima concludere tutte le ispezioni aperte da piu' di 20 giorni.");
else return;
{
await Dialog.ShowWarning("Per aprire una nuova ispezione è necessario concludere prima tutte le ispezioni aperte più vecchie di 20 giorni!");
}
} }
private async Task CompleteInspection() // La scelta del punto vendita naviga da sola sull'ispezione: qui non
{ // c'e' altro da fare, nemmeno quando l'utente annulla.
var result = await _messageBox.ShowAsync(); await Dialog.OpenSelectShop();
if (result is true)
Messenger.Send(new CompleteInspectionMessage());
} }
private async Task NewScheda() public void Dispose()
{ {
var ispezione = SteupDataService.InspectionPageState.Ispezione; NavigationManager.LocationChanged -= OnLocationChanged;
var modal = await Dialog.OpenFormScheda(ispezione.CodMdep, ispezione.Data, true); PageActions.Changed -= OnPageActionsChanged;
if (modal is { Canceled: false })
Messenger.Send(new NewSchedaMessage());
}
private void OnOpenMenu()
{
var location = NavigationManager.Uri.Remove(0, NavigationManager.BaseUri.Length);
SchedaVisible = new List<string> { "ispezione" }.Contains(location);
ShowCompleteInspection = !SteupDataService.InspectionPageState.Ispezione.ActivityId.IsNullOrEmpty();
StateHasChanged();
} }
} }
@@ -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);
}
}
@@ -1,24 +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.
*@
<MudOverlay Visible="VisibleOverlay" LightBackground="true"> @if (VisibleOverlay)
{
<div class="overlay" role="status" aria-live="polite">
@if (SuccessAnimation) @if (SuccessAnimation)
{ {
<div class="success-checkmark"> <div class="checkmark">
<div class="check-icon"> <svg viewBox="0 0 32 32" aria-hidden="true">
<span class="icon-line line-tip"></span> <path d="M6 17 L13 24 L26 9"/>
<span class="icon-line line-long"></span> </svg>
<div class="icon-circle"></div>
<div class="icon-fix"></div>
</div>
</div> </div>
} }
else else
{ {
<SpinnerLayout/> <span class="spinner spinner--lg" aria-hidden="true"></span>
} }
</MudOverlay>
@if (!string.IsNullOrWhiteSpace(Message))
{
<p class="overlay__message">@Message</p>
}
</div>
}
@code { @code {
[Parameter] public required bool SuccessAnimation { get; set; } [Parameter] public bool SuccessAnimation { get; set; }
[Parameter] public required bool VisibleOverlay { get; set; } [Parameter] public required bool VisibleOverlay { get; set; }
/// Testo mostrato sotto lo spinner (es. "Invio scheda 3 di 7…").
[Parameter] public string? Message { get; set; }
} }
@@ -1,156 +0,0 @@
.success-checkmark {
width: 80px;
height: 80px;
margin: 0 auto;
}
.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;
}
}
@@ -0,0 +1,81 @@
@using SteUp.Shared.Core.Messages.System
@inject BackButtonService BackButton
@implements IDisposable
@*
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)
{
<div class="sheet-scrim" @onclick="Close" aria-hidden="true"></div>
<div class="sheet @(Tall ? "sheet--tall" : "")" role="dialog" aria-modal="true"
aria-label="@Title">
<div class="sheet__handle"></div>
<div class="sheet__head">
<div class="sheet__titles">
<h2 class="sheet__title">@Title</h2>
@if (!string.IsNullOrWhiteSpace(Subtitle))
{
<p class="sheet__subtitle">@Subtitle</p>
}
</div>
<MudIconButton Icon="@Icons.Material.Rounded.Close" Color="Color.Inherit"
aria-label="Chiudi" title="Chiudi" OnClick="Close"/>
</div>
<div class="sheet__divider"></div>
<div class="sheet__body">
@ChildContent
</div>
</div>
}
@code {
[Parameter] public bool Open { get; set; }
[Parameter] public EventCallback<bool> 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; }
// Il tasto indietro del dispositivo chiude lo sheet invece di uscire dalla
// schermata sotto: e' il gesto che l'utente si aspetta, ed e' anche la
// ragione per cui i gestori sono impilati (vedi BackButtonService).
private IDisposable? _backRegistration;
protected override void OnParametersSet()
{
if (Open && _backRegistration is null)
{
_backRegistration = BackButton.Register(() => InvokeAsync(Close));
}
else if (!Open)
{
Unregister();
}
}
private async Task Close()
{
Unregister();
Open = false;
await OpenChanged.InvokeAsync(false);
}
private void Unregister()
{
_backRegistration?.Dispose();
_backRegistration = null;
}
public void Dispose() => Unregister();
}
@@ -1,13 +0,0 @@
<MudOverlay Visible="Visible" DarkBackground="false">
<div class="overlay-container">
<span>@(Text ?? "Caricamento")</span>
<MudProgressLinear Color="Color.Primary" Rounded="true" Size="Size.Medium" Indeterminate="true" />
</div>
</MudOverlay>
@code
{
[Parameter] public bool Visible { get; set; }
[Parameter] public string? Text { get; set; }
}
@@ -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;
}
@@ -1,5 +1,8 @@
<div class="spinner-container @(FullScreen ? "" : "not-fullScreen")"> @* Indicatore di attesa. A schermo pieno solo quando non c'e' ancora nulla da
<span class="loader"></span> mostrare; dentro una sezione resta contenuto e non fa saltare il layout. *@
<div style="display:flex; align-items:center; justify-content:center; @(FullScreen ? "height:100dvh;" : "padding: var(--space-xl) 0;")">
<span class="spinner @(FullScreen ? "spinner--lg" : "")" role="status" aria-label="Caricamento"></span>
</div> </div>
@code @code
@@ -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)
}
}
+456 -92
View File
@@ -2,100 +2,295 @@
@using Microsoft.Extensions.Logging @using Microsoft.Extensions.Logging
@using SteUp.Shared.Components.Layout @using SteUp.Shared.Components.Layout
@using SteUp.Shared.Components.Layout.Overlay @using SteUp.Shared.Components.Layout.Overlay
@using SteUp.Shared.Components.Layout.Sheet
@using SteUp.Shared.Components.SingleElements
@using SteUp.Shared.Components.SingleElements.Card @using SteUp.Shared.Components.SingleElements.Card
@using SteUp.Shared.Components.SingleElements.MessageBox
@using SteUp.Shared.Core.Dto @using SteUp.Shared.Core.Dto
@using SteUp.Shared.Core.Entities @using SteUp.Shared.Core.Entities
@using SteUp.Shared.Core.Enum @using SteUp.Shared.Core.Enum
@using SteUp.Shared.Core.Helpers
@using SteUp.Shared.Core.Interface.IntegryApi @using SteUp.Shared.Core.Interface.IntegryApi
@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.Ispezione @using SteUp.Shared.Core.Messages.System
@using SteUp.Shared.Core.Messages.Scheda
@inject NewSchedaService NewScheda
@inject CompleteInspectionService CompleteInspection
@inject IIspezioniService IspezioniService @inject IIspezioniService IspezioniService
@inject IDialogService Dialog @inject IDialogService Dialog
@inject IIntegrySteupService IntegrySteupService @inject IIntegrySteupService IntegrySteupService
@inject IFileManager FileManager @inject IFileManager FileManager
@inject ILogger<IspezionePage> Logger @inject ILogger<IspezionePage> Logger
@inject PageActionsService PageActions
@implements IDisposable @implements IDisposable
<HeaderLayout Title="Ispezione" BackTo="Indietro" Back="true"/> @* Le azioni non stanno nella testata: le apre il comando al centro della
barra in basso, che finche' questa schermata e' a video significa "azioni
di questa ispezione" (vedi PageActionsService). Su un'ispezione conclusa
non si registra nulla e resta solo la consultazione. *@
<HeaderLayout Title="@Titolo" Subtitle="@Sottotitolo" Back="true" Compact="true"/>
<div class="container content pb-safe-area ispezione"> <ActionSheet @bind-Open="_actionsOpen" Title="Azioni ispezione" Subtitle="@Ispezione.CodMdep">
<InspectionCard Ispezione="SteupDataService.InspectionPageState.Ispezione"/> <div class="stack stack-xs">
<button type="button" class="action-tile" disabled="@(!NetworkService.IsNetworkAvailable())"
@onclick="NewScheda">
<span class="action-tile__icon action-tile__icon--primary">
<i class="ri-add-line" aria-hidden="true"></i>
</span>
<span class="action-tile__label">Nuova scheda</span>
</button>
@if (!SchedeGrouped.IsNullOrEmpty()) @if (!Ispezione.ActivityId.IsNullOrEmpty())
{ {
<MudExpansionPanels MultiExpansion="true"> <button type="button" class="action-tile" disabled="@(!NetworkService.IsNetworkAvailable())"
@foreach (var group in SchedeGrouped) @onclick="ConfirmCompleteInspection">
<span class="action-tile__icon action-tile__icon--success">
<i class="ri-check-double-line" aria-hidden="true"></i>
</span>
<span class="action-tile__label">Concludi ispezione</span>
</button>
}
<button type="button" class="action-tile" disabled="@(!NetworkService.IsNetworkAvailable())"
@onclick="ConfirmDeleteInspection">
<span class="action-tile__icon action-tile__icon--danger">
<i class="ri-delete-bin-line" aria-hidden="true"></i>
</span>
<span class="action-tile__label">Elimina ispezione</span>
</button>
@if (!NetworkService.IsNetworkAvailable())
{ {
<MudExpansionPanel Class="expansion-panel" Expanded="true" Text="@group.Key.Descrizione"> <div class="feedback feedback--warning" role="status">
<TitleContent> <i class="feedback__icon ri-wifi-off-line" aria-hidden="true"></i>
<div class="header-scheda-group"> <span>Senza connessione queste azioni non sono disponibili. Il lavoro gia' fatto resta sul dispositivo.</span>
<div class="title">
<MudText Typo="Typo.h6"><b>@group.Key.Descrizione</b></MudText>
<MudChip T="string" Size="Size.Small" Color="Color.Default">
@($"{group.Value.Count} sched{(group.Value.Count == 1 ? "a" : "e")}")
</MudChip>
</div> </div>
@if (SteupDataService.InspectionPageState.Ispezione.Stato != StatusEnum.Completata) }
{ </div>
<div class="action-scheda-group"> </ActionSheet>
@if (NetworkService.IsNetworkAvailable())
{
<MudFab StartIcon="@Icons.Material.Rounded.Add"
Color="Color.Warning" Size="Size.Small"
OnClick="@(() => CreateNewScheda(group.Key))"/>
<MudFab StartIcon="@Icons.Material.Rounded.CloudSync" Label="Esporta reparto" <div class="app-scroll">
Color="Color.Success" Size="Size.Small" <div class="page-width page-body stack stack-lg">
OnClick="@(() => ExportReparto(group.Key))"/>
@* §6.6 Pannello di riepilogo: primo elemento della pagina, con lo stato
del documento e i conteggi sempre a vista. Superficie bianca come
ogni altro pannello: era l'unico blocco tinto dell'app, e quel
colore non appartiene a nessuno stato. *@
<section class="panel panel--lg">
@* Il nome del punto vendita e' gia' nella testata: qui basta la data
e lo stato del documento, che la testata non porta. *@
<div class="summary__head">
<div class="section-header__text">
<p class="summary__subtitle" style="margin: 0;">@Ispezione.Data.ToString("d MMMM yyyy")</p>
</div>
<Badge Spec="@Ispezione.Stato.ToBadge()"/>
</div>
<div class="summary__metrics">
<div class="summary__metric">
<i class="summary__metric-icon ri-file-list-3-line" aria-hidden="true"></i>
<div class="summary__metric-value">@SchedeList.Count</div>
<div class="summary__metric-label">@(SchedeList.Count == 1 ? "scheda" : "schede")</div>
</div>
<div class="summary__metric">
<i class="summary__metric-icon ri-smartphone-line" aria-hidden="true"></i>
<div class="summary__metric-value">@DaInviare</div>
<div class="summary__metric-label">da inviare</div>
</div>
<div class="summary__metric">
<i class="summary__metric-icon ri-edit-line" aria-hidden="true"></i>
<div class="summary__metric-value">@Bozze</div>
<div class="summary__metric-label">da completare</div>
</div>
</div>
@if (SchedeList.Count > 0)
{
<div class="summary__progress-head">
<span class="summary__progress-count">@Inviate di @SchedeList.Count inviate</span>
<span class="summary__progress-pct">@PercentualeInviate%</span>
</div>
<div class="progress-track" role="progressbar" aria-valuenow="@PercentualeInviate"
aria-valuemin="0" aria-valuemax="100" aria-label="Schede inviate al server">
<div class="progress-fill" style="--progress: @Progresso"></div>
</div>
}
</section>
@if (Bozze > 0)
{
@* Feedback inline: resta a video finche' il problema esiste, a
differenza del toast che sparisce. *@
<div class="feedback feedback--warning" role="status" aria-live="polite">
<i class="feedback__icon ri-edit-line" aria-hidden="true"></i>
<span>
@(Bozze == 1 ? "1 scheda è rimasta a metà" : $"{Bozze} schede sono rimaste a metà").
Completale o eliminale prima di concludere l'ispezione.
</span>
</div>
}
@if (SchedeGrouped.Count == 0)
{
<div class="empty-state">
<i class="empty-state__icon ri-clipboard-line" aria-hidden="true"></i>
<p class="empty-state__title">Nessuna scheda</p>
@if (!IsCompleted)
{
<p class="empty-state__text">
Aggiungi la prima scheda di questa ispezione.
</p>
<div class="empty-state__action">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Rounded.Add"
Disabled="@(!NetworkService.IsNetworkAvailable())"
OnClick="NewScheda">
Nuova scheda
</MudButton>
</div>
}
</div>
} }
else else
{ {
<MudFab StartIcon="@Icons.Material.Rounded.Add" @* Un reparto per volta: la testata si tocca per richiudere il
Label="@($"Nuova scheda su {group.Key.Descrizione}")" gruppo, e i conteggi restano scritti li' anche da chiuso — un
Color="Color.Warning" Size="Size.Medium" reparto compattato non deve nascondere quello che gli manca. *@
OnClick="@(() => CreateNewScheda(group.Key))"/> @foreach (var group in SchedeGrouped)
{
var reparto = group.Key;
var chiuso = IsCollapsed(reparto);
var daInviare = group.Value.CountDaInviare();
<section class="reparto @(chiuso ? "reparto--collapsed" : "")">
<div class="reparto__head">
<button type="button" class="reparto__toggle" @onclick="@(() => Toggle(reparto))"
aria-expanded="@((!chiuso).ToString().ToLowerInvariant())">
<i class="reparto__chevron ri-arrow-down-s-line" aria-hidden="true"></i>
<span class="reparto__text">
<span class="reparto__title">@reparto.Descrizione</span>
<span class="reparto__meta">@Conteggi(group.Value)</span>
</span>
</button>
<div class="reparto__actions">
@if (!IsCompleted)
{
@* Da qui la scheda nasce gia' con il suo reparto: e'
l'unica differenza con il "+" della barra. *@
<button type="button" class="reparto__add"
disabled="@(!NetworkService.IsNetworkAvailable())"
aria-label="@($"Nuova scheda nel reparto {reparto.Descrizione}")"
title="@($"Nuova scheda in {reparto.Descrizione}")"
@onclick="@(() => CreateNewScheda(reparto))">
<i class="ri-add-line" aria-hidden="true"></i>
</button>
@if (NetworkService.IsNetworkAvailable() && daInviare > 0)
{
<MudButton Variant="Variant.Outlined" Size="Size.Small"
StartIcon="@Icons.Material.Rounded.CloudUpload"
aria-label="@($"Invia le schede del reparto {reparto.Descrizione}")"
OnClick="@(() => ExportReparto(reparto))">
Invia @daInviare
</MudButton>
}
} }
</div> </div>
}
</div> </div>
</TitleContent>
<ChildContent> @if (!chiuso)
{
<div class="card-list reparto__body">
@foreach (var scheda in group.Value) @foreach (var scheda in group.Value)
{ {
<SchedaCard Scheda="scheda" OnSchedaDeleted="OnSchedaDeleted" <SchedaCard Scheda="scheda" Ispezione="Ispezione"
OnSchedaDeleted="OnSchedaDeleted"
OnSchedaModified="OnSchedaModified" OnSchedaModified="OnSchedaModified"
Ispezione="@SteupDataService.InspectionPageState.Ispezione"/> OnBusyChanged="@(b => VisibleOverlay = b)"/>
} }
</ChildContent> </div>
</MudExpansionPanel>
} }
</MudExpansionPanels> </section>
} }
}
</div>
</div> </div>
<SpinnerOverlay VisibleOverlay="VisibleOverlay"/> <SpinnerOverlay VisibleOverlay="VisibleOverlay" SuccessAnimation="_success" Message="@_progressMessage"/>
<ConfirmMessageBox @ref="_completeBox" Title="Concludere l'ispezione?" NoText="Annulla"
YesText="Concludi" YesColor="Color.Primary"
Message="Una volta conclusa, l'ispezione non sarà più modificabile e non potrai aggiungere altre schede."/>
<ConfirmMessageBox @ref="_deleteBox" Title="Eliminare l'ispezione?" NoText="Annulla"
YesText="Elimina" YesColor="Color.Error"
Message="L'ispezione e tutte le sue schede verranno eliminate dal dispositivo. L'operazione non è reversibile."/>
<ConfirmMessageBox @ref="_exportRepartoBox" Title="Inviare le schede del reparto?" NoText="Annulla"
YesText="Invia" YesColor="Color.Primary"
Message="Le schede complete di questo reparto verranno inviate al server. Procedere?"/>
@code { @code {
private List<Scheda> SchedeList { get; set; } = []; private List<Scheda> SchedeList { get; set; } = [];
private Dictionary<JtbFasiDto, List<Scheda>> SchedeGrouped { get; set; } = []; private Dictionary<JtbFasiDto, List<Scheda>> SchedeGrouped { get; set; } = [];
private bool VisibleOverlay { get; set; } private bool VisibleOverlay { get; set; }
private bool _isBusy;
private bool _success;
private string? _progressMessage;
private ConfirmMessageBox _exportRepartoBox = null!;
private ConfirmMessageBox _completeBox = null!;
private ConfirmMessageBox _deleteBox = null!;
private bool _actionsOpen;
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 PuntoVenditaDto? PuntoVendita => SteupDataService.PuntiVenditaList
.Find(x => x.CodMdep != null && x.CodMdep.EqualsIgnoreCase(Ispezione.CodMdep));
// Il codice del punto vendita sta davanti al nome, come su ogni documento
// cartaceo dell'ispezione: e' quello che si confronta, il nome serve a
// riconoscerlo.
private string Titolo => PuntoVendita?.Descrizione is { Length: > 0 } descrizione
? $"{Ispezione.CodMdep} - {descrizione}"
: Ispezione.CodMdep;
private string? Sottotitolo => PuntoVendita?.Indirizzo;
private IDisposable? _actionsRegistration;
protected override void OnInitialized() protected override void OnInitialized()
{ {
NewScheda.OnNewScheda += LoadSchede;
CompleteInspection.OnComplete += HandleCompleteInspection;
LoadSchede(); LoadSchede();
// Su un'ispezione conclusa non c'e' niente da fare: il comando della
// barra si tiene il suo significato di default invece di aprire uno
// sheet di azioni tutte spente.
if (!IsCompleted)
_actionsRegistration = PageActions.Register(() => InvokeAsync(OpenActions));
} }
private void OpenActions()
{
_actionsOpen = true;
StateHasChanged();
}
public void Dispose() => _actionsRegistration?.Dispose();
private void LoadSchede() private void LoadSchede()
{ {
var ispezione = SteupDataService.InspectionPageState.Ispezione; var ispezione = Ispezione;
InvokeAsync(async () => InvokeAsync(async () =>
{ {
@@ -108,8 +303,87 @@
}); });
} }
private async Task NewScheda()
{
_actionsOpen = false;
var modal = await Dialog.OpenFormScheda(Ispezione.CodMdep, Ispezione.Data, true);
if (modal is { Canceled: false }) LoadSchede();
}
/// Stessa scheda nuova, ma con il reparto gia' scelto: partendo da un
/// reparto non ha senso richiederlo.
private async Task CreateNewScheda(JtbFasiDto reparto)
{
var modal = await Dialog.OpenFormScheda(
Ispezione.CodMdep, Ispezione.Data, true, new Scheda { Reparto = reparto }
);
if (modal is { Canceled: false }) LoadSchede();
}
// I reparti richiusi stanno qui e non nell'entita': e' uno stato della
// schermata, non del documento.
private readonly HashSet<string> _collapsed = new(StringComparer.OrdinalIgnoreCase);
private bool IsCollapsed(JtbFasiDto reparto) =>
reparto.CodJfas is not null && _collapsed.Contains(reparto.CodJfas);
private void Toggle(JtbFasiDto reparto)
{
if (reparto.CodJfas is null) return;
if (!_collapsed.Remove(reparto.CodJfas)) _collapsed.Add(reparto.CodJfas);
}
private static string Conteggi(List<Scheda> schede)
{
var parti = new List<string> { schede.Count == 1 ? "1 scheda" : $"{schede.Count} schede" };
var daInviare = schede.CountDaInviare();
if (daInviare > 0) parti.Add($"{daInviare} da inviare");
var bozze = schede.CountBozze();
if (bozze > 0) parti.Add($"{bozze} da completare");
return string.Join(" · ", parti);
}
private async Task ConfirmCompleteInspection()
{
_actionsOpen = false;
var confirmed = await _completeBox.ShowAsync();
if (confirmed is true) HandleCompleteInspection();
}
private async Task ConfirmDeleteInspection()
{
_actionsOpen = false;
// Prima non c'era conferma: un tocco cancellava l'ispezione e tutte le
// sue schede senza appello.
var confirmed = await _deleteBox.ShowAsync();
if (confirmed is not true) return;
try
{
var ispezione = Ispezione;
await IspezioniService.DeleteIspezioneAsync(ispezione.CodMdep, ispezione.Data, ispezione.Rilevatore);
NavigationManager.NavigateTo("/ispezioni");
}
catch (Exception e)
{
OnError(e, e.Message);
}
}
private async void HandleCompleteInspection() private async void HandleCompleteInspection()
{ {
// Guardia anti doppio-invio: il completamento è irreversibile.
if (_isBusy) return;
_isBusy = true;
try try
{ {
await InvokeAsync(() => await InvokeAsync(() =>
@@ -118,10 +392,36 @@
StateHasChanged(); StateHasChanged();
}); });
var ispezione = SteupDataService.InspectionPageState.Ispezione; var ispezione = Ispezione;
foreach (var scheda in ispezione.Schede.Where(x => x.ActivityId == null)) // 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;
foreach (var scheda in daInviare)
{
indice++;
await SetProgress($"Invio scheda {indice} di {totale}…");
var apiResponse = await IntegrySteupService.SaveScheda( var apiResponse = await IntegrySteupService.SaveScheda(
new SaveRequestDto new SaveRequestDto
{ {
@@ -134,11 +434,18 @@
PersonaRif = scheda.Responsabile, PersonaRif = scheda.Responsabile,
Barcodes = scheda.Articoli.ConvertAll(x => x.Barcode), Barcodes = scheda.Articoli.ConvertAll(x => x.Barcode),
Scandeza = (ScadenzaEnum)scheda.Scadenza, Scandeza = (ScadenzaEnum)scheda.Scadenza,
ParentActivityId = scheda.Ispezione?.ActivityId // Aggancia all'ispezione esistente: dopo la prima scheda l'id e' noto,
// così le successive non creano nuove ispezioni.
ParentActivityId = ispezione.ActivityId
} }
); );
if (apiResponse == null) continue; // Fallita: resta "Da inviare". Non completeremo l'ispezione finché tutte non sono arrivate.
if (apiResponse == null)
{
fallite++;
continue;
}
scheda.ActivityId = apiResponse.ActivityIdScheda; scheda.ActivityId = apiResponse.ActivityIdScheda;
await IspezioniService.UpdateSchedaAsync(scheda); await IspezioniService.UpdateSchedaAsync(scheda);
@@ -149,6 +456,8 @@
ispezione.Rilevatore, ispezione.Rilevatore,
apiResponse.ActivityIdIspezione apiResponse.ActivityIdIspezione
); );
// Aggiorna l'id in memoria per l'iterazione successiva.
ispezione.ActivityId = apiResponse.ActivityIdIspezione;
if (scheda.ImageNames == null) continue; if (scheda.ImageNames == null) continue;
@@ -157,12 +466,39 @@
if (fileList == null) continue; if (fileList == null) continue;
var totFoto = fileList.Count;
var indiceFoto = 0;
foreach (var file in fileList) foreach (var file in fileList)
{ {
indiceFoto++;
await SetProgress($"Scheda {indice}/{totale} · foto {indiceFoto}/{totFoto}…");
await IntegrySteupService.UploadFile(scheda.ActivityId!, file.FileBytes!, file.Name!); await IntegrySteupService.UploadFile(scheda.ActivityId!, file.FileBytes!, file.Name!);
} }
} }
// Integrità dati: se anche una sola scheda non è arrivata al server, NON completare.
// L'ispezione resta editabile e le schede fallite restano "Da inviare".
if (fallite > 0)
{
await InvokeAsync(() =>
{
_progressMessage = null;
StateHasChanged();
});
LoadSchede(); // rinfresca i badge: riuscite → "Inviato", fallite → restano "Da inviare"
await Dialog.ShowWarning(
fallite == 1
? "1 scheda non è stata inviata e resta salvata sul dispositivo. Controlla la connessione e riprova prima di concludere l'ispezione."
: $"{fallite} schede non sono state inviate e restano salvate sul dispositivo. Controlla la connessione e riprova prima di concludere l'ispezione."
);
return;
}
await SetProgress("Completamento ispezione…");
await IntegrySteupService.CompleteInspection(ispezione.ActivityId!); await IntegrySteupService.CompleteInspection(ispezione.ActivityId!);
await IspezioniService.UpdateStatoIspezioneAsync( await IspezioniService.UpdateStatoIspezioneAsync(
ispezione.CodMdep, ispezione.CodMdep,
@@ -171,27 +507,40 @@
StatusEnum.Completata 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(() => await InvokeAsync(() =>
{ {
VisibleOverlay = false; _progressMessage = "Ispezione inviata";
_success = true;
StateHasChanged(); StateHasChanged();
}); });
await Task.Delay(1800);
} }
catch (Exception e) catch (Exception e)
{ {
Console.WriteLine(e.Message); OnError(e, e.Message);
}
finally
{
_isBusy = false;
_success = false;
_progressMessage = null;
await InvokeAsync(() => await InvokeAsync(() =>
{ {
VisibleOverlay = false; VisibleOverlay = false;
StateHasChanged(); StateHasChanged();
}); });
}
}
OnError(e, e.Message); private Task SetProgress(string message) =>
} InvokeAsync(() =>
} {
_progressMessage = message;
StateHasChanged();
});
private void GroupSchede() private void GroupSchede()
{ {
@@ -207,7 +556,7 @@
private void OnSchedaModified(Scheda obj) private void OnSchedaModified(Scheda obj)
{ {
var index = SchedeList.FindIndex(x => x.Id.Equals(obj.Id)); var index = SchedeList.FindIndex(x => x.Id.Equals(obj.Id));
if (index > 0) SchedeList[index] = obj; if (index >= 0) SchedeList[index] = obj;
GroupSchede(); GroupSchede();
StateHasChanged(); StateHasChanged();
@@ -220,26 +569,32 @@
StateHasChanged(); 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) private async Task ExportReparto(JtbFasiDto jtbFasi)
{ {
// Guardia anti doppio-tap: un secondo tocco durante l'invio non ri-esporta il batch.
if (_isBusy) return;
var confirmed = await _exportRepartoBox.ShowAsync();
if (confirmed is not true) return;
_isBusy = true;
VisibleOverlay = true; VisibleOverlay = true;
StateHasChanged(); StateHasChanged();
var ispezione = SteupDataService.InspectionPageState.Ispezione; try
{
var ispezione = 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 inviare in questo reparto", Severity.Info);
return;
}
var saveRequest = daEsportare.ConvertAll(x =>
{ {
return new SaveRequestDto return new SaveRequestDto
{ {
@@ -252,54 +607,63 @@
PersonaRif = x.Responsabile, PersonaRif = x.Responsabile,
Barcodes = x.Articoli.ConvertAll(y => y.Barcode), Barcodes = x.Articoli.ConvertAll(y => y.Barcode),
Scandeza = (ScadenzaEnum)x.Scadenza, Scandeza = (ScadenzaEnum)x.Scadenza,
ParentActivityId = x.Ispezione?.ActivityId // Aggancia all'ispezione esistente sul server (evita di crearne una nuova a ogni export).
ParentActivityId = ispezione.ActivityId
}; };
}); });
var apiResponse = await IntegrySteupService.SaveMultipleSchede(saveRequest); var apiResponse = await IntegrySteupService.SaveMultipleSchede(saveRequest);
if (apiResponse != null) if (apiResponse == null)
{ {
SteupDataService.InspectionPageState.Ispezione.ActivityId = apiResponse.ActivityIdIspezione; Snackbar.Add("Invio non riuscito. Le schede restano sul dispositivo, riprova.", Severity.Error);
return;
}
ispezione.ActivityId = apiResponse.ActivityIdIspezione;
await IspezioniService.UpdateActivityIdIspezioneAsync(ispezione.CodMdep, ispezione.Data, await IspezioniService.UpdateActivityIdIspezioneAsync(ispezione.CodMdep, ispezione.Data,
UserSession.User.Username, apiResponse.ActivityIdIspezione UserSession.User.Username, apiResponse.ActivityIdIspezione
); );
if (apiResponse.ActivityIdSchedaList.IsNullOrEmpty()) return; if (!apiResponse.ActivityIdSchedaList.IsNullOrEmpty())
{
foreach (var scheda in SchedeGrouped[jtbFasi]) foreach (var scheda in daEsportare)
{ {
scheda.ActivityId = apiResponse.ActivityIdScheda;
await IspezioniService.UpdateActivityIdSchedaAsync(scheda.Id, scheda.ActivityId);
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
)?.ActivityId; )?.ActivityId;
if (activityId == null) return; if (activityId == null) continue;
scheda.ActivityId = activityId; scheda.ActivityId = activityId;
await IspezioniService.UpdateActivityIdSchedaAsync(scheda.Id, scheda.ActivityId); await IspezioniService.UpdateActivityIdSchedaAsync(scheda.Id, scheda.ActivityId);
} }
} }
Snackbar.Add(daEsportare.Count == 1 ? "Scheda inviata" : $"{daEsportare.Count} schede inviate",
Severity.Success);
}
catch (Exception e)
{
OnError(e, e.Message);
}
finally
{
_isBusy = false;
VisibleOverlay = false; VisibleOverlay = false;
StateHasChanged(); StateHasChanged();
} }
private void OnError(Exception? e, string? errorMessage)
{
if (e != null) Logger.LogError(e, errorMessage);
if (errorMessage == null) return;
_ = Dialog.ShowError(errorMessage);
} }
void IDisposable.Dispose() private void OnError(Exception? e, string? technicalMessage)
{ {
NewScheda.OnNewScheda -= LoadSchede; // Il dettaglio tecnico va nei log, non davanti al rilevatore in campo.
CompleteInspection.OnComplete -= HandleCompleteInspection; if (e != null) Logger.LogError(e, technicalMessage);
_ = Dialog.ShowError(
"Invio non riuscito. Il lavoro resta salvato sul dispositivo: controlla la connessione e riprova."
);
} }
} }
@@ -1,11 +0,0 @@
.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: space-around;
}
@@ -1,47 +1,95 @@
@page "/ispezioni" @page "/ispezioni"
@attribute [Authorize] @attribute [Authorize]
@using SteUp.Shared.Components.Layout @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.Card
@using SteUp.Shared.Core.Entities @using SteUp.Shared.Core.Entities
@using SteUp.Shared.Core.Interface.LocalDb @using SteUp.Shared.Core.Interface.LocalDb
@inject IIspezioniService IspezioniService @inject IIspezioniService IspezioniService
@inject IDialogService Dialog
<HeaderLayout Title="Ispezioni"/> @* L'azione "nuova ispezione" non sta qui: vive nella barra in basso, dove
il pollice arriva senza attraversare lo schermo. *@
<HeaderLayout Title="Ispezioni" Subtitle="@Subtitle"/>
<div class="container ispezioni pb-safe-area"> <div class="app-scroll">
@if (Ispezioni.IsNullOrEmpty()) <div class="page-width page-body">
@if (_loading)
{ {
<NoDataAvailable Text="Nessuna ispezione effettuata" @* Scheletri, non uno spinner in mezzo al contenuto: la pagina ha
ImageSource="_content/SteUp.Shared/images/undraw_file-search_cbur.svg"/> gia' la forma che avra', e non salta quando i dati arrivano. *@
<div class="card-list" aria-hidden="true">
@for (var i = 0; i < 3; i++)
{
<div class="list-card">
<div class="skeleton skeleton--line-short"></div>
<div class="skeleton skeleton--title" style="margin-top: 10px;"></div>
<div class="skeleton skeleton--line" style="margin-top: 12px;"></div>
<div class="skeleton skeleton--badge" style="margin-top: 14px;"></div>
</div>
}
</div>
}
else if (Ispezioni.Count == 0)
{
<div class="empty-state">
<i class="empty-state__icon ri-clipboard-line" aria-hidden="true"></i>
<p class="empty-state__title">Nessuna ispezione</p>
<p class="empty-state__text">
Apri la prima ispezione scegliendo il punto vendita da controllare.
</p>
<div class="empty-state__action">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Rounded.Add"
Disabled="@(!NetworkService.IsNetworkAvailable())"
OnClick="NewInspection">
Nuova ispezione
</MudButton>
</div>
</div>
} }
else else
{ {
<Virtualize Items="Ispezioni" Context="ispezione"> <div class="card-list">
<InspectionCard Class="no-margin" Ispezione="ispezione" CompactView="true" OnClick="@OnClickIspezione"/> <Virtualize Items="Ispezioni" Context="ispezione" OverscanCount="4">
<InspectionCard Ispezione="ispezione" OnClick="@OnClickIspezione"/>
</Virtualize> </Virtualize>
</div>
} }
</div>
</div> </div>
<SpinnerOverlay VisibleOverlay="VisibleOverlay"/>
@code{ @code{
private List<Ispezione> Ispezioni { get; set; } = []; private List<Ispezione> 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() protected override async Task OnInitializedAsync()
{ {
await LoadData(); Ispezioni = await IspezioniService.GetAllIspezioniWithSchedeAsync(UserSession.User.Username);
VisibleOverlay = false; _loading = false;
StateHasChanged(); StateHasChanged();
} }
private async Task LoadData() private async Task NewInspection()
{ {
Ispezioni = await IspezioniService.GetAllIspezioniWithSchedeAsync(UserSession.User.Username); if (await SteupDataService.CanOpenNewInspection())
{
var modal = await Dialog.OpenSelectShop();
// La scelta del punto vendita naviga da sola sull'ispezione; se
// l'utente torna indietro, la lista dev'essere quella aggiornata.
if (modal is { Canceled: true }) return;
}
else
{
await Dialog.ShowWarning(
"Per aprire una nuova ispezione devi prima concludere tutte le ispezioni aperte da piu' di 20 giorni.");
}
} }
private void OnClickIspezione(Ispezione ispezione) private void OnClickIspezione(Ispezione ispezione)
@@ -49,5 +97,4 @@
SteupDataService.InspectionPageState.Ispezione = ispezione; SteupDataService.InspectionPageState.Ispezione = ispezione;
NavigationManager.NavigateTo("/ispezione"); NavigationManager.NavigateTo("/ispezione");
} }
} }
@@ -1,5 +0,0 @@
.ispezioni{
display: flex;
flex-direction: column;
gap: 1rem;
}
+84 -64
View File
@@ -1,6 +1,5 @@
@page "/login" @page "/login"
@using Microsoft.Extensions.Logging @using Microsoft.Extensions.Logging
@using SteUp.Shared.Components.Layout.Spinner
@using SteUp.Shared.Core.BarcodeReader.Contracts @using SteUp.Shared.Core.BarcodeReader.Contracts
@using SteUp.Shared.Core.Interface.System @using SteUp.Shared.Core.Interface.System
@using SteUp.Shared.Core.Services @using SteUp.Shared.Core.Services
@@ -10,69 +9,92 @@
@inject IBarcodeManager BarcodeManager @inject IBarcodeManager BarcodeManager
@inject ILogger<LoginPage> Logger @inject ILogger<LoginPage> Logger
@if (Spinner) @*
{ Accesso (STILE_UI §7): stessa struttura dell'app, registro diverso.
<SpinnerLayout/> L'accento resta dove sta sempre, sull'unica azione della schermata.
} *@
else
{ <div class="onboarding">
<div class="login-page"> <form class="onboarding__panel" @onsubmit="SignInUser" @onsubmit:preventDefault="true">
<div class="container container-top-logo"> <img src="_content/SteUp.Shared/images/logo_steup.svg" class="onboarding__logo" alt="SteUP"/>
<img src="_content/SteUp.Shared/images/logo_steup.svg" class="logo" alt="sales book">
</div> @* Un solo pannello con tre righe divise da una linea, come le liste
<div class="container container-login"> del resto dell'app. Prima ogni campo era un riquadro dentro il
<div class="login-form-container"> riquadro, con l'etichetta che saltava sopra il testo al primo tocco:
<div class="input-group"> un gesto da modulo web, non da applicazione. Qui l'etichetta sta
<MudTextField @bind-Value="UserData.Username" Label="Username" Variant="Variant.Outlined"/> ferma sopra il campo e non si muove mai. *@
</div> <div class="onboarding__fields">
<div class="input-group"> <label class="onboarding__field">
<MudTextField InputType="@_passwordInput" @bind-Value="UserData.Password" Label="Password" Variant="Variant.Outlined" Adornment="Adornment.End" AdornmentIcon="@_passwordInputIcon" OnAdornmentClick="ShowPassword" AdornmentAriaLabel="Show Password"/> <span class="onboarding__label">Username</span>
</div> <input class="onboarding__input" type="text" autocomplete="username"
<div class="input-group mb-2"> autocapitalize="none" autocorrect="off" enterkeyhint="next"
<MudTextField @bind-Value="UserData.CodHash" Label="Profilo azienda" Variant="Variant.Outlined"/> value="@UserData.Username" @oninput="@(e => UserData.Username = e.Value?.ToString())"/>
</label>
<label class="onboarding__field">
<span class="onboarding__label">Password</span>
<span class="onboarding__input-row">
<input class="onboarding__input" type="@(_isShow ? "text" : "password")"
autocomplete="current-password" enterkeyhint="next"
value="@UserData.Password" @oninput="@(e => UserData.Password = e.Value?.ToString())"/>
<button type="button" class="onboarding__reveal" @onclick="ShowPassword"
aria-label="@(_isShow ? "Nascondi la password" : "Mostra la password")">
<i class="@(_isShow ? "ri-eye-off-line" : "ri-eye-line")" aria-hidden="true"></i>
</button>
</span>
</label>
<label class="onboarding__field">
<span class="onboarding__label">Profilo azienda</span>
<input class="onboarding__input" type="text" autocomplete="organization"
autocapitalize="none" autocorrect="off" enterkeyhint="go"
value="@UserData.CodHash" @oninput="@(e => UserData.CodHash = e.Value?.ToString())"/>
</label>
</div> </div>
<MudButton OnClick="@SignInUser" Color="Color.Primary" Variant="Variant.Filled">Login</MudButton>
@if (_attemptFailed) @if (_attemptFailed)
{ {
<MudAlert Class="my-3" Dense="true" Severity="Severity.Error" Variant="Variant.Filled">@ErrorMessage</MudAlert> @* Stesso riquadro di avviso usato dentro l'app: l'errore di accesso
non e' un caso speciale che merita un suo stile. *@
<div class="feedback feedback--danger onboarding__error" role="alert" aria-live="assertive">
<i class="feedback__icon ri-error-warning-line" aria-hidden="true"></i>
<span>@ErrorMessage</span>
</div>
} }
</div>
<div class="my-4 login-footer"> <button type="submit" class="onboarding__submit" disabled="@(_busy || !CanSubmit)">
<span>@GenericSystemService.GetCurrentAppVersion() | Powered by</span> @if (_busy)
<img src="_content/SteUp.Shared/images/logoIntegry.svg" class="img-fluid" alt="Integry">
</div>
</div>
</div>
}
@code {
private SignIn UserData { get; } = new();
private bool Spinner { get; set; }
private string ErrorMessage { get; set; } = "";
private bool _attemptFailed;
private bool _isShow;
private InputType _passwordInput = InputType.Password;
private string _passwordInputIcon = Icons.Material.Rounded.VisibilityOff;
private void ShowPassword()
{ {
if (_isShow) <span class="onboarding__spinner" aria-hidden="true"></span>
{ <span>Accesso in corso…</span>
_isShow = false;
_passwordInputIcon = Icons.Material.Rounded.VisibilityOff;
_passwordInput = InputType.Password;
} }
else else
{ {
_isShow = true; <span>Accedi</span>
_passwordInputIcon = Icons.Material.Rounded.Visibility;
_passwordInput = InputType.Text;
}
} }
</button>
<div class="onboarding__footer">
<span>@GenericSystemService.GetCurrentAppVersion() · Powered by</span>
<img src="_content/SteUp.Shared/images/logoIntegry.svg" alt="Integry"/>
</div>
</form>
</div>
@code {
private SignIn UserData { get; } = new();
private string ErrorMessage { get; set; } = "";
private bool _attemptFailed;
private bool _busy;
private bool _isShow;
private bool CanSubmit =>
!string.IsNullOrWhiteSpace(UserData.Username) &&
!string.IsNullOrWhiteSpace(UserData.Password) &&
!string.IsNullOrWhiteSpace(UserData.CodHash);
private void ShowPassword() => _isShow = !_isShow;
protected override void OnInitialized() protected override void OnInitialized()
{ {
@@ -82,36 +104,34 @@ else
private async Task SignInUser() private async Task SignInUser()
{ {
if (_busy || !CanSubmit) return;
_attemptFailed = false; _attemptFailed = false;
if (!string.IsNullOrEmpty(UserData.Username) && !string.IsNullOrEmpty(UserData.Password) && !string.IsNullOrEmpty(UserData.CodHash)) _busy = true;
{
Spinner = true;
StateHasChanged(); StateHasChanged();
try try
{ {
SteupDataService.RegisterAppVersion(); SteupDataService.RegisterAppVersion();
await UserAccountService.Login(UserData.Username, UserData.Password, UserData.CodHash); await UserAccountService.Login(UserData.Username!, UserData.Password!, UserData.CodHash!);
AuthenticationStateProvider.NotifyAuthenticationState(); //Chiamato per forzare il refresh AuthenticationStateProvider.NotifyAuthenticationState(); //Chiamato per forzare il refresh
await SteupDataService.Init(); await SteupDataService.Init();
BarcodeManager.Init(); BarcodeManager.Init();
LocalStorage.SetString("codHash", UserData.CodHash); LocalStorage.SetString("codHash", UserData.CodHash!);
NavigationManager.NavigateTo("/"); NavigationManager.NavigateTo("/");
StateHasChanged();
} }
catch (Exception e) catch (Exception e)
{ {
ErrorMessage = e.Message; ErrorMessage = e.Message;
Logger.LogError(e, ErrorMessage); Logger.LogError(e, ErrorMessage);
Console.WriteLine(e.Message);
Spinner = false;
StateHasChanged();
_attemptFailed = true; _attemptFailed = true;
Console.WriteLine(e);
} }
finally
{
_busy = false;
StateHasChanged();
} }
} }
@@ -1,54 +0,0 @@
.login-page {
height: 100%;
display: flex;
flex-direction: column;
background: var(--mud-palette-surface);
}
.container-top-logo > .logo {
width: 50%;
}
.container-login > span {
font-size: large;
font-weight: 900;
text-align: center;
}
.container-top-logo {
height: 35vh;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.login-form-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.container-login {
height: 100%;
display: flex;
flex-direction: column;
padding: 4px 16px 16px;
justify-content: space-between;
}
.login-footer {
width: 100%;
display: flex;
justify-content: center;
}
.login-footer span {
font-size: 9px;
color: var(--mud-palette-gray-darker);
}
.login-footer img {
height: 15px;
margin-left: 4px;
}
+16 -2
View File
@@ -2,5 +2,19 @@
@using SteUp.Shared.Components.Layout @using SteUp.Shared.Components.Layout
@layout MainLayout @layout MainLayout
<h3>Not Found</h3> <HeaderLayout Title="Pagina non trovata" Back="true"/>
<p>Sorry, the content you are looking for does not exist.</p>
<div class="app-scroll">
<div class="page-width page-body">
<div class="empty-state">
<i class="empty-state__icon ri-compass-3-line" aria-hidden="true"></i>
<h1 class="empty-state__title">Qui non c'è niente</h1>
<p class="empty-state__text">
La schermata che cercavi non esiste. Torna all'elenco delle ispezioni per riprendere il lavoro.
</p>
<div class="empty-state__action">
<MudButton Variant="Variant.Outlined" Href="/ispezioni">Vai alle ispezioni</MudButton>
</div>
</div>
</div>
</div>
+94 -81
View File
@@ -3,6 +3,7 @@
@using SteUp.Shared.Components.Layout @using SteUp.Shared.Components.Layout
@using SteUp.Shared.Components.Layout.Overlay @using SteUp.Shared.Components.Layout.Overlay
@using SteUp.Shared.Components.SingleElements @using SteUp.Shared.Components.SingleElements
@using SteUp.Shared.Components.SingleElements.MessageBox
@using SteUp.Shared.Core.Authorization.Enum @using SteUp.Shared.Core.Authorization.Enum
@using SteUp.Shared.Core.Dto @using SteUp.Shared.Core.Dto
@using SteUp.Shared.Core.Interface.IntegryApi @using SteUp.Shared.Core.Interface.IntegryApi
@@ -10,109 +11,95 @@
@using SteUp.Shared.Core.Services @using SteUp.Shared.Core.Services
@using SteUp.Shared.Core.Utility @using SteUp.Shared.Core.Utility
@inject AppAuthenticationStateProvider AuthenticationStateProvider @inject AppAuthenticationStateProvider AuthenticationStateProvider
@inject INetworkService NetworkService
@inject IGenericSystemService GenericSystemService @inject IGenericSystemService GenericSystemService
@inject IFileManager FileManager @inject IFileManager FileManager
@inject IIntegryApiService IntegryApiService @inject IIntegryApiService IntegryApiService
<HeaderLayout Title="Profilo"/> <HeaderLayout Title="Profilo"/>
@if (IsLoggedIn) <div class="app-scroll">
{ <div class="page-width page-body stack stack-md">
<div class="container content pb-safe-area">
<div class="container-primary-info mud-elevation-1">
<div class="section-primary-info">
<MudAvatar Style="height: 70px; width: 70px; font-size: 2rem; font-weight: bold"
Color="Color.Secondary">
@UtilityString.ExtractInitials(UserSession.User.Fullname)
</MudAvatar>
<div class="personal-info"> @if (IsLoggedIn)
<span class="info-nome">@UserSession.User.Fullname</span> {
@* §6.16 Intestazione profilo *@
<section class="panel panel--lg">
<div class="profile-head">
<div class="profile-avatar" aria-hidden="true">
@UtilityString.ExtractInitials(UserSession.User.Fullname)
</div>
<div class="section-header__text">
<h2 class="summary__title">@UserSession.User.Fullname</h2>
@if (UserSession.User.KeyGroup is not null) @if (UserSession.User.KeyGroup is not null)
{ {
<span <p class="summary__subtitle">
class="info-section">@(((KeyGroupEnum)UserSession.User.KeyGroup).ConvertToHumanReadable())</span> @(((KeyGroupEnum)UserSession.User.KeyGroup).ConvertToHumanReadable())
</p>
} }
</div> </div>
</div> </div>
</section>
<div class="divider"></div> <section class="panel">
<div class="nav-list__item" style="pointer-events: none;">
<div class="section-info"> <i class="nav-list__icon ri-building-line" aria-hidden="true"></i>
<div class="section-personal-info"> <span class="nav-list__label">Profilo azienda</span>
<div> <span class="t-meta">@CodHash</span>
<span class="info-title">Profilo azienda</span>
<span class="info-text">@CodHash</span>
</div>
</div> </div>
<div class="section-personal-info"> <div class="nav-list__item" style="pointer-events: none;">
<div> <i class="nav-list__icon ri-cloud-line" aria-hidden="true"></i>
<span class="info-title">Status</span> <span class="nav-list__label">Connessione</span>
@if (NetworkService.ConnectionAvailable) <Badge Spec="@ConnessioneBadge"/>
{
<div class="status online">
<i class="ri-wifi-line"></i>
<span>Online</span>
</div> </div>
}
else <div class="nav-list__item" style="pointer-events: none;">
{ <i class="nav-list__icon ri-information-line" aria-hidden="true"></i>
<div class="status offline"> <span class="nav-list__label">Versione</span>
<i class="ri-wifi-off-line"></i> <span class="t-meta">@GenericSystemService.GetCurrentAppVersion()</span>
<span>Offline</span>
</div> </div>
</section>
<section class="panel">
<button type="button" class="nav-list__item" @onclick="UpdateDb">
<i class="nav-list__icon ri-refresh-line" aria-hidden="true"></i>
<span class="nav-list__label">Sincronizza ispezioni esportate</span>
<i class="nav-list__chevron ri-arrow-right-s-line" aria-hidden="true"></i>
</button>
<button type="button" class="nav-list__item" @onclick="ExportLog">
<i class="nav-list__icon ri-file-upload-line" aria-hidden="true"></i>
<span class="nav-list__label">Invia i log allo sviluppo</span>
<i class="nav-list__chevron ri-arrow-right-s-line" aria-hidden="true"></i>
</button>
@* Le voci distruttive portano l'icona nel tono danger e non hanno chevron. *@
<button type="button" class="nav-list__item nav-list__item--danger" @onclick="ConfirmLogout">
<i class="nav-list__icon ri-logout-box-r-line" aria-hidden="true"></i>
<span class="nav-list__label">Esci</span>
</button>
</section>
} }
</div> </div>
</div> </div>
</div>
</div>
<div class="container-button ripple-container mud-elevation-1"> <SpinnerOverlay VisibleOverlay="VisibleOverlay" Message="@_progressMessage"/>
<MudButton Class="button-settings red-icon" <ConfirmMessageBox @ref="_logoutBox" Title="Uscire dall'account?" NoText="Annulla"
FullWidth="true" YesText="Esci" YesColor="Color.Error"
StartIcon="@Icons.Material.Rounded.UploadFile" Message="Uscendo servirà di nuovo la connessione per rientrare. Le ispezioni salvate sul dispositivo restano al loro posto."/>
Size="Size.Medium"
OnClick="@ExportLog"
Variant="Variant.Outlined">
Esporta log
</MudButton>
</div>
<div class="container-button ripple-container mud-elevation-1">
<MudButton Class="button-settings green-icon"
FullWidth="true"
StartIcon="@Icons.Material.Outlined.Sync"
Size="Size.Medium"
OnClick="@UpdateDb"
Variant="Variant.Outlined">
Sincronizza ispezioni esportate
</MudButton>
</div>
<div class="container-button ripple-container mud-elevation-1">
<MudButton Class="button-settings exit"
FullWidth="true"
Color="Color.Error"
Size="Size.Medium"
OnClick="@Logout"
Variant="Variant.Outlined">
Esci
</MudButton>
</div>
<AppVersion/>
</div>
}
<SpinnerOverlay VisibleOverlay="VisibleOverlay"/>
@code { @code {
private bool IsLoggedIn { get; set; } private bool IsLoggedIn { get; set; }
private bool VisibleOverlay { get; set; } private bool VisibleOverlay { get; set; }
private string? _progressMessage;
private string? CodHash { get; set; } = ""; private string? CodHash { get; set; } = "";
private ConfirmMessageBox _logoutBox = null!;
private Core.Helpers.StatusBadgeHelper.BadgeSpec ConnessioneBadge => NetworkService.ConnectionAvailable
? new Core.Helpers.StatusBadgeHelper.BadgeSpec("success", "ri-cloud-line", "Online")
: new Core.Helpers.StatusBadgeHelper.BadgeSpec("danger", "ri-wifi-off-line", "Offline");
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
IsLoggedIn = await UserSession.IsLoggedIn(); IsLoggedIn = await UserSession.IsLoggedIn();
@@ -122,9 +109,12 @@
private async Task ExportLog() private async Task ExportLog()
{ {
_progressMessage = "Invio dei log…";
VisibleOverlay = true; VisibleOverlay = true;
StateHasChanged(); StateHasChanged();
try
{
var profiloAzienda = LocalStorage.GetString("codHash"); var profiloAzienda = LocalStorage.GetString("codHash");
var email = new SendEmailDto var email = new SendEmailDto
@@ -141,27 +131,50 @@
}; };
await IntegryApiService.SendEmail(email); await IntegryApiService.SendEmail(email);
Snackbar.Add("Log inviati allo sviluppo", Severity.Success);
}
catch
{
Snackbar.Add("Invio dei log non riuscito. Controlla la connessione e riprova.", Severity.Error);
}
finally
{
VisibleOverlay = false; VisibleOverlay = false;
_progressMessage = null;
StateHasChanged(); StateHasChanged();
} }
}
private async Task UpdateDb() private async Task UpdateDb()
{ {
_progressMessage = "Sincronizzazione…";
VisibleOverlay = true; VisibleOverlay = true;
StateHasChanged(); StateHasChanged();
try
{
await SteupDataService.CheckAndUpdateStatus(); await SteupDataService.CheckAndUpdateStatus();
Snackbar.Add("Ispezioni aggiornate", Severity.Success);
}
catch
{
Snackbar.Add("Sincronizzazione non riuscita. Controlla la connessione e riprova.", Severity.Error);
}
finally
{
VisibleOverlay = false; VisibleOverlay = false;
_progressMessage = null;
StateHasChanged(); StateHasChanged();
} }
}
private async Task Logout() private async Task ConfirmLogout()
{ {
var result = await _logoutBox.ShowAsync();
if (result is not true) return;
await AuthenticationStateProvider.SignOut(); await AuthenticationStateProvider.SignOut();
IsLoggedIn = await UserSession.IsLoggedIn(); IsLoggedIn = await UserSession.IsLoggedIn();
StateHasChanged(); StateHasChanged();
} }
} }
@@ -1,83 +0,0 @@
.container-primary-info {
/*background: var(--light-card-background);*/
width: 100%;
margin-bottom: 2rem;
border-radius: 20px;
}
.container-primary-info .divider {
margin: 0 0 0 7rem;
width: unset;
}
.section-primary-info {
display: flex;
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: .8rem 1.2rem .4rem;
}
.personal-info {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: normal;
}
.info-nome {
color: var(--mud-palette-text-primary);
font-weight: 800;
font-size: x-large;
}
.info-section {
color: var(--mud-palette-gray-default);
font-size: medium;
font-weight: 600;
}
.section-info {
display: flex;
justify-content: space-between;
flex-direction: row;
padding: .4rem 1.2rem .8rem;
}
.section-personal-info {
display: flex;
flex-direction: column;
}
.section-personal-info > div {
display: flex;
flex-direction: column;
line-height: normal;
margin: .25rem 0;
}
.info-title {
color: var(--mud-palette-gray-darker);
font-weight: 800;
}
.info-text {
color: var(--mud-palette-text-secondary);
font-weight: 700;
font-size: small;
}
.content ::deep .user-button { border: 1px solid var(--card-border-color) !important; }
.user-button > i { font-size: large; }
.user-button > span {
font-size: medium;
font-weight: 600;
}
.status { font-weight: 700; }
.status.online { color: var(--mud-palette-success); }
.status.offline { color: var(--mud-palette-error); }
@@ -1,6 +0,0 @@
@using SteUp.Shared.Core.Interface.System
@inject IGenericSystemService GenericSystemService
<div class="app-version">
<span>@GenericSystemService.GetCurrentAppVersion()</span>
</div>
@@ -1,11 +0,0 @@
.app-version{
width: 100%;
display: flex;
justify-content: center;
margin: 8px 0;
}
.app-version span{
font-size: smaller;
color: var(--mud-palette-gray-darker);
}
@@ -0,0 +1,20 @@
@using SteUp.Shared.Core.Helpers
@*
Badge di stato (STILE_UI §6.4). Non riceve colori: riceve uno stato, e la
mappatura in StatusBadgeHelper decide tono, icona ed etichetta.
*@
<span class="badge badge--@Spec.Tone" aria-label="@(AriaLabel ?? Spec.Label)">
<i class="badge__icon @Spec.Icon" aria-hidden="true"></i>
<span>@(Text ?? Spec.Label)</span>
</span>
@code {
[Parameter] public required StatusBadgeHelper.BadgeSpec Spec { get; set; }
/// Testo alternativo, per i casi in cui l'etichetta porta anche un conteggio.
[Parameter] public string? Text { get; set; }
[Parameter] public string? AriaLabel { get; set; }
}
@@ -1,105 +1,87 @@
@using SteUp.Shared.Core.Dto @using SteUp.Shared.Core.Dto
@using SteUp.Shared.Core.Entities @using SteUp.Shared.Core.Entities
@using SteUp.Shared.Core.Helpers
<div class="container-primary-info mud-elevation-1 @Class @(OnClick.HasDelegate ? "ripple-container" : "")" @onclick="OnCardClick"> @*
<div class="section-primary-info"> Card di elenco (STILE_UI §6.5). Il titolo e' l'entita' — il punto vendita —
<div class="inspection-info"> non il tipo di record. I conteggi stanno sempre a vista: il rilevatore deve
@if (CompactView) sapere quanto ha davanti senza contare.
{ *@
if (OnLoading)
{ <div class="list-card @(Tappable ? "panel--tappable" : "")"
<MudSkeleton Width="40vw"/> @onclick="OnCardClick"
} role="@(Tappable ? "button" : null)"
else tabindex="@(Tappable ? 0 : -1)"
{ aria-label="@Ispezione.ToSemanticLabel()">
<span class="info-title compactView">
@PuntoVendita.CodMdep - @PuntoVendita.Descrizione <div class="list-card__head">
</span> <div class="list-card__ident">
} <div class="t-ident">@Ispezione.CodMdep</div>
} <h3 class="list-card__title">@Descrizione</h3>
else
{
if (OnLoading)
{
<MudSkeleton Width="40vw"/>
<MudSkeleton Width="55vw"/>
}
else
{
<span class="info-title">
@PuntoVendita.CodMdep - @PuntoVendita.Descrizione
</span>
<span class="info-subtitle">
@PuntoVendita.Indirizzo
</span>
}
}
</div> </div>
</div> </div>
<div class="divider"></div> @if (!string.IsNullOrWhiteSpace(Indirizzo))
{
<p class="list-card__meta">@Indirizzo</p>
}
<div class="section-info"> <div class="list-card__badges badge-row">
<div class="section-inspection-info"> <Badge Spec="@Ispezione.Stato.ToBadge()"/>
<div>
<span class="info-inspection-title">Data ispezione</span> @if (DaInviare > 0)
@if (OnLoading)
{ {
<MudSkeleton Width="100%"/> <Badge Spec="@SyncState.DaInviare.ToBadge()" Text="@($"{DaInviare} da inviare")"/>
} }
else
@if (Bozze > 0)
{ {
<MudChip T="string" Size="Size.Small" Color="Color.Default"> <Badge Spec="@SyncState.Bozza.ToBadge()" Text="@($"{Bozze} da completare")"/>
@Ispezione.Data.ToString("d")
</MudChip>
} }
</div> </div>
</div>
<div class="section-inspection-info"> <div class="list-card__footer">
<div> <i class="icon ri-calendar-line" aria-hidden="true"></i>
<span class="info-inspection-title">Stato ispezione</span> <span>@Ispezione.Data.ToString("d MMMM yyyy")</span>
@if (OnLoading) <span aria-hidden="true">·</span>
{ <span>@SchedeLabel</span>
<MudSkeleton Width="100%"/>
}
else
{
<MudChip T="string" Size="Size.Small" Color="@Ispezione.Stato.GetColor()">
@Ispezione.Stato.ConvertToHumanReadable()
</MudChip>
}
</div>
</div>
</div> </div>
</div> </div>
@code @code
{ {
[Parameter] public string Class { get; set; } = string.Empty;
[Parameter] public Ispezione Ispezione { get; set; } = new(); [Parameter] public Ispezione Ispezione { get; set; } = new();
[Parameter] public bool CompactView { get; set; }
[Parameter] public EventCallback<Ispezione> OnClick { get; set; } [Parameter] public EventCallback<Ispezione> OnClick { get; set; }
private PuntoVenditaDto PuntoVendita { get; set; } = new(); private bool Tappable => OnClick.HasDelegate;
private bool OnLoading { get; set; } = true; private PuntoVenditaDto? _puntoVendita;
protected override async Task OnParametersSetAsync() // Il punto vendita puo' mancare dalla cache di riferimento (primo avvio
// offline): si degrada al codice invece di far fallire la card.
private string Descrizione => _puntoVendita?.Descrizione.IsNullOrEmpty() == false
? _puntoVendita.Descrizione!
: Ispezione.CodMdep;
private string? Indirizzo => _puntoVendita is null
? null
: string.Join(" · ", new[] { _puntoVendita.Indirizzo, _puntoVendita.Citta }
.Where(x => !x.IsNullOrEmpty()));
private int DaInviare => Ispezione.Schede.CountDaInviare();
private int Bozze => Ispezione.Schede.CountBozze();
private string SchedeLabel => Ispezione.Schede.Count == 1
? "1 scheda"
: $"{Ispezione.Schede.Count} schede";
protected override void OnParametersSet()
{ {
OnLoading = true; _puntoVendita = SteupDataService.PuntiVenditaList.Find(x =>
StateHasChanged();
var puntoVendita = SteupDataService.PuntiVenditaList.Find(x =>
x.CodMdep != null && x.CodMdep.EqualsIgnoreCase(Ispezione.CodMdep) x.CodMdep != null && x.CodMdep.EqualsIgnoreCase(Ispezione.CodMdep)
); );
await Task.Delay(250);
PuntoVendita = puntoVendita ?? throw new Exception("Punto vendita non trovato");
OnLoading = false;
StateHasChanged();
} }
private Task OnCardClick() => private Task OnCardClick() =>
OnClick.HasDelegate ? OnClick.InvokeAsync(Ispezione) : Task.CompletedTask; Tappable ? OnClick.InvokeAsync(Ispezione) : Task.CompletedTask;
} }
@@ -1,83 +0,0 @@
.container-primary-info {
/*background: var(--light-card-background);*/
width: 100%;
margin-bottom: 2rem;
border-radius: 20px;
}
.container-primary-info.no-margin {
margin-bottom: 0 !important;
min-height: max-content;
}
.container-primary-info .divider {
margin: .25rem 0;
}
.section-primary-info {
display: flex;
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: .8rem 1.2rem .4rem;
}
.personal-info {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: normal;
}
.inspection-info{
display: flex;
flex-direction: column;
}
.info-title {
color: var(--mud-palette-text-primary);
font-weight: 800;
font-size: x-large;
line-height: normal;
}
.info-subtitle {
color: var(--mud-palette-gray-default);
font-size: medium;
font-weight: 600;
line-height: normal;
}
.info-title.compactView{
font-size: medium;
}
.section-info {
display: flex;
justify-content: space-between;
flex-direction: row;
padding: .4rem 1.2rem .8rem;
}
.section-inspection-info {
display: flex;
flex-direction: column;
}
.section-inspection-info > div {
display: flex;
flex-direction: column;
line-height: normal;
margin: .25rem 0;
}
.info-inspection-title {
color: var(--mud-palette-gray-darker);
font-weight: 800;
}
.info-inspection-text {
color: var(--mud-palette-text-secondary);
font-weight: 700;
font-size: small;
}
@@ -1,23 +1,29 @@
<div class="card-form-modal"> @* Gruppo di campo del form scheda: un pannello con la sua etichetta e,
<div class="card-form-modal-title @ClassTitle"> quando serve, il conteggio di cio' che contiene. *@
<MudText Style="font-weight: 800" Typo="Typo.subtitle1">@Title</MudText>
<div class="form-group @Class">
<div class="form-group__label">
<span>@Title</span>
@if (!string.IsNullOrWhiteSpace(Count))
{
<span class="form-group__count">@Count</span>
}
</div> </div>
<div class="card-form-modal-body @ClassBody">
@if (Loading) @if (Loading)
{ {
<MudSkeleton/> <div class="skeleton skeleton--line" style="height: 24px;" aria-hidden="true"></div>
} }
else else
{ {
@ChildContent @ChildContent
} }
</div>
</div> </div>
@code { @code {
[Parameter] public string Title { get; set; } = ""; [Parameter] public string Title { get; set; } = "";
[Parameter] public string? Count { get; set; }
[Parameter] public bool Loading { get; set; } [Parameter] public bool Loading { get; set; }
[Parameter] public string Class { get; set; } = "";
[Parameter] public RenderFragment? ChildContent { get; set; } [Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public string ClassTitle { get; set; } = "";
[Parameter] public string ClassBody { get; set; } = "";
} }
@@ -1,23 +0,0 @@
.card-form-modal {
background: var(--mud-palette-table-striped);
border-radius: 20px;
padding: .5rem 1rem;
width: -webkit-fill-available;
}
.is-dark .card-form-modal {
background: var(--mud-palette-background); !important;
}
.card-form-modal-body ::deep .file-upload-input {
position: absolute;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 10;
opacity: 0;
}
small ::deep {
color: var(--mud-palette-text-primary) !important;
}
@@ -1,77 +1,138 @@
@using SteUp.Shared.Components.Layout.Overlay @using SteUp.Shared.Components.SingleElements.MessageBox
@using SteUp.Shared.Components.SingleElements.MessageBox
@using SteUp.Shared.Core.Dto @using SteUp.Shared.Core.Dto
@using SteUp.Shared.Core.Entities @using SteUp.Shared.Core.Entities
@using SteUp.Shared.Core.Enum @using SteUp.Shared.Core.Enum
@using SteUp.Shared.Core.Helpers
@using SteUp.Shared.Core.Interface.IntegryApi @using SteUp.Shared.Core.Interface.IntegryApi
@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 Microsoft.Extensions.Logging
@inject IIspezioniService IspezioniService @inject IIspezioniService IspezioniService
@inject IIntegrySteupService IntegrySteupService @inject IIntegrySteupService IntegrySteupService
@inject IDialogService Dialog @inject IDialogService Dialog
@inject IFileManager FileManager @inject IFileManager FileManager
@inject ILogger<SchedaCard> Logger
<div class="scheda-card"> <div class="list-card" aria-label="@SemanticLabel">
<div class="scheda-body-section"> <div class="list-card__head">
<div class="title-section"> <div class="list-card__ident">
<MudText Class="scheda-title" Typo="Typo.subtitle1"> <div class="t-ident">@Scheda.DescrizioneReparto</div>
<b>@Scheda.ActivityTypeId</b> <h3 class="list-card__title">@Titolo</h3>
</MudText>
<div class="sub-info-section">
<MudChip T="string" Icon="@Icons.Material.Rounded.PhotoCamera" Size="Size.Small" Color="Color.Default">
@Scheda.ImageNames.Count()
</MudChip>
<MudChip T="string" Icon="@Icons.Material.Rounded.QrCode2" Size="Size.Small" Color="Color.Default">
@Scheda.Articoli.Count
</MudChip>
</div> </div>
</div> </div>
<div class="scheda-card-action"> <div class="list-card__badges badge-row">
@if (Ispezione.Stato != StatusEnum.Completata) <Badge Spec="@SyncState.ToBadge()"/>
<Badge Spec="@ScadenzaBadge" />
</div>
@if (!Scheda.Note.IsNullOrEmpty())
{ {
if (NetworkService.IsNetworkAvailable()) <p class="list-card__meta t-prose">@Scheda.Note</p>
{
<MudFab Color="Color.Info" Size="Size.Small" StartIcon="@Icons.Material.Rounded.Edit"
OnClick="@UpdateScheda"/>
<MudFab Color="Color.Error" Size="Size.Small" StartIcon="@Icons.Material.Rounded.Delete"
OnClick="@DeleteScheda"/>
<MudFab Color="Color.Success" Size="Size.Small" StartIcon="@Icons.Material.Rounded.CloudSync"
OnClick="@ExportScheda"/>
} }
else
<div class="list-card__footer">
<i class="icon ri-qr-code-line" aria-hidden="true"></i>
<span>@ArticoliLabel</span>
<span aria-hidden="true">·</span>
<i class="icon ri-camera-line" aria-hidden="true"></i>
<span>@FotoLabel</span>
</div>
<div class="list-card__actions">
@if (ReadOnly)
{ {
<MudFab Color="Color.Info" Size="Size.Small" StartIcon="@Icons.Material.Rounded.Edit" <MudButton Variant="Variant.Outlined" FullWidth="true"
OnClick="@UpdateScheda"/> StartIcon="@Icons.Material.Rounded.Visibility" OnClick="@ViewScheda">
<MudFab Color="Color.Error" Size="Size.Small" StartIcon="@Icons.Material.Rounded.Delete" Apri
OnClick="@DeleteScheda"/> </MudButton>
@if (Scheda.ImageNames?.Count > 0)
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
StartIcon="@Icons.Material.Rounded.CloudUpload" OnClick="@ExportImg">
Invia foto
</MudButton>
} }
} }
else else
{ {
<MudFab Color="Color.Info" Size="Size.Small" StartIcon="@Icons.Material.Rounded.RemoveRedEye" <MudButton Variant="Variant.Outlined" FullWidth="true"
OnClick="@ViewScheda"/> StartIcon="@Icons.Material.Rounded.Edit" OnClick="@UpdateScheda">
<MudFab Color="Color.Success" Size="Size.Small" StartIcon="@Icons.Material.Rounded.CloudSync" @(IsBozza ? "Completa" : "Modifica")
OnClick="@ExportImg"/> </MudButton>
@if (NetworkService.IsNetworkAvailable() && SyncState == Core.Helpers.SyncState.DaInviare)
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
StartIcon="@Icons.Material.Rounded.CloudUpload" OnClick="@ExportScheda">
Invia
</MudButton>
}
} }
</div> </div>
</div>
@if (!ReadOnly)
{
<MudButton Class="mt-2" Variant="Variant.Text" FullWidth="true"
StartIcon="@Icons.Material.Rounded.DeleteOutline"
Style="color: var(--danger-ink)" OnClick="@DeleteScheda">
Elimina scheda
</MudButton>
}
</div> </div>
<SpinnerOverlay VisibleOverlay="VisibleOverlay"/> <ConfirmMessageBox @ref="_messageBox" Title="Eliminare la scheda?" NoText="Annulla"
<ConfirmMessageBox @ref="_messageBox" YesText="Cancella" YesColor="Color.Error" YesText="Elimina" YesColor="Color.Error"
Message="Confermi la cancellazione della scheda corrente?"/> Message="La scheda verrà eliminata dal dispositivo e, se già inviata, anche dal server. L'operazione non è reversibile."/>
@code{ @code{
[Parameter] public Ispezione Ispezione { get; set; } = new(); [Parameter] public Ispezione Ispezione { get; set; } = new();
[Parameter] public required Scheda Scheda { get; set; } [Parameter] public required Scheda Scheda { get; set; }
[Parameter] public EventCallback<Scheda> OnSchedaModified { get; set; } [Parameter] public EventCallback<Scheda> OnSchedaModified { get; set; }
[Parameter] public EventCallback<Scheda> OnSchedaDeleted { get; set; } [Parameter] public EventCallback<Scheda> OnSchedaDeleted { get; set; }
[Parameter] public EventCallback<bool> OnBusyChanged { get; set; }
private bool VisibleOverlay { get; set; }
private ConfirmMessageBox _messageBox = null!; private ConfirmMessageBox _messageBox = null!;
private bool _busy;
private bool ReadOnly => Ispezione.Stato == StatusEnum.Completata;
private SyncState SyncState => Scheda.GetSyncState();
private bool IsBozza => SyncState == Core.Helpers.SyncState.Bozza;
// Una bozza puo' non avere ancora il motivo: meglio dirlo che lasciare un vuoto.
private string Titolo => Scheda.ActivityTypeId.IsNullOrEmpty()
? "Motivo da indicare"
: Scheda.ActivityTypeId!;
private StatusBadgeHelper.BadgeSpec ScadenzaBadge => (ScadenzaEnum)Scheda.Scadenza switch
{
ScadenzaEnum.Altissima24Ore => new StatusBadgeHelper.BadgeSpec("danger", "ri-alarm-warning-line", "Entro 24 ore"),
ScadenzaEnum.Alta1Settimana => new StatusBadgeHelper.BadgeSpec("warning", "ri-time-line", "Entro 1 settimana"),
ScadenzaEnum.Media1Mese => new StatusBadgeHelper.BadgeSpec("info", "ri-calendar-line", "Entro 1 mese"),
_ => new StatusBadgeHelper.BadgeSpec("neutral", "ri-calendar-line", "Entro 2 mesi")
};
private string ArticoliLabel => Scheda.Articoli.Count == 1
? "1 articolo"
: $"{Scheda.Articoli.Count} articoli";
private string FotoLabel => Scheda.ImageNames?.Count switch
{
null or 0 => "nessuna foto",
1 => "1 foto",
var n => $"{n} foto"
};
private string SemanticLabel =>
$"Scheda {Titolo}, reparto {Scheda.DescrizioneReparto}, {SyncState.ToBadge().Label}, {ArticoliLabel}, {FotoLabel}";
private async Task SetBusy(bool value)
{
_busy = value;
await OnBusyChanged.InvokeAsync(value);
StateHasChanged();
}
private async Task UpdateScheda() private async Task UpdateScheda()
{ {
@@ -79,15 +140,18 @@
if (modal is { Canceled: false, Data: Scheda scheda }) await OnSchedaModified.InvokeAsync(scheda); if (modal is { Canceled: false, Data: Scheda scheda }) await OnSchedaModified.InvokeAsync(scheda);
} }
private void ViewScheda() =>
_ = Dialog.OpenFormScheda(Ispezione.CodMdep, Ispezione.Data, false, Scheda, true);
private async Task DeleteScheda() private async Task DeleteScheda()
{ {
var result = await _messageBox.ShowAsync(); var result = await _messageBox.ShowAsync();
if (result is not true) return;
if (result is true) await SetBusy(true);
try
{ {
VisibleOverlay = true;
StateHasChanged();
if (Scheda.ActivityId != null) if (Scheda.ActivityId != null)
await IntegrySteupService.DeleteScheda(Scheda.ActivityId); await IntegrySteupService.DeleteScheda(Scheda.ActivityId);
@@ -101,20 +165,21 @@
var deleteScheda = await IspezioniService.DeleteSchedaAsync(Scheda.Id); var deleteScheda = await IspezioniService.DeleteSchedaAsync(Scheda.Id);
if (deleteScheda) await OnSchedaDeleted.InvokeAsync(Scheda); if (deleteScheda) await OnSchedaDeleted.InvokeAsync(Scheda);
}
VisibleOverlay = false; finally
StateHasChanged(); {
await SetBusy(false);
} }
} }
private void ViewScheda() =>
_ = Dialog.OpenFormScheda(Ispezione.CodMdep, Ispezione.Data, false, Scheda, true);
private async Task ExportScheda() private async Task ExportScheda()
{ {
VisibleOverlay = true; // Guardia anti doppio-tap: invio irreversibile al server.
StateHasChanged(); if (_busy) return;
await SetBusy(true);
try
{
var apiResponse = await IntegrySteupService.SaveScheda( var apiResponse = await IntegrySteupService.SaveScheda(
new SaveRequestDto new SaveRequestDto
{ {
@@ -127,12 +192,17 @@
PersonaRif = Scheda.Responsabile, PersonaRif = Scheda.Responsabile,
Barcodes = Scheda.Articoli.ConvertAll(x => x.Barcode), Barcodes = Scheda.Articoli.ConvertAll(x => x.Barcode),
Scandeza = (ScadenzaEnum)Scheda.Scadenza, Scandeza = (ScadenzaEnum)Scheda.Scadenza,
ParentActivityId = Scheda.Ispezione?.ActivityId // Aggancia all'ispezione esistente sul server (evita di crearne una nuova a ogni invio).
ParentActivityId = SteupDataService.InspectionPageState.Ispezione.ActivityId
} }
); );
if (apiResponse != null) if (apiResponse == null)
{ {
Snackbar.Add("Invio non riuscito. La scheda resta sul dispositivo, riprova.", Severity.Error);
return;
}
Scheda.ActivityId = apiResponse.ActivityIdScheda; Scheda.ActivityId = apiResponse.ActivityIdScheda;
SteupDataService.InspectionPageState.Ispezione.ActivityId = apiResponse.ActivityIdIspezione; SteupDataService.InspectionPageState.Ispezione.ActivityId = apiResponse.ActivityIdIspezione;
@@ -140,24 +210,54 @@
UserSession.User.Username, apiResponse.ActivityIdIspezione UserSession.User.Username, apiResponse.ActivityIdIspezione
); );
await IspezioniService.UpdateActivityIdSchedaAsync(Scheda.Id, Scheda.ActivityId); await IspezioniService.UpdateActivityIdSchedaAsync(Scheda.Id, Scheda.ActivityId);
}
VisibleOverlay = false; // Notifica il genitore così il contatore "da inviare" del reparto si aggiorna.
StateHasChanged(); await OnSchedaModified.InvokeAsync(Scheda);
Snackbar.Add("Scheda inviata", Severity.Success);
}
catch (Exception e)
{
Snackbar.Add("Invio non riuscito. La scheda resta sul dispositivo, riprova.", Severity.Error);
Logger.LogError(e, e.Message);
}
finally
{
await SetBusy(false);
}
} }
private async Task ExportImg() private async Task ExportImg()
{ {
if (Scheda.ImageNames == null) return; if (_busy || Scheda.ImageNames == null) return;
await SetBusy(true);
try
{
var fileList = (await FileManager.GetInspectionFiles(Ispezione, Scheda.ImageNames))? var fileList = (await FileManager.GetInspectionFiles(Ispezione, Scheda.ImageNames))?
.Where(x => x.ToUpload).ToList(); .Where(x => x.ToUpload).ToList();
if (fileList == null) return; if (fileList == null || fileList.Count == 0)
{
Snackbar.Add("Nessuna nuova foto da inviare", Severity.Info);
return;
}
foreach (var file in fileList) foreach (var file in fileList)
{ {
await IntegrySteupService.UploadFile(Scheda.ActivityId!, file.FileBytes!, file.Name!); await IntegrySteupService.UploadFile(Scheda.ActivityId!, file.FileBytes!, file.Name!);
} }
Snackbar.Add(fileList.Count == 1 ? "Foto inviata" : $"{fileList.Count} foto inviate", Severity.Success);
}
catch (Exception e)
{
Snackbar.Add("Invio foto non riuscito, riprova.", Severity.Error);
Logger.LogError(e, e.Message);
}
finally
{
await SetBusy(false);
}
} }
} }
@@ -1,23 +0,0 @@
.scheda-card{
padding: .75rem 1.25rem;
background-color: var(--light-card-background);
border-radius: 1em;
}
.scheda-body-section{
display: flex;
flex-direction: column;
gap: 1rem;
}
.title-section{
display: flex;
align-items: center;
justify-content: space-between;
}
.scheda-card-action{
display: flex;
gap: 1rem;
justify-content: center;
}
@@ -1,49 +1,67 @@
@using SteUp.Shared.Components.Layout.Overlay
@using SteUp.Shared.Core.Dto @using SteUp.Shared.Core.Dto
@using SteUp.Shared.Core.Helpers
@using SteUp.Shared.Core.Interface.LocalDb @using SteUp.Shared.Core.Interface.LocalDb
@inject IIspezioniService IspezioniService @inject IIspezioniService IspezioniService
<div class="shop-card ripple-container" @onclick="StartInspection"> @*
<div class="shop-body-section"> Riga di scelta di un punto vendita. E' un <button>: si raggiunge da
<div class="title-section"> tastiera, il lettore di schermo la annuncia per quello che e', e il tocco
<MudText Class="shop-title" Typo="Typo.subtitle1"> si comporta come quello nativo. Il chevron dice che porta altrove.
<b>@PuntoVendita.CodMdep</b> - @PuntoVendita.Descrizione *@
</MudText>
</div>
@if (!PuntoVendita.Indirizzo.IsNullOrEmpty()) <button type="button" class="list-card shop-card panel--tappable" @onclick="StartInspection"
aria-label="@AriaLabel">
<span class="shop-card__text">
<span class="t-ident">@PuntoVendita.CodMdep</span>
<span class="list-card__title">@PuntoVendita.Descrizione</span>
@if (!Luogo.IsNullOrEmpty())
{ {
<div class="subtitle-section"> <span class="list-card__meta">@Luogo</span>
<MudText Class="shop-title" Typo="Typo.subtitle1">
@PuntoVendita.Indirizzo
</MudText>
</div>
<div class="sub-info-section">
<MudChip T="string" Icon="@Icons.Material.Rounded.LocationCity" Size="Size.Small" Color="Color.Default">
@PuntoVendita.Citta
</MudChip>
<MudChip T="string" Size="Size.Small" Color="Color.Default">
@PuntoVendita.Cap
</MudChip>
<MudChip T="string" Size="Size.Small" Color="Color.Default">
@PuntoVendita.Provincia
</MudChip>
</div>
} }
</div>
</div>
<SpinnerOverlay VisibleOverlay="VisibleOverlay"/> @if (Aperta)
{
@* Toccando si riapre l'ispezione di oggi invece di crearne una
nuova: meglio dirlo prima del tocco. *@
<span class="list-card__badges badge-row">
<Badge Spec="@ApertaBadge"/>
</span>
}
</span>
<i class="shop-card__chevron ri-arrow-right-s-line" aria-hidden="true"></i>
</button>
@code { @code {
[Parameter] public PuntoVenditaDto PuntoVendita { get; set; } = null!; [Parameter] public PuntoVenditaDto PuntoVendita { get; set; } = null!;
private bool VisibleOverlay { get; set; } [Parameter] public EventCallback OnStarting { get; set; }
/// Vero se il rilevatore ha gia' un'ispezione aperta oggi su questo punto vendita.
[Parameter] public bool Aperta { get; set; }
private static readonly StatusBadgeHelper.BadgeSpec ApertaBadge =
new("info", "ri-play-circle-line", "Ispezione di oggi");
// Indirizzo, citta', CAP e provincia su una riga sola con separatore " · ":
// sono metadati, non meritano tre chip che fanno rumore.
private string Luogo => string.Join(" · ", new[]
{
PuntoVendita.Indirizzo,
PuntoVendita.Citta,
string.Join(" ", new[] { PuntoVendita.Cap, PuntoVendita.Provincia }
.Where(x => !x.IsNullOrEmpty()))
}
.Where(x => !x.IsNullOrEmpty()));
private string AriaLabel => Aperta
? $"Riprendi l'ispezione di oggi su {PuntoVendita.CodMdep} {PuntoVendita.Descrizione}"
: $"Apri ispezione su {PuntoVendita.CodMdep} {PuntoVendita.Descrizione}";
private async Task StartInspection() private async Task StartInspection()
{ {
VisibleOverlay = true; await OnStarting.InvokeAsync();
StateHasChanged();
SteupDataService.InspectionPageState.Ispezione = await IspezioniService.GetOrCreateIspezioneAsync( SteupDataService.InspectionPageState.Ispezione = await IspezioniService.GetOrCreateIspezioneAsync(
PuntoVendita.CodMdep!, PuntoVendita.CodMdep!,
@@ -51,10 +69,6 @@
UserSession.User.Username UserSession.User.Username
); );
VisibleOverlay = false;
StateHasChanged();
NavigationManager.NavigateTo("/ispezione"); NavigationManager.NavigateTo("/ispezione");
} }
} }
@@ -1,5 +0,0 @@
.shop-card{
padding: .5rem 1rem;
background-color: var(--mud-palette-background-gray);
border-radius: 1em;
}
@@ -1,26 +1,35 @@
<div class="Connection @(ShowWarning ? "Show" : "Hide") @(IsNetworkAvailable? ServicesIsDown ? "ServicesIsDown" : "SystemOk" : "NetworkKo")"> @*
@if (IsNetworkAvailable) La firma visiva di "Offline e' la verita'": una striscia sottile ancorata in
{ cima che scende quando lo stato di rete cambia. Mai il solo colore: sempre
if(ServicesIsDown) icona piu' etichetta, leggibile anche in pieno sole.
{ *@
<i class="ri-cloud-off-fill"></i>
<span>Servizi offline</span> <div class="connection @(ShowWarning ? "connection--show" : "") @ToneClass"
} role="status" aria-live="polite">
else <i class="@IconClass" aria-hidden="true"></i>
{ <span>@Label</span>
<i class="ri-cloud-fill"></i>
<span>Online</span>
}
}
else
{
<i class="ri-wifi-off-line"></i>
<span>Nessuna connessione</span>
}
</div> </div>
@code{ @code{
[Parameter] public bool IsNetworkAvailable { get; set; } [Parameter] public bool IsNetworkAvailable { get; set; }
[Parameter] public bool ServicesIsDown { get; set; } [Parameter] public bool ServicesIsDown { get; set; }
[Parameter] public bool ShowWarning { get; set; } [Parameter] public bool ShowWarning { get; set; }
private string ToneClass => !IsNetworkAvailable
? "connection--offline"
: ServicesIsDown
? "connection--down"
: "connection--ok";
private string IconClass => !IsNetworkAvailable
? "ri-wifi-off-line"
: ServicesIsDown
? "ri-cloud-off-line"
: "ri-cloud-line";
private string Label => !IsNetworkAvailable
? "Nessuna connessione · il lavoro resta sul dispositivo"
: ServicesIsDown
? "Servizi non raggiungibili"
: "Di nuovo online";
} }
@@ -1,4 +1,9 @@
<MudMessageBox @ref="_confirmDelete" Title="Attenzione!" CancelText="@NoText"> @* Conferma di un'azione. Il titolo dice cosa sta per succedere, il messaggio
che conseguenze ha: davanti a un'azione irreversibile la domanda deve
essere leggibile prima del testo, non dedotta da esso.
YesText -> true, NoText -> false, chiusura senza scelta -> null. *@
<MudMessageBox @ref="_confirmDelete" Title="@Title" CancelText="@NoText">
<MessageContent> <MessageContent>
@Message @Message
</MessageContent> </MessageContent>
@@ -11,6 +16,7 @@
@code @code
{ {
[Parameter] public string Title { get; set; } = "Attenzione";
[Parameter] public string Message { get; set; } = string.Empty; [Parameter] public string Message { get; set; } = string.Empty;
[Parameter] public string YesText { get; set; } = string.Empty; [Parameter] public string YesText { get; set; } = string.Empty;
[Parameter] public string NoText { get; set; } = "Annulla"; [Parameter] public string NoText { get; set; } = "Annulla";
@@ -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">
@@ -1,25 +1,33 @@
@using Microsoft.Extensions.Logging @using Microsoft.Extensions.Logging
@inject ILogger<ExceptionModal> Logger @inject ILogger<ExceptionModal> Logger
<div class="container container-modal"> @*
<div class="c-modal"> Schermata di ripiego quando un componente va in errore. Non lascia il
<div class="exception-header mb-2"> rilevatore davanti a uno stack trace: dice cosa è successo in una frase e
<i class="ri-emotion-unhappy-line"></i> offre due vie d'uscita. Il dettaglio tecnico resta sotto, per l'assistenza.
<span>Ops</span> *@
</div>
<div class="text"> <div class="onboarding">
@if (Exception != null) <div class="onboarding__panel">
{ <div class="panel panel--lg">
<code>@Message</code> <div class="dialog-icon dialog-icon--danger" aria-hidden="true">
} <i class="ri-error-warning-line"></i>
</div>
<div class="button-container">
<div @onclick="OnRetryClick" class="card-button">
<span>Riprova</span>
</div> </div>
<div @onclick="OnContinueClick" class="card-button"> <h1 class="dialog-title">Qualcosa si è inceppato</h1>
<span>Continua</span> <p class="dialog-message">
Il lavoro salvato sul dispositivo non è andato perso. Riprova, e se succede di nuovo
invia i log dalla schermata Profilo.
</p>
@if (!string.IsNullOrWhiteSpace(Message))
{
<p class="dialog-detail selectable">@Message</p>
}
<div class="action-bar" style="border: none; background: none; padding: var(--space-lg) 0 0;">
<MudButton Variant="Variant.Outlined" OnClick="@OnContinueClick">Torna all'elenco</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@OnRetryClick">Riprova</MudButton>
</div> </div>
</div> </div>
</div> </div>
@@ -1,61 +0,0 @@
.container-modal {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.c-modal {
border-radius: 16px;
box-shadow: var(--exception-box-shadow);
padding: 16px;
}
.button-container {
display: flex;
gap: 1rem;
flex-direction: row;
align-items: center;
width: 100%;
justify-content: space-between;
margin: 1.5rem 0 0 0;
}
.text {
font-size: medium;
font-weight: 500;
display: flex;
text-align: center;
}
.card-button {
text-align: center;
background-color: transparent;
padding: .3rem 1rem;
font-weight: 700;
color: var(--bs-primary-text-emphasis);
}
.exception-header {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.exception-header > i {
font-size: 3rem;
line-height: normal;
color: var(--bs-danger);
}
.exception-header > span {
font-size: x-large;
font-weight: 700;
}
code {
width: 100%;
height: auto;
color: var(--bs-gray-dark);
}
@@ -1,27 +1,24 @@
<MudDialog OnBackdropClick="Cancel"> @*
Dialog informativo (STILE_UI §6.10): cerchio-icona del tono, titolo,
messaggio, un solo bottone a piena larghezza.
*@
<MudDialog>
<DialogContent> <DialogContent>
@if (IsWarning) <div class="dialog-icon dialog-icon--@(IsWarning ? "warning" : "danger")" aria-hidden="true">
<i class="@(IsWarning ? "ri-alert-line" : "ri-error-warning-line")"></i>
</div>
<h2 class="dialog-title">@(IsWarning ? "Attenzione" : "Qualcosa non ha funzionato")</h2>
<p class="dialog-message">@Message</p>
@if (ShowDetail)
{ {
<div class="exception-header mb-2"> <p class="dialog-detail">@ErrorMessage</p>
<i class="ri-error-warning-line warning-icon"></i>
<span>Attenzione</span>
</div>
} }
else
{
<div class="exception-header mb-2">
<i class="ri-emotion-unhappy-line error-icon"></i>
<span>Ops</span>
</div>
}
<div class="text">
<code>@ErrorMessage</code>
</div>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<MudButton Variant="Variant.Text" OnClick="@Cancel" Size="Size.Small" Color="Color.Primary"> <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@Cancel">Ho capito</MudButton>
Chiudi
</MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
@@ -30,5 +27,14 @@
[Parameter] public string ErrorMessage { get; set; } = string.Empty; [Parameter] public string ErrorMessage { get; set; } = string.Empty;
[Parameter] public bool IsWarning { get; set; } [Parameter] public bool IsWarning { get; set; }
// Gli avvisi sono gia' scritti per il rilevatore. Gli errori tecnici no:
// si mostra una frase comprensibile e il dettaglio resta sotto, per chi
// deve riferirlo all'assistenza.
private bool ShowDetail => !IsWarning;
private string Message => IsWarning
? ErrorMessage
: "Il lavoro resta salvato sul dispositivo. Riprova.";
private void Cancel() => MudDialog.Cancel(); private void Cancel() => MudDialog.Cancel();
} }
@@ -1,37 +0,0 @@
.exception-header {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.exception-header > i {
font-size: 3rem;
line-height: normal;
}
.error-icon {
color: var(--bs-danger);
}
.warning-icon {
color: var(--bs-warning);
}
.exception-header > span {
font-size: x-large;
font-weight: 700;
}
.text {
font-size: medium;
font-weight: 500;
display: flex;
text-align: center;
}
code {
width: 100%;
height: auto;
color: var(--bs-gray-dark);
}
@@ -1,27 +1,35 @@
@using SteUp.Shared.Components.Layout @using SteUp.Shared.Core.Dto
@using SteUp.Shared.Core.Dto
@using SteUp.Shared.Core.Interface.System @using SteUp.Shared.Core.Interface.System
@inject IAttachedService AttachedService @inject IAttachedService AttachedService
<MudDialog Class="disable-safe-area"> <MudDialog>
<DialogContent> <DialogContent>
<HeaderLayout SmallHeader="true" Cancel="true" OnCancel="@(() => MudDialog.Cancel())" Title="Aggiungi allegati"/> <h2 class="dialog-title">Aggiungi una foto</h2>
<p class="dialog-message">Scatta sul posto oppure scegli un'immagine già sul dispositivo.</p>
<div style="margin-bottom: 1rem;" class="content attached"> <div class="stack stack-xs" style="margin-top: var(--space-md);">
<MudFab Size="Size.Small" Color="Color.Primary" <button type="button" class="action-tile" @onclick="@OnCamera">
StartIcon="@Icons.Material.Rounded.CameraAlt" <span class="action-tile__icon action-tile__icon--primary">
Label="Camera" OnClick="@OnCamera"/> <i class="ri-camera-line" aria-hidden="true"></i>
</span>
<span class="action-tile__label">Scatta una foto</span>
</button>
<MudFab Size="Size.Small" Color="Color.Primary" <button type="button" class="action-tile" @onclick="@OnGallery">
StartIcon="@Icons.Material.Rounded.Image" <span class="action-tile__icon action-tile__icon--neutral">
Label="Galleria" OnClick="@OnGallery"/> <i class="ri-image-line" aria-hidden="true"></i>
</span>
<span class="action-tile__label">Scegli dalla galleria</span>
</button>
</div> </div>
</DialogContent> </DialogContent>
<DialogActions>
<MudButton Variant="Variant.Outlined" OnClick="@(() => MudDialog.Cancel())">Annulla</MudButton>
</DialogActions>
</MudDialog> </MudDialog>
@code { @code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!; [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public bool CanAddPosition { get; set; }
private List<AttachedDto>? Attached { get; set; } private List<AttachedDto>? Attached { get; set; }
@@ -1,9 +0,0 @@
.content.attached {
display: flex;
gap: 2rem;
padding: 1rem;
height: unset;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
@@ -4,6 +4,7 @@
@using SteUp.Shared.Components.Layout.Spinner @using SteUp.Shared.Components.Layout.Spinner
@using SteUp.Shared.Components.SingleElements.Card.ModalForm @using SteUp.Shared.Components.SingleElements.Card.ModalForm
@using SteUp.Shared.Components.SingleElements.MessageBox @using SteUp.Shared.Components.SingleElements.MessageBox
@using SteUp.Shared.Components.SingleElements.Picker
@using SteUp.Shared.Core.Dto @using SteUp.Shared.Core.Dto
@using SteUp.Shared.Core.Entities @using SteUp.Shared.Core.Entities
@using SteUp.Shared.Core.Enum @using SteUp.Shared.Core.Enum
@@ -11,6 +12,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
@@ -18,171 +21,238 @@
@inject IIspezioniService IspezioniService @inject IIspezioniService IspezioniService
@inject IIntegrySteupService IntegrySteupService @inject IIntegrySteupService IntegrySteupService
@inject OnScannerService OnScannerService @inject OnScannerService OnScannerService
@inject IAttachedService AttachedService
@inject ILogger<ModalFormScheda> Logger @inject ILogger<ModalFormScheda> Logger
@implements IDisposable
@*
Form scheda: la schermata dove il rilevatore passa la maggior parte del
turno. Struttura a shell — testata, corpo scorrevole, barra azioni ancorata
in fondo (STILE_UI §6.13) — così il salvataggio resta sempre sotto il
pollice invece di stare in fondo a uno scroll lungo.
*@
<MudDialog Class="customDialog-form"> <MudDialog Class="customDialog-form">
<DialogContent> <DialogContent>
<MudForm ReadOnly="ReadOnly" @ref="_form"> <MudForm ReadOnly="ReadOnly" @ref="_form">
<HeaderLayout Cancel="true" OnCancel="@Cancel" LabelSave="@LabelSave" <div class="app-shell">
OnSave="Save" Title="Scheda"/>
<div class="content"> <HeaderLayout Title="@(IsNew ? "Nuova scheda" : ReadOnly ? "Scheda" : "Modifica scheda")"
Subtitle="@Scheda.DescrizioneReparto"
Cancel="true" OnCancel="@Cancel" Compact="true"/>
<CardFormModal Title="Reparto" Loading="SteupDataService.Reparti.IsNullOrEmpty()"> <div class="app-scroll">
<MudSelectExtended T="JtbFasiDto?" Variant="Variant.Text" <div class="page-width stack stack-10" style="padding-bottom: var(--space-lg);">
@bind-Value="Scheda.Reparto" ToStringFunc="@(x => x?.Descrizione)"
@bind-Value:after="OnAfterChangeReparto" Required="true" @if (IsBozza)
RequiredError="Reparto obbligatorio">
@foreach (var fasi in SteupDataService.Reparti)
{ {
<MudSelectItemExtended Class="custom-item-select" Value="@fasi"> <div class="feedback feedback--warning" role="status">
@fasi.Descrizione <i class="feedback__icon ri-edit-line" aria-hidden="true"></i>
</MudSelectItemExtended> <span>Questa scheda è una bozza: completala per poterla inviare.</span>
}
</MudSelectExtended>
</CardFormModal>
<CardFormModal Title="Motivo" Loading="SteupDataService.TipiAttività.IsNullOrEmpty()">
<MudSelectExtended ReadOnly="@Scheda.CodJfas.IsNullOrEmpty()" T="string?"
Variant="Variant.Text"
@bind-Value="Scheda.ActivityTypeId" @bind-Value:after="OnAfterChangeValue"
Required="true" RequiredError="Motivo obbligatorio">
@foreach (var type in SteupDataService.TipiAttività.Where(x => x.CodJfas.EqualsIgnoreCase(Scheda.CodJfas!)))
{
<MudSelectItemExtended Class="custom-item-select"
Value="@type.ActivityTypeId">@type.ActivityTypeId</MudSelectItemExtended>
}
</MudSelectExtended>
</CardFormModal>
@if (FileLoading)
{
<CardFormModal Title="Immagini">
<div class="container-attached">
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-3"/>
</div> </div>
</CardFormModal>
}
else if (!AttachedList.IsNullOrEmpty())
{
<CardFormModal Title="Immagini">
<div class="container-attached">
<div class="scroll-attached">
@foreach (var item in AttachedList!.Select((p, index) => new { p, index }))
{
<MudCard>
@if (!item.p.ThumbPath.IsNullOrEmpty())
{
<MudCardMedia Image="@item.p.ThumbPath" Height="100"/>
}
<MudCardContent Class="image_card">
<MudText Typo="Typo.subtitle1"><b>@item.p.Name</b></MudText>
@if (item.p.ToUpload)
{
<MudIconButton Variant="Variant.Outlined"
Icon="@Icons.Material.Rounded.Close"
Size="Size.Small" Color="Color.Error"
OnClick="@(() => OnRemoveAttached(item.index))"/>
}
</MudCardContent>
</MudCard>
}
</div>
</div>
</CardFormModal>
} }
<CardFormModal Title="Reparto" Loading="SteupDataService.Reparti.IsNullOrEmpty()"
Class="@(_showErrors && RepartoMancante ? "form-group--invalid" : "")">
<SelectField TValue="JtbFasiDto" Title="Reparto"
Items="SteupDataService.Reparti"
ItemLabel="@(x => x.Descrizione)"
Value="Scheda.Reparto" ValueChanged="OnRepartoChanged"
Placeholder="Scegli il reparto" ReadOnly="ReadOnly"/>
@if (_showErrors && RepartoMancante)
{
<p class="form-error" role="alert">
<i class="ri-error-warning-line" aria-hidden="true"></i>
Indica il reparto
</p>
}
</CardFormModal>
<CardFormModal Title="Motivo" Loading="SteupDataService.TipiAttività.IsNullOrEmpty()"
Class="@(_showErrors && MotivoMancante ? "form-group--invalid" : "")">
@if (Scheda.CodJfas.IsNullOrEmpty())
{
<p class="t-meta">Scegli prima il reparto.</p>
}
else
{
<SelectField TValue="string" Title="Motivo"
Subtitle="@Scheda.DescrizioneReparto"
Items="MotiviDelReparto"
ItemLabel="@(x => x)"
Value="Scheda.ActivityTypeId" ValueChanged="OnMotivoChanged"
Placeholder="Scegli il motivo" ReadOnly="ReadOnly"/>
@if (_showErrors && MotivoMancante)
{
<p class="form-error" role="alert">
<i class="ri-error-warning-line" aria-hidden="true"></i>
Indica il motivo
</p>
}
}
</CardFormModal>
@* --- Articoli: il compito primario della schermata --- *@
<CardFormModal Title="Articoli" Count="@ArticoliCount">
@if (!ReadOnly) @if (!ReadOnly)
{ {
<div class="container-button ripple-container"> <div class="barcode-field">
<MudButton Class="button-settings green-icon" <input type="text" inputmode="numeric" placeholder="Digita o scansiona un codice"
FullWidth="true" aria-label="Codice a barre"
StartIcon="@Icons.Material.Rounded.AttachFile" value="@ManualBarcode" @oninput="OnManualBarcodeInput"
Size="Size.Medium" @onkeydown="OnManualBarcodeKey"/>
OnClick="@OpenAddAttached" <button type="button" class="barcode-field__send"
Variant="Variant.Outlined"> disabled="@(ManualBarcode.IsNullOrEmpty())"
Aggiungi foto aria-label="Aggiungi il codice" @onclick="OnManualBarcodeSet">
</MudButton> <i class="ri-arrow-right-line" aria-hidden="true"></i>
</button>
</div> </div>
} }
<CardFormModal Title="Articoli">
@if (!ReadOnly)
{
<div class="input-manual-barcode">
<MudTextField FullWidth="true" T="string?" Variant="Variant.Text"
@bind-Value="ManualBarcode" Placeholder="Digita manualmente il codice"/>
<MudIconButton Color="Color.Primary" OnClick="@OnManualBarcodeSet"
Size="Size.Small" Icon="@Icons.Material.Rounded.Send"/>
</div>
}
@if (!Scheda.Articoli.IsNullOrEmpty()) @if (!Scheda.Articoli.IsNullOrEmpty())
{ {
<div class="art-list"> <div class="art-list" style="margin-top: var(--space-sm);">
@foreach (var articolo in Scheda.Articoli) @foreach (var articolo in Scheda.Articoli)
{ {
<MudChip T="string" OnClose="@(() => RemoveArt(articolo.Barcode))" <div class="art-item" @key="articolo.Barcode">
style="height: auto;"> <div class="art-item__text">
<MudStack Direction="Column" Spacing="0" class="py-1"> <div class="art-item__name">@articolo.Descrizione</div>
<MudText Typo="Typo.subtitle2" Style="line-height: 1.1; font-weight: 700;"> <div class="art-item__code selectable">@articolo.Barcode</div>
@articolo.Descrizione </div>
</MudText> @if (!ReadOnly)
<MudText Typo="Typo.body2" Style="line-height: 1.5;"> {
Barcode: @articolo.Barcode <button type="button" class="art-item__remove"
</MudText> aria-label="@($"Rimuovi {articolo.Descrizione}")"
</MudStack> @onclick="@(() => RemoveArt(articolo.Barcode))">
</MudChip> <i class="ri-close-line" aria-hidden="true"></i>
</button>
} }
</div> </div>
} }
</CardFormModal> </div>
}
else if (ReadOnly)
{
<p class="t-meta">Nessun articolo.</p>
}
@if (NetworkService.ConnectionAvailable && !ReadOnly) @if (NetworkService.ConnectionAvailable && !ReadOnly)
{ {
<div class="container-button ripple-container"> <button type="button" class="action-tile" style="margin-top: var(--space-sm);"
<MudButton Class="button-settings red-icon" @onclick="@OpenSelectArt">
FullWidth="true" <span class="action-tile__icon action-tile__icon--neutral">
StartIcon="@Icons.Material.Rounded.Balance" <i class="ri-list-check-2" aria-hidden="true"></i>
Size="Size.Medium" </span>
OnClick="@OpenSelectArt" <span class="action-tile__label">Scegli dalla griglia</span>
Variant="Variant.Outlined"> <i class="nav-list__chevron ri-arrow-right-s-line" aria-hidden="true"></i>
Consulta articoli </button>
</MudButton> }
</CardFormModal>
@* --- Foto --- *@
<CardFormModal Title="Foto" Count="@FotoCount" Loading="FileLoading">
@if (!AttachedList.IsNullOrEmpty())
{
<div class="attach-strip">
@foreach (var item in AttachedList!.Select((p, index) => new { p, index }))
{
<div class="attach-item">
@if (!item.p.ThumbPath.IsNullOrEmpty())
{
<img class="attach-item__img" src="@item.p.ThumbPath"
alt="@item.p.Name"/>
}
else
{
<div class="attach-item__placeholder">
<span class="spinner" aria-label="Elaborazione"></span>
</div> </div>
} }
<CardFormModal Title="Scadenza"> @if (item.p.ToUpload && !ReadOnly)
<MudSelectExtended FullWidth="true" T="int" Variant="Variant.Text" {
@bind-Value="@Scheda.Scadenza" @bind-Value:after="OnAfterChangeValue"> <button type="button" class="attach-item__remove"
<MudSelectItemExtended Class="custom-item-select" Text="24H" Value="3"/> aria-label="@($"Rimuovi la foto {item.p.Name}")"
<MudSelectItemExtended Class="custom-item-select" Text="1 Settimana" Value="2"/> @onclick="@(() => OnRemoveAttached(item.index))">
<MudSelectItemExtended Class="custom-item-select" Text="1 Mese" Value="1"/> <i class="ri-close-line" aria-hidden="true"></i>
<MudSelectItemExtended Class="custom-item-select" Text="2 Mesi" Value="0"/> </button>
</MudSelectExtended> }
</div>
}
</div>
}
else if (ReadOnly)
{
<p class="t-meta">Nessuna foto.</p>
}
@if (!ReadOnly)
{
<button type="button" class="action-tile" style="margin-top: var(--space-sm);"
@onclick="@OpenAddAttached">
<span class="action-tile__icon action-tile__icon--primary">
<i class="ri-camera-line" aria-hidden="true"></i>
</span>
<span class="action-tile__label">Aggiungi una foto</span>
</button>
}
</CardFormModal> </CardFormModal>
<CardFormModal Title="Responsabile"> @* --- Urgenza: pillole invece di un menu a tendina --- *@
<CardFormModal Title="Entro quando">
<div class="option-row">
@foreach (var opzione in ScadenzaOptions)
{
<button type="button" class="option"
disabled="@ReadOnly"
aria-pressed="@(Scheda.Scadenza == opzione.Value ? "true" : "false")"
@onclick="@(() => SetScadenza(opzione.Value))">
@opzione.Label
</button>
}
</div>
</CardFormModal>
<CardFormModal Title="Persona di riferimento">
<MudTextField FullWidth="true" T="string?" Variant="Variant.Text" <MudTextField FullWidth="true" T="string?" Variant="Variant.Text"
Placeholder="Nome di chi ha seguito il controllo"
@bind-Value="Scheda.Responsabile" @bind-Value:after="OnAfterChangeValue" @bind-Value="Scheda.Responsabile" @bind-Value:after="OnAfterChangeValue"
DebounceInterval="500" OnDebounceIntervalElapsed="OnAfterChangeValue"/> DebounceInterval="500" OnDebounceIntervalElapsed="OnAfterChangeValue"/>
</CardFormModal> </CardFormModal>
<CardFormModal Title="Note"> <CardFormModal Title="Note">
<MudTextField T="string?" Variant="Variant.Text" Lines="3" <MudTextField @key="_noteFieldKey" T="string?" Variant="Variant.Text" Lines="3"
Placeholder="Cosa hai rilevato"
@bind-Value="Scheda.Note" @bind-Value:after="OnAfterChangeValue" @bind-Value="Scheda.Note" @bind-Value:after="OnAfterChangeValue"
DebounceInterval="500" OnDebounceIntervalElapsed="OnAfterChangeValue"/> DebounceInterval="500" OnDebounceIntervalElapsed="OnAfterChangeValue"/>
</CardFormModal>
<div class="container-button"> @if (!ReadOnly)
<MudButton Class="button-settings primary-icon" {
FullWidth="true" <button type="button" class="action-tile" style="margin-top: var(--space-sm);"
StartIcon="@Icons.Material.Rounded.Description" @onclick="@SuggestActivityDescription">
Size="Size.Medium" <span class="action-tile__icon action-tile__icon--info">
OnClick="@SuggestActivityDescription" <i class="ri-lightbulb-line" aria-hidden="true"></i>
Variant="Variant.Outlined"> </span>
Suggerisci note descrittive <span class="action-tile__label">Usa una nota ricorrente</span>
<i class="nav-list__chevron ri-arrow-right-s-line" aria-hidden="true"></i>
</button>
}
</CardFormModal>
</div>
</div>
@if (!ReadOnly)
{
@* Barra azioni ancorata: il salvataggio non si cerca mai. *@
<div class="action-bar">
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
Disabled="@(!IsDirty)" OnClick="@Submit">
@(LabelSave ?? (IsNew ? "Salva" : "Aggiorna"))
</MudButton> </MudButton>
</div> </div>
}
</div> </div>
</MudForm> </MudForm>
@@ -191,7 +261,7 @@
</MudDialog> </MudDialog>
<SpinnerOverlay VisibleOverlay="VisibleOverlay" SuccessAnimation="SuccessAnimation"/> <SpinnerOverlay VisibleOverlay="VisibleOverlay" SuccessAnimation="SuccessAnimation"/>
<LoaderLayout Visible="OnLoading" Text="@TextLoading"/> <SpinnerOverlay VisibleOverlay="OnLoading" Message="@TextLoading"/>
@code { @code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!; [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
@@ -203,6 +273,45 @@
private string? ManualBarcode { get; set; } private string? ManualBarcode { get; set; }
private bool IsBozza => Scheda.Bozza;
private string ArticoliCount => Scheda.Articoli.Count == 0
? "nessuno"
: Scheda.Articoli.Count == 1 ? "1" : Scheda.Articoli.Count.ToString();
private string FotoCount => AttachedList is null or { Count: 0 }
? "nessuna"
: AttachedList.Count.ToString();
// I valori sono quelli di ScadenzaEnum. Come pillole invece che in un menu
// a tendina: si scelgono con un tocco solo e si vede subito quale e' attiva.
private static readonly (int Value, string Label)[] ScadenzaOptions =
[
(3, "24 ore"),
(2, "1 settimana"),
(1, "1 mese"),
(0, "2 mesi")
];
private void SetScadenza(int value)
{
if (ReadOnly) return;
Scheda.Scadenza = value;
OnAfterChangeValue();
}
private void OnManualBarcodeInput(ChangeEventArgs e)
{
ManualBarcode = e.Value?.ToString();
}
// Invio da tastiera: il codice digitato si conferma senza cercare il pulsante.
private void OnManualBarcodeKey(KeyboardEventArgs e)
{
if (e.Key is "Enter" or "NumpadEnter") OnManualBarcodeSet();
}
//Overlay //Overlay
private bool VisibleOverlay { get; set; } private bool VisibleOverlay { get; set; }
private bool SuccessAnimation { get; set; } private bool SuccessAnimation { get; set; }
@@ -215,10 +324,47 @@
private MudForm _form = null!; private MudForm _form = null!;
private string? LabelSave { get; set; } private string? LabelSave { get; set; }
// Cambiare questa chiave forza Blazor a ricreare il MudTextField delle note,
// così una nota impostata da codice (suggerimenti) compare subito a video
// nonostante il DebounceInterval del campo.
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;
@@ -227,8 +373,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();
} }
@@ -266,6 +417,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
{ {
@@ -275,6 +429,7 @@
new SaveRequestDto new SaveRequestDto
{ {
LocalIdScheda = Scheda.Id, LocalIdScheda = Scheda.Id,
ActivityId = Scheda.ActivityId,
ActivityTypeId = Scheda.ActivityTypeId, ActivityTypeId = Scheda.ActivityTypeId,
CodJfas = Scheda.CodJfas, CodJfas = Scheda.CodJfas,
CodMdep = CodMdep, CodMdep = CodMdep,
@@ -283,7 +438,7 @@
PersonaRif = Scheda.Responsabile, PersonaRif = Scheda.Responsabile,
Barcodes = Scheda.Articoli.ConvertAll(x => x.Barcode), Barcodes = Scheda.Articoli.ConvertAll(x => x.Barcode),
Scandeza = (ScadenzaEnum)Scheda.Scadenza, Scandeza = (ScadenzaEnum)Scheda.Scadenza,
ParentActivityId = IsNew ? null : Scheda.Ispezione?.ActivityId ParentActivityId = SteupDataService.InspectionPageState.Ispezione.ActivityId
} }
); );
} }
@@ -340,13 +495,16 @@
await IspezioniService.UpdateActivityIdIspezioneAsync(CodMdep, Data, await IspezioniService.UpdateActivityIdIspezioneAsync(CodMdep, Data,
UserSession.User.Username, apiResponse.ActivityIdIspezione UserSession.User.Username, apiResponse.ActivityIdIspezione
); );
// Tiene in memoria l'ActivityId dell'ispezione: il prossimo salvataggio nella stessa
// sessione lo userà come ParentActivityId invece di creare una nuova ispezione.
SteupDataService.InspectionPageState.Ispezione.ActivityId = apiResponse.ActivityIdIspezione;
Scheda.ActivityId = apiResponse.ActivityIdScheda; Scheda.ActivityId = apiResponse.ActivityIdScheda;
} }
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())
{ {
@@ -376,13 +534,19 @@
} }
} }
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)
{
await IspezioniService.UpdateActivityIdIspezioneAsync(CodMdep, Data, await IspezioniService.UpdateActivityIdIspezioneAsync(CodMdep, Data,
UserSession.User.Username, apiResponse.ActivityIdIspezione UserSession.User.Username, apiResponse.ActivityIdIspezione
); );
SteupDataService.InspectionPageState.Ispezione.ActivityId = apiResponse.ActivityIdIspezione;
}
} }
private async Task UploadFile(Scheda scheda) private async Task UploadFile(Scheda scheda)
@@ -413,6 +577,11 @@
} }
private async Task Cancel() private async Task Cancel()
{
if (_closing) return;
_closing = true;
try
{ {
if (await CheckSavePreAction()) if (await CheckSavePreAction())
{ {
@@ -421,29 +590,75 @@
MudDialog.Cancel(); MudDialog.Cancel();
} }
} }
finally
{
_closing = false;
}
}
#region Form #region Form
private void OnAfterChangeReparto() /// Gli obbligatori si segnalano solo dopo il primo tentativo di salvataggio:
/// un form che si tinge di rosso prima che tu abbia fatto qualcosa accusa
/// l'utente di un errore che non ha ancora commesso.
private bool _showErrors;
private bool RepartoMancante => Scheda.Reparto is null;
private bool MotivoMancante => Scheda.ActivityTypeId.IsNullOrEmpty();
/// I motivi disponibili dipendono dal reparto scelto.
private List<string> MotiviDelReparto => SteupDataService.TipiAttività
.Where(x => x.CodJfas.EqualsIgnoreCase(Scheda.CodJfas!))
.Select(x => x.ActivityTypeId)
.Where(x => !x.IsNullOrEmpty())
.Select(x => x!)
.ToList();
private void OnRepartoChanged(JtbFasiDto? reparto)
{ {
if (reparto is null) return;
Scheda.Reparto = reparto;
// Cambiando reparto il motivo precedente non e' piu' fra quelli validi.
Scheda.ActivityTypeId = null; Scheda.ActivityTypeId = null;
OnAfterChangeValue(); OnAfterChangeValue();
} }
private void OnMotivoChanged(string? motivo)
{
Scheda.ActivityTypeId = motivo;
OnAfterChangeValue();
}
private void OnAfterChangeValue() private void OnAfterChangeValue()
{ {
RecalcDirty(); RecalcDirty();
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)
@@ -460,17 +675,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();
@@ -478,10 +701,53 @@
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()
{ {
// Reparto e motivo non passano piu' dalla validazione di MudForm: sono
// campi tappabili, non input. Si controllano qui, e l'errore si vede sul
// gruppo interessato oltre che nel messaggio.
_showErrors = true;
await _form.Validate(); await _form.Validate();
if (_form.IsValid) await Save();
if (!RepartoMancante && !MotivoMancante && _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);
StateHasChanged();
} }
#endregion #endregion
@@ -490,8 +756,10 @@
private async Task OpenAddAttached() private async Task OpenAddAttached()
{ {
var result = await Dialog.OpenAddAttached(); var selectImageFromCamera = await AttachedService.SelectImageFromCamera();
if (result is not { Canceled: false, Data: List<AttachedDto> attachedList }) return; if (selectImageFromCamera == null) return;
var attachedList = new List<AttachedDto> { selectImageFromCamera };
OnLoading = true; OnLoading = true;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
@@ -504,7 +772,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 () =>
@@ -577,7 +846,7 @@
return; return;
} }
var modal = await Dialog.OpenSelectArt(articoli); var modal = await WithChildDialog(() => Dialog.OpenSelectArt(articoli));
await InvokeAsync(() => await InvokeAsync(() =>
{ {
@@ -589,14 +858,18 @@
if (modal is { Canceled: false, Data: List<ArticoliInGrigliaDto> articoliSelezionati }) if (modal is { Canceled: false, Data: List<ArticoliInGrigliaDto> articoliSelezionati })
{ {
Scheda.Articoli.AddRange(articoliSelezionati.ConvertAll(x => new SchedaArticolo Scheda.Articoli.InsertRange(0, articoliSelezionati.ConvertAll(x => new SchedaArticolo
{ {
Barcode = x.Barcode, Barcode = x.Barcode,
Descrizione = x.Descrizione Descrizione = x.Descrizione
}) })
); );
await InvokeAsync(StateHasChanged); await InvokeAsync(() =>
{
RecalcDirty();
StateHasChanged();
});
} }
}); });
} }
@@ -609,13 +882,14 @@
AttachedList[index].ToRemove = true; AttachedList[index].ToRemove = true;
AttachedList.RemoveAt(index); AttachedList.RemoveAt(index);
RecalcDirty(true); _attachmentsDirty = true;
RecalcDirty();
StateHasChanged(); StateHasChanged();
} }
#endregion #endregion
private void SuggestActivityDescription() private async Task SuggestActivityDescription()
{ {
if (Scheda.ActivityTypeId == null) if (Scheda.ActivityTypeId == null)
{ {
@@ -626,18 +900,25 @@
VisibleOverlay = true; VisibleOverlay = true;
StateHasChanged(); StateHasChanged();
_ = Task.Run(async () => try
{ {
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: not null })
Scheda.Note = modal.Data!.ToString();
if (modal is { Canceled: false, Data: string descrizione } && !string.IsNullOrWhiteSpace(descrizione))
{
Scheda.Note = descrizione;
_noteFieldKey++; // ricrea il campo note così la nota selezionata compare subito a video
// Aggiorna anche lo stato (dirty → Salva/Aggiorna) come farebbe l'input manuale.
OnAfterChangeValue();
}
}
finally
{
VisibleOverlay = false; VisibleOverlay = false;
await InvokeAsync(StateHasChanged); StateHasChanged();
}); }
} }
#region Scanner #region Scanner
@@ -672,14 +953,13 @@
if (art != null) if (art != null)
{ {
RecalcDirty(true); Scheda.Articoli.Insert(0, new SchedaArticolo
Scheda.Articoli.Add(new SchedaArticolo
{ {
Barcode = art.Barcode, Barcode = art.Barcode,
Descrizione = art.Descrizione Descrizione = art.Descrizione
}); });
RecalcDirty();
StateHasChanged(); StateHasChanged();
} }
else else
@@ -719,8 +999,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();
} }
@@ -728,6 +1008,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();
} }
} }
@@ -1,49 +0,0 @@
.container-attached {
width: 100%;
margin-bottom: 1rem;
}
.container-attached .divider {
margin: unset;
}
.input-manual-barcode {
display: flex;
align-items: center;
gap: 1.5rem;
margin-top: .5rem;
margin-bottom: .5rem;
}
.scroll-attached {
max-height: 185px;
overflow: auto;
display: flex;
gap: 1rem;
flex-direction: column;
padding: .5rem;
}
.container-attached ::deep .image_card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px;
}
.container-button {
margin-bottom: unset !important;
}
.input-card {
margin-bottom: 1.5rem;
}
.art-list {
max-height: 150px;
overflow: auto;
}
.art-list ::deep p {
white-space: normal;
}
@@ -1,59 +1,128 @@
@using SteUp.Shared.Components.Layout @using SteUp.Shared.Components.Layout
@using SteUp.Shared.Core.Dto @using SteUp.Shared.Core.Dto
<MudDialog OnBackdropClick="Cancel"> @*
Scelta degli articoli dalla griglia. Al posto del menu a tendina a selezione
multipla, un elenco a schermo intero con righe da 56px: si centra con i
guanti, e il conteggio dei selezionati resta sempre a vista.
*@
<MudDialog>
<DialogContent> <DialogContent>
<HeaderLayout SmallHeader="true" Cancel="true" OnCancel="@(() => MudDialog.Cancel())" <div class="app-shell">
Title="Seleziona articoli"/> <HeaderLayout Title="Articoli" Subtitle="@Conteggio" Cancel="true"
OnCancel="@Cancel" Compact="true"/>
@if (!Articoli.IsNullOrEmpty()) @if (!Articoli.IsNullOrEmpty())
{ {
<MudSelectExtended MultiSelection="true" ItemCollection="Articoli" SearchBox="true" <div class="page-width" style="padding-bottom: var(--space-sm);">
SearchBoxAutoFocus="true" @bind-SelectedValues="ArticoliSelected" <div class="search-field">
SearchFunc="SearchFunc" T="ArticoliInGrigliaDto" Virtualize="true" <i class="search-field__icon ri-search-line" aria-hidden="true"></i>
Label="Articoli" AnchorOrigin="Origin.BottomCenter" Variant="Variant.Outlined" <input type="search" placeholder="Cerca per descrizione o codice"
SearchBoxPlaceholder="Descrizione articolo" SearchBoxClearable="true" autocapitalize="none" autocorrect="off"
MultiSelectionTextFunc="MultiSelectionTextFunc" ToStringFunc="ToStringFunc"/> aria-label="Cerca un articolo"
value="@FilterText" @oninput="OnFilterInput"/>
@if (!FilterText.IsNullOrEmpty())
{
<MudIconButton Icon="@Icons.Material.Rounded.Close" Size="Size.Small"
aria-label="Cancella la ricerca" OnClick="@ClearFilter"/>
}
</div>
</div>
}
<div class="app-scroll">
<div class="page-width page-body--flush">
@if (Articoli.IsNullOrEmpty())
{
<div class="empty-state">
<i class="empty-state__icon ri-inbox-line" aria-hidden="true"></i>
<p class="empty-state__title">Nessun articolo in griglia</p>
<p class="empty-state__text">
Per questo reparto e motivo non ci sono articoli: usa lo scanner o digita il codice.
</p>
</div>
}
else if (Filtrati.Count == 0)
{
<div class="empty-state">
<i class="empty-state__icon ri-search-line" aria-hidden="true"></i>
<p class="empty-state__title">Nessun risultato</p>
<p class="empty-state__text">Nessun articolo corrisponde alla ricerca.</p>
<div class="empty-state__action">
<MudButton Variant="Variant.Outlined" OnClick="@ClearFilter">
Azzera la ricerca
</MudButton>
</div>
</div>
} }
else else
{ {
<div class="spinner-container" style="height: unset !important; margin-bottom: 1rem;"> <div class="stack stack-xs">
<MudIcon Size="Size.Large" Color="Color.Error" Icon="@Icons.Material.Rounded.Close"/> <Virtualize Items="Filtrati" Context="art" OverscanCount="8">
<MudText>Nessun articolo trovato</MudText> <button type="button" class="select-row"
aria-pressed="@(IsSelected(art) ? "true" : "false")"
@onclick="@(() => Toggle(art))">
<span class="select-row__check" aria-hidden="true">
<i class="ri-check-line"></i>
</span>
<span class="select-row__text">
<span class="select-row__title">@art.Descrizione</span>
<span class="select-row__meta">@art.Barcode</span>
</span>
</button>
</Virtualize>
</div> </div>
} }
</DialogContent> </div>
<DialogActions> </div>
@if (!Articoli.IsNullOrEmpty()) @if (!Articoli.IsNullOrEmpty())
{ {
<MudButton Variant="Variant.Filled" OnClick="@Submit" Size="Size.Small" Color="Color.Primary"> <div class="action-bar">
Seleziona <MudButton Variant="Variant.Outlined" OnClick="@Cancel">Annulla</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
Disabled="@(_selected.Count == 0)" OnClick="@Submit">
@(_selected.Count == 0 ? "Aggiungi" : $"Aggiungi {_selected.Count}")
</MudButton> </MudButton>
</div>
} }
</DialogActions> </div>
</DialogContent>
</MudDialog> </MudDialog>
@code { @code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!; [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public List<ArticoliInGrigliaDto>? Articoli { get; set; } [Parameter] public List<ArticoliInGrigliaDto>? Articoli { get; set; }
private IEnumerable<ArticoliInGrigliaDto>? ArticoliSelected { get; set; } private readonly List<ArticoliInGrigliaDto> _selected = [];
private string? FilterText { get; set; }
private List<ArticoliInGrigliaDto> Filtrati =>
FilterText.IsNullOrEmpty()
? Articoli ?? []
: (Articoli ?? []).FindAll(x =>
x.Descrizione.ContainsIgnoreCase(FilterText!) ||
x.Barcode.ContainsIgnoreCase(FilterText!));
private string? Conteggio => Articoli.IsNullOrEmpty()
? null
: _selected.Count > 0
? $"{_selected.Count} selezionat{(_selected.Count == 1 ? "o" : "i")}"
: $"{Articoli!.Count} disponibili";
private bool IsSelected(ArticoliInGrigliaDto art) => _selected.Contains(art);
private void Toggle(ArticoliInGrigliaDto art)
{
if (!_selected.Remove(art)) _selected.Add(art);
}
private void OnFilterInput(ChangeEventArgs e) => FilterText = e.Value?.ToString();
private void ClearFilter() => FilterText = null;
private void Cancel() => MudDialog.Cancel(); private void Cancel() => MudDialog.Cancel();
private void Submit() => private void Submit() => MudDialog.Close(DialogResult.Ok(_selected.ToList()));
MudDialog.Close(DialogResult.Ok(ArticoliSelected?.ToList()));
private static bool SearchFunc(ArticoliInGrigliaDto? obj, string? searchString)
{
if (searchString.IsNullOrEmpty() || obj == null) return true;
return obj.Descrizione.ContainsIgnoreCase(searchString!);
}
private static string? ToStringFunc(ArticoliInGrigliaDto? arg) => arg?.Descrizione;
private static string MultiSelectionTextFunc(List<ArticoliInGrigliaDto?> arg) =>
$"{arg.Count} selezionat{(arg.Count == 1 ? "o" : "i")}";
} }
@@ -1,87 +1,146 @@
@using SteUp.Shared.Components.SingleElements.Card @using SteUp.Shared.Components.Layout
@using SteUp.Shared.Components.SingleElements.Card
@using SteUp.Shared.Core.Dto @using SteUp.Shared.Core.Dto
@using SteUp.Shared.Core.Enum
@using SteUp.Shared.Core.Interface.LocalDb
@inject IIspezioniService IspezioniService
<MudDialog OnBackdropClick="Cancel"> @*
Scelta del punto vendita: schermata intera, non un riquadro. L'elenco e'
lungo e si scorre in piedi: serve tutto lo spazio e un campo di ricerca
grande quanto il pollice.
*@
<MudDialog>
<DialogContent> <DialogContent>
<div class="app-shell">
<HeaderLayout Title="Punto vendita" Subtitle="@Conteggio" Cancel="true"
OnCancel="@Cancel" Compact="true"/>
<div class="select-shop-content"> <div class="page-width" style="padding-bottom: var(--space-sm);">
<div class="shop-header"> <div class="search-field">
<div class="shop-title"> <i class="search-field__icon ri-search-line" aria-hidden="true"></i>
<MudText Typo="Typo.h5"><b>Seleziona il negozio</b></MudText> <input type="search" placeholder="Cerca per codice, nome o città"
autocapitalize="none" autocorrect="off"
<MudIconButton Icon="@Icons.Material.Rounded.Close" Size="Size.Small" OnClick="@Cancel"/> aria-label="Cerca un punto vendita"
</div> value="@FilterText" @oninput="OnFilterInput"/>
@if (!FilterText.IsNullOrEmpty())
<div class="input-card clearButton">
<MudTextField T="string?" Placeholder="Cerca..." Variant="Variant.Text"
@bind-Value="FilterText" DebounceInterval="500"
OnDebounceIntervalElapsed="ApplyFilters"/>
</div>
</div>
<div class="shop-body">
@if (_afterRender)
{ {
if (FilteredList.IsNullOrEmpty()) <MudIconButton Icon="@Icons.Material.Rounded.Close" Size="Size.Small"
aria-label="Cancella la ricerca" OnClick="@ClearFilter"/>
}
</div>
</div>
<div class="app-scroll">
<div class="page-width page-body--flush">
@if (FilteredList is null)
{ {
<MudText Typo="Typo.body2">Nessun negozio trovato</MudText> <div class="card-list" aria-hidden="true">
@for (var i = 0; i < 4; i++)
{
<div class="list-card">
<div class="skeleton skeleton--line-short"></div>
<div class="skeleton skeleton--title" style="margin-top: 10px;"></div>
</div>
}
</div>
}
else if (FilteredList.Count == 0)
{
<div class="empty-state">
<i class="empty-state__icon ri-search-line" aria-hidden="true"></i>
<p class="empty-state__title">Nessun punto vendita</p>
<p class="empty-state__text">Nessun risultato per "@FilterText".</p>
<div class="empty-state__action">
<MudButton Variant="Variant.Outlined" OnClick="@ClearFilter">
Azzera la ricerca
</MudButton>
</div>
</div>
} }
else else
{ {
<Virtualize Items="FilteredList" Context="puntoVendita"> <div class="card-list">
<ShopCard PuntoVendita="puntoVendita"/> <Virtualize Items="FilteredList" Context="puntoVendita" OverscanCount="6">
<ShopCard PuntoVendita="puntoVendita"
Aperta="@HaIspezioneOggi(puntoVendita)"
OnStarting="@(() => _busy = true)"/>
</Virtualize> </Virtualize>
} </div>
} }
</div> </div>
</div> </div>
</div>
</DialogContent> </DialogContent>
</MudDialog> </MudDialog>
<SpinnerOverlay VisibleOverlay="_busy" Message="Apertura ispezione…"/>
@code { @code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!; [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
private List<PuntoVenditaDto>? FilteredList { get; set; } private List<PuntoVenditaDto>? FilteredList { get; set; }
private string? FilterText { get; set; } private string? FilterText { get; set; }
private bool _busy;
private bool _afterRender; private string? Conteggio => FilteredList is null
? null
: FilteredList.Count == 1 ? "1 punto vendita" : $"{FilteredList.Count} punti vendita";
// I codici su cui il rilevatore ha gia' un'ispezione aperta oggi. Si legge
// una volta sola: toccare una di quelle righe riapre l'ispezione esistente
// invece di crearne una nuova, e la riga deve poterlo dire.
private HashSet<string> _aperteOggi = [];
private void Cancel() => MudDialog.Cancel(); private void Cancel() => MudDialog.Cancel();
protected override void OnInitialized() private bool HaIspezioneOggi(PuntoVenditaDto pv) =>
pv.CodMdep is not null && _aperteOggi.Contains(pv.CodMdep);
protected override async Task OnInitializedAsync()
{ {
_afterRender = false; ApplyFilters();
var oggi = DateTime.Today;
var ispezioni = await IspezioniService.GetAllIspezioni(UserSession.User.Username);
_aperteOggi = ispezioni
.Where(x => x.Data.Date == oggi && x.Stato != StatusEnum.Completata && x.CodMdep is not null)
.Select(x => x.CodMdep!)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
StateHasChanged();
} }
protected override void OnAfterRender(bool firstRender) private void OnFilterInput(ChangeEventArgs e)
{ {
if (_afterRender) return; FilterText = e.Value?.ToString();
ApplyFilters();
}
_afterRender = true; private void ClearFilter()
{
FilterText = null;
ApplyFilters(); ApplyFilters();
} }
private void ApplyFilters() private void ApplyFilters()
{ {
if (FilterText.IsNullOrEmpty()) var source = SteupDataService.PuntiVenditaList;
{
FilteredList = SteupDataService.PuntiVenditaList; FilteredList = (FilterText.IsNullOrEmpty()
} ? source
else : source.FindAll(x =>
{
FilteredList = SteupDataService.PuntiVenditaList.FindAll(x =>
(x.Indirizzo != null && x.Indirizzo.ContainsIgnoreCase(FilterText!)) || (x.Indirizzo != null && x.Indirizzo.ContainsIgnoreCase(FilterText!)) ||
(x.Descrizione != null && x.Descrizione.ContainsIgnoreCase(FilterText!)) || (x.Descrizione != null && x.Descrizione.ContainsIgnoreCase(FilterText!)) ||
(x.CodMdep != null && x.CodMdep.ContainsIgnoreCase(FilterText!)) || (x.CodMdep != null && x.CodMdep.ContainsIgnoreCase(FilterText!)) ||
(x.Citta != null && x.Citta.ContainsIgnoreCase(FilterText!)) || (x.Citta != null && x.Citta.ContainsIgnoreCase(FilterText!)) ||
(x.Cap != null && x.Cap.ContainsIgnoreCase(FilterText!)) || (x.Cap != null && x.Cap.ContainsIgnoreCase(FilterText!)) ||
(x.Provincia != null && x.Provincia.ContainsIgnoreCase(FilterText!)) (x.Provincia != null && x.Provincia.ContainsIgnoreCase(FilterText!))))
); .OrderBy(x => x.CodMdep)
} .ToList();
FilteredList = FilteredList.OrderBy(x => x.CodMdep).ToList();
StateHasChanged(); StateHasChanged();
} }
} }
@@ -1,18 +0,0 @@
.shop-header{
display: flex;
flex-direction: column;
gap: .5rem;
}
.shop-title{
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.shop-body{
display: flex;
flex-direction: column;
gap: 1rem;
}
@@ -1,24 +1,34 @@
@using SteUp.Shared.Core.Dto @using SteUp.Shared.Core.Dto
<MudDialog OnBackdropClick="Cancel"> <MudDialog>
<DialogContent> <DialogContent>
<h2 class="dialog-title">Note descrittive</h2>
<p class="dialog-message">Scegli una descrizione ricorrente per questo motivo.</p>
@if (!ActivityTypers.IsNullOrEmpty()) @if (!ActivityTypers.IsNullOrEmpty())
{ {
<MudList T="string" SelectedValueChanged="OnClickItem"> <div class="stack stack-xs" style="margin-top: var(--space-md);">
@foreach (var item in ActivityTypers!) @foreach (var item in ActivityTypers!)
{ {
<MudListItem Text="@item.ActivityTypeDescription" Value="item.ActivityTypeDescription"/> <button type="button" class="action-tile"
@onclick="@(() => OnClickItem(item.ActivityTypeDescription))">
<span class="action-tile__label">@item.ActivityTypeDescription</span>
</button>
} }
</MudList> </div>
} }
else else
{ {
<div class="spinner-container" style="height: unset !important;"> <div class="empty-state">
<MudIcon Size="Size.Large" Color="Color.Error" Icon="@Icons.Material.Rounded.Close"/> <i class="empty-state__icon ri-chat-off-line" aria-hidden="true"></i>
<MudText>Nessuna descrizione consigliata</MudText> <p class="empty-state__title">Nessuna descrizione consigliata</p>
<p class="empty-state__text">Per questo motivo non ci sono note ricorrenti: scrivila a mano.</p>
</div> </div>
} }
</DialogContent> </DialogContent>
<DialogActions>
<MudButton Variant="Variant.Outlined" OnClick="@Cancel">Annulla</MudButton>
</DialogActions>
</MudDialog> </MudDialog>
@code { @code {
@@ -1,10 +1,17 @@
<div class="no-data opacity-75 d-flex flex-column align-items-center"> @* Stato vuoto (STILE_UI §6.12): suggerisce la mossa successiva, non si limita
<img src="@ImageSource" alt=""/> a dire che non c'e' niente. *@
<p class="mt-3">@Text</p> <div class="empty-state">
<i class="empty-state__icon @Icon" aria-hidden="true"></i>
<p class="empty-state__title">@Text</p>
@if (!string.IsNullOrWhiteSpace(Hint))
{
<p class="empty-state__text">@Hint</p>
}
</div> </div>
@code { @code {
[Parameter] public string ImageSource { get; set; } = string.Empty;
[Parameter] public string Text { get; set; } = string.Empty; [Parameter] public string Text { get; set; } = string.Empty;
[Parameter] public string? Hint { get; set; }
[Parameter] public string Icon { get; set; } = "ri-inbox-line";
} }
@@ -1,13 +0,0 @@
.no-data {
position: fixed;
top: 35%;
width: calc(100% - 3rem); /* remove page padding */
}
.no-data img {
width: 60%;
}
.no-data p {
font-size: 1.2rem;
}
@@ -0,0 +1,87 @@
@typeparam TValue
@using SteUp.Shared.Components.Layout.Sheet
@*
Scelta a valore singolo. Al posto della tendina: una riga tappabile che
apre un bottom sheet con le opzioni a tutta larghezza.
La tendina di serie e' un residuo del web — galleggia bianca su bianco
sopra le card, si porta dietro la barra di scorrimento del desktop e offre
bersagli alti quanto una riga di testo. Qui l'elenco arriva dal basso, ha
un titolo, righe da 56 px e la scelta corrente segnata: si usa in piedi,
con una mano, anche con i guanti.
*@
<button type="button" class="select-field" disabled="@ReadOnly" @onclick="Open"
aria-haspopup="dialog" aria-label="@($"{Title}: {(HasValue ? SelectedLabel : Placeholder)}")">
<span class="select-field__value @(HasValue ? "" : "select-field__value--empty")">
@(HasValue ? SelectedLabel : Placeholder)
</span>
@if (!ReadOnly)
{
<i class="select-field__icon ri-arrow-down-s-line" aria-hidden="true"></i>
}
</button>
<ActionSheet @bind-Open="_open" Title="@Title" Subtitle="@Subtitle">
<div class="stack stack-xs">
@foreach (var item in Items)
{
var selected = IsSelected(item);
<button type="button" class="select-row select-row--single"
aria-pressed="@(selected ? "true" : "false")"
@onclick="@(() => Choose(item))">
<span class="select-row__check" aria-hidden="true">
<i class="ri-check-line"></i>
</span>
<span class="select-row__text">
<span class="select-row__title">@ItemLabel(item)</span>
</span>
</button>
}
</div>
</ActionSheet>
@code {
/// Titolo dello sheet: e' il nome del campo ("Reparto", "Motivo").
[Parameter, EditorRequired] public string Title { get; set; } = "";
[Parameter] public string? Subtitle { get; set; }
[Parameter, EditorRequired] public IEnumerable<TValue> Items { get; set; } = [];
[Parameter] public TValue? Value { get; set; }
[Parameter] public EventCallback<TValue?> ValueChanged { get; set; }
/// Come si scrive un'opzione. Deve essere fornita: il ToString() di un DTO
/// non e' un'etichetta.
[Parameter, EditorRequired] public Func<TValue, string?> ItemLabel { get; set; } = _ => null;
[Parameter] public string Placeholder { get; set; } = "Scegli";
[Parameter] public bool ReadOnly { get; set; }
private bool _open;
private bool HasValue => Value is not null;
private string? SelectedLabel => Value is null ? null : ItemLabel(Value);
// Il confronto passa per l'etichetta, non per l'oggetto: il valore corrente
// arriva ricostruito dall'entita' salvata (Scheda.Reparto lo crea al volo da
// codice e descrizione), quindi non e' mai lo stesso oggetto di quelli in
// elenco e il confronto di riferimento non segnerebbe mai nulla.
private bool IsSelected(TValue item) =>
Value is not null &&
string.Equals(ItemLabel(item), SelectedLabel, StringComparison.Ordinal);
private void Open()
{
if (!ReadOnly) _open = true;
}
private async Task Choose(TValue item)
{
_open = false;
Value = item;
await ValueChanged.InvokeAsync(item);
}
}
@@ -1,5 +1,8 @@
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Maui.Devices.Sensors;
using SteUp.Shared.Core.BarcodeReader.Contracts; using SteUp.Shared.Core.BarcodeReader.Contracts;
using SteUp.Shared.Core.BarcodeReader.Dto;
using SteUp.Shared.Core.BarcodeReader.Enum;
using SteUp.Shared.Core.Messages.Scanner; using SteUp.Shared.Core.Messages.Scanner;
namespace SteUp.Shared.Core.BarcodeReader; namespace SteUp.Shared.Core.BarcodeReader;
@@ -19,14 +22,48 @@ public class BarcodeManager(
scanner.Register( scanner.Register(
onScanSuccessful: dto => onScanSuccessful: dto =>
{ {
messenger.Send(new NewScannerMessage(dto.StringValue)); var stringValue = dto.StringValue;
if (IsEanPeso(dto))
stringValue = DecodeEanPeso(stringValue);
else if (IsPlu(dto))
stringValue = DecodePlu(stringValue);
messenger.Send(new NewScannerMessage(stringValue));
}, },
onScanFailed: ex => onScanFailed: ex => { messenger.Send(new ErrorScannerMessage(ex.Message)); }
{
messenger.Send(new ErrorScannerMessage(ex.Message));
}
); );
scanner.Init(() => { scanner.ChangeSettings([("TRIGGER_SCAN_MODE", "ONE_SHOT")]); }); scanner.Init(() => { scanner.ChangeSettings([("TRIGGER_SCAN_MODE", "ONE_SHOT")]); });
} }
private static bool IsEanPeso(BarcodeScanDto barcodeScan) =>
(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);
}
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);
}
} }
@@ -1,8 +1,10 @@
namespace SteUp.Shared.Core.BarcodeReader.Dto; using SteUp.Shared.Core.BarcodeReader.Enum;
namespace SteUp.Shared.Core.BarcodeReader.Dto;
public class BarcodeScanDto public class BarcodeScanDto
{ {
public string? StringValue { get; set; } public string? StringValue { get; set; }
public string? Type { get; set; } public BarcodeType? Type { get; set; }
public string? Name { get; set; } public string? Name { get; set; }
} }
@@ -0,0 +1,75 @@
namespace SteUp.Shared.Core.BarcodeReader.Enum;
public enum BarcodeType
{
NIL = 0,
AIRLINE_2OF5_13_DIGIT = 1,
AIRLINE_2OF5_15_DIGIT = 2,
AZTEC = 3,
AUSTRALIAN_POSTAL = 4,
BOOKLAND_EAN = 5,
BPO = 6,
CANPOST = 7,
CHINAPOST = 8,
CHINESE_2OF5 = 9,
CODABAR = 10,
CODABLOCK = 11,
CODE11 = 12,
CODE128 = 13,
CODE16K = 14,
CODE32 = 15,
CODE39 = 16,
CODE49 = 17,
CODE93 = 18,
COMPOSITE = 19,
COUPON_CODE = 20,
DATAMATRIX = 21,
DISCRETE_2OF5 = 22,
DUTCH_POSTAL = 23,
EAN128 = 24,
EAN13 = 25,
EAN8 = 26,
GS1_DATABAR_14 = 27,
GS1_DATABAR_EXPANDED = 28,
GS1_DATABAR_LIMITED = 29,
HONGKONG_2OF5 = 30,
IATA_2OF5 = 31,
IDTAG = 32,
INTERLEAVED_2OF5 = 33,
ISBT128 = 34,
JAPANESE_POSTAL = 35,
KOREAN_POSTAL = 36,
MATRIX_2OF5 = 37,
MAXICODE = 38,
MESA = 39,
MICRO_PDF417 = 40,
MICRO_QR = 41,
MSI = 42,
NEC_2OF5 = 43,
OCR = 44,
PDF417 = 45,
PLESSEY = 46,
POSICODE = 47,
POST_US4 = 48,
QR = 49,
STRAIGHT_2OF5 = 50,
STANDARD_2OF5 = 51,
TELEPEN = 52,
TLCODE39 = 53,
TRIOPTIC = 54,
UK_POSTAL = 55,
UPCA = 56,
UPCE = 57,
UPCE1 = 58,
US_PLANET = 59,
US_POSTNET = 60,
USPS_4CB = 61,
RSS = 62,
LABEL = 63,
HANXIN = 64,
GRIDMATRIX = 65,
INFO_MAIL = 66,
INTELLIGENT_MAIL = 67,
SWEDENPOST = 68,
LAST = 69
}
+3
View File
@@ -8,6 +8,9 @@ public class SaveRequestDto
[JsonPropertyName("localIdScheda")] [JsonPropertyName("localIdScheda")]
public int? LocalIdScheda { get; set; } public int? LocalIdScheda { get; set; }
[JsonPropertyName("activityId")]
public string? ActivityId { get; set; }
[JsonPropertyName("codMdep")] [JsonPropertyName("codMdep")]
public string? CodMdep { get; set; } public string? CodMdep { get; set; }
+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; }
+4 -6
View File
@@ -17,10 +17,10 @@ public static class ModalHelper
new DialogParameters(), new DialogParameters(),
new DialogOptions new DialogOptions
{ {
FullScreen = false, FullScreen = true,
CloseButton = false, CloseButton = false,
NoHeader = true, NoHeader = true,
BackdropClick = true BackdropClick = false
} }
); );
@@ -100,12 +100,10 @@ public static class ModalHelper
}, },
new DialogOptions new DialogOptions
{ {
FullScreen = false, FullScreen = true,
CloseButton = false, CloseButton = false,
NoHeader = true, NoHeader = true,
BackdropClick = true, BackdropClick = false
FullWidth = true,
MaxWidth = MaxWidth.ExtraLarge
} }
); );
@@ -0,0 +1,71 @@
using MudBlazor;
using SteUp.Shared.Core.Entities;
namespace SteUp.Shared.Core.Helpers;
/// <summary>
/// Stato di sincronizzazione di una scheda rispetto al server Integry.
/// Principio "Offline è la verità": il rilevatore deve sempre vedere cosa è
/// ancora solo sul dispositivo e cosa è già arrivato al server.
/// </summary>
public enum SyncState
{
/// Salvata solo in locale, non ancora inviata al server.
DaInviare = 0,
/// Inviata al server (ha un ActivityId).
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) =>
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
};
/// <summary>
/// Colore del badge. Corallo (Primary) = azione richiesta (da inviare);
/// verde (Success) = fatto (inviato). Rispetta la Regola dell'Unico Accento.
/// </summary>
public static Color GetColor(this SyncState state) =>
state switch
{
SyncState.DaInviare => Color.Primary,
SyncState.Inviato => Color.Success,
SyncState.Bozza => Color.Warning,
_ => Color.Default
};
public static string GetIcon(this SyncState state) =>
state switch
{
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. Le bozze non contano:
/// non sono inviabili finché non vengono completate.
public static int CountDaInviare(this IEnumerable<Scheda> schede) =>
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,59 @@
using SteUp.Shared.Core.Entities;
using SteUp.Shared.Core.Enum;
namespace SteUp.Shared.Core.Helpers;
/// <summary>
/// Mappature di stato per i badge (STILE_UI §6.4): tono, icona ed etichetta di
/// ogni stato vivono qui e solo qui. Le schermate non compongono badge a mano,
/// così lo stesso stato si presenta identico ovunque e un nuovo stato si
/// aggiunge in un punto solo.
/// </summary>
public static class StatusBadgeHelper
{
public readonly record struct BadgeSpec(string Tone, string Icon, string Label);
/// Stato di una scheda rispetto al server.
public static BadgeSpec ToBadge(this SyncState state) =>
state switch
{
SyncState.Bozza => new BadgeSpec("warning", "ri-edit-line", "Da completare"),
SyncState.DaInviare => new BadgeSpec("danger", "ri-smartphone-line", "Da inviare"),
SyncState.Inviato => new BadgeSpec("success", "ri-cloud-line", "Inviato"),
_ => new BadgeSpec("neutral", "ri-question-line", "Sconosciuto")
};
/// Stato di un'ispezione.
public static BadgeSpec ToBadge(this StatusEnum stato) =>
stato switch
{
StatusEnum.InCorso => new BadgeSpec("info", "ri-play-circle-line", "In corso"),
StatusEnum.Completata => new BadgeSpec("success", "ri-check-double-line", "Conclusa"),
StatusEnum.Verifica => new BadgeSpec("warning", "ri-search-eye-line", "In verifica"),
StatusEnum.Annullata => new BadgeSpec("neutral", "ri-close-circle-line", "Annullata"),
_ => new BadgeSpec("neutral", "ri-question-line", "Sconosciuto")
};
/// <summary>
/// Sintesi di un'ispezione per i lettori di schermo: cosa è, a che punto
/// sta e cosa resta da fare, in una frase sola.
/// </summary>
public static string ToSemanticLabel(this Ispezione ispezione)
{
var schede = ispezione.Schede.Count;
var daInviare = ispezione.Schede.CountDaInviare();
var bozze = ispezione.Schede.CountBozze();
var parti = new List<string>
{
$"Ispezione {ispezione.CodMdep}",
ispezione.Stato.ToBadge().Label,
schede == 1 ? "1 scheda" : $"{schede} schede"
};
if (daInviare > 0) parti.Add($"{daInviare} da inviare");
if (bozze > 0) parti.Add($"{bozze} da completare");
return string.Join(", ", parti);
}
}
@@ -7,12 +7,13 @@ public static class StatusEnumHelper
{ {
public static string ConvertToHumanReadable(this StatusEnum enumValue) public static string ConvertToHumanReadable(this StatusEnum enumValue)
{ {
// Sentence-case: l'enfasi la dà il peso del chip, non il maiuscolo (Regola del Peso, non del Maiuscolo).
return enumValue switch return enumValue switch
{ {
StatusEnum.InCorso => "IN CORSO", StatusEnum.InCorso => "In corso",
StatusEnum.Completata => "COMPLETATA", StatusEnum.Completata => "Completata",
StatusEnum.Verifica => "VERIFICA", StatusEnum.Verifica => "Verifica",
StatusEnum.Annullata => "ANNULLATA", StatusEnum.Annullata => "Annullata",
_ => throw new ArgumentOutOfRangeException(nameof(enumValue), enumValue, null) _ => throw new ArgumentOutOfRangeException(nameof(enumValue), enumValue, null)
}; };
} }
@@ -1,5 +0,0 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace SteUp.Shared.Core.Messages.Ispezione;
public class CompleteInspectionMessage(object? value = null) : ValueChangedMessage<object?>(value);
@@ -1,13 +0,0 @@
using CommunityToolkit.Mvvm.Messaging;
namespace SteUp.Shared.Core.Messages.Ispezione;
public class CompleteInspectionService
{
public event Action? OnComplete;
public CompleteInspectionService(IMessenger messenger)
{
messenger.Register<CompleteInspectionMessage>(this, (_, _) => { OnComplete?.Invoke(); });
}
}
@@ -1,5 +0,0 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace SteUp.Shared.Core.Messages.Scheda;
public class NewSchedaMessage(object? value = null) : ValueChangedMessage<object?>(value);
@@ -1,13 +0,0 @@
using CommunityToolkit.Mvvm.Messaging;
namespace SteUp.Shared.Core.Messages.Scheda;
public class NewSchedaService
{
public event Action? OnNewScheda;
public NewSchedaService(IMessenger messenger)
{
messenger.Register<NewSchedaMessage>(this, (_, _) => { OnNewScheda?.Invoke(); });
}
}
@@ -0,0 +1,58 @@
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.
///
/// I gestori sono impilati: sopra il form può aprirsi un bottom sheet, e il
/// tasto indietro deve chiudere prima quello. Chiudendolo si torna al gestore
/// sottostante, che resta valido — senza la pila il sheet, uscendo di scena,
/// porterebbe via con sé anche la registrazione del form.
/// </summary>
public class BackButtonService
{
private readonly List<Func<Task>> _handlers = [];
/// <summary>
/// Segnala all'host se in questo momento c'è qualcuno da avvisare. L'host
/// se ne serve per accendere e spegnere la propria intercettazione, così
/// quando la UI non ha nulla da dire il tasto indietro torna a comportarsi
/// esattamente come sempre.
/// </summary>
public event Action<bool>? HandlersChanged;
public bool HasHandlers => _handlers.Count > 0;
/// Registra il gestore corrente in cima alla pila. Ritorna la deregistrazione.
public IDisposable Register(Func<Task> handler)
{
_handlers.Add(handler);
HandlersChanged?.Invoke(true);
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()
{
if (_handlers.Count == 0) return false;
// Fire-and-forget: il gestore rientra nel dispatcher Blazor da solo.
_ = _handlers[^1]();
return true;
}
private sealed class Registration(BackButtonService owner, Func<Task> handler) : IDisposable
{
public void Dispose()
{
if (!owner._handlers.Remove(handler)) return;
owner.HandlersChanged?.Invoke(owner.HasHandlers);
}
}
}
@@ -0,0 +1,46 @@
namespace SteUp.Shared.Core.Messages.System;
/// <summary>
/// Ponte fra il comando al centro della barra di navigazione e la schermata
/// che sta sotto. Il comando sta sempre nello stesso punto — sotto il pollice,
/// dove il rilevatore lo cerca — ma quello che fa appartiene alla schermata:
/// dentro un'ispezione apre le azioni di quell'ispezione, altrove apre una
/// nuova ispezione.
///
/// La pagina si registra finché è a video e si sfila uscendo, così la barra
/// non tiene in vita un riferimento a schermate che non ci sono più. Chi non
/// registra niente lascia al comando il suo significato di default: la barra
/// non resta mai con un tasto che non fa nulla.
/// </summary>
public class PageActionsService
{
private Func<Task>? _handler;
/// Avvisa la barra che il significato del comando è cambiato, così si ridisegna.
public event Action? Changed;
public bool HasActions => _handler is not null;
public IDisposable Register(Func<Task> handler)
{
_handler = handler;
Changed?.Invoke();
return new Registration(this, handler);
}
public Task Invoke() => _handler?.Invoke() ?? Task.CompletedTask;
private sealed class Registration(PageActionsService owner, Func<Task> handler) : IDisposable
{
public void Dispose()
{
// Solo chi ha registrato per ultimo può sfilare: se una schermata
// nuova si è già registrata, la vecchia uscendo non deve spegnerla.
if (owner._handler != handler) return;
owner._handler = null;
owner.Changed?.Invoke();
}
}
}
@@ -26,7 +26,7 @@ public class IntegrySteupService(IIntegryApiRestClient integryApiRestClient) : I
public Task<ArticoliInGrigliaDto?> RetrieveArtFromBarcode(string barcode) => public Task<ArticoliInGrigliaDto?> RetrieveArtFromBarcode(string barcode) =>
integryApiRestClient.AuthorizedGet<ArticoliInGrigliaDto?>( integryApiRestClient.AuthorizedGet<ArticoliInGrigliaDto?>(
$"{BaseRequest}/retrieveArtFromBarcode", $"{BaseRequest}/retrieveArtFromBarcode",
new Dictionary<string, object> new Dictionary<string, object?>
{ {
{ "barcode", barcode } { "barcode", barcode }
} }
@@ -73,7 +73,7 @@ public class IntegrySteupService(IIntegryApiRestClient integryApiRestClient) : I
public Task DeleteScheda(string activityId) => public Task DeleteScheda(string activityId) =>
integryApiRestClient.AuthorizedGet<object>( integryApiRestClient.AuthorizedGet<object>(
$"{BaseRequest}/deleteScheda", $"{BaseRequest}/deleteScheda",
new Dictionary<string, object> new Dictionary<string, object?>
{ {
{ "activityId", activityId } { "activityId", activityId }
} }
+7 -7
View File
@@ -11,15 +11,15 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="IntegryApiClient.Core" Version="2.0.0" /> <PackageReference Include="IntegryApiClient.Core" Version="2.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.3" /> <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.7" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0"/> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="MudBlazor" Version="9.0.0" /> <PackageReference Include="MudBlazor" Version="9.0.0" />
<PackageReference Include="CodeBeam.MudBlazor.Extensions" Version="9.0.1" /> <PackageReference Include="CodeBeam.MudBlazor.Extensions" Version="9.0.1" />
<PackageReference Include="Sentry" Version="6.1.0" /> <PackageReference Include="Sentry" Version="6.4.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.17.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.3" /> <PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.7" />
<PackageReference Include="Microsoft.Maui.Essentials" Version="10.0.41" /> <PackageReference Include="Microsoft.Maui.Essentials" Version="10.0.51" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
-335
View File
@@ -1,335 +0,0 @@
html {
overflow: hidden;
}
.page, article, main {
height: 100% !important;
overflow: hidden;
}
#app {
height: 100vh;
}
html, body {
font-family: "Nunito", sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 1.8;
color: black;
}
* {
font-family: "Nunito", sans-serif !important;
}
html ::-webkit-scrollbar {
width: 3px;
height: 3px;
}
html ::-webkit-scrollbar-thumb {
background: #bbb;
border-radius: 2px;
}
.mud-button-label {
font-weight: 700 !important;
}
a, .btn-link {
/*color: #006bb7;*/
text-decoration: none;
color: inherit;
}
article {
display: flex;
flex-direction: column;
}
/*ServicesIsDown" : "SystemOk" : "NetworkKo*/
.Connection {
padding: 0 .75rem;
font-weight: 700;
display: flex;
flex-direction: row;
gap: 1rem;
font-size: larger;
transition: all 0.5s ease;
opacity: 0;
transform: translateY(-35px);
min-height: 35px;
align-items: center;
}
.Connection.ServicesIsDown, .Connection.NetworkKo {
background-color: var(--mud-palette-error);
color: white;
}
.Connection.SystemOk {
background-color: var(--mud-palette-success);
color: white;
}
.Connection.Show {
opacity: 1;
transform: translateY(0);
}
.Connection.Hide ~ .page {
transition: all 0.5s ease;
transform: translateY(-35px);
}
.btn-primary {
color: #fff;
background-color: var(--primary-color);
border-color: var(--darker-color);
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1rem;
display: flex;
align-items: center;
flex-direction: column;
height: 90vh;
}
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.status-bar-safe-area {
display: none;
}
.page-title {
font-size: x-large;
font-weight: 800;
margin: 0;
line-height: normal;
color: var(--mud-palette-text-primary);
}
.custom-mudfab {
position: fixed !important;
bottom: 4rem;
margin-bottom: 16px;
right: 16px;
}
.divider {
display: block;
width: 100%;
border: .05rem solid var(--mud-palette-lines-inputs);
}
.pb-safe-area {
padding-bottom: 75px;
overflow: auto;
}
/*Spinner*/
.spinner-container {
display: flex;
justify-content: center;
height: 100vh;
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)
}
}
/*MudBlazor Personalization*/
.mud-button-group-horizontal:not(.mud-button-group-rtl) > .mud-button-root:not(:last-child), .mud-button-group-horizontal:not(.mud-button-group-rtl) > :not(:last-child) .mud-button-root {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.mud-button-group-horizontal:not(.mud-button-group-rtl) > .mud-button-root:not(:first-child), .mud-button-group-horizontal:not(.mud-button-group-rtl) > :not(:first-child) .mud-button-root {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
.customDialog-form .mud-dialog-content {
padding: 0 0;
margin: 0;
overflow: hidden;
}
.custom-item-select {
padding: 6px 16px;
}
.custom-item-select .mud-typography-body1 {
font-weight: 600;
font-size: .9rem;
}
.container {
padding-right: var(--m-page-x) !important;
padding-left: var(--m-page-x) !important;
}
.lm-container {
padding-right: calc(var(--m-page-x) * 0.5) !important;
padding-left: calc(var(--m-page-x) * 0.5) !important;
}
.mud-message-box > .mud-dialog-title > h6 {
font-weight: 800 !important;
}
.mud-dialog-actions button {
margin-left: .5rem !important;
margin-right: .5rem !important;
}
@supports (-webkit-touch-callout: none) {
.status-bar-safe-area {
display: flex;
position: fixed;
top: 0;
height: env(safe-area-inset-top);
width: 100%;
z-index: 1;
background-color: var(--mud-palette-surface);
}
.modal {
padding-top: env(safe-area-inset-top);
}
.safe-area-bottom {
margin-bottom: env(safe-area-inset-bottom) !important;
}
.pb-safe-area {
padding-bottom: env(safe-area-inset-bottom) !important;
}
#app {
margin-top: env(safe-area-inset-top);
height: calc(100vh - env(safe-area-inset-top));
}
.flex-column, .navbar-brand {
padding-left: env(safe-area-inset-left);
}
.customDialog-form .mud-dialog-content {
margin-top: env(safe-area-inset-top);
}
}
/*Ripple*/
.ripple-container {
position: relative;
overflow: hidden;
cursor: pointer;
transform: translateZ(0);
}
.ripple {
position: absolute;
border-radius: 50%;
transform: scale(0);
animation: ripple-effect 0.6s linear;
background-color: rgba(0, 0, 0, 0.2);
pointer-events: none;
}
@keyframes ripple-effect {
to {
transform: scale(4);
opacity: 0;
}
}
.ripple-white .ripple {
background-color: rgba(255, 255, 255, 0.5);
}
+299
View File
@@ -0,0 +1,299 @@
/* =============================================================================
SteUP Base
Reset, shell applicativa, tipografia e comportamenti "da app nativa".
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
height: 100%;
overflow: hidden;
/* La WebView non deve comportarsi come un browser: niente rimbalzo elastico
ai bordi, niente pull-to-refresh, niente zoom a doppio tocco. */
overscroll-behavior: none;
touch-action: manipulation;
-webkit-text-size-adjust: 100%;
}
body {
height: 100%;
margin: 0;
overflow: hidden;
background-color: var(--workspace);
color: var(--ink);
font-family: var(--font-sans);
font-size: var(--body-lg-size);
line-height: var(--body-lg-height);
font-weight: 400;
/* Inter: cifre a larghezza fissa, cosi' i contatori non "ballano" mentre
si aggiornano, e forme delle lettere piu' leggibili in piccolo. */
font-feature-settings: "tnum" 1, "cv05" 1;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* Niente selezione del testo, niente menu contestuale al tocco lungo,
niente lampo grigio sul tap: sono i tre segnali che tradiscono una
pagina web dentro un'app. */
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
cursor: default;
}
/* Il contenuto dei dati resta selezionabile: un barcode o un codice servono
anche da copiare. */
.selectable,
input,
textarea {
user-select: text;
-webkit-user-select: text;
-webkit-touch-callout: default;
}
#app {
height: 100dvh;
overflow: hidden;
}
img {
max-width: 100%;
display: block;
}
[hidden] {
display: none !important;
}
/* -----------------------------------------------------------------------------
Tipografia scala fissa (STILE_UI §4)
La gerarchia si fa con peso e dimensione, mai con il maiuscolo spaziato.
-------------------------------------------------------------------------- */
.t-headline-md {
font-size: var(--headline-md-size);
line-height: var(--headline-md-height);
font-weight: 700;
letter-spacing: -0.02em;
}
.t-headline-sm {
font-size: var(--headline-sm-size);
line-height: var(--headline-sm-height);
font-weight: 700;
letter-spacing: -0.02em;
}
.t-title-lg {
font-size: var(--title-lg-size);
line-height: var(--title-lg-height);
font-weight: 700;
letter-spacing: -0.01em;
}
.t-title-md {
font-size: var(--title-md-size);
line-height: var(--title-md-height);
font-weight: 700;
}
.t-body-lg {
font-size: var(--body-lg-size);
line-height: var(--body-lg-height);
font-weight: 400;
}
.t-body-md {
font-size: var(--body-md-size);
line-height: var(--body-md-height);
font-weight: 400;
}
.t-label-lg {
font-size: var(--label-lg-size);
line-height: 1.25;
font-weight: 500;
}
.t-label-md {
font-size: var(--label-md-size);
line-height: 1.25;
font-weight: 500;
}
/* Identificativo tecnico sopra il titolo di una card (STILE_UI §4) */
.t-ident {
font-size: var(--label-sm-size);
line-height: 1.3;
font-weight: 700;
letter-spacing: 0.3px;
color: var(--ink-muted);
}
.t-muted {
color: var(--ink-muted);
}
/* Il testo secondario si distingue per colore, mai per opacita'. */
.t-meta {
font-size: var(--body-md-size);
line-height: var(--body-md-height);
font-weight: 400;
color: var(--ink-muted);
}
/* Prosa: righe non piu' lunghe di 65-75 caratteri. */
.t-prose {
max-width: 68ch;
text-wrap: pretty;
}
h1, h2, h3 {
text-wrap: balance;
margin: 0;
}
h1, h2, h3, h4, h5, h6, p {
margin: 0;
}
/* -----------------------------------------------------------------------------
Shell applicativa
-------------------------------------------------------------------------- */
.app-shell {
display: flex;
flex-direction: column;
height: 100dvh;
overflow: hidden;
background-color: var(--workspace);
}
/* Area scorrevole unica della schermata. Lo scroll con inerzia e il
contenimento evitano che il gesto "sfondi" sulla pagina sottostante. */
.app-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
scrollbar-width: none;
}
.app-scroll::-webkit-scrollbar {
display: none;
}
/* Contenitore di pagina (STILE_UI §6.1): su telefono e' trasparente, su
schermo largo tiene una colonna leggibile invece di allargarsi all'infinito. */
.page-width {
width: 100%;
max-width: var(--content-max);
margin-inline: auto;
padding-inline: var(--space-md);
}
/* Spazio in coda per non finire sotto la barra di navigazione flottante. */
.page-body {
padding-bottom: calc(var(--nav-clearance) + env(safe-area-inset-bottom, 0px));
}
/* Pagine senza barra di navigazione (login, form a schermo pieno). */
.page-body--flush {
padding-bottom: calc(var(--space-lg) + env(safe-area-inset-bottom, 0px));
}
.stack {
display: flex;
flex-direction: column;
}
.stack-xs { gap: var(--space-xs); }
.stack-10 { gap: var(--space-10); }
.stack-sm { gap: var(--space-sm); }
.stack-md { gap: var(--space-md); }
.stack-lg { gap: var(--space-lg); }
/* -----------------------------------------------------------------------------
Focus
Visibile e coerente ovunque, senza sporcare il tocco: solo da tastiera.
-------------------------------------------------------------------------- */
:focus {
outline: none;
}
/* L'anello segna un comando raggiunto da tastiera. Va messo sui controlli, non
su qualunque elemento che il motore decida di mettere a fuoco: la WebView
sposta il fuoco anche su contenitori non interattivi (per esempio la testata,
dopo la chiusura di un dialog) e li' l'anello indica un bersaglio che non
esiste. */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[role="button"]:focus-visible,
[tabindex]:not([tabindex="-1"]):focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
border-radius: var(--radius-compact);
}
/* Dove e' il contenitore a segnare il fuoco campo di accesso, ricerca,
barcode, gruppo di form l'anello sull'input disegnerebbe un secondo bordo
dentro il primo, con raggio e colore diversi. Il fuoco resta visibile: lo
mostra il bordo del campo. */
.onboarding__field input:focus-visible,
.search-field input:focus-visible,
.barcode-field input:focus-visible,
.form-group input:focus-visible,
.form-group textarea:focus-visible {
outline: none;
}
/* -----------------------------------------------------------------------------
Risposta al tocco
Feedback fisico immediato (principio "Feedback immediato a ogni scan"):
l'elemento cede sotto il dito e torna. Niente stati basati sull'hover, che
su un touch screen non esiste e lascia elementi "accesi" dopo il tocco.
-------------------------------------------------------------------------- */
.pressable {
transition: transform var(--duration-fast) var(--ease-out),
background-color var(--duration-fast) var(--ease-out);
}
.pressable:active {
transform: scale(0.98);
}
@media (hover: hover) and (pointer: fine) {
.pressable:hover {
background-color: var(--surface-muted);
}
}
/* -----------------------------------------------------------------------------
Movimento ridotto non e' opzionale
-------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.pressable:active {
transform: none;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More