From e9de88366441619342d387af46cdb66b48eb5eb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 21:12:26 +0000 Subject: [PATCH 1/3] fix(style): show original-case style name instead of lowercase URL slug Style URLs use a lowercase canonical form (buildStylePath), so the router hands StyleScreen a lowercased style name. The screen rendered that raw value in its header and breadcrumb, so tapping a "Golden Ale" chip landed on a page titled "golden ale". Resolve the display name from a matched drink's original style string instead, keeping the lowercase value only for URL/lookup purposes. https://claude.ai/code/session_0135nVBYGpwkaQvG13XyS66H --- lib/screens/style_screen.dart | 20 ++++++++++++++------ test/style_screen_test.dart | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/lib/screens/style_screen.dart b/lib/screens/style_screen.dart index ba5b560d..2202ab12 100644 --- a/lib/screens/style_screen.dart +++ b/lib/screens/style_screen.dart @@ -55,18 +55,22 @@ class _StyleScreenState extends State { ); } + // Style URLs use a lowercase canonical form, so widget.style may be + // lowercased. Display the original mixed-case name from a matched drink. + final displayStyle = styleDrinks.first.style ?? widget.style; + final theme = Theme.of(context); return Scaffold( appBar: AppBar( - title: _buildAppBarTitle(context, provider), + title: _buildAppBarTitle(context, provider, displayStyle), leading: buildHomeLeadingButton(context, widget.festivalId), ), body: CustomScrollView( slivers: [ // Header section SliverToBoxAdapter( - child: _buildHeader(context, theme), + child: _buildHeader(context, theme, displayStyle), ), // Hero info card SliverToBoxAdapter( @@ -97,16 +101,20 @@ class _StyleScreenState extends State { } /// Build the app bar title with breadcrumb navigation - Widget _buildAppBarTitle(BuildContext context, BeerProvider provider) { + Widget _buildAppBarTitle( + BuildContext context, + BeerProvider provider, + String displayStyle, + ) { return buildBreadcrumbTitle( context, - title: widget.style, + title: displayStyle, festivalName: provider.currentFestival.name, ); } /// Build clean white header with style name - Widget _buildHeader(BuildContext context, ThemeData theme) { + Widget _buildHeader(BuildContext context, ThemeData theme, String displayStyle) { return Container( width: double.infinity, padding: const EdgeInsets.all(24.0), @@ -115,7 +123,7 @@ class _StyleScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ SelectableText( - widget.style, + displayStyle, style: theme.textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface, diff --git a/test/style_screen_test.dart b/test/style_screen_test.dart index 72ec980a..5533a527 100644 --- a/test/style_screen_test.dart +++ b/test/style_screen_test.dart @@ -130,6 +130,22 @@ void main() { expect(find.textContaining('Festival'), findsWidgets); }); + testWidgets('displays original mixed-case style name for a lowercase URL param', + (WidgetTester tester) async { + // Style URLs use a lowercase canonical form (see buildStylePath), so the + // router passes a lowercased style. The screen must still display the + // original mixed-case name from the matched drinks. + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => [drink1, drink2]); + await provider.loadDrinks(); + + await tester.pumpWidget(createTestWidget('ipa')); + await tester.pumpAndSettle(); + + expect(find.text('IPA'), findsWidgets); + expect(find.text('ipa'), findsNothing); + }); + testWidgets('displays drinks with the specified style', (WidgetTester tester) async { when(mockDrinkRepository.getDrinks(any)) From a1b9bd0796aafaabde26903c11169334cee50d54 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 21:18:48 +0000 Subject: [PATCH 2/3] fix(crash): stop reporting transient google_fonts failures as fatal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit google_fonts downloads fonts over HTTP on first use. When the device is offline or the font CDN fails, the load throws an uncaught async error that PlatformDispatcher.onError recorded to Crashlytics with fatal: true. The app keeps running with a fallback font, so this is a transient, non-fatal condition — reporting it as fatal distorts the crash-free metric. Classify google_fonts font-fetch failures (by exception message and by google_fonts stack frames) and record them as non-fatal in both the Flutter and async error handlers. https://claude.ai/code/session_0135nVBYGpwkaQvG13XyS66H --- lib/main.dart | 30 +++++++++++++++++++++++++++--- test/main_test.dart | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 7d31fa17..3ac14e92 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,12 +30,24 @@ void main() async { options: DefaultFirebaseOptions.currentPlatform, ); - // Pass all uncaught Flutter errors to Crashlytics - FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError; + // Pass all uncaught Flutter errors to Crashlytics. Transient google_fonts + // font-fetch failures are downgraded to non-fatal (see + // isTransientFontLoadError). + FlutterError.onError = (details) { + if (isTransientFontLoadError(details.exception, details.stack)) { + FirebaseCrashlytics.instance.recordFlutterError(details); + } else { + FirebaseCrashlytics.instance.recordFlutterFatalError(details); + } + }; // Pass all uncaught asynchronous errors to Crashlytics PlatformDispatcher.instance.onError = (error, stack) { - FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); + FirebaseCrashlytics.instance.recordError( + error, + stack, + fatal: !isTransientFontLoadError(error, stack), + ); return true; }; @@ -49,6 +61,18 @@ void main() async { runApp(const BeerFestivalApp()); } +/// Whether [error] originates from `google_fonts` runtime font fetching. +/// +/// google_fonts downloads fonts over HTTP on first use. When the device is +/// offline or the font CDN fails, the load throws an uncaught async error. +/// The app keeps running with a fallback font, so such failures are transient +/// and non-fatal — they must not be recorded to Crashlytics as fatal crashes, +/// which would otherwise distort the crash-free metric. +bool isTransientFontLoadError(Object error, StackTrace? stack) { + if (error.toString().contains('Failed to load font')) return true; + return stack != null && stack.toString().contains('google_fonts'); +} + class BeerFestivalApp extends StatelessWidget { const BeerFestivalApp({super.key}); diff --git a/test/main_test.dart b/test/main_test.dart index a0256a71..7f5f6491 100644 --- a/test/main_test.dart +++ b/test/main_test.dart @@ -397,4 +397,37 @@ void main() { expect(find.text('Drink Detail'), findsOneWidget); }); }); + + group('isTransientFontLoadError', () { + test('detects google_fonts HTTP fetch failure by message', () { + final error = Exception( + 'Failed to load font with url: https://fonts.gstatic.com/s/a/abc.ttf', + ); + expect(isTransientFontLoadError(error, StackTrace.empty), isTrue); + }); + + test('detects font load failure by google_fonts stack frames', () { + // A network-level exception whose message gives no hint, but whose + // stack trace runs through the google_fonts package. + final stack = StackTrace.fromString( + '#0 _httpFetchFontAndSaveToDevice (package:google_fonts/src/google_fonts_base.dart:288)\n' + '#1 loadFontIfNecessary (package:google_fonts/src/google_fonts_base.dart:175)', + ); + expect( + isTransientFontLoadError(Exception('connection refused'), stack), + isTrue, + ); + }); + + test('does not flag unrelated application errors as font errors', () { + final stack = StackTrace.fromString( + '#0 BeerProvider.loadDrinks (package:cambridge_beer_festival/providers/beer_provider.dart:270)', + ); + expect( + isTransientFontLoadError(Exception('Something went wrong'), stack), + isFalse, + ); + expect(isTransientFontLoadError(StateError('bad state'), null), isFalse); + }); + }); } From 167740287fee1eb0403e063fd86832dc9853bbe2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 21:44:28 +0000 Subject: [PATCH 3/3] fix(provider): re-filter on toggleTasted under the not-tasted filter toggleFavorite re-applies filters when the favourites filter is active, but toggleTasted did not. Marking a drink tasted while the "not tasted" visibility filter was on left the drink stuck in the visible list until the next filter, sort or reload. Re-run filter+sort in toggleTasted when the notTasted filter is active, mirroring toggleFavorite. https://claude.ai/code/session_0135nVBYGpwkaQvG13XyS66H --- lib/providers/beer_provider.dart | 6 ++++++ test/beer_provider_test.dart | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/lib/providers/beer_provider.dart b/lib/providers/beer_provider.dart index 863c1ec2..7a9a1d37 100644 --- a/lib/providers/beer_provider.dart +++ b/lib/providers/beer_provider.dart @@ -598,6 +598,12 @@ class BeerProvider extends ChangeNotifier { ); drink.isTasted = newStatus; + // Re-filter so the drink appears/disappears immediately when the + // not-tasted visibility filter is active. + if (_visibilityFilters.contains(DrinkVisibilityFilter.notTasted)) { + _applyFiltersAndSort(); + } + notifyListeners(); // Log analytics event diff --git a/test/beer_provider_test.dart b/test/beer_provider_test.dart index cad89cb9..9ddf3782 100644 --- a/test/beer_provider_test.dart +++ b/test/beer_provider_test.dart @@ -948,6 +948,36 @@ void main() { expect(provider.drinks.any((d) => d.isTasted), isFalse); }); + test('toggleTasted refreshes filtered list while notTasted filter is active', + () async { + provider = BeerProvider( + drinkRepository: mockDrinkRepository, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + await provider.initialize(); + + final sampleDrinks = createSampleDrinks(); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => sampleDrinks); + await provider.loadDrinks(); + + await provider.setVisibilityFilter(DrinkVisibilityFilter.notTasted, true); + expect(provider.drinks.length, sampleDrinks.length); + + // Mark the first visible drink as tasted via the provider. + final target = provider.drinks.first; + when(mockDrinkRepository.toggleTasted( + provider.currentFestival.id, target.id)) + .thenAnswer((_) async => true); + await provider.toggleTasted(target); + + // With the not-tasted filter active the drink must drop out + // of the visible list immediately. + expect(provider.drinks.length, sampleDrinks.length - 1); + expect(provider.drinks.any((d) => d.id == target.id), isFalse); + }); + test('veganOnly filter shows only vegan drinks', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository,