Chore — Unificazione event card

Commit: chore(widgets): consolidate event cards into MinimalEventCard; drop EventCard + HeroEventCard + CompactEventCard · Branch: edit/mobile-audit-ux-polish · Audit: 2026-04-20 UX polish (finding L2, esteso)

Cosa non andava

L’audit (L2) aveva marcato “varianti multiple di EventCard che non condividono API/stile”. La verifica ha confermato 4 widget distinti con responsabilità sovrapposte:

WidgetFileLineeCall sitesStato
MinimalEventCardminimal_event_card.dart58413Canonico — design-token aligned, localizzato, varianti {hero, tall, standard, compact}
EventCard (legacy)event_card.dart:94601 (event_discovery_screen.dart:380)Stringhe IT hardcoded, emoji non-brand, Image.network senza cache
CompactEventCardevent_card.dart:4611100 (orfano)Dead code
HeroEventCardhero_event_card.dart2491 (feed_screen.dart:929)Stringhe IT hardcoded, fontFamily: 'Outfit' hardcoded, facepile con NetworkImage('https://i.pravatar.cc/100') placeholder

Il problema era la trappola cognitiva del polymorphism via duplicazione:

  • Tre classi con nome simile in autocomplete (EventCard, HeroEventCard, MinimalEventCard) → developer non sa quale importare.
  • Stili divergenti → lo stesso evento appare con typography/spacing/padding diversi a seconda dello screen.
  • L10n coverage disomogenea: MinimalEventCard usa AppLocalizations.of(context)!.friendsGoing(n); HeroEventCard usa "$friendsAttending amici vanno" hardcoded in IT.
  • EventCard e HeroEventCard esponevano callback onRSVP/onShare direttamente sulla row — feature ridondante con la detail screen e che competeva con il gesto “tap to browse”.

Decisioni

ElementoStatusDecisione
MinimalEventCardCanonicoEsteso — aggiunti featured: bool e rendering social-proof anche nella variante hero
EventCard (event_card.dart)Legacy, 1 consumerEliminato — consumer migrato a MinimalEventCard(variant: standard)
CompactEventCardOrfano (0 consumer)Eliminato
HeroEventCardLegacy, 1 consumerEliminato — consumer migrato a MinimalEventCard(variant: hero, featured: true)
onRSVP / onShare sulle list-row cardsDroppedRSVP/share vivono solo sulla detail screen; la list row è per browsing
Altezza 400 px del heroEstratta dal widgetOra responsabilità del parent (SizedBox(height: 400) in feed_screen.dart) — il widget resta layout-agnostic

L10n

Due nuove chiavi (già usate come stringhe hardcoded in HeroEventCard, ora localizzate):

// app_it.arb
"chosenForYou": "SCELTO PER TE",
"beFirstOfCrew": "Sii il primo della tua crew",
 
// app_en.arb
"chosenForYou": "CHOSEN FOR YOU",
"beFirstOfCrew": "Be the first of your crew",
 
// app_es.arb
"chosenForYou": "ELEGIDO PARA TI",
"beFirstOfCrew": "Sé el primero de tu crew",

Microcopy convention: crew (singular, always lowercase inside sentence) — coerente con launchYourCrew, myCrewLabel, leaveCrewQuestion già nel repo. Nessuna pluralizzazione (crews solo in crewsNearYou come plurale di insegna, non di entità primary).

API estesa di MinimalEventCard

class MinimalEventCard extends StatelessWidget {
  final Event event;
  final MinimalEventCardVariant variant;   // hero | tall | standard | compact
  final VoidCallback? onTap;
  final VoidCallback? onLongPress;
  final int friendsAttending;              // 0 = nessun amico
  final bool featured;                     // NEW — solo hero variant
}

La variante hero ora renderizza (dall’alto verso il basso, allineato a sinistra):

  1. Featured badge (chosenForYou) — solo se featured: true. Pill coral con typography 11/800/letterSpacing 0.08.
  2. Social proof pill — facepile + friendsGoing(n) se friendsAttending > 0; icona people_outline + beFirstOfCrew altrimenti. Pill semi-trasparente (white @ 15% con border white @ 30%).
  3. _buildDateBadge (giorno/mese bianco su pill bianca).
  4. Titolo + tier icon (verified / official).
  5. Location + price tag sulla stessa riga.

