diff --git a/.impeccable/config.json b/.impeccable/config.json
new file mode 100644
index 0000000..fdcefed
--- /dev/null
+++ b/.impeccable/config.json
@@ -0,0 +1,14 @@
+{
+ "detector": {
+ "ignoreRules": [],
+ "ignoreFiles": [],
+ "ignoreValues": [
+ {
+ "rule": "overused-font",
+ "value": "inter",
+ "reason": "Scelta esplicita dell'utente: STILE_UI.md prescrive Inter per uniformare le app Integry; il register product della skill ammette Inter per le UI di prodotto",
+ "createdAt": "2026-09-17T08:51:01.079Z"
+ }
+ ]
+ }
+}
diff --git a/DESIGN.md b/DESIGN.md
index e7a7e26..d54c483 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -3,69 +3,150 @@ name: SteUP
description: Strumento da campo per ispezioni nei punti vendita — solido, chiaro, offline-first.
colors:
primary: "#ec4c41"
- secondary: "#002339"
- tertiary: "#dff2ff"
+ primary-pressed: "#d23a30"
+ primary-dark: "#002339"
+ on-primary: "#ffffff"
+ workspace: "#e9edf2"
+ surface: "#ffffff"
+ surface-muted: "#f4f6f9"
+ divider: "#d9e0e8"
ink: "#000000"
- paper: "#ffffff"
- surface-dark: "#000406"
- success: "#26b050"
- error: "#e50000"
+ ink-muted: "#465563"
+ danger-ink: "#b3120c"
+ danger-surface: "#fdeceb"
+ danger-solid: "#e50000"
+ warning-ink: "#8a4f00"
+ warning-surface: "#fdf0db"
+ warning-solid: "#b26a00"
+ success-ink: "#10702f"
+ success-surface: "#e4f5e9"
+ success-solid: "#26b050"
+ info-ink: "#002339"
+ info-surface: "#dff2ff"
+ info-solid: "#1877d8"
+ neutral-ink: "#465563"
+ neutral-surface: "#f4f6f9"
focus-ring: "#258cfb"
typography:
- page-title:
- fontFamily: "Nunito, sans-serif"
- fontSize: "1.5rem"
- fontWeight: 800
+ headline-medium:
+ fontFamily: "Inter, sans-serif"
+ fontSize: "34px"
+ fontWeight: 700
lineHeight: 1.2
- letterSpacing: "normal"
- title:
- fontFamily: "Nunito, sans-serif"
- fontSize: "1.1rem"
+ letterSpacing: "-0.02em"
+ headline-small:
+ fontFamily: "Inter, sans-serif"
+ fontSize: "28px"
fontWeight: 700
- lineHeight: 1.4
+ lineHeight: 1.25
+ letterSpacing: "-0.02em"
+ title-large:
+ fontFamily: "Inter, sans-serif"
+ fontSize: "22px"
+ fontWeight: 700
+ lineHeight: 1.3
+ letterSpacing: "-0.01em"
+ title-medium:
+ fontFamily: "Inter, sans-serif"
+ fontSize: "18px"
+ fontWeight: 700
+ lineHeight: 1.35
letterSpacing: "normal"
- body:
- fontFamily: "Nunito, sans-serif"
- fontSize: "0.875rem"
+ body-large:
+ fontFamily: "Inter, sans-serif"
+ fontSize: "16px"
fontWeight: 400
- lineHeight: 1.8
+ lineHeight: 1.5
letterSpacing: "normal"
- label:
- fontFamily: "Nunito, sans-serif"
- fontSize: "0.875rem"
+ body-medium:
+ fontFamily: "Inter, sans-serif"
+ fontSize: "15px"
+ fontWeight: 400
+ lineHeight: 1.45
+ letterSpacing: "normal"
+ label-large:
+ fontFamily: "Inter, sans-serif"
+ fontSize: "14px"
+ fontWeight: 600
+ lineHeight: 1.25
+ letterSpacing: "normal"
+ label-medium:
+ fontFamily: "Inter, sans-serif"
+ fontSize: "13px"
+ fontWeight: 600
+ lineHeight: 1.25
+ letterSpacing: "normal"
+ label-small:
+ fontFamily: "Inter, sans-serif"
+ fontSize: "12px"
fontWeight: 700
- lineHeight: 1.4
+ lineHeight: 1.3
+ letterSpacing: "0.3px"
+ icon-sm:
+ fontFamily: "remixicon"
+ fontSize: "15px"
+ fontWeight: 400
+ lineHeight: 1
+ letterSpacing: "normal"
+ icon-md:
+ fontFamily: "remixicon"
+ fontSize: "20px"
+ fontWeight: 400
+ lineHeight: 1
+ letterSpacing: "normal"
+ icon-lg:
+ fontFamily: "remixicon"
+ fontSize: "26px"
+ fontWeight: 400
+ lineHeight: 1
+ letterSpacing: "normal"
+ icon-xl:
+ fontFamily: "remixicon"
+ fontSize: "44px"
+ fontWeight: 400
+ lineHeight: 1
letterSpacing: "normal"
rounded:
- input: "9px"
- panel: "1em"
- container: "20px"
- skeleton: "0.5em"
+ compact: "8px"
+ control: "16px"
+ surface: "20px"
+ sheet: "24px"
+ pill: "999px"
spacing:
- page-x: "1rem"
- header-h: "4rem"
+ xs: "8px"
+ sm: "12px"
+ md: "16px"
+ lg: "24px"
+ xl: "32px"
touch-min: "44px"
+ button-height: "48px"
+ appbar-height: "80px"
+ navbar-height: "68px"
+ content-max: "920px"
components:
+ panel:
+ backgroundColor: "{colors.surface}"
+ textColor: "{colors.ink}"
+ rounded: "{rounded.surface}"
+ padding: "16px"
button-primary:
backgroundColor: "{colors.primary}"
- textColor: "{colors.paper}"
- rounded: "{rounded.container}"
- typography: "{typography.label}"
- padding: "0.5rem 1.25rem"
- button-primary-active:
- backgroundColor: "{colors.primary}"
- textColor: "{colors.paper}"
- rounded: "{rounded.container}"
- input-card:
- backgroundColor: "#f5f5f5"
+ textColor: "{colors.on-primary}"
+ rounded: "{rounded.control}"
+ typography: "{typography.label-large}"
+ padding: "0 24px"
+ button-outlined:
+ backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
- rounded: "{rounded.input}"
- padding: "0.5rem 1rem"
- panel:
- backgroundColor: "{colors.paper}"
- textColor: "{colors.ink}"
- rounded: "{rounded.panel}"
- padding: "1rem"
+ rounded: "{rounded.control}"
+ typography: "{typography.label-large}"
+ badge:
+ rounded: "{rounded.pill}"
+ typography: "{typography.label-small}"
+ padding: "5px 9px"
+ navbar:
+ backgroundColor: "{colors.surface}"
+ rounded: "{rounded.sheet}"
---
# Design System: SteUP
@@ -74,119 +155,116 @@ components:
**Creative North Star: "Il taccuino da campo"**
-SteUP è lo strumento che il rilevatore tiene in mano mentre gira i punti vendita: deve funzionare come carta e inchiostro — sempre, anche senza rete, leggibile in pieno sole, comprensibile con un'occhiata. La chiarezza viene prima di qualsiasi ornamento. L'interfaccia poggia su MudBlazor con un'unica famiglia tipografica (Nunito) e una palette essenziale: un solo accento caldo per l'azione, un blu profondo come base stabile, superfici bianche e pulite con angoli generosamente arrotondati.
+SteUP è lo strumento che il rilevatore tiene in mano mentre gira i punti vendita: deve funzionare come carta e inchiostro — sempre, anche senza rete, leggibile in pieno sole, comprensibile con un'occhiata.
-Il sistema è **tattile e sicuro**: bersagli ampi pensati per il pollice, una sola mano e i guanti; feedback fisico immediato a ogni tocco e a ogni scansione (effetto ripple, cambi di stato evidenti). Lo stato — salvato in locale, in attesa di sync, inviato al server, completato — non è mai ambiguo: è la funzione più importante che la UI comunica.
+Il sistema visivo segue **`STILE_UI.md`**, lo stile condiviso delle app Integry (riferimento: Fixiy). Quel documento definisce forma, ritmo, tipografia e composizione; questo documento fissa i valori concreti di SteUP su quei ruoli.
-Questo sistema rifiuta esplicitamente due cose. Non è un **gestionale datato e denso**: niente tabelle fittissime, testo minuscolo, grigio ovunque, densità che non serve al lavoro da campo. E non è un'**app consumer social/giocosa**: niente colore ludico, niente decorazione fine a sé stessa. È uno strumento professionale.
-
-**Key Characteristics:**
-- Offline-first: lo stato di salvataggio e sincronizzazione è sempre esplicito
-- Mobile-first, uso a una mano/guanti: tocchi ≥44px, azioni a portata di pollice
-- Alto contrasto e testo generoso per la leggibilità all'aperto
-- Un solo accento (Arancio-Rosso Allerta), usato per azione e stato, mai per decorazione
-- Un'unica famiglia tipografica, angoli arrotondati coerenti (9px → 20px → 1em)
+**Caratteristiche portanti:**
+- **Superfici piatte.** Nessuna ombra costruisce gerarchia: la separazione viene dal contrasto di superficie (sfondo pagina più freddo delle superfici) e dallo spazio. L'unica ombra del sistema è quella del toast.
+- **Offline-first.** Lo stato di salvataggio e sincronizzazione è sempre esplicito. Il carattere è ospitato in locale: un font che arriva dalla rete è un font che in campo non arriva.
+- **Il pollice comanda.** Tocchi ≥44px, azioni primarie in basso: navigazione flottante, barra azioni ancorata in fondo ai form.
+- **Alto contrasto.** Inchiostro pieno sul corpo del testo, per la luce diretta del sole.
+- **Un solo accento.** Il corallo indica azione e stato attivo, mai decorazione.
## 2. Colors
-Una palette essenziale: un accento caldo che chiama l'azione, un blu profondo come ancora, il resto neutro e pulito.
+I ruoli sono quelli di `STILE_UI.md` §2, riempiti con la palette storica di SteUP. Sono stati aggiunti solo i ruoli che mancavano: `workspace`, `surface-muted`, `divider`, `ink-muted` e le triplette dei toni semantici.
-### Primary
-- **Arancio-Rosso Allerta** (#ec4c41): il colore dell'azione e dello stato attivo. Pulsanti primari, FAB, selezione corrente, icone d'azione, indicatori di stato che richiedono attenzione. Riservato: la sua presenza segnala "qui si agisce".
+### Accento e ancora
+- **Corallo** (`#ec4c41`) — `primary`: il colore dell'azione. Pulsanti primari, selezione, progresso, FAB. La sua rarità è ciò che lo rende un segnale.
+- **Blu Profondo** (`#002339`) — `primary-dark`: ancora della struttura. Testo su superfici informative, icone metrica, valori in evidenza, bottone di accesso.
-### Secondary
-- **Blu Profondo** (#002339): base stabile e di struttura. Testo forte, intestazioni, elementi strutturali, superfici scure in dark mode. È l'ancora visiva contro cui l'accento risalta.
+### Superfici — tre gradini netti
+- `workspace` (`#e9edf2`): sfondo dell'applicazione, più freddo delle superfici di lavoro.
+- `surface` (`#ffffff`): card, pannelli, testate, barra di navigazione, dialog.
+- `surface-muted` (`#f4f6f9`): superficie secondaria dentro una superficie (campo di ricerca, chip a riposo, riquadri interni).
+- `divider` (`#d9e0e8`): bordi 1px e contorni dei controlli a riposo.
-### Tertiary
-- **Azzurro Tenue** (#dff2ff): tocco freddo e leggero per evidenziazioni informative e superfici secondarie, dove serve distinguere senza gridare.
+### Testo
+- `ink` (`#000000`): corpo del testo. **La Regola del Contrasto da Sole**: il grigio chiaro "per eleganza" è vietato sul testo leggibile.
+- `ink-muted` (`#465563`): testo secondario, metadati, icone non attive. 8.3:1 su `surface`, 7.4:1 su `workspace`. Il testo secondario si distingue per **colore**, mai per opacità o peso sotto 400.
-### Neutral
-- **Inchiostro** (#000000): corpo del testo su fondo chiaro; massimo contrasto per la lettura al sole.
-- **Carta** (#ffffff): superficie di contenuto primaria (pannelli, card, dialog).
-- **Superficie Scura** (#000406): sfondo della dark mode (attualmente predisposta ma disattivata).
-- **Grigio Campo** (~#f5f5f5): fondo delle input-card e delle superfici tonali secondarie; distingue per tono, non per bordo.
+### Toni semantici — la regola delle triplette
+Ogni tono è una tripletta *(ink, surface, solid)*:
+- `-ink` colora testo e icone **sopra la superficie tenue** (sempre ≥4.5:1 su quella superficie);
+- `-surface` riempie badge, pannelli di feedback e cerchi-icona dei dialog;
+- `-solid` è il riempimento pieno dei toast, con testo bianco sopra.
-### Semantic
-- **Verde OK** (#26b050): validazione positiva, esito riuscito.
-- **Rosso Errore** (#e50000): validazione fallita, messaggi d'errore, connessione/servizio KO.
-- **Blu Focus** (#258cfb): anello di focus per la navigazione da tastiera.
+I colori "puri" della palette storica (`#e50000`, `#26b050`) vivono come `-solid`: sono giusti per riempimenti e icone, non abbastanza contrastati per il testo. Per questo esistono `danger-ink` e `success-ink`.
+
+| Tono | Uso |
+|---|---|
+| danger | errori, azioni distruttive, schede da inviare |
+| warning | attenzione, dati incompleti, bozze da completare |
+| success | conferme, completamenti, sincronizzato |
+| info | stato informativo, pannelli di riepilogo, selezione |
+| neutral | stati senza carica semantica |
### Named Rules
-**La Regola dell'Unico Accento.** L'Arancio-Rosso Allerta è l'unico accento del sistema e appare solo su azione o stato attivo, mai come decorazione. La sua rarità è ciò che lo rende un segnale.
+**La Regola dell'Unico Accento.** Il corallo appare solo su azione o stato attivo, mai come decorazione.
-**La Regola del Contrasto da Sole.** Il testo di corpo è inchiostro (#000) su carta (#fff). Il grigio chiaro "per eleganza" è vietato sul testo leggibile: si progetta per lo schermo colpito dal sole, non per lo screenshot.
+**Mai il solo colore.** Ogni stato porta sempre icona **e** etichetta testuale. Le mappature stanno in `StatusBadgeHelper`: uno stato si presenta identico ovunque, e uno nuovo si aggiunge in un punto solo.
## 3. Typography
-**Display / Body / Label Font:** Nunito (variable 200–1000, con fallback `sans-serif`). Caricata da Google Fonts; usata su tutta l'interfaccia.
+**Famiglia unica: Inter** (variabile 100–900), **ospitata in locale** in `wwwroot/fonts`. Scala fissa, pensata per la scansione rapida, non per l'effetto editoriale. La gerarchia si fa con peso e dimensione.
-**Character:** un'unica famiglia sans humanist e morbida per tutto — titoli, etichette, pulsanti, corpo, dati. Gli angoli tondeggianti di Nunito si accordano con i raggi ampi dei componenti e rafforzano il tono solido ma accessibile del "taccuino da campo". Nessun accostamento display/body: la gerarchia si fa con peso e dimensione.
-
-### Hierarchy
-- **Page Title** (peso 800, ~1.5rem/x-large, line-height stretta): titolo di schermata; ancora l'utente al compito corrente.
-- **Title** (peso 700, ~1.1rem): intestazioni di sezione, titoli di dialog e message-box.
-- **Body** (peso 400, 0.875rem/14px, line-height 1.8): testo corrente e dati. Per la prosa mantenere 65–75ch; dati e UI compatta possono essere più densi.
-- **Label** (peso 700, 0.875rem): etichette dei pulsanti e dei controlli di form. Il grassetto dà presenza al tocco senza ricorrere al maiuscolo.
+Le cifre sono tabellari (`font-feature-settings: "tnum"`), così contatori e codici non "ballano" mentre si aggiornano.
### Named Rules
-**La Regola del Peso, non del Maiuscolo.** L'enfasi si ottiene con il peso (700/800) e la dimensione, non con il maiuscolo spaziato. Niente eyebrow maiuscoli tracciati sopra le sezioni.
+**La Regola del Peso, non del Maiuscolo.** L'enfasi si ottiene con il peso (600/700/800) e la dimensione. Niente maiuscoletto spaziato, niente eyebrow sopra le sezioni.
+
+**Il titolo è l'entità.** Nelle card di elenco il titolo è il punto vendita o il motivo, non il tipo di record. L'identificativo tecnico sta sopra, in 12px `ink-muted`.
+
+**Le righe di metadato** si compongono con separatore ` · `.
## 4. Elevation
-Sistema prevalentemente **piatto con ombre funzionali leggere**. La profondità è tonale (Carta su Grigio Campo) più un'ombra morbida e diffusa dove un elemento deve staccarsi davvero (card in rilievo, overlay). MudBlazor fornisce la scala di elevazione; sopra di essa il progetto definisce due ombre custom soffuse. Le superfici a riposo sono piatte; l'ombra è una risposta al ruolo (galleggiamento, overlay), non decorazione.
+**Zero.** Le superfici sono piatte: nessuna ombra, nessun `surface-tint`. La profondità è tonale (`surface` su `workspace`) più lo spazio.
-### Shadow Vocabulary
-- **Ombra Custom** (`box-shadow: 1px 2px 5px hsl(from var(--mud-palette-overlay-dark) h s 40%)`): stacco morbido per card ed elementi che devono galleggiare sul contenuto.
-- **Ombra Eccezione** (`box-shadow: 1px 2px 5px rgba(0,0,0,0.3)`): rilievo leggermente più marcato per box di errore/eccezione.
+Le uniche eccezioni: il **toast**, che deve leggersi sopra qualunque contenuto, e i livelli modali di sistema (scrim del bottom sheet e degli overlay). Popover e menu si staccano con un bordo 1px `divider`, non con un'ombra.
-### Named Rules
-**La Regola del Piatto-di-Default.** Le superfici sono piatte a riposo. L'ombra compare solo quando un elemento deve galleggiare (overlay, dialog, card sollevata) o rispondere a uno stato. Se sembra un'app del 2014, l'ombra è troppo scura e troppo stretta.
+La profondità di impilamento usa una **scala semantica** (`--z-sticky` → `--z-navbar` → `--z-connection` → `--z-overlay` → `--z-modal` → `--z-toast`), mai valori arbitrari.
## 5. Components
-Componenti **tattili e sicuri**: bersagli generosi e arrotondati, feedback fisico, presenza netta, pensati per il pollice in campo.
-
-### Buttons
-- **Shape:** angoli molto arrotondati (raggio 20px sul contenitore `container-button`; i pulsanti MudBlazor ereditano `--mud-default-borderradius: 20px`).
-- **Primary:** fondo Arancio-Rosso Allerta (#ec4c41), testo Carta (#fff), etichetta in peso 700; padding compatto verticale ma area di tocco ampia.
-- **Hover / Focus / Active:** effetto **ripple** al tocco (chiaro su fondo scuro, scuro su fondo chiaro); focus da tastiera con doppio anello (bianco + Blu Focus #258cfb). Nei contenitori `ripple-container` il ripple nativo Mud è disattivato in favore di quello custom.
-- **Settings buttons:** riga con icona in "pill" arrotondata (raggio 6px) tinta per ruolo — grigio (neutro), primary (azione), verde (successo), rosso (distruttivo) — su fondo tenue della stessa tinta.
-- **FAB (`custom-mudfab`):** azione primaria fissa in basso a destra (bottom 4rem, right 16px), a portata di pollice; rispetta la safe-area inferiore.
-
-### Cards / Containers
-- **Corner Style:** pannelli e dialog a 1em; contenitori d'azione a 20px; input-card a 9px.
-- **Background:** Carta (#fff) per il contenuto; Grigio Campo (~#f5f5f5) per input-card e superfici tonali secondarie.
-- **Shadow Strategy:** piatte di default; Ombra Custom solo quando devono galleggiare (vedi Elevation).
-- **Border:** preferire lo stacco tonale al bordo; quando serve, bordo pieno sottile (`--card-border-color`). Mai bordo-laterale colorato come accento.
-- **Internal Padding:** input-card `.5rem 1rem`; pannelli ~1rem; margine orizzontale di pagina `--m-page-x: 1rem`.
-
-### Inputs / Fields
-- **Style:** input dentro `input-card` su fondo Grigio Campo, raggio 9px; underline Mud rimossa (`:before/:after` a `none`) per un aspetto pulito a "scheda".
-- **Layout:** `form-container` a due colonne (etichetta in peso 700 a sinistra, valore a destra), altezza minima 35px per un tocco comodo.
-- **Valid / Error:** valido → outline verde 1px (#26b050); invalido → outline rosso 1px (#e50000) con `validation-message` in rosso.
-
-### Navigation
-- **NavMenu** in testa alla pagina; barra di stato connessione (`ConnectionState`) che scorre dall'alto: verde (SystemOk) o rosso (NetworkKo / ServicesIsDown), peso 700, testo bianco. Comunica lo stato di rete/servizio senza rubare spazio al contenuto.
+- **Panel** — il mattone di tutto: `surface`, raggio 20, padding 16, senza bordo. Varianti tonali per riepiloghi (`info`) e sotto-riquadri (`muted`).
+- **Card di elenco** — identificativo, titolo-entità, riga meta, badge, footer con conteggi, barra azioni interna che passa a colonna sotto i 350px.
+- **Badge** — pillola con icona 15px + etichetta 12px peso 700, costruita da funzione di mappatura.
+- **Testata** — alta 80px, titolo a sinistra, sfondo uguale alla pagina. Non una AppBar colorata col titolo centrato.
+- **Navigazione** — barra flottante in basso (raggio 24, margine 16, bordo 1px), icona outline a riposo e piena da selezionata, pastiglia `info-surface` sulla voce attiva. Da 720px diventa colonna laterale.
+- **Bottom sheet** — maniglia, testata, un'unica linea di struttura, corpo scorrevole. Sostituisce i menu flottanti.
+- **Barra azioni ancorata** — bordo superiore 1px, bottoni in riga, colonna sotto i 390px. Nei form il salvataggio sta qui, non in fondo allo scroll.
+- **Campi** — riempiti, raggio 16, bordo 1px a riposo e 2px in focus. Sottolineatura Material rimossa: i campi sono schede, non righe.
+- **Selezione multipla** — righe da 56px con casella 26px, non un menu a tendina: con i guanti si centra un bersaglio grande.
+- **Stato vuoto** — icona, titolo, messaggio che **suggerisce la mossa successiva**, azione facoltativa.
+- **Caricamento** — scheletri che hanno già la forma del contenuto, non spinner in mezzo alla pagina. L'overlay pieno è riservato alle operazioni bloccanti, e dice sempre cosa sta facendo.
### Signature: Barra di Stato Connessione
-Striscia sottile animata (`.Connection`) ancorata in cima che appare/scompare con transizione morbida (translateY) per segnalare rete assente o backend giù. È l'incarnazione visiva del principio "Offline è la verità": lo stato di connettività è sempre onesto e visibile.
+Striscia sottile ancorata in cima che scende con una transizione morbida quando la rete o il backend cambiano stato. È l'incarnazione visiva di "Offline è la verità".
-## 6. Do's and Don'ts
+## 6. Motion
+
+Curve esponenziali in uscita (`cubic-bezier(0.22, 1, 0.36, 1)`), durate 120/200/280ms. Il movimento **serve allo stato**: risposta al tocco (l'elemento cede sotto il dito), entrata di un articolo scansionato, salita di uno sheet, disegno della spunta di conferma. Niente animazioni di scena al caricamento.
+
+Nessuno stato dipende dall'hover: su un touch screen non esiste, e lascia elementi accesi dopo il tocco. Gli stili hover sono dietro `@media (hover: hover)`.
+
+`prefers-reduced-motion` ha un'alternativa per ogni animazione.
+
+## 7. Do's and Don'ts
### Do:
-- **Do** usare l'Arancio-Rosso Allerta (#ec4c41) solo per azione e stato attivo; mantenerlo raro (Regola dell'Unico Accento).
-- **Do** tenere il testo di corpo in inchiostro (#000) su carta (#fff); alto contrasto per la lettura al sole.
-- **Do** dimensionare i bersagli di tocco ≥44px e collocare le azioni primarie a portata di pollice (FAB in basso a destra), pensando all'uso a una mano/guanti.
-- **Do** rendere sempre esplicito lo stato offline/sync (barra di connessione, stati di scheda/ispezione).
-- **Do** dare feedback fisico immediato a ogni tocco e scansione (ripple, cambio di stato).
-- **Do** usare un'unica famiglia (Nunito) e fare gerarchia con peso e dimensione.
-- **Do** rispettare le safe-area iOS/Android e `prefers-reduced-motion`.
+- **Do** usare solo i token: un colore, un raggio o una spaziatura scritti a mano in una schermata sono un difetto da correggere.
+- **Do** mostrare sempre i conteggi ("12 schede", "3 da inviare"): il rilevatore deve sapere quanto ha davanti senza contare.
+- **Do** distinguere "non c'è nulla" da "i filtri non trovano nulla", e nel secondo caso offrire l'azzeramento.
+- **Do** dimensionare i bersagli ≥44px e tenere le azioni primarie a portata di pollice.
+- **Do** rendere esplicito lo stato offline/sync, sempre con icona più etichetta.
+- **Do** rispettare le safe-area e `prefers-reduced-motion`.
### Don't:
-- **Don't** costruire un **gestionale datato e denso**: niente tabelle fittissime, testo minuscolo, form infiniti, grigio ovunque.
-- **Don't** virare verso un'estetica **consumer social/giocosa**: niente colore ludico o decorazione fine a sé stessa.
-- **Don't** usare testo grigio chiaro sul corpo "per eleganza": è la causa numero uno di illeggibilità al sole.
-- **Don't** usare bordo-laterale colorato (`border-left/right` > 1px) come accento su card, liste o alert.
-- **Don't** usare testo con gradiente (`background-clip: text`), glassmorphism decorativo, griglie di card tutte identiche, o eyebrow maiuscoli tracciati su ogni sezione.
-- **Don't** introdurre animazioni di scena orchestrate al caricamento: l'app entra dritta nel compito; il movimento serve allo stato, non allo spettacolo.
-- **Don't** reinventare affordance standard (scrollbar strane, controlli di form non convenzionali) per "carattere".
+- **Don't** usare ombre per costruire gerarchia, né `surface-tint` sulle superfici.
+- **Don't** usare le firme Android di serie: card con ombra, FAB circolare al centro, AppBar colorata col titolo centrato, snackbar di sistema.
+- **Don't** usare bordo-laterale colorato (`border-left/right` > 1px) come accento.
+- **Don't** usare testo grigio chiaro sul corpo, testo con gradiente, glassmorphism decorativo, griglie di card tutte identiche, eyebrow maiuscoli.
+- **Don't** reinventare affordance standard per "carattere".
+- **Don't** far dipendere un'informazione dal solo colore.
diff --git a/STILE_UI.md b/STILE_UI.md
new file mode 100644
index 0000000..0abe20f
--- /dev/null
+++ b/STILE_UI.md
@@ -0,0 +1,304 @@
+# Specifiche grafiche — stile UI condiviso
+
+Documento di riferimento per **uniformare la grafica delle app Integry** allo stile adottato in Fixiy.
+
+Descrive *forma, ritmo, tipografia e composizione dei componenti*. **Non contiene una palette**: ogni app mantiene i propri colori e li mappa sui **ruoli semantici** elencati al §2. Lo stile è quello che rimane costante al variare della tinta: superfici piatte, raggi ampi, spaziature a scala fissa, gerarchia data dallo spazio prima che dalla decorazione.
+
+Riferimento d'implementazione: Flutter + Material 3 (`useMaterial3: true`). I nomi dei token qui sotto sono quelli reali del codice Fixiy (`lib/core/design_system.dart`, `lib/core/feedback.dart`); in un'altra app si rinominano col prefisso dell'app, ma **valori e semantica restano invariati**.
+
+---
+
+## 1. Principi
+
+1. **Superfici piatte.** Niente ombre per costruire gerarchia: `elevation: 0` ovunque (card, AppBar, NavigationBar, NavigationRail). L'unica ombra ammessa è quella del toast (elevation 6) e dei livelli modali di sistema. La separazione si ottiene con *contrasto di superficie* (sfondo pagina più freddo delle superfici) e con lo **spazio**.
+2. **Nessun tint automatico.** `surfaceTintColor: Colors.transparent` su AppBar, Card, Dialog, pannelli: le superfici non devono virare verso il colore primario quando si scrolla.
+3. **Colore = significato.** L'accento primario serve solo ad azioni, selezione, focus e progresso — mai a decorare. I colori semantici (urgenza, stato, esito) compaiono sempre accompagnati da **icona + etichetta testuale**, mai da soli.
+4. **Target generosi.** Ogni elemento interattivo ha lato minimo **44 px** (`minimumSize: Size(44, 44)`), 48 px d'altezza per i bottoni principali. L'app si usa in piedi, con i guanti, in negozio.
+5. **Densità standard.** `visualDensity: VisualDensity.standard`: si rifiuta la densità desktop, anche su schermo largo.
+6. **Lo spazio prima della linea.** I divisori si usano solo dove l'informazione cambia natura (testata di uno sheet, righe di una lista di navigazione); altrove separa la spaziatura.
+7. **Nulla di stock.** Si evitano le firme visive Android di default: card con ombra, FAB circolare al centro, AppBar colorata con titolo centrato, snackbar di sistema.
+8. **Scrivibile a voce alta.** Testi in lingua dell'app, brevi, orientati all'azione; gli enum passano da una mappa di etichette centralizzata (`labels.dart`), mai formattati inline.
+
+---
+
+## 2. Ruoli colore (da riempire con la palette dell'app)
+
+L'app ospite definisce i **propri** valori per questi ruoli e li usa con la stessa semantica. Nessun valore è fissato qui.
+
+| Ruolo | Uso |
+|---|---|
+| `primary` | accento unico di azione: bottone pieno, focus, selezione, indicatori di progresso, icone attive |
+| `primaryDark` | testo/icona sopra `primaryContainer`, valori numerici in evidenza |
+| `workspace` | sfondo dell'applicazione (`scaffoldBackgroundColor`), leggermente più freddo/scuro di `surface` |
+| `surface` | superficie di lavoro: card, pannelli, AppBar, barra di navigazione, dialog |
+| `surfaceMuted` | superficie secondaria dentro una superficie (campo di ricerca, chip non selezionato, riquadro informativo interno) |
+| `divider` | bordi 1 px, separatori, contorno dei controlli a riposo |
+| `ink` | testo principale |
+| `inkMuted` | testo secondario, hint, etichette di metadato, icone non attive |
+| `emergency` / `errorSurface` | tono **danger**: errori, azioni distruttive, priorità massima |
+| `high` / `warningSurface` | tono **warning**: attenzione, dati incompleti, priorità alta |
+| `success` / `successSurface` | tono **success**: conferme, completamenti |
+| `info` / `infoSurface` | tono **info**: stato informativo, pannello di riepilogo, indicatore di selezione |
+| `neutral` / `surfaceMuted` | tono **neutral**: stati senza carica semantica |
+
+**Regola delle coppie.** Ogni tono semantico è una coppia *(foreground forte, superficie tenue)*. Le superfici tenui riempiono badge, pannelli di feedback e cerchi-icona dei dialog; il foreground forte colora icona e testo sopra di esse. I **toast** invertono la regola: riempimento pieno col colore forte e testo bianco, per essere leggibili sopra qualunque contenuto.
+
+**Schema colore.** Si genera da un seme (`ColorScheme.fromSeed`) e poi si sovrascrivono esplicitamente `primary`, `onPrimary`, `primaryContainer` (= `infoSurface`), `onPrimaryContainer` (= `primaryDark`), `secondaryContainer` (= `surfaceMuted`), `outline` e `outlineVariant` (= `divider`). Non si lascia al seme la scelta dei contenitori: devono restare tenui e neutri.
+
+---
+
+## 3. Scale di token
+
+### Spaziatura (`Space`)
+
+```
+xs = 8 sm = 12 md = 16 lg = 24 xl = 32
+```
+
+Tutti i margini e le distanze sono multipli di 2 e, per quanto possibile, uno di questi cinque valori. Valori d'uso frequente derivati: 4 e 6 per le micro-distanze interne (titolo→sottotitolo, icona→testo), 10 tra le card di una lista, 18/20/22 per il padding dei pannelli di testata.
+
+### Raggi (`Radius`)
+
+```
+compact = 8 riquadri informativi, barre di progresso, feedback inline
+control = 16 campi di testo, bottoni, toast
+surface = 20 card, pannelli, dialog, sheet
+pill = 999 badge, chip, segmented button, campo di ricerca, bottoni onboarding
+```
+
+Il contrasto tra i raggi è voluto: **superfici più arrotondate dei controlli**, elementi di stato completamente stondati (pill).
+
+### Breakpoint
+
+```
+rail = 720 da qui la navigazione passa da barra inferiore a NavigationRail laterale
+twoColumn = 900 soglia per layout a due colonne
+contentMax = 1180 larghezza massima assoluta del contenuto
+```
+
+Larghezza tipica di una pagina: `maxWidth: 920`, centrata in alto (`PageWidth` = `Align(topCenter) + ConstrainedBox`). Su schermi larghi il contenuto **non si allarga all'infinito**: resta una colonna leggibile.
+
+---
+
+## 4. Tipografia
+
+Famiglia principale **Inter**, fallback `['SF Pro Display', 'Roboto', 'sans-serif']`. Scala fissa, pensata per la scansione rapida, non per l'effetto editoriale:
+
+| Stile | Dim. | Interlinea | Peso | Uso |
+|---|---|---|---|---|
+| `headlineMedium` | 34 | 1.2 | 700 | titoli di apertura |
+| `headlineSmall` | 28 | 1.25 | 700 | titolo dell'AppBar, valore percentuale in evidenza |
+| `titleLarge` | 22 | 1.3 | 700 | titolo di card e di sheet, testata di sezione |
+| `titleMedium` | 18 | 1.35 | 700 | sotto-titoli, valori metrica, titolo dialog |
+| `bodyLarge` | 16 | 1.5 | 400 | testo corrente, descrizioni |
+| `bodyMedium` | 15 | 1.45 | 400 | metadati, righe secondarie |
+| `labelLarge` | 14 | 1.25 | 600 | etichette dei bottoni |
+| `labelMedium` | 13 | 1.25 | 600 | etichette di navigazione, caption |
+
+Regole d'uso:
+
+- Il **titolo di una card è l'entità**, non il tipo di record (es. il negozio, non "Manutenzione"): `titleLarge` con peso 800 e interlinea compressa 1.15.
+- Gli **identificativi tecnici** (numero ticket, codice) vanno sopra il titolo, 12 px, peso 700, colore `inkMuted`, `letterSpacing: 0.3`.
+- Le **righe di metadato** si compongono con separatore ` · ` (spazio–punto medio–spazio) e colore `inkMuted`.
+- Il testo secondario non usa mai un peso inferiore a 400 né opacità: si distingue per **colore** (`inkMuted`), non per trasparenza.
+- Nessun maiuscoletto forzato, nessun `letterSpacing` ampio: le etichette restano in caso naturale.
+
+---
+
+## 5. Tema — impostazioni globali
+
+| Elemento | Configurazione |
+|---|---|
+| **Scaffold** | sfondo `workspace`; `extendBody: true` quando la barra di navigazione è flottante |
+| **AppBar** | `toolbarHeight: 80`, titolo a sinistra (`centerTitle: false`), `titleSpacing` 20 (28 su schermo largo), sfondo `workspace` (si fonde con la pagina), elevation 0, `scrolledUnderElevation: 0` |
+| **Card / pannelli** | elevation 0, `margin: EdgeInsets.zero`, sfondo `surface`, raggio `surface`, senza bordo |
+| **Campi di testo** | `filled: true` su `surface`, padding 16×16, raggio `control`; bordo a riposo 1 px `divider`, in focus 2 px `primary`, in errore 1 px `emergency`; hint e helper in `inkMuted` |
+| **Bottone pieno** | `minimumSize: (44, 48)`, sfondo `primary`, testo bianco `labelLarge`, raggio `control` |
+| **Bottone contornato** | `minimumSize: (44, 48)`, testo `ink`, bordo `divider`, raggio `control` |
+| **Bottone testuale / icona** | `minimumSize: (44, 44)` |
+| **Segmented button** | `StadiumBorder`, min `(52, 44)`; selezionato = riempimento `primary` + testo bianco; non selezionato = `surface` + testo `inkMuted`; bordo `divider` |
+| **NavigationBar** | altezza 68, elevation 0, sfondo `surface`, indicatore `infoSurface`, etichette sempre visibili (`alwaysShow`), stile `labelMedium` |
+| **NavigationRail** | sfondo `surface`, indicatore `primaryContainer`, etichette su tutte le voci (`labelType: all`), etichetta selezionata in `primary` |
+| **Snackbar** | comportamento `floating` (ma nell'app si usa il toast custom, §6.9) |
+| **Progress** | colore `primary` |
+
+**Localizzazione.** Locale unico dichiarato esplicitamente con i tre delegati globali (Material, Widgets, Cupertino), così date, picker e messaggi di sistema parlano la lingua dell'app.
+
+---
+
+## 6. Componenti
+
+### 6.1 `PageWidth` — contenitore di pagina
+
+`Align(topCenter)` + `ConstrainedBox(maxWidth: 920)`. Avvolge **ogni** contenuto di pagina. Su telefono è trasparente; su tablet/desktop impedisce righe di testo lunghissime e mantiene la stessa composizione a tutte le larghezze.
+
+### 6.2 `Panel` — superficie base
+
+`Material` con `color: surface`, `surfaceTintColor: transparent`, `clipBehavior: antiAlias`, forma arrotondata `surface` (20), padding di default 16.
+
+È il mattone di tutto: card di lista, riquadri di riepilogo, righe raggruppate, campi compositi. Varianti d'uso:
+
+- **con colore**: `color: infoSurface` per i pannelli di riepilogo/testata; `color: surfaceMuted` per i sotto-riquadri dentro un form; `color: errorSurface` per una card in stato critico.
+- **`padding: EdgeInsets.zero` + `InkWell` interno** quando l'intera superficie è toccabile (raggio dell'ink coerente con quello del pannello).
+
+### 6.3 `SectionHeader` — testata di sezione
+
+Riga con, a sinistra, `titleLarge` + eventuale descrizione (`inkMuted`, 4 px sotto), e a destra un widget opzionale (bottone, badge, contatore) separato da 12 px. Apre ogni blocco tematico dentro una pagina lunga.
+
+### 6.4 `Badge` — stato in pillola
+
+`DecoratedBox` con raggio 999, superficie tenue del tono, padding 9×5, contenuto = icona 15 px + 5 px + testo 12 px peso 700, entrambi nel foreground del tono. Avvolto in `Semantics(label:)`.
+
+I badge **si costruiscono da funzioni di mappatura** (`priorityBadge(priority)`, `statusBadge(status)`), non a mano nelle pagine: così ogni schermata mostra lo stesso stato con la stessa icona e lo stesso tono. Ogni nuovo stato si aggiunge a quella funzione.
+
+### 6.5 Card di elenco
+
+Composizione canonica, dall'alto:
+
+1. **Barra di priorità** verticale a tutta altezza sul bordo sinistro, larghezza **6 px**, colore dal tono di urgenza (`Stack` + `Positioned(top/bottom/left: 0)`). La card usa padding asimmetrico `(22, 16, 16, 16)` per far respirare la barra.
+2. **Identificativo** (12 px, `inkMuted`, 700) con, a destra, l'eventuale maniglia di trascinamento.
+3. **Titolo** = l'entità, `titleLarge` peso 800.
+4. **Riga meta** con separatori ` · `, `bodyMedium` peso 600 `inkMuted`.
+5. **Badge** (priorità, stato) in un `Wrap(spacing: 8, runSpacing: 8)`.
+6. **Descrizione** integrale, `bodyLarge` interlinea 1.35 — niente troncamenti sul contenuto che serve a decidere.
+7. **Riga oraria/assegnatario** preceduta da icona 16 px `inkMuted`.
+8. **Barra azioni interna**: secondaria contornata + primaria piena, affiancate con `Expanded`; sotto i **350 px** di larghezza della card diventano una colonna a piena larghezza (`LayoutBuilder`).
+
+La card intera espone un `Semantics(label:)` che riassume entità, priorità e stato.
+
+### 6.6 Pannello di riepilogo
+
+Primo elemento della pagina, `Panel` con superficie tenue (`primaryContainer`/`infoSurface`), padding 18:
+
+- riga di testata: titolo `titleLarge` + data/sottotitolo `inkMuted`, e a destra un **badge di stato** del documento ("Da salvare" / "Salvato" / "Concluso");
+- eventuale fila di **chip** di scelta (`Wrap(spacing: 8, runSpacing: 8)`);
+- fila di **metriche** in `Expanded` uguali, ciascuna: icona 20 px in `primaryDark` → valore `titleMedium` → etichetta 12 px `inkMuted` su una riga sola con ellissi;
+- eventuale riga di stato in coda (`labelMedium` `inkMuted`), es. l'ora dell'ultimo salvataggio.
+
+Quando c'è un progresso, si aggiunge una `LinearProgressIndicator` con `minHeight: 8` e `borderRadius: 8`, preceduta dal conteggio in chiaro ("3 di 7 completate") e dalla percentuale in `headlineSmall`.
+
+### 6.7 Chip e filtri
+
+- **Filtri**: `ChoiceChip` `StadiumBorder`, `showCheckmark: false`, **l'etichetta include il conteggio** (`Tutte · 12`). A riposo: superficie `surfaceMuted`, bordo `divider`, testo `inkMuted` peso 600. Selezionato: superficie `infoSurface`, bordo `primary`, testo `primaryDark`. Disposti in una riga orizzontale scorrevole alta **44 px**, spaziatura 8.
+- **Chip-entità** (selezione di un oggetto): `Material` + `InkWell` con `StadiumBorder`, padding 12×8, icona 16 px + etichetta peso 600 + dato secondario 12 px `inkMuted`. Selezionato = superficie `surface` e bordo `primaryDark`; non selezionato = `surfaceMuted` e bordo `divider`.
+
+### 6.8 Campo di ricerca
+
+Non usa il tema dei campi: è **completamente stondato** (raggio 999), riempito in `surfaceMuted`, **senza bordo** a riposo e in enabled, bordo 1.5 px `primary` solo in focus. Padding 18×14, icona di ricerca `inkMuted` come prefisso, pulsante "cancella" come suffisso solo quando c'è testo.
+
+### 6.9 Toast
+
+Sostituisce ogni snackbar di sistema. `SnackBar` `floating` con sfondo trasparente, elevation 0, padding zero e margine `(md, 0, md, md)`; il contenuto è un `Material` con **riempimento pieno del tono**, raggio `control`, elevation 6, ombra nera al 24 %, padding 14×12, e dentro: icona bianca 22 px → 12 px → testo bianco peso 600 interlinea 1.35 → eventuale azione testuale bianca peso 700.
+
+Prima di mostrarlo si chiama `hideCurrentSnackBar()`: mai una coda di toast. Durata 4 s. Si espone come scorciatoie per tono (`success`/`error`/`warning`/`info`) più una funzione generica.
+
+### 6.10 Dialog
+
+`Dialog` su `surface`, raggio `surface`, `insetPadding` 24, `maxWidth: 420`, padding interno 24. Composizione verticale allineata a sinistra:
+
+1. **cerchio-icona** 48×48 riempito con la superficie tenue del tono, icona del tono 26 px;
+2. 16 px → **titolo** `titleMedium`;
+3. 8 px → **messaggio** `inkMuted` interlinea 1.45;
+4. 24 px → **azioni**: due bottoni in `Expanded` (annulla contornato a sinistra, conferma pieno a destra, 12 px di distanza); se non c'è annullamento, un solo bottone a piena larghezza.
+
+Le azioni distruttive colorano di `emergency` il bottone di conferma. Due sole funzioni di ingresso: conferma (ritorna booleano) e informativo (un solo bottone).
+
+### 6.11 Feedback inline
+
+Riquadro non modale per messaggi persistenti dentro la pagina: superficie tenue del tono, raggio `compact` (8), padding 12, icona 20 px del tono + 10 px + testo peso 600 del tono, allineati in alto. Marcato `Semantics(liveRegion: true)`. Si usa per errori di caricamento e avvisi che devono restare visibili, a differenza del toast che è effimero.
+
+### 6.12 Stato vuoto
+
+Centrato, padding 28, larghezza massima 420: icona 44 px `inkMuted` → 14 px → titolo `titleMedium` centrato → 6 px → messaggio `inkMuted` centrato → 18 px → azione facoltativa (bottone contornato). Il messaggio **suggerisce la mossa successiva**; se lo stato vuoto dipende da filtri, l'azione li azzera.
+
+### 6.13 Barra azioni fissa
+
+Ancorata in fondo alla pagina: `SafeArea(top: false)`, superficie `surface`, **bordo superiore 1 px** `divider`, padding `(16, 12, 16, 16)`. I bottoni sono in riga con `Expanded` e 12 px di distanza; sotto i **390 px** diventano una colonna a piena larghezza con 8 px di distanza.
+
+### 6.14 Bottom sheet
+
+Sempre `isScrollControlled: true`, `useSafeArea: true`, `showDragHandle: true`. Altezza **fissata a una frazione dello schermo**: ~0.78 per uno sheet di consultazione, ~0.92 per uno sheet di compilazione.
+
+Struttura:
+
+1. **testata**: padding `(20, 0, 8, 12)`, titolo `titleLarge` + sottotitolo `inkMuted` a sinistra, `IconButton` di chiusura a destra;
+2. eventuale **segmented button** a piena larghezza (`expandedInsets: EdgeInsets.zero`) quando lo sheet ha due modalità;
+3. `Divider(height: 1)` — l'unica linea di struttura;
+4. **corpo** in `Expanded`, scrollabile, padding 16. Con più modalità si usa `IndexedStack`, che preserva lo stato dei form.
+
+### 6.15 Navigazione
+
+- **Telefono**: `NavigationBar` **flottante** — avvolta in `SafeArea(minimum: (16, 0, 16, 12))` e in un `ClipRRect(borderRadius: 24)`, con `extendBody: true` sullo Scaffold, così il contenuto scorre sotto. Le pagine lasciano ~110–120 px di padding inferiore per non finirci sotto.
+- **Da 720 px**: `NavigationRail` a sinistra con etichette su tutte le voci, seguito da `VerticalDivider(width: 1)`.
+- Le destinazioni usano **icona outline a riposo e piena da selezionata**, con la stessa etichetta in entrambe le modalità.
+- Le sezioni si tengono vive in un `IndexedStack`: cambiare tab non ricostruisce né perde lo scroll.
+- Le **notifiche** stanno nelle `actions` dell'AppBar: `IconButton` con icona outline dentro un `Badge` numerico visibile solo se ci sono elementi non letti, e 12 px di margine destro.
+
+### 6.16 Liste di navigazione (profilo, impostazioni)
+
+Un `Panel` che contiene `ListTile` con `contentPadding: EdgeInsets.zero`, icona a sinistra, `Icon(chevron_right)` come trailing per le voci che aprono una pagina, e `Divider()` tra una voce e l'altra. Le voci distruttive (uscita, eliminazione) portano l'icona in `emergency` e non hanno chevron.
+
+L'intestazione del profilo è un `Panel` con padding 22: `CircleAvatar` raggio 30 su superficie tenue con l'iniziale in 22 px peso 800, 16 px, poi nome `titleLarge` e riga secondaria `inkMuted`.
+
+### 6.17 Campo composito (tappabile)
+
+Per valori che si scelgono da un picker invece che digitare: `Panel` `surfaceMuted` con padding zero, `InkWell` interno, padding 14, dentro un'etichetta `labelMedium`, 5 px, e una riga icona 19 px + 7 px + valore con ellissi. Si comporta come un campo ma apre un selettore.
+
+---
+
+## 7. Tema di onboarding (login e schermate pre-accesso)
+
+Le schermate fuori dall'applicazione autenticata usano una **variante derivata** dello stesso tema (`base.copyWith(...)`), non un tema separato: cambia solo il registro, non la struttura.
+
+- Sfondo pagina uniforme e neutro; primario = colore d'inchiostro (accento cromatico assente: l'attenzione è sul marchio).
+- Campi con riempimento bianco e bordo neutro; in focus bordo 2 px del colore d'inchiostro.
+- Bottone principale **a pillola** (`StadiumBorder`), altezza minima 52.
+- Composizione: `SafeArea` → `Center` → `SingleChildScrollView(padding: 24)` → `ConstrainedBox(maxWidth: 440)` → colonna `stretch`.
+- Ordine: logo (altezza 128, dentro `Semantics(image: true, label:)`) → 32 px → campi a 16 px di distanza → 24 px → eventuale messaggio d'errore centrato in `colorScheme.error` e `liveRegion` → 12 px → bottone di invio → 20 px → footer con versione.
+- Il bottone di invio mostra il caricamento **al posto dell'icona** (`SizedBox.square(dimension: 18)` con `CircularProgressIndicator(strokeWidth: 2)`), restando della stessa dimensione; durante il caricamento `onPressed: null`.
+- I campi dichiarano `autofillHints` dentro un `AutofillGroup` e incatenano `textInputAction: next`, con invio sull'ultimo.
+
+---
+
+## 8. Comportamenti trasversali
+
+**Caricamento.** Al primo caricamento senza dati: `CircularProgressIndicator` centrato in un'area di almeno 48 px di padding verticale. Con dati già presenti non si sostituisce mai il contenuto con uno spinner: si aggiorna in silenzio. Ogni elenco è avvolto in un `RefreshIndicator` (pull-to-refresh).
+
+**Errore di caricamento.** Non svuota la pagina: compare un **feedback inline** (§6.11) in tono danger sopra il contenuto, con l'istruzione per riprovare ("Trascina verso il basso per riprovare"). Gli errori di rete non bloccano la navigazione.
+
+**Vuoto vs. filtrato.** Si distinguono: "non c'è nulla" e "i filtri non trovano nulla" hanno testi diversi, e il secondo offre l'azzeramento dei filtri.
+
+**Riordino.** Nelle liste riordinabili solo gli elementi *ammissibili* mostrano la maniglia (`ReorderableDragStartListener` con `Icon(drag_handle)`); il riordino è disabilitato quando è attivo un filtro o una ricerca, e un suggerimento testuale compare accanto al conteggio solo sopra i 520 px di larghezza.
+
+**Conteggi sempre visibili.** Titoli di sezione, filtri e badge portano il numero ("12 attività", "Allegati (3)", "Tutte · 12"): il tecnico deve sapere quanto ha davanti senza contare.
+
+**Adattamento locale.** L'adattamento non è solo a livello di pagina: i **singoli componenti** misurano il proprio spazio con `LayoutBuilder` e passano da riga a colonna sotto le soglie indicate (350 px per le azioni di una card, 390 px per la barra azioni). Così lo stesso componente funziona in colonna singola e affiancato.
+
+**Accessibilità.**
+- `Semantics(label:)` su card, badge e maniglie di riordino, con una descrizione che riassume l'entità e i suoi stati.
+- `Semantics(liveRegion: true)` su toast, feedback inline e messaggi d'errore dei form.
+- `tooltip` su ogni `IconButton` senza etichetta.
+- Nessuna informazione veicolata dal solo colore: sempre icona e/o testo.
+- Target ≥ 44 px anche per le icone in testata.
+
+**Testi e stati.** Le date estese si scrivono per intero nella lingua dell'app; gli orari in `HH:mm` con zero iniziale, e i valori mancanti come `--:--`; i dati incompleti si dichiarano ("km finali mancanti", "Partenza da compilare") invece di lasciare un vuoto.
+
+---
+
+## 9. Come adottare lo stile in un'altra app
+
+1. **Copiare la struttura dei token**: quattro classi statiche `Colors`, `Space`, `Radius`, `Breakpoints` con i valori dei §3 (spazi, raggi e breakpoint **identici**) e i propri colori sui ruoli del §2.
+2. **Ricostruire il tema** con le impostazioni del §5: sono la parte che rende immediatamente riconoscibile lo stile (AppBar alta e fusa con la pagina, card piatte a raggio 20, controlli a raggio 16, target 44/48).
+3. **Portare i componenti condivisi** del §6 — `PageWidth`, `Panel`, `SectionHeader`, `Badge`, `Feedback`, `EmptyState`, `ActionBar`, toast e dialog — rinominandoli col prefisso dell'app. Sono ~600 righe senza dipendenze oltre a Material.
+4. **Definire le mappature di stato** dell'app (`priorityBadge`, `statusBadge`, o gli equivalenti del dominio) in un unico punto, accanto ai componenti.
+5. **Vietare i valori ad-hoc**: nelle pagine si usano solo i token. Un colore o un raggio scritto a mano in una pagina è un difetto da correggere, non una scorciatoia.
+6. **Verificare la lista di controllo**:
+ - [ ] nessuna ombra sulle superfici (elevation 0) e nessun `surfaceTint`;
+ - [ ] sfondo pagina diverso dalle superfici;
+ - [ ] ogni tocco ≥ 44 px;
+ - [ ] ogni stato = icona + etichetta + tono;
+ - [ ] contenuto limitato a ~920 px e centrato;
+ - [ ] barra di navigazione flottante sotto i 720 px, rail sopra;
+ - [ ] toast custom al posto delle snackbar di sistema;
+ - [ ] stato vuoto, stato di caricamento e stato d'errore previsti per ogni elenco;
+ - [ ] componenti che si riorganizzano da soli sotto le soglie di larghezza.
diff --git a/SteUp.Data/Migrations/20260916111422_AddBozzaScheda.Designer.cs b/SteUp.Data/Migrations/20260916111422_AddBozzaScheda.Designer.cs
new file mode 100644
index 0000000..3b4ea07
--- /dev/null
+++ b/SteUp.Data/Migrations/20260916111422_AddBozzaScheda.Designer.cs
@@ -0,0 +1,157 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using SteUp.Data.LocalDb;
+
+#nullable disable
+
+namespace SteUp.Data.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260916111422_AddBozzaScheda")]
+ partial class AddBozzaScheda
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
+
+ modelBuilder.Entity("SteUp.Shared.Core.Entities.Ispezione", b =>
+ {
+ b.Property("CodMdep")
+ .HasColumnType("TEXT");
+
+ b.Property("Data")
+ .HasColumnType("TEXT");
+
+ b.Property("Rilevatore")
+ .HasColumnType("TEXT");
+
+ b.Property("ActivityId")
+ .HasColumnType("TEXT");
+
+ b.Property("Stato")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("CodMdep", "Data", "Rilevatore");
+
+ b.ToTable("Ispezioni");
+ });
+
+ modelBuilder.Entity("SteUp.Shared.Core.Entities.Scheda", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ActivityId")
+ .HasColumnType("TEXT");
+
+ b.Property("ActivityTypeId")
+ .HasColumnType("TEXT");
+
+ b.Property("Bozza")
+ .HasColumnType("INTEGER");
+
+ b.Property("CodJfas")
+ .HasColumnType("TEXT");
+
+ b.Property("CodMdep")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Data")
+ .HasColumnType("TEXT");
+
+ b.Property("DescrizioneReparto")
+ .HasColumnType("TEXT");
+
+ b.Property("ImageNames")
+ .HasColumnType("TEXT");
+
+ b.Property("Note")
+ .HasColumnType("TEXT");
+
+ b.Property("Responsabile")
+ .HasColumnType("TEXT");
+
+ b.Property("Rilevatore")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Scadenza")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CodMdep", "Data", "Rilevatore");
+
+ b.ToTable("Schede");
+ });
+
+ modelBuilder.Entity("SteUp.Shared.Core.Entities.SchedaArticolo", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Barcode")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("Descrizione")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("SchedaId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SchedaId", "Barcode")
+ .IsUnique();
+
+ b.ToTable("SchedaArticoli");
+ });
+
+ modelBuilder.Entity("SteUp.Shared.Core.Entities.Scheda", b =>
+ {
+ b.HasOne("SteUp.Shared.Core.Entities.Ispezione", "Ispezione")
+ .WithMany("Schede")
+ .HasForeignKey("CodMdep", "Data", "Rilevatore")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Ispezione");
+ });
+
+ modelBuilder.Entity("SteUp.Shared.Core.Entities.SchedaArticolo", b =>
+ {
+ b.HasOne("SteUp.Shared.Core.Entities.Scheda", "Scheda")
+ .WithMany("Articoli")
+ .HasForeignKey("SchedaId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Scheda");
+ });
+
+ modelBuilder.Entity("SteUp.Shared.Core.Entities.Ispezione", b =>
+ {
+ b.Navigation("Schede");
+ });
+
+ modelBuilder.Entity("SteUp.Shared.Core.Entities.Scheda", b =>
+ {
+ b.Navigation("Articoli");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/SteUp.Data/Migrations/20260916111422_AddBozzaScheda.cs b/SteUp.Data/Migrations/20260916111422_AddBozzaScheda.cs
new file mode 100644
index 0000000..5fda41e
--- /dev/null
+++ b/SteUp.Data/Migrations/20260916111422_AddBozzaScheda.cs
@@ -0,0 +1,29 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace SteUp.Data.Migrations
+{
+ ///
+ public partial class AddBozzaScheda : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "Bozza",
+ table: "Schede",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: false);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "Bozza",
+ table: "Schede");
+ }
+ }
+}
diff --git a/SteUp.Data/Migrations/AppDbContextModelSnapshot.cs b/SteUp.Data/Migrations/AppDbContextModelSnapshot.cs
index 21aa4ae..f268281 100644
--- a/SteUp.Data/Migrations/AppDbContextModelSnapshot.cs
+++ b/SteUp.Data/Migrations/AppDbContextModelSnapshot.cs
@@ -15,7 +15,7 @@ namespace SteUp.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
- modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.Entity("SteUp.Shared.Core.Entities.Ispezione", b =>
{
@@ -51,6 +51,9 @@ namespace SteUp.Data.Migrations
b.Property("ActivityTypeId")
.HasColumnType("TEXT");
+ b.Property("Bozza")
+ .HasColumnType("INTEGER");
+
b.Property("CodJfas")
.HasColumnType("TEXT");
diff --git a/SteUp.Maui/Core/CoreModule.cs b/SteUp.Maui/Core/CoreModule.cs
index 139ee4d..8383627 100644
--- a/SteUp.Maui/Core/CoreModule.cs
+++ b/SteUp.Maui/Core/CoreModule.cs
@@ -17,9 +17,8 @@ using SteUp.Shared.Core.Interface.IntegryApi;
using SteUp.Shared.Core.Interface.LocalDb;
using SteUp.Shared.Core.Interface.System;
using SteUp.Shared.Core.Interface.System.Network;
-using SteUp.Shared.Core.Messages.Ispezione;
using SteUp.Shared.Core.Messages.Scanner;
-using SteUp.Shared.Core.Messages.Scheda;
+using SteUp.Shared.Core.Messages.System;
using SteUp.Shared.Core.Services;
namespace SteUp.Maui.Core;
@@ -62,9 +61,9 @@ public static class CoreModule
public void RegisterMessageServices()
{
builder.Services.AddSingleton();
- builder.Services.AddSingleton();
builder.Services.AddSingleton();
- builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
}
public void RegisterDbServices()
diff --git a/SteUp.Maui/Core/Services/ImageOrientation.cs b/SteUp.Maui/Core/Services/ImageOrientation.cs
new file mode 100644
index 0000000..5f816ab
--- /dev/null
+++ b/SteUp.Maui/Core/Services/ImageOrientation.cs
@@ -0,0 +1,106 @@
+using SkiaSharp;
+
+namespace SteUp.Maui.Core.Services;
+
+///
+/// Raddrizza le immagini applicando l'orientamento EXIF ai pixel.
+/// La fotocamera di sistema non ruota lo scatto: salva i pixel come li ha letti il
+/// sensore e annota la rotazione in EXIF. SkiaSharp ()
+/// ignora quel tag, quindi senza questo passaggio le foto scattate ruotate finiscono
+/// storte nelle miniature e sul server.
+///
+public static class ImageOrientation
+{
+ ///
+ /// Restituisce i byte dell'immagine con i pixel già nel verso giusto e senza tag
+ /// di orientamento residui. In caso di formato non gestito o errore restituisce
+ /// l'originale: meglio una foto storta che perdere lo scatto del rilevatore.
+ ///
+ public static byte[] Normalize(byte[] bytes, int quality = 92)
+ {
+ try
+ {
+ using var codec = SKCodec.Create(new SKMemoryStream(bytes));
+ if (codec is null) return bytes;
+
+ var origin = codec.EncodedOrigin;
+
+ // Già dritta: non ricodifichiamo, così non si perde qualità inutilmente.
+ if (origin == SKEncodedOrigin.TopLeft) return bytes;
+
+ using var decoded = SKBitmap.Decode(codec);
+ if (decoded is null) return bytes;
+
+ using var upright = ApplyOrigin(decoded, origin);
+ using var image = SKImage.FromBitmap(upright);
+
+ // Manteniamo il formato di partenza: il nome file conserva la sua estensione.
+ var format = codec.EncodedFormat == SKEncodedImageFormat.Png
+ ? SKEncodedImageFormat.Png
+ : SKEncodedImageFormat.Jpeg;
+
+ using var encoded = image.Encode(format, quality);
+ return encoded?.ToArray() ?? bytes;
+ }
+ catch
+ {
+ return bytes;
+ }
+ }
+
+ ///
+ /// Versione dritta di una bitmap già decodificata. Restituisce sempre una nuova
+ /// bitmap, anche quando non serve ruotare, così chi chiama ne possiede una sola.
+ ///
+ public static SKBitmap Upright(SKBitmap src, SKEncodedOrigin origin) =>
+ origin == SKEncodedOrigin.TopLeft ? src.Copy() : ApplyOrigin(src, origin);
+
+ private static SKBitmap ApplyOrigin(SKBitmap src, SKEncodedOrigin origin)
+ {
+ // Gli orientamenti "di lato" scambiano larghezza e altezza.
+ var sideways = origin is SKEncodedOrigin.LeftTop or SKEncodedOrigin.RightTop
+ or SKEncodedOrigin.RightBottom or SKEncodedOrigin.LeftBottom;
+
+ var w = sideways ? src.Height : src.Width;
+ var h = sideways ? src.Width : src.Height;
+
+ var dst = new SKBitmap(w, h, src.ColorType, src.AlphaType);
+ using var canvas = new SKCanvas(dst);
+
+ switch (origin)
+ {
+ case SKEncodedOrigin.TopRight: // specchiata in orizzontale
+ canvas.Translate(w, 0);
+ canvas.Scale(-1, 1);
+ break;
+ case SKEncodedOrigin.BottomRight: // ruotata di 180°
+ canvas.Translate(w, h);
+ canvas.RotateDegrees(180);
+ break;
+ case SKEncodedOrigin.BottomLeft: // specchiata in verticale
+ canvas.Translate(0, h);
+ canvas.Scale(1, -1);
+ break;
+ case SKEncodedOrigin.LeftTop: // trasposta
+ canvas.RotateDegrees(90);
+ canvas.Scale(1, -1);
+ break;
+ case SKEncodedOrigin.RightTop: // ruotata di 90° in senso orario
+ canvas.Translate(w, 0);
+ canvas.RotateDegrees(90);
+ break;
+ case SKEncodedOrigin.RightBottom: // trasversa
+ canvas.Translate(w, h);
+ canvas.RotateDegrees(270);
+ canvas.Scale(1, -1);
+ break;
+ case SKEncodedOrigin.LeftBottom: // ruotata di 90° in senso antiorario
+ canvas.Translate(0, h);
+ canvas.RotateDegrees(-90);
+ break;
+ }
+
+ canvas.DrawBitmap(src, 0, 0);
+ return dst;
+ }
+}
diff --git a/SteUp.Maui/Core/Services/ImageThumb.cs b/SteUp.Maui/Core/Services/ImageThumb.cs
index 2ba4ff0..bc552e9 100644
--- a/SteUp.Maui/Core/Services/ImageThumb.cs
+++ b/SteUp.Maui/Core/Services/ImageThumb.cs
@@ -19,11 +19,15 @@ public static class ImageThumb
throw new InvalidOperationException("Formato immagine non supportato o file corrotto.");
// Decodifica
- var info = codec.Info;
- using var bitmap = SKBitmap.Decode(codec);
- if (bitmap is null)
+ using var decoded = SKBitmap.Decode(codec);
+ if (decoded is null)
throw new InvalidOperationException("Impossibile decodificare l'immagine.");
+ // SKBitmap.Decode non applica l'orientamento EXIF: senza questo passaggio le
+ // miniature delle foto scattate ruotate escono storte (vale anche per gli
+ // scatti già sul dispositivo, che non passano da UtilityFile.ConvertToDto).
+ using var bitmap = ImageOrientation.Upright(decoded, codec.EncodedOrigin);
+
// Calcola resize mantenendo aspect ratio
var w = bitmap.Width;
var h = bitmap.Height;
diff --git a/SteUp.Maui/Core/Utility/UtilityFile.cs b/SteUp.Maui/Core/Utility/UtilityFile.cs
index 83665e7..c4b65d8 100644
--- a/SteUp.Maui/Core/Utility/UtilityFile.cs
+++ b/SteUp.Maui/Core/Utility/UtilityFile.cs
@@ -1,3 +1,4 @@
+using SteUp.Maui.Core.Services;
using SteUp.Shared.Core.Dto;
namespace SteUp.Maui.Core.Utility;
@@ -10,13 +11,20 @@ public static class UtilityFile
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
+ var bytes = ms.ToArray();
+
+ // Unico punto di passaggio di fotocamera e galleria: raddrizziamo qui, cosi'
+ // miniature, salvataggio locale e upload lavorano tutti su pixel gia' dritti.
+ if (type == AttachedDto.TypeAttached.Image)
+ bytes = ImageOrientation.Normalize(bytes);
+
return new AttachedDto
{
Name = file.FileName,
Path = file.FullPath,
MimeType = file.ContentType,
- DimensionBytes = ms.Length,
- FileBytes = ms.ToArray(),
+ DimensionBytes = bytes.Length,
+ FileBytes = bytes,
Type = type
};
}
diff --git a/SteUp.Maui/MainPage.xaml b/SteUp.Maui/MainPage.xaml
index f6025b4..074e039 100644
--- a/SteUp.Maui/MainPage.xaml
+++ b/SteUp.Maui/MainPage.xaml
@@ -4,7 +4,7 @@
xmlns:shared="clr-namespace:SteUp.Shared;assembly=SteUp.Shared"
x:Class="SteUp.Maui.MainPage"
SafeAreaEdges="All"
- BackgroundColor="{DynamicResource PageBackgroundColor}">
+ BackgroundColor="#E9EDF2">
diff --git a/SteUp.Maui/Platforms/Android/Core/HoneywellScannerService.cs b/SteUp.Maui/Platforms/Android/Core/HoneywellScannerService.cs
index 70ad78a..7432e7c 100644
--- a/SteUp.Maui/Platforms/Android/Core/HoneywellScannerService.cs
+++ b/SteUp.Maui/Platforms/Android/Core/HoneywellScannerService.cs
@@ -164,7 +164,14 @@ public partial class HoneywellScannerService
: Java.Lang.Object, BarcodeReader.IBarcodeListener
{
public void OnBarcodeEvent(BarcodeReadEvent? e) => service.DispatchEvent(e!);
- public void OnFailureEvent(BarcodeFailureEvent? e) =>
- service.OnScanFailed?.Invoke(new Exception($"Scan failure: {e}"));
+
+ // Honeywell emette BarcodeFailureEvent anche solo premendo e rilasciando il
+ // trigger senza inquadrare nulla: e' la normale mancata lettura, non un guasto.
+ // Trattarlo come eccezione faceva comparire un dialog bloccante col toString()
+ // Java a ogni pressione a vuoto. Non c'e' nulla da comunicare: chi ha premuto
+ // il trigger sa gia' di non aver letto niente e sta ripuntando lo scanner.
+ public void OnFailureEvent(BarcodeFailureEvent? e)
+ {
+ }
}
}
\ No newline at end of file
diff --git a/SteUp.Maui/Platforms/Android/MainActivity.cs b/SteUp.Maui/Platforms/Android/MainActivity.cs
index 9e73182..3b0b201 100644
--- a/SteUp.Maui/Platforms/Android/MainActivity.cs
+++ b/SteUp.Maui/Platforms/Android/MainActivity.cs
@@ -1,14 +1,146 @@
using Android.App;
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
{
[Activity(
- Theme = "@style/Maui.SplashTheme",
+ Theme = "@style/SteUp.SplashTheme",
MainLauncher = true,
+ WindowSoftInputMode = SoftInput.AdjustResize | SoftInput.StateHidden,
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode |
ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
+ 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());
+ }
+
+ ///
+ /// 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.
+ ///
+ private void ApplySystemBarsAppearance()
+ {
+ if (Window is null) return;
+
+ var controller = WindowCompat.GetInsetsController(Window, Window.DecorView);
+ controller.AppearanceLightStatusBars = true;
+ controller.AppearanceLightNavigationBars = true;
+ }
+
+ ///
+ /// 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.
+ ///
+ private void HookBackButton()
+ {
+ _backButton = IPlatformApplication.Current?.Services.GetService();
+ 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);
+ }
+
+ ///
+ /// 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'.
+ ///
+ 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;
+ }
+ }
}
}
diff --git a/SteUp.Maui/Platforms/Android/Resources/values-v27/themes.xml b/SteUp.Maui/Platforms/Android/Resources/values-v27/themes.xml
new file mode 100644
index 0000000..888974d
--- /dev/null
+++ b/SteUp.Maui/Platforms/Android/Resources/values-v27/themes.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/SteUp.Maui/Platforms/Android/Resources/values/colors.xml b/SteUp.Maui/Platforms/Android/Resources/values/colors.xml
index 236b2f5..30a56e9 100644
--- a/SteUp.Maui/Platforms/Android/Resources/values/colors.xml
+++ b/SteUp.Maui/Platforms/Android/Resources/values/colors.xml
@@ -1,6 +1,8 @@
#ec4c41
- #d02315
- #d02315
+
+ #e9edf2
+ #ec4c41
diff --git a/SteUp.Maui/Platforms/Android/Resources/values/themes.xml b/SteUp.Maui/Platforms/Android/Resources/values/themes.xml
new file mode 100644
index 0000000..9fef8cc
--- /dev/null
+++ b/SteUp.Maui/Platforms/Android/Resources/values/themes.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/SteUp.Maui/SteUp.Maui.csproj b/SteUp.Maui/SteUp.Maui.csproj
index 7fb525a..113948d 100644
--- a/SteUp.Maui/SteUp.Maui.csproj
+++ b/SteUp.Maui/SteUp.Maui.csproj
@@ -30,8 +30,8 @@
it.integry.SteUp
- 1.1.1
- 5
+ 1.2.0
+ 615.0
@@ -98,7 +98,9 @@
-
+
+
diff --git a/SteUp.Maui/wwwroot/index.html b/SteUp.Maui/wwwroot/index.html
index 77f1eb1..2f9e516 100644
--- a/SteUp.Maui/wwwroot/index.html
+++ b/SteUp.Maui/wwwroot/index.html
@@ -1,53 +1,76 @@
-
+
- SteUp.Maui
+ SteUP
-
-
-
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
- An unhandled error has occurred.
- Reload
+ Si è verificato un errore imprevisto.
+ Ricarica🗙
-
-
\ No newline at end of file
+
diff --git a/SteUp.Shared/Components/Layout/HeaderLayout.razor b/SteUp.Shared/Components/Layout/HeaderLayout.razor
index fd5ea3d..e2d8f2b 100644
--- a/SteUp.Shared/Components/Layout/HeaderLayout.razor
+++ b/SteUp.Shared/Components/Layout/HeaderLayout.razor
@@ -1,67 +1,56 @@
@inject IJSRuntime Js
-
-
- @if (!SmallHeader)
- {
- if (Back)
- {
-
-
- @BackTo
-
-
- }
+@*
+ Testata di schermata (STILE_UI §5): alta, titolo a sinistra, sfondo uguale
+ alla pagina. Non e' una AppBar colorata col titolo centrato: quella e' una
+ firma Android di serie, e il documento la esclude esplicitamente.
+*@
-
+}
+
+@code {
+ [Parameter] public bool Open { get; set; }
+ [Parameter] public EventCallback OpenChanged { get; set; }
+ [Parameter] public string? Title { get; set; }
+ [Parameter] public string? Subtitle { get; set; }
+
+ /// Sheet di compilazione (~92% dello schermo) invece che di consultazione.
+ [Parameter] public bool Tall { get; set; }
+
+ [Parameter] public RenderFragment? ChildContent { get; set; }
+
+ // 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();
+}
diff --git a/SteUp.Shared/Components/Layout/Spinner/LoaderLayout.razor b/SteUp.Shared/Components/Layout/Spinner/LoaderLayout.razor
deleted file mode 100644
index dd2b055..0000000
--- a/SteUp.Shared/Components/Layout/Spinner/LoaderLayout.razor
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
- @(Text ?? "Caricamento")
-
-
-
-
-
-@code
-{
- [Parameter] public bool Visible { get; set; }
- [Parameter] public string? Text { get; set; }
-}
\ No newline at end of file
diff --git a/SteUp.Shared/Components/Layout/Spinner/LoaderLayout.razor.css b/SteUp.Shared/Components/Layout/Spinner/LoaderLayout.razor.css
deleted file mode 100644
index 6ef8793..0000000
--- a/SteUp.Shared/Components/Layout/Spinner/LoaderLayout.razor.css
+++ /dev/null
@@ -1,18 +0,0 @@
-.overlay-container {
- background: var(--mud-palette-background);
- width: 20rem;
- height: 6rem;
- padding: 0 1rem;
- display: flex;
- gap: .5rem;
- flex-direction: column;
- justify-content: center;
- border-radius: 20px;
- box-shadow: var(--custom-box-shadow);
-}
-
-.overlay-container > span {
- text-align: center;
- font-size: medium;
- font-weight: 600;
-}
\ No newline at end of file
diff --git a/SteUp.Shared/Components/Layout/Spinner/SpinnerLayout.razor b/SteUp.Shared/Components/Layout/Spinner/SpinnerLayout.razor
index 89399a9..1194cc7 100644
--- a/SteUp.Shared/Components/Layout/Spinner/SpinnerLayout.razor
+++ b/SteUp.Shared/Components/Layout/Spinner/SpinnerLayout.razor
@@ -1,8 +1,11 @@
-
-
+@* Indicatore di attesa. A schermo pieno solo quando non c'e' ancora nulla da
+ mostrare; dentro una sezione resta contenuto e non fa saltare il layout. *@
+
+
+
@code
{
[Parameter] public bool FullScreen { get; set; } = true;
-}
\ No newline at end of file
+}
diff --git a/SteUp.Shared/Components/Layout/Spinner/SpinnerLayout.razor.css b/SteUp.Shared/Components/Layout/Spinner/SpinnerLayout.razor.css
deleted file mode 100644
index 80fb011..0000000
--- a/SteUp.Shared/Components/Layout/Spinner/SpinnerLayout.razor.css
+++ /dev/null
@@ -1,43 +0,0 @@
-.spinner-container {
- display: flex;
- justify-content: center;
- height: calc(100vh - 10.1rem);
- align-items: center;
- color: var(--mud-palette-primary);
-}
-
-.not-fullScreen {
- height: auto !important;
- padding: 2rem 0 !important;
-}
-
-.loader {
- width: 50px;
- aspect-ratio: 1;
- border-radius: 50%;
- border: 8px solid #0000;
- border-right-color: var(--mud-palette-primary);
- position: relative;
- animation: l24 1s infinite linear;
-}
-
-.loader:before,
-.loader:after {
- content: "";
- position: absolute;
- inset: -8px;
- border-radius: 50%;
- border: inherit;
- animation: inherit;
- animation-duration: 2s;
-}
-
-.loader:after {
- animation-duration: 4s;
-}
-
-@keyframes l24 {
- 100% {
- transform: rotate(1turn)
- }
-}
\ No newline at end of file
diff --git a/SteUp.Shared/Components/Pages/IspezionePage.razor b/SteUp.Shared/Components/Pages/IspezionePage.razor
index d8b7f0e..94296bb 100644
--- a/SteUp.Shared/Components/Pages/IspezionePage.razor
+++ b/SteUp.Shared/Components/Pages/IspezionePage.razor
@@ -2,109 +2,229 @@
@using Microsoft.Extensions.Logging
@using SteUp.Shared.Components.Layout
@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.MessageBox
@using SteUp.Shared.Core.Dto
@using SteUp.Shared.Core.Entities
@using SteUp.Shared.Core.Enum
+@using SteUp.Shared.Core.Helpers
@using SteUp.Shared.Core.Interface.IntegryApi
@using SteUp.Shared.Core.Interface.LocalDb
@using SteUp.Shared.Core.Interface.System
-@using SteUp.Shared.Core.Messages.Ispezione
-@using SteUp.Shared.Core.Messages.Scheda
-@inject NewSchedaService NewScheda
-@inject CompleteInspectionService CompleteInspection
+@using SteUp.Shared.Core.Messages.System
@inject IIspezioniService IspezioniService
@inject IDialogService Dialog
@inject IIntegrySteupService IntegrySteupService
@inject IFileManager FileManager
@inject ILogger Logger
+@inject PageActionsService PageActions
@implements IDisposable
-
+@* 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. *@
+
-
+
+ Senza connessione queste azioni non sono disponibili. Il lavoro gia' fatto resta sul dispositivo.
+
+ }
+
+
+
+
+
+
+ @* §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. *@
+
+ @* Il nome del punto vendita e' gia' nella testata: qui basta la data
+ e lo stato del documento, che la testata non porta. *@
+
+
+
@Ispezione.Data.ToString("d MMMM yyyy")
+
+
+
+
+
+
+
+
@SchedeList.Count
+
@(SchedeList.Count == 1 ? "scheda" : "schede")
+
+
+
+
@DaInviare
+
da inviare
+
+
+
+
@Bozze
+
da completare
+
+
+
+ @if (SchedeList.Count > 0)
+ {
+
+ @Inviate di @SchedeList.Count inviate
+ @PercentualeInviate%
+
+
+
+
}
+
+
+ @if (Bozze > 0)
+ {
+ @* Feedback inline: resta a video finche' il problema esiste, a
+ differenza del toast che sparisce. *@
+
+
+
+ @(Bozze == 1 ? "1 scheda è rimasta a metà" : $"{Bozze} schede sono rimaste a metà").
+ Completale o eliminale prima di concludere l'ispezione.
+
+
+ }
+
+ @if (SchedeGrouped.Count == 0)
+ {
+
+
+
Nessuna scheda
+ @if (!IsCompleted)
+ {
+
+ Aggiungi la prima scheda di questa ispezione.
+
+
+
+ Nuova scheda
+
+
+ }
+
+ }
+ else
+ {
+ @* Un reparto per volta: la testata si tocca per richiudere il
+ gruppo, e i conteggi restano scritti li' anche da chiuso — un
+ reparto compattato non deve nascondere quello che gli manca. *@
@foreach (var group in SchedeGrouped)
{
- var isFirstPanel = panelIndex == 0;
- panelIndex++;
-
-
- @{
- var daInviare = group.Value.CountDaInviare();
- }
-
-
+ var reparto = group.Key;
+ var chiuso = IsCollapsed(reparto);
+ var daInviare = group.Value.CountDaInviare();
- @if (NetworkService.IsNetworkAvailable() && daInviare > 0)
- {
-
- }
-
+
+
+
+
+
+ @if (!IsCompleted)
+ {
+ @* Da qui la scheda nasce gia' con il suo reparto: e'
+ l'unica differenza con il "+" della barra. *@
+
+
+ @if (NetworkService.IsNetworkAvailable() && daInviare > 0)
+ {
+
+ Invia @daInviare
+
+ }
}
+ @if (_loading)
+ {
+ @* Scheletri, non uno spinner in mezzo al contenuto: la pagina ha
+ gia' la forma che avra', e non salta quando i dati arrivano. *@
+
+ @for (var i = 0; i < 3; i++)
+ {
+
+
+
+
+
+
+ }
+
+ }
+ else if (Ispezioni.Count == 0)
+ {
+
+
+
Nessuna ispezione
+
+ Apri la prima ispezione scegliendo il punto vendita da controllare.
+
+
+
+ Nuova ispezione
+
+
+
+ }
+ else
+ {
+
+
+
+
+
+ }
+
-
-
@code{
private List Ispezioni { get; set; } = [];
+ private bool _loading = true;
- private bool VisibleOverlay { get; set; } = true;
+ private string? Subtitle => _loading || Ispezioni.Count == 0
+ ? null
+ : Ispezioni.Count == 1
+ ? "1 ispezione"
+ : $"{Ispezioni.Count} ispezioni";
protected override async Task OnInitializedAsync()
{
- await LoadData();
+ Ispezioni = await IspezioniService.GetAllIspezioniWithSchedeAsync(UserSession.User.Username);
- VisibleOverlay = false;
+ _loading = false;
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)
@@ -49,5 +97,4 @@
SteupDataService.InspectionPageState.Ispezione = ispezione;
NavigationManager.NavigateTo("/ispezione");
}
-
-}
\ No newline at end of file
+}
diff --git a/SteUp.Shared/Components/Pages/IspezioniPage.razor.css b/SteUp.Shared/Components/Pages/IspezioniPage.razor.css
deleted file mode 100644
index 0d64851..0000000
--- a/SteUp.Shared/Components/Pages/IspezioniPage.razor.css
+++ /dev/null
@@ -1,5 +0,0 @@
-.ispezioni{
- display: flex;
- flex-direction: column;
- gap: 1rem;
-}
\ No newline at end of file
diff --git a/SteUp.Shared/Components/Pages/LoginPage.razor b/SteUp.Shared/Components/Pages/LoginPage.razor
index b325120..421dad0 100644
--- a/SteUp.Shared/Components/Pages/LoginPage.razor
+++ b/SteUp.Shared/Components/Pages/LoginPage.razor
@@ -1,6 +1,5 @@
@page "/login"
@using Microsoft.Extensions.Logging
-@using SteUp.Shared.Components.Layout.Spinner
@using SteUp.Shared.Core.BarcodeReader.Contracts
@using SteUp.Shared.Core.Interface.System
@using SteUp.Shared.Core.Services
@@ -10,69 +9,92 @@
@inject IBarcodeManager BarcodeManager
@inject ILogger Logger
-@if (Spinner)
-{
-
-}
-else
-{
-
-
-
+@*
+ Accesso (STILE_UI §7): stessa struttura dell'app, registro diverso.
+ L'accento resta dove sta sempre, sull'unica azione della schermata.
+*@
+
+