Skip to content

Commit faba3e1

Browse files
fix: correctness fixes from a codebase review (#498)
* fix(router): preserve path encoding and query on festival redirect go_router hands back percent-decoded path parameters, so the six per-route onInvalidFestival closures — each rebuilding its own path by string interpolation — silently lost the encoding. A style containing a slash ("Porter/Stout", encoded %2F by buildStylePath) split into two segments and stopped matching its route; a "?" became a query string and a "#" became a fragment, truncating the id. Reaching a stale link to a retired festival was enough to trigger it. All six closures did the same thing: swap segment 0 for the current festival. Doing that once in _festivalScopeRedirect, from state.uri with each segment re-encoded, fixes the corruption and collapses the duplication. It also carries the query string across, which previously only /:festivalId did and the five nested routes dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoEMyawRmvhA2zuSDBmLdr * fix(drinks): sort names, breweries and styles case-insensitively DrinkSortService used a raw String.compareTo, which orders every capitalised value ahead of every lowercase one. In the live cbf2026 feed that puts the brewery "d'Achouffe" at index 277 of 279 — below "Zotler" — instead of alphabetically between "Cydefx" and "Daleside". The style facet and the My Festival list already sort case-insensitively via StringComparisonHelper, so DrinkSortService was the outlier. Use the same comparator for all three text sorts. Tie order is still unspecified (equal-ABV drinks are not alphabetical); adding a secondary tiebreak is left as a separate change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoEMyawRmvhA2zuSDBmLdr * fix(festivals): make Retry and Refresh work in the festival sheet FestivalSelectorSheet renders provider state from a reference captured via context.read, which subscribes to nothing, and a modal route is not rebuilt by its opener. Every other sheet in this file gets away with that because its controls pop the sheet before the notification lands — but Retry and Refresh deliberately stay open, so loadFestivals() ran and nothing on screen changed. Both were dead buttons. Wrap the body in a ListenableBuilder on the injected provider. Consumer would work too, but listening to the instance directly keeps the widget usable without an ancestor Provider, which is how its tests build it. The two tests covering this asserted provider state only; one carried a comment explaining that the sheet would not rebuild. Both now assert the rendered UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoEMyawRmvhA2zuSDBmLdr * refactor(utils): rename compareLocaleAware to compareCaseInsensitive The function lower-cases both operands and calls String.compareTo, which compares UTF-16 code units. That is not collation: 'é' is greater than every ASCII letter, so "Rosé" sorts after "Rosz" rather than next to "Rose", and the brewery "Ārpus" sorts below "Zötler". The old name and docstring promised locale-aware ordering the code never delivered, and the docstring's examples ("Café comes right after Cafe") were true only because the base word is a prefix of the accented one. Behaviour is unchanged — this renames the function and rewrites the docstring to describe what it does, including the limitation. The test that asserted the coincidental accent ordering now also pins the real behaviour, so introducing a collator later is a deliberate change with a visible diff rather than a silent reordering of the style filter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoEMyawRmvhA2zuSDBmLdr * fix(provider): never strand the app on the startup spinner BeerProvider.initialize() awaits SharedPreferences, two one-time migrations, the festival cache and the saved selection, with no error handling. If any of them threw, _isInitialized was never set, so the router's '/' redirect kept returning null and the app sat on the CircularProgressIndicator forever. Crashlytics recorded it as fatal, but the user had no way out short of reinstalling. Move the restore sequence into _restoreState() and wrap it: a failure is logged, surfaced as provider.error so the drinks screen offers a Retry, and startup completes either way. loadDrinks() now also refuses to dereference a null repository, which is the state left behind when initialize() fails before the repositories are built. Also wraps three fire-and-forget futures in unawaited() while in these files — including the logError call in _handlePostInitRedirect, the only analytics call in lib/ that was not wrapped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoEMyawRmvhA2zuSDBmLdr * fix(screens): guard every festival-scoped screen against stale data MyFestivalScreen was the only screen holding the festival-flash guard from #397. The router schedules setFestival in a post-frame callback, so on a URL-driven festival change — a cross-festival deep link on a warm app, browser back/forward, or the post-init redirect — the other four screens built once against the previous festival's catalogue. The drinks list showed the old festival's name and drinks; the detail screens resolved the wrong entity or a spurious "not found". The in-app switcher was already safe (it calls setFestival before router.go), which is why this never showed up in normal use. Adds the guard to DrinksScreen, DrinkDetailScreen, BreweryScreen and StyleScreen, and gives the invariant one test file covering all five so a new festival-scoped screen has an obvious place to be added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoEMyawRmvhA2zuSDBmLdr * docs: correct navigateToRoute and stale symbol references navigateToRoute stopped branching on platform in #470 (PR #478) — it is now context.push on every platform — but AGENTS.md and two skill files still described the web/mobile branch in the present tense, with line citations that had also drifted. Skills steer future agents, so this sent them looking for code that no longer exists. Also corrects references to the FavoritesScreen class (renamed to MyFestivalScreen; the route stays /:festivalId/favorites, and that distinction is now stated as such), and marks BottomActionBar, ActionButton and BreadcrumbBar in the reuse table as currently unused — no screen wires them up, so the table was recommending widgets that are not in fact an established pattern here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoEMyawRmvhA2zuSDBmLdr * fix: address review feedback and remaining review findings Router: - Preserve the URL fragment across the invalid-festival redirect. The docstring claimed "everything else about the URL" but dropped it. Uri.query and Uri.fragment both return the raw, still-encoded form, so appending round-trips exactly (verified — re-encoding double-escapes). This retires a documented known limitation: the test that asserted the lossy behaviour, and its TODO, are replaced by one pinning the fix. - Stop double-decoding the style path parameter. go_router already percent-decodes path parameters, so safeDecodeComponent turned a style literally containing "%20" into a space. The helper existed only for that call site and is removed with it. Provider: - Guard loadFestivals() against a null repository explicitly. This was not a crash — the null dereference was already caught and surfaced as festivalsError — but relying on a TypeError for control flow hid the intent and produced a generic message. Tests pin loadFestivals and refreshIfStale completing without repositories. - Cover the loadDrinks null-repository guard, which Codecov correctly flagged as the untested part of the previous commit. Repository: - Report each unknown status_text once per session. getDrinks runs on every cold start, festival switch, staleness refresh and pull-to- refresh, so one new phrase from the organisers logged a Crashlytics error from every user roughly hourly for the whole festival. UI: - Replace Colors.grey / Colors.red with theme colours. The fixed grey is 2.7:1 against a light surface, below the 4.5:1 WCAG AA requires for body text (it only passed in dark mode). Docs: - Fix a broken markdown table row introduced in the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoEMyawRmvhA2zuSDBmLdr * refactor: remove duplicated filter predicates and theme sheet DrinkFilterService defined eight single-purpose filters and then re-implemented every one of those predicates inline in filterDrinks, so each rule existed twice: the copy with unit tests had no production caller, and the copy the app actually ran had none of its own. A fix to filterByAvailability could pass its test while changing nothing on screen. filterDrinks now composes them, leaving one copy of each rule. The service's 67 existing tests pass unchanged, which is the evidence the composition is behaviour-identical. about_screen carried a 77-line verbatim copy of ThemeSelectorSheet; it now uses the shared widget. Also adds a test for the drinks screen's full error view — one of the four documented loading/error signals, previously asserted only as "not showing" and never rendered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoEMyawRmvhA2zuSDBmLdr --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c6e71f8 commit faba3e1

29 files changed

Lines changed: 966 additions & 360 deletions

.claude/skills/architecture-contract/SKILL.md

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ run on every launch.
281281
| Personal state (favourites/ratings/tastings) is catalogue-independent (#390) | `DrinkRepository.getPersonalEntries` doc (`drink_repository.dart:56-65`): "the caller can enumerate a user's favourites, ratings, and tasting history purely from the personal-data store, before (or without) the drink catalogue being fetched." Fixed the favourites-flash bug family (#310/#397) as a side effect, because the My Festival list stopped being `_allDrinks.where(...)` (whichever festival happened to be loaded) and became its own festival-scoped query. |
282282
| Stale-while-revalidate (SWR) with two independent per-type caches | `cache_service.dart:8-19` class doc: render last-good data instantly, refresh in background, keep cache on failure. Per-*type* (not per-festival) caching specifically so a flaky `cider.json` fetch can't wipe out a good `beer.json` cache — see invariant 11. |
283283
| The `/` route must have a `builder`, not just a `redirect` | `router.dart:76-83` comment, citing issue #386: a redirect-only route that stays put (because the provider hasn't initialized yet) leaves go_router with an empty `pages` list and no `onGenerateRoute`, which crashes with "Null check operator used on a null value" in **release** builds only. The minimal `CircularProgressIndicator` builder at `router.dart:84-85` is the fix; removing it reintroduces a release-only crash invisible in debug/tests. |
284-
| `navigateToRoute()` branches `context.go()` (web) vs `context.push()` (mobile) | `navigation_helpers.dart:228-240`: `push` from inside a `ShellRoute` doesn't update the browser URL bar on web, but `push` is preferred on mobile to preserve the native back-stack. Always use this helper for drill-down navigation (drink detail, brewery) rather than calling `context.go`/`context.push` directly — see AGENTS.md's Navigation pattern. |
284+
| `navigateToRoute()` pushes on every platform (no web/mobile branch since #470) | `navigation_helpers.dart:237-239` is now just `context.push(path)`. It used to branch to `context.go()` on web because `push` from inside a `ShellRoute` didn't update the browser URL bar; enabling `GoRouter.optionURLReflectsImperativeAPIs` (`router.dart`) fixed that, and `go` was disposing the calling screen and losing its scroll position (#470, PR #478). The one-line helper is kept deliberately: it is the only place that rationale is recorded, and the single seam if that flag ever has to come back off. Always use it for drill-down navigation (drink detail, brewery) rather than calling `context.go`/`context.push` directly. |
285285
| Analytics only fires in production | `AnalyticsService._isAnalyticsEnabled = isProduction()` (`analytics_service.dart:21`); `EnvironmentService.isProduction()`/`isProductionHost()` (`environment_service.dart`). Fixed issue #269: unknown hostnames used to default to "production", polluting real analytics with staging/preview traffic; now unknown → NOT production (under-count is the safe failure direction). `logError` is the one exception — it runs in **every** environment so Crashlytics still sees staging crashes. |
286286
| `DefaultFestivals` hard-coded fallback | `models/festival.dart:262+` — four literal `Festival` objects (`cbf2026` active, `cbf2025`, `cbfw2025`, `cbf2024`) used only when both the network *and* the festival cache are unavailable (`FestivalController.currentFestival` getter, `festival_controller.dart:41-47`, and `BeerProvider.loadDrinks`, `beer_provider.dart:391-398`). This is a last-resort constant, not a data source to keep in sync with `data/festivals.json` — do not add new festivals here expecting them to appear in the switcher; that's the registry's job. |
287287

@@ -303,11 +303,13 @@ found" — they're tracked.
303303
(`docs/todos.md:57-68`).
304304
- **No way to navigate back from the `/about` deep link** — archived todo
305305
H6 (`docs/todos.md:136-147`).
306-
- **URL fragments are lost during the post-init redirect.** Explicitly
307-
marked with a `TODO` in `test/router_test.dart:666`: "Fix this by
308-
preserving `currentUri.fragment` in redirect URL construction." The test
309-
at lines 655-664 documents the current (lossy) behaviour as a known
310-
limitation, not a passing spec for correct behaviour.
306+
- **URL fragments in the *post-init* redirect** (`main.dart`'s
307+
`_handlePostInitRedirect`) are still dropped — it rebuilds the path from
308+
`segments` + query only. The router's own invalid-festival redirect no
309+
longer loses them: `_redirectToCurrentFestival` (`router.dart`) carries
310+
query and fragment across verbatim, and `test/router_test.dart`'s "URL
311+
fragments survive the invalid-festival redirect" pins that. The old
312+
lossy-behaviour test and its TODO are gone.
311313
- **Flutter web renders to `<canvas>`**, so Playwright (the only E2E tool
312314
in use, per ADR 0005) can never assert on rendered UI content — a route
313315
can return HTTP 200 and still be showing an error state underneath.
@@ -316,13 +318,13 @@ found" — they're tracked.
316318
- **AGENTS.md architecture doc drift** — see the callout at the top of this
317319
file. `FavoritesService`/`RatingsService`/`TastingLogService` are gone;
318320
`UserDataStore` is reality.
319-
- **The class is called `FavoritesScreen` but the file is
320-
`lib/screens/my_festival_screen.dart`.** PR #448 renamed the underlying
321+
- **The screen class is `MyFestivalScreen` but its route is still
322+
`/:festivalId/favorites`.** PR #448 renamed the underlying
321323
model (`FavoriteDrinkEntry``MyFestivalEntry`) and generalised
322-
`favoriteDrinks``myFestivalEntries`, but the screen class name and its
323-
URL path (`/:festivalId/favorites`) are unchanged **on purpose** — URLs
324-
are a public contract (see skill `change-control`'s unwritten rule #1).
325-
Don't be surprised the class name and file name disagree; don't rename
324+
`favoriteDrinks``myFestivalEntries`, and the class has since been renamed
325+
to match its file, but the URL path (`/:festivalId/favorites`) is unchanged
326+
**on purpose** — URLs are a public contract (see skill `change-control`'s
327+
unwritten rule #1). Don't be surprised the class and route disagree; don't rename
326328
the class without checking every import, and never rename the route.
327329
- **The `/v1alpha` catalogue API is contract-only** (issue #432/PR #433):
328330
proto + generated OpenAPI + a read-only worker endpoint exist, but there
@@ -440,8 +442,8 @@ grep "^version:" pubspec.yaml
440442
# Confirm known-weak-points are still open (re-check state, not just existence)
441443
gh issue view 432 --json state,title 2>/dev/null || echo "use mcp__github__issue_read method=get issue_number=432 instead"
442444

443-
# Re-read the fragment-loss TODO to confirm it hasn't been fixed
444-
sed -n '650,667p' test/router_test.dart
445+
# Confirm the router redirect still preserves query + fragment
446+
grep -n "hasFragment\|hasQuery" lib/router.dart
445447
```
446448

447449
If any of these disagree with the text above, the code has moved on —

.claude/skills/my-festival-campaign/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ Ground rules for the whole track:
112112
- **#412 is already largely landed.** `MyFestivalEntry`
113113
(`lib/models/my_festival_entry.dart`), `BeerProvider.myFestivalEntries` /
114114
`favoriteEntries`, and `lib/screens/my_festival_screen.dart` (which today
115-
contains class `FavoritesScreen`) already exist. Don't re-create them; extend
115+
contains class `MyFestivalScreen`) already exist. Don't re-create them; extend
116116
them. Note the issues say `FavoriteDrinkEntry` — the code already renamed it to
117117
`MyFestivalEntry`.
118118

@@ -223,7 +223,7 @@ want-to-try / tasted badge` — Fixes #413.
223223
favourites body. Rename the nav tab.
224224

225225
**Files:** `lib/screens/my_festival_screen.dart` (extend the existing
226-
`FavoritesScreen`), `lib/screens/screens.dart`, `lib/main.dart` (nav tab
226+
`MyFestivalScreen`), `lib/screens/screens.dart`, `lib/main.dart` (nav tab
227227
icon/label/semantics ~406–417), `lib/router.dart`,
228228
`test/screens/my_festival_screen_test.dart` (new).
229229

.claude/skills/ui-and-accessibility/SKILL.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ that failure mode is expensive and it has already happened.
5252
skill `change-control` for the full unwritten-rules list. Concretely for UI
5353
work: the "My Festival" rename (issue #414) renames the **nav label and
5454
icon**, not the route. The route stays `/:festivalId/favorites`
55-
(`lib/router.dart:111`, `FavoritesScreen` class in
55+
(`lib/router.dart`, `MyFestivalScreen` class in
5656
`lib/screens/my_festival_screen.dart`) even though the tab text changes to
5757
"My Festival." A visual/branding rename is never a licence to touch
5858
`router.dart` path strings.
@@ -62,8 +62,7 @@ that failure mode is expensive and it has already happened.
6262
deliberately small shared-component set (the identity hero panels
6363
`DrinkHeroPanel`/`BreweryHeroPanel`/`StyleHeroPanel` with their shared
6464
`FactsStrip`/`FactCell`, `InfoChip`, `SectionHeader`,
65-
`BottomActionBar`/`ActionButton`, `BreadcrumbBar`, `buildOverflowMenu`, the
66-
filter sheets). A second widget that does 90% of
65+
`buildOverflowMenu`, the filter sheets). A second widget that does 90% of
6766
what an existing one does is scope creep and a maintenance burden for a
6867
solo maintainer.
6968

@@ -140,8 +139,8 @@ unless noted.
140139
| Divided strip of "key facts" (centred value over an uppercase label) inside a hero | `FactsStrip` + `FactCell` | `facts_strip.dart` | `FactsStrip` owns the top/bottom/left dividers; a `FactCell` becomes a navigation button when given `onTap` + `semanticLabel` |
141140
| Small metadata pill (style, dispense, bar location) | `InfoChip` | `info_chip.dart` | Optional `onTap` makes it a `Semantics(button: true)` link |
142141
| Section title with underline on a detail screen | `SectionHeader` | `section_header.dart` | `showSeparator` toggles the underline |
143-
| Sticky bottom row of actions (tasting log, rate, favourite, share) | `BottomActionBar` + `ActionButton` | `bottom_action_bar.dart` | `ActionButton.isActive` drives colour + `FontWeight`; `semanticLabel` overrides the visible label for screen readers |
144-
| Back-navigation header on a detail screen (drink/brewery/style) | `BreadcrumbBar` | `breadcrumb_bar.dart` | Only the `IconButton` gets `Semantics`, never the text row — see the "BAD" example in `docs/code/widget-standards.md`. 28px icon → 48×48 touch target. Text segments only become tappable/underlined when a callback is provided |
142+
| Sticky bottom row of actions (tasting log, rate, favourite, share) | `BottomActionBar` + `ActionButton` | `bottom_action_bar.dart` | **Currently unused** — no screen wires it up (the drink detail screen uses a FAB + `YourTakeCard` instead). Available, but check it still fits before adopting. `ActionButton.isActive` drives colour + `FontWeight`; `semanticLabel` overrides the visible label for screen readers |
143+
| Back-navigation header on a detail screen (drink/brewery/style) | `BreadcrumbBar` | `breadcrumb_bar.dart` | **Currently unused** — detail screens use `CollapsingDetailAppBar` + `buildHomeLeadingButton`. Only the `IconButton` gets `Semantics`, never the text row — see the "BAD" example in `docs/code/widget-standards.md`. 28px icon → 48×48 touch target. Text segments only become tappable/underlined when a callback is provided |
145144
| Three-dot menu for festival switch / settings / about | `buildOverflowMenu(context)` | `overflow_menu.dart` | A function, not a widget class — `docs/code/ui-components.md` documents where to include it (Drinks, My Festival screen) and where not to (detail screens, About, modals) |
146145
| Modal filter pickers (category, style, sort, visibility) | `showCategoryFilter` / `showStyleFilter` / `showSortOptions` / `showVisibilityFilter` | `drink_filter_sheets.dart` | All route through the private `_showSheet` helper (`isScrollControlled: true`) and share `_SheetHandle` — add a new filter type by adding a sheet class + show-function here, not a bespoke `showModalBottomSheet` call elsewhere |
147146
| Star rating display or picker | `StarRating` | `star_rating.dart` | `isEditable` toggles read-only vs tap-to-rate; semantic `value` is always `'$rating out of 5 stars'` |
@@ -276,7 +275,7 @@ citable bug. Breaking one silently reintroduces a fixed defect.
276275

277276
### 1. Festival-flash guard (REQUIRED in any festival-scoped screen)
278277

279-
`lib/screens/my_festival_screen.dart:15` (inside `FavoritesScreen.build`):
278+
`lib/screens/my_festival_screen.dart` (first thing in `MyFestivalScreen.build`):
280279
```dart
281280
final provider = context.watch<BeerProvider>();
282281
if (provider.currentFestival.id != festivalId) {
@@ -314,10 +313,11 @@ invent a fifth loading state or collapse two of these into one.
314313

315314
### 3. `navigateToRoute` + typed path builders
316315

317-
`lib/utils/navigation_helpers.dart:234``navigateToRoute(context, path)`
318-
picks `context.go(path)` on web and `context.push(path)` on mobile, because
319-
`push` from inside a `ShellRoute` doesn't update the browser URL bar on web,
320-
while `push` is preferred on mobile to preserve the native back stack. Use it
316+
`lib/utils/navigation_helpers.dart:237``navigateToRoute(context, path)`
317+
calls `context.push(path)` on every platform. It used to branch to
318+
`context.go()` on web (the URL bar didn't follow a `push` from inside a
319+
`ShellRoute`); `GoRouter.optionURLReflectsImperativeAPIs` fixed that in #470,
320+
and `go` was disposing the calling screen and losing its scroll. Use it
321321
for any drill-down navigation to content (drink detail, brewery, style). For
322322
root/tab navigation that replaces the stack (bottom nav taps, "go home"), call
323323
`context.go()` directly instead — that's a deliberate exception, not an

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ Container(
206206

207207
**Provider reads**`context.watch<BeerProvider>()` in `build()` only (subscribes to rebuilds). `context.read<BeerProvider>()` in callbacks, `initState`, and post-frame callbacks (one-shot, no rebuild subscription). Analytics calls in `initState` must be deferred via `WidgetsBinding.instance.addPostFrameCallback()`.
208208

209-
**Navigation** — for drill-down navigation to content (drink detail, brewery), use `navigateToRoute()` from `lib/utils/navigation_helpers.dart`; it selects `context.go()` (web) or `context.push()` (mobile) automatically. For root/tab navigation that replaces the route stack (bottom nav, home button), use `context.go()` directly. Build URL paths with the typed helpers (`buildFestivalPath()`, `buildDrinkDetailPath()`, etc.) — never interpolate raw strings.
209+
**Navigation** — for drill-down navigation to content (drink detail, brewery), use `navigateToRoute()` from `lib/utils/navigation_helpers.dart`; it pushes the route on every platform, so the calling screen keeps its scroll position (#470). For root/tab navigation that replaces the route stack (bottom nav, home button), use `context.go()` directly. Build URL paths with the typed helpers (`buildFestivalPath()`, `buildDrinkDetailPath()`, etc.) — never interpolate raw strings.
210210

211211
**Loading/error states** — four mutually exclusive signals on `BeerProvider`:
212212

lib/domain/controllers/drink_filter_controller.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ class DrinkFilterController {
6262

6363
/// Unique styles in the source drinks, narrowed to the selected category when
6464
/// one is active, sorted case-insensitively (via
65-
/// [StringComparisonHelper.compareLocaleAware]) so styles order in a stable,
65+
/// [StringComparisonHelper.compareCaseInsensitive]) so styles order in a stable,
6666
/// human-friendly way regardless of capitalisation. Presentation consumes
6767
/// this directly — no sorting in the UI.
6868
List<String> get availableStyles {
@@ -71,7 +71,7 @@ class DrinkFilterController {
7171
.map((d) => d.style!)
7272
.toSet()
7373
.toList()
74-
..sort(StringComparisonHelper.compareLocaleAware);
74+
..sort(StringComparisonHelper.compareCaseInsensitive);
7575
}
7676

7777
/// Drink count per category across the full source.

lib/domain/repositories/api_drink_repository.dart

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ class ApiDrinkRepository implements DrinkRepository {
2020

2121
static const Uuid _uuid = Uuid();
2222

23+
/// Availability phrases already reported to Crashlytics this session, so a
24+
/// novel `status_text` is logged once rather than on every refresh.
25+
final Set<String> _reportedUnknownStatuses = {};
26+
2327
ApiDrinkRepository({
2428
required BeerApiService apiService,
2529
required UserDataStore userDataStore,
@@ -85,12 +89,19 @@ class ApiDrinkRepository implements DrinkRepository {
8589

8690
_applyUserState(update.drinks, festival.id);
8791

92+
// Report only phrases this session hasn't already reported. getDrinks runs
93+
// on every cold start, festival switch, staleness refresh and pull-to-
94+
// refresh, so without this a single new phrase coined by the organisers
95+
// would log an error from every user roughly hourly for the whole run of
96+
// the festival — drowning real crashes in the Crashlytics dashboard.
8897
final unknownStatuses = update.drinks
8998
.where((d) => d.availabilityStatus == AvailabilityStatus.unknown)
9099
.map((d) => d.statusText)
91100
.whereType<String>()
92-
.toSet();
101+
.toSet()
102+
.difference(_reportedUnknownStatuses);
93103
if (unknownStatuses.isNotEmpty) {
104+
_reportedUnknownStatuses.addAll(unknownStatuses);
94105
final sample = unknownStatuses.take(5).join(', ');
95106
final count = unknownStatuses.length;
96107
unawaited(

lib/domain/services/drink_filter_service.dart

Lines changed: 29 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,14 @@ class DrinkFilterService {
128128
/// 5. Allergen exclusions
129129
/// 6. Search filter
130130
///
131-
/// Each filter is only applied if its criteria is active.
132-
/// Uses Iterable chaining to avoid intermediate list allocations.
131+
/// Each filter is only applied if its criteria is active (each `filterByX`
132+
/// short-circuits on an inactive criterion).
133+
///
134+
/// Composed from the single-purpose filters above rather than re-testing each
135+
/// predicate inline: there is exactly one copy of every rule, so a fix to
136+
/// `filterByAvailability` (say) cannot pass its own unit test while leaving
137+
/// the list the app actually renders unchanged. Still lazy — the `Iterable`
138+
/// chain materialises once at the end.
133139
List<Drink> filterDrinks(
134140
List<Drink> drinks, {
135141
String? category,
@@ -139,43 +145,27 @@ class DrinkFilterService {
139145
Set<String> excludedAllergens = const {},
140146
String searchQuery = '',
141147
}) {
142-
Iterable<Drink> result = drinks;
143-
144-
if (category != null) {
145-
result = result.where((d) => d.category == category);
146-
}
147-
148-
if (styles != null && styles.isNotEmpty) {
149-
result = result.where((d) => d.style != null && styles.contains(d.style));
150-
}
151-
152-
if (favoritesOnly) {
153-
result = result.where((d) => d.isFavorite);
154-
}
155-
156-
if (visibilityFilters.contains(DrinkVisibilityFilter.availableOnly)) {
157-
result = result.where(
158-
(d) => d.availabilityStatus != AvailabilityStatus.out,
159-
);
160-
}
161-
if (visibilityFilters.contains(DrinkVisibilityFilter.notTasted)) {
162-
result = result.where((d) => !d.isTasted);
163-
}
164-
if (visibilityFilters.contains(DrinkVisibilityFilter.veganOnly)) {
165-
result = result.where((d) => d.isVegan == true);
166-
}
167-
168-
if (excludedAllergens.isNotEmpty) {
169-
result = result.where(
170-
(d) => excludedAllergens.every((a) => (d.allergens[a] ?? 0) == 0),
171-
);
172-
}
173-
174-
if (searchQuery.isNotEmpty) {
175-
final lowerQuery = searchQuery.toLowerCase();
176-
result = result.where((d) => _matchesSearch(d, lowerQuery));
177-
}
178-
148+
Iterable<Drink> result = filterByCategory(drinks, category);
149+
result = filterByStyles(result, styles ?? const {});
150+
result = filterByFavorites(result, favoritesOnly: favoritesOnly);
151+
result = filterByAvailability(
152+
result,
153+
hideUnavailable: visibilityFilters.contains(
154+
DrinkVisibilityFilter.availableOnly,
155+
),
156+
);
157+
result = filterByNotTasted(
158+
result,
159+
notTastedOnly: visibilityFilters.contains(
160+
DrinkVisibilityFilter.notTasted,
161+
),
162+
);
163+
result = filterByVegan(
164+
result,
165+
veganOnly: visibilityFilters.contains(DrinkVisibilityFilter.veganOnly),
166+
);
167+
result = filterByExcludedAllergens(result, excludedAllergens);
168+
result = filterBySearch(result, searchQuery);
179169
return result.toList();
180170
}
181171
}

0 commit comments

Comments
 (0)