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:
| Widget | File | Linee | Call sites | Stato |
|---|---|---|---|---|
MinimalEventCard | minimal_event_card.dart | 584 | 13 | Canonico — design-token aligned, localizzato, varianti {hero, tall, standard, compact} |
EventCard (legacy) | event_card.dart:9 | 460 | 1 (event_discovery_screen.dart:380) | Stringhe IT hardcoded, emoji non-brand, Image.network senza cache |
CompactEventCard | event_card.dart:461 | 110 | 0 (orfano) | Dead code |
HeroEventCard | hero_event_card.dart | 249 | 1 (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:
MinimalEventCardusaAppLocalizations.of(context)!.friendsGoing(n);HeroEventCardusa"$friendsAttending amici vanno"hardcoded in IT. EventCardeHeroEventCardesponevano callbackonRSVP/onSharedirettamente sulla row — feature ridondante con la detail screen e che competeva con il gesto “tap to browse”.
Decisioni
| Elemento | Status | Decisione |
|---|---|---|
MinimalEventCard | Canonico | Esteso — aggiunti featured: bool e rendering social-proof anche nella variante hero |
EventCard (event_card.dart) | Legacy, 1 consumer | Eliminato — consumer migrato a MinimalEventCard(variant: standard) |
CompactEventCard | Orfano (0 consumer) | Eliminato |
HeroEventCard | Legacy, 1 consumer | Eliminato — consumer migrato a MinimalEventCard(variant: hero, featured: true) |
onRSVP / onShare sulle list-row cards | Dropped | RSVP/share vivono solo sulla detail screen; la list row è per browsing |
| Altezza 400 px del hero | Estratta dal widget | Ora 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):
- Featured badge (
chosenForYou) — solo sefeatured: true. Pill coral con typography 11/800/letterSpacing 0.08. - Social proof pill — facepile +
friendsGoing(n)sefriendsAttending > 0; iconapeople_outline+beFirstOfCrewaltrimenti. Pill semi-trasparente (white @ 15%con borderwhite @ 30%). _buildDateBadge(giorno/mese bianco su pill bianca).- Titolo + tier icon (verified / official).
- Location + price tag sulla stessa riga.
Questa struttura replica il layout di HeroEventCard ma usando:
AppTheme.electricCoral/AppTheme.radius8invece diColor(0xFFFF5E57)/BorderRadius.circular(8).AppLocalizationsinvece di stringhe IT hardcoded._buildFacepile(placeholder colorati deterministici) invece diNetworkImage('https://i.pravatar.cc/100').
Diff riassuntivo
lib/shared/widgets/minimal_event_card.dart
- Aggiunto param
featured: bool = false. - Esteso
_buildHeroCardcon 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: rimossoexclude: 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”.
MinimalEventCardaveva la varianteheroda mesi, maHeroEventCardcontinuava a esistere accanto ad essa. Perché? Perché il primo consumer dihero(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.herooggi è un layout primitive (nessuna altezza, nessun margin), mentreHeroEventCardera 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 customavoid_hardcoded_stringso un grep"[\w ]+"[,)]nella CI pipeline su widget files. - Orfani si nascondono dentro file non-orfani:
CompactEventCardstava nello stesso file diEventCard— un grepCompactEventCardtrovava 0 consumer, ma il file non era orfano (era referenziato inanalysis_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 fattofeaturedText: String(passando la stringa dall’esterno), avremmo re-introdotto il problema di l10n drift — il call site avrebbe potuto dimenticareAppLocalizations. API flag + l10n interna = single source of truth per il copy.