Refactor — CustomButton allineato al brand + variante gradient
Commit:
refactor(button): align CustomButton with theme tokens + gradient variant· Branch:edit/mobile-audit-ux-polish· Audit: 2026-04-20 UX polish (findings H3, H4)
Cosa non andava
CustomButton è il componente CTA più usato dell’app. Quattro consumer (custom_button.dart stesso, create_campaign_screen.dart, profile_setup_screen.dart, create_crew_screen.dart) lo pilotano da snowflake su snowflake. Problemi trovati nell’audit:
- Tokens mentiti: radius
circular(16)e height56hardcoded → duplicati ovunque, drift inevitabile. - Secondary invisibile in dark mode:
backgroundColor ?? theme.colorScheme.surface→ in dark mode surface ≈inkBlack= stesso colore dello scaffold → pulsante nero su nero. 'Loading...'hardcoded: rompe l’i18n già configurata (en/it/es) con una chiaveloadingpresente in tutti i locali.- Nessuna variante brand: per ogni CTA “hero” (i primi di login, welcome, registration) ogni dev ricomponeva a mano il gradient coral→violet → zero consistency.
CustomIconButton.borderRadius = size / 3: icona 32 → radius 10.67, icona 48 → 16, icona 64 → 21.33. Disuniforme a ogni size.- Hardcoded
Colors.blacknelle shadow: invisibile in dark mode. - Ternario ridondante:
elevation: isDisabled ? 0 : 0(entrambi i rami = 0).
Cosa abbiamo cambiato
1. Nuova variante ButtonType.gradient
Il gradient brand (coral → violet) ora è un first-class button variant:
CustomButton.gradient(
text: 'Inizia ora',
onPressed: _onStart,
)Implementato tramite un widget _GradientButton interno che combina Container(gradient) + Material + InkWell — la strada corretta in Flutter per avere ripple su gradient (la API ElevatedButton non espone gradient diretti). Disabled state = Opacity(0.5), nessuna shadow quando disabled.
Quando usarlo: il CTA hero della schermata (welcome, onboarding step finali, primo RSVP). Regola di design: max una gradient per viewport (vedi FLOW_DESIGN_PHILOSOPHY.md — “one element glows per viewport”).
2. Dimensioni canonicalizzate
class _ButtonDims {
static const double height = 56.0;
static const double textHeight = 44.0;
static const double radius = AppTheme.radius16;
static const double radiusText = AppTheme.radius12;
}Unificato: primary/gradient/secondary/outlined = 56h × radius16, text = 44h × radius12. Derivato da AppTheme.radius16 e AppTheme.radius12 così i futuri token shift si propagano. Height 56 è deliberatamente più alto dei 48 di Material standard — è il “Flow tactile feel” (pulsanti più generosi, più touch-friendly, più sicuri).
3. Secondary dark-mode safe
Passato da colorScheme.surface (= inkBlack in dark) a colorScheme.surfaceContainerHighest (Material 3 — surface elevato, sempre distinto da scaffold). Ora secondary è un tono neutro sopra lo scaffold in entrambi i mode.
4. Loading localizzato
final loadingLabel = AppLocalizations.of(context)?.loading ?? 'Loading...';La chiave loading esisteva già in tutti e 3 i locali (Loading... / Caricamento... / Cargando...) ma non era consumata. Ora lo è. Il fallback a 'Loading...' protegge contro null-context in edge case.
5. CustomIconButton radius fissa
const radius = AppTheme.radius14;Sostituito size / 3 con radius14 costante. Tutte le icon button adesso hanno lo stesso squircle indipendentemente dalla size. Uniformità visiva garantita.
6. Shadow theme-aware
Colors.black.withValues(alpha: 0.1) → theme.colorScheme.onSurface.withValues(alpha: 0.08). In dark mode onSurface ≈ white, quindi shadow diventa un alone chiarissimo — appena percettibile ma visibile (dove prima c’era il vuoto del black-on-black).
Matrice consumer → visibile
| File | Variante usata | Impatto visivo atteso |
|---|---|---|
create_campaign_screen.dart | primary | Radius 14→16, lieve aumento. Nessun cambio funzionale. |
profile_setup_screen.dart | primary | Stesso lieve shift radius; l10n loading ora in italiano |
create_crew_screen.dart | primary | Idem |
Tutti i 3 consumer passano flutter analyze. Nessun breaking change nell’API pubblica (solo aggiunta del .gradient constructor).
Suggerimenti per uso futuro
- Login welcome screen → candidato per
CustomButton.gradientcome CTA primario unico - Onboarding step finali → idem
- Pricing/Pro upgrade sheet (se/quando esisterà) → idem, uno solo per viewport
- NON usare gradient per: azioni secondarie, dialoghi di conferma, lista di opzioni. Quella è “ispirazione brand” fuori luogo → confonde la gerarchia.
Verifica
flutter analyze lib/shared/widgets/custom_button.dart \
lib/features/campaigns/screens/create_campaign_screen.dart \
lib/features/onboarding/screens/profile_setup_screen.dart \
lib/features/social/screens/create_crew_screen.dart
# → No issues foundNon toccato (deferito)
- Migrare
ElevatedButtonraw aCustomButton.primarydove possibile — allineamento totale del look. Grep suElevatedButton(serve dopo questo pass. - Haptic feedback: aggiungere
HapticFeedback.selectionClick()su tap — aumenta la percezione di qualità. Da valutare dopo Ultimate test. - Pressed state animation:
AnimatedScale(0.96)su pressed — micro interazione brand. Skill canvas-design style.
Lessons
size / 3è un antipattern “fluid radius” che in pratica genera valori non-armonici. Le radius vanno scelte dal design system, non calcolate runtime dai dimensioni del widget.- Secondary buttons invisible in dark mode è il bug più silenzioso di un theme system Material: il testing manuale su light mode non lo rivela mai. Solo chi svolge QA sistematico in dark mode lo trova. Una regola di sanità: ogni tipo di button va collaudato in entrambe le mode prima del merge.
- I18n fallback pattern:
AppLocalizations.of(context)?.key ?? 'hardcoded'. Il?.e il??insieme proteggono da (a) context non in-tree al l10n provider (b) chiave mancante in un locale. È più robusto diAppLocalizations.of(context)!.key.