Summary
State is well decomposed behind BeerProvider — DrinkFilterController, FestivalController, UserDrinkStateController, UserPreferencesController are clean, Flutter-free and individually testable. But the provider flattens all four back into a single notification channel, and the widget tree is wired to depend on that channel wholesale. A search keystroke, a theme change and a favourite toggle all produce the same rebuild.
Root cause
Two independent mechanisms, both of which have to change for either to help.
1. Screens subscribe to the whole provider. Every screen opens build() with a bare context.watch<BeerProvider>():
lib/screens/drinks_screen.dart:42
lib/screens/drink_detail_screen.dart, my_festival_screen.dart, brewery_screen.dart, style_screen.dart, festival_info_screen.dart, about_screen.dart (one each)
There is no use of context.select or Selector anywhere in lib/, and only three Consumer uses (lib/widgets/drink_filter_sheets.dart:43,226,378). BeerProvider has ~68 public members and 28 notifyListeners() call sites, so any of the 28 rebuilds all of the above. Concretely: changing theme mode re-runs the whole SliverChildBuilderDelegate in _buildDrinksListSliver and rebuilds every visible DrinkCard.
2. Widgets take BeerProvider as a constructor field — 18 sites:
lib/widgets/festival_header.dart:8
lib/widgets/festival_banner.dart:10
lib/widgets/festival_menu_sheets.dart:44,424,491
lib/widgets/drink_filter_sheets.dart:150
A const constructor holding a mutable ChangeNotifier means those widgets can never rebuild on their own — they are entirely dependent on an ancestor's watch. This is the inverse of what provider is for, and it means fixing (1) alone would break them. It also makes them untestable without constructing a full provider.
Fix approach
Incremental, one screen at a time — not a sweep (see AGENTS.md on this repo's history with sweeping UI changes).
- Drop the
BeerProvider provider constructor parameter from the six widgets above; have each read what it needs via context.select<BeerProvider, T>(...) at its own build boundary. Keep the parameter vestigial for one pass if call sites are out of scope, then delete (AGENTS.md "File manifests hide cross-file API coupling").
- Replace top-level
context.watch<BeerProvider>() in each screen with narrow context.select reads, or Selector around the subtrees that actually depend on a field.
drinks_screen.dart first — it has the largest rebuild surface and the most measurable win.
Widget-level rebuild boundaries (_buildX helper methods) are a related but separate concern; tracked separately.
Verification
Existing widget tests should pass unchanged. Add a rebuild-count assertion on DrinkCard for a theme-mode change, which currently rebuilds every card and should rebuild none.
Files: lib/screens/*.dart, lib/widgets/festival_header.dart, lib/widgets/festival_banner.dart, lib/widgets/festival_menu_sheets.dart, lib/widgets/drink_filter_sheets.dart
Summary
State is well decomposed behind
BeerProvider—DrinkFilterController,FestivalController,UserDrinkStateController,UserPreferencesControllerare clean, Flutter-free and individually testable. But the provider flattens all four back into a single notification channel, and the widget tree is wired to depend on that channel wholesale. A search keystroke, a theme change and a favourite toggle all produce the same rebuild.Root cause
Two independent mechanisms, both of which have to change for either to help.
1. Screens subscribe to the whole provider. Every screen opens
build()with a barecontext.watch<BeerProvider>():lib/screens/drinks_screen.dart:42lib/screens/drink_detail_screen.dart,my_festival_screen.dart,brewery_screen.dart,style_screen.dart,festival_info_screen.dart,about_screen.dart(one each)There is no use of
context.selectorSelectoranywhere inlib/, and only threeConsumeruses (lib/widgets/drink_filter_sheets.dart:43,226,378).BeerProviderhas ~68 public members and 28notifyListeners()call sites, so any of the 28 rebuilds all of the above. Concretely: changing theme mode re-runs the wholeSliverChildBuilderDelegatein_buildDrinksListSliverand rebuilds every visibleDrinkCard.2. Widgets take
BeerProvideras a constructor field — 18 sites:lib/widgets/festival_header.dart:8lib/widgets/festival_banner.dart:10lib/widgets/festival_menu_sheets.dart:44,424,491lib/widgets/drink_filter_sheets.dart:150A
constconstructor holding a mutableChangeNotifiermeans those widgets can never rebuild on their own — they are entirely dependent on an ancestor'swatch. This is the inverse of whatprovideris for, and it means fixing (1) alone would break them. It also makes them untestable without constructing a full provider.Fix approach
Incremental, one screen at a time — not a sweep (see AGENTS.md on this repo's history with sweeping UI changes).
BeerProvider providerconstructor parameter from the six widgets above; have each read what it needs viacontext.select<BeerProvider, T>(...)at its own build boundary. Keep the parameter vestigial for one pass if call sites are out of scope, then delete (AGENTS.md "File manifests hide cross-file API coupling").context.watch<BeerProvider>()in each screen with narrowcontext.selectreads, orSelectoraround the subtrees that actually depend on a field.drinks_screen.dartfirst — it has the largest rebuild surface and the most measurable win.Widget-level rebuild boundaries (
_buildXhelper methods) are a related but separate concern; tracked separately.Verification
Existing widget tests should pass unchanged. Add a rebuild-count assertion on
DrinkCardfor a theme-mode change, which currently rebuilds every card and should rebuild none.Files:
lib/screens/*.dart,lib/widgets/festival_header.dart,lib/widgets/festival_banner.dart,lib/widgets/festival_menu_sheets.dart,lib/widgets/drink_filter_sheets.dart