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/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/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/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, 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); + }); + }); } 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))