Questa struttura replica il layout di HeroEventCard ma usando:

  • AppTheme.electricCoral / AppTheme.radius8 invece di Color(0xFFFF5E57) / BorderRadius.circular(8).
  • AppLocalizations invece di stringhe IT hardcoded.
  • _buildFacepile (placeholder colorati deterministici) invece di NetworkImage('https://i.pravatar.cc/100').

Diff riassuntivo

lib/shared/widgets/minimal_event_card.dart

  • Aggiunto param featured: bool = false.
  • Esteso _buildHeroCard con blocchi featured badge + social proof (l10n-aware).
  • Nessun breaking change — tutti i call sites esistenti continuano a funzionare.

lib/features/events/screens/event_discovery_screen.dart

-import '../../../shared/widgets/event_card.dart';
+import '../../../shared/widgets/minimal_event_card.dart';
-import '../../../shared/services/share_service.dart';
 
-  final ShareService _shareService = ShareService();
-
   // ...
-  return EventCard(
-    id: event.id,
-    title: event.title,
-    imageUrl: event.imageUrl,
-    startTime: event.startDate,
-    venueName: event.location.name,
-    category: event.category.name,
-    attendeeCount: event.stats.attendeeCount,
-    friendsGoing: event.stats.friendsGoingCount,
-    isGoing: event.isUserAttending,
-    isFeatured: index == 0 && _selectedFilter == 'all',
-    price: event.price,
-    onTap: () => _openEventDetails(context, event.id, event),
-    onRSVP: () => _handleRSVP(event),
-    onShare: () => _shareEvent(event),
-  );
+  return Padding(
+    padding: const EdgeInsets.only(bottom: AppTheme.space16),
+    child: MinimalEventCard(
+      event: event,
+      variant: MinimalEventCardVariant.standard,
+      friendsAttending: event.stats.friendsGoingCount,
+      onTap: () => _openEventDetails(context, event.id, event),
+    ),
+  );

Rimossi inoltre _handleRSVP(event) e _shareEvent(event) — orfani dopo la migrazione.

lib/features/feed/screens/feed_screen.dart

-import 'package:flow_app/shared/widgets/hero_event_card.dart';
 
-  child: HeroEventCard(
-    event: heroEvent!,
-    friendsAttending: 4,
-    onTap: () => context.push(...),
-  ),
+  child: ClipRRect(
+    borderRadius: BorderRadius.circular(AppTheme.radius24),
+    child: SizedBox(
+      height: 400,
+      child: MinimalEventCard(
+        event: heroEvent!,
+        variant: MinimalEventCardVariant.hero,
+        featured: true,
+        friendsAttending: 4,
+        onTap: () => context.push(...),
+      ),
+    ),
+  ),

Il ClipRRect è esplicito perché MinimalEventCard.hero non clippa la propria image — riflette la preferenza “layout-agnostic: il parent decide lo shape”.

Rimosso

  • lib/shared/widgets/event_card.dart (460 + 110 = 570 righe, 2 classi)
  • lib/shared/widgets/hero_event_card.dart (249 righe, 1 classe)
  • analysis_options.yaml: rimosso exclude: lib/shared/widgets/event_card.dart (non c’è più il file da escludere)

Totale eliminato: 819 righe.

Perché droppare onRSVP / onShare dalla list-row card

Domanda legittima: la viral loop beneficia da “join with one tap” — perché rimuovere il bottone RSVP dalla riga della lista?

