From bdf77a3c353cedf443293a0dc9d4941454076618 Mon Sep 17 00:00:00 2001 From: Mark Fairless Date: Thu, 8 Aug 2024 12:56:52 -0500 Subject: [PATCH] refactor: go router (#27) * feat: implement GoRouter * test: update tests * feat: add slide animation and remove path strategy --- .../airplane_entertainment_system_screen.dart | 254 +++++------------- lib/app/view/app.dart | 11 +- lib/app_router/app_router.dart | 31 +++ lib/app_router/routes.dart | 79 ++++++ lib/app_router/routes.g.dart | 77 ++++++ lib/main_development.dart | 6 + lib/main_production.dart | 8 + lib/main_staging.dart | 6 + lib/music_player/view/music_player_page.dart | 1 + pubspec.lock | 34 ++- pubspec.yaml | 3 + ...lane_entertainment_system_screen_test.dart | 130 +++++++-- test/helpers/pump_experience.dart | 8 +- 13 files changed, 442 insertions(+), 206 deletions(-) create mode 100644 lib/app_router/app_router.dart create mode 100644 lib/app_router/routes.dart create mode 100644 lib/app_router/routes.g.dart diff --git a/lib/airplane_entertainment_system/view/airplane_entertainment_system_screen.dart b/lib/airplane_entertainment_system/view/airplane_entertainment_system_screen.dart index fd97353..5bbda8b 100644 --- a/lib/airplane_entertainment_system/view/airplane_entertainment_system_screen.dart +++ b/lib/airplane_entertainment_system/view/airplane_entertainment_system_screen.dart @@ -2,13 +2,22 @@ import 'package:aes_ui/aes_ui.dart'; import 'package:airplane_entertainment_system/airplane_entertainment_system/airplane_entertainment_system.dart'; import 'package:airplane_entertainment_system/l10n/l10n.dart'; import 'package:airplane_entertainment_system/music_player/music_player.dart'; -import 'package:airplane_entertainment_system/overview/overview.dart'; import 'package:airplane_entertainment_system/weather/weather.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; class AirplaneEntertainmentSystemScreen extends StatelessWidget { - const AirplaneEntertainmentSystemScreen({super.key}); + const AirplaneEntertainmentSystemScreen({ + required this.navigationShell, + required this.children, + super.key, + }); + + final StatefulNavigationShell navigationShell; + + final List children; @override Widget build(BuildContext context) { @@ -26,23 +35,24 @@ class AirplaneEntertainmentSystemScreen extends StatelessWidget { )..initialize(), ), ], - child: const AirplaneEntertainmentSystemView(), + child: AirplaneEntertainmentSystemView( + navigationShell, + children, + ), ); } } -class AirplaneEntertainmentSystemView extends StatefulWidget { - @visibleForTesting - const AirplaneEntertainmentSystemView({super.key}); +class AirplaneEntertainmentSystemView extends StatelessWidget { + const AirplaneEntertainmentSystemView( + this.navigationShell, + this.children, { + super.key, + }); - @override - State createState() => - _AirplaneEntertainmentSystemViewState(); -} + final StatefulNavigationShell navigationShell; -class _AirplaneEntertainmentSystemViewState - extends State { - int _currentPage = 0; + final List children; @override Widget build(BuildContext context) { @@ -67,7 +77,7 @@ class _AirplaneEntertainmentSystemViewState children: [ Positioned.fill( child: SystemBackground( - page: _currentPage, + page: navigationShell.currentIndex, ), ), SafeArea( @@ -80,20 +90,19 @@ class _AirplaneEntertainmentSystemViewState if (layout != AesLayoutData.small) AesNavigationRail( destinations: destinations, - selectedIndex: _currentPage, - onDestinationSelected: (value) { - setState(() { - _currentPage = value; - }); + selectedIndex: navigationShell.currentIndex, + onDestinationSelected: (index) { + navigationShell.goBranch( + index, + initialLocation: + index == navigationShell.currentIndex, + ); }, ), Expanded( - child: _ContentPageView( - pageSize: Size( - constraints.maxWidth, - constraints.maxHeight, - ), - pageIndex: _currentPage, + child: _AnimatedBranchContainer( + currentIndex: navigationShell.currentIndex, + children: children, ), ), ], @@ -110,7 +119,7 @@ class _AirplaneEntertainmentSystemViewState child: IgnorePointer( child: AnimatedOpacity( duration: const Duration(milliseconds: 600), - opacity: _currentPage == 0 ? 0.8 : 0, + opacity: navigationShell.currentIndex == 0 ? 0.8 : 0, child: const WeatherClouds( key: Key('foregroundClouds'), count: 3, @@ -127,11 +136,12 @@ class _AirplaneEntertainmentSystemViewState bottomNavigationBar: (layout == AesLayoutData.small) ? AesBottomNavigationBar( destinations: destinations, - selectedIndex: _currentPage, - onDestinationSelected: (value) { - setState(() { - _currentPage = value; - }); + selectedIndex: navigationShell.currentIndex, + onDestinationSelected: (index) { + navigationShell.goBranch( + index, + initialLocation: index == navigationShell.currentIndex, + ); }, ) : null, @@ -139,169 +149,53 @@ class _AirplaneEntertainmentSystemViewState } } -class _ContentPageView extends StatefulWidget { - const _ContentPageView({ - required this.pageSize, - required this.pageIndex, +class _AnimatedBranchContainer extends StatelessWidget { + const _AnimatedBranchContainer({ + required this.currentIndex, + required this.children, }); - final Size pageSize; - final int pageIndex; - - @override - State<_ContentPageView> createState() => _ContentPageViewState(); -} - -class _ContentPageViewState extends State<_ContentPageView> - with SingleTickerProviderStateMixin { - static const _pages = [ - OverviewPage(key: Key('overviewPage')), - MusicPlayerPage(key: Key('musicPlayerPage')), - ]; - - late final AnimationController _controller = AnimationController( - vsync: this, - value: 1, - duration: const Duration(milliseconds: 600), - ); - late int _previousPage; - late int _currentPage; + final int currentIndex; - @override - void initState() { - super.initState(); - - _previousPage = widget.pageIndex; - _currentPage = widget.pageIndex; - - _controller.addStatusListener((status) { - if (status == AnimationStatus.completed) { - setState(() { - _previousPage = _currentPage; - }); - } - }); - } - - @override - void didUpdateWidget(covariant _ContentPageView oldWidget) { - super.didUpdateWidget(oldWidget); - - if (widget.pageIndex != oldWidget.pageIndex) { - setState(() { - _previousPage = oldWidget.pageIndex; - _currentPage = widget.pageIndex; - }); - _controller.forward(from: 0); - } - } - - @override - void dispose() { - _controller.dispose(); - - super.dispose(); - } + final List children; @override Widget build(BuildContext context) { final isSmall = AesLayout.of(context) == AesLayoutData.small; - final pageSize = widget.pageSize; - final pageSide = isSmall ? pageSize.width : pageSize.height; - final pageOffset = pageSide / 4; final axis = isSmall ? Axis.horizontal : Axis.vertical; return Stack( - children: [ - if (_previousPage != _currentPage) - _PositionedFadeTransition( - axis: axis, - positionAnimation: _controller.drive( - CurveTween( - curve: Curves.easeInOut, - ), + children: children.mapIndexed( + (int index, Widget navigator) { + return AnimatedSlide( + duration: const Duration(milliseconds: 600), + curve: index == currentIndex ? Curves.easeOut : Curves.easeInOut, + offset: Offset( + axis == Axis.horizontal + ? index == currentIndex + ? 0 + : 0.25 + : 0, + axis == Axis.vertical + ? index == currentIndex + ? 0 + : 0.25 + : 0, ), - opacityAnimation: _controller.drive( - Tween(begin: 1, end: 0).chain( - CurveTween( - curve: const Interval( - 0, - 0.5, - curve: Curves.easeOut, - ), + child: AnimatedOpacity( + opacity: index == currentIndex ? 1 : 0, + duration: const Duration(milliseconds: 300), + child: IgnorePointer( + ignoring: index != currentIndex, + child: TickerMode( + enabled: index == currentIndex, + child: navigator, ), ), ), - pageSize: widget.pageSize, - endOffset: _currentPage > _previousPage ? -pageOffset : pageOffset, - child: _pages[_previousPage], - ), - _PositionedFadeTransition( - axis: axis, - positionAnimation: _controller.drive( - CurveTween( - curve: Curves.easeInOut, - ), - ), - opacityAnimation: _controller.drive( - CurveTween( - curve: const Interval( - 0.5, - 1, - curve: Curves.easeIn, - ), - ), - ), - pageSize: widget.pageSize, - beginOffset: _currentPage > _previousPage ? pageOffset : -pageOffset, - child: _pages[_currentPage], - ), - ], - ); - } -} - -class _PositionedFadeTransition extends StatelessWidget { - const _PositionedFadeTransition({ - required this.positionAnimation, - required this.opacityAnimation, - required this.pageSize, - required this.child, - required this.axis, - this.beginOffset = 0, - this.endOffset = 0, - }); - - final Animation positionAnimation; - final Animation opacityAnimation; - final Size pageSize; - final Widget child; - final double beginOffset; - final double endOffset; - final Axis axis; - - @override - Widget build(BuildContext context) { - final begin = axis == Axis.horizontal - ? RelativeRect.fromLTRB(beginOffset, 0, -beginOffset, 0) - : RelativeRect.fromLTRB(0, beginOffset, 0, -beginOffset); - - final end = axis == Axis.horizontal - ? RelativeRect.fromLTRB(endOffset, 0, -endOffset, 0) - : RelativeRect.fromLTRB(0, endOffset, 0, -endOffset); - - return PositionedTransition( - rect: positionAnimation.drive( - RelativeRectTween(begin: begin, end: end), - ), - child: FadeTransition( - key: ValueKey(child.key), - opacity: opacityAnimation, - child: SizedBox.fromSize( - size: pageSize, - child: child, - ), - ), + ); + }, + ).toList(), ); } } diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 0ed46d7..3b839ff 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -1,5 +1,5 @@ import 'package:aes_ui/aes_ui.dart'; -import 'package:airplane_entertainment_system/airplane_entertainment_system/airplane_entertainment_system.dart'; +import 'package:airplane_entertainment_system/app_router/app_router.dart'; import 'package:airplane_entertainment_system/l10n/l10n.dart'; import 'package:audioplayers/audioplayers.dart'; import 'package:flight_information_repository/flight_information_repository.dart'; @@ -14,16 +14,19 @@ class App extends StatelessWidget { required MusicRepository musicRepository, required AudioPlayer audioPlayer, required FlightInformationRepository flightInformationRepository, + required AppRouter appRouter, super.key, }) : _weatherRepository = weatherRepository, _musicRepository = musicRepository, _audioPlayer = audioPlayer, - _flightInformationRepository = flightInformationRepository; + _flightInformationRepository = flightInformationRepository, + _appRouter = appRouter; final WeatherRepository _weatherRepository; final MusicRepository _musicRepository; final AudioPlayer _audioPlayer; final FlightInformationRepository _flightInformationRepository; + final AppRouter _appRouter; @override Widget build(BuildContext context) { @@ -43,11 +46,11 @@ class App extends StatelessWidget { ), ], child: AesLayout( - child: MaterialApp( + child: MaterialApp.router( theme: const AesTheme().themeData, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: const AirplaneEntertainmentSystemScreen(), + routerConfig: _appRouter.routes, ), ), ); diff --git a/lib/app_router/app_router.dart b/lib/app_router/app_router.dart new file mode 100644 index 0000000..3a206d4 --- /dev/null +++ b/lib/app_router/app_router.dart @@ -0,0 +1,31 @@ +import 'package:airplane_entertainment_system/app_router/routes.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class AppRouter { + AppRouter({ + required GlobalKey navigatorKey, + bool debugLogDiagnostics = false, + }) { + _goRouter = _routes( + navigatorKey, + debugLogDiagnostics, + ); + } + + late final GoRouter _goRouter; + + GoRouter get routes => _goRouter; + + GoRouter _routes( + GlobalKey navigatorKey, + bool debugLogDiagnostics, + ) { + return GoRouter( + navigatorKey: navigatorKey, + initialLocation: const OverviewPageRouteData().location, + debugLogDiagnostics: debugLogDiagnostics, + routes: $appRoutes, + ); + } +} diff --git a/lib/app_router/routes.dart b/lib/app_router/routes.dart new file mode 100644 index 0000000..2eae1ca --- /dev/null +++ b/lib/app_router/routes.dart @@ -0,0 +1,79 @@ +import 'package:airplane_entertainment_system/airplane_entertainment_system/airplane_entertainment_system.dart'; +import 'package:airplane_entertainment_system/music_player/music_player.dart'; +import 'package:airplane_entertainment_system/overview/overview.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +part 'routes.g.dart'; + +@TypedStatefulShellRoute( + branches: [ + TypedStatefulShellBranch( + routes: [ + TypedGoRoute( + name: 'overview', + path: '/overview', + ), + ], + ), + TypedStatefulShellBranch( + routes: [ + TypedGoRoute( + name: 'music', + path: '/music', + ), + ], + ), + ], +) +@immutable +class HomeScreenRouteData extends StatefulShellRouteData { + const HomeScreenRouteData(); + + @override + Widget builder( + BuildContext context, + GoRouterState state, + StatefulNavigationShell navigationShell, + ) => + navigationShell; + + static Widget $navigatorContainerBuilder( + BuildContext context, + StatefulNavigationShell navigationShell, + List children, + ) { + return AirplaneEntertainmentSystemScreen( + navigationShell: navigationShell, + children: children, + ); + } +} + +@immutable +class OverviewPageBranchData extends StatefulShellBranchData { + const OverviewPageBranchData(); +} + +@immutable +class OverviewPageRouteData extends GoRouteData { + const OverviewPageRouteData(); + + @override + Widget build(BuildContext context, GoRouterState state) => + const OverviewPage(); +} + +@immutable +class MusicPageBranchData extends StatefulShellBranchData { + const MusicPageBranchData(); +} + +@immutable +class MusicPlayerPageRouteData extends GoRouteData { + const MusicPlayerPageRouteData(); + + @override + Widget build(BuildContext context, GoRouterState state) => + const MusicPlayerPage(); +} diff --git a/lib/app_router/routes.g.dart b/lib/app_router/routes.g.dart new file mode 100644 index 0000000..9b50767 --- /dev/null +++ b/lib/app_router/routes.g.dart @@ -0,0 +1,77 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'routes.dart'; + +// ************************************************************************** +// GoRouterGenerator +// ************************************************************************** + +List get $appRoutes => [ + $homeScreenRouteData, + ]; + +RouteBase get $homeScreenRouteData => StatefulShellRouteData.$route( + navigatorContainerBuilder: HomeScreenRouteData.$navigatorContainerBuilder, + factory: $HomeScreenRouteDataExtension._fromState, + branches: [ + StatefulShellBranchData.$branch( + routes: [ + GoRouteData.$route( + path: '/overview', + name: 'overview', + factory: $OverviewPageRouteDataExtension._fromState, + ), + ], + ), + StatefulShellBranchData.$branch( + routes: [ + GoRouteData.$route( + path: '/music', + name: 'music', + factory: $MusicPlayerPageRouteDataExtension._fromState, + ), + ], + ), + ], + ); + +extension $HomeScreenRouteDataExtension on HomeScreenRouteData { + static HomeScreenRouteData _fromState(GoRouterState state) => + const HomeScreenRouteData(); +} + +extension $OverviewPageRouteDataExtension on OverviewPageRouteData { + static OverviewPageRouteData _fromState(GoRouterState state) => + const OverviewPageRouteData(); + + String get location => GoRouteData.$location( + '/overview', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + +extension $MusicPlayerPageRouteDataExtension on MusicPlayerPageRouteData { + static MusicPlayerPageRouteData _fromState(GoRouterState state) => + const MusicPlayerPageRouteData(); + + String get location => GoRouteData.$location( + '/music', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} diff --git a/lib/main_development.dart b/lib/main_development.dart index 36bc188..74416b4 100644 --- a/lib/main_development.dart +++ b/lib/main_development.dart @@ -1,8 +1,10 @@ import 'package:airplane_entertainment_system/app/app.dart'; +import 'package:airplane_entertainment_system/app_router/app_router.dart'; import 'package:airplane_entertainment_system/bootstrap.dart'; import 'package:audioplayers/audioplayers.dart'; import 'package:flight_api_client/flight_api_client.dart'; import 'package:flight_information_repository/flight_information_repository.dart'; +import 'package:flutter/material.dart'; import 'package:music_repository/music_repository.dart'; import 'package:weather_api_client/weather_api_client.dart'; import 'package:weather_repository/weather_repository.dart'; @@ -14,12 +16,16 @@ void main() { final audioPlayer = AudioPlayer(); final flightInformationRepository = FlightInformationRepository(FlightApiClient()); + final appRouter = AppRouter( + navigatorKey: GlobalKey(), + ); return App( weatherRepository: weatherRepository, musicRepository: musicRepository, audioPlayer: audioPlayer, flightInformationRepository: flightInformationRepository, + appRouter: appRouter, ); }); } diff --git a/lib/main_production.dart b/lib/main_production.dart index 36bc188..dbd81a7 100644 --- a/lib/main_production.dart +++ b/lib/main_production.dart @@ -1,8 +1,11 @@ import 'package:airplane_entertainment_system/app/app.dart'; +import 'package:airplane_entertainment_system/app_router/app_router.dart'; import 'package:airplane_entertainment_system/bootstrap.dart'; import 'package:audioplayers/audioplayers.dart'; import 'package:flight_api_client/flight_api_client.dart'; import 'package:flight_information_repository/flight_information_repository.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:music_repository/music_repository.dart'; import 'package:weather_api_client/weather_api_client.dart'; import 'package:weather_repository/weather_repository.dart'; @@ -14,12 +17,17 @@ void main() { final audioPlayer = AudioPlayer(); final flightInformationRepository = FlightInformationRepository(FlightApiClient()); + final appRouter = AppRouter( + navigatorKey: GlobalKey(), + debugLogDiagnostics: kDebugMode, + ); return App( weatherRepository: weatherRepository, musicRepository: musicRepository, audioPlayer: audioPlayer, flightInformationRepository: flightInformationRepository, + appRouter: appRouter, ); }); } diff --git a/lib/main_staging.dart b/lib/main_staging.dart index 36bc188..74416b4 100644 --- a/lib/main_staging.dart +++ b/lib/main_staging.dart @@ -1,8 +1,10 @@ import 'package:airplane_entertainment_system/app/app.dart'; +import 'package:airplane_entertainment_system/app_router/app_router.dart'; import 'package:airplane_entertainment_system/bootstrap.dart'; import 'package:audioplayers/audioplayers.dart'; import 'package:flight_api_client/flight_api_client.dart'; import 'package:flight_information_repository/flight_information_repository.dart'; +import 'package:flutter/material.dart'; import 'package:music_repository/music_repository.dart'; import 'package:weather_api_client/weather_api_client.dart'; import 'package:weather_repository/weather_repository.dart'; @@ -14,12 +16,16 @@ void main() { final audioPlayer = AudioPlayer(); final flightInformationRepository = FlightInformationRepository(FlightApiClient()); + final appRouter = AppRouter( + navigatorKey: GlobalKey(), + ); return App( weatherRepository: weatherRepository, musicRepository: musicRepository, audioPlayer: audioPlayer, flightInformationRepository: flightInformationRepository, + appRouter: appRouter, ); }); } diff --git a/lib/music_player/view/music_player_page.dart b/lib/music_player/view/music_player_page.dart index 526095f..059e1a4 100644 --- a/lib/music_player/view/music_player_page.dart +++ b/lib/music_player/view/music_player_page.dart @@ -157,6 +157,7 @@ class _MusicBottomSheet extends StatelessWidget { builder: (context, track) { if (track == null) return const SizedBox(); return Row( + mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/pubspec.lock b/pubspec.lock index a92ffcf..cfc00a8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -222,7 +222,7 @@ packages: source: hosted version: "4.10.0" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a @@ -399,6 +399,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: d380de0355788c5c784fe9f81b43fc833b903991c25ecc4e2a416a67faefa722 + url: "https://pub.dev" + source: hosted + version: "14.2.2" + go_router_builder: + dependency: "direct dev" + description: + name: go_router_builder + sha256: "51eca134e77d84c78a5297242ed45dc6988c66531a97cb4bf49d149e8f60d809" + url: "https://pub.dev" + source: hosted + version: "2.7.0" graphs: dependency: transitive description: @@ -755,6 +771,22 @@ packages: description: flutter source: sdk version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" + url: "https://pub.dev" + source: hosted + version: "1.3.4" source_map_stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ace9db7..9223ade 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: path: packages/aes_ui audioplayers: ^6.0.0 bloc: ^8.1.4 + collection: ^1.18.0 equatable: ^2.0.5 flight_api_client: path: packages/flight_api_client @@ -21,6 +22,7 @@ dependencies: flutter_bloc: ^8.1.6 flutter_localizations: sdk: flutter + go_router: ^14.2.2 intl: ^0.19.0 music_repository: path: packages/music_repository @@ -35,6 +37,7 @@ dev_dependencies: flutter_gen_runner: ^5.6.0 flutter_test: sdk: flutter + go_router_builder: ^2.7.0 mocktail: ^1.0.4 very_good_analysis: ^6.0.0 diff --git a/test/airplane_entertainment_system/view/airplane_entertainment_system_screen_test.dart b/test/airplane_entertainment_system/view/airplane_entertainment_system_screen_test.dart index 3ad262b..84a866f 100644 --- a/test/airplane_entertainment_system/view/airplane_entertainment_system_screen_test.dart +++ b/test/airplane_entertainment_system/view/airplane_entertainment_system_screen_test.dart @@ -8,6 +8,7 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; import 'package:music_repository/music_repository.dart'; import 'package:weather_repository/weather_repository.dart'; @@ -18,6 +19,14 @@ class _MockAudioCache extends Mock implements AudioCache {} class _MockAudioPlayer extends Mock implements AudioPlayer {} +class _MockStatefulNavigationShell extends Mock + implements StatefulNavigationShell { + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return ''; + } +} + class _MockWeatherBloc extends MockBloc implements WeatherBloc {} @@ -29,6 +38,7 @@ void main() { late WeatherRepository weatherRepository; late MusicRepository musicRepository; late AudioPlayer audioPlayer; + late StatefulNavigationShell navigationShell; setUp(() { weatherRepository = MockWeatherRepository(); @@ -48,12 +58,28 @@ void main() { final audioCache = _MockAudioCache(); when(() => audioPlayer.audioCache).thenReturn(audioCache); when(() => audioPlayer.setVolume(any())).thenAnswer((_) async {}); + + navigationShell = _MockStatefulNavigationShell(); + + when( + () => navigationShell.goBranch( + any(), + initialLocation: any(named: 'initialLocation'), + ), + ).thenAnswer((_) => {}); + when(() => navigationShell.currentIndex).thenReturn(0); }); - testWidgets('finds one AirplaneEntertainmentSystemView Widget', + testWidgets('finds one $AirplaneEntertainmentSystemView Widget', (tester) async { await tester.pumpApp( - const AirplaneEntertainmentSystemScreen(), + AirplaneEntertainmentSystemScreen( + navigationShell: navigationShell, + children: const [ + OverviewPage(), + MusicPlayerPage(), + ], + ), layout: AesLayoutData.small, musicRepository: musicRepository, weatherRepository: weatherRepository, @@ -65,10 +91,30 @@ void main() { }); group('$AirplaneEntertainmentSystemView', () { + late StatefulNavigationShell navigationShell; + + setUp(() { + navigationShell = _MockStatefulNavigationShell(); + + when( + () => navigationShell.goBranch( + any(), + initialLocation: any(named: 'initialLocation'), + ), + ).thenAnswer((_) => {}); + when(() => navigationShell.currentIndex).thenReturn(0); + }); + testWidgets('shows $AesNavigationRail on large screens', (tester) async { await tester.binding.setSurfaceSize(const Size(1600, 1200)); await tester.pumpSubject( - const AirplaneEntertainmentSystemView(), + AirplaneEntertainmentSystemView( + navigationShell, + const [ + OverviewPage(), + MusicPlayerPage(), + ], + ), AesLayoutData.large, ); @@ -78,16 +124,28 @@ void main() { testWidgets('shows $AesBottomNavigationBar on small screens', (tester) async { await tester.pumpSubject( - const AirplaneEntertainmentSystemView(), + AirplaneEntertainmentSystemView( + navigationShell, + const [ + OverviewPage(), + MusicPlayerPage(), + ], + ), AesLayoutData.small, ); expect(find.byType(AesBottomNavigationBar), findsOneWidget); }); - testWidgets('shows TopButtonBar', (tester) async { + testWidgets('shows $TopButtonBar', (tester) async { await tester.pumpSubject( - const AirplaneEntertainmentSystemView(), + AirplaneEntertainmentSystemView( + navigationShell, + const [ + OverviewPage(), + MusicPlayerPage(), + ], + ), AesLayoutData.small, ); @@ -96,51 +154,83 @@ void main() { testWidgets('contains background', (tester) async { await tester.pumpSubject( - const AirplaneEntertainmentSystemView(), + AirplaneEntertainmentSystemView( + navigationShell, + const [ + OverviewPage(), + MusicPlayerPage(), + ], + ), AesLayoutData.small, ); expect(find.byType(SystemBackground), findsOneWidget); }); - testWidgets('shows OverviewPage initially', (tester) async { + testWidgets('$OverviewPage selected initially', (tester) async { await tester.pumpSubject( - const AirplaneEntertainmentSystemView(), + AirplaneEntertainmentSystemView( + navigationShell, + const [ + OverviewPage(), + MusicPlayerPage(), + ], + ), AesLayoutData.small, ); - expect(find.byType(OverviewPage), findsOneWidget); + expect( + tester.widget(find.byType(NavigationBar)), + isA() + .having((widget) => widget.selectedIndex, 'index', 0), + ); }); - testWidgets('shows MusicPlayerPage when icon is selected', (tester) async { + testWidgets( + 'verify navigation to $MusicPlayerPage ' + 'when icon is selected', (tester) async { await tester.pumpSubject( - const AirplaneEntertainmentSystemView(), + AirplaneEntertainmentSystemView( + navigationShell, + const [ + OverviewPage(), + MusicPlayerPage(), + ], + ), AesLayoutData.small, ); await tester.tap(find.byIcon(Icons.music_note)); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 650)); + await tester.pump(const Duration(milliseconds: 350)); - expect(find.byType(MusicPlayerPage), findsOneWidget); + verify(() => navigationShell.goBranch(1)).called(1); }); for (final layout in AesLayoutData.values) { testWidgets( - 'shows $OverviewPage when icon is ' + 'verify navigation to $OverviewPage when icon is ' 'selected for $layout layout', (tester) async { await tester.pumpSubject( - const AirplaneEntertainmentSystemView(), + AirplaneEntertainmentSystemView( + navigationShell, + const [ + OverviewPage(), + MusicPlayerPage(), + ], + ), layout, ); await tester.tap(find.byIcon(Icons.music_note)); - await tester.pump(const Duration(milliseconds: 600)); + await tester.pump(const Duration(milliseconds: 300)); + + verify(() => navigationShell.goBranch(1)).called(1); await tester.tap(find.byIcon(Icons.airplanemode_active_outlined)); - await tester.pump(const Duration(milliseconds: 600)); + await tester.pump(const Duration(milliseconds: 300)); - expect(find.byType(OverviewPage), findsOneWidget); + verify(() => navigationShell.goBranch(0, initialLocation: true)) + .called(1); }); } }); diff --git a/test/helpers/pump_experience.dart b/test/helpers/pump_experience.dart index ebd16d9..b5ab2be 100644 --- a/test/helpers/pump_experience.dart +++ b/test/helpers/pump_experience.dart @@ -5,6 +5,7 @@ import 'package:flight_information_repository/flight_information_repository.dart import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; import 'package:music_repository/music_repository.dart'; import 'package:weather_repository/weather_repository.dart'; @@ -15,6 +16,8 @@ class MockMusicRepository extends Mock implements MusicRepository {} class MockAudioPlayer extends Mock implements AudioPlayer {} +class _MockGoRouter extends Mock implements GoRouter {} + class _MockFlightRepository extends Mock implements FlightInformationRepository { @override @@ -57,7 +60,10 @@ extension PumpApp on WidgetTester { child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: widget, + home: InheritedGoRouter( + goRouter: _MockGoRouter(), + child: widget, + ), ), ), ),