// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_test/flutter_test.dart'; import 'observer_tester.dart'; import 'semantics_tester.dart'; class FirstWidget extends StatelessWidget { const FirstWidget({ Key? key }) : super(key: key); @override Widget build(BuildContext context) { return GestureDetector( onTap: () { Navigator.pushNamed(context, '/second'); }, child: Container( color: const Color(0xFFFFFF00), child: const Text('X'), ), ); } } class SecondWidget extends StatefulWidget { const SecondWidget({ Key? key }) : super(key: key); @override SecondWidgetState createState() => SecondWidgetState(); } class SecondWidgetState extends State<SecondWidget> { @override Widget build(BuildContext context) { return GestureDetector( onTap: () => Navigator.pop(context), child: Container( color: const Color(0xFFFF00FF), child: const Text('Y'), ), ); } } typedef ExceptionCallback = void Function(dynamic exception); class ThirdWidget extends StatelessWidget { const ThirdWidget({ Key? key, required this.targetKey, required this.onException }) : super(key: key); final Key targetKey; final ExceptionCallback onException; @override Widget build(BuildContext context) { return GestureDetector( key: targetKey, onTap: () { try { Navigator.of(context); } catch (e) { onException(e); } }, behavior: HitTestBehavior.opaque, ); } } class OnTapPage extends StatelessWidget { const OnTapPage({ Key? key, required this.id, this.onTap }) : super(key: key); final String id; final VoidCallback? onTap; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Page $id')), body: GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: Center( child: Text(id, style: Theme.of(context).textTheme.headline3), ), ), ); } } class SlideInOutPageRoute<T> extends PageRouteBuilder<T> { SlideInOutPageRoute({required WidgetBuilder bodyBuilder, RouteSettings? settings}) : super( settings: settings, pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) => bodyBuilder(context), transitionsBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) { return SlideTransition( position: Tween<Offset>( begin: const Offset(1.0, 0), end: Offset.zero, ).animate(animation), child: SlideTransition( position: Tween<Offset>( begin: Offset.zero, end: const Offset(-1.0, 0), ).animate(secondaryAnimation), child: child, ), ); }, ); @override AnimationController? get controller => super.controller; } void main() { testWidgets('Can navigator navigate to and from a stateful widget', (WidgetTester tester) async { final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/': (BuildContext context) => const FirstWidget(), // X '/second': (BuildContext context) => const SecondWidget(), // Y }; await tester.pumpWidget(MaterialApp(routes: routes)); expect(find.text('X'), findsOneWidget); expect(find.text('Y', skipOffstage: false), findsNothing); await tester.tap(find.text('X')); await tester.pump(); expect(find.text('X'), findsOneWidget); expect(find.text('Y', skipOffstage: false), isOffstage); await tester.pump(const Duration(milliseconds: 10)); expect(find.text('X'), findsOneWidget); expect(find.text('Y'), findsOneWidget); await tester.pump(const Duration(milliseconds: 10)); expect(find.text('X'), findsOneWidget); expect(find.text('Y'), findsOneWidget); await tester.pump(const Duration(milliseconds: 10)); expect(find.text('X'), findsOneWidget); expect(find.text('Y'), findsOneWidget); await tester.pump(const Duration(seconds: 1)); expect(find.text('X'), findsNothing); expect(find.text('X', skipOffstage: false), findsOneWidget); expect(find.text('Y'), findsOneWidget); await tester.tap(find.text('Y')); expect(find.text('X'), findsNothing); expect(find.text('Y'), findsOneWidget); await tester.pump(); await tester.pump(); expect(find.text('X'), findsOneWidget); expect(find.text('Y'), findsOneWidget); await tester.pump(const Duration(milliseconds: 10)); expect(find.text('X'), findsOneWidget); expect(find.text('Y'), findsOneWidget); await tester.pump(const Duration(seconds: 1)); expect(find.text('X'), findsOneWidget); expect(find.text('Y', skipOffstage: false), findsNothing); }); testWidgets('Navigator.of fails gracefully when not found in context', (WidgetTester tester) async { const Key targetKey = Key('foo'); dynamic exception; final Widget widget = ThirdWidget( targetKey: targetKey, onException: (dynamic e) { exception = e; }, ); await tester.pumpWidget(widget); await tester.tap(find.byKey(targetKey)); expect(exception, isFlutterError); expect('$exception', startsWith('Navigator operation requested with a context')); }); testWidgets('Navigator.of rootNavigator finds root Navigator', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( home: Material( child: Column( children: <Widget>[ const SizedBox( height: 300.0, child: Text('Root page'), ), SizedBox( height: 300.0, child: Navigator( onGenerateRoute: (RouteSettings settings) { if (settings.name == '/') { return MaterialPageRoute<void>( builder: (BuildContext context) { return ElevatedButton( child: const Text('Next'), onPressed: () { Navigator.of(context).push( MaterialPageRoute<void>( builder: (BuildContext context) { return ElevatedButton( child: const Text('Inner page'), onPressed: () { Navigator.of(context, rootNavigator: true).push( MaterialPageRoute<void>( builder: (BuildContext context) { return const Text('Dialog'); }, ), ); }, ); }, ), ); }, ); }, ); } return null; }, ), ), ], ), ), )); await tester.tap(find.text('Next')); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); // Both elements are on screen. expect(tester.getTopLeft(find.text('Root page')).dy, 0.0); expect(tester.getTopLeft(find.text('Inner page')).dy, greaterThan(300.0)); await tester.tap(find.text('Inner page')); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); // Dialog is pushed to the whole page and is at the top of the screen, not // inside the inner page. expect(tester.getTopLeft(find.text('Dialog')).dy, 0.0); }); testWidgets('Gestures between push and build are ignored', (WidgetTester tester) async { final List<String> log = <String>[]; final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/': (BuildContext context) { return Row( children: <Widget>[ GestureDetector( onTap: () { log.add('left'); Navigator.pushNamed(context, '/second'); }, child: const Text('left'), ), GestureDetector( onTap: () { log.add('right'); }, child: const Text('right'), ), ], ); }, '/second': (BuildContext context) => Container(), }; await tester.pumpWidget(MaterialApp(routes: routes)); expect(log, isEmpty); await tester.tap(find.text('left')); expect(log, equals(<String>['left'])); await tester.tap(find.text('right'), warnIfMissed: false); expect(log, equals(<String>['left'])); }); testWidgets('Pending gestures are rejected', (WidgetTester tester) async { final List<String> log = <String>[]; final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/': (BuildContext context) { return Row( children: <Widget>[ GestureDetector( onTap: () { log.add('left'); Navigator.pushNamed(context, '/second'); }, child: const Text('left'), ), GestureDetector( onTap: () { log.add('right'); }, child: const Text('right'), ), ], ); }, '/second': (BuildContext context) => Container(), }; await tester.pumpWidget(MaterialApp(routes: routes)); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('right')), pointer: 23); expect(log, isEmpty); await tester.tap(find.text('left')); expect(log, equals(<String>['left'])); await gesture.up(); expect(log, equals(<String>['left'])); }, skip: true); // https://github.com/flutter/flutter/issues/4771 testWidgets('popAndPushNamed', (WidgetTester tester) async { final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/' : (BuildContext context) => OnTapPage(id: '/', onTap: () { Navigator.pushNamed(context, '/A'); }), '/A': (BuildContext context) => OnTapPage(id: 'A', onTap: () { Navigator.popAndPushNamed(context, '/B'); }), '/B': (BuildContext context) => OnTapPage(id: 'B', onTap: () { Navigator.pop(context); }), }; await tester.pumpWidget(MaterialApp(routes: routes)); expect(find.text('/'), findsOneWidget); expect(find.text('A', skipOffstage: false), findsNothing); expect(find.text('B', skipOffstage: false), findsNothing); await tester.tap(find.text('/')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.text('/'), findsNothing); expect(find.text('A'), findsOneWidget); expect(find.text('B'), findsNothing); await tester.tap(find.text('A')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.text('/'), findsNothing); expect(find.text('A'), findsNothing); expect(find.text('B'), findsOneWidget); }); testWidgets('popAndPushNamed with explicit void type parameter', (WidgetTester tester) async { final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/' : (BuildContext context) => OnTapPage(id: '/', onTap: () { Navigator.pushNamed<void>(context, '/A'); }), '/A': (BuildContext context) => OnTapPage(id: 'A', onTap: () { Navigator.popAndPushNamed<void, void>(context, '/B'); }), '/B': (BuildContext context) => OnTapPage(id: 'B', onTap: () { Navigator.pop<void>(context); }), }; await tester.pumpWidget(MaterialApp(routes: routes)); expect(find.text('/'), findsOneWidget); expect(find.text('A', skipOffstage: false), findsNothing); expect(find.text('B', skipOffstage: false), findsNothing); await tester.tap(find.text('/')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.text('/'), findsNothing); expect(find.text('A'), findsOneWidget); expect(find.text('B'), findsNothing); await tester.tap(find.text('A')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.text('/'), findsNothing); expect(find.text('A'), findsNothing); expect(find.text('B'), findsOneWidget); }); testWidgets('Push and pop should trigger the observers', (WidgetTester tester) async { final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/' : (BuildContext context) => OnTapPage(id: '/', onTap: () { Navigator.pushNamed(context, '/A'); }), '/A': (BuildContext context) => OnTapPage(id: 'A', onTap: () { Navigator.pop(context); }), }; bool isPushed = false; bool isPopped = false; final TestObserver observer = TestObserver() ..onPushed = (Route<dynamic>? route, Route<dynamic>? previousRoute) { // Pushes the initial route. expect(route is PageRoute && route.settings.name == '/', isTrue); expect(previousRoute, isNull); isPushed = true; } ..onPopped = (Route<dynamic>? route, Route<dynamic>? previousRoute) { isPopped = true; }; await tester.pumpWidget(MaterialApp( routes: routes, navigatorObservers: <NavigatorObserver>[observer], )); expect(find.text('/'), findsOneWidget); expect(find.text('A'), findsNothing); expect(isPushed, isTrue); expect(isPopped, isFalse); isPushed = false; isPopped = false; observer.onPushed = (Route<dynamic>? route, Route<dynamic>? previousRoute) { expect(route is PageRoute && route.settings.name == '/A', isTrue); expect(previousRoute is PageRoute && previousRoute.settings.name == '/', isTrue); isPushed = true; }; await tester.tap(find.text('/')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.text('/'), findsNothing); expect(find.text('A'), findsOneWidget); expect(isPushed, isTrue); expect(isPopped, isFalse); isPushed = false; isPopped = false; observer.onPopped = (Route<dynamic>? route, Route<dynamic>? previousRoute) { expect(route is PageRoute && route.settings.name == '/A', isTrue); expect(previousRoute is PageRoute && previousRoute.settings.name == '/', isTrue); isPopped = true; }; await tester.tap(find.text('A')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.text('/'), findsOneWidget); expect(find.text('A'), findsNothing); expect(isPushed, isFalse); expect(isPopped, isTrue); }); testWidgets('Add and remove an observer should work', (WidgetTester tester) async { final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/' : (BuildContext context) => OnTapPage(id: '/', onTap: () { Navigator.pushNamed(context, '/A'); }), '/A': (BuildContext context) => OnTapPage(id: 'A', onTap: () { Navigator.pop(context); }), }; bool isPushed = false; bool isPopped = false; final TestObserver observer1 = TestObserver(); final TestObserver observer2 = TestObserver() ..onPushed = (Route<dynamic>? route, Route<dynamic>? previousRoute) { isPushed = true; } ..onPopped = (Route<dynamic>? route, Route<dynamic>? previousRoute) { isPopped = true; }; await tester.pumpWidget(MaterialApp( routes: routes, navigatorObservers: <NavigatorObserver>[observer1], )); expect(isPushed, isFalse); expect(isPopped, isFalse); await tester.pumpWidget(MaterialApp( routes: routes, navigatorObservers: <NavigatorObserver>[observer1, observer2], )); await tester.tap(find.text('/')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(isPushed, isTrue); expect(isPopped, isFalse); isPushed = false; isPopped = false; await tester.pumpWidget(MaterialApp( routes: routes, navigatorObservers: <NavigatorObserver>[observer1], )); await tester.tap(find.text('A')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(isPushed, isFalse); expect(isPopped, isFalse); }); testWidgets('initial route trigger observer in the right order', (WidgetTester tester) async { final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/' : (BuildContext context) => const Text('/'), '/A': (BuildContext context) => const Text('A'), '/A/B': (BuildContext context) => const Text('B'), }; final List<NavigatorObservation> observations = <NavigatorObservation>[]; final TestObserver observer = TestObserver() ..onPushed = (Route<dynamic>? route, Route<dynamic>? previousRoute) { // Pushes the initial route. observations.add( NavigatorObservation( current: route?.settings.name, previous: previousRoute?.settings.name, operation: 'push', ), ); }; await tester.pumpWidget(MaterialApp( routes: routes, initialRoute: '/A/B', navigatorObservers: <NavigatorObserver>[observer], )); expect(observations.length, 3); expect(observations[0].operation, 'push'); expect(observations[0].current, '/'); expect(observations[0].previous, isNull); expect(observations[1].operation, 'push'); expect(observations[1].current, '/A'); expect(observations[1].previous, '/'); expect(observations[2].operation, 'push'); expect(observations[2].current, '/A/B'); expect(observations[2].previous, '/A'); }); testWidgets('Route didAdd and dispose in same frame work', (WidgetTester tester) async { // Regression Test for https://github.com/flutter/flutter/issues/61346. Widget buildNavigator() { return Navigator( pages: const <Page<void>>[ MaterialPage<void>( child: Placeholder(), ), ], onPopPage: (Route<dynamic> route, dynamic result) => false, ); } final TabController controller = TabController(length: 3, vsync: tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: TabBarView( controller: controller, children: <Widget>[ buildNavigator(), buildNavigator(), buildNavigator(), ], ), ), ); // This test should finish without crashing. controller.index = 2; await tester.pumpAndSettle(); }); testWidgets('Pages update does update overlay correctly', (WidgetTester tester) async { // Regression Test for https://github.com/flutter/flutter/issues/64941. List<Page<void>> pages = const <Page<void>>[ MaterialPage<void>( key: ValueKey<int>(0), child: Text('page 0'), ), MaterialPage<void>( key: ValueKey<int>(1), child: Text('page 1'), ), ]; Widget buildNavigator() { return Navigator( pages: pages, onPopPage: (Route<dynamic> route, dynamic result) => false, ); } await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: buildNavigator(), ), ); expect(find.text('page 1'), findsOneWidget); expect(find.text('page 0'), findsNothing); // Removes the first page. pages = const <Page<void>>[ MaterialPage<void>( key: ValueKey<int>(1), child: Text('page 1'), ), ]; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: buildNavigator(), ), ); // Overlay updates correctly. expect(find.text('page 1'), findsOneWidget); expect(find.text('page 0'), findsNothing); await tester.pumpAndSettle(); expect(find.text('page 1'), findsOneWidget); expect(find.text('page 0'), findsNothing); }); testWidgets('replaceNamed replaces', (WidgetTester tester) async { final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/' : (BuildContext context) => OnTapPage(id: '/', onTap: () { Navigator.pushReplacementNamed(context, '/A'); }), '/A': (BuildContext context) => OnTapPage(id: 'A', onTap: () { Navigator.pushReplacementNamed(context, '/B'); }), '/B': (BuildContext context) => const OnTapPage(id: 'B'), }; await tester.pumpWidget(MaterialApp(routes: routes)); await tester.tap(find.text('/')); // replaceNamed('/A') await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.text('/'), findsNothing); expect(find.text('A'), findsOneWidget); await tester.tap(find.text('A')); // replaceNamed('/B') await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.text('/'), findsNothing); expect(find.text('A'), findsNothing); expect(find.text('B'), findsOneWidget); }); testWidgets('pushReplacement sets secondaryAnimation after transition, with history change during transition', (WidgetTester tester) async { final Map<String, SlideInOutPageRoute<dynamic>> routes = <String, SlideInOutPageRoute<dynamic>>{}; final Map<String, WidgetBuilder> builders = <String, WidgetBuilder>{ '/' : (BuildContext context) => OnTapPage( id: '/', onTap: () { Navigator.pushNamed(context, '/A'); }, ), '/A': (BuildContext context) => OnTapPage( id: 'A', onTap: () { Navigator.pushNamed(context, '/B'); }, ), '/B': (BuildContext context) => OnTapPage( id: 'B', onTap: () { Navigator.pushReplacementNamed(context, '/C'); }, ), '/C': (BuildContext context) => OnTapPage( id: 'C', onTap: () { Navigator.removeRoute(context, routes['/']!); }, ), }; await tester.pumpWidget(MaterialApp( onGenerateRoute: (RouteSettings settings) { final SlideInOutPageRoute<dynamic> ret = SlideInOutPageRoute<dynamic>(bodyBuilder: builders[settings.name]!, settings: settings); routes[settings.name!] = ret; return ret; }, )); await tester.pumpAndSettle(); await tester.tap(find.text('/')); await tester.pumpAndSettle(); final double a2 = routes['/A']!.secondaryAnimation!.value; await tester.tap(find.text('A')); await tester.pump(); await tester.pump(const Duration(milliseconds: 16)); expect(routes['/A']!.secondaryAnimation!.value, greaterThan(a2)); await tester.pumpAndSettle(); await tester.tap(find.text('B')); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); expect(routes['/A']!.secondaryAnimation!.value, equals(1.0)); await tester.tap(find.text('C')); await tester.pumpAndSettle(); expect(find.text('C'), isOnstage); expect(routes['/A']!.secondaryAnimation!.value, equals(routes['/C']!.animation!.value)); final AnimationController controller = routes['/C']!.controller!; controller.value = 1 - controller.value; expect(routes['/A']!.secondaryAnimation!.value, equals(routes['/C']!.animation!.value)); }); testWidgets('new route removed from navigator history during pushReplacement transition', (WidgetTester tester) async { final Map<String, SlideInOutPageRoute<dynamic>> routes = <String, SlideInOutPageRoute<dynamic>>{}; final Map<String, WidgetBuilder> builders = <String, WidgetBuilder>{ '/' : (BuildContext context) => OnTapPage( id: '/', onTap: () { Navigator.pushNamed(context, '/A'); }, ), '/A': (BuildContext context) => OnTapPage( id: 'A', onTap: () { Navigator.pushReplacementNamed(context, '/B'); }, ), '/B': (BuildContext context) => OnTapPage( id: 'B', onTap: () { Navigator.removeRoute(context, routes['/B']!); }, ), }; await tester.pumpWidget(MaterialApp( onGenerateRoute: (RouteSettings settings) { final SlideInOutPageRoute<dynamic> ret = SlideInOutPageRoute<dynamic>(bodyBuilder: builders[settings.name]!, settings: settings); routes[settings.name!] = ret; return ret; }, )); await tester.pumpAndSettle(); await tester.tap(find.text('/')); await tester.pumpAndSettle(); await tester.tap(find.text('A')); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); expect(find.text('A'), isOnstage); expect(find.text('B'), isOnstage); await tester.tap(find.text('B')); await tester.pumpAndSettle(); expect(find.text('/'), isOnstage); expect(find.text('B'), findsNothing); expect(find.text('A'), findsNothing); expect(routes['/']!.secondaryAnimation!.value, equals(0.0)); expect(routes['/']!.animation!.value, equals(1.0)); }); testWidgets('pushReplacement triggers secondaryAnimation', (WidgetTester tester) async { final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/' : (BuildContext context) => OnTapPage( id: '/', onTap: () { Navigator.pushReplacementNamed(context, '/A'); }, ), '/A': (BuildContext context) => OnTapPage( id: 'A', onTap: () { Navigator.pushReplacementNamed(context, '/B'); }, ), '/B': (BuildContext context) => const OnTapPage(id: 'B'), }; await tester.pumpWidget(MaterialApp( onGenerateRoute: (RouteSettings settings) { return SlideInOutPageRoute<dynamic>(bodyBuilder: routes[settings.name]!); }, )); await tester.pumpAndSettle(); final Offset rootOffsetOriginal = tester.getTopLeft(find.text('/')); await tester.tap(find.text('/')); await tester.pump(); await tester.pump(const Duration(milliseconds: 16)); expect(find.text('/'), isOnstage); expect(find.text('A'), isOnstage); expect(find.text('B'), findsNothing); final Offset rootOffset = tester.getTopLeft(find.text('/')); expect(rootOffset.dx, lessThan(rootOffsetOriginal.dx)); Offset aOffsetOriginal = tester.getTopLeft(find.text('A')); await tester.pumpAndSettle(); Offset aOffset = tester.getTopLeft(find.text('A')); expect(aOffset.dx, lessThan(aOffsetOriginal.dx)); aOffsetOriginal = aOffset; await tester.tap(find.text('A')); await tester.pump(); await tester.pump(const Duration(milliseconds: 16)); expect(find.text('/'), findsNothing); expect(find.text('A'), isOnstage); expect(find.text('B'), isOnstage); aOffset = tester.getTopLeft(find.text('A')); expect(aOffset.dx, lessThan(aOffsetOriginal.dx)); }); testWidgets('pushReplacement correctly reports didReplace to the observer', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/56892. final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/' : (BuildContext context) => const OnTapPage( id: '/', ), '/A': (BuildContext context) => const OnTapPage( id: 'A', ), '/A/B': (BuildContext context) => OnTapPage( id: 'B', onTap: (){ Navigator.of(context).popUntil((Route<dynamic> route) => route.isFirst); Navigator.of(context).pushReplacementNamed('/C'); }, ), '/C': (BuildContext context) => const OnTapPage(id: 'C', ), }; final List<NavigatorObservation> observations = <NavigatorObservation>[]; final TestObserver observer = TestObserver() ..onPopped = (Route<dynamic>? route, Route<dynamic>? previousRoute) { observations.add( NavigatorObservation( current: route?.settings.name, previous: previousRoute?.settings.name, operation: 'didPop', ), ); } ..onReplaced = (Route<dynamic>? route, Route<dynamic>? previousRoute) { observations.add( NavigatorObservation( current: route?.settings.name, previous: previousRoute?.settings.name, operation: 'didReplace', ), ); }; await tester.pumpWidget( MaterialApp( routes: routes, navigatorObservers: <NavigatorObserver>[observer], initialRoute: '/A/B', ), ); await tester.pumpAndSettle(); expect(find.text('B'), isOnstage); await tester.tap(find.text('B')); await tester.pump(); await tester.pump(const Duration(milliseconds: 16)); expect(observations.length, 3); expect(observations[0].current, '/A/B'); expect(observations[0].previous, '/A'); expect(observations[0].operation, 'didPop'); expect(observations[1].current, '/A'); expect(observations[1].previous, '/'); expect(observations[1].operation, 'didPop'); expect(observations[2].current, '/C'); expect(observations[2].previous, '/'); expect(observations[2].operation, 'didReplace'); await tester.pumpAndSettle(); expect(find.text('C'), isOnstage); }); testWidgets('Able to pop all routes', (WidgetTester tester) async { final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/' : (BuildContext context) => const OnTapPage( id: '/', ), '/A': (BuildContext context) => const OnTapPage( id: 'A', ), '/A/B': (BuildContext context) => OnTapPage( id: 'B', onTap: (){ // Pops all routes with bad predicate. Navigator.of(context).popUntil((Route<dynamic> route) => false); }, ), }; await tester.pumpWidget( MaterialApp( routes: routes, initialRoute: '/A/B', ), ); await tester.tap(find.text('B')); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); testWidgets('pushAndRemoveUntil triggers secondaryAnimation', (WidgetTester tester) async { final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/' : (BuildContext context) => OnTapPage( id: '/', onTap: () { Navigator.pushNamed(context, '/A'); }, ), '/A': (BuildContext context) => OnTapPage( id: 'A', onTap: () { Navigator.pushNamedAndRemoveUntil(context, '/B', (Route<dynamic> route) => false); }, ), '/B': (BuildContext context) => const OnTapPage(id: 'B'), }; await tester.pumpWidget(MaterialApp( onGenerateRoute: (RouteSettings settings) { return SlideInOutPageRoute<dynamic>(bodyBuilder: routes[settings.name]!); }, )); await tester.pumpAndSettle(); final Offset rootOffsetOriginal = tester.getTopLeft(find.text('/')); await tester.tap(find.text('/')); await tester.pump(); await tester.pump(const Duration(milliseconds: 16)); expect(find.text('/'), isOnstage); expect(find.text('A'), isOnstage); expect(find.text('B'), findsNothing); final Offset rootOffset = tester.getTopLeft(find.text('/')); expect(rootOffset.dx, lessThan(rootOffsetOriginal.dx)); Offset aOffsetOriginal = tester.getTopLeft(find.text('A')); await tester.pumpAndSettle(); Offset aOffset = tester.getTopLeft(find.text('A')); expect(aOffset.dx, lessThan(aOffsetOriginal.dx)); aOffsetOriginal = aOffset; await tester.tap(find.text('A')); await tester.pump(); await tester.pump(const Duration(milliseconds: 16)); expect(find.text('/'), findsNothing); expect(find.text('A'), isOnstage); expect(find.text('B'), isOnstage); aOffset = tester.getTopLeft(find.text('A')); expect(aOffset.dx, lessThan(aOffsetOriginal.dx)); await tester.pumpAndSettle(); expect(find.text('/'), findsNothing); expect(find.text('A'), findsNothing); expect(find.text('B'), isOnstage); }); testWidgets('pushAndRemoveUntil does not remove routes below the first route that pass the predicate', (WidgetTester tester) async { // Regression https://github.com/flutter/flutter/issues/56688 final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/': (BuildContext context) => const Text('home'), '/A': (BuildContext context) => const Text('page A'), '/A/B': (BuildContext context) => OnTapPage( id: 'B', onTap: () { Navigator.of(context).pushNamedAndRemoveUntil('/D', ModalRoute.withName('/A')); }, ), '/D': (BuildContext context) => const Text('page D'), }; await tester.pumpWidget( MaterialApp( navigatorKey: navigator, routes: routes, initialRoute: '/A/B', ), ); await tester.pumpAndSettle(); await tester.tap(find.text('B')); await tester.pumpAndSettle(); expect(find.text('page D'), isOnstage); navigator.currentState!.pop(); await tester.pumpAndSettle(); expect(find.text('page A'), isOnstage); navigator.currentState!.pop(); await tester.pumpAndSettle(); expect(find.text('home'), isOnstage); }); testWidgets('replaceNamed returned value', (WidgetTester tester) async { late Future<String?> value; final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/' : (BuildContext context) => OnTapPage(id: '/', onTap: () { Navigator.pushNamed(context, '/A'); }), '/A': (BuildContext context) => OnTapPage(id: 'A', onTap: () { value = Navigator.pushReplacementNamed(context, '/B', result: 'B'); }), '/B': (BuildContext context) => OnTapPage(id: 'B', onTap: () { Navigator.pop(context, 'B'); }), }; await tester.pumpWidget(MaterialApp( onGenerateRoute: (RouteSettings settings) { return PageRouteBuilder<String>( settings: settings, pageBuilder: (BuildContext context, Animation<double> _, Animation<double> __) { return routes[settings.name]!(context); }, ); }, )); expect(find.text('/'), findsOneWidget); expect(find.text('A', skipOffstage: false), findsNothing); expect(find.text('B', skipOffstage: false), findsNothing); await tester.tap(find.text('/')); // pushNamed('/A'), stack becomes /, /A await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.text('/'), findsNothing); expect(find.text('A'), findsOneWidget); expect(find.text('B'), findsNothing); await tester.tap(find.text('A')); // replaceNamed('/B'), stack becomes /, /B await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.text('/'), findsNothing); expect(find.text('A'), findsNothing); expect(find.text('B'), findsOneWidget); await tester.tap(find.text('B')); // pop, stack becomes / await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.text('/'), findsOneWidget); expect(find.text('A'), findsNothing); expect(find.text('B'), findsNothing); final String? replaceNamedValue = await value; // replaceNamed result was 'B' expect(replaceNamedValue, 'B'); }); testWidgets('removeRoute', (WidgetTester tester) async { final Map<String, WidgetBuilder> pageBuilders = <String, WidgetBuilder>{ '/' : (BuildContext context) => OnTapPage(id: '/', onTap: () { Navigator.pushNamed(context, '/A'); }), '/A': (BuildContext context) => OnTapPage(id: 'A', onTap: () { Navigator.pushNamed(context, '/B'); }), '/B': (BuildContext context) => const OnTapPage(id: 'B'), }; final Map<String, Route<String>> routes = <String, Route<String>>{}; late Route<String> removedRoute; late Route<String> previousRoute; final TestObserver observer = TestObserver() ..onRemoved = (Route<dynamic>? route, Route<dynamic>? previous) { removedRoute = route! as Route<String>; previousRoute = previous! as Route<String>; }; await tester.pumpWidget(MaterialApp( navigatorObservers: <NavigatorObserver>[observer], onGenerateRoute: (RouteSettings settings) { routes[settings.name!] = PageRouteBuilder<String>( settings: settings, pageBuilder: (BuildContext context, Animation<double> _, Animation<double> __) { return pageBuilders[settings.name!]!(context); }, ); return routes[settings.name]; }, )); expect(find.text('/'), findsOneWidget); expect(find.text('A'), findsNothing); expect(find.text('B'), findsNothing); await tester.tap(find.text('/')); // pushNamed('/A'), stack becomes /, /A await tester.pumpAndSettle(); expect(find.text('/'), findsNothing); expect(find.text('A'), findsOneWidget); expect(find.text('B'), findsNothing); await tester.tap(find.text('A')); // pushNamed('/B'), stack becomes /, /A, /B await tester.pumpAndSettle(); expect(find.text('/'), findsNothing); expect(find.text('A'), findsNothing); expect(find.text('B'), findsOneWidget); // Verify that the navigator's stack is ordered as expected. expect(routes['/']!.isActive, true); expect(routes['/A']!.isActive, true); expect(routes['/B']!.isActive, true); expect(routes['/']!.isFirst, true); expect(routes['/B']!.isCurrent, true); final NavigatorState navigator = tester.state<NavigatorState>(find.byType(Navigator)); navigator.removeRoute(routes['/B']!); // stack becomes /, /A await tester.pump(); expect(find.text('/'), findsNothing); expect(find.text('A'), findsOneWidget); expect(find.text('B'), findsNothing); // Verify that the navigator's stack no longer includes /B expect(routes['/']!.isActive, true); expect(routes['/A']!.isActive, true); expect(routes['/B']!.isActive, false); expect(routes['/']!.isFirst, true); expect(routes['/A']!.isCurrent, true); expect(removedRoute, routes['/B']); expect(previousRoute, routes['/A']); navigator.removeRoute(routes['/A']!); // stack becomes just / await tester.pump(); expect(find.text('/'), findsOneWidget); expect(find.text('A'), findsNothing); expect(find.text('B'), findsNothing); // Verify that the navigator's stack no longer includes /A expect(routes['/']!.isActive, true); expect(routes['/A']!.isActive, false); expect(routes['/B']!.isActive, false); expect(routes['/']!.isFirst, true); expect(routes['/']!.isCurrent, true); expect(removedRoute, routes['/A']); expect(previousRoute, routes['/']); }); testWidgets('remove a route whose value is awaited', (WidgetTester tester) async { late Future<String?> pageValue; final Map<String, WidgetBuilder> pageBuilders = <String, WidgetBuilder>{ '/': (BuildContext context) => OnTapPage(id: '/', onTap: () { pageValue = Navigator.pushNamed(context, '/A'); }), '/A': (BuildContext context) => OnTapPage(id: 'A', onTap: () { Navigator.pop(context, 'A'); }), }; final Map<String, Route<String>> routes = <String, Route<String>>{}; await tester.pumpWidget(MaterialApp( onGenerateRoute: (RouteSettings settings) { routes[settings.name!] = PageRouteBuilder<String>( settings: settings, pageBuilder: (BuildContext context, Animation<double> _, Animation<double> __) { return pageBuilders[settings.name!]!(context); }, ); return routes[settings.name]; }, )); await tester.tap(find.text('/')); // pushNamed('/A'), stack becomes /, /A await tester.pumpAndSettle(); pageValue.then((String? value) { assert(false); }); final NavigatorState navigator = tester.state<NavigatorState>(find.byType(Navigator)); navigator.removeRoute(routes['/A']!); // stack becomes /, pageValue will not complete }); testWidgets('replacing route can be observed', (WidgetTester tester) async { final GlobalKey<NavigatorState> key = GlobalKey<NavigatorState>(); final List<String> log = <String>[]; final TestObserver observer = TestObserver() ..onPushed = (Route<dynamic>? route, Route<dynamic>? previousRoute) { log.add('pushed ${route!.settings.name} (previous is ${previousRoute == null ? "<none>" : previousRoute.settings.name})'); } ..onPopped = (Route<dynamic>? route, Route<dynamic>? previousRoute) { log.add('popped ${route!.settings.name} (previous is ${previousRoute == null ? "<none>" : previousRoute.settings.name})'); } ..onRemoved = (Route<dynamic>? route, Route<dynamic>? previousRoute) { log.add('removed ${route!.settings.name} (previous is ${previousRoute == null ? "<none>" : previousRoute.settings.name})'); } ..onReplaced = (Route<dynamic>? newRoute, Route<dynamic>? oldRoute) { log.add('replaced ${oldRoute!.settings.name} with ${newRoute!.settings.name}'); }; late Route<void> routeB; await tester.pumpWidget(MaterialApp( navigatorKey: key, navigatorObservers: <NavigatorObserver>[observer], home: TextButton( child: const Text('A'), onPressed: () { key.currentState!.push<void>(routeB = MaterialPageRoute<void>( settings: const RouteSettings(name: 'B'), builder: (BuildContext context) { return TextButton( child: const Text('B'), onPressed: () { key.currentState!.push<void>(MaterialPageRoute<int>( settings: const RouteSettings(name: 'C'), builder: (BuildContext context) { return TextButton( child: const Text('C'), onPressed: () { key.currentState!.replace( oldRoute: routeB, newRoute: MaterialPageRoute<int>( settings: const RouteSettings(name: 'D'), builder: (BuildContext context) { return const Text('D'); }, ), ); }, ); }, )); }, ); }, )); }, ), )); expect(log, <String>['pushed / (previous is <none>)']); await tester.tap(find.text('A')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(log, <String>['pushed / (previous is <none>)', 'pushed B (previous is /)']); await tester.tap(find.text('B')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(log, <String>['pushed / (previous is <none>)', 'pushed B (previous is /)', 'pushed C (previous is B)']); await tester.tap(find.text('C')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(log, <String>['pushed / (previous is <none>)', 'pushed B (previous is /)', 'pushed C (previous is B)', 'replaced B with D']); }); testWidgets('didStartUserGesture observable', (WidgetTester tester) async { final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/' : (BuildContext context) => OnTapPage(id: '/', onTap: () { Navigator.pushNamed(context, '/A'); }), '/A': (BuildContext context) => OnTapPage(id: 'A', onTap: () { Navigator.pop(context); }), }; late Route<dynamic> observedRoute; late Route<dynamic> observedPreviousRoute; final TestObserver observer = TestObserver() ..onStartUserGesture = (Route<dynamic>? route, Route<dynamic>? previousRoute) { observedRoute = route!; observedPreviousRoute = previousRoute!; }; await tester.pumpWidget(MaterialApp( routes: routes, navigatorObservers: <NavigatorObserver>[observer], )); await tester.tap(find.text('/')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.text('/'), findsNothing); expect(find.text('A'), findsOneWidget); tester.state<NavigatorState>(find.byType(Navigator)).didStartUserGesture(); expect(observedRoute.settings.name, '/A'); expect(observedPreviousRoute.settings.name, '/'); }); testWidgets('ModalRoute.of sets up a route to rebuild if its state changes', (WidgetTester tester) async { final GlobalKey<NavigatorState> key = GlobalKey<NavigatorState>(); final List<String> log = <String>[]; late Route<void> routeB; await tester.pumpWidget(MaterialApp( navigatorKey: key, theme: ThemeData( pageTransitionsTheme: const PageTransitionsTheme( builders: <TargetPlatform, PageTransitionsBuilder>{ TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(), }, ), ), home: TextButton( child: const Text('A'), onPressed: () { key.currentState!.push<void>(routeB = MaterialPageRoute<void>( settings: const RouteSettings(name: 'B'), builder: (BuildContext context) { log.add('building B'); return TextButton( child: const Text('B'), onPressed: () { key.currentState!.push<void>(MaterialPageRoute<int>( settings: const RouteSettings(name: 'C'), builder: (BuildContext context) { log.add('building C'); log.add('found ${ModalRoute.of(context)!.settings.name}'); return TextButton( child: const Text('C'), onPressed: () { key.currentState!.replace( oldRoute: routeB, newRoute: MaterialPageRoute<int>( settings: const RouteSettings(name: 'D'), builder: (BuildContext context) { log.add('building D'); return const Text('D'); }, ), ); }, ); }, )); }, ); }, )); }, ), )); expect(log, <String>[]); await tester.tap(find.text('A')); await tester.pumpAndSettle(const Duration(milliseconds: 10)); expect(log, <String>['building B']); await tester.tap(find.text('B')); await tester.pumpAndSettle(const Duration(milliseconds: 10)); expect(log, <String>['building B', 'building C', 'found C']); await tester.tap(find.text('C')); await tester.pumpAndSettle(const Duration(milliseconds: 10)); expect(log, <String>['building B', 'building C', 'found C', 'building D']); key.currentState!.pop<void>(); await tester.pumpAndSettle(const Duration(milliseconds: 10)); expect(log, <String>['building B', 'building C', 'found C', 'building D']); }); testWidgets('Routes don\'t rebuild just because their animations ended', (WidgetTester tester) async { final GlobalKey<NavigatorState> key = GlobalKey<NavigatorState>(); final List<String> log = <String>[]; Route<dynamic>? nextRoute = PageRouteBuilder<int>( pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { log.add('building page 1 - ${ModalRoute.of(context)!.canPop}'); return const Placeholder(); }, ); await tester.pumpWidget(MaterialApp( navigatorKey: key, onGenerateRoute: (RouteSettings settings) { assert(nextRoute != null); final Route<dynamic> result = nextRoute!; nextRoute = null; return result; }, )); expect(log, <String>['building page 1 - false']); key.currentState!.pushReplacement(PageRouteBuilder<int>( pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { log.add('building page 2 - ${ModalRoute.of(context)!.canPop}'); return const Placeholder(); }, )); expect(log, <String>['building page 1 - false']); await tester.pump(); expect(log, <String>['building page 1 - false', 'building page 2 - false']); await tester.pump(const Duration(milliseconds: 150)); expect(log, <String>['building page 1 - false', 'building page 2 - false']); key.currentState!.pushReplacement(PageRouteBuilder<int>( pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { log.add('building page 3 - ${ModalRoute.of(context)!.canPop}'); return const Placeholder(); }, )); expect(log, <String>['building page 1 - false', 'building page 2 - false']); await tester.pump(); expect(log, <String>['building page 1 - false', 'building page 2 - false', 'building page 3 - false']); await tester.pump(const Duration(milliseconds: 200)); expect(log, <String>['building page 1 - false', 'building page 2 - false', 'building page 3 - false']); }); testWidgets('route semantics', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/': (BuildContext context) => OnTapPage(id: '1', onTap: () { Navigator.pushNamed(context, '/A'); }), '/A': (BuildContext context) => OnTapPage(id: '2', onTap: () { Navigator.pushNamed(context, '/B/C'); }), '/B/C': (BuildContext context) => const OnTapPage(id: '3'), }; await tester.pumpWidget(MaterialApp(routes: routes)); expect(semantics, includesNodeWith( flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], )); expect(semantics, includesNodeWith( label: 'Page 1', flags: <SemanticsFlag>[ SemanticsFlag.namesRoute, SemanticsFlag.isHeader, ], )); await tester.tap(find.text('1')); // pushNamed('/A') await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(semantics, includesNodeWith( flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], )); expect(semantics, includesNodeWith( label: 'Page 2', flags: <SemanticsFlag>[ SemanticsFlag.namesRoute, SemanticsFlag.isHeader, ], )); await tester.tap(find.text('2')); // pushNamed('/B/C') await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(semantics, includesNodeWith( flags: <SemanticsFlag>[ SemanticsFlag.scopesRoute, ], )); expect(semantics, includesNodeWith( label: 'Page 3', flags: <SemanticsFlag>[ SemanticsFlag.namesRoute, SemanticsFlag.isHeader, ], )); semantics.dispose(); }); testWidgets('arguments for named routes on Navigator', (WidgetTester tester) async { late GlobalKey currentRouteKey; final List<Object?> arguments = <Object?>[]; await tester.pumpWidget(MaterialApp( onGenerateRoute: (RouteSettings settings) { arguments.add(settings.arguments); return MaterialPageRoute<void>( settings: settings, builder: (BuildContext context) => Center(key: currentRouteKey = GlobalKey(), child: Text(settings.name!)), ); }, )); expect(find.text('/'), findsOneWidget); expect(arguments.single, isNull); arguments.clear(); Navigator.pushNamed( currentRouteKey.currentContext!, '/A', arguments: 'pushNamed', ); await tester.pumpAndSettle(); expect(find.text('/'), findsNothing); expect(find.text('/A'), findsOneWidget); expect(arguments.single, 'pushNamed'); arguments.clear(); Navigator.popAndPushNamed( currentRouteKey.currentContext!, '/B', arguments: 'popAndPushNamed', ); await tester.pumpAndSettle(); expect(find.text('/'), findsNothing); expect(find.text('/A'), findsNothing); expect(find.text('/B'), findsOneWidget); expect(arguments.single, 'popAndPushNamed'); arguments.clear(); Navigator.pushNamedAndRemoveUntil( currentRouteKey.currentContext!, '/C', (Route<dynamic> route) => route.isFirst, arguments: 'pushNamedAndRemoveUntil', ); await tester.pumpAndSettle(); expect(find.text('/'), findsNothing); expect(find.text('/A'), findsNothing); expect(find.text('/B'), findsNothing); expect(find.text('/C'), findsOneWidget); expect(arguments.single, 'pushNamedAndRemoveUntil'); arguments.clear(); Navigator.pushReplacementNamed( currentRouteKey.currentContext!, '/D', arguments: 'pushReplacementNamed', ); await tester.pumpAndSettle(); expect(find.text('/'), findsNothing); expect(find.text('/A'), findsNothing); expect(find.text('/B'), findsNothing); expect(find.text('/C'), findsNothing); expect(find.text('/D'), findsOneWidget); expect(arguments.single, 'pushReplacementNamed'); arguments.clear(); }); testWidgets('arguments for named routes on NavigatorState', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); final List<Object?> arguments = <Object?>[]; await tester.pumpWidget(MaterialApp( navigatorKey: navigatorKey, onGenerateRoute: (RouteSettings settings) { arguments.add(settings.arguments); return MaterialPageRoute<void>( settings: settings, builder: (BuildContext context) => Center(child: Text(settings.name!)), ); }, )); expect(find.text('/'), findsOneWidget); expect(arguments.single, isNull); arguments.clear(); navigatorKey.currentState!.pushNamed( '/A', arguments:'pushNamed', ); await tester.pumpAndSettle(); expect(find.text('/'), findsNothing); expect(find.text('/A'), findsOneWidget); expect(arguments.single, 'pushNamed'); arguments.clear(); navigatorKey.currentState!.popAndPushNamed( '/B', arguments: 'popAndPushNamed', ); await tester.pumpAndSettle(); expect(find.text('/'), findsNothing); expect(find.text('/A'), findsNothing); expect(find.text('/B'), findsOneWidget); expect(arguments.single, 'popAndPushNamed'); arguments.clear(); navigatorKey.currentState!.pushNamedAndRemoveUntil( '/C', (Route<dynamic> route) => route.isFirst, arguments: 'pushNamedAndRemoveUntil', ); await tester.pumpAndSettle(); expect(find.text('/'), findsNothing); expect(find.text('/A'), findsNothing); expect(find.text('/B'), findsNothing); expect(find.text('/C'), findsOneWidget); expect(arguments.single, 'pushNamedAndRemoveUntil'); arguments.clear(); navigatorKey.currentState!.pushReplacementNamed( '/D', arguments: 'pushReplacementNamed', ); await tester.pumpAndSettle(); expect(find.text('/'), findsNothing); expect(find.text('/A'), findsNothing); expect(find.text('/B'), findsNothing); expect(find.text('/C'), findsNothing); expect(find.text('/D'), findsOneWidget); expect(arguments.single, 'pushReplacementNamed'); arguments.clear(); }); testWidgets('Initial route can have gaps', (WidgetTester tester) async { final GlobalKey<NavigatorState> keyNav = GlobalKey<NavigatorState>(); const Key keyRoot = Key('Root'); const Key keyA = Key('A'); const Key keyABC = Key('ABC'); await tester.pumpWidget( MaterialApp( navigatorKey: keyNav, initialRoute: '/A/B/C', routes: <String, WidgetBuilder>{ '/': (BuildContext context) => Container(key: keyRoot), '/A': (BuildContext context) => Container(key: keyA), // The route /A/B is intentionally left out. '/A/B/C': (BuildContext context) => Container(key: keyABC), }, ), ); // The initial route /A/B/C should've been pushed successfully. expect(find.byKey(keyRoot, skipOffstage: false), findsOneWidget); expect(find.byKey(keyA, skipOffstage: false), findsOneWidget); expect(find.byKey(keyABC), findsOneWidget); keyNav.currentState!.pop(); await tester.pumpAndSettle(); expect(find.byKey(keyRoot, skipOffstage: false), findsOneWidget); expect(find.byKey(keyA), findsOneWidget); expect(find.byKey(keyABC, skipOffstage: false), findsNothing); }); testWidgets('The full initial route has to be matched', (WidgetTester tester) async { final GlobalKey<NavigatorState> keyNav = GlobalKey<NavigatorState>(); const Key keyRoot = Key('Root'); const Key keyA = Key('A'); const Key keyAB = Key('AB'); await tester.pumpWidget( MaterialApp( navigatorKey: keyNav, initialRoute: '/A/B/C', routes: <String, WidgetBuilder>{ '/': (BuildContext context) => Container(key: keyRoot), '/A': (BuildContext context) => Container(key: keyA), '/A/B': (BuildContext context) => Container(key: keyAB), // The route /A/B/C is intentionally left out. }, ), ); final dynamic exception = tester.takeException(); expect(exception, isA<String>()); expect(exception.startsWith('Could not navigate to initial route.'), isTrue); // Only the root route should've been pushed. expect(find.byKey(keyRoot), findsOneWidget); expect(find.byKey(keyA), findsNothing); expect(find.byKey(keyAB), findsNothing); }); testWidgets("Popping immediately after pushing doesn't crash", (WidgetTester tester) async { // Added this test to protect against regression of https://github.com/flutter/flutter/issues/45539 final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/' : (BuildContext context) => OnTapPage(id: '/', onTap: () { Navigator.pushNamed(context, '/A'); Navigator.of(context).pop(); }), '/A': (BuildContext context) => OnTapPage(id: 'A', onTap: () { Navigator.pop(context); }), }; bool isPushed = false; bool isPopped = false; final TestObserver observer = TestObserver() ..onPushed = (Route<dynamic>? route, Route<dynamic>? previousRoute) { // Pushes the initial route. expect(route is PageRoute && route.settings.name == '/', isTrue); expect(previousRoute, isNull); isPushed = true; } ..onPopped = (Route<dynamic>? route, Route<dynamic>? previousRoute) { isPopped = true; }; await tester.pumpWidget(MaterialApp( routes: routes, navigatorObservers: <NavigatorObserver>[observer], )); expect(find.text('/'), findsOneWidget); expect(find.text('A'), findsNothing); expect(isPushed, isTrue); expect(isPopped, isFalse); isPushed = false; isPopped = false; observer.onPushed = (Route<dynamic>? route, Route<dynamic>? previousRoute) { expect(route is PageRoute && route.settings.name == '/A', isTrue); expect(previousRoute is PageRoute && previousRoute.settings.name == '/', isTrue); isPushed = true; }; await tester.tap(find.text('/')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.text('/'), findsOneWidget); expect(find.text('A'), findsNothing); expect(isPushed, isTrue); expect(isPopped, isTrue); }); group('error control test', () { testWidgets('onUnknownRoute null and onGenerateRoute returns null', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); await tester.pumpWidget(Navigator( key: navigatorKey, onGenerateRoute: (_) => null, )); final dynamic exception = tester.takeException(); expect(exception, isNotNull); expect(exception, isFlutterError); final FlutterError error = exception as FlutterError; expect(error, isNotNull); expect(error.diagnostics.last, isA<DiagnosticsProperty<NavigatorState>>()); expect( error.toStringDeep(), equalsIgnoringHashCodes( 'FlutterError\n' ' Navigator.onGenerateRoute returned null when requested to build\n' ' route "/".\n' ' The onGenerateRoute callback must never return null, unless an\n' ' onUnknownRoute callback is provided as well.\n' ' The Navigator was:\n' ' NavigatorState#00000(lifecycle state: initialized)\n', ), ); }); testWidgets('onUnknownRoute null and onGenerateRoute returns null', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); await tester.pumpWidget(Navigator( key: navigatorKey, onGenerateRoute: (_) => null, onUnknownRoute: (_) => null, )); final dynamic exception = tester.takeException(); expect(exception, isNotNull); expect(exception, isFlutterError); final FlutterError error = exception as FlutterError; expect(error, isNotNull); expect(error.diagnostics.last, isA<DiagnosticsProperty<NavigatorState>>()); expect( error.toStringDeep(), equalsIgnoringHashCodes( 'FlutterError\n' ' Navigator.onUnknownRoute returned null when requested to build\n' ' route "/".\n' ' The onUnknownRoute callback must never return null.\n' ' The Navigator was:\n' ' NavigatorState#00000(lifecycle state: initialized)\n', ), ); }); }); testWidgets('OverlayEntry of topmost initial route is marked as opaque', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/38038. final Key root = UniqueKey(); final Key intermediate = UniqueKey(); final GlobalKey topmost = GlobalKey(); await tester.pumpWidget( MaterialApp( initialRoute: '/A/B', routes: <String, WidgetBuilder>{ '/': (BuildContext context) => Container(key: root), '/A': (BuildContext context) => Container(key: intermediate), '/A/B': (BuildContext context) => Container(key: topmost), }, ), ); expect(ModalRoute.of(topmost.currentContext!)!.overlayEntries.first.opaque, isTrue); expect(find.byKey(root), findsNothing); // hidden by opaque Route expect(find.byKey(intermediate), findsNothing); // hidden by opaque Route expect(find.byKey(topmost), findsOneWidget); }); testWidgets('OverlayEntry of topmost route is set to opaque after Push', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/38038. final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); await tester.pumpWidget( MaterialApp( navigatorKey: navigator, initialRoute: '/', onGenerateRoute: (RouteSettings settings) { return NoAnimationPageRoute( pageBuilder: (_) => Container(key: ValueKey<String>(settings.name!)), ); }, ), ); expect(find.byKey(const ValueKey<String>('/')), findsOneWidget); navigator.currentState!.pushNamed('/A'); await tester.pump(); final BuildContext topMostContext = tester.element(find.byKey(const ValueKey<String>('/A'))); expect(ModalRoute.of(topMostContext)!.overlayEntries.first.opaque, isTrue); expect(find.byKey(const ValueKey<String>('/')), findsNothing); // hidden by /A expect(find.byKey(const ValueKey<String>('/A')), findsOneWidget); }); testWidgets('OverlayEntry of topmost route is set to opaque after Replace', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/38038. final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); await tester.pumpWidget( MaterialApp( navigatorKey: navigator, initialRoute: '/A/B', onGenerateRoute: (RouteSettings settings) { return NoAnimationPageRoute( pageBuilder: (_) => Container(key: ValueKey<String>(settings.name!)), ); }, ), ); expect(find.byKey(const ValueKey<String>('/')), findsNothing); expect(find.byKey(const ValueKey<String>('/A')), findsNothing); expect(find.byKey(const ValueKey<String>('/A/B')), findsOneWidget); final Route<dynamic> oldRoute = ModalRoute.of( tester.element(find.byKey(const ValueKey<String>('/A'), skipOffstage: false)), )!; final Route<void> newRoute = NoAnimationPageRoute( pageBuilder: (_) => Container(key: const ValueKey<String>('/C')), ); navigator.currentState!.replace<void>(oldRoute: oldRoute, newRoute: newRoute); await tester.pump(); expect(newRoute.overlayEntries.first.opaque, isTrue); expect(find.byKey(const ValueKey<String>('/')), findsNothing); // hidden by /A/B expect(find.byKey(const ValueKey<String>('/A')), findsNothing); // replaced expect(find.byKey(const ValueKey<String>('/C')), findsNothing); // hidden by /A/B expect(find.byKey(const ValueKey<String>('/A/B')), findsOneWidget); navigator.currentState!.pop(); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey<String>('/')), findsNothing); // hidden by /C expect(find.byKey(const ValueKey<String>('/A')), findsNothing); // replaced expect(find.byKey(const ValueKey<String>('/A/B')), findsNothing); // popped expect(find.byKey(const ValueKey<String>('/C')), findsOneWidget); }); testWidgets('Pushing opaque Route does not rebuild routes below', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/45797. final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); final Key bottomRoute = UniqueKey(); final Key topRoute = UniqueKey(); await tester.pumpWidget( MaterialApp( theme: ThemeData( pageTransitionsTheme: const PageTransitionsTheme( builders: <TargetPlatform, PageTransitionsBuilder>{ TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(), }, ), ), navigatorKey: navigator, routes: <String, WidgetBuilder>{ '/' : (BuildContext context) => StatefulTestWidget(key: bottomRoute), '/a': (BuildContext context) => StatefulTestWidget(key: topRoute), }, ), ); expect(tester.state<StatefulTestState>(find.byKey(bottomRoute)).rebuildCount, 1); navigator.currentState!.pushNamed('/a'); await tester.pumpAndSettle(); // Bottom route is offstage and did not rebuild. expect(find.byKey(bottomRoute), findsNothing); expect(tester.state<StatefulTestState>(find.byKey(bottomRoute, skipOffstage: false)).rebuildCount, 1); expect(tester.state<StatefulTestState>(find.byKey(topRoute)).rebuildCount, 1); }); testWidgets('initial routes below opaque route are offstage', (WidgetTester tester) async { final GlobalKey<NavigatorState> testKey = GlobalKey<NavigatorState>(); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Navigator( key: testKey, initialRoute: '/a/b', onGenerateRoute: (RouteSettings s) { return MaterialPageRoute<void>( builder: (BuildContext c) { return Text('+${s.name}+'); }, settings: s, ); }, ), ), ); expect(find.text('+/+'), findsNothing); expect(find.text('+/+', skipOffstage: false), findsOneWidget); expect(find.text('+/a+'), findsNothing); expect(find.text('+/a+', skipOffstage: false), findsOneWidget); expect(find.text('+/a/b+'), findsOneWidget); testKey.currentState!.pop(); await tester.pumpAndSettle(); expect(find.text('+/+'), findsNothing); expect(find.text('+/+', skipOffstage: false), findsOneWidget); expect(find.text('+/a+'), findsOneWidget); expect(find.text('+/a/b+'), findsNothing); testKey.currentState!.pop(); await tester.pumpAndSettle(); expect(find.text('+/+'), findsOneWidget); expect(find.text('+/a+'), findsNothing); expect(find.text('+/a/b+'), findsNothing); }); testWidgets('Can provide custom onGenerateInitialRoutes', (WidgetTester tester) async { bool onGenerateInitialRoutesCalled = false; final GlobalKey<NavigatorState> testKey = GlobalKey<NavigatorState>(); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Navigator( key: testKey, initialRoute: 'Hello World', onGenerateInitialRoutes: (NavigatorState navigator, String initialRoute) { onGenerateInitialRoutesCalled = true; final List<Route<void>> result = <Route<void>>[]; for (final String route in initialRoute.split(' ')) { result.add(MaterialPageRoute<void>(builder: (BuildContext context) { return Text(route); })); } return result; }, ), ), ); expect(onGenerateInitialRoutesCalled, true); expect(find.text('Hello'), findsNothing); expect(find.text('World'), findsOneWidget); testKey.currentState!.pop(); await tester.pumpAndSettle(); expect(find.text('Hello'), findsOneWidget); expect(find.text('World'), findsNothing); }); testWidgets('Navigator.of able to handle input context is a navigator context', (WidgetTester tester) async { final GlobalKey<NavigatorState> testKey = GlobalKey<NavigatorState>(); await tester.pumpWidget( MaterialApp( navigatorKey: testKey, home: const Text('home'), ), ); final NavigatorState state = Navigator.of(testKey.currentContext!); expect(state, testKey.currentState); }); testWidgets('Navigator.of able to handle input context is a navigator context - root navigator', (WidgetTester tester) async { final GlobalKey<NavigatorState> root = GlobalKey<NavigatorState>(); final GlobalKey<NavigatorState> sub = GlobalKey<NavigatorState>(); await tester.pumpWidget( MaterialApp( navigatorKey: root, home: Navigator( key: sub, onGenerateRoute: (RouteSettings settings) { return MaterialPageRoute<void>( settings: settings, builder: (BuildContext context) => const Text('dummy'), ); }, ), ), ); final NavigatorState state = Navigator.of(sub.currentContext!, rootNavigator: true); expect(state, root.currentState); }); testWidgets('Navigator.maybeOf throws when there is no navigator', (WidgetTester tester) async { final GlobalKey<NavigatorState> testKey = GlobalKey<NavigatorState>(); await tester.pumpWidget(SizedBox(key: testKey)); expect(() async { Navigator.of(testKey.currentContext!); }, throwsFlutterError); }); testWidgets('Navigator.maybeOf works when there is no navigator', (WidgetTester tester) async { final GlobalKey<NavigatorState> testKey = GlobalKey<NavigatorState>(); await tester.pumpWidget(SizedBox(key: testKey)); final NavigatorState? state = Navigator.maybeOf(testKey.currentContext!); expect(state, isNull); }); testWidgets('Navigator.maybeOf able to handle input context is a navigator context', (WidgetTester tester) async { final GlobalKey<NavigatorState> testKey = GlobalKey<NavigatorState>(); await tester.pumpWidget( MaterialApp( navigatorKey: testKey, home: const Text('home'), ), ); final NavigatorState? state = Navigator.maybeOf(testKey.currentContext!); expect(state, isNotNull); expect(state, testKey.currentState); }); testWidgets('Navigator.maybeOf able to handle input context is a navigator context - root navigator', (WidgetTester tester) async { final GlobalKey<NavigatorState> root = GlobalKey<NavigatorState>(); final GlobalKey<NavigatorState> sub = GlobalKey<NavigatorState>(); await tester.pumpWidget( MaterialApp( navigatorKey: root, home: Navigator( key: sub, onGenerateRoute: (RouteSettings settings) { return MaterialPageRoute<void>( settings: settings, builder: (BuildContext context) => const Text('dummy'), ); }, ), ), ); final NavigatorState? state = Navigator.maybeOf(sub.currentContext!, rootNavigator: true); expect(state, isNotNull); expect(state, root.currentState); }); testWidgets('pushAndRemove until animates the push', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/25080. const Duration kFourTenthsOfTheTransitionDuration = Duration(milliseconds: 120); final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); final Map<String, MaterialPageRoute<dynamic>> routeNameToContext = <String, MaterialPageRoute<dynamic>>{}; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Navigator( key: navigator, initialRoute: 'root', onGenerateRoute: (RouteSettings settings) { return MaterialPageRoute<void>( settings: settings, builder: (BuildContext context) { routeNameToContext[settings.name!] = ModalRoute.of(context)! as MaterialPageRoute<dynamic>; return Text('Route: ${settings.name}'); }, ); }, ), ), ); expect(find.text('Route: root'), findsOneWidget); navigator.currentState!.pushNamed('1'); await tester.pumpAndSettle(); expect(find.text('Route: 1'), findsOneWidget); navigator.currentState!.pushNamed('2'); await tester.pumpAndSettle(); expect(find.text('Route: 2'), findsOneWidget); navigator.currentState!.pushNamed('3'); await tester.pumpAndSettle(); expect(find.text('Route: 3'), findsOneWidget); expect(find.text('Route: 2', skipOffstage: false), findsOneWidget); expect(find.text('Route: 1', skipOffstage: false), findsOneWidget); expect(find.text('Route: root', skipOffstage: false), findsOneWidget); navigator.currentState!.pushNamedAndRemoveUntil('4', (Route<dynamic> route) => route.isFirst); await tester.pump(); expect(find.text('Route: 3'), findsOneWidget); expect(find.text('Route: 4'), findsOneWidget); final Animation<double> route4Entry = routeNameToContext['4']!.animation!; expect(route4Entry.value, 0.0); // Entry animation has not started. await tester.pump(kFourTenthsOfTheTransitionDuration); expect(find.text('Route: 3'), findsOneWidget); expect(find.text('Route: 4'), findsOneWidget); expect(route4Entry.value, 0.4); await tester.pump(kFourTenthsOfTheTransitionDuration); expect(find.text('Route: 3'), findsOneWidget); expect(find.text('Route: 4'), findsOneWidget); expect(route4Entry.value, 0.8); expect(find.text('Route: 2', skipOffstage: false), findsOneWidget); expect(find.text('Route: 1', skipOffstage: false), findsOneWidget); expect(find.text('Route: root', skipOffstage: false), findsOneWidget); // When we hit 1.0 all but root and current have been removed. await tester.pump(kFourTenthsOfTheTransitionDuration); expect(find.text('Route: 3', skipOffstage: false), findsNothing); expect(find.text('Route: 4'), findsOneWidget); expect(route4Entry.value, 1.0); expect(find.text('Route: 2', skipOffstage: false), findsNothing); expect(find.text('Route: 1', skipOffstage: false), findsNothing); expect(find.text('Route: root', skipOffstage: false), findsOneWidget); navigator.currentState!.pop(); await tester.pumpAndSettle(); expect(find.text('Route: root'), findsOneWidget); expect(find.text('Route: 4', skipOffstage: false), findsNothing); }); testWidgets('Wrapping TickerMode can turn off ticking in routes', (WidgetTester tester) async { int tickCount = 0; Widget widgetUnderTest({required bool enabled}) { return TickerMode( enabled: enabled, child: Directionality( textDirection: TextDirection.ltr, child: Navigator( initialRoute: 'root', onGenerateRoute: (RouteSettings settings) { return MaterialPageRoute<void>( settings: settings, builder: (BuildContext context) { return _TickingWidget( onTick: () { tickCount++; }, ); }, ); }, ), ), ); } await tester.pumpWidget(widgetUnderTest(enabled: false)); expect(tickCount, 0); await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1)); expect(tickCount, 0); await tester.pumpWidget(widgetUnderTest(enabled: true)); expect(tickCount, 0); await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1)); expect(tickCount, 4); }); testWidgets('Route announce correctly for first route and last route', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/57133. Route<void>? previousOfFirst = NotAnnounced(); Route<void>? nextOfFirst = NotAnnounced(); Route<void>? popNextOfFirst = NotAnnounced(); Route<void>? firstRoute; Route<void>? previousOfSecond = NotAnnounced(); Route<void>? nextOfSecond = NotAnnounced(); Route<void>? popNextOfSecond = NotAnnounced(); Route<void>? secondRoute; final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); await tester.pumpWidget( MaterialApp( navigatorKey: navigator, initialRoute: '/second', onGenerateRoute: (RouteSettings settings) { if (settings.name == '/') { firstRoute = RouteAnnouncementSpy( onDidChangeNext: (Route<void>? next) => nextOfFirst = next, onDidChangePrevious: (Route<void>? previous) => previousOfFirst = previous, onDidPopNext: (Route<void>? next) => popNextOfFirst = next, settings: settings, ); return firstRoute; } secondRoute = RouteAnnouncementSpy( onDidChangeNext: (Route<void>? next) => nextOfSecond = next, onDidChangePrevious: (Route<void>? previous) => previousOfSecond = previous, onDidPopNext: (Route<void>? next) => popNextOfSecond = next, settings: settings, ); return secondRoute; }, ), ); await tester.pumpAndSettle(); expect(previousOfFirst, isNull); expect(nextOfFirst, secondRoute); expect(popNextOfFirst, isA<NotAnnounced>()); expect(previousOfSecond, firstRoute); expect(nextOfSecond, isNull); expect(popNextOfSecond, isA<NotAnnounced>()); navigator.currentState!.pop(); expect(popNextOfFirst, secondRoute); }); testWidgets('hero controller scope works', (WidgetTester tester) async { final GlobalKey<NavigatorState> top = GlobalKey<NavigatorState>(); final GlobalKey<NavigatorState> sub = GlobalKey<NavigatorState>(); final List<NavigatorObservation> observations = <NavigatorObservation>[]; final HeroControllerSpy spy = HeroControllerSpy() ..onPushed = (Route<dynamic>? route, Route<dynamic>? previousRoute) { observations.add( NavigatorObservation( current: route?.settings.name, previous: previousRoute?.settings.name, operation: 'didPush', ), ); }; await tester.pumpWidget( HeroControllerScope( controller: spy, child: Directionality( textDirection: TextDirection.ltr, child: Navigator( key: top, initialRoute: 'top1', onGenerateRoute: (RouteSettings s) { return MaterialPageRoute<void>( builder: (BuildContext c) { return Navigator( key: sub, initialRoute: 'sub1', onGenerateRoute: (RouteSettings s) { return MaterialPageRoute<void>( builder: (BuildContext c) { return const Placeholder(); }, settings: s, ); }, ); }, settings: s, ); }, ), ), ), ); // It should only observe the top navigator. expect(observations.length, 1); expect(observations[0].current, 'top1'); expect(observations[0].previous, isNull); sub.currentState!.push(MaterialPageRoute<void>( settings: const RouteSettings(name:'sub2'), builder: (BuildContext context) => const Text('sub2'), )); await tester.pumpAndSettle(); expect(find.text('sub2'), findsOneWidget); // It should not record sub navigator. expect(observations.length, 1); top.currentState!.push(MaterialPageRoute<void>( settings: const RouteSettings(name:'top2'), builder: (BuildContext context) => const Text('top2'), )); await tester.pumpAndSettle(); expect(observations.length, 2); expect(observations[1].current, 'top2'); expect(observations[1].previous, 'top1'); }); testWidgets('hero controller can correctly transfer subscription - replacing navigator', (WidgetTester tester) async { final GlobalKey<NavigatorState> key1 = GlobalKey<NavigatorState>(); final GlobalKey<NavigatorState> key2 = GlobalKey<NavigatorState>(); final List<NavigatorObservation> observations = <NavigatorObservation>[]; final HeroControllerSpy spy = HeroControllerSpy() ..onPushed = (Route<dynamic>? route, Route<dynamic>? previousRoute) { observations.add( NavigatorObservation( current: route?.settings.name, previous: previousRoute?.settings.name, operation: 'didPush', ), ); }; await tester.pumpWidget( HeroControllerScope( controller: spy, child: Directionality( textDirection: TextDirection.ltr, child: Navigator( key: key1, initialRoute: 'navigator1', onGenerateRoute: (RouteSettings s) { return MaterialPageRoute<void>( builder: (BuildContext c) { return const Placeholder(); }, settings: s, ); }, ), ), ), ); // Transfer the subscription to another navigator await tester.pumpWidget( HeroControllerScope( controller: spy, child: Directionality( textDirection: TextDirection.ltr, child: Navigator( key: key2, initialRoute: 'navigator2', onGenerateRoute: (RouteSettings s) { return MaterialPageRoute<void>( builder: (BuildContext c) { return const Placeholder(); }, settings: s, ); }, ), ), ), ); observations.clear(); key2.currentState!.push(MaterialPageRoute<void>( settings: const RouteSettings(name:'new route'), builder: (BuildContext context) => const Text('new route'), )); await tester.pumpAndSettle(); expect(find.text('new route'), findsOneWidget); // It should record from the new navigator. expect(observations.length, 1); expect(observations[0].current, 'new route'); expect(observations[0].previous, 'navigator2'); }); testWidgets('hero controller can correctly transfer subscription - swapping navigator', (WidgetTester tester) async { final GlobalKey<NavigatorState> key1 = GlobalKey<NavigatorState>(); final GlobalKey<NavigatorState> key2 = GlobalKey<NavigatorState>(); final List<NavigatorObservation> observations1 = <NavigatorObservation>[]; final HeroControllerSpy spy1 = HeroControllerSpy() ..onPushed = (Route<dynamic>? route, Route<dynamic>? previousRoute) { observations1.add( NavigatorObservation( current: route?.settings.name, previous: previousRoute?.settings.name, operation: 'didPush', ), ); }; final List<NavigatorObservation> observations2 = <NavigatorObservation>[]; final HeroControllerSpy spy2 = HeroControllerSpy() ..onPushed = (Route<dynamic>? route, Route<dynamic>? previousRoute) { observations2.add( NavigatorObservation( current: route?.settings.name, previous: previousRoute?.settings.name, operation: 'didPush', ), ); }; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Stack( children: <Widget>[ HeroControllerScope( controller: spy1, child: Navigator( key: key1, initialRoute: 'navigator1', onGenerateRoute: (RouteSettings s) { return MaterialPageRoute<void>( builder: (BuildContext c) { return const Placeholder(); }, settings: s, ); }, ), ), HeroControllerScope( controller: spy2, child: Navigator( key: key2, initialRoute: 'navigator2', onGenerateRoute: (RouteSettings s) { return MaterialPageRoute<void>( builder: (BuildContext c) { return const Placeholder(); }, settings: s, ); }, ), ), ], ), ), ); expect(observations1.length, 1); expect(observations1[0].current, 'navigator1'); expect(observations1[0].previous, isNull); expect(observations2.length, 1); expect(observations2[0].current, 'navigator2'); expect(observations2[0].previous, isNull); // Swaps the spies. await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Stack( children: <Widget>[ HeroControllerScope( controller: spy2, child: Navigator( key: key1, initialRoute: 'navigator1', onGenerateRoute: (RouteSettings s) { return MaterialPageRoute<void>( builder: (BuildContext c) { return const Placeholder(); }, settings: s, ); }, ), ), HeroControllerScope( controller: spy1, child: Navigator( key: key2, initialRoute: 'navigator2', onGenerateRoute: (RouteSettings s) { return MaterialPageRoute<void>( builder: (BuildContext c) { return const Placeholder(); }, settings: s, ); }, ), ), ], ), ), ); // Pushes a route to navigator2. key2.currentState!.push(MaterialPageRoute<void>( settings: const RouteSettings(name:'new route2'), builder: (BuildContext context) => const Text('new route2'), )); await tester.pumpAndSettle(); expect(find.text('new route2'), findsOneWidget); // The spy1 should record the push in navigator2. expect(observations1.length, 2); expect(observations1[1].current, 'new route2'); expect(observations1[1].previous, 'navigator2'); // The spy2 should not record anything. expect(observations2.length, 1); // Pushes a route to navigator1 key1.currentState!.push(MaterialPageRoute<void>( settings: const RouteSettings(name:'new route1'), builder: (BuildContext context) => const Text('new route1'), )); await tester.pumpAndSettle(); expect(find.text('new route1'), findsOneWidget); // The spy1 should not record anything. expect(observations1.length, 2); // The spy2 should record the push in navigator1. expect(observations2.length, 2); expect(observations2[1].current, 'new route1'); expect(observations2[1].previous, 'navigator1'); }); testWidgets('hero controller subscribes to multiple navigators does throw', (WidgetTester tester) async { final HeroControllerSpy spy = HeroControllerSpy(); await tester.pumpWidget( HeroControllerScope( controller: spy, child: Directionality( textDirection: TextDirection.ltr, child: Stack( children: <Widget>[ Navigator( initialRoute: 'navigator1', onGenerateRoute: (RouteSettings s) { return MaterialPageRoute<void>( builder: (BuildContext c) { return const Placeholder(); }, settings: s, ); }, ), Navigator( initialRoute: 'navigator2', onGenerateRoute: (RouteSettings s) { return MaterialPageRoute<void>( builder: (BuildContext c) { return const Placeholder(); }, settings: s, ); }, ), ], ), ), ), ); expect(tester.takeException(), isAssertionError); }); testWidgets('hero controller throws has correct error message', (WidgetTester tester) async { final HeroControllerSpy spy = HeroControllerSpy(); await tester.pumpWidget( HeroControllerScope( controller: spy, child: Directionality( textDirection: TextDirection.ltr, child: Stack( children: <Widget>[ Navigator( initialRoute: 'navigator1', onGenerateRoute: (RouteSettings s) { return MaterialPageRoute<void>( builder: (BuildContext c) { return const Placeholder(); }, settings: s, ); }, ), Navigator( initialRoute: 'navigator2', onGenerateRoute: (RouteSettings s) { return MaterialPageRoute<void>( builder: (BuildContext c) { return const Placeholder(); }, settings: s, ); }, ), ], ), ), ), ); final dynamic exception = tester.takeException(); expect(exception, isFlutterError); final FlutterError error = exception as FlutterError; expect( error.toStringDeep(), equalsIgnoringHashCodes( 'FlutterError\n' ' A HeroController can not be shared by multiple Navigators. The\n' ' Navigators that share the same HeroController are:\n' ' - NavigatorState#00000(tickers: tracking 1 ticker)\n' ' - NavigatorState#00000(tickers: tracking 1 ticker)\n' ' Please create a HeroControllerScope for each Navigator or use a\n' ' HeroControllerScope.none to prevent subtree from receiving a\n' ' HeroController.\n' '', ), ); }); group('Page api', (){ Widget buildNavigator({ required List<Page<dynamic>> pages, required PopPageCallback onPopPage, GlobalKey<NavigatorState>? key, TransitionDelegate<dynamic>? transitionDelegate, List<NavigatorObserver> observers = const <NavigatorObserver>[], }) { return MediaQuery( data: MediaQueryData.fromWindow(WidgetsBinding.instance!.window), child: Localizations( locale: const Locale('en', 'US'), delegates: const <LocalizationsDelegate<dynamic>>[ DefaultMaterialLocalizations.delegate, DefaultWidgetsLocalizations.delegate, ], child: Directionality( textDirection: TextDirection.ltr, child: Navigator( key: key, pages: pages, onPopPage: onPopPage, observers: observers, transitionDelegate: transitionDelegate ?? const DefaultTransitionDelegate<dynamic>(), ), ), ), ); } testWidgets('can initialize with pages list', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); final List<TestPage> myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name:'initial'), const TestPage(key: ValueKey<String>('2'), name:'second'), const TestPage(key: ValueKey<String>('3'), name:'third'), ]; bool onPopPage(Route<dynamic> route, dynamic result) { myPages.removeWhere((Page<dynamic> page) => route.settings == page); return route.didPop(result); } await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); expect(find.text('third'), findsOneWidget); expect(find.text('second'), findsNothing); expect(find.text('initial'), findsNothing); navigator.currentState!.pop(); await tester.pumpAndSettle(); expect(find.text('third'), findsNothing); expect(find.text('second'), findsOneWidget); expect(find.text('initial'), findsNothing); navigator.currentState!.pop(); await tester.pumpAndSettle(); expect(find.text('third'), findsNothing); expect(find.text('second'), findsNothing); expect(find.text('initial'), findsOneWidget); }); testWidgets('throw if onPopPage callback is not provided', (WidgetTester tester) async { final List<TestPage> myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name:'initial'), const TestPage(key: ValueKey<String>('2'), name:'second'), const TestPage(key: ValueKey<String>('3'), name:'third'), ]; await tester.pumpWidget( MediaQuery( data: MediaQueryData.fromWindow(WidgetsBinding.instance!.window), child: Localizations( locale: const Locale('en', 'US'), delegates: const <LocalizationsDelegate<dynamic>>[ DefaultMaterialLocalizations.delegate, DefaultWidgetsLocalizations.delegate, ], child: Directionality( textDirection: TextDirection.ltr, child: Navigator( pages: myPages, ), ), ), ), ); final dynamic exception = tester.takeException(); expect(exception, isFlutterError); final FlutterError error = exception as FlutterError; expect( error.toStringDeep(), equalsIgnoringHashCodes( 'FlutterError\n' ' The Navigator.onPopPage must be provided to use the\n' ' Navigator.pages API\n' '', ), ); }); Widget _buildFrame(String action) { const TestPage myPage = TestPage(key: ValueKey<String>('1'), name:'initial'); final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/' : (BuildContext context) => OnTapPage( id: action, onTap: (){ if (action == 'push') { Navigator.of(context).push(myPage.createRoute(context)); } else if (action == 'pushReplacement') { Navigator.of(context).pushReplacement(myPage.createRoute(context)); } else if (action == 'pushAndRemoveUntil') { Navigator.of(context).pushAndRemoveUntil(myPage.createRoute(context), (_) => true); } }, ), }; return MaterialApp(routes: routes); } void _checkException(WidgetTester tester) { final dynamic exception = tester.takeException(); expect(exception, isFlutterError); final FlutterError error = exception as FlutterError; expect( error.toStringDeep(), equalsIgnoringHashCodes( 'FlutterError\n' ' A page-based route should not be added using the imperative api.\n' ' Provide a new list with the corresponding Page to Navigator.pages\n' ' instead.\n' '', ), ); } testWidgets('throw if add page-based route using the imperative api - push', (WidgetTester tester) async { await tester.pumpWidget(_buildFrame('push')); await tester.tap(find.text('push')); await tester.pumpAndSettle(); _checkException(tester); }); testWidgets('throw if add page-based route using the imperative api - pushReplacement', (WidgetTester tester) async { await tester.pumpWidget(_buildFrame('pushReplacement')); await tester.tap(find.text('pushReplacement')); await tester.pumpAndSettle(); _checkException(tester); }); testWidgets('throw if add page-based route using the imperative api - pushAndRemoveUntil', (WidgetTester tester) async { await tester.pumpWidget(_buildFrame('pushAndRemoveUntil')); await tester.tap(find.text('pushAndRemoveUntil')); await tester.pumpAndSettle(); _checkException(tester); }); testWidgets('throw if page list is empty', (WidgetTester tester) async { final List<TestPage> myPages = <TestPage>[]; final FlutterExceptionHandler? originalOnError = FlutterError.onError; FlutterErrorDetails? firstError; FlutterError.onError = (FlutterErrorDetails? detail) { // We only care about the first error; firstError ??= detail; }; await tester.pumpWidget( MediaQuery( data: MediaQueryData.fromWindow(WidgetsBinding.instance!.window), child: Localizations( locale: const Locale('en', 'US'), delegates: const <LocalizationsDelegate<dynamic>>[ DefaultMaterialLocalizations.delegate, DefaultWidgetsLocalizations.delegate, ], child: Directionality( textDirection: TextDirection.ltr, child: Navigator( pages: myPages, ), ), ), ), ); FlutterError.onError = originalOnError; expect( firstError!.exception.toString(), 'The Navigator.pages must not be empty to use the Navigator.pages API', ); }); testWidgets('can push and pop pages using page api', (WidgetTester tester) async { late Animation<double> secondaryAnimationOfRouteOne; late Animation<double> primaryAnimationOfRouteOne; late Animation<double> secondaryAnimationOfRouteTwo; late Animation<double> primaryAnimationOfRouteTwo; late Animation<double> secondaryAnimationOfRouteThree; late Animation<double> primaryAnimationOfRouteThree; final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); List<Page<dynamic>> myPages = <Page<dynamic>>[ BuilderPage( key: const ValueKey<String>('1'), name:'initial', pageBuilder: (_, Animation<double> animation, Animation<double> secondaryAnimation) { secondaryAnimationOfRouteOne = secondaryAnimation; primaryAnimationOfRouteOne = animation; return const Text('initial'); }, ), ]; bool onPopPage(Route<dynamic> route, dynamic result) { myPages.removeWhere((Page<dynamic> page) => route.settings == page); return route.didPop(result); } await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); expect(find.text('initial'), findsOneWidget); myPages = <Page<dynamic>>[ BuilderPage( key: const ValueKey<String>('1'), name:'initial', pageBuilder: (_, Animation<double> animation, Animation<double> secondaryAnimation) { secondaryAnimationOfRouteOne = secondaryAnimation; primaryAnimationOfRouteOne = animation; return const Text('initial'); }, ), BuilderPage( key: const ValueKey<String>('2'), name:'second', pageBuilder: (_, Animation<double> animation, Animation<double> secondaryAnimation) { secondaryAnimationOfRouteTwo = secondaryAnimation; primaryAnimationOfRouteTwo = animation; return const Text('second'); }, ), BuilderPage( key: const ValueKey<String>('3'), name:'third', pageBuilder: (_, Animation<double> animation, Animation<double> secondaryAnimation) { secondaryAnimationOfRouteThree = secondaryAnimation; primaryAnimationOfRouteThree = animation; return const Text('third'); }, ), ]; await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); // The third page is transitioning, and the secondary animation of first // page should chain with the third page. The animation of second page // won't start until the third page finishes transition. expect(secondaryAnimationOfRouteOne.value, primaryAnimationOfRouteThree.value); expect(primaryAnimationOfRouteOne.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteTwo.status, AnimationStatus.dismissed); expect(primaryAnimationOfRouteTwo.status, AnimationStatus.dismissed); expect(secondaryAnimationOfRouteThree.status, AnimationStatus.dismissed); expect(primaryAnimationOfRouteThree.status, AnimationStatus.forward); await tester.pump(const Duration(milliseconds: 30)); expect(secondaryAnimationOfRouteOne.value, primaryAnimationOfRouteThree.value); expect(primaryAnimationOfRouteOne.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteTwo.status, AnimationStatus.dismissed); expect(primaryAnimationOfRouteTwo.status, AnimationStatus.dismissed); expect(secondaryAnimationOfRouteThree.status, AnimationStatus.dismissed); expect(primaryAnimationOfRouteThree.value, 0.1); await tester.pumpAndSettle(); // After transition finishes, the routes' animations are correctly chained. expect(secondaryAnimationOfRouteOne.value, primaryAnimationOfRouteTwo.value); expect(primaryAnimationOfRouteOne.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteThree.value); expect(primaryAnimationOfRouteTwo.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteThree.status, AnimationStatus.dismissed); expect(primaryAnimationOfRouteThree.status, AnimationStatus.completed); expect(find.text('third'), findsOneWidget); expect(find.text('second'), findsNothing); expect(find.text('initial'), findsNothing); // Starts pops the pages using page api and verify the animations chain // correctly. myPages = <Page<dynamic>>[ BuilderPage( key: const ValueKey<String>('1'), name:'initial', pageBuilder: (_, Animation<double> animation, Animation<double> secondaryAnimation) { secondaryAnimationOfRouteOne = secondaryAnimation; primaryAnimationOfRouteOne = animation; return const Text('initial'); }, ), BuilderPage( key: const ValueKey<String>('2'), name:'second', pageBuilder: (_, Animation<double> animation, Animation<double> secondaryAnimation) { secondaryAnimationOfRouteTwo = secondaryAnimation; primaryAnimationOfRouteTwo = animation; return const Text('second'); }, ), ]; await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); await tester.pump(const Duration(milliseconds: 30)); expect(secondaryAnimationOfRouteOne.value, primaryAnimationOfRouteTwo.value); expect(primaryAnimationOfRouteOne.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteThree.value); expect(primaryAnimationOfRouteTwo.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteThree.status, AnimationStatus.dismissed); expect(primaryAnimationOfRouteThree.value, 0.9); await tester.pumpAndSettle(); expect(secondaryAnimationOfRouteOne.value, primaryAnimationOfRouteTwo.value); expect(primaryAnimationOfRouteOne.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteThree.value); expect(primaryAnimationOfRouteTwo.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteThree.status, AnimationStatus.dismissed); expect(primaryAnimationOfRouteThree.status, AnimationStatus.dismissed); }); testWidgets('can modify routes history and secondary animation still works', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); late Animation<double> secondaryAnimationOfRouteOne; late Animation<double> primaryAnimationOfRouteOne; late Animation<double> secondaryAnimationOfRouteTwo; late Animation<double> primaryAnimationOfRouteTwo; late Animation<double> secondaryAnimationOfRouteThree; late Animation<double> primaryAnimationOfRouteThree; List<Page<dynamic>> myPages = <Page<void>>[ BuilderPage( key: const ValueKey<String>('1'), name:'initial', pageBuilder: (_, Animation<double> animation, Animation<double> secondaryAnimation) { secondaryAnimationOfRouteOne = secondaryAnimation; primaryAnimationOfRouteOne = animation; return const Text('initial'); }, ), BuilderPage( key: const ValueKey<String>('2'), name:'second', pageBuilder: (_, Animation<double> animation, Animation<double> secondaryAnimation) { secondaryAnimationOfRouteTwo = secondaryAnimation; primaryAnimationOfRouteTwo = animation; return const Text('second'); }, ), BuilderPage( key: const ValueKey<String>('3'), name:'third', pageBuilder: (_, Animation<double> animation, Animation<double> secondaryAnimation) { secondaryAnimationOfRouteThree = secondaryAnimation; primaryAnimationOfRouteThree = animation; return const Text('third'); }, ), ]; bool onPopPage(Route<dynamic> route, dynamic result) { myPages.removeWhere((Page<dynamic> page) => route.settings == page); return route.didPop(result); } await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); expect(find.text('third'), findsOneWidget); expect(find.text('second'), findsNothing); expect(find.text('initial'), findsNothing); expect(secondaryAnimationOfRouteOne.value, primaryAnimationOfRouteTwo.value); expect(primaryAnimationOfRouteOne.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteThree.value); expect(primaryAnimationOfRouteTwo.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteThree.status, AnimationStatus.dismissed); expect(primaryAnimationOfRouteThree.status, AnimationStatus.completed); myPages = myPages.reversed.toList(); await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); // Reversed routes are still chained up correctly. expect(secondaryAnimationOfRouteThree.value, primaryAnimationOfRouteTwo.value); expect(primaryAnimationOfRouteThree.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteOne.value); expect(primaryAnimationOfRouteTwo.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteOne.status, AnimationStatus.dismissed); expect(primaryAnimationOfRouteOne.status, AnimationStatus.completed); navigator.currentState!.pop(); await tester.pump(); await tester.pump(const Duration(milliseconds: 30)); expect(secondaryAnimationOfRouteThree.value, primaryAnimationOfRouteTwo.value); expect(primaryAnimationOfRouteThree.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteOne.value); expect(primaryAnimationOfRouteTwo.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteOne.status, AnimationStatus.dismissed); expect(primaryAnimationOfRouteOne.value, 0.9); await tester.pumpAndSettle(); expect(secondaryAnimationOfRouteThree.value, primaryAnimationOfRouteTwo.value); expect(primaryAnimationOfRouteThree.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteOne.value); expect(primaryAnimationOfRouteTwo.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteOne.status, AnimationStatus.dismissed); expect(primaryAnimationOfRouteOne.status, AnimationStatus.dismissed); navigator.currentState!.pop(); await tester.pump(); await tester.pump(const Duration(milliseconds: 30)); expect(secondaryAnimationOfRouteThree.value, primaryAnimationOfRouteTwo.value); expect(primaryAnimationOfRouteThree.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteOne.value); expect(primaryAnimationOfRouteTwo.value, 0.9); expect(secondaryAnimationOfRouteOne.status, AnimationStatus.dismissed); expect(primaryAnimationOfRouteOne.status, AnimationStatus.dismissed); await tester.pumpAndSettle(); expect(secondaryAnimationOfRouteThree.value, primaryAnimationOfRouteTwo.value); expect(primaryAnimationOfRouteThree.status, AnimationStatus.completed); expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteOne.value); expect(primaryAnimationOfRouteTwo.status, AnimationStatus.dismissed); expect(secondaryAnimationOfRouteOne.status, AnimationStatus.dismissed); expect(primaryAnimationOfRouteOne.status, AnimationStatus.dismissed); }); testWidgets('can work with pageless route', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); List<TestPage> myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name:'initial'), const TestPage(key: ValueKey<String>('2'), name:'second'), ]; bool onPopPage(Route<dynamic> route, dynamic result) { myPages.removeWhere((Page<dynamic> page) => route.settings == page); return route.didPop(result); } await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); expect(find.text('second'), findsOneWidget); expect(find.text('initial'), findsNothing); // Pushes two pageless routes to second page route navigator.currentState!.push( MaterialPageRoute<void>( builder: (BuildContext context) => const Text('second-pageless1'), settings: null, ), ); navigator.currentState!.push( MaterialPageRoute<void>( builder: (BuildContext context) => const Text('second-pageless2'), settings: null, ), ); await tester.pumpAndSettle(); // Now the history should look like // [initial, second, second-pageless1, second-pageless2]. expect(find.text('initial'), findsNothing); expect(find.text('second'), findsNothing); expect(find.text('second-pageless1'), findsNothing); expect(find.text('second-pageless2'), findsOneWidget); myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name:'initial'), const TestPage(key: ValueKey<String>('2'), name:'second'), const TestPage(key: ValueKey<String>('3'), name:'third'), ]; await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); await tester.pumpAndSettle(); expect(find.text('initial'), findsNothing); expect(find.text('second'), findsNothing); expect(find.text('second-pageless1'), findsNothing); expect(find.text('second-pageless2'), findsNothing); expect(find.text('third'), findsOneWidget); // Pushes one pageless routes to third page route navigator.currentState!.push( MaterialPageRoute<void>( builder: (BuildContext context) => const Text('third-pageless1'), settings: null, ), ); await tester.pumpAndSettle(); // Now the history should look like // [initial, second, second-pageless1, second-pageless2, third, third-pageless1]. expect(find.text('initial'), findsNothing); expect(find.text('second'), findsNothing); expect(find.text('second-pageless1'), findsNothing); expect(find.text('second-pageless2'), findsNothing); expect(find.text('third'), findsNothing); expect(find.text('third-pageless1'), findsOneWidget); myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name:'initial'), const TestPage(key: ValueKey<String>('3'), name:'third'), const TestPage(key: ValueKey<String>('2'), name:'second'), ]; await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); // Swaps the order without any adding or removing should not trigger any // transition. The routes should update without a pumpAndSettle // Now the history should look like // [initial, third, third-pageless1, second, second-pageless1, second-pageless2]. expect(find.text('initial'), findsNothing); expect(find.text('third'), findsNothing); expect(find.text('third-pageless1'), findsNothing); expect(find.text('second'), findsNothing); expect(find.text('second-pageless1'), findsNothing); expect(find.text('second-pageless2'), findsOneWidget); // Pops the route one by one to make sure the order is correct. navigator.currentState!.pop(); await tester.pumpAndSettle(); expect(find.text('initial'), findsNothing); expect(find.text('third'), findsNothing); expect(find.text('third-pageless1'), findsNothing); expect(find.text('second'), findsNothing); expect(find.text('second-pageless1'), findsOneWidget); expect(find.text('second-pageless2'), findsNothing); expect(myPages.length, 3); navigator.currentState!.pop(); await tester.pumpAndSettle(); expect(find.text('initial'), findsNothing); expect(find.text('third'), findsNothing); expect(find.text('third-pageless1'), findsNothing); expect(find.text('second'), findsOneWidget); expect(find.text('second-pageless1'), findsNothing); expect(find.text('second-pageless2'), findsNothing); expect(myPages.length, 3); navigator.currentState!.pop(); await tester.pumpAndSettle(); expect(find.text('initial'), findsNothing); expect(find.text('third'), findsNothing); expect(find.text('third-pageless1'), findsOneWidget); expect(find.text('second'), findsNothing); expect(find.text('second-pageless1'), findsNothing); expect(find.text('second-pageless2'), findsNothing); expect(myPages.length, 2); navigator.currentState!.pop(); await tester.pumpAndSettle(); expect(find.text('initial'), findsNothing); expect(find.text('third'), findsOneWidget); expect(find.text('third-pageless1'), findsNothing); expect(find.text('second'), findsNothing); expect(find.text('second-pageless1'), findsNothing); expect(find.text('second-pageless2'), findsNothing); expect(myPages.length, 2); navigator.currentState!.pop(); await tester.pumpAndSettle(); expect(find.text('initial'), findsOneWidget); expect(find.text('third'), findsNothing); expect(find.text('third-pageless1'), findsNothing); expect(find.text('second'), findsNothing); expect(find.text('second-pageless1'), findsNothing); expect(find.text('second-pageless2'), findsNothing); expect(myPages.length, 1); }); testWidgets('complex case 1', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); List<TestPage> myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name: 'initial'), ]; bool onPopPage(Route<dynamic> route, dynamic result) { myPages.removeWhere((Page<dynamic> page) => route.settings == page); return route.didPop(result); } // Add initial page route with one pageless route. await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); bool initialPageless1Completed = false; navigator.currentState!.push( MaterialPageRoute<void>( builder: (BuildContext context) => const Text('initial-pageless1'), settings: null, ), ).then((_) => initialPageless1Completed = true); await tester.pumpAndSettle(); // Pushes second page route with two pageless routes. myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name: 'initial'), const TestPage(key: ValueKey<String>('2'), name: 'second'), ]; await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); await tester.pumpAndSettle(); bool secondPageless1Completed = false; navigator.currentState!.push( MaterialPageRoute<void>( builder: (BuildContext context) => const Text('second-pageless1'), settings: null, ), ).then((_) => secondPageless1Completed = true); await tester.pumpAndSettle(); bool secondPageless2Completed = false; navigator.currentState!.push( MaterialPageRoute<void>( builder: (BuildContext context) => const Text('second-pageless2'), settings: null, ), ).then((_) => secondPageless2Completed = true); await tester.pumpAndSettle(); // Pushes third page route with one pageless route. myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name: 'initial'), const TestPage(key: ValueKey<String>('2'), name: 'second'), const TestPage(key: ValueKey<String>('3'), name: 'third'), ]; await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); await tester.pumpAndSettle(); bool thirdPageless1Completed = false; navigator.currentState!.push( MaterialPageRoute<void>( builder: (BuildContext context) => const Text('third-pageless1'), settings: null, ), ).then((_) => thirdPageless1Completed = true); await tester.pumpAndSettle(); // Nothing has been popped. expect(initialPageless1Completed, false); expect(secondPageless1Completed, false); expect(secondPageless2Completed, false); expect(thirdPageless1Completed, false); // Switches order and removes the initial page route. myPages = <TestPage>[ const TestPage(key: ValueKey<String>('3'), name: 'third'), const TestPage(key: ValueKey<String>('2'), name: 'second'), ]; await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); // The pageless route of initial page route should be completed. expect(initialPageless1Completed, true); expect(secondPageless1Completed, false); expect(secondPageless2Completed, false); expect(thirdPageless1Completed, false); myPages = <TestPage>[ const TestPage(key: ValueKey<String>('3'), name: 'third'), ]; await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); await tester.pumpAndSettle(); expect(secondPageless1Completed, true); expect(secondPageless2Completed, true); expect(thirdPageless1Completed, false); myPages = <TestPage>[ const TestPage(key: ValueKey<String>('4'), name: 'forth'), ]; await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); expect(thirdPageless1Completed, true); await tester.pumpAndSettle(); expect(find.text('forth'), findsOneWidget); }); testWidgets('complex case 1 - with always remove transition delegate', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); final AlwaysRemoveTransitionDelegate transitionDelegate = AlwaysRemoveTransitionDelegate(); List<TestPage> myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name: 'initial'), ]; bool onPopPage(Route<dynamic> route, dynamic result) { myPages.removeWhere((Page<dynamic> page) => route.settings == page); return route.didPop(result); } // Add initial page route with one pageless route. await tester.pumpWidget( buildNavigator( pages: myPages, onPopPage: onPopPage, key: navigator, transitionDelegate: transitionDelegate, ), ); bool initialPageless1Completed = false; navigator.currentState!.push( MaterialPageRoute<void>( builder: (BuildContext context) => const Text('initial-pageless1'), settings: null, ), ).then((_) => initialPageless1Completed = true); await tester.pumpAndSettle(); // Pushes second page route with two pageless routes. myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name: 'initial'), const TestPage(key: ValueKey<String>('2'), name: 'second'), ]; await tester.pumpWidget( buildNavigator( pages: myPages, onPopPage: onPopPage, key: navigator, transitionDelegate: transitionDelegate, ), ); bool secondPageless1Completed = false; navigator.currentState!.push( MaterialPageRoute<void>( builder: (BuildContext context) => const Text('second-pageless1'), settings: null, ), ).then((_) => secondPageless1Completed = true); await tester.pumpAndSettle(); bool secondPageless2Completed = false; navigator.currentState!.push( MaterialPageRoute<void>( builder: (BuildContext context) => const Text('second-pageless2'), settings: null, ), ).then((_) => secondPageless2Completed = true); await tester.pumpAndSettle(); // Pushes third page route with one pageless route. myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name: 'initial'), const TestPage(key: ValueKey<String>('2'), name: 'second'), const TestPage(key: ValueKey<String>('3'), name: 'third'), ]; await tester.pumpWidget( buildNavigator( pages: myPages, onPopPage: onPopPage, key: navigator, transitionDelegate: transitionDelegate, ), ); bool thirdPageless1Completed = false; navigator.currentState!.push( MaterialPageRoute<void>( builder: (BuildContext context) => const Text('third-pageless1'), settings: null, ), ).then((_) => thirdPageless1Completed = true); await tester.pumpAndSettle(); // Nothing has been popped. expect(initialPageless1Completed, false); expect(secondPageless1Completed, false); expect(secondPageless2Completed, false); expect(thirdPageless1Completed, false); // Switches order and removes the initial page route. myPages = <TestPage>[ const TestPage(key: ValueKey<String>('3'), name: 'third'), const TestPage(key: ValueKey<String>('2'), name: 'second'), ]; await tester.pumpWidget( buildNavigator( pages: myPages, onPopPage: onPopPage, key: navigator, transitionDelegate: transitionDelegate, ), ); // The pageless route of initial page route should be removed without complete. expect(initialPageless1Completed, false); expect(secondPageless1Completed, false); expect(secondPageless2Completed, false); expect(thirdPageless1Completed, false); myPages = <TestPage>[ const TestPage(key: ValueKey<String>('3'), name: 'third'), ]; await tester.pumpWidget( buildNavigator( pages: myPages, onPopPage: onPopPage, key: navigator, transitionDelegate: transitionDelegate, ), ); await tester.pumpAndSettle(); expect(initialPageless1Completed, false); expect(secondPageless1Completed, false); expect(secondPageless2Completed, false); expect(thirdPageless1Completed, false); myPages = <TestPage>[ const TestPage(key: ValueKey<String>('4'), name: 'forth'), ]; await tester.pumpWidget( buildNavigator( pages: myPages, onPopPage: onPopPage, key: navigator, transitionDelegate: transitionDelegate, ), ); await tester.pump(); expect(initialPageless1Completed, false); expect(secondPageless1Completed, false); expect(secondPageless2Completed, false); expect(thirdPageless1Completed, false); expect(find.text('forth'), findsOneWidget); }); testWidgets('can repush a page that was previously popped before it has finished popping', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); List<Page<dynamic>> myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name: 'initial'), const TestPage(key: ValueKey<String>('2'), name: 'second'), ]; bool onPopPage(Route<dynamic> route, dynamic result) { myPages.removeWhere((Page<dynamic> page) => route.settings == page); return route.didPop(result); } await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); // Pops the second page route. myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name: 'initial'), ]; await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); // Re-push the second page again before it finishes popping. myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name: 'initial'), const TestPage(key: ValueKey<String>('2'), name: 'second'), ]; await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); // It should not crash the app. expect(tester.takeException(), isNull); await tester.pumpAndSettle(); expect(find.text('second'), findsOneWidget); }); testWidgets('can update pages before a route has finished popping', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); List<Page<dynamic>> myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name: 'initial'), const TestPage(key: ValueKey<String>('2'), name: 'second'), ]; bool onPopPage(Route<dynamic> route, dynamic result) { myPages.removeWhere((Page<dynamic> page) => route.settings == page); return route.didPop(result); } await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); // Pops the second page route. myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name: 'initial'), ]; await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); // Updates the pages again before second page finishes popping. myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name: 'initial'), ]; await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); // It should not crash the app. expect(tester.takeException(), isNull); await tester.pumpAndSettle(); expect(find.text('initial'), findsOneWidget); }); testWidgets('can update pages before a pageless route has finished popping', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/68162. final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); List<Page<dynamic>> myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name: 'initial'), const TestPage(key: ValueKey<String>('2'), name: 'second'), ]; bool onPopPage(Route<dynamic> route, dynamic result) { myPages.removeWhere((Page<dynamic> page) => route.settings == page); return route.didPop(result); } await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); // Pushes a pageless route. showDialog<void>( useRootNavigator: false, context: navigator.currentContext!, builder: (BuildContext context) => const Text('dialog'), ); await tester.pumpAndSettle(); expect(find.text('dialog'), findsOneWidget); // Pops the pageless route. navigator.currentState!.pop(); // Before the pop finishes, updates the page list. myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name: 'initial'), ]; await tester.pumpWidget( buildNavigator(pages: myPages, onPopPage: onPopPage, key: navigator), ); // It should not crash the app. expect(tester.takeException(), isNull); await tester.pumpAndSettle(); expect(find.text('initial'), findsOneWidget); }); testWidgets('pages remove and add trigger observer in the right order', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); List<TestPage> myPages = <TestPage>[ const TestPage(key: ValueKey<String>('1'), name:'first'), const TestPage(key: ValueKey<String>('2'), name:'second'), const TestPage(key: ValueKey<String>('3'), name:'third'), ]; final List<NavigatorObservation> observations = <NavigatorObservation>[]; final TestObserver observer = TestObserver() ..onPushed = (Route<dynamic>? route, Route<dynamic>? previousRoute) { observations.add( NavigatorObservation( current: route?.settings.name, previous: previousRoute?.settings.name, operation: 'push', ), ); } ..onRemoved = (Route<dynamic>? route, Route<dynamic>? previousRoute) { observations.add( NavigatorObservation( current: route?.settings.name, previous: previousRoute?.settings.name, operation: 'remove', ), ); }; bool onPopPage(Route<dynamic> route, dynamic result) => false; await tester.pumpWidget( buildNavigator( pages: myPages, onPopPage: onPopPage, key: navigator, observers: <NavigatorObserver>[observer], ), ); myPages = <TestPage>[ const TestPage(key: ValueKey<String>('4'), name:'forth'), const TestPage(key: ValueKey<String>('5'), name:'fifth'), ]; await tester.pumpWidget( buildNavigator( pages: myPages, onPopPage: onPopPage, key: navigator, observers: <NavigatorObserver>[observer], ), ); await tester.pumpAndSettle(); expect(observations.length, 8); // Initial routes are pushed. expect(observations[0].operation, 'push'); expect(observations[0].current, 'first'); expect(observations[0].previous, isNull); expect(observations[1].operation, 'push'); expect(observations[1].current, 'second'); expect(observations[1].previous, 'first'); expect(observations[2].operation, 'push'); expect(observations[2].current, 'third'); expect(observations[2].previous, 'second'); // Pages are updated. // New routes are pushed before removing the initial routes. expect(observations[3].operation, 'push'); expect(observations[3].current, 'forth'); expect(observations[3].previous, 'third'); expect(observations[4].operation, 'push'); expect(observations[4].current, 'fifth'); expect(observations[4].previous, 'forth'); // Initial routes are removed. expect(observations[5].operation, 'remove'); expect(observations[5].current, 'third'); expect(observations[5].previous, isNull); expect(observations[6].operation, 'remove'); expect(observations[6].current, 'second'); expect(observations[6].previous, isNull); expect(observations[7].operation, 'remove'); expect(observations[7].current, 'first'); expect(observations[7].previous, isNull); }); }); } typedef AnnouncementCallBack = void Function(Route<dynamic>?); class NotAnnounced extends Route<void> {/* A place holder for not announced route*/} class RouteAnnouncementSpy extends Route<void> { RouteAnnouncementSpy({ this.onDidChangePrevious, this.onDidChangeNext, this.onDidPopNext, RouteSettings? settings, }) : super(settings: settings); final AnnouncementCallBack? onDidChangePrevious; final AnnouncementCallBack? onDidChangeNext; final AnnouncementCallBack? onDidPopNext; @override List<OverlayEntry> get overlayEntries => <OverlayEntry>[ OverlayEntry( builder: (BuildContext context) => const Placeholder(), ), ]; @override void didChangeNext(Route<dynamic>? nextRoute) { super.didChangeNext(nextRoute); onDidChangeNext?.call(nextRoute); } @override void didChangePrevious(Route<dynamic>? previousRoute) { super.didChangePrevious(previousRoute); onDidChangePrevious?.call(previousRoute); } @override void didPopNext(Route<dynamic> nextRoute) { super.didPopNext(nextRoute); onDidPopNext?.call(nextRoute); } } class _TickingWidget extends StatefulWidget { const _TickingWidget({required this.onTick}); final VoidCallback onTick; @override State<_TickingWidget> createState() => _TickingWidgetState(); } class _TickingWidgetState extends State<_TickingWidget> with SingleTickerProviderStateMixin { late Ticker _ticker; @override void initState() { super.initState(); _ticker = createTicker((Duration _) { widget.onTick(); })..start(); } @override Widget build(BuildContext context) { return Container(); } @override void dispose() { _ticker.dispose(); super.dispose(); } } class AlwaysRemoveTransitionDelegate extends TransitionDelegate<void> { @override Iterable<RouteTransitionRecord> resolve({ required List<RouteTransitionRecord> newPageRouteHistory, required Map<RouteTransitionRecord?, RouteTransitionRecord> locationToExitingPageRoute, required Map<RouteTransitionRecord?, List<RouteTransitionRecord>> pageRouteToPagelessRoutes, }) { final List<RouteTransitionRecord> results = <RouteTransitionRecord>[]; void handleExitingRoute(RouteTransitionRecord? location) { if (!locationToExitingPageRoute.containsKey(location)) return; final RouteTransitionRecord exitingPageRoute = locationToExitingPageRoute[location]!; if (exitingPageRoute.isWaitingForExitingDecision) { final bool hasPagelessRoute = pageRouteToPagelessRoutes.containsKey(exitingPageRoute); exitingPageRoute.markForRemove(); if (hasPagelessRoute) { final List<RouteTransitionRecord> pagelessRoutes = pageRouteToPagelessRoutes[exitingPageRoute]!; for (final RouteTransitionRecord pagelessRoute in pagelessRoutes) { pagelessRoute.markForRemove(); } } } results.add(exitingPageRoute); handleExitingRoute(exitingPageRoute); } handleExitingRoute(null); for (final RouteTransitionRecord pageRoute in newPageRouteHistory) { if (pageRoute.isWaitingForEnteringDecision) { pageRoute.markForAdd(); } results.add(pageRoute); handleExitingRoute(pageRoute); } return results; } } class TestPage extends Page<void> { const TestPage({ LocalKey? key, required String name, Object? arguments, }) : super(key: key, name: name, arguments: arguments); @override Route<void> createRoute(BuildContext context) { return MaterialPageRoute<void>( builder: (BuildContext context) => Text(name!), settings: this, ); } } class NoAnimationPageRoute extends PageRouteBuilder<void> { NoAnimationPageRoute({required WidgetBuilder pageBuilder}) : super(pageBuilder: (BuildContext context, __, ___) { return pageBuilder(context); }); @override AnimationController createAnimationController() { return super.createAnimationController()..value = 1.0; } } class StatefulTestWidget extends StatefulWidget { const StatefulTestWidget({Key? key}) : super(key: key); @override State<StatefulTestWidget> createState() => StatefulTestState(); } class StatefulTestState extends State<StatefulTestWidget> { int rebuildCount = 0; @override Widget build(BuildContext context) { rebuildCount += 1; return Container(); } } class HeroControllerSpy extends HeroController { OnObservation? onPushed; @override void didPush(Route<dynamic>? route, Route<dynamic>? previousRoute) { onPushed?.call(route, previousRoute); } } class NavigatorObservation { const NavigatorObservation({this.previous, this.current, required this.operation}); final String? previous; final String? current; final String operation; } class BuilderPage extends Page<void> { const BuilderPage({LocalKey? key, String? name, required this.pageBuilder}) : super(key: key, name: name); final RoutePageBuilder pageBuilder; @override Route<void> createRoute(BuildContext context) { return PageRouteBuilder<void>( settings: this, pageBuilder: pageBuilder, ); } }