diff --git a/README.it.md b/README.it.md index 5867515..b4a0065 100644 --- a/README.it.md +++ b/README.it.md @@ -207,6 +207,8 @@ bridge/harbour-aira-bridge.service unit systemd utente bridge/README.md istruzioni di installazione del bridge (inglese) docs/PIANO.md piano di progetto e scelte docs/PROTOCOL.md API del bridge +docs/UX-STUDIO.md studio UX e proposta per la 0.6.0 +docs/ux/ schemi dello studio UX translations/ harbour-aira-it.ts (l'inglese è la lingua sorgente) docs/anteprima/ anteprime di layout in inglese (in italiano: it/) ``` diff --git a/README.md b/README.md index 0b78bae..8402903 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,8 @@ bridge/ local HTTP bridge -> see bridge/README.md translations/ harbour-aira-it.ts (English is the source language) docs/PIANO.md project plan and decisions (Italian) docs/PROTOCOL.md bridge API +docs/UX-STUDIO.md UX review + proposal for 0.6.0 (Italian) +docs/ux/ UX study wireframes docs/anteprima/ layout previews (English; docs/anteprima/it/ Italian) ``` diff --git a/docs/UX-STUDIO.md b/docs/UX-STUDIO.md new file mode 100644 index 0000000..7669626 --- /dev/null +++ b/docs/UX-STUDIO.md @@ -0,0 +1,329 @@ +# Studio UX/UI — harbour-aira + +Come rendere le schermate più facili da usare, senza perdere quello che già +funziona. Riguarda la versione 0.5.0 (Home / Dati / Impianto / Altri comandi / +Impostazioni) e propone la 0.6.0. + +Immagini di questo studio (schemi, non anteprime): `docs/ux/`. + +--- + +## 1. Metodo + +Tre riferimenti, in quest'ordine: + +1. **Il contesto d'uso reale**: un telefono, una mano, in piedi, a volte al + freddo. La casa è una sola e l'impianto è lo stesso da mesi: le azioni + frequenti sono pochissime. +2. **Le convenzioni Sailfish**: menù a tendina dall'alto per la navigazione, + menù dal basso per le azioni, copertina come faccia dell'app, un gesto per + tornare indietro. La UI deve sembrare un'app di sistema. +3. **I vincoli tecnici**: Qt 5.6, Silica 5.1, e la regola già pagata cara che + un componente documentato può non esistere sul kit. + +Le euristiche di Nielsen (visibilità dello stato, controllo e libertà, coerenza, +prevenzione dell'errore, riconoscere invece di ricordare) sono usate come +griglia di controllo, non citate come teoria. + +**Cosa si può verificare qui e cosa no.** Qui si verificano: numero di +componenti e altezze minime, chiavi lette dalla UI contro quelle che il bridge +restituisce (`tests/ui_contract_check.py`), stringhe e traduzioni, sintassi QML. +Sul telefono si verificano: che i componenti si istanzino davvero, l'altezza +reale del contenuto (una riga di log con `contentHeight` alla prima build), +la resa di giorno e l'uso con una mano sola. + +--- + +## 2. Per chi, e in quali momenti + +| Momento | Frequenza | Cosa serve | Cosa non serve | +|---|---|---|---| +| Guardare com'è la casa (è calda? l'acqua è pronta?) | molte volte al giorno | due numeri, stato in una riga, se ci sono errori | scorrere, regolare | +| Regolare una temperatura | qualche volta a settimana | valore attuale, obiettivo, conferma | tutto il resto | +| Boost acqua calda / ospiti | qualche volta al mese | un tocco, sapere che è partito | leggere lo stato dell'impianto | +| Assenza | quando si parte | accenderla e *vedere* che è accesa | navigare | +| Impianto, versioni, diagnosi | raramente | dettagli, un posto solo | stare in mezzo alla home | + +Il caso più frequente — **guardare** — oggi costa uno scorrimento; il caso più +raro (regolare) occupa metà della prima schermata. È il rovescio di quello che +serve. + +--- + +## 3. Problemi trovati + +Con gravità: **alta** = fa sbagliare o non capire; **media** = fa perdere tempo; +**bassa** = rifinitura. + +| # | Dove | Problema | Gravità | +|---|---|---|---| +| P1 | Home | Non è una schermata di consultazione ma un modulo: stato, messaggio, scheda impianto, **2 slider con 2 pulsanti di conferma, 2 pulsanti boost, 3 pulsanti di navigazione** (7 pulsanti, 2 slider in tutto). Il contenuto supera l'altezza dello schermo: per vedere l'acqua calda bisogna scorrere | alta | +| P2 | Home, Altri comandi | L'esito dei comandi (`page.notice`) e l'indicatore di attività stanno **dentro** la pagina che scorre, il primo in cima e il secondo in fondo. Premendo "Avvia boost" — che è in fondo — il messaggio compare sopra, fuori vista: non si sa se il comando è partito | alta | +| P3 | Altri comandi | **12 pulsanti**, di cui 8 in coppie acceso/spento (assenza, riscaldamento forzato, funzione riscaldamento, ACS). Lo stato sta in 6 righe più in alto: per agire bisogna leggere, ricordare, poi scegliere il pulsante giusto. Due schermate per fare una cosa sola | alta | +| P4 | Home | Le stesse pagine si raggiungono **due volte**: dal menù a tendina (5 voci: Aggiorna, Dati, Impianto, Altri comandi, Impostazioni) e da 3 pulsanti in fondo alla home. Il menù a tendina Sailfish è fatto per 2–4 voci; con 5 si scorre dentro la tendina | media | +| P5 | Home | "Sposta lo slider, poi premi Imposta": due passi, e il valore mostrato dallo slider **non è ancora** quello dell'impianto. Con le dita fredde il cursore è difficile da centrare, e due slider nella stessa pagina che scorre si contendono il trascinamento verticale | media | +| P6 | Home, Dati, Impianto | Se il bridge non risponde, i numeri **restano a schermo come se fossero attuali** (l'ultima risposta non viene svuotata) e l'errore passa in un banner temporaneo. Manca "aggiornato alle HH:MM" e uno stato visibile "non raggiungibile" | media | +| P7 | Copertina | Dice "est. 18,0 °C" e "casa · obiettivo": abbreviato in modo poco chiaro, e "est." non si usa altrove (l'app dice "Esterna") | bassa | +| P8 | Copertina | Due temperature e nient'altro: non si può fare nulla senza aprire l'app, nemmeno il boost | bassa | +| P9 | Impostazioni | "Salva" e "Prova connessione": il secondo salva comunque, quindi i due pulsanti si sovrappongono | bassa | +| P10 | Altri comandi / Impianto | La diagnostica è in due posti: "Verifica impianto" in Altri comandi, versioni ed errori in Impianto | bassa | + +### P1 — il conto dei componenti + +Nella Home attuale: 1 intestazione di pagina, 2 righe di stato, 1 messaggio di +2 righe, 1 scheda impianto, **3 intestazioni di sezione, 7 pulsanti, 2 slider, +1 barra di avanzamento**, più note e indicatore di attività. Sommando le altezze +minime dei componenti Silica (un pulsante ~80 px, uno slider ~100 px) si arriva +ben oltre l'altezza di uno schermo: nel confronto affiancato +(`docs/ux/confronto.png`) si vede dove il contenuto viene tagliato. +L'altezza esatta si legge dal log `contentHeight` alla prima build sul telefono. + +### P2 — dettaglio + +In `MainPage.qml` il banner dei messaggi è la prima `Label` della `Column` e il +`BusyIndicator` è l'ultimo. Entrambi scorrono con il contenuto. Il caso peggiore +è proprio il più probabile: i pulsanti boost sono in fondo alla pagina, quindi +il messaggio di esito nasce fuori dallo schermo. + +--- + +## 4. La proposta + +Tre movimenti, niente di più: + +1. **La Home torna a essere una vista.** In alto quello che si guarda; quello + che si tocca sta in una pagina sua. +2. **Le regolazioni hanno una pagina propria**, un soggetto per blocco, con il + valore attuale sempre visibile e preimpostazioni per le dita fredde. +3. **Gli interruttori dicono lo stato**; i pulsanti restano per le azioni che + uno stato non ce l'hanno (boost, legionella, notte, verifica). + +### 4.1 Dove sta cosa, dopo + +``` +PRIMA DOPO +───────────────────────────── ───────────────────────────── +Home Casa (una schermata, niente scroll) + ├ stato + messaggio lungo ├ stato in una riga + ora + ├ scheda impianto ├ Casa 22,8 °C · obiettivo 19,0 + ├ Riscaldamento: slider + Imposta ├ Acqua calda 50,7 · obiettivo 55,0 + ├ ACS: barra + slider + Imposta ├ Azioni rapide: Boost · Assenza · Altri comandi + ├ Boost acceso / spento └ Esterna · Termostato · zona + ├ Dettagli: Impianto · Dati ───────────────────────────── + └ Altri comandi Regolazioni (nuova) +├───────────── ├ Riscaldamento · casa (valore + slider + preset) +│ ├ Acqua calda (valore + slider + preset) +Dati (invariata) └ Boost acqua calda +Impianto (riordinata): stato · ───────────────────────────── + termostato · versioni · errori Comandi impianto (ex Altri comandi) +Altri comandi (12 pulsanti) ├ Modalità: Assenza · Forzato · Funzione (interruttori) +Impostazioni ├ Acqua calda: interruttore + legionella + ├ Rumore: notte un'ora +Menù a tendina: 5 voci └ Diagnostica: verifica impianto + ───────────────────────────── + Dati (invariata) · Impianto (con diagnostica) + Impostazioni: "Salva e prova" + Menù a tendina: 4 voci +``` + +### 4.2 Casa — `docs/ux/casa.png` + +I richiami numerati dell'immagine: + +1. **Menù a tendina a 4 voci**: Aggiorna · Dati · Impianto · Impostazioni. + Solo navigazione fra pagine, una voce per pagina: i pulsanti in fondo alla + home non ci sono più (risolve P4). +2. **Stato in una riga**: pallino colorato + "Tutto regolare · Automatico" + + ora dell'ultimo aggiornamento. Verde regolare, ambra attenzione, rosso + errore o non raggiungibile (risolve P6). La frase estesa dell'app ufficiale + ("Relax: la pompa funziona regolarmente") è bella ma occupa 2 righe in cima: + sta meglio nella pagina Impianto (decisione 1). +3. **Due letture grandi, affiancate**: Casa 22,8 °C (obiettivo 19,0) e Acqua + calda 50,7 °C (obiettivo 55,0) con la barretta di avanzamento della carica. + Sono il motivo per cui si apre l'app: due tocchi sotto l'intestazione, senza + scorrere. Ogni riquadro è **toccabile** e porta a Regolazioni (è il gesto + dell'app ufficiale: tocco la scheda, vedo il dettaglio). +4. **"Tocca un valore per regolarlo"**: una riga che spiega il tocco. Sparisce + se il tocco si capisce (decisione 4). +5. **Azioni rapide**: Boost ACS · Assenza · Altri comandi. Le due azioni + frequenti più la porta verso tutti gli altri comandi, che il menù a tendina + non porta (così resta una sola strada per pagina, non due). In alternativa + le stesse due azioni possono stare nel menù dal basso, all'altezza del + pollice: è la decisione 3. +6. **Letture di contorno**, in fondo e in una riga: Esterna, Termostato, zona. + Servono di rado: non meritano una riga ciascuna in alto. +7. **"Aggiornato alle 21:18"** in fondo: la stessa informazione del richiamo 2, + ripetuta dove finisce la lettura. + +Cosa sparisce: due slider, sette pulsanti, la scheda impianto separata (il suo +contenuto si tocca dal riquadro), tre pulsanti di navigazione. Cosa resta: la +frase di stato (spostata), la barra ACS, l'aggiornamento automatico. + +### 4.3 Regolazioni — `docs/ux/regolazioni.png` + +1. **Riscaldamento · casa**: valore attuale grande a destra, slider 5–30 °C, + riga di preimpostazioni (16° 17° 18° 19° 20° 21°), pulsante "Imposta 19,0 °C". +2. **Acqua calda**: stessa struttura, 45–60 °C, preimpostazioni 45/47/50/55/60. +3. **Boost acqua calda**: Avvia / Ferma. Azione senza stato: resta un pulsante. +4. **Nota**: il comando passa dal bridge e lo stato si aggiorna da solo. + +Perché è meglio (risolve P5): + +- **Un solo slider per blocco** e due blocchi distinti: nessun trascinamento + verticale che finisce sul cursore sbagliato. +- **Il valore attuale non si muove**: lo slider è la proposta, il numero grande + a destra è l'obiettivo reale finché non si preme Imposta. +- **Le preimpostazioni** evitano il cursore con le dita fredde: un tocco invece + di un trascinamento di precisione (l'impianto vuole passi di 0,5 °C: i preset + coprono il caso normale, lo slider resta per il resto). +- La conferma esplicita resta (com'è giusto per una scrittura sull'impianto). + +### 4.4 Comandi impianto — `docs/ux/comandi.png` + +1. **Modalità — interruttori**: Assenza (con la spiegazione "abbassa la casa e + spegne l'acqua calda"), Riscaldamento forzato, Funzione riscaldamento. +2. **Acqua calda — interruttore + pulsante**: Riscaldamento ACS (interruttore), + Ciclo legionella (pulsante). +3. **Diagnostica**: Verifica impianto, spostata qui da Altri comandi. +4. **Nota**: "Gli interruttori mostrano lo stato attuale: la sezione *Stato + attuale* non serve più" (risolve P3 e P11). + +Da 12 pulsanti + 6 righe di stato a **4 interruttori + 3 pulsanti**. Il titolo +della pagina diventa "Comandi impianto": dice cosa contiene. + +**Nota di realizzazione importante.** Un interruttore che cambia da solo quando +lo tocchi mostra una bugia se il comando fallisce. Va legato allo stato che +arriva dal bridge (`checked` sull'ultimo dato reale), non al tocco: al tocco si +invia il comando, si mostra l'attività, e lo stato lo conferma l'aggiornamento. +Se il comando non riesce, l'interruttore torna indietro da solo. È lo stesso +principio del pulsante Imposta: la UI non anticipa l'impianto. + +### 4.5 Copertina + +Resta com'è nelle letture (casa grande, ACS, esterna), con due migliorie basse: + +- Dicitura chiara: "esterna" invece di "est." (P7). +- **Azioni sulla copertina**: Silica ha `CoverAction`/`CoverActionList` + documentati ("un'azione per una copertina"). Una o due icone — boost e + assenza — permettono di agire senza aprire l'app (P8). È rifinitura: da + provare sul kit prima di contarla, come ultimo passo. + +### 4.6 Feedback e stato non aggiornato (P2, P6) + +Fuori dalla parte che scorre: + +- **Banner dei messaggi ancorato al bordo inferiore della pagina**, non alla + cima del contenuto: nasce dove si tocca, sempre visibile. Sfuma da solo dopo + qualche secondo come oggi. +- **Indicatore di attività ancorato** allo stesso punto (o nell'intestazione): + non scorre via. +- **Riga "Aggiornato alle HH:MM"** nell'intestazione della Home (richiamo 2) e + **"non raggiungibile"** in ambra, con i numeri spenti invece che pieni quando + l'ultima lettura non è recente. Serve una proprietà in più in C++ + (`lastUpdate`, `stale`) per saperlo: piccolo, ma è la differenza fra un dato + vecchio e un dato sbagliato. + +--- + +## 5. Microcopy + +| Dove | Ora | Proposta | Perché | +|---|---|---|---| +| Copertina | est. 18,0 °C | esterna 18,0 °C | si capisce | +| Home | Relax: la pompa di calore funziona regolarmente | ● Tutto regolare · Automatico | una riga; la frase estesa va in Impianto | +| Home | (niente) | Aggiornato alle 21:18 | il dato ha un'età | +| Altri comandi | Altri comandi | Comandi impianto | dice cosa c'è dentro | +| Regolazioni | Imposta casa a 19,0 °C | Imposta 19,0 °C | il blocco dice già "casa" | +| Assenza | Turn away mode on/off | interruttore Assenza | lo stato si vede | +| Impostazioni | Salva · Prova connessione | Salva e prova | sono la stessa cosa | + +Le stringhe nuove o cambiate saranno una ventina; il catalogo è a 170 voci, +l'aggiornamento è routine (`lupdate` + traduzione + `lrelease`). + +--- + +## 6. Cosa non si tocca + +- **La pagina Dati**: riproduce i numeri dell'app ufficiale ed è già una + schermata di sola lettura ben fatta. Nessuna modifica. +- **La pagina Impianto** nella sostanza: si aggiunge la diagnostica (P10) e + nient'altro. +- **Il pulsante Imposta con la barra "scorri per confermare"** (RemorseItem) sui + comandi che cambiano l'impianto: è la prevenzione dell'errore giusta in + Sailfish, e in più non serve una finestra di conferma. +- **Aggiornamento automatico all'apertura** e **menù a tendina come navigazione**: + sono convenzioni, non decorazioni. +- **Le due letture grandi** come criterio della Home: vengono dall'aver copiato + l'organizzazione dell'app ufficiale, ed è la scelta che rende la Home utile. + +--- + +## 7. Piano di verifica + +**Prima di compilare (qui, senza telefono)** + +1. `qmllint` sui file QML toccati. +2. `tests/ui_contract_check.py`: ogni chiave letta dalla UI esiste nelle + risposte del bridge (41 chiavi fra `/summary` e `/stats`). +3. Test del nucleo `tests/core_test.cpp` (61 verifiche) + nuove verifiche per + `stale`/`lastUpdate` e per le etichette cambiate. +4. `lupdate` → nessuna stringa mancante → `lrelease`. +5. Anteprime rigenerate (`scripts/render_mockups.py`, it/en), così le immagini + dei README restano vere. + +**Sul telefono (Carlo)** + +1. Una riga di log all'avvio con `contentHeight` e altezza schermo: verifica + numerica che la Home non scorre più. +2. Che `TextSwitch` e, se si fa, `CoverAction` si istanzino (lezione già presa + con `Notices`: documentato non vuol dire disponibile). +3. Prova con una mano sola e alla luce del giorno. + +**Prova d'uso, 3 compiti** (si può fare in dieci minuti con una persona che non +conosce l'app: basta un amico o la famiglia) + +| # | Compito | Riuscito se | +|---|---|---| +| T1 | "Quanto è calda l'acqua adesso, e quanto deve arrivare?" | risponde senza scorrere | +| T2 | "Portala a 47 °C" | ≤3 tocchi, nessun dubbio su cosa è stato inviato | +| T3 | "Metti la casa in assenza e dimmi se è attiva" | ≤2 tocchi + lo stato si vede dalla schermata | + +Si annotano: tempo, tocchi, scorrimenti, tocchi sbagliati. Un solo numero conta +davvero: **quanti tocchi per T2 e T3**, prima e dopo. + +--- + +## 8. Rischi tecnici + +| Rischio | Come si governa | +|---|---| +| `TextSwitch` documentato ma non istanziabile sul kit 5.1 | Provarlo in una pagina di prova alla prima build; ripiego: si resta sui pulsanti (già funzionanti) | +| `CoverAction` idem | È l'ultimo passo; se non c'è, la copertina resta com'è | +| Le anteprime non rappresentano lo scroll reale | La riga di log con `contentHeight` dà la misura vera | +| Un interruttore che anticipa lo stato (bug classico) | `checked` legato ai dati del bridge, non al tocco (§4.4) | +| Il cambio lingua ricarica la radice (Qt 5.6) | Nessun impatto: già così | + +--- + +## 9. Quanto costa, e cosa decidere + +Tre blocchi di lavoro, in quest'ordine: + +1. **Home + Regolazioni** (P1, P4, P5, P7, microcopy) — il grosso del guadagno. +2. **Comandi impianto con interruttori + diagnostica in Impianto** (P3, P9, P10). +3. **Feedback ancorato + stato aggiornato + copertina** (P2, P6, P8). + +Ogni blocco: una tornata di lavoro mia (codice + stringhe + anteprime + test), +una build e un controllo tuo sul telefono. + +**Decisioni richieste** (le altre le ho prese seguendo le convenzioni): + +1. **La frase di stato lunga** ("Relax: la pompa di calore funziona + regolarmente"): la tengo in una riga sola sopra le letture come nell'app + ufficiale, o la sposto nella pagina Impianto e lascio in Home la riga + compatta come nel wireframe? +2. **"Altri comandi" → "Comandi impianto"**: rinominare? +3. **Azioni rapide**: pulsanti visibili sulla Home (proposta) o menù dal basso + all'altezza del pollice? +4. **Copertina con le azioni**: la facciamo (dopo aver verificato `CoverAction` + sul kit) o resta di sola lettura? diff --git a/docs/ux/casa.png b/docs/ux/casa.png new file mode 100644 index 0000000..2544ea7 Binary files /dev/null and b/docs/ux/casa.png differ diff --git a/docs/ux/comandi.png b/docs/ux/comandi.png new file mode 100644 index 0000000..aefc1af Binary files /dev/null and b/docs/ux/comandi.png differ diff --git a/docs/ux/confronto.png b/docs/ux/confronto.png new file mode 100644 index 0000000..8970d0e Binary files /dev/null and b/docs/ux/confronto.png differ diff --git a/docs/ux/regolazioni.png b/docs/ux/regolazioni.png new file mode 100644 index 0000000..2120c4f Binary files /dev/null and b/docs/ux/regolazioni.png differ diff --git a/scripts/render_wireframes.py b/scripts/render_wireframes.py new file mode 100644 index 0000000..a6eb78e --- /dev/null +++ b/scripts/render_wireframes.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Wireframe dello studio UX/UI di harbour-aira (proposta). + +Sono SCHEMI, non anteprime rifinite: mostrano disposizione, gerarchia e +comportamento dei componenti. Le anteprime curate delle schermate attuali +stanno in docs/anteprima/; qui si confrontano le proposte dello studio. + + python3 scripts/render_wireframes.py -> docs/ux/*.png +""" +from __future__ import annotations + +import pathlib + +from PIL import Image, ImageDraw + +from render_mockups import (W, H, M, font, background, status_bar, + PRIMARY, SECONDARY, ACCENT, BTN_BG, BTN_EDGE, OK) + +OUT = pathlib.Path("docs/ux") +WARN = (232, 178, 92) # ambra: attenzione +BAD = (226, 106, 106) # rosso: problema + + +# ---------------------------------------------------------------- primitive + +def badge(d, x, y, n): + """Numero di richiamo: si spiega nelle note dello studio.""" + d.ellipse([x, y, x + 34, y + 34], fill=ACCENT) + t = str(n) + tw = d.textlength(t, font=font(22, bold=True)) + d.text((x + 17 - tw / 2, y + 5), t, font=font(22, bold=True), fill=(10, 12, 16)) + + +def box(d, x, y, w, h, fill=None, outline=BTN_EDGE, radius=14, width=2): + d.rounded_rectangle([x, y, x + w, y + h], radius=radius, + fill=fill, outline=outline, width=width) + + +def btn(d, x, y, w, text, h=66, primary=False): + box(d, x, y, w, h, fill=BTN_BG if not primary else (30, 62, 78), + outline=ACCENT if primary else BTN_EDGE) + f = font(24) + tw = d.textlength(text, font=f) + d.text((x + (w - tw) / 2, y + (h - 26) / 2), text, font=f, + fill=PRIMARY) + + +def text_line(d, x, y, text, size=24, color=SECONDARY, bold=False, width=None, + align="left"): + f = font(size, bold) + if width is None: + d.text((x, y), text, font=f, fill=color) + return y + size + 8 + words, lines, cur = text.split(), [], "" + for wtxt in words: + probe = (cur + " " + wtxt).strip() + if d.textlength(probe, font=f) <= width or not cur: + cur = probe + else: + lines.append(cur) + cur = wtxt + lines.append(cur) + for ln in lines: + tx = x if align == "left" else (x + (width - d.textlength(ln, font=f)) / 2) + d.text((tx, y), ln, font=f, fill=color) + y += size + 10 + return y + + +def pulley_hint(d, y, items): + d.text((M, y), "▾", font=font(28, bold=True), fill=ACCENT) + x = M + 40 + for i, it in enumerate(items): + if i: + d.text((x, y + 2), "·", font=font(22), fill=BTN_EDGE) + x += 22 + d.text((x, y + 2), it, font=font(22), fill=SECONDARY) + x += d.textlength(it, font=font(22)) + 16 + return y + 40 + + +def pushup_hint(d, y, items): + d.text((M, y), "▴", font=font(28, bold=True), fill=ACCENT) + x = M + 40 + for i, it in enumerate(items): + if i: + d.text((x, y + 2), "·", font=font(22), fill=BTN_EDGE) + x += 22 + d.text((x, y + 2), it, font=font(22), fill=SECONDARY) + x += d.textlength(it, font=font(22)) + 16 + return y + 40 + + +def cell(d, x, y, w, h, title, value, sub, frac=None, chevron=True): + box(d, x, y, w, h, fill=BTN_BG) + d.text((x + 24, y + 22), title, font=font(24), fill=ACCENT) + d.text((x + 24, y + 58), value, font=font(54, bold=True), fill=PRIMARY) + d.text((x + 24, y + 124), sub, font=font(22), fill=SECONDARY) + if frac is not None: + d.rounded_rectangle([x + 24, y + h - 34, x + w - 24, y + h - 24], + radius=5, fill=(56, 64, 74)) + d.rounded_rectangle([x + 24, y + h - 34, + x + 24 + (w - 48) * frac, y + h - 24], + radius=5, fill=ACCENT) + if chevron: + d.text((x + w - 40, y + h / 2 - 22), "›", font=font(38, bold=True), + fill=SECONDARY) + + +def slider(d, x, y, w, frac, left, right): + cy = y + 18 + d.text((x, cy - 14), left, font=font(22), fill=SECONDARY) + rw = d.textlength(right, font=font(22)) + d.text((x + w - rw, cy - 14), right, font=font(22), fill=SECONDARY) + tx0, tx1 = x + 60, x + w - 60 + d.rounded_rectangle([tx0, cy - 3, tx1, cy + 3], radius=3, fill=(56, 64, 74)) + kx = tx0 + (tx1 - tx0) * frac + d.rounded_rectangle([tx0, cy - 3, kx, cy + 3], radius=3, fill=ACCENT) + d.ellipse([kx - 22, cy - 22, kx + 22, cy + 22], fill=PRIMARY) + return y + 62 + + +def switch_row(d, y, label, desc, on, x=M, w=W - 2 * M): + d.text((x, y), label, font=font(26), fill=PRIMARY) + d.text((x, y + 34), desc, font=font(20), fill=SECONDARY) + sw, sh = 104, 56 + sx, sy = x + w - sw, y + 4 + d.rounded_rectangle([sx, sy, sx + sw, sy + sh], radius=sh / 2, + fill=(30, 62, 78) if on else (44, 50, 58), + outline=ACCENT if on else BTN_EDGE, width=2) + kx = sx + sw - 28 if on else sx + 28 + d.ellipse([kx - 22, sy + sh / 2 - 22, kx + 22, sy + sh / 2 + 22], + fill=PRIMARY) + return y + 96 + + +def presets(d, y, values): + n = len(values) + gap = 10 + bw = (W - 2 * M - gap * (n - 1)) / n + for i, v in enumerate(values): + x = M + i * (bw + gap) + box(d, x, y, bw, 58, fill=BTN_BG) + tw = d.textlength(v, font=font(22)) + d.text((x + (bw - tw) / 2, y + 16), v, font=font(22), fill=PRIMARY) + return y + 74 + + +# ------------------------------------------------------------------ schermate + +def casa_screen(): + img = background() + d = ImageDraw.Draw(img) + status_bar(d) + d.text((M, 84), "Aira", font=font(44, bold=True), fill=PRIMARY) + y = pulley_hint(d, 142, ["Aggiorna", "Dati", "Impianto", "Impostazioni"]) + badge(d, W - M - 34, 136, 1) + + # stato in una riga sola + d.ellipse([M, y + 10, M + 22, y + 32], fill=OK) + d.text((M + 38, y + 6), "Tutto regolare · Automatico", font=font(24), + fill=PRIMARY) + d.text((W - M - 120, y + 8), "21:18", font=font(22), fill=SECONDARY) + badge(d, W - M - 34, y + 2, 2) + y += 74 + + # due letture grandi: sono il motivo per cui si apre l'app + cw = (W - 2 * M - 20) / 2 + cell(d, M, y, cw, 214, "Casa", "22,8 °C", "obiettivo 19,0 °C") + cell(d, M + cw + 20, y, cw, 214, "Acqua calda", "50,7 °C", + "obiettivo 55,0 °C", frac=0.78) + badge(d, W - M - 34, y + 180, 3) + y += 230 + + y = text_line(d, M, y, "Tocca un valore per regolarlo ›", 22, SECONDARY) + badge(d, W - M - 34, y - 44, 4) + y += 18 + + y = text_line(d, M, y, "Azioni rapide", 28, ACCENT, bold=True) + badge(d, W - M - 34, y - 40, 5) + bw = (W - 2 * M - 2 * 16) / 3 + for i, t in enumerate(("Boost ACS", "Assenza", "Altri comandi")): + btn(d, M + i * (bw + 16), y, bw, t, h=72) + y += 92 + + # letture di contorno, in fondo: servono di rado + y = text_line(d, M, y, "Esterna 18,0 °C · Termostato 22,8 °C · 1 zona", + 22, SECONDARY) + badge(d, W - M - 34, y - 44, 6) + + d.text((M, H - 76), "Aggiornato alle 21:18", font=font(22), fill=SECONDARY) + badge(d, W - M - 34, H - 88, 7) + return img + + +def regolazioni_screen(): + img = background() + d = ImageDraw.Draw(img) + status_bar(d) + d.text((M, 84), "Regolazioni", font=font(44, bold=True), fill=PRIMARY) + y = pulley_hint(d, 142, ["Aggiorna", "Dati", "Impianto", "Impostazioni"]) + 10 + + yh = y + y = text_line(d, M, y, "Riscaldamento · casa", 28, ACCENT, bold=True) + badge(d, W - M - 34, yh - 4, 1) + fv = font(38, bold=True) + vw = d.textlength("19,0 °C", font=fv) + d.text((W - M - vw, y), "19,0 °C", font=fv, fill=PRIMARY) + y += 54 + y = slider(d, M, y, W - 2 * M, (19.0 - 5) / 25.0, "5 °C", "30 °C") + y = presets(d, y, ["16°", "17°", "18°", "19°", "20°", "21°"]) + btn(d, M, y, W - 2 * M, "Imposta 19,0 °C", h=70, primary=True) + y += 104 + + yh = y + y = text_line(d, M, y, "Acqua calda", 28, ACCENT, bold=True) + badge(d, W - M - 34, yh - 4, 2) + vw = d.textlength("55,0 °C", font=fv) + d.text((W - M - vw, y), "55,0 °C", font=fv, fill=PRIMARY) + y += 54 + y = slider(d, M, y, W - 2 * M, (55.0 - 45) / 15.0, "45 °C", "60 °C") + y = presets(d, y, ["45°", "47°", "50°", "55°", "60°"]) + btn(d, M, y, W - 2 * M, "Imposta 55,0 °C", h=70, primary=True) + y += 104 + + yh = y + y = text_line(d, M, y, "Boost acqua calda", 28, ACCENT, bold=True) + badge(d, W - M - 34, yh - 4, 3) + bw = (W - 2 * M - 16) / 2 + btn(d, M, y, bw, "Avvia boost", h=70) + btn(d, M + bw + 16, y, bw, "Ferma boost", h=70) + y += 108 + + text_line(d, M, y, "Il comando passa dal bridge e può richiedere qualche " + "secondo: lo stato si aggiorna da solo.", 20, SECONDARY, + width=W - 2 * M) + badge(d, W - M - 34, y + 34, 4) + return img + + +def comandi_screen(): + img = background() + d = ImageDraw.Draw(img) + status_bar(d) + d.text((M, 84), "Comandi impianto", font=font(44, bold=True), fill=PRIMARY) + y = pulley_hint(d, 142, ["Aggiorna", "Dati", "Impianto", "Impostazioni"]) + 6 + + yh = y + y = text_line(d, M, y, "Modalità", 28, ACCENT, bold=True) + badge(d, W - M - 34, yh - 4, 1) + y = switch_row(d, y, "Assenza", "Abbassa la casa e spegne l'acqua calda", False) + y = switch_row(d, y, "Riscaldamento forzato", "Scalda finché non lo fermi", False) + y = switch_row(d, y, "Funzione riscaldamento", "Il riscaldamento può accendersi", True) + y += 12 + + yh = y + y = text_line(d, M, y, "Acqua calda", 28, ACCENT, bold=True) + badge(d, W - M - 34, yh - 4, 2) + y = switch_row(d, y, "Riscaldamento ACS", "Manterrà l'acqua in temperatura", True) + btn(d, M, y, W - 2 * M, "Ciclo legionella", h=70) + y += 96 + + y = text_line(d, M, y, "Rumore", 28, ACCENT, bold=True) + btn(d, M, y, W - 2 * M, "Modalità notte per un'ora", h=70) + y += 96 + + yh = y + y = text_line(d, M, y, "Diagnostica", 28, ACCENT, bold=True) + badge(d, W - M - 34, yh - 4, 3) + btn(d, M, y, W - 2 * M, "Verifica impianto", h=70) + y += 100 + + text_line(d, M, y, "Gli interruttori mostrano lo stato attuale: la sezione " + "\"Stato attuale\" non serve più.", 20, SECONDARY, + width=W - 2 * M) + badge(d, W - M - 34, y + 34, 4) + return img + + +def confronto(): + """Prima/dopo affiancati, con l'altezza del contenuto a colpo d'occhio.""" + left = Image.open("docs/anteprima/it/dashboard.png") + right = casa_screen() + s = 0.52 + lw, lh = int(left.width * s), int(left.height * s) + rw, rh = int(right.width * s), int(right.height * s) + gap, top, capt = 28, 92, 158 + canvas = Image.new("RGB", (gap * 3 + lw + rw, top + max(lh, rh) + capt), + (18, 22, 28)) + d = ImageDraw.Draw(canvas) + canvas.paste(left.resize((lw, lh)), (gap, top)) + canvas.paste(right.resize((rw, rh)), (gap * 2 + lw, top)) + # la home attuale scorre oltre lo schermo + ov = Image.new("RGB", (lw, 74), BAD) + sh = Image.blend(canvas.crop((gap, top + lh - 74, gap + lw, top + lh)), ov, 0.35) + canvas.paste(sh, (gap, top + lh - 74)) + d.text((gap + 16, top + lh - 52), "↓ il contenuto continua sotto", + font=font(22, bold=True), fill=(20, 20, 20)) + # didascalie: vanno a capo dentro la larghezza della schermata + text_line(d, gap, 34, "OGGI: si scorre (≈1,7 schermate)", 26, BAD, + bold=True, width=lw) + text_line(d, gap * 2 + lw, 34, "PROPOSTA: sta in una schermata", 26, OK, + bold=True, width=rw) + text_line(d, gap, top + lh + 22, + "Due slider con pulsante di conferma e tre pulsanti di " + "navigazione ridondanti col menù.", 20, SECONDARY, width=lw) + text_line(d, gap * 2 + lw, top + rh + 22, + "Stato, due letture grandi, tre azioni rapide: le regolazioni " + "hanno una pagina loro.", 20, SECONDARY, width=rw) + return canvas + + +if __name__ == "__main__": + OUT.mkdir(parents=True, exist_ok=True) + for name, fn in (("casa", casa_screen), ("regolazioni", regolazioni_screen), + ("comandi", comandi_screen), ("confronto", confronto)): + p = OUT / f"{name}.png" + fn().save(p) + print(f"scritto {p} ({p.stat().st_size} byte)")