Polish — Haptic feedback e press-scale per CustomButton

Commit: polish(button): add haptic feedback and pressed-scale animation · Branch: edit/mobile-audit-ux-polish · Audit: 2026-04-20 UX polish (finding M1)

Contesto

Dopo il refactor refactor(button) che ha allineato CustomButton ai token brand, restava un gap percettivo: i pulsanti sembravano giusti visivamente, ma morti al tocco. Nessun haptic, nessun micro-movimento al press — esperienza da web, non da app nativa. L’audit UX polish (M1) l’aveva marcato come “feedback tattile assente → percezione di sluggishness”.

Questo è un pass percettivo, non funzionale. Nessun API change, nessun nuovo flusso — solo due micro-interazioni che firmano il tocco.

Cosa abbiamo cambiato

1. Haptic selection-click su ogni tap

Introdotto un helper privato _flowTapHaptic() in cima a custom_button.dart che emette HapticFeedback.selectionClick(). È il variante standard-industry per “button pressed”:

  • iOS: UIImpactFeedbackGenerator(style: .light) equivalente
  • Android: HapticFeedbackConstants.CONTEXT_CLICK (API 23+)
  • Device senza haptic hardware: no-op silenzioso (fire-and-forget)

Un secondo helper _withHaptic(VoidCallback?) wrappa il callback dell’utente, restituendo null se l’originale era null (preserva la semantica di “disabled”). Applicato a:

  • CustomButton (primary, gradient, secondary, outlined)
  • CustomIconButton
  • CustomFloatingActionButton

NON applicato a ButtonType.text perché i link testuali sono azioni “leggere” (dismiss, cancel, toggle) dove un click aptic sarebbe eccessivo.

void _flowTapHaptic() {
  HapticFeedback.selectionClick();
}
 
VoidCallback? _withHaptic(VoidCallback? onPressed) {
  if (onPressed == null) return null;
  return () {
    _flowTapHaptic();
    onPressed();
  };
}

Uso nel build:

final tapHandler = isDisabled ? null : _withHaptic(onPressed);
// …passato a ElevatedButton/OutlinedButton/TextButton/InkWell

2. Press-scale animation (scale 1.0 → 0.96)

Aggiunto _PressableScale, un wrapper StatefulWidget che:

  • Ascolta Listener per onPointerDown/Up/Cancel (non GestureDetector — quest’ultimo consuma gli eventi prima che arrivino al child ElevatedButton).
  • Applica AnimatedScale con scale 0.96 mentre premuto, 1.0 a riposo, curve Curves.easeOut, durata 120ms.
  • Consulta MediaQuery.of(context).disableAnimations (il signal Flutter di reduce-motion, letto da iOS “Reduce Motion” e Android “Remove Animations”).
    • Se l’utente ha reduce-motion attivo → skip dello scale, ma l’haptic rimane.
class _PressableScale extends StatefulWidget {
  final Widget child;
  const _PressableScale({required this.child});
  // …
}
 
class _PressableScaleState extends State<_PressableScale> {
  bool _pressed = false;
  void _setPressed(bool value) { /* setState guard */ }
 
  @override
  Widget build(BuildContext context) {
    final reduceMotion = MediaQuery.of(context).disableAnimations;
    final targetScale = _pressed && !reduceMotion ? 0.96 : 1.0;
    return Listener(
      onPointerDown: (_) => _setPressed(true),
      onPointerUp: (_) => _setPressed(false),
      onPointerCancel: (_) => _setPressed(false),
      child: AnimatedScale(
        scale: targetScale,
        duration: const Duration(milliseconds: 120),
        curve: Curves.easeOut,
        child: widget.child,
      ),
    );
  }
}

3. Architettura: wrapper, non StatefulWidget refactor

Potevamo convertire CustomButton da StatelessWidget a StatefulWidget e gestire _pressed inline. Scelta diversa: _PressableScale come wrapper StatefulWidget applicato al termine del build(). Perché:

  • CustomButton resta StatelessWidget — consumatori non cambiano, no rebuild-churn quando la pagina ricostruisce (il wrapper è isolato).
  • Tutte e tre le factory (CustomButton, CustomIconButton, CustomFloatingActionButton) possono riusare lo stesso wrapper.
  • Il wrapper è escluso per ButtonType.text e isDisabled=true senza bisogno di if-branch dentro uno state.

Il trade-off: un Listener extra nell’albero widget. Trascurabile — il Listener non consuma eventi (non è un gesture arena participant, è solo un observer).

4. Perché Listener e non GestureDetector?

GestureDetector partecipa alla gesture arena di Flutter: il primo detector a vincere un gesto (es. long-press) consuma gli eventi e impedisce ai figli di vederli. Se avessimo messo GestureDetector(onTapDown/Up) attorno a ElevatedButton, il bottone avrebbe perso il proprio ripple + tap (è anch’esso un participant).

