Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};
Comment on lines +33 to +42

// 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;
};

Expand All @@ -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');
Comment on lines +64 to +73
}


class BeerFestivalApp extends StatelessWidget {
const BeerFestivalApp({super.key});
Expand Down
20 changes: 14 additions & 6 deletions lib/screens/style_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,18 +55,22 @@ class _StyleScreenState extends State<StyleScreen> {
);
}

// 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(
Expand Down Expand Up @@ -97,16 +101,20 @@ class _StyleScreenState extends State<StyleScreen> {
}

/// 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),
Expand All @@ -115,7 +123,7 @@ class _StyleScreenState extends State<StyleScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(
widget.style,
displayStyle,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
Expand Down
33 changes: 33 additions & 0 deletions test/main_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
}
16 changes: 16 additions & 0 deletions test/style_screen_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading