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 height 56 hardcoded → 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 chiave loading presente 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.black nelle 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

FileVariante usataImpatto visivo atteso
create_campaign_screen.dartprimaryRadius 14→16, lieve aumento. Nessun cambio funzionale.
profile_setup_screen.dartprimaryStesso lieve shift radius; l10n loading ora in italiano
create_crew_screen.dartprimaryIdem

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.gradient come 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 found

Non toccato (deferito)

  • Migrare ElevatedButton raw a CustomButton.primary dove possibile — allineamento totale del look. Grep su ElevatedButton( 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 di AppLocalizations.of(context)!.key.