Listener invece è un observer passivo: vede tutti gli eventi pointer senza claim-arle. Il ElevatedButton interno continua a ricevere tap normalmente, e noi in parallelo sappiamo quando il dito è giù.

Diff riassuntivo

lib/shared/widgets/custom_button.dart

 import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
 import '../../core/theme/app_theme.dart';
 import '../../l10n/app_localizations.dart';
 
+void _flowTapHaptic() {
+  HapticFeedback.selectionClick();
+}
+
+VoidCallback? _withHaptic(VoidCallback? onPressed) {
+  if (onPressed == null) return null;
+  return () {
+    _flowTapHaptic();
+    onPressed();
+  };
+}
 Widget build(BuildContext context) {
   final theme = Theme.of(context);
   final isDisabled = isLoading || !isEnabled || onPressed == null;
+  final tapHandler = isDisabled ? null : _withHaptic(onPressed);
+  Widget core;
   switch (type) {
     case ButtonType.primary:
-      return ElevatedButton(
+      core = ElevatedButton(
-        onPressed: isDisabled ? null : onPressed,
+        onPressed: tapHandler,

       );
+      break;
     // …altri rami analoghi
   }
+  if (type == ButtonType.text || isDisabled) {
+    return core;
+  }
+  return _PressableScale(child: core);
 }

Perché solo selectionClick e non lightImpact

Flutter espone 5 impact types: selection, light, medium, heavy, vibrate. Abbiamo scelto selectionClick:

  • lightImpact: simula un piccolo urto → adatto per “apertura di qualcosa” (menu, sheet). Un CTA che invia una form sembra un micro-urto fuori contesto.
  • selectionClick: simula lo scatto meccanico di un toggle/picker → semanticamente = “ho registrato il tuo click”. Perfetto per CTA.
  • mediumImpact/heavyImpact: riservati per conferme forti (submit di acquisto, delete irreversibile). In un prossimo pass si potrà upgradarli selettivamente (es. il CTA “Acquista biglietto” → mediumImpact).

Decisioni deferite

  • Haptic upgrade contestuale: i CTA di acquisto, conferma e azioni distruttive meriterebbero mediumImpact invece di selectionClick. Non in scope qui — richiede un’API expansion (CustomButton(hapticStrength: HapticStrength.light|medium|strong)) e un audit di quale CTA è quale. Task futuro.
  • Disable totale dell’haptic tramite setting utente: molte app offrono “Haptics: on/off” nelle Settings. Flow ancora no. Quando lo faremo, _flowTapHaptic() consulterà il setting invece di chiamare sempre HapticFeedback.
  • Ripple personalizzato sul press: il ripple Material è ancora quello di default (ElevatedButton stock). Potremmo sostituirlo con un glow coral/violet per firmare ulteriormente il tocco. Rimandato: il rischio è di over-design.

Verifica

flutter analyze lib/shared/widgets/custom_button.dart
# → No issues found!

Check manuale (su device, NON emulatore — gli emulatori non simulano haptic):

  • Tap su CTA primary/gradient/secondary/outlined → feedback tattile + leggero scale down
  • Tap + drag-off-before-release → feedback solo all’initial press, scale torna a 1.0 su cancel
  • Tap con Settings → Accessibility → Reduce Motion ON → solo haptic, nessuno scale
  • Disabled button → nessun haptic, nessuno scale (il wrapper non viene applicato)
  • ButtonType.text → nessun haptic, nessuno scale (comportamento “link”)

Lessons

  • Feedback tattile è firma di brand. Un brand “energetico” come Flow non può sembrare un SSG statico al tocco. L’haptic è il primo segno che l’app è “viva” quando il dito si appoggia.
  • Listener vs GestureDetector: partecipazione vs osservazione. Confusione comune: si mette un GestureDetector ovunque pensando che “solo ascolti”. Non è vero — partecipa all’arena e può rubare gesti ai figli. Regola flow: se vuoi sapere del gesto ma non consumarlo, usa Listener.
  • Reduce-motion è già risolto da Flutter. MediaQuery.disableAnimations è il signal unificato: iOS UIAccessibility.isReduceMotionEnabled, Android ACCESSIBILITY_REDUCED_ANIMATIONS. Leggerlo costa zero, ignorarlo costa agli utenti con disturbi vestibolari. Non è “optional”.
  • Wrapper > inheritance per polish incrementale. Convertire CustomButton in StatefulWidget significa toccare ogni consumer + preoccuparsi di rebuild-propagation. Un wrapper isolato è diff-small, rollback-safe, e compone con qualsiasi child. Per aggiunte trasversali (haptic, scale, long-press menu), wrapper > extend.
  • Text buttons non sono bottoni. Un ButtonType.text è semanticamente un link. Trattarlo uguale al primary (con haptic + scale) crea rumore: il flusso “Cancel” non deve celebrare il tap come fa “Submit”. Differenziare tocchi per peso semantico è parte della grammatica motion.