diff --git a/lib/screens/brewery_screen.dart b/lib/screens/brewery_screen.dart index 2d018ac8..ade4ac46 100644 --- a/lib/screens/brewery_screen.dart +++ b/lib/screens/brewery_screen.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import '../providers/providers.dart'; import '../models/models.dart'; @@ -39,25 +38,13 @@ class _BreweryScreenState extends State { }); } - /// Safely check if we can pop (handles test contexts without GoRouter) - bool _canPop(BuildContext context) { - try { - return GoRouter.of(context).canPop(); - } catch (e) { - return false; - } - } - @override Widget build(BuildContext context) { final provider = context.watch(); // Show loading state while drinks are being fetched if (provider.isLoading) { - return Scaffold( - appBar: AppBar(title: const Text('Loading...')), - body: const Center(child: CircularProgressIndicator()), - ); + return buildLoadingScaffold(); } // Get all drinks from this brewery @@ -80,19 +67,7 @@ class _BreweryScreenState extends State { return Scaffold( appBar: AppBar( title: _buildAppBarTitle(context, provider, producer), - leading: _canPop(context) - ? null - : Semantics( - label: 'Go to home screen', - hint: 'Double tap to return to drinks list', - button: true, - child: IconButton( - icon: const Icon(Icons.home), - onPressed: () => - context.go(buildFestivalHome(widget.festivalId)), - tooltip: 'Home', - ), - ), + leading: buildHomeLeadingButton(context, widget.festivalId), ), body: CustomScrollView( slivers: [ @@ -122,25 +97,10 @@ class _BreweryScreenState extends State { BeerProvider provider, Producer producer, ) { - final festivalName = provider.currentFestival.name; - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - producer.name, - style: Theme.of(context).textTheme.titleLarge, - overflow: TextOverflow.ellipsis, - ), - Text( - festivalName, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - overflow: TextOverflow.ellipsis, - ), - ], + return buildBreadcrumbTitle( + context, + title: producer.name, + festivalName: provider.currentFestival.name, ); } diff --git a/lib/screens/drink_detail_screen.dart b/lib/screens/drink_detail_screen.dart index dfca9856..8deab19f 100644 --- a/lib/screens/drink_detail_screen.dart +++ b/lib/screens/drink_detail_screen.dart @@ -46,10 +46,7 @@ class _DrinkDetailScreenState extends State { // Show loading state while drinks are being fetched if (provider.isLoading) { - return Scaffold( - appBar: AppBar(title: const Text('Loading...')), - body: const Center(child: CircularProgressIndicator()), - ); + return buildLoadingScaffold(); } final drink = provider.getDrinkById(widget.drinkId); @@ -66,18 +63,7 @@ class _DrinkDetailScreenState extends State { return Scaffold( appBar: AppBar( title: _buildAppBarTitle(context, provider, drink), - leading: _canPop(context) - ? null - : Semantics( - label: 'Go to home screen', - hint: 'Double tap to return to drinks list', - button: true, - child: IconButton( - icon: const Icon(Icons.home), - onPressed: () => context.go(buildFestivalHome(widget.festivalId)), - tooltip: 'Home', - ), - ), + leading: buildHomeLeadingButton(context, widget.festivalId), ), body: Column( children: [ @@ -475,13 +461,4 @@ class _DrinkDetailScreenState extends State { final provider = context.read(); unawaited(provider.analyticsService.logDrinkShared(drink)); } - - bool _canPop(BuildContext context) { - try { - GoRouter.of(context); - return context.canPop(); - } catch (e) { - return true; - } - } } diff --git a/lib/screens/style_screen.dart b/lib/screens/style_screen.dart index 957504e2..ba5b560d 100644 --- a/lib/screens/style_screen.dart +++ b/lib/screens/style_screen.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import '../providers/providers.dart'; import '../models/models.dart'; @@ -33,25 +32,13 @@ class _StyleScreenState extends State { }); } - /// Safely check if we can pop (handles test contexts without GoRouter) - bool _canPop(BuildContext context) { - try { - return GoRouter.of(context).canPop(); - } catch (e) { - return false; - } - } - @override Widget build(BuildContext context) { final provider = context.watch(); // Show loading state while drinks are being fetched if (provider.isLoading) { - return Scaffold( - appBar: AppBar(title: const Text('Loading...')), - body: const Center(child: CircularProgressIndicator()), - ); + return buildLoadingScaffold(); } // Get all drinks with this style @@ -73,19 +60,7 @@ class _StyleScreenState extends State { return Scaffold( appBar: AppBar( title: _buildAppBarTitle(context, provider), - leading: _canPop(context) - ? null - : Semantics( - label: 'Go to home screen', - hint: 'Double tap to return to drinks list', - button: true, - child: IconButton( - icon: const Icon(Icons.home), - onPressed: () => - context.go(buildFestivalHome(widget.festivalId)), - tooltip: 'Home', - ), - ), + leading: buildHomeLeadingButton(context, widget.festivalId), ), body: CustomScrollView( slivers: [ @@ -123,25 +98,10 @@ class _StyleScreenState extends State { /// Build the app bar title with breadcrumb navigation Widget _buildAppBarTitle(BuildContext context, BeerProvider provider) { - final festivalName = provider.currentFestival.name; - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.style, - style: Theme.of(context).textTheme.titleLarge, - overflow: TextOverflow.ellipsis, - ), - Text( - festivalName, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - overflow: TextOverflow.ellipsis, - ), - ], + return buildBreadcrumbTitle( + context, + title: widget.style, + festivalName: provider.currentFestival.name, ); } diff --git a/lib/utils/navigation_helpers.dart b/lib/utils/navigation_helpers.dart index 5026e440..889e927d 100644 --- a/lib/utils/navigation_helpers.dart +++ b/lib/utils/navigation_helpers.dart @@ -7,6 +7,9 @@ /// characters safely. library; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + /// Builds a festival-scoped URL path. /// /// The [festivalId] and [path] must not be empty. @@ -191,3 +194,25 @@ String? extractFestivalId(String path) { bool isFestivalPath(String path) { return extractFestivalId(path) != null; } + +/// Checks if navigation can pop in the current context. +/// +/// Safely handles contexts where GoRouter may not be available (e.g., in tests). +/// Returns `true` if the router can navigate back, `false` otherwise. +/// +/// This is useful for determining whether to show a back button or a home button +/// in the app bar. +/// +/// Example: +/// ```dart +/// final canPop = canPopNavigation(context); +/// leading: canPop ? null : IconButton(icon: Icon(Icons.home), ...) +/// ``` +bool canPopNavigation(BuildContext context) { + try { + return GoRouter.of(context).canPop(); + } catch (e) { + // GoRouter not available (e.g., in tests) + return false; + } +} diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 93786b00..702eb663 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -6,3 +6,4 @@ export 'string_comparison_helper.dart'; export 'string_formatting_helper.dart'; export 'style_description_helper.dart'; export 'url_launcher_helper.dart'; +export 'widget_builders.dart'; diff --git a/lib/utils/widget_builders.dart b/lib/utils/widget_builders.dart new file mode 100644 index 00000000..ebc6c619 --- /dev/null +++ b/lib/utils/widget_builders.dart @@ -0,0 +1,102 @@ +/// Common widget builders for reducing duplication across screens. +/// +/// This file contains reusable widget builders that are used across multiple +/// screens to maintain consistency and reduce code duplication. +library; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'navigation_helpers.dart'; + +/// Builds a loading scaffold with standard appearance. +/// +/// Used when data is being fetched to show a consistent loading state +/// across all screens. +/// +/// Example: +/// ```dart +/// if (provider.isLoading) { +/// return buildLoadingScaffold(); +/// } +/// ``` +Widget buildLoadingScaffold() { + return Scaffold( + appBar: AppBar(title: const Text('Loading...')), + body: const Center(child: CircularProgressIndicator()), + ); +} + +/// Builds a home button for the AppBar leading position. +/// +/// Shows a home button instead of the back button when navigation cannot pop. +/// This ensures users can always navigate back to the festival home. +/// +/// The [festivalId] is used to navigate to the correct festival home page. +/// +/// Example: +/// ```dart +/// AppBar( +/// leading: buildHomeLeadingButton(context, festivalId), +/// ) +/// ``` +Widget? buildHomeLeadingButton(BuildContext context, String festivalId) { + if (canPopNavigation(context)) { + return null; // Use default back button + } + + return Semantics( + label: 'Go to home screen', + hint: 'Double tap to return to drinks list', + button: true, + child: IconButton( + icon: const Icon(Icons.home), + onPressed: () => context.go(buildFestivalHome(festivalId)), + tooltip: 'Home', + ), + ); +} + +/// Builds a breadcrumb-style title for the AppBar. +/// +/// Shows a primary title with the festival name as a subtitle for context. +/// This provides consistent navigation breadcrumbs across detail screens. +/// +/// The [title] is the main heading (e.g., brewery name, style name, drink name). +/// The [festivalName] appears as a smaller subtitle below the title. +/// +/// Example: +/// ```dart +/// AppBar( +/// title: buildBreadcrumbTitle( +/// context, +/// title: 'IPA', +/// festivalName: 'Cambridge Beer Festival 2025', +/// ), +/// ) +/// ``` +Widget buildBreadcrumbTitle( + BuildContext context, { + required String title, + required String festivalName, +}) { + final theme = Theme.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleLarge, + overflow: TextOverflow.ellipsis, + ), + Text( + festivalName, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ); +} diff --git a/test/goldens/drink_detail_screen_long_name_light.png b/test/goldens/drink_detail_screen_long_name_light.png index ef8047a2..fdfac1e1 100644 Binary files a/test/goldens/drink_detail_screen_long_name_light.png and b/test/goldens/drink_detail_screen_long_name_light.png differ diff --git a/test/goldens/drink_detail_screen_medium_name_light.png b/test/goldens/drink_detail_screen_medium_name_light.png index 804c490c..693c9f6c 100644 Binary files a/test/goldens/drink_detail_screen_medium_name_light.png and b/test/goldens/drink_detail_screen_medium_name_light.png differ diff --git a/test/utils/navigation_helpers_test.dart b/test/utils/navigation_helpers_test.dart index 47330e00..79447a5c 100644 --- a/test/utils/navigation_helpers_test.dart +++ b/test/utils/navigation_helpers_test.dart @@ -1,4 +1,5 @@ import 'package:cambridge_beer_festival/utils/utils.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -315,5 +316,28 @@ void main() { ); }); }); + + group('canPopNavigation', () { + testWidgets('returns false when GoRouter is not available', (tester) async { + // In test environment with MaterialApp but without GoRouter + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + final result = canPopNavigation(context); + return Scaffold( + body: Text('Can pop: $result'), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + // Without GoRouter, canPopNavigation should return false + expect(find.text('Can pop: false'), findsOneWidget); + }); + }); }); } diff --git a/test/utils/widget_builders_test.dart b/test/utils/widget_builders_test.dart new file mode 100644 index 00000000..2b4d4091 --- /dev/null +++ b/test/utils/widget_builders_test.dart @@ -0,0 +1,283 @@ +import 'package:cambridge_beer_festival/utils/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Widget Builders', () { + group('buildLoadingScaffold', () { + testWidgets('creates scaffold with loading indicator', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: buildLoadingScaffold(), + ), + ); + + // Verify AppBar with "Loading..." title + expect(find.text('Loading...'), findsOneWidget); + expect(find.byType(AppBar), findsOneWidget); + + // Verify CircularProgressIndicator + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Verify it's centered + expect(find.byType(Center), findsOneWidget); + }); + + testWidgets('has correct widget structure', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: buildLoadingScaffold(), + ), + ); + + // Verify Scaffold is the root + expect(find.byType(Scaffold), findsOneWidget); + + // Verify structure: Scaffold > AppBar + body + final scaffold = tester.widget(find.byType(Scaffold)); + expect(scaffold.appBar, isNotNull); + expect(scaffold.body, isNotNull); + }); + }); + + group('buildHomeLeadingButton', () { + testWidgets('returns widget with home icon when called', (tester) async { + Widget? result; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + result = buildHomeLeadingButton(context, 'cbf2025'); + // If result is null, show placeholder + return Scaffold( + appBar: AppBar( + leading: result ?? const Icon(Icons.error), + ), + body: const Text('Test'), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + // In test environment without GoRouter, canPopNavigation returns false + // So buildHomeLeadingButton should return a home button widget + expect(result, isNotNull); + + // Verify home icon is present + expect(find.byIcon(Icons.home), findsOneWidget); + expect(find.byType(IconButton), findsOneWidget); + }); + + testWidgets('home button has correct semantics', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + final widget = buildHomeLeadingButton(context, 'cbf2025'); + return Scaffold( + appBar: AppBar(leading: widget), + body: const Text('Test'), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + // Find all Semantics widgets that are ancestors of the home icon + final semanticsList = tester.widgetList( + find.ancestor( + of: find.byIcon(Icons.home), + matching: find.byType(Semantics), + ), + ); + + // Find the one with our custom label + final customSemantics = semanticsList.firstWhere( + (s) => s.properties.label == 'Go to home screen', + ); + + expect(customSemantics.properties.label, equals('Go to home screen')); + expect(customSemantics.properties.hint, equals('Double tap to return to drinks list')); + expect(customSemantics.properties.button, isTrue); + }); + + testWidgets('home button has tooltip', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + final widget = buildHomeLeadingButton(context, 'cbf2025'); + return Scaffold( + appBar: AppBar(leading: widget), + body: const Text('Test'), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + // Find IconButton and check tooltip + final iconButton = tester.widget(find.byType(IconButton)); + expect(iconButton.tooltip, equals('Home')); + }); + }); + + group('buildBreadcrumbTitle', () { + testWidgets('displays title and festival name', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + appBar: AppBar( + title: buildBreadcrumbTitle( + context, + title: 'IPA Beers', + festivalName: 'Cambridge Beer Festival 2025', + ), + ), + body: const Text('Body'), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('IPA Beers'), findsOneWidget); + expect(find.text('Cambridge Beer Festival 2025'), findsOneWidget); + }); + + testWidgets('uses correct text styles', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + appBar: AppBar( + title: buildBreadcrumbTitle( + context, + title: 'Test Title', + festivalName: 'Test Festival', + ), + ), + body: const Text('Body'), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + // Find the Column widget that contains both texts + final column = tester.widget( + find.descendant( + of: find.byType(AppBar), + matching: find.byType(Column), + ), + ); + + expect(column.mainAxisSize, equals(MainAxisSize.min)); + expect(column.crossAxisAlignment, equals(CrossAxisAlignment.start)); + + // Verify two Text widgets exist + final textWidgets = find.descendant( + of: find.byType(Column), + matching: find.byType(Text), + ); + expect(textWidgets, findsNWidgets(2)); + }); + + testWidgets('handles long text with ellipsis', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + appBar: AppBar( + title: buildBreadcrumbTitle( + context, + title: 'A' * 100, // Very long title + festivalName: 'B' * 100, // Very long festival name + ), + ), + body: const Text('Body'), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + // Find all Text widgets in the AppBar + final textWidgets = tester.widgetList( + find.descendant( + of: find.byType(AppBar), + matching: find.byType(Text), + ), + ); + + // Both text widgets should have ellipsis overflow + for (final text in textWidgets) { + expect(text.overflow, equals(TextOverflow.ellipsis)); + } + }); + + testWidgets('uses theme-appropriate colors', (tester) async { + late BuildContext capturedContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + capturedContext = context; + return Scaffold( + appBar: AppBar( + title: buildBreadcrumbTitle( + context, + title: 'Test Title', + festivalName: 'Test Festival', + ), + ), + body: const Text('Body'), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + final theme = Theme.of(capturedContext); + + // Find the festival name text (should be the second Text widget) + final textWidgets = tester.widgetList( + find.descendant( + of: find.byType(AppBar), + matching: find.byType(Text), + ), + ).toList(); + + expect(textWidgets.length, equals(2)); + + // First text should use titleLarge + expect(textWidgets[0].style?.fontSize, equals(theme.textTheme.titleLarge?.fontSize)); + + // Second text should use bodySmall with onSurfaceVariant color + expect(textWidgets[1].style?.fontSize, equals(theme.textTheme.bodySmall?.fontSize)); + expect(textWidgets[1].style?.color, equals(theme.colorScheme.onSurfaceVariant)); + }); + }); + }); +}