Summary
End-to-end refactor of lib/features/events/screens/event_details_screen.dart (≈700 lines touched). Replaces hardcoded/fake data with real Supabase tables; consolidates duplicated UI; and aligns the like model to event_attendees.status='interested' (no new tables required).
Branch: edit/mobile-audit-ux-polish · Date: 2026-05-07 (initial), 2026-05-07 hotfix.
2026-05-08 hotfix — schema misalignments
The “venue/series not loading” report turned out NOT to be a race condition (the previous hotfix’s await + ref.listen + post-frame triple-trigger was fine). The real culprits were 3 schema misalignments surfaced by debug logging.
| Query | Mistake | Fix |
|---|---|---|
venues select=…,city → 400 “column venues.city does not exist” | The city is inside the location jsonb, not a top-level column. | Select location instead and extract city client-side, surfacing it on the row map so existing breadcrumb code (venue['city']) keeps working. |
crews select=…,current_size → 400 | current_size is NOT a column on crews — it’s the count of crew_members for that crew. | Use PostgREST aggregate join: select=…,crew_members(count). Collapse the nested [{count: N}] to an int and stamp it onto the row as current_size. |
posts table → 404 | Table doesn’t exist in the live schema. | _loadCommunityPosts is now a documented no-op. The widget consuming this state was already commented out of the build. Re-enable when the posts feature ships. |
Also: the breadcrumb load was wrapped in a SINGLE try/catch around both venue+series queries, so the 400 from venues aborted the whole block and silently dropped the (otherwise successful) series fetch. Each query now has its own try/catch.
flutter analyze: 0 issues.
2026-05-08 hotfix — venue/series race + thumb-friendly actions
Series invisible for events with series_id set
_loadVenueAndSeries used await Future.delayed(Duration.zero) then read the event from state — a microtask yield that does NOT wait for loadEventDetails to complete. Result: event was usually null at read time, the loader returned early, and _venue / _series stayed null forever. Confirmed against DB: 5 La Prosperosa events have valid series_id, but the breadcrumb never rendered.
Fix: initState now awaits loadEventDetails, then triggers _loadVenueAndSeries and _loadPastEditions (the two loaders that need the event row in state). Independent loaders (community posts, crews, areas, ticket types, attendees) still fire in parallel — they don’t depend on the event.
Like + share moved out of the hero
The corner buttons over the hero photo were far from a phone user’s resting thumb. Replaced with a wide 2-column action row (_buildActionsRow) below the stats strip, using fixed-44pt-tall pills (_ActionPill) that clear iOS HIG / Material 3 minimum tap target. The like pill flips between coral-outline and coral-filled to communicate state. Share pill stays neutral.
Top hero now only has the back button — cleaner editorial aesthetic, all interactive surfaces below the fold.
Breadcrumb covers proxied on web
_BreadcrumbCover (venue / series cover image) now routes through _imageUrlForPlatform — same weserv.nl proxy used for the hero image. Without this, venue images from any non-CORS host show as fallback icons on Flutter web/CanvasKit.
New l10n keys: likeActionLabel, likedActionLabel, shareActionLabel.
flutter analyze: 0 issues.
2026-05-08 — Decouple like from attendance
Migration: decouple_event_likes_from_attendance (live).
The earlier model mapped “Like” to event_attendees.status='interested'. Because the table has UNIQUE on (event_id, user_id) with a single status column, attendance and like were structurally mutually exclusive — going to an event silently erased the user’s like, and vice-versa. This was a pragmatic shortcut taken in v3 to avoid a new table; the user flagged it as a UX problem on 2026-05-08 and we decoupled.
DB changes
CREATE TABLE event_likes (
user_id UUID FK → profiles ON DELETE CASCADE,
event_id UUID FK → events ON DELETE CASCADE,
created_at TIMESTAMPTZ,
PRIMARY KEY (user_id, event_id)
);
ALTER TABLE events ADD COLUMN like_count INTEGER NOT NULL DEFAULT 0;
-- Trigger event_likes_count_trg maintains events.like_count on INSERT/DELETE.RLS: public SELECT (popularity is a public signal); INSERT/DELETE only on own row (user_id = auth.uid()). FORCE RLS enabled.
Backfill: every existing event_attendees row with status='interested' was migrated to event_likes (preserving created_at). Then those event_attendees rows were deleted — that table now holds only RSVP states (going, not_going). Live counts post-migration: 11 likes, 0 leftover interested attendees, like_count denormalized matches.
events.interested_count is left in place (legacy data, not maintained by any trigger) but the app no longer reads it. The mapper now reads like_count, falling back to interested_count only if like_count is missing — so the field is safe to drop in a future cleanup.
App changes
EventApiService.likeEvent(id)/unlikeEvent(id)— new methods, hitevent_likesdirectly with composite-PK upsert / delete.markInterested(in repository) now routes tolikeEvent;unmarkInterestedroutes tounlikeEvent(was:joinEventwithstatus='interested'andleaveEventrespectively, which deleted the entire attendee row).getEventByIdqueries 3 things in parallel: event row, user’s attendee row, user’s like row. Both flags (is_user_attending,is_user_interested) reflect independent rows.getInterestedEventsqueriesevent_likes(was:event_attendees WHERE status='interested').EventNotifier.joinEvent/leaveEventno longer touch the like flag or counter.markInterested/unmarkInterestedno longer touch attendance. Each toggle is independent and idempotent at the local-state level.
Naming note
Internally the Dart code keeps interestedCount / isUserInterested / markInterested / unmarkInterested for now (46 call-sites would need renaming to likeCount / isUserLiked etc.). The mapper translates events.like_count → EventStats.interestedCount so the rename can happen in a separate cosmetic pass without affecting behavior.
flutter analyze: 0 issues.
2026-05-07 hotfix #3 — empty areas, image loader, ticket data check
| Issue | Reality | Fix |
|---|---|---|
| ”Vedo solo un’area” | DB had 2 event_areas rows (Piano 1, Piano 2) but only 1 event_lineup_slots row, in Piano 1. The lineup section’s gating if (slots != null && slots.isNotEmpty) hid every area without slots. | Lineup now renders every event_areas row in sort_order, regardless of slot count. Areas with zero slots show their description + a lineupTbaForArea (TBA / “In programmazione”) placeholder. The section is gated on `_areas.isNotEmpty |
| ”Non vedo l’immagine dell’evento” | events.image_url was a valid HTTPS URL (baiaimperiale.net). Image.network was failing silently — no loading state, no logged error, errorBuilder rendered the same coral placeholder as “no image”, so the failure was indistinguishable from missing data. | Image rendering now: (a) gates on imageUrl.isNotEmpty (was just != null), (b) shows a loading spinner via loadingBuilder while the request is in flight, (c) sends a browser-like User-Agent header (some hosts reject default Dart UA), (d) debugPrints on error so the exact failure URL + reason is visible in the dev console. |
| ”Vedo solo un biglietto, ma il collega ne aveva inseriti più” | DB query: 0 rows in ticket_types for this event. The colleague did not actually insert any rows. | No code change needed — the screen correctly falls back to the synthetic ticket card driven by events.price_cents (added in hotfix #2). When the colleague inserts the rows for real, the section will iterate them automatically. |
New l10n key: lineupTbaForArea (en: “TBA”, it: “In programmazione”, es: “Por anunciar”).
2026-05-07 hotfix #2 — first-paint state + venue route + ticket fallback
| Issue | Root cause | Fix |
|---|---|---|
| Opening an event the user already RSVPed to still showed the “Partecipa” CTA | getEventById queried events only — the user’s event_attendees row was never joined, so is_user_attending / is_user_interested defaulted to false. The notifier could only set those flags after a tap. | getEventById now does the events row + the user’s event_attendees row in parallel via Future.wait. The status string ('going' / 'interested') gates the two flags. The auxiliary query failing never blocks the event from loading. _mapSupabaseEventToEvent now reads is_user_attending / is_user_interested from the merged map. |
| Tickets section disappeared for most events | The v3 gate was _ticketTypes.isNotEmpty — most events don’t have rows in ticket_types yet, so the section was hidden even when events.price_cents was set. | Gate now also fires on !event.isFree or non-empty event.externalUrl. New _buildFallbackTicketCard renders a single synthetic row priced from event.price_cents. The real-tickets path is unchanged when ticket_types rows exist. |
| Venue breadcrumb wasn’t reachable | Two layered bugs: (a) Event model had no venueId field, so the API mapper dropped events.venue_id. (b) _loadVenueAndSeries was reading event.metadata['venue_id'] which is the wrong column entirely (venue_id is a top-level column on events, not nested in metadata jsonb). | Added venueId to Event/copyWith/constructor, _mapSupabaseEventToEvent now reads data['venue_id'], and the loader uses event.venueId. Existing /venue/:venueId route already wired. |
Build: re-ran flutter pub run build_runner build --delete-conflicting-outputs to regenerate event_model.g.dart for the new venueId field. flutter analyze: 0 issues.
2026-05-07 hotfix
After the initial rewrite a few user-reported regressions surfaced. All fixed in a follow-up commit:
| Issue | Root cause | Fix |
|---|---|---|
| Tapping “Partecipa” never flips the button to “Stai partecipando”, and counter inflates on every tap | EventNotifier.joinEvent/leaveEvent did not pass isUserAttending: true/false to Event.copyWith (it took bool? so omitting kept the old value). Each tap re-incremented the local attendeeCount despite the server’s UPSERT being idempotent. | All four toggles (joinEvent, leaveEvent, markInterested, unmarkInterested) now (a) pass the explicit attending/interested flag, (b) skip the local counter mutation when already in the target state (idempotent guard), (c) cross-correct the sibling counter when status transitions e.g. interested→going. |
GoException: no routes for location: /series/:id | SeriesDetailScreen existed but was never registered in the router. | Added top-level GoRoute(path: '/series/:seriesId', …) in app_router.dart. |
GoException: no routes for location: /social/crews/create?eventId=… | The breadcrumb used a path that didn’t match the registered route (/social/create-crew). | Empty-state CTA now uses /social/create-crew?eventId=…. |
”See all crews” button hit a non-existent /social/crews?… | No crew listing/discovery route existed. | Registered /social/find-crews → CrewMatchmakingWizard (already implemented, just unwired). The “see all” CTA + per-crew tap now both go there until a dedicated crew-detail route lands. |
| Lineup didn’t show area description | Group header rendered only area.name. | _LineupGroup extended with description; _buildLineupGroup renders event_areas.description under the area title when set. Slots without artist already rendered (title + description, artist conditional) — confirmed working. |
This pass supersedes feature-event-details-v2 for the sections below — that earlier doc remains valid for the friends-first attendees list and “external ticket URL” behavior, both still active.
What changed
1. Like (interested) — unified, optimistic, persisted
- Before: two heart buttons. Top-right hero overlay had
onTap: () {}(dead button). Bottom bar had a workingOutlinedButtoncallingmarkInterested/unmarkInterestedbut only in the not-attending state. - After: single coral heart in the top-right hero overlay (
_HeroIconBtnwithactiveColor). Optimistic local toggle (_likeOverride: bool?) updates UI before the network round-trip; reverts on failure. The bottom bar no longer carries a heart — single source of truth. - Persistence:
event_attendees.status = 'interested'. The denormalized counterevents.interested_countis the canonical “likes” metric. Noevent_likestable was created — this status was already part of the schema.
2. Stats strip — 3 real values (was 4 with 1 fake)
| Slot | Source | Notes |
|---|---|---|
| Attendees | events.attendee_count | Denormalized from event_attendees.status='going' |
| Likes | events.interested_count | Denormalized from event_attendees.status='interested' |
| Rating | event_series.avg_rating if series_id != null, else venues.rating if venue_id != null, else omitted | Never the per-event events.avg_rating (too noisy on individual instances) |
Removed: viewCount + 142 “Hype” stat, friendsGoing.length “Crew” stat duplicate, event.avgRating “Karma” stat. Removed: views/shares chips in _buildHeader (also viewCount + 142, shareCount + 12 — both fake offsets).
3. “Live tonight” chip — date-aware
Helper _liveChipText(BuildContext, DateTime) returns:
l10n.liveTonight(“LIVE STASERA”) wheneventDay == todayl10n.tomorrowNight(“DOMANI SERA”) wheneventDay == today + 1null(chip hidden) otherwise
The chip was previously a hardcoded '● LIVE STASERA' rendered for every event regardless of date.
4. Title cleanup — strips edition markers
_cleanTitle(String) strips trailing #NN patterns (with optional -/–/— separator) at the end of the title. “La Prosperosa #12” renders as “La Prosperosa” in the hero. Same regex is duplicated in share_service.dart::_stripEditionMarker for share consistency.
5. Header dedupe
_buildHeader no longer renders the title — it was already in the SliverAppBar at line 573. Now renders only:
- Tier pill (verified/official/community)
- Max-attendees pill (when set)
- Free-form interest tags
Removed: duplicate title Text(event.title), fake viewsCount/sharesCount stat chips, _buildStatChip helper (also removed — unused).
6. Venue + series breadcrumb (replaces creator)
_buildVenueSeriesBreadcrumb(Event) — new section that replaces _buildOrganizerSection. Shows two clickable rows when present:
[venue cover] LOCALE
The Prosperosa · Milano ›
─────────────────────────────────────────────
[series cover] SERIE
La Prosperosa ›
Loaders pull venues(id, name, image_url, rating, review_count, city) and event_series(id, name, slug, cover_image_url, avg_rating, total_ratings, total_editions) in parallel during initState. Section returns SizedBox.shrink() when both are null. The follow-organizer button + _toggleFollowOrganizer + _loadFollowState + _isFollowingOrganizer are gone (also removed UserApiService import — unused).
7. Tickets — driven by real ticket_types
_buildTicketSection + _buildTicketCard(Event, TicketType):
- Iterates over
_ticketTypes(loaded from API in_loadTicketTypes) - Each card shows: name, optional description, price, status indicator
- Status precedence:
soldOut>isLowStock>!onSale> clean - Low-stock threshold:
remaining <= ceil(quantity_total * 0.1) && remaining > 0— showsAffrettati! N posti rimasti(l10n.ticketLowStockUrgent) - Border tint: warm-sun when low-stock, otherwise dividerLight
- Buy button disabled when
soldOut || !onSale - Section is gated at the call site by
_ticketTypes.isNotEmpty— events with no rows inticket_typesnever render the section
Removed: hardcoded single ticket with stock: 50, // In production, fetch from backend, the getsEarlyBird 10%-off-for-level-10+ logic (no spec, looked like a placeholder), and the legacy single-Container layout.
8. Lineup — driven by event_areas + event_lineup_slots
_buildLineupSection queries the two tables in parallel and groups slots by area_id:
- Slots with
area_id == nullgo in a single “General” group at the top (if any) - Then each
event_areasrow insort_orderascending, when it has slots - Defensive: orphan
area_idvalues (slot has area_id but no matchingevent_areasrow, e.g. area was deleted) render underl10n.lineupUnknownArea
Each slot row: HH:mm (and optional → HH:mm end time) | title | artist (coral subtitle) | description.
Replaces the legacy event.lineup jsonb rendering — which used name/role/time keys not actually populated in production. The legacy column is not dropped in this pass (it may have data in older rows); see “Inconsistencies” below.
9. Crews — three explicit states
_buildCrewsSection rewrite:
- Header copy:
l10n.crewsHeader(“Crew”), notcrewsIncoming(“Crew in arrivo”) — section is fully implemented. - State 1 — user already in a crew for this event:
_buildUserCrewCard(kept). - State 2 — crews exist for this event: show first 2 inline with
_buildCrewListCard(compact row + chevron), then_buildExistingCrewsBlockoutlined “See all crews (+N)” CTA. - State 3 — no crews yet:
_buildCreateCrewCta— single coral-tinted card; tapping navigates straight to/social/crews/create?eventId=....
Removed: _buildFindCrewBanner (legacy generic banner, no longer used).
10. Bottom bar — solid white + thin border
The previous bar had gradient: [transparent → pureWhite] stops [0.0, 0.3] which made scrolling content show through the upper portion as a dirty grey edge. New bar: solid pureWhite + Border(top: BorderSide(color: dividerLight, width: 1)). Three states: locked (gray unlock CTA), attending (label + outlined “Abbandona”), default (price + coral “Partecipa”). The duplicate heart was removed from the default row (top sliver carries the only heart).
11. Share — compact, locale-aware
share_service.dart:
- URL path shortened:
/events/{id}→/e/{id}(web router maps both) - Removed: 6-line message with
🔥, social proof (👥 X amici stanno andando), price emoji, hashtag block (#Flow #${category} #${city}). - New format (4 lines):
title/Day · HH:mm · Venue/Free | €X.XX/link _stripEditionMarkerapplied to title for parity with the in-app rendering.
Files changed
| File | Type | Notes |
|---|---|---|
lib/shared/models/event_area_model.dart | new | EventArea, EventLineupSlot plain Dart with fromRow |
lib/shared/models/ticket_type_model.dart | new | TicketType with isLowStock, isOnSale, isSoldOut, isFree getters; price stored as cents (converted from DB numeric) |
lib/core/network/event_api_service.dart | extended | getEventAreas, getEventLineupSlots, getEventTicketTypes — all return const [] on error |
lib/features/events/screens/event_details_screen.dart | rewritten | All sections above; +_LineupGroup, _BreadcrumbCover, _RatingContext, _RatingSource private types |
lib/shared/services/share_service.dart | simplified | Compact 4-line message; /e/{id} deep link; edition marker strip |
lib/l10n/app_en.arb, app_it.arb, app_es.arb | extended | +22 keys (live chip, stats, ticket statuses, crew states, bottom bar labels, lineup area headers, venue/series labels) |
Inconsistencies discovered
The DB recon for this refactor surfaced several drift items. Logged here so future passes can address them:
| Area | Reality | Doc said | Action |
|---|---|---|---|
event_areas table (2 rows) | exists with FK CASCADE, RLS public read + organizer write, sort_order indexed | undocumented | Documented in database-schema.md |
event_lineup_slots table (1 row) | exists with area_id NULLABLE FK, CHECK end_after_start, RLS like areas | undocumented | Documented in database-schema.md |
squads, squad_members | 0 rows but tables existed | doc said “do not exist” | Dropped in migration drop_squads_tables_use_crews_only (2026-05-07). Mobile Squad/SquadService classes still exist but already point to the crews table internally — naming inconsistency, not a runtime issue. |
events.avg_rating, review_count, lineup (jsonb), past_edition_photos, disclaimers, min_level, privacy | all exist | undocumented | Added to database-schema.md |
ticket_types.price | numeric(10,2) | not flagged | Violates §2.2 No-Float monetary rule. Should be price_cents INTEGER. Tracked in database-schema tech debt. Read path tolerates it via (price * 100).round(). |
events.lineup jsonb | exists, default [] | not flagged | Likely legacy — superseded by event_lineup_slots table. Kept for now; may be dropped after confirming no production rows depend on it. |
Build/verify
flutter pub get
flutter gen-l10n
flutter analyze # 0 issues — verified 2026-05-07Related
database-schema — DB tables documented here
feature-event-details-v2 — earlier pass (still active for friends-first attendees + external ticket URL)
feat-event-series — series detail screen, complementary navigation target
localization — l10n keys reference
refactor-theme-tokens — radius12/14/20, color tokens used throughout