diff --git a/packages/go_router/.gitignore b/packages/go_router/.gitignore index 96486fd93024..f5575c826644 100644 --- a/packages/go_router/.gitignore +++ b/packages/go_router/.gitignore @@ -28,3 +28,4 @@ migrate_working_dir/ .dart_tool/ .packages build/ +/coverage/ diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 79eb13d61fad..e1b8a532b7c9 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 17.4.0 + +- Adds `maybePop` to `GoRouter`, `GoRouterDelegate`, and `GoRouterHelper`. This method mirrors [Navigator.maybePop] and [BackButton] by returning `false` instead of throwing when there is nothing to pop, and calls [GoRouter.restore] when a pop completes synchronously. + ## 17.3.0 - Updates minimum supported SDK version to Flutter 3.38/Dart 3.10. diff --git a/packages/go_router/lib/src/delegate.dart b/packages/go_router/lib/src/delegate.dart index 1459d2f395ce..6f00252a20a0 100644 --- a/packages/go_router/lib/src/delegate.dart +++ b/packages/go_router/lib/src/delegate.dart @@ -56,6 +56,9 @@ class GoRouterDelegate extends RouterDelegate with ChangeNotifie Future popRoute() async { final Iterable states = _findCurrentNavigators(); for (final state in states) { + if (!state.mounted) { + continue; + } final bool didPop = await state.maybePop(); // Call maybePop() directly if (didPop) { return true; // Return true if maybePop handled the pop @@ -103,6 +106,24 @@ class GoRouterDelegate extends RouterDelegate with ChangeNotifie states.first.pop(result); } + /// Calls [NavigatorState.maybePop] on the current navigator stack. + /// + /// Returns `true` if a route was popped and `false` otherwise. This method + /// does not throw if there is nothing to pop. + Future maybePop([T? result]) async { + final Iterable states = _findCurrentNavigators(); + for (final NavigatorState state in states) { + if (!state.mounted) { + continue; + } + final bool didPop = await state.maybePop(result); + if (didPop) { + return true; + } + } + return false; + } + /// Get a prioritized list of NavigatorStates, /// which either can pop or are exit routes. /// diff --git a/packages/go_router/lib/src/misc/extensions.dart b/packages/go_router/lib/src/misc/extensions.dart index 36cb7e3142e6..e2d2cb785669 100644 --- a/packages/go_router/lib/src/misc/extensions.dart +++ b/packages/go_router/lib/src/misc/extensions.dart @@ -67,6 +67,16 @@ extension GoRouterHelper on BuildContext { /// Returns `true` if there is more than 1 page on the stack. bool canPop() => GoRouter.of(this).canPop(); + /// Pop the top page off the Navigator's page stack if possible. + /// + /// Returns `true` if a route was popped and `false` otherwise. This method + /// does not throw if there is nothing to pop. + /// + /// See also: + /// * [pop], which throws if there is nothing to pop. + /// * [canPop], which can be used to check whether a pop is possible. + Future maybePop([T? result]) => GoRouter.of(this).maybePop(result); + /// Pop the top page off the Navigator's page stack by calling /// [Navigator.pop]. void pop([T? result]) => GoRouter.of(this).pop(result); diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 5f1ff5dbb793..ace411c9fc52 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -552,6 +552,10 @@ class GoRouter implements RouterConfig { /// /// Ensure that the `value` of `routeInformationProvider` is synced /// with `routerDelegate.currentConfiguration`. + /// + /// See also: + /// * [maybePop], which returns `false` instead of throwing if there is + /// nothing to pop. void pop([T? result]) { assert(() { log('popping ${routerDelegate.currentConfiguration.uri}'); @@ -566,6 +570,25 @@ class GoRouter implements RouterConfig { } } + /// Pop the top-most route off the current screen if possible. + /// + /// This method calls [NavigatorState.maybePop] on the underlying navigators, + /// similar to [BackButton]. It returns `true` if a route was popped and + /// `false` otherwise. Unlike [pop], this method does not throw if there is + /// nothing to pop. + /// + /// When a pop completes synchronously, this method also calls [restore] to + /// keep the [routeInformationProvider] in sync with + /// [GoRouterDelegate.currentConfiguration]. + Future maybePop([T? result]) async { + final RouteMatchList configBeforePop = routerDelegate.currentConfiguration; + final bool didPop = await routerDelegate.maybePop(result); + if (didPop && !identical(routerDelegate.currentConfiguration, configBeforePop)) { + restore(routerDelegate.currentConfiguration); + } + return didPop; + } + /// Refresh the route. void refresh() { assert(() { diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 77e1c11fde77..1b1d3897d666 100644 --- a/packages/go_router/pubspec.yaml +++ b/packages/go_router/pubspec.yaml @@ -1,7 +1,7 @@ name: go_router description: A declarative router for Flutter based on Navigation 2 supporting deep linking, data-driven routes and more -version: 17.3.0 +version: 17.4.0 repository: https://github.com/flutter/packages/tree/main/packages/go_router issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22 diff --git a/packages/go_router/test/delegate_test.dart b/packages/go_router/test/delegate_test.dart index 12d2d914ad6a..2e2d6a0f42e2 100644 --- a/packages/go_router/test/delegate_test.dart +++ b/packages/go_router/test/delegate_test.dart @@ -442,6 +442,111 @@ void main() { expect(goRouter.routerDelegate.currentConfiguration.matches.length, 0); expect(goRouter.routerDelegate.canPop(), false); }); + + testWidgets('It should return false on screen A and true on screen B after push', ( + WidgetTester tester, + ) async { + final GoRouter goRouter = GoRouter( + initialLocation: '/a', + routes: [ + GoRoute(path: '/a', builder: (_, _) => const Text('Screen A')), + GoRoute(path: '/b', builder: (_, _) => const Text('Screen B')), + ], + ); + addTearDown(goRouter.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: goRouter)); + await tester.pumpAndSettle(); + + expect(find.text('Screen A'), findsOneWidget); + expect(goRouter.routerDelegate.canPop(), isFalse); + + goRouter.push('/b'); + await tester.pumpAndSettle(); + + expect(find.text('Screen B'), findsOneWidget); + expect(goRouter.routerDelegate.canPop(), isTrue); + + goRouter.pop(); + await tester.pumpAndSettle(); + + expect(find.text('Screen A'), findsOneWidget); + expect(goRouter.routerDelegate.canPop(), isFalse); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 1); + expect(goRouter.routerDelegate.currentConfiguration.uri.path, '/a'); + }); + + testWidgets('It should check shell navigator when root navigator cannot pop', ( + WidgetTester tester, + ) async { + final shellNavigatorKey = GlobalKey(); + final GoRouter goRouter = GoRouter( + initialLocation: '/a', + routes: [ + ShellRoute( + navigatorKey: shellNavigatorKey, + builder: (_, _, Widget child) => child, + routes: [ + GoRoute(path: '/a', builder: (_, _) => const Text('Screen A')), + GoRoute(path: '/b', builder: (_, _) => const Text('Screen B')), + ], + ), + ], + ); + addTearDown(goRouter.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: goRouter)); + await tester.pumpAndSettle(); + + expect(goRouter.routerDelegate.canPop(), isFalse); + expect(goRouter.routerDelegate.navigatorKey.currentState?.canPop(), isFalse); + + goRouter.push('/b'); + await tester.pumpAndSettle(); + + expect(goRouter.routerDelegate.canPop(), isTrue); + expect(shellNavigatorKey.currentState?.canPop(), isTrue); + expect(goRouter.routerDelegate.navigatorKey.currentState?.canPop(), isFalse); + + goRouter.pop(); + await tester.pumpAndSettle(); + + expect(find.text('Screen A'), findsOneWidget); + expect(goRouter.routerDelegate.canPop(), isFalse); + expect(shellNavigatorKey.currentState?.canPop(), isFalse); + }); + }); + + group('willHandlePopInternally', () { + testWidgets('does not remove route when page handles pop internally', ( + WidgetTester tester, + ) async { + final GoRouter goRouter = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const Text('Home'), + routes: [ + GoRoute( + path: 'internal', + pageBuilder: (_, _) => const _InternalPopPage(), + ), + ], + ), + ], + ); + addTearDown(goRouter.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: goRouter)); + + goRouter.push('/internal'); + await tester.pumpAndSettle(); + expect(find.text('Internal'), findsOneWidget); + + goRouter.pop(); + await tester.pumpAndSettle(); + + expect(find.text('Internal'), findsOneWidget); + expect(find.text('Home'), findsNothing); + }); }); group('pushReplacement', () { @@ -752,3 +857,45 @@ class _DummyStatefulWidgetState extends State { @override Widget build(BuildContext context) => Container(); } + +class _InternalPopPage extends Page { + const _InternalPopPage(); + + @override + Route createRoute(BuildContext context) => _InternalPopRoute(this); +} + +class _InternalPopRoute extends PageRoute { + _InternalPopRoute(_InternalPopPage page) : super(settings: page); + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + bool get opaque => true; + + @override + bool get maintainState => true; + + @override + Duration get transitionDuration => Duration.zero; + + @override + bool get willHandlePopInternally => true; + + @override + // ignore: must_call_super + bool didPop(void result) => false; + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return const Text('Internal'); + } +} diff --git a/packages/go_router/test/extension_test.dart b/packages/go_router/test/extension_test.dart index 6a70da3a7f9c..1125ccb5d871 100644 --- a/packages/go_router/test/extension_test.dart +++ b/packages/go_router/test/extension_test.dart @@ -6,9 +6,80 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'test_helpers.dart'; + void main() { + final key = GlobalKey(); + final routes = [ + GoRoute( + path: '/', + name: 'home', + builder: (BuildContext context, GoRouterState state) => DummyStatefulWidget(key: key), + ), + GoRoute( + path: '/page1', + name: 'page1', + builder: (BuildContext context, GoRouterState state) => const Page1Screen(), + ), + GoRoute( + path: '/page-0/:tab', + name: 'page-0', + builder: (BuildContext context, GoRouterState state) => const SizedBox(), + ), + ]; + + const name = 'page1'; + final params = {'a-param-key': 'a-param-value'}; + final queryParams = {'a-query-key': 'a-query-value'}; + const location = '/page1'; + const extra = 'Hello'; + + group('GoRouterHelper extensions', () { + testWidgets('calls [canPop] on closest GoRouter', (WidgetTester tester) async { + final router = GoRouterCanPopSpy(routes: routes, canPopResult: true); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + expect(key.currentContext!.canPop(), isTrue); + expect(router.canPopCalled, isTrue); + }); + + testWidgets('calls [pushReplacement] on closest GoRouter', (WidgetTester tester) async { + final router = GoRouterPushReplacementSpy(routes: routes); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + key.currentContext!.pushReplacement(location, extra: extra); + expect(router.myLocation, location); + expect(router.extra, extra); + }); + + testWidgets('calls [pushReplacementNamed] on closest GoRouter', (WidgetTester tester) async { + final router = GoRouterPushReplacementNamedSpy(routes: routes); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + key.currentContext!.pushReplacementNamed( + name, + pathParameters: params, + queryParameters: queryParams, + extra: extra, + ); + expect(router.name, name); + expect(router.pathParameters, params); + expect(router.queryParameters, queryParams); + expect(router.extra, extra); + }); + + testWidgets('calls [replace] on closest GoRouter', (WidgetTester tester) async { + final router = GoRouterReplaceSpy(routes: routes); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + key.currentContext!.replace(location, extra: extra); + expect(router.myLocation, location); + expect(router.extra, extra); + }); + }); + group('replaceNamed', () { - Future createGoRouter(WidgetTester tester, {Listenable? refreshListenable}) async { + Future createGoRouter(WidgetTester tester) async { final router = GoRouter( initialLocation: '/', routes: [ @@ -31,6 +102,437 @@ void main() { ); }); }); + + group('canPop integration', () { + testWidgets('returns true on screen B and false after popping back to screen A', ( + WidgetTester tester, + ) async { + final screenAKey = GlobalKey(); + final router = GoRouter( + initialLocation: '/a', + routes: [ + GoRoute( + path: '/a', + builder: (_, _) => _CanPopScreen(key: screenAKey, label: 'A'), + ), + GoRoute( + path: '/b', + builder: (_, _) => const _CanPopScreen(label: 'B'), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + expect(find.text('Screen A'), findsOneWidget); + expect(find.text('canPop=false'), findsOneWidget); + expect(router.canPop(), isFalse); + + router.push('/b'); + await tester.pumpAndSettle(); + + expect(find.text('Screen B'), findsOneWidget); + expect(find.text('canPop=true'), findsOneWidget); + expect(router.canPop(), isTrue); + + router.pop(); + await tester.pumpAndSettle(); + + expect(find.text('Screen A'), findsOneWidget); + expect(find.text('canPop=false'), findsOneWidget); + expect(router.canPop(), isFalse); + expect(screenAKey.currentContext!.canPop(), isFalse); + }); + + testWidgets('returns false when navigating with go between sibling routes', ( + WidgetTester tester, + ) async { + final router = GoRouter( + initialLocation: '/a', + routes: [ + GoRoute( + path: '/a', + builder: (_, _) => const _CanPopScreen(label: 'A'), + ), + GoRoute( + path: '/b', + builder: (_, _) => const _CanPopScreen(label: 'B'), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.go('/b'); + await tester.pumpAndSettle(); + + expect(find.text('Screen B'), findsOneWidget); + expect(find.text('canPop=false'), findsOneWidget); + expect(router.canPop(), isFalse); + }); + }); + + group('canPop pop button', () { + Future pumpRouter(WidgetTester tester) async { + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const _ScreenWithPopButton(label: 'Home'), + ), + GoRoute( + path: '/a', + builder: (_, _) => const _ScreenWithPopButton(label: 'A'), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + return router; + } + + testWidgets('go to A hides pop button because context.canPop() is false', ( + WidgetTester tester, + ) async { + final GoRouter router = await pumpRouter(tester); + + expect(find.text('Screen Home'), findsOneWidget); + expect(find.byType(BackButton), findsNothing); + + router.go('/a'); + await tester.pumpAndSettle(); + + expect(find.text('Screen A'), findsOneWidget); + expect(find.text('canPop=false'), findsOneWidget); + expect(find.byType(BackButton), findsNothing); + expect(router.canPop(), isFalse); + }); + + testWidgets('push to A shows pop button and popping returns to Home', ( + WidgetTester tester, + ) async { + final GoRouter router = await pumpRouter(tester); + + router.push('/a'); + await tester.pumpAndSettle(); + + expect(find.text('Screen A'), findsOneWidget); + expect(find.text('canPop=true'), findsOneWidget); + expect(find.byType(BackButton), findsOneWidget); + expect(router.canPop(), isTrue); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + + expect(find.text('Screen Home'), findsOneWidget); + expect(find.text('canPop=false'), findsOneWidget); + expect(find.byType(BackButton), findsNothing); + expect(router.canPop(), isFalse); + }); + + testWidgets('replace to A hides pop button because context.canPop() is false', ( + WidgetTester tester, + ) async { + final GoRouter router = await pumpRouter(tester); + + router.replace('/a'); + await tester.pumpAndSettle(); + + expect(find.text('Screen A'), findsOneWidget); + expect(find.text('canPop=false'), findsOneWidget); + expect(find.byType(BackButton), findsNothing); + expect(router.canPop(), isFalse); + expect(router.routerDelegate.currentConfiguration.uri.path, '/a'); + }); + }); + + group('Navigator.of(context).pop()', () { + Future pumpRouter(WidgetTester tester, {required PopMethod popMethod}) async { + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => _ScreenWithPopButton(label: 'Home', popMethod: popMethod), + ), + GoRoute( + path: '/a', + builder: (_, _) => _ScreenWithPopButton(label: 'A', popMethod: popMethod), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + return router; + } + + testWidgets('after push to A, Navigator.pop returns to Home and canPop is false', ( + WidgetTester tester, + ) async { + final GoRouter router = await pumpRouter(tester, popMethod: PopMethod.navigator); + + router.push('/a'); + await tester.pumpAndSettle(); + + expect(find.text('Screen A'), findsOneWidget); + expect(find.text('canPop=true'), findsOneWidget); + expect(find.byType(BackButton), findsOneWidget); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + + expect(find.text('Screen Home'), findsOneWidget); + expect(find.text('canPop=false'), findsOneWidget); + expect(find.byType(BackButton), findsNothing); + expect(router.canPop(), isFalse); + }); + + testWidgets('after go to A, context.canPop() is false so Navigator.pop is not offered', ( + WidgetTester tester, + ) async { + final GoRouter router = await pumpRouter(tester, popMethod: PopMethod.navigator); + + router.go('/a'); + await tester.pumpAndSettle(); + + expect(find.text('Screen A'), findsOneWidget); + expect(find.text('canPop=false'), findsOneWidget); + expect(find.byType(BackButton), findsNothing); + expect(router.canPop(), isFalse); + }); + + testWidgets('context.pop throws but Navigator.pop is guarded by canPop on go routes', ( + WidgetTester tester, + ) async { + final GoRouter router = await pumpRouter(tester, popMethod: PopMethod.goRouter); + + router.go('/a'); + await tester.pumpAndSettle(); + expect(router.canPop(), isFalse); + + expect(router.pop, throwsA(isA())); + }); + + testWidgets('push then Navigator.pop behaves the same as context.pop', ( + WidgetTester tester, + ) async { + final GoRouter routerNav = await pumpRouter(tester, popMethod: PopMethod.navigator); + routerNav.push('/a'); + await tester.pumpAndSettle(); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.text('Screen Home'), findsOneWidget); + expect(routerNav.canPop(), isFalse); + + final GoRouter routerGo = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => + const _ScreenWithPopButton(label: 'Home', popMethod: PopMethod.goRouter), + ), + GoRoute( + path: '/a', + builder: (_, _) => + const _ScreenWithPopButton(label: 'A', popMethod: PopMethod.goRouter), + ), + ], + ); + addTearDown(routerGo.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: routerGo)); + await tester.pumpAndSettle(); + + routerGo.push('/a'); + await tester.pumpAndSettle(); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.text('Screen Home'), findsOneWidget); + expect(routerGo.canPop(), isFalse); + }); + }); + + group('framework Navigator 1.0 widgets', () { + Future pumpScaffoldRouter(WidgetTester tester) async { + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => Scaffold( + appBar: AppBar(title: const Text('Home')), + body: const Text('Home body'), + ), + ), + GoRoute( + path: '/a', + builder: (_, _) => Scaffold( + appBar: AppBar(title: const Text('A')), + body: const Text('A body'), + ), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + return router; + } + + testWidgets('AppBar BackButton uses Navigator.maybePop after push', ( + WidgetTester tester, + ) async { + final GoRouter router = await pumpScaffoldRouter(tester); + + router.push('/a'); + await tester.pumpAndSettle(); + + // AppBar shows BackButton when ModalRoute.canPop is true (Navigator stack > 1). + expect(router.canPop(), isTrue); + expect(find.byType(BackButton), findsOneWidget); + + // Framework BackButton calls Navigator.maybePop — no custom onPressed needed. + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + + expect(find.text('Home body'), findsOneWidget); + expect(router.canPop(), isFalse); + expect(find.byType(BackButton), findsNothing); + }); + + testWidgets('AppBar hides BackButton after go because ModalRoute.canPop is false', ( + WidgetTester tester, + ) async { + final GoRouter router = await pumpScaffoldRouter(tester); + + router.go('/a'); + await tester.pumpAndSettle(); + + expect(router.canPop(), isFalse); + expect(find.byType(BackButton), findsNothing); + expect(find.text('A body'), findsOneWidget); + }); + + testWidgets('showDialog closes with Navigator.pop from framework dialog actions', ( + WidgetTester tester, + ) async { + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (BuildContext context, _) => Scaffold( + body: ElevatedButton( + onPressed: () => showDialog( + context: context, + builder: (BuildContext dialogContext) => AlertDialog( + content: const Text('Dialog'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Close'), + ), + ], + ), + ), + child: const Text('Open'), + ), + ), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(find.text('Dialog'), findsOneWidget); + expect(router.canPop(), isTrue); + + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + + expect(find.text('Dialog'), findsNothing); + expect(router.canPop(), isFalse); + }); + }); + + group('mixed Navigator.pop and context.pop', () { + testWidgets('push A, push B, Navigator.pop, push B, context.pop returns to A correctly', ( + WidgetTester tester, + ) async { + final GoRouter router = GoRouter( + initialLocation: '/home', + routes: [ + GoRoute( + path: '/home', + builder: (_, _) => const _RouteScreen(label: 'Home'), + ), + GoRoute( + path: '/a', + builder: (_, _) => const _RouteScreen(label: 'A'), + ), + GoRoute( + path: '/b', + builder: (_, _) => const _RouteScreen(label: 'B'), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + expect(find.text('Screen Home'), findsOneWidget); + expect(router.routerDelegate.currentConfiguration.matches.length, 1); + + router.push('/a'); + await tester.pumpAndSettle(); + expect(find.text('Screen A'), findsOneWidget); + expect(router.routerDelegate.currentConfiguration.matches.length, 2); + expect(router.canPop(), isTrue); + + router.push('/b'); + await tester.pumpAndSettle(); + expect(find.text('Screen B'), findsOneWidget); + expect(router.routerDelegate.currentConfiguration.matches.length, 3); + + // First pop: framework Navigator API from screen B. + Navigator.of(tester.element(find.text('Screen B'))).pop(); + await tester.pumpAndSettle(); + expect(find.text('Screen A'), findsOneWidget); + expect(find.text('Screen B'), findsNothing); + expect(router.routerDelegate.currentConfiguration.matches.length, 2); + expect(router.canPop(), isTrue); + // Imperative pushes keep the declarative URL at /home by default. + expect(router.routerDelegate.currentConfiguration.uri.path, '/home'); + + router.push('/b'); + await tester.pumpAndSettle(); + expect(find.text('Screen B'), findsOneWidget); + expect(router.routerDelegate.currentConfiguration.matches.length, 3); + + // Second pop: GoRouter context.pop from screen B. + tester.element(find.text('Screen B')).pop(); + await tester.pumpAndSettle(); + expect(find.text('Screen A'), findsOneWidget); + expect(find.text('Screen B'), findsNothing); + expect(router.routerDelegate.currentConfiguration.matches.length, 2); + expect(router.canPop(), isTrue); + + // Third pop: back to home. + tester.element(find.text('Screen A')).pop(); + await tester.pumpAndSettle(); + expect(find.text('Screen Home'), findsOneWidget); + expect(router.routerDelegate.currentConfiguration.matches.length, 1); + expect(router.canPop(), isFalse); + }); + }); } class _MyWidget extends StatelessWidget { @@ -48,3 +550,60 @@ class _MyWidget extends StatelessWidget { ); } } + +class _CanPopScreen extends StatelessWidget { + const _CanPopScreen({required this.label, super.key}); + + final String label; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [Text('Screen $label'), Text('canPop=${context.canPop()}')], + ); + } +} + +/// A screen that mirrors common app usage: show a pop button only when +/// [BuildContext.canPop] returns true. +class _ScreenWithPopButton extends StatelessWidget { + const _ScreenWithPopButton({required this.label, this.popMethod = PopMethod.goRouter}); + + final String label; + final PopMethod popMethod; + + @override + Widget build(BuildContext context) { + final bool canPop = context.canPop(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Screen $label'), + Text('canPop=$canPop'), + if (canPop) + BackButton( + onPressed: () { + switch (popMethod) { + case PopMethod.goRouter: + context.pop(); + case PopMethod.navigator: + Navigator.of(context).pop(); + } + }, + ), + ], + ); + } +} + +enum PopMethod { goRouter, navigator } + +class _RouteScreen extends StatelessWidget { + const _RouteScreen({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) => Text('Screen $label'); +} diff --git a/packages/go_router/test/maybe_pop_test.dart b/packages/go_router/test/maybe_pop_test.dart new file mode 100644 index 000000000000..addc69578a78 --- /dev/null +++ b/packages/go_router/test/maybe_pop_test.dart @@ -0,0 +1,197 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'test_helpers.dart'; + +void main() { + group('GoRouter.maybePop', () { + Future pumpRouter(WidgetTester tester) async { + final GoRouter router = GoRouter( + initialLocation: '/home', + routes: [ + GoRoute(path: '/home', builder: (_, _) => const Text('Home')), + GoRoute(path: '/a', builder: (_, _) => const Text('A')), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + return router; + } + + testWidgets('returns false without throwing when there is nothing to pop', ( + WidgetTester tester, + ) async { + final GoRouter router = await pumpRouter(tester); + + expect(router.canPop(), isFalse); + expect(await router.maybePop(), isFalse); + expect(find.text('Home'), findsOneWidget); + }); + + testWidgets('returns false after go when there is no pop stack', (WidgetTester tester) async { + final GoRouter router = await pumpRouter(tester); + + router.go('/a'); + await tester.pumpAndSettle(); + + expect(router.canPop(), isFalse); + expect(await router.maybePop(), isFalse); + expect(find.text('A'), findsOneWidget); + }); + + testWidgets('returns true and pops after push', (WidgetTester tester) async { + final GoRouter router = await pumpRouter(tester); + + router.push('/a'); + await tester.pumpAndSettle(); + expect(router.canPop(), isTrue); + + expect(await router.maybePop(), isTrue); + await tester.pumpAndSettle(); + + expect(find.text('Home'), findsOneWidget); + expect(router.canPop(), isFalse); + }); + + testWidgets('calls restore when pop completes synchronously', (WidgetTester tester) async { + final GoRouter router = await pumpRouter(tester); + + router.push('/a'); + await tester.pumpAndSettle(); + + expect(await router.maybePop(), isTrue); + final RouteInformationState state = + router.routeInformationProvider.value.state! as RouteInformationState; + expect(state.type, NavigatingType.restore); + await tester.pumpAndSettle(); + }); + + testWidgets('pop still throws when there is nothing to pop', (WidgetTester tester) async { + final GoRouter router = await pumpRouter(tester); + expect(router.pop, throwsA(isA())); + }); + }); + + group('context.maybePop', () { + testWidgets('returns false on root route like BackButton would no-op', ( + WidgetTester tester, + ) async { + final GlobalKey> key = GlobalKey>(); + final GoRouter router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => Scaffold( + key: key, + appBar: AppBar(title: const Text('Home')), + body: const Text('Home body'), + ), + ), + GoRoute( + path: '/a', + builder: (_, _) => Scaffold( + appBar: AppBar(title: const Text('A')), + body: const Text('A body'), + ), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + expect(await key.currentContext!.maybePop(), isFalse); + expect(find.text('Home body'), findsOneWidget); + }); + + testWidgets('pops after push like BackButton', (WidgetTester tester) async { + final GlobalKey> key = GlobalKey>(); + final GoRouter router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => Scaffold( + key: key, + appBar: AppBar(title: const Text('Home')), + body: const Text('Home body'), + ), + ), + GoRoute( + path: '/a', + builder: (_, _) => Scaffold( + appBar: AppBar(title: const Text('A')), + body: const Text('A body'), + ), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.push('/a'); + await tester.pumpAndSettle(); + expect(find.text('A body'), findsOneWidget); + + expect(await key.currentContext!.maybePop(), isTrue); + await tester.pumpAndSettle(); + expect(find.text('Home body'), findsOneWidget); + }); + + testWidgets('matches Navigator.maybePop when PopScope blocks the pop', ( + WidgetTester tester, + ) async { + final GlobalKey> key = GlobalKey>(); + final GoRouter router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute(path: '/', builder: (_, _) => const Text('Home')), + GoRoute( + path: '/a', + builder: (_, _) => PopScope( + canPop: false, + child: Text('A', key: key), + ), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + + router.push('/a'); + await tester.pumpAndSettle(); + + final BuildContext context = key.currentContext!; + final bool navigatorDidPop = await Navigator.of(context).maybePop(); + final bool goRouterDidPop = await context.maybePop(); + expect(goRouterDidPop, navigatorDidPop); + await tester.pumpAndSettle(); + expect(find.text('A'), findsOneWidget); + }); + }); + + group('GoRouterDelegate.maybePop', () { + testWidgets('delegates to Navigator.maybePop', (WidgetTester tester) async { + final GoRouter router = await createRouter([ + GoRoute(path: '/', builder: (_, _) => const Text('Home')), + GoRoute(path: '/a', builder: (_, _) => const Text('A')), + ], tester); + + router.push('/a'); + await tester.pumpAndSettle(); + + expect(await router.routerDelegate.maybePop(), isTrue); + await tester.pumpAndSettle(); + expect(find.text('Home'), findsOneWidget); + expect(await router.routerDelegate.maybePop(), isFalse); + }); + }); +} diff --git a/packages/go_router/test/restore_test.dart b/packages/go_router/test/restore_test.dart new file mode 100644 index 000000000000..fc8282c79cc3 --- /dev/null +++ b/packages/go_router/test/restore_test.dart @@ -0,0 +1,425 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'test_helpers.dart'; + +void main() { + group('RouteInformationState.restore', () { + test('uses base extra when extra is not provided', () { + final base = RouteMatchList( + matches: const [], + uri: Uri.parse('/home'), + pathParameters: const {}, + extra: 'base-extra', + ); + + final RouteInformationState state = RouteInformationState.restore(base: base); + + expect(state.type, NavigatingType.restore); + expect(state.baseRouteMatchList, base); + expect(state.extra, 'base-extra'); + expect(state.completer, isNull); + }); + + test('allows overriding extra', () { + final base = RouteMatchList( + matches: const [], + uri: Uri.parse('/home'), + pathParameters: const {}, + extra: 'base-extra', + ); + + final RouteInformationState state = RouteInformationState.restore( + base: base, + extra: 'override-extra', + ); + + expect(state.extra, 'override-extra'); + }); + }); + + group('GoRouteInformationProvider.restore', () { + testWidgets('updates value with restore navigation type', (WidgetTester tester) async { + final provider = GoRouteInformationProvider(initialLocation: '/', initialExtra: null); + addTearDown(provider.dispose); + + final router = GoRouter( + routes: [ + GoRoute(path: '/', builder: (_, _) => const Text('Home')), + GoRoute(path: '/a', builder: (_, _) => const Text('A')), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.push('/a'); + await tester.pumpAndSettle(); + final RouteMatchList matchList = router.routerDelegate.currentConfiguration; + + var notified = false; + provider.addListener(() => notified = true); + provider.restore(matchList.uri.toString(), matchList: matchList); + + expect(notified, isTrue); + final state = provider.value.state! as RouteInformationState; + expect(state.type, NavigatingType.restore); + expect(state.baseRouteMatchList, matchList); + expect(state.extra, matchList.extra); + expect(provider.value.uri, matchList.uri); + }); + }); + + group('GoRouter.restore', () { + testWidgets('syncs routeInformationProvider with the given match list', ( + WidgetTester tester, + ) async { + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute(path: '/', builder: (_, _) => const Text('Home')), + GoRoute(path: '/a', builder: (_, _) => const Text('A')), + GoRoute(path: '/b', builder: (_, _) => const Text('B')), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.push('/a'); + await tester.pumpAndSettle(); + router.push('/b'); + await tester.pumpAndSettle(); + expect(find.text('B'), findsOneWidget); + + final RouteMatchList twoDeep = router.routerDelegate.currentConfiguration; + expect(twoDeep.matches.length, 3); + + router.go('/'); + await tester.pumpAndSettle(); + expect(find.text('Home'), findsOneWidget); + + router.restore(twoDeep); + final stateAfterRestore = + router.routeInformationProvider.value.state! as RouteInformationState; + expect(stateAfterRestore.type, NavigatingType.restore); + await tester.pumpAndSettle(); + + expect(find.text('B'), findsOneWidget); + expect(router.routerDelegate.currentConfiguration.matches.length, 3); + }); + + testWidgets('is called when routing config changes', (WidgetTester tester) async { + final config = ValueNotifier( + RoutingConfig( + routes: [ + GoRoute(path: '/', builder: (_, _) => const Text('Home v1')), + GoRoute(path: '/a', builder: (_, _) => const Text('A v1')), + ], + ), + ); + addTearDown(config.dispose); + + final GoRouter router = await createRouterWithRoutingConfig(config, tester); + router.push('/a'); + await tester.pumpAndSettle(); + expect(find.text('A v1'), findsOneWidget); + + config.value = RoutingConfig( + routes: [ + GoRoute(path: '/', builder: (_, _) => const Text('Home v2')), + GoRoute(path: '/a', builder: (_, _) => const Text('A v2')), + ], + ); + final stateAfterConfigChange = + router.routeInformationProvider.value.state! as RouteInformationState; + expect(stateAfterConfigChange.type, NavigatingType.restore); + await tester.pumpAndSettle(); + + expect(find.text('A v2'), findsOneWidget); + }); + + testWidgets('StatefulShellRoute goBranch restores the branch match list', ( + WidgetTester tester, + ) async { + StatefulNavigationShell? navigationShell; + final router = GoRouter( + initialLocation: '/a', + routes: [ + StatefulShellRoute.indexedStack( + builder: (_, _, StatefulNavigationShell shell) { + navigationShell = shell; + return shell; + }, + branches: [ + StatefulShellBranch( + routes: [GoRoute(path: '/a', builder: (_, _) => const Text('Branch A'))], + ), + StatefulShellBranch( + routes: [GoRoute(path: '/b', builder: (_, _) => const Text('Branch B'))], + ), + ], + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + expect(find.text('Branch A'), findsOneWidget); + + navigationShell!.goBranch(1); + await tester.pumpAndSettle(); + expect(find.text('Branch B'), findsOneWidget); + + navigationShell!.goBranch(0); + await tester.pumpAndSettle(); + expect(find.text('Branch A'), findsOneWidget); + + navigationShell!.goBranch(1); + final stateAfterBranch = + router.routeInformationProvider.value.state! as RouteInformationState; + expect(stateAfterBranch.type, NavigatingType.restore); + await tester.pumpAndSettle(); + expect(find.text('Branch B'), findsOneWidget); + }); + }); + + group('GoRouter.pop restore integration', () { + testWidgets('context.pop calls restore and syncs provider after push', ( + WidgetTester tester, + ) async { + final router = GoRouter( + initialLocation: '/home', + routes: [ + GoRoute(path: '/home', builder: (_, _) => const Text('Home')), + GoRoute(path: '/a', builder: (_, _) => const Text('A')), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.push('/a'); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.path, '/home'); + + router.pop(); + final stateAfterPop = + router.routeInformationProvider.value.state! as RouteInformationState; + expect(stateAfterPop.type, NavigatingType.restore); + await tester.pumpAndSettle(); + + expect(find.text('Home'), findsOneWidget); + expect(router.routerDelegate.currentConfiguration.matches.length, 1); + }); + + testWidgets('Navigator.pop updates stack but context.pop restores provider URL', ( + WidgetTester tester, + ) async { + GoRouter.optionURLReflectsImperativeAPIs = true; + addTearDown(() => GoRouter.optionURLReflectsImperativeAPIs = false); + + final router = GoRouter( + initialLocation: '/home', + routes: [ + GoRoute(path: '/home', builder: (_, _) => const Text('Home')), + GoRoute(path: '/a', builder: (_, _) => const Text('A')), + GoRoute(path: '/b', builder: (_, _) => const Text('B')), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.push('/a'); + await tester.pumpAndSettle(); + router.push('/b'); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.path, '/b'); + + Navigator.of(tester.element(find.text('B'))).pop(); + await tester.pumpAndSettle(); + expect(find.text('A'), findsOneWidget); + expect(router.routerDelegate.currentConfiguration.matches.length, 2); + + router.pop(); + final stateAfterGoRouterPop = + router.routeInformationProvider.value.state! as RouteInformationState; + expect(stateAfterGoRouterPop.type, NavigatingType.restore); + await tester.pumpAndSettle(); + expect(find.text('Home'), findsOneWidget); + expect(router.routeInformationProvider.value.uri.path, '/home'); + }); + + testWidgets('does not call restore synchronously when onExit defers pop', ( + WidgetTester tester, + ) async { + final GoRouter router = await createRouter( + [ + GoRoute( + path: '/', + builder: (_, _) => const Text('Home'), + routes: [ + GoRoute( + path: 'detail', + onExit: (_, _) async { + await Future.delayed(Duration.zero); + return true; + }, + builder: (_, _) => const Text('Detail'), + ), + ], + ), + ], + tester, + initialLocation: '/detail', + ); + + final RouteMatchList beforePop = router.routerDelegate.currentConfiguration; + router.pop(); + // Pop is deferred; configuration should be unchanged synchronously. + expect(identical(router.routerDelegate.currentConfiguration, beforePop), isTrue); + + await tester.pumpAndSettle(); + expect(find.text('Home'), findsOneWidget); + }); + }); + + group('GoRouteInformationParser restore', () { + testWidgets('keeps imperative stack when restore URIs match', (WidgetTester tester) async { + final navKey = GlobalKey(); + final GoRouter router = await createRouter( + [ + GoRoute( + path: '/', + builder: (_, _) => const Text('Home'), + routes: [GoRoute(path: 'a', builder: (_, _) => const Text('A'))], + ), + ], + tester, + navigatorKey: navKey, + ); + + router.push('/a'); + await tester.pumpAndSettle(); + final RouteMatchList imperativeStack = router.routerDelegate.currentConfiguration; + expect(imperativeStack.matches.length, 2); + + final BuildContext context = navKey.currentContext!; + final RouteMatchList restored = await router.routeInformationParser + .parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/'), + state: RouteInformationState( + type: NavigatingType.restore, + baseRouteMatchList: imperativeStack, + ), + ), + context, + ); + + expect(restored.matches.length, 2); + expect(restored.uri.path, '/'); + }); + + testWidgets('uses new matches when restore URI differs from base', (WidgetTester tester) async { + final navKey = GlobalKey(); + final GoRouter router = await createRouter( + [ + GoRoute(path: '/home', builder: (_, _) => const Text('Home')), + GoRoute(path: '/a', builder: (_, _) => const Text('A')), + ], + tester, + initialLocation: '/home', + navigatorKey: navKey, + ); + + final RouteMatchList homeOnly = router.routerDelegate.currentConfiguration; + final BuildContext context = navKey.currentContext!; + + final RouteMatchList restored = await router.routeInformationParser + .parseRouteInformationWithDependencies( + RouteInformation( + uri: Uri.parse('/a'), + state: RouteInformationState( + type: NavigatingType.restore, + baseRouteMatchList: homeOnly, + ), + ), + context, + ); + + expect(restored.uri.path, '/a'); + expect(restored.matches.length, 1); + expect(restored.matches.last.matchedLocation, '/a'); + }); + + testWidgets('decodes encoded match list state from browser back-forward', ( + WidgetTester tester, + ) async { + final GoRouter router = await createRouter([ + GoRoute(path: '/', builder: (_, _) => const Text('Home')), + GoRoute(path: '/a', builder: (_, _) => const Text('A')), + ], tester); + + router.push('/a'); + await tester.pumpAndSettle(); + final RouteMatchList matchList = router.routerDelegate.currentConfiguration; + final RouteInformation encoded = router.routeInformationParser.restoreRouteInformation( + matchList, + )!; + + expect(encoded.state, isNot(isA())); + + router.go('/'); + await tester.pumpAndSettle(); + expect(find.text('Home'), findsOneWidget); + + router.routeInformationProvider.didPushRouteInformation(encoded); + await tester.pumpAndSettle(); + + expect(find.text('A'), findsOneWidget); + expect(router.routerDelegate.currentConfiguration.matches.length, 2); + }); + + testWidgets('restoreRouteInformation round-trips imperative stack', ( + WidgetTester tester, + ) async { + GoRouter.optionURLReflectsImperativeAPIs = true; + addTearDown(() => GoRouter.optionURLReflectsImperativeAPIs = false); + + final navKey = GlobalKey(); + final GoRouter router = await createRouter( + [ + GoRoute( + path: '/', + builder: (_, _) => const Text('Home'), + routes: [GoRoute(path: 'a', builder: (_, _) => const Text('A'))], + ), + ], + tester, + navigatorKey: navKey, + ); + + router.go('/a'); + await tester.pumpAndSettle(); + router.push('/'); + await tester.pumpAndSettle(); + + final RouteMatchList original = router.routerDelegate.currentConfiguration; + final RouteInformation routeInformation = router.routeInformationParser + .restoreRouteInformation(original)!; + + final RouteMatchList parsed = await router.routeInformationParser + .parseRouteInformationWithDependencies(routeInformation, navKey.currentContext!); + + expect(parsed.uri.toString(), original.uri.toString()); + expect(parsed.matches.length, original.matches.length); + }); + }); +} diff --git a/packages/go_router/test/router_test.dart b/packages/go_router/test/router_test.dart new file mode 100644 index 000000000000..dd67619321ee --- /dev/null +++ b/packages/go_router/test/router_test.dart @@ -0,0 +1,43 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'test_helpers.dart'; + +void main() { + group('GoRouter', () { + testWidgets('canPop delegates to routerDelegate', (WidgetTester tester) async { + final GoRouter router = await createRouter([ + GoRoute(path: '/', builder: (_, _) => const HomeScreen()), + GoRoute(path: '/a', builder: (_, _) => const Page1Screen()), + ], tester); + + expect(router.canPop(), isFalse); + + router.push('/a'); + await tester.pumpAndSettle(); + + expect(router.canPop(), isTrue); + expect(router.canPop(), router.routerDelegate.canPop()); + }); + + testWidgets('refresh notifies routeInformationProvider listeners', ( + WidgetTester tester, + ) async { + final GoRouter router = await createRouter([ + GoRoute(path: '/', builder: (_, _) => const HomeScreen()), + ], tester); + + var listenerCalled = false; + router.routeInformationProvider.addListener(() { + listenerCalled = true; + }); + + router.refresh(); + expect(listenerCalled, isTrue); + }); + }); +} diff --git a/packages/go_router/test/test_helpers.dart b/packages/go_router/test/test_helpers.dart index 96653c8c52bb..a9f9297d6fe4 100644 --- a/packages/go_router/test/test_helpers.dart +++ b/packages/go_router/test/test_helpers.dart @@ -143,6 +143,74 @@ class GoRouterPopSpy extends GoRouter { } } +class GoRouterCanPopSpy extends GoRouter { + GoRouterCanPopSpy({required List routes, this.canPopResult = false}) + : super.routingConfig(routingConfig: ConstantRoutingConfig(RoutingConfig(routes: routes))); + + bool canPopResult; + bool canPopCalled = false; + + @override + bool canPop() { + canPopCalled = true; + return canPopResult; + } +} + +class GoRouterPushReplacementSpy extends GoRouter { + GoRouterPushReplacementSpy({required List routes}) + : super.routingConfig(routingConfig: ConstantRoutingConfig(RoutingConfig(routes: routes))); + + String? myLocation; + Object? extra; + + @override + Future pushReplacement(String location, {Object? extra}) { + myLocation = location; + this.extra = extra; + return Future.value(); + } +} + +class GoRouterPushReplacementNamedSpy extends GoRouter { + GoRouterPushReplacementNamedSpy({required List routes}) + : super.routingConfig(routingConfig: ConstantRoutingConfig(RoutingConfig(routes: routes))); + + String? name; + Map? pathParameters; + Map? queryParameters; + Object? extra; + + @override + Future pushReplacementNamed( + String name, { + Map pathParameters = const {}, + Map queryParameters = const {}, + Object? extra, + }) { + this.name = name; + this.pathParameters = pathParameters; + this.queryParameters = queryParameters; + this.extra = extra; + return Future.value(); + } +} + +class GoRouterReplaceSpy extends GoRouter { + GoRouterReplaceSpy({required List routes}) + : super.routingConfig(routingConfig: ConstantRoutingConfig(RoutingConfig(routes: routes))); + + String? myLocation; + Object? extra; + + @override + Future replace(String location, {Object? extra}) { + myLocation = location; + this.extra = extra; + return Future.value(); + } +} + Future createRouter( List routes, WidgetTester tester, {