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:
- A complete
_AdvancedFilterSheet(bottom sheet widget — 216 lines) - A notification bell with badge (50 lines)
- A map/list two-state toggle (75 lines)
- A composite subheader row (date + city dropdowns + toggle — 80 lines)
- A dead
_buildHeaderIconhelper (15 lines, marked// ignore: unused_element)
Keeping them inline had three costs:
- Rebuild surface too large. A
setStateanywhere in_FeedScreenState(e.g., changing_selectedVibe) triggered a rebuild that touched all of these sub-trees. Extracting them asConsumerWidget/StatelessWidgetlets Flutter’s element tree short-circuit rebuilds at the right boundary. - Hard to find and reason about. Searching
notificationorfilterin 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 removed | Lines | Replacement |
|---|---|---|
_buildNotificationBell() | 48 | FeedNotificationBell |
_buildHeaderIcon() | 15 | deleted (dead code, // ignore: unused_element) |
_buildMapListToggle() | 75 | FeedMapToggle |
_buildSubheader() | 80 | FeedSubheader |
_defaultDateFilter() | 4 | Inlined in initState; static in FeedSubheader |
_AdvancedFilterSheet + state | 216 | AdvancedFilterSheet |
Unused flutter_animate import | 1 | Removed |
Net result: 1,409 → 971 lines (−438, −31%)
New files
| File | Lines | Widget type | State |
|---|---|---|---|
features/feed/widgets/advanced_filter_sheet.dart | 219 | StatefulWidget | Local PriceFilter selection |
features/feed/widgets/feed_notification_bell.dart | 62 | ConsumerWidget | Watches unreadNotificationsCountProvider |
features/feed/widgets/feed_map_toggle.dart | 86 | StatelessWidget | Stateless; callbacks for page navigation |
features/feed/widgets/feed_subheader.dart | 140 | ConsumerWidget | Watches 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:
_FeedScreenStatescaffolding (init, dispose, filter helpers, vibe/price filter logic)_buildListContent(~450 lines) — the mainCustomScrollViewwith all sectionSliverLists- 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 removed | Lines | Replacement |
|---|---|---|
_showLocationPicker + _buildLocationOption | 89 | FeedLocationSheet (props: selectedCity, onCitySelected) |
_showDatePicker + _buildDateOption + _pickCustomDate | 107 | FeedDateSheet (no props — owns provider ref) |
Net result: 971 → ~790 lines (−181, −19% from Round 3; −44% from original 1,409)
New files (Round 4)
| File | Lines | Widget type | State |
|---|---|---|---|
features/feed/widgets/feed_location_sheet.dart | 139 | StatelessWidget | Stateless; callback to parent for city |
features/feed/widgets/feed_date_sheet.dart | 151 | ConsumerStatefulWidget | Owns 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:
_FeedScreenStatescaffolding (init, dispose, filter helpers, vibe/price filter logic)_buildListContent(~450 lines) — the mainCustomScrollViewwith all sectionSliverLists_filterByVibe,_applyPriceFilter,_nearbyEventspure helpers (30 lines)_buildSectionHeader,_showCreateSquadSheetUI helpers (25 lines)- Thin wrappers:
_showLocationPicker,_showDatePicker,_showAdvancedFilterModal
Round 5 — What changed (2026-04-24)
Deleted from feed_screen.dart
| Method/variable removed | Lines | Replacement |
|---|---|---|
_buildListContent(context, {...8 params}) | ~450 | FeedListContent (ConsumerWidget) |
_filterByVibe, _applyPriceFilter | 20 | FeedListContent static methods |
_nearbyEvents | 5 | Deleted (dead param — was never read inside _buildListContent) |
_buildSectionHeader | 12 | FeedListContent._sectionHeader static |
_showCreateSquadSheet | 8 | FeedListContent._showCreateSquadSheet static |
Dead vars in build(): pastEvents, isLoading, trendingEvents, filteredEvents, popularEvents, nearbyEvents, showSections, priceFilter | ~8 | Now 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.dart | 6 | Removed |
Net result: ~790 → 255 lines (−68% from Round 4; −82% from original 1,409)
New file (Round 5)
| File | Lines | Widget type | State |
|---|---|---|---|
features/feed/widgets/feed_list_content.dart | 613 | ConsumerWidget | Watches 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
| Removed | Lines | Replacement |
|---|---|---|
vibeWeights for-loop + sort + fallback + userVibes construction | 40 | ref.watch(feedVibeChipsProvider) (1 line) |
ref.watch(tagListProvider) + allTags + allEvents | 4 | Internal to provider |
Net result: 255 → 211 lines
New file (Round 6)
| File | Lines | Type | Inputs |
|---|---|---|---|
features/feed/providers/feed_vibe_chips_provider.dart | 87 | Provider<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}): oneref.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:
| Section | Lines |
|---|---|
| Imports | 21 |
| Class + state fields | 7 |
initState / dispose | 20 |
build() (AppBar + FeedSubheader + VibeFilterBar + PageView) | ~95 |
_showLocationPicker, _showDatePicker | 17 |
_showAdvancedFilterModal | 21 |
Total reduction: 1,409 → 211 lines (−85%)
| Round | Date | Δ lines | Key extraction |
|---|---|---|---|
| 3 | 2026-04-22 | −438 | AdvancedFilterSheet, FeedNotificationBell, FeedMapToggle, FeedSubheader |
| 4 | 2026-04-24 | −181 | FeedLocationSheet, FeedDateSheet |
| 5 | 2026-04-24 | −535 | FeedListContent + filter helpers + dead vars |
| 6 | 2026-04-24 | −44 | feedVibeChipsProvider |
| Total | −1,198 | 6 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
-
ConsumerWidgetvs passing state as props:FeedNotificationBell,FeedSubheader, andFeedDateSheetall useref.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. -
Import graph as a correctness signal: When the analyzer reported
feed_map_toggle.dartas an unused import infeed_screen.dartafter addingFeedSubheader, that was the analyzer confirming the encapsulation was right —FeedSubheaderhad taken full ownership of the toggle. An “unused import” warning can be a sign of good extraction, not bad code. -
Extract dead code by deleting it, not by moving it:
_buildHeaderIconwas already suppressed. Moving dead code to its own file just moves the problem. Deletion is the correct action. -
One god-screen split rarely finishes in one pass:
_buildListContentat ~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. -
Modal-scoped ref: Any
ref.watchcall from inside ashowModalBottomSheetbuilder 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 toConsumerStatefulWidgetto get a properly scoped ref at the sheet root.