docs/UX-STUDIO.md: 10 problemi rilevati (esito dei comandi fuori vista, 12 pulsanti con coppie acceso/spento, doppia navigazione, dati vecchi mostrati come attuali), la proposta (Home di sola consultazione, pagina Regolazioni, interruttori, feedback ancorato), microcopy, piano di verifica e decisioni da prendere. Schemi in docs/ux/.
320 lines
12 KiB
Python
320 lines
12 KiB
Python
#!/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)")
|