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.

QueryMistakeFix
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 → 400current_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 → 404Table 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.

_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, hit event_likes directly with composite-PK upsert / delete.
  • markInterested (in repository) now routes to likeEvent; unmarkInterested routes to unlikeEvent (was: joinEvent with status='interested' and leaveEvent respectively, which deleted the entire attendee row).
  • getEventById queries 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.
  • getInterestedEvents queries event_likes (was: event_attendees WHERE status='interested').
  • EventNotifier.joinEvent / leaveEvent no longer touch the like flag or counter. markInterested / unmarkInterested no 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_countEventStats.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

IssueRealityFix
”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

IssueRoot causeFix
Opening an event the user already RSVPed to still showed the “Partecipa” CTAgetEventById 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 eventsThe 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 reachableTwo 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:

IssueRoot causeFix
Tapping “Partecipa” never flips the button to “Stai partecipando”, and counter inflates on every tapEventNotifier.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/:idSeriesDetailScreen 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-crewsCrewMatchmakingWizard (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 descriptionGroup 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 working OutlinedButton calling markInterested/unmarkInterested but only in the not-attending state.
  • After: single coral heart in the top-right hero overlay (_HeroIconBtn with activeColor). 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 counter events.interested_count is the canonical “likes” metric. No event_likes table was created — this status was already part of the schema.

2. Stats strip — 3 real values (was 4 with 1 fake)

SlotSourceNotes
Attendeesevents.attendee_countDenormalized from event_attendees.status='going'
Likesevents.interested_countDenormalized from event_attendees.status='interested'
Ratingevent_series.avg_rating if series_id != null, else venues.rating if venue_id != null, else omittedNever 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”) when eventDay == today
  • l10n.tomorrowNight (“DOMANI SERA”) when eventDay == today + 1
  • null (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 — shows Affrettati! 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 in ticket_types never 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:

  1. Slots with area_id == null go in a single “General” group at the top (if any)
  2. Then each event_areas row in sort_order ascending, when it has slots
  3. Defensive: orphan area_id values (slot has area_id but no matching event_areas row, e.g. area was deleted) render under l10n.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”), not crewsIncoming (“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 _buildExistingCrewsBlock outlined “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
  • _stripEditionMarker applied to title for parity with the in-app rendering.

Files changed

FileTypeNotes
lib/shared/models/event_area_model.dartnewEventArea, EventLineupSlot plain Dart with fromRow
lib/shared/models/ticket_type_model.dartnewTicketType with isLowStock, isOnSale, isSoldOut, isFree getters; price stored as cents (converted from DB numeric)
lib/core/network/event_api_service.dartextendedgetEventAreas, getEventLineupSlots, getEventTicketTypes — all return const [] on error
lib/features/events/screens/event_details_screen.dartrewrittenAll sections above; +_LineupGroup, _BreadcrumbCover, _RatingContext, _RatingSource private types
lib/shared/services/share_service.dartsimplifiedCompact 4-line message; /e/{id} deep link; edition marker strip
lib/l10n/app_en.arb, app_it.arb, app_es.arbextended+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:

AreaRealityDoc saidAction
event_areas table (2 rows)exists with FK CASCADE, RLS public read + organizer write, sort_order indexedundocumentedDocumented in database-schema.md
event_lineup_slots table (1 row)exists with area_id NULLABLE FK, CHECK end_after_start, RLS like areasundocumentedDocumented in database-schema.md
squads, squad_members0 rows but tables existeddoc 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, privacyall existundocumentedAdded to database-schema.md
ticket_types.pricenumeric(10,2)not flaggedViolates §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 jsonbexists, default []not flaggedLikely 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-07

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-tokensradius12/14/20, color tokens used throughout