Risposta UX:

  • Un CTA primario su ogni riga compete con il gesto di tap principale (“browse → detail”). Ogni riga che espone un’azione principale ambigua aumenta il cognitive load.
  • L’azione “vai → leggi → decidi” è più allineata al comportamento reale degli utenti su event discovery (scroll browsing + considered commit), mentre il pattern “tap-to-join” funziona su app di intrattenimento passivo (like su post).
  • La detail screen è già la stessa che apre dal feed, dalla notifica push, dal deep link e dal radar — RSVP lì è single source of truth. Duplicarlo sulla row crea drift (e.g. quale snackbar mostrare? come sincronizzare state?).
  • Share è ancora utile, ma sulla detail screen (dove l’utente ha già contesto visivo — foto, descrizione, data — da condividere). Share dalla row richiede all’utente di decidere “vale la pena condividere?” senza aver ancora letto cosa sta condividendo.

Risultato: ShareService resta invariato (usato dalla detail screen); _handleRSVP resta nel notifier (usato dalla detail screen). Solo il fanout sulla row è stato rimosso.

Perché SizedBox(height: 400) al call site invece di bakearla nel widget

Il vecchio HeroEventCard aveva Container(height: 400, margin: EdgeInsets.symmetric(horizontal: 24)) hardcoded. Convenient al primo call site, ma:

  • Impossibile riusare il widget in contesti con altezza diversa (feed carousel, profile grid, modal).
  • Il margin orizzontale nel widget leaka layout context — il parent non sa dove le sue edges finiscono.
  • Rendeva il widget non-composable con SliverAppBar / PreferredSize / stacked panes.

La nuova versione:

  • Il widget è puro Stack(fit: StackFit.expand) — si adatta a qualsiasi constraint.
  • Il parent decide altezza + padding + margin + border radius (tramite ClipRRect).
  • Un domani, se servisse un hero più piccolo (e.g. 240px), basta wrappare in SizedBox(height: 240) — senza toccare il widget.

Trade-off accettato: il call site è più verboso (SizedBox + ClipRRect + MinimalEventCard). In cambio, MinimalEventCard è reusable in più contesti e ha meno sorprese layout-wise.

Verifica

flutter pub get
flutter gen-l10n
# → app_localizations*.dart rigenerati con chosenForYou + beFirstOfCrew
 
flutter analyze
# → 1 pre-existing info (squad_detail_sheet.dart context-across-async)
 
flutter test test/widget/features/onboarding/profile_setup_screen_test.dart test/unit/
# → 45 tests passed (44 unit + widget onboarding)

Lessons

  • “Variant system already exists” non significa “variant system already used”. MinimalEventCard aveva la variante hero da mesi, ma HeroEventCard continuava a esistere accanto ad essa. Perché? Perché il primo consumer di hero (feed_screen) aveva feature — featured badge + social proof — che la variante non supportava. La decisione “creare HeroEventCard” invece di “estendere MinimalEventCard.hero” sembrava più veloce sul momento ma ha creato debt che è costato 3 mesi di drift prima di essere raccolto. Rule of thumb: prima di creare un nuovo widget che sembra simile a uno esistente, chiediti “posso estendere quello?” — anche a costo di 20 righe di codice in più oggi.
  • Layout primitives vs. finished widgets: MinimalEventCard.hero oggi è un layout primitive (nessuna altezza, nessun margin), mentre HeroEventCard era un finished widget (altezza bakeda, margin orizzontale). I layout primitive sono più verbosi al call site ma più riusabili. Per widget che vivono in molti contesti (feed, carousel, grid), preferire sempre il primitive.
  • L10n drift è un red flag silente: due dei tre widget rimossi avevano stringhe IT hardcoded. Nessun test le catturava perché non c’erano assertions su AppLocalizations. Lesson: tenere attivo un lint custom avoid_hardcoded_strings o un grep "[\w ]+"[,)] nella CI pipeline su widget files.
  • Orfani si nascondono dentro file non-orfani: CompactEventCard stava nello stesso file di EventCard — un grep CompactEventCard trovava 0 consumer, ma il file non era orfano (era referenziato in analysis_options.yaml). Controllare sempre al livello di classe, non di file, per identificare dead code.
  • Feature flag vs API flag: featured: bool è un API flag: il widget decide come renderizzare. Se lo avessimo fatto featuredText: String (passando la stringa dall’esterno), avremmo re-introdotto il problema di l10n drift — il call site avrebbe potuto dimenticare AppLocalizations. API flag + l10n interna = single source of truth per il copy.