Refactor — Feed Screen God-Screen Split (M4 / Rounds 3 & 4)

Date (Round 3): 2026-04-22 · Date (Round 4): 2026-04-24 Mobile branch: edit/mobile-audit-ux-polish · Docs branch: edit/web-brand-parity-docs Round 3 commit: refactor(feed): extract 4 widgets from god-screen feed_screen.dart Round 4 commit: refactor(feed): extract FeedLocationSheet and FeedDateSheet Round 5 commit: refactor(feed): extract FeedListContent from god-screen feed_screen.dart Round 6 commit: refactor(feed): extract vibe-chip computation into feedVibeChipsProvider


Why

feed_screen.dart was 1,409 lines across two stateful widget classes and housed four distinct UI concerns in a single file:

  1. A complete _AdvancedFilterSheet (bottom sheet widget — 216 lines)
  2. A notification bell with badge (50 lines)
  3. A map/list two-state toggle (75 lines)
  4. A composite subheader row (date + city dropdowns + toggle — 80 lines)
  5. A dead _buildHeaderIcon helper (15 lines, marked // ignore: unused_element)

Keeping them inline had three costs:

  • Rebuild surface too large. A setState anywhere in _FeedScreenState (e.g., changing _selectedVibe) triggered a rebuild that touched all of these sub-trees. Extracting them as ConsumerWidget / StatelessWidget lets Flutter’s element tree short-circuit rebuilds at the right boundary.
  • Hard to find and reason about. Searching notification or filter in the file returned inline builder methods, not named types. Now each concern is a named file with its own doc comment and import graph.
  • Harder to unit-test. Widget tests for the toggle or the filter sheet had to pump the full 1,400-line screen rather than the individual widget.

What changed

Deleted from feed_screen.dart

Method removedLinesReplacement
_buildNotificationBell()48FeedNotificationBell
_buildHeaderIcon()15deleted (dead code, // ignore: unused_element)
_buildMapListToggle()75FeedMapToggle
_buildSubheader()80FeedSubheader
_defaultDateFilter()4Inlined in initState; static in FeedSubheader
_AdvancedFilterSheet + state216AdvancedFilterSheet
Unused flutter_animate import1Removed

Net result: 1,409 → 971 lines (−438, −31%)

New files

FileLinesWidget typeState
features/feed/widgets/advanced_filter_sheet.dart219StatefulWidgetLocal PriceFilter selection
features/feed/widgets/feed_notification_bell.dart62ConsumerWidgetWatches unreadNotificationsCountProvider
features/feed/widgets/feed_map_toggle.dart86StatelessWidgetStateless; callbacks for page navigation
features/feed/widgets/feed_subheader.dart140ConsumerWidgetWatches eventNotifierProvider for date filter

Design decisions

AdvancedFilterSheet: public, not part of

The original class was _AdvancedFilterSheet (library-private). The extraction makes it AdvancedFilterSheet (public). The alternative — using part/part of to keep the leading underscore — would have tightly coupled the files at the language level and broken IDE “go to definition”. Public + documented is cleaner.

FeedMapToggle: callbacks, not PageController

_buildMapListToggle accessed _pageController directly (an implementation detail of _FeedScreenState). FeedMapToggle receives onShowList and onShowMap callbacks instead. The parent thread the _pageController.animateToPage call:

FeedMapToggle(
  showMap: _showMap,
  onShowList: () => _pageController.animateToPage(0, ...),
  onShowMap: () => _pageController.animateToPage(1, ...),
)

This inverts the dependency: the toggle widget doesn’t know PageController exists. Future navigation change (e.g., TabController) only touches the parent, not the toggle widget.

FeedSubheader: embeds FeedMapToggle

The subheader row contains the toggle visually, so FeedSubheader embeds FeedMapToggle rather than exposing the toggle separately at screen level. This produced a clean side-effect: feed_screen.dart no longer needs to import feed_map_toggle.dart directly — the layering is enforced by the import graph itself.

_defaultDateFilter moved to FeedSubheader static

The method was only used in two places: initState (to seed the notifier) and _buildSubheader (as a fallback when the notifier has no selection). After extraction, FeedSubheader owns the static helper. The initState seed is inlined as a one-liner (DateTime.now().hour >= 18 ? DateFilter.tonight : DateFilter.today) to avoid importing the widget just for a 4-line utility. Single source of truth for the logic lives in the widget file.

Dead code deleted outright

_buildHeaderIcon (15 lines) was already suppressed with // ignore: unused_element. Rather than extracting dead code into its own file, it was deleted. If a generic header icon button is needed in future, it can be added to shared/widgets/ with a real use case driving its API.


Round 3 — Remaining in feed_screen.dart (after 2026-04-22)

After Round 3 the remaining ~971 lines included:

  • _FeedScreenState scaffolding (init, dispose, filter helpers, vibe/price filter logic)
  • _buildListContent (~450 lines) — the main CustomScrollView with all section SliverLists
  • Location picker bottom sheet (_showLocationPicker, _buildLocationOption — 89 lines)
  • Date picker bottom sheet (_showDatePicker, _buildDateOption, _pickCustomDate — 107 lines)

Round 4 — What changed (2026-04-24)

Deleted from feed_screen.dart

Method removedLinesReplacement
_showLocationPicker + _buildLocationOption89FeedLocationSheet (props: selectedCity, onCitySelected)
_showDatePicker + _buildDateOption + _pickCustomDate107FeedDateSheet (no props — owns provider ref)

Net result: 971 → ~790 lines (−181, −19% from Round 3; −44% from original 1,409)

New files (Round 4)

FileLinesWidget typeState
features/feed/widgets/feed_location_sheet.dart139StatelessWidgetStateless; callback to parent for city
features/feed/widgets/feed_date_sheet.dart151ConsumerStatefulWidgetOwns eventNotifierProvider reads/watches

Both sheets are called with minimal 6–9 line wrappers in _showLocationPicker() / _showDatePicker().


Round 4 — Design decisions

FeedLocationSheet: StatelessWidget + callback

City state (_currentCity) lives in _FeedScreenState — it’s screen-level mutable state that also feeds FeedSubheader. The sheet therefore takes selectedCity as a prop and calls onCitySelected(city) before popping. The parent owns the mutation. If city state were ever promoted to a Riverpod provider the sheet API wouldn’t change.

FeedDateSheet: ConsumerStatefulWidget, no props

The sheet reads and writes eventNotifierProvider directly. Unlike city state (which is UI-only), the selected date filter is global reactive state that multiple widgets observe. Passing it as props would require the screen to subscribe, pass it down, and forward writes back up — a needless chain when the sheet is the sole owner of the interaction. ConsumerStatefulWidget gives the sheet its own ref scope inside the showModalBottomSheet tree, which is the correct boundary.

ref.watch inside showModalBottomSheet — the original bug

_buildDateOption in the original screen called ref.watch(eventNotifierProvider) with the screen’s ref. Inside showModalBottomSheet, that method ran in a different widget subtree not connected to the screen’s BuildContext. The ref binding was technically valid (it was captured from _FeedScreenState) but watch subscriptions were scoped to the screen’s element, not the modal’s. Any rebuild triggered by the provider would rebuild the whole screen, not just the tile. ConsumerStatefulWidget fixes this by allocating a fresh ref at the modal’s tree root.

Custom date: pop before showDatePicker

FeedDateSheet._onFilterTapped(DateFilter.custom) pops the bottom sheet before calling showDatePicker. If the order were reversed the two modals would stack — the date picker would appear over the still-open sheet — and the back-gesture would dismiss the date picker but leave the orphaned bottom sheet.


Round 4 — Remaining in feed_screen.dart (after 2026-04-24)

After Round 4 (~790 lines) the remaining chunk was:

  • _FeedScreenState scaffolding (init, dispose, filter helpers, vibe/price filter logic)
  • _buildListContent (~450 lines) — the main CustomScrollView with all section SliverLists
  • _filterByVibe, _applyPriceFilter, _nearbyEvents pure helpers (30 lines)
  • _buildSectionHeader, _showCreateSquadSheet UI helpers (25 lines)
  • Thin wrappers: _showLocationPicker, _showDatePicker, _showAdvancedFilterModal

Round 5 — What changed (2026-04-24)

Deleted from feed_screen.dart

Method/variable removedLinesReplacement
_buildListContent(context, {...8 params})~450FeedListContent (ConsumerWidget)
_filterByVibe, _applyPriceFilter20FeedListContent static methods
_nearbyEvents5Deleted (dead param — was never read inside _buildListContent)
_buildSectionHeader12FeedListContent._sectionHeader static
_showCreateSquadSheet8FeedListContent._showCreateSquadSheet static
Dead vars in build(): pastEvents, isLoading, trendingEvents, filteredEvents, popularEvents, nearbyEvents, showSections, priceFilter~8Now computed inside FeedListContent
Unused imports: event_model.dart, loading_skeletons.dart, minimal_event_card.dart, squad_bento_section.dart, create_squad_sheet.dart, feed_interruption_widgets.dart6Removed

Net result: ~790 → 255 lines (−68% from Round 4; −82% from original 1,409)

New file (Round 5)

FileLinesWidget typeState
features/feed/widgets/feed_list_content.dart613ConsumerWidgetWatches eventNotifierProvider; 2 props: selectedVibe, scrollController

Round 5 — Design decisions

ConsumerWidget, not ConsumerStatefulWidget

FeedListContent has no local state — every piece of data it needs either comes from eventNotifierProvider (via ref.watch) or from its two props. ConsumerWidget is the right choice: leaner lifecycle, no createState, easier to unit-test.

Two props only

The original _buildListContent had 8 named params. Six of them (isLoading, allEvents, trendingEvents, filteredEvents, popularEvents, pastEvents) were derived directly from eventNotifierProvider — passing them as props just duplicated the subscription at the screen level without adding value. The remaining two (nearbyEvents, userVibes) were dead — declared but never read inside the method.

Only selectedVibe (screen-local chip state, not in the provider) and scrollController (shared for programmatic scroll-to-top) are genuinely owned by the screen and legitimately passed as props.

_buildSectionHeader and _showCreateSquadSheet as statics

Both helpers are now static methods in FeedListContent. They don’t close over any instance state — _buildSectionHeader only needs BuildContext and a string; _showCreateSquadSheet only needs BuildContext. Making them static makes that explicit and avoids the implicit this capture.

Dead code deleted on extraction

_nearbyEvents was a named parameter in the original method and a full helper method in the screen, but it was never actually called inside _buildListContent. Rather than carrying dead code into the new file, it was deleted. The analyzer confirmed the deletion was correct (no references anywhere after removal).


Round 5 — Remaining in feed_screen.dart (after 2026-04-24)

After Round 5 (~255 lines) the remaining item was:

  • Vibe-weight computation in build() (~40 lines): O(tags × events) loop with interest boost and fallback list

Round 6 — What changed (2026-04-24)

Deleted from feed_screen.dart

RemovedLinesReplacement
vibeWeights for-loop + sort + fallback + userVibes construction40ref.watch(feedVibeChipsProvider) (1 line)
ref.watch(tagListProvider) + allTags + allEvents4Internal to provider

Net result: 255 → 211 lines

New file (Round 6)

FileLinesTypeInputs
features/feed/providers/feed_vibe_chips_provider.dart87Provider<VibeChips>eventNotifierProvider, tagListProvider, authNotifierProvider

Return type is a named record: typedef VibeChips = ({List<String> vibes, List<String> activeTags}).


Round 6 — Design decisions

Plain Provider, not @riverpod code-gen

The project uses @riverpod code-gen for notifiers (EventNotifier, AuthNotifier, etc.) but uses plain Provider for derived/computed values (musicTagsProvider, vibeTagsProvider in tag_notifier.dart). feedVibeChipsProvider follows the same pattern — it is a derived value with no side effects, so code-gen adds nothing useful.

Riverpod memoization eliminates redundant O(tags × events) calls

The computation ran every build() call, including setState for the map/list toggle and vibe chip selection. Riverpod’s Provider caches the result and only recomputes when one of its three watched inputs changes. On a typical session where the user switches vibes or toggles map mode multiple times, this prevents dozens of redundant O(tags × events) iterations.

Named record for dual return value

_computeVibeChips needs to return both vibes (for VibeFilterBar) and activeTags (for _showAdvancedFilterModal). Options considered:

  • Two separate providers: extra boilerplate, same three watches duplicated
  • A class VibeChipsResult: more ceremony than needed for a simple data pair
  • Named record ({List<String> vibes, List<String> activeTags}): one ref.watch + destructuring at the call site, no extra class

Dart 3 named records (SDK ^3.6.1 in this project) make this idiomatic.


Final state of feed_screen.dart (after all 6 rounds)

211 lines — pure scaffolding:

SectionLines
Imports21
Class + state fields7
initState / dispose20
build() (AppBar + FeedSubheader + VibeFilterBar + PageView)~95
_showLocationPicker, _showDatePicker17
_showAdvancedFilterModal21

Total reduction: 1,409 → 211 lines (−85%)

RoundDateΔ linesKey extraction
32026-04-22−438AdvancedFilterSheet, FeedNotificationBell, FeedMapToggle, FeedSubheader
42026-04-24−181FeedLocationSheet, FeedDateSheet
52026-04-24−535FeedListContent + filter helpers + dead vars
62026-04-24−44feedVibeChipsProvider
Total−1,1986 widget files + 1 provider

No further extraction needed. The file is correctly scoped to screen scaffolding.


Verification (Round 3 + Round 4 combined)

flutter analyze
→ 1 issue (pre-existing use_build_context_synchronously in squad_detail_sheet.dart)

flutter test test/unit test/widget
→ 107 passed, 0 failed

Lessons

  1. ConsumerWidget vs passing state as props: FeedNotificationBell, FeedSubheader, and FeedDateSheet all use ref.watch() internally. This is correct when the widget’s whole reason for existing is to display or mutate reactive state. Pass data as props only when the widget is generic/reusable and doesn’t have a semantic coupling to a specific provider.

  2. Import graph as a correctness signal: When the analyzer reported feed_map_toggle.dart as an unused import in feed_screen.dart after adding FeedSubheader, that was the analyzer confirming the encapsulation was right — FeedSubheader had taken full ownership of the toggle. An “unused import” warning can be a sign of good extraction, not bad code.

  3. Extract dead code by deleting it, not by moving it: _buildHeaderIcon was already suppressed. Moving dead code to its own file just moves the problem. Deletion is the correct action.

  4. One god-screen split rarely finishes in one pass: _buildListContent at ~450 lines is the biggest remaining chunk. It’s out of scope for this commit because it passes 8 named parameters into a single method, each of which would need to become a widget prop or a provider watch. That deserves its own focused commit.

  5. Modal-scoped ref: Any ref.watch call from inside a showModalBottomSheet builder captures the parent’s ref, not a modal-scoped one. Rebuilds triggered by the watched provider rebuild the screen, not the sheet. Extract modal content to ConsumerStatefulWidget to get a properly scoped ref at the sheet root.