Skip to content

Commit e3fdb5c

Browse files
fix: style screen title casing and google_fonts crash reporting (#295)
* 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 * fix(crash): stop reporting transient google_fonts failures as fatal 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 * 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9a5394d commit e3fdb5c

6 files changed

Lines changed: 126 additions & 9 deletions

File tree

lib/main.dart

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,24 @@ void main() async {
3030
options: DefaultFirebaseOptions.currentPlatform,
3131
);
3232

33-
// Pass all uncaught Flutter errors to Crashlytics
34-
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
33+
// Pass all uncaught Flutter errors to Crashlytics. Transient google_fonts
34+
// font-fetch failures are downgraded to non-fatal (see
35+
// isTransientFontLoadError).
36+
FlutterError.onError = (details) {
37+
if (isTransientFontLoadError(details.exception, details.stack)) {
38+
FirebaseCrashlytics.instance.recordFlutterError(details);
39+
} else {
40+
FirebaseCrashlytics.instance.recordFlutterFatalError(details);
41+
}
42+
};
3543

3644
// Pass all uncaught asynchronous errors to Crashlytics
3745
PlatformDispatcher.instance.onError = (error, stack) {
38-
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
46+
FirebaseCrashlytics.instance.recordError(
47+
error,
48+
stack,
49+
fatal: !isTransientFontLoadError(error, stack),
50+
);
3951
return true;
4052
};
4153

@@ -49,6 +61,18 @@ void main() async {
4961
runApp(const BeerFestivalApp());
5062
}
5163

64+
/// Whether [error] originates from `google_fonts` runtime font fetching.
65+
///
66+
/// google_fonts downloads fonts over HTTP on first use. When the device is
67+
/// offline or the font CDN fails, the load throws an uncaught async error.
68+
/// The app keeps running with a fallback font, so such failures are transient
69+
/// and non-fatal — they must not be recorded to Crashlytics as fatal crashes,
70+
/// which would otherwise distort the crash-free metric.
71+
bool isTransientFontLoadError(Object error, StackTrace? stack) {
72+
if (error.toString().contains('Failed to load font')) return true;
73+
return stack != null && stack.toString().contains('google_fonts');
74+
}
75+
5276

5377
class BeerFestivalApp extends StatelessWidget {
5478
const BeerFestivalApp({super.key});

lib/providers/beer_provider.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,12 @@ class BeerProvider extends ChangeNotifier {
598598
);
599599
drink.isTasted = newStatus;
600600

601+
// Re-filter so the drink appears/disappears immediately when the
602+
// not-tasted visibility filter is active.
603+
if (_visibilityFilters.contains(DrinkVisibilityFilter.notTasted)) {
604+
_applyFiltersAndSort();
605+
}
606+
601607
notifyListeners();
602608

603609
// Log analytics event

lib/screens/style_screen.dart

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,18 +55,22 @@ class _StyleScreenState extends State<StyleScreen> {
5555
);
5656
}
5757

58+
// Style URLs use a lowercase canonical form, so widget.style may be
59+
// lowercased. Display the original mixed-case name from a matched drink.
60+
final displayStyle = styleDrinks.first.style ?? widget.style;
61+
5862
final theme = Theme.of(context);
5963

6064
return Scaffold(
6165
appBar: AppBar(
62-
title: _buildAppBarTitle(context, provider),
66+
title: _buildAppBarTitle(context, provider, displayStyle),
6367
leading: buildHomeLeadingButton(context, widget.festivalId),
6468
),
6569
body: CustomScrollView(
6670
slivers: [
6771
// Header section
6872
SliverToBoxAdapter(
69-
child: _buildHeader(context, theme),
73+
child: _buildHeader(context, theme, displayStyle),
7074
),
7175
// Hero info card
7276
SliverToBoxAdapter(
@@ -97,16 +101,20 @@ class _StyleScreenState extends State<StyleScreen> {
97101
}
98102

99103
/// Build the app bar title with breadcrumb navigation
100-
Widget _buildAppBarTitle(BuildContext context, BeerProvider provider) {
104+
Widget _buildAppBarTitle(
105+
BuildContext context,
106+
BeerProvider provider,
107+
String displayStyle,
108+
) {
101109
return buildBreadcrumbTitle(
102110
context,
103-
title: widget.style,
111+
title: displayStyle,
104112
festivalName: provider.currentFestival.name,
105113
);
106114
}
107115

108116
/// Build clean white header with style name
109-
Widget _buildHeader(BuildContext context, ThemeData theme) {
117+
Widget _buildHeader(BuildContext context, ThemeData theme, String displayStyle) {
110118
return Container(
111119
width: double.infinity,
112120
padding: const EdgeInsets.all(24.0),
@@ -115,7 +123,7 @@ class _StyleScreenState extends State<StyleScreen> {
115123
crossAxisAlignment: CrossAxisAlignment.start,
116124
children: [
117125
SelectableText(
118-
widget.style,
126+
displayStyle,
119127
style: theme.textTheme.headlineSmall?.copyWith(
120128
fontWeight: FontWeight.bold,
121129
color: theme.colorScheme.onSurface,

test/beer_provider_test.dart

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,36 @@ void main() {
948948
expect(provider.drinks.any((d) => d.isTasted), isFalse);
949949
});
950950

951+
test('toggleTasted refreshes filtered list while notTasted filter is active',
952+
() async {
953+
provider = BeerProvider(
954+
drinkRepository: mockDrinkRepository,
955+
festivalRepository: mockFestivalRepository,
956+
analyticsService: mockAnalyticsService,
957+
);
958+
await provider.initialize();
959+
960+
final sampleDrinks = createSampleDrinks();
961+
when(mockDrinkRepository.getDrinks(any))
962+
.thenAnswer((_) async => sampleDrinks);
963+
await provider.loadDrinks();
964+
965+
await provider.setVisibilityFilter(DrinkVisibilityFilter.notTasted, true);
966+
expect(provider.drinks.length, sampleDrinks.length);
967+
968+
// Mark the first visible drink as tasted via the provider.
969+
final target = provider.drinks.first;
970+
when(mockDrinkRepository.toggleTasted(
971+
provider.currentFestival.id, target.id))
972+
.thenAnswer((_) async => true);
973+
await provider.toggleTasted(target);
974+
975+
// With the not-tasted filter active the drink must drop out
976+
// of the visible list immediately.
977+
expect(provider.drinks.length, sampleDrinks.length - 1);
978+
expect(provider.drinks.any((d) => d.id == target.id), isFalse);
979+
});
980+
951981
test('veganOnly filter shows only vegan drinks', () async {
952982
provider = BeerProvider(
953983
drinkRepository: mockDrinkRepository,

test/main_test.dart

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,4 +397,37 @@ void main() {
397397
expect(find.text('Drink Detail'), findsOneWidget);
398398
});
399399
});
400+
401+
group('isTransientFontLoadError', () {
402+
test('detects google_fonts HTTP fetch failure by message', () {
403+
final error = Exception(
404+
'Failed to load font with url: https://fonts.gstatic.com/s/a/abc.ttf',
405+
);
406+
expect(isTransientFontLoadError(error, StackTrace.empty), isTrue);
407+
});
408+
409+
test('detects font load failure by google_fonts stack frames', () {
410+
// A network-level exception whose message gives no hint, but whose
411+
// stack trace runs through the google_fonts package.
412+
final stack = StackTrace.fromString(
413+
'#0 _httpFetchFontAndSaveToDevice (package:google_fonts/src/google_fonts_base.dart:288)\n'
414+
'#1 loadFontIfNecessary (package:google_fonts/src/google_fonts_base.dart:175)',
415+
);
416+
expect(
417+
isTransientFontLoadError(Exception('connection refused'), stack),
418+
isTrue,
419+
);
420+
});
421+
422+
test('does not flag unrelated application errors as font errors', () {
423+
final stack = StackTrace.fromString(
424+
'#0 BeerProvider.loadDrinks (package:cambridge_beer_festival/providers/beer_provider.dart:270)',
425+
);
426+
expect(
427+
isTransientFontLoadError(Exception('Something went wrong'), stack),
428+
isFalse,
429+
);
430+
expect(isTransientFontLoadError(StateError('bad state'), null), isFalse);
431+
});
432+
});
400433
}

test/style_screen_test.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,22 @@ void main() {
130130
expect(find.textContaining('Festival'), findsWidgets);
131131
});
132132

133+
testWidgets('displays original mixed-case style name for a lowercase URL param',
134+
(WidgetTester tester) async {
135+
// Style URLs use a lowercase canonical form (see buildStylePath), so the
136+
// router passes a lowercased style. The screen must still display the
137+
// original mixed-case name from the matched drinks.
138+
when(mockDrinkRepository.getDrinks(any))
139+
.thenAnswer((_) async => [drink1, drink2]);
140+
await provider.loadDrinks();
141+
142+
await tester.pumpWidget(createTestWidget('ipa'));
143+
await tester.pumpAndSettle();
144+
145+
expect(find.text('IPA'), findsWidgets);
146+
expect(find.text('ipa'), findsNothing);
147+
});
148+
133149
testWidgets('displays drinks with the specified style',
134150
(WidgetTester tester) async {
135151
when(mockDrinkRepository.getDrinks(any))

0 commit comments

Comments
 (0)