diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 7ff717ed4513..3368d08eef69 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,9 @@ +## 4.5.0 + +- Adds ShellRoute for nested navigation support (flutter/flutter#99126) +- Adds `parentNavigatorKey` to GoRoute, which specifies the Navigator to place that + route's Page onto. + ## 4.4.1 - Fix an issue where disabling logging clears the root logger's listeners diff --git a/packages/go_router/README.md b/packages/go_router/README.md index edd832b3aa8c..5f9a36d43cfc 100644 --- a/packages/go_router/README.md +++ b/packages/go_router/README.md @@ -134,6 +134,48 @@ methods: onTap: () => context.go('/page2') ``` +## Nested Navigation + +The `ShellRoute` route type provides a way to wrap all sub-routes with a UI shell. +Under the hood, GoRouter places a Navigator in the widget tree, which is used +to display matching sub-routes: + +```dart +final _router = GoRouter( + routes: [ + ShellRoute( + builder: (context, state, child) { + return AppScaffold(child: child); + }, + routes: [ + GoRoute( + path: '/albums', + builder: (context, state) { + return HomeScreen(); + }, + routes: [ + /// The details screen to display stacked on the inner Navigator. + GoRoute( + path: 'song/:songId', + builder: (BuildContext context, GoRouterState state) { + return const DetailsScreen(label: 'A'); + }, + ), + ], + ), + ], + ), + ], +); +``` + +For more details, see the +[ShellRoute](https://pub.dev/documentation/go_router/latest/go_router/ShellRoute-class.html) +API documentation. For a complete +example, see the +[ShellRoute sample](https://github.com/flutter/packages/tree/main/packages/go_router/example/lib/shell_route.dart) +in the example/ directory. + ### Still not sure how to proceed? See [examples](https://github.com/flutter/packages/tree/main/packages/go_router/example) for complete runnable examples or visit [API documentation](https://pub.dev/documentation/go_router/latest/go_router/go_router-library.html) diff --git a/packages/go_router/example/lib/shell_route.dart b/packages/go_router/example/lib/shell_route.dart new file mode 100644 index 000000000000..6a1921d94cc7 --- /dev/null +++ b/packages/go_router/example/lib/shell_route.dart @@ -0,0 +1,236 @@ +// Copyright 2013 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 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +final GlobalKey _rootNavigatorKey = + GlobalKey(debugLabel: 'root'); +final GlobalKey _shellNavigatorKey = + GlobalKey(debugLabel: 'shell'); + +// This scenario demonstrates how to set up nested navigation using ShellRoute, +// which is a pattern where an additional Navigator is placed in the widget tree +// to be used instead of the root navigator. This allows deep-links to display +// pages along with other UI components such as a BottomNavigationBar. +// +// This example demonstrates how to display a route within a ShellRoute and also +// push a screen using a different navigator (such as the root Navigator) by +// providing a `parentNavigatorKey`. + +void main() { + runApp(ShellRouteExampleApp()); +} + +/// An example demonstrating how to use [ShellRoute] +class ShellRouteExampleApp extends StatelessWidget { + /// Creates a [ShellRouteExampleApp] + ShellRouteExampleApp({Key? key}) : super(key: key); + + final GoRouter _router = GoRouter( + navigatorKey: _rootNavigatorKey, + initialLocation: '/a', + routes: [ + /// Application shell + ShellRoute( + navigatorKey: _shellNavigatorKey, + builder: (BuildContext context, GoRouterState state, Widget child) { + return ScaffoldWithNavBar(child: child); + }, + routes: [ + /// The first screen to display in the bottom navigation bar. + GoRoute( + path: '/a', + builder: (BuildContext context, GoRouterState state) { + return const ScreenA(); + }, + routes: [ + // The details screen to display stacked on the inner Navigator. + // This will cover screen A but not the application shell. + GoRoute( + path: 'details', + builder: (BuildContext context, GoRouterState state) { + return const DetailsScreen(label: 'A'); + }, + ), + ], + ), + + /// Displayed when the second item in the the bottom navigation bar is + /// selected. + GoRoute( + path: '/b', + builder: (BuildContext context, GoRouterState state) { + return const ScreenB(); + }, + routes: [ + /// Same as "/a/details", but displayed on the root Navigator by + /// specifying [parentNavigatorKey]. This will cover both screen B + /// and the application shell. + GoRoute( + path: 'details', + parentNavigatorKey: _rootNavigatorKey, + builder: (BuildContext context, GoRouterState state) { + return const DetailsScreen(label: 'B'); + }, + ), + ], + ), + ], + ), + ], + ); + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + routeInformationParser: _router.routeInformationParser, + routerDelegate: _router.routerDelegate, + routeInformationProvider: _router.routeInformationProvider, + ); + } +} + +/// Builds the "shell" for the app by building a Scaffold with a +/// BottomNavigationBar, where [child] is placed in the body of the Scaffold. +class ScaffoldWithNavBar extends StatelessWidget { + /// Constructs an [ScaffoldWithNavBar]. + const ScaffoldWithNavBar({ + required this.child, + Key? key, + }) : super(key: key); + + /// The widget to display in the body of the Scaffold. + /// In this sample, it is a Navigator. + final Widget child; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: child, + bottomNavigationBar: BottomNavigationBar( + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.home), + label: 'A Screen', + ), + BottomNavigationBarItem( + icon: Icon(Icons.business), + label: 'B Screen', + ), + ], + currentIndex: _calculateSelectedIndex(context), + onTap: (int idx) => _onItemTapped(idx, context), + ), + ); + } + + static int _calculateSelectedIndex(BuildContext context) { + final GoRouter route = GoRouter.of(context); + final String location = route.location; + if (location == '/a') { + return 0; + } + if (location == '/b') { + return 1; + } + return 0; + } + + void _onItemTapped(int index, BuildContext context) { + switch (index) { + case 0: + GoRouter.of(context).go('/a'); + break; + case 1: + GoRouter.of(context).go('/b'); + break; + } + } +} + +/// The first screen in the bottom navigation bar. +class ScreenA extends StatelessWidget { + /// Constructs a [ScreenA] widget. + const ScreenA({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Screen A'), + TextButton( + onPressed: () { + GoRouter.of(context).go('/a/details'); + }, + child: const Text('View A details'), + ), + ], + ), + ), + ); + } +} + +/// The second screen in the bottom navigation bar. +class ScreenB extends StatelessWidget { + /// Constructs a [ScreenB] widget. + const ScreenB({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Screen B'), + TextButton( + onPressed: () { + GoRouter.of(context).go('/b/details'); + }, + child: const Text('View B details'), + ), + ], + ), + ), + ); + } +} + +/// The details screen for either the A or B screen. +class DetailsScreen extends StatelessWidget { + /// Constructs a [DetailsScreen]. + const DetailsScreen({ + required this.label, + Key? key, + }) : super(key: key); + + /// The label to display in the center of the screen. + final String label; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Details Screen'), + ), + body: Center( + child: Text( + 'Details for $label', + style: Theme.of(context).textTheme.headlineMedium, + ), + ), + ); + } +} diff --git a/packages/go_router/lib/go_router.dart b/packages/go_router/lib/go_router.dart index 431bf3dcb7f7..d47c63d291bb 100644 --- a/packages/go_router/lib/go_router.dart +++ b/packages/go_router/lib/go_router.dart @@ -6,7 +6,8 @@ /// deep linking, data-driven routes and more. library go_router; -export 'src/configuration.dart' show GoRoute, GoRouterState; +export 'src/configuration.dart' + show GoRoute, GoRouterState, RouteBase, ShellRoute; export 'src/misc/extensions.dart'; export 'src/misc/inherited_router.dart'; export 'src/misc/refresh_stream.dart'; diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart index 7df67479b2a7..41cbc3e196d9 100644 --- a/packages/go_router/lib/src/builder.dart +++ b/packages/go_router/lib/src/builder.dart @@ -47,176 +47,272 @@ class RouteBuilder { /// changes. final List observers; - /// Builds the top-level Navigator by invoking the build method on each - /// matching route + /// Builds the top-level Navigator for the given [RouteMatchList]. Widget build( BuildContext context, - RouteMatchList matches, + RouteMatchList matchList, VoidCallback pop, - Key navigatorKey, bool routerNeglect, ) { - List>? pages; - Exception? error; - final String location = matches.location.toString(); - final List matchesList = matches.matches; try { - // build the stack of pages - if (routerNeglect) { - Router.neglect( - context, - () => pages = getPages(context, matchesList).toList(), - ); - } else { - pages = getPages(context, matchesList).toList(); - } - - // note that we need to catch it this way to get all the info, e.g. the - // file/line info for an error in an inline function impl, e.g. an inline - // `redirect` impl - // ignore: avoid_catches_without_on_clauses - } catch (err, stack) { - assert(() { - log.severe('Exception during GoRouter navigation', err, stack); - return true; - }()); - - // if there's an error, show an error page - error = err is Exception ? err : Exception(err); - final Uri uri = Uri.parse(location); - pages = >[ - _errorPageBuilder( + return tryBuild( + context, matchList, pop, routerNeglect, configuration.navigatorKey); + } on _RouteBuilderError catch (e) { + return _buildErrorNavigator( context, - GoRouterState( - configuration, - location: location, - subloc: uri.path, - name: null, - queryParams: uri.queryParameters, - queryParametersAll: uri.queryParametersAll, - error: error, - ), - ), - ]; - } - - // we should've set pages to something by now - assert(pages != null); - - // pass either the match error or the build error along to the navigator - // builder, preferring the match error - if (matches.isError) { - error = matches.error; + e, + Uri.parse(matchList.location.toString()), + pop, + configuration.navigatorKey); } + } - // wrap the returned Navigator to enable GoRouter.of(context).go() - final Uri uri = Uri.parse(location); + /// Builds the top-level Navigator by invoking the build method on each + /// matching route. + /// + /// Throws a [_RouteBuilderError]. + @visibleForTesting + Widget tryBuild( + BuildContext context, + RouteMatchList matchList, + VoidCallback pop, + bool routerNeglect, + GlobalKey navigatorKey, + ) { return builderWithNav( context, GoRouterState( configuration, - location: location, - // no name available at the top level + location: matchList.location.toString(), name: null, - // trim the query params off the subloc to match route.redirect - subloc: uri.path, - // pass along the query params 'cuz that's all we have right now - queryParams: uri.queryParameters, - queryParametersAll: uri.queryParametersAll, - // pass along the error, if there is one - error: error, + subloc: matchList.location.path, + queryParams: matchList.location.queryParameters, + queryParametersAll: matchList.location.queryParametersAll, + error: matchList.isError ? matchList.error : null, ), - Navigator( - restorationScopeId: restorationScopeId, - key: navigatorKey, - // needed to enable Android system Back button - pages: pages!, + _buildNavigator( + pop, + buildPages(context, matchList, pop, routerNeglect, navigatorKey), + navigatorKey, observers: observers, - onPopPage: (Route route, dynamic result) { - if (!route.didPop(result)) { - return false; - } - pop(); - return true; - }, ), ); } - /// Get the stack of sub-routes that matches the location and turn it into a - /// stack of pages, for example: - /// - /// routes: [ - /// / - /// family/:fid - /// person/:pid - /// /login - /// ] - /// - /// loc: / - /// pages: [ HomePage()] - /// - /// loc: /login - /// pages: [ LoginPage() ] - /// - /// loc: /family/f2 - /// pages: [ HomePage(), FamilyPage(f2) ] - /// - /// loc: /family/f2/person/p1 - /// pages: [ HomePage(), FamilyPage(f2), PersonPage(f2, p1) ] + /// Returns the top-level pages instead of the root navigator. Used for + /// testing. @visibleForTesting - Iterable> getPages( + List> buildPages( + BuildContext context, + RouteMatchList matchList, + VoidCallback onPop, + bool routerNeglect, + GlobalKey navigatorKey) { + try { + final Map, List>> keyToPage = + , List>>{}; + final Map params = {}; + _buildRecursive(context, matchList, 0, onPop, routerNeglect, keyToPage, + params, navigatorKey); + return keyToPage[navigatorKey]!; + } on _RouteBuilderError catch (e) { + return >[ + _buildErrorPage(context, e, matchList.location), + ]; + } + } + + void _buildRecursive( BuildContext context, - List matches, - ) sync* { - assert(matches.isNotEmpty); - - Map params = {}; - for (final RouteMatch match in matches) { - // merge new params to keep params from previously matched paths, e.g. - // /family/:fid/person/:pid provides fid and pid to person/:pid - params = {...params, ...match.decodedParams}; - - // get a page from the builder and associate it with a sub-location - final GoRouterState state = GoRouterState( - configuration, - location: match.fullUriString, - subloc: match.subloc, - name: match.route.name, - path: match.route.path, - fullpath: match.fullpath, - params: params, - error: match.error, - queryParams: match.queryParams, - queryParametersAll: match.queryParametersAll, - extra: match.extra, - pageKey: match.pageKey, // push() remaps the page key for uniqueness - ); - if (match.error != null) { - yield _errorPageBuilder(context, state); - break; - } + RouteMatchList matchList, + int startIndex, + VoidCallback pop, + bool routerNeglect, + Map, List>> keyToPages, + Map params, + GlobalKey navigatorKey, + ) { + if (startIndex >= matchList.matches.length) { + return; + } + final RouteMatch match = matchList.matches[startIndex]; + + if (match.error != null) { + throw _RouteBuilderError('Match error found during build phase', + exception: match.error); + } + + final RouteBase route = match.route; + final Map newParams = { + ...params, + ...match.decodedParams + }; + final GoRouterState state = buildState(match, newParams); + if (route is GoRoute) { + final Page page = _buildPageForRoute(context, state, match); + + // If this GoRoute is for a different Navigator, add it to the + // list of out of scope pages + final GlobalKey goRouteNavKey = + route.parentNavigatorKey ?? navigatorKey; + + keyToPages.putIfAbsent(goRouteNavKey, () => >[]).add(page); + + _buildRecursive(context, matchList, startIndex + 1, pop, routerNeglect, + keyToPages, newParams, navigatorKey); + } else if (route is ShellRoute) { + // The key for the Navigator that will display this ShellRoute's page. + final GlobalKey parentNavigatorKey = navigatorKey; - final GoRouterPageBuilder? pageBuilder = match.route.pageBuilder; - Page? page; + // The key to provide to the ShellRoute's Navigator. + final GlobalKey shellNavigatorKey = route.navigatorKey; + + // Add an entry for the parent navigator if none exists. + keyToPages.putIfAbsent(parentNavigatorKey, () => >[]); + + // Add an entry for the shell route's navigator + keyToPages.putIfAbsent(shellNavigatorKey, () => >[]); + + // Calling _buildRecursive can result in adding pages to the + // parentNavigatorKey entry's list. Store the current length so + // that the page for this ShellRoute is placed at the right index. + final int shellPageIdx = keyToPages[parentNavigatorKey]!.length; + + // Build the remaining pages + _buildRecursive(context, matchList, startIndex + 1, pop, routerNeglect, + keyToPages, newParams, shellNavigatorKey); + + // Build the Navigator + final Widget child = _buildNavigator( + pop, keyToPages[shellNavigatorKey]!, shellNavigatorKey); + + // Build the Page for this route + final Page page = + _buildPageForRoute(context, state, match, child: child); + + // Place the ShellRoute's Page onto the list for the parent navigator. + keyToPages + .putIfAbsent(parentNavigatorKey, () => >[]) + .insert(shellPageIdx, page); + } + } + + Navigator _buildNavigator( + VoidCallback pop, + List> pages, + Key? navigatorKey, { + List observers = const [], + }) { + return Navigator( + key: navigatorKey, + restorationScopeId: restorationScopeId, + pages: pages, + observers: observers, + onPopPage: (Route route, dynamic result) { + if (!route.didPop(result)) { + return false; + } + pop(); + return true; + }, + ); + } + + /// Helper method that builds a [GoRouterState] object for the given [match] + /// and [params]. + @visibleForTesting + GoRouterState buildState(RouteMatch match, Map params) { + final RouteBase route = match.route; + String? name = ''; + String path = ''; + if (route is GoRoute) { + name = route.name; + path = route.path; + } + return GoRouterState( + configuration, + location: match.fullUriString, + subloc: match.subloc, + name: name, + path: path, + fullpath: match.fullpath, + params: params, + error: match.error, + queryParams: match.queryParams, + queryParametersAll: match.queryParametersAll, + extra: match.extra, + pageKey: match.pageKey, + ); + } + + /// Builds a [Page] for [StackedRoute] + Page _buildPageForRoute( + BuildContext context, GoRouterState state, RouteMatch match, + {Widget? child}) { + final RouteBase route = match.route; + Page? page; + + if (route is GoRoute) { + // Call the pageBuilder if it's non-null + final GoRouterPageBuilder? pageBuilder = route.pageBuilder; if (pageBuilder != null) { page = pageBuilder(context, state); - if (page is NoOpPage) { - page = null; - } } + } else if (route is ShellRoute) { + final ShellRoutePageBuilder? pageBuilder = route.pageBuilder; + assert(child != null, 'ShellRoute must contain a child route'); + if (pageBuilder != null) { + page = pageBuilder(context, state, child!); + } + } - yield page ?? _pageBuilder(context, state, match.route.builder); + if (page is NoOpPage) { + page = null; } + + // Return the result of the route's builder() or pageBuilder() + return page ?? + buildPage(context, state, + _callRouteBuilder(context, state, match, childWidget: child)); } - Page Function({ - required LocalKey key, - required String? name, - required Object? arguments, - required String restorationId, - required Widget child, - })? _pageBuilderForAppType; + /// Calls the user-provided route builder from the [RouteMatch]'s [RouteBase]. + Widget _callRouteBuilder( + BuildContext context, GoRouterState state, RouteMatch match, + {Widget? childWidget}) { + final RouteBase route = match.route; + + if (route == null) { + throw _RouteBuilderError('No route found for match: $match'); + } + + if (route is GoRoute) { + final GoRouterWidgetBuilder? builder = route.builder; + + if (builder == null) { + throw _RouteBuilderError('No routeBuilder provided to GoRoute: $route'); + } + + return builder(context, state); + } else if (route is ShellRoute) { + if (childWidget == null) { + throw _RouteBuilderException( + 'Attempt to build ShellRoute without a child widget'); + } + + final ShellRouteBuilder? builder = route.builder; + + if (builder == null) { + throw _RouteBuilderError('No builder provided to ShellRoute: $route'); + } + + return builder(context, state, childWidget); + } + + throw _RouteBuilderException('Unsupported route type $route'); + } + + _PageBuilderForAppType? _pageBuilderForAppType; Widget Function( BuildContext context, @@ -232,26 +328,17 @@ class RouteBuilder { final Element? elem = context is Element ? context : null; if (elem != null && isMaterialApp(elem)) { - assert(() { - log.info('MaterialApp found'); - return true; - }()); + log.info('Using MaterialApp configuration'); _pageBuilderForAppType = pageBuilderForMaterialApp; _errorBuilderForAppType = (BuildContext c, GoRouterState s) => MaterialErrorScreen(s.error); } else if (elem != null && isCupertinoApp(elem)) { - assert(() { - log.info('CupertinoApp found'); - return true; - }()); + log.info('Using CupertinoApp configuration'); _pageBuilderForAppType = pageBuilderForCupertinoApp; _errorBuilderForAppType = (BuildContext c, GoRouterState s) => CupertinoErrorScreen(s.error); } else { - assert(() { - log.info('WidgetsApp found'); - return true; - }()); + log.info('Using WidgetsApp configuration'); _pageBuilderForAppType = pageBuilderForWidgetApp; _errorBuilderForAppType = (BuildContext c, GoRouterState s) => ErrorScreen(s.error); @@ -262,11 +349,12 @@ class RouteBuilder { assert(_errorBuilderForAppType != null); } - // builds the page based on app type, i.e. MaterialApp vs. CupertinoApp - Page _pageBuilder( + /// builds the page based on app type, i.e. MaterialApp vs. CupertinoApp + @visibleForTesting + Page buildPage( BuildContext context, GoRouterState state, - GoRouterWidgetBuilder builder, + Widget child, ) { // build the page based on app type _cacheAppType(context); @@ -275,7 +363,7 @@ class RouteBuilder { name: state.name ?? state.fullpath, arguments: {...state.params, ...state.queryParams}, restorationId: state.pageKey.value, - child: builder(context, state), + child: child, ); } @@ -295,22 +383,93 @@ class RouteBuilder { child: child, ); - Page _errorPageBuilder( + /// Builds a Navigator containing an error page. + Widget _buildErrorNavigator(BuildContext context, _RouteBuilderError e, + Uri uri, VoidCallback pop, GlobalKey navigatorKey) { + return _buildNavigator( + pop, + >[ + _buildErrorPage(context, e, uri), + ], + navigatorKey, + ); + } + + /// Builds a an error page. + Page _buildErrorPage( BuildContext context, - GoRouterState state, + _RouteBuilderError error, + Uri uri, ) { - // if the error page builder is provided, use that; otherwise, if the error - // builder is provided, wrap that in an app-specific page, e.g. - // MaterialPage; finally, if nothing is provided, use a default error page - // wrapped in the app-specific page, e.g. - // MaterialPage(GoRouterMaterialErrorPage(...)) + final GoRouterState state = GoRouterState( + configuration, + location: uri.toString(), + subloc: uri.path, + name: null, + queryParams: uri.queryParameters, + queryParametersAll: uri.queryParametersAll, + error: Exception(error), + ); + + // If the error page builder is provided, use that, otherwise, if the error + // builder is provided, wrap that in an app-specific page (for example, + // MaterialPage). Finally, if nothing is provided, use a default error page + // wrapped in the app-specific page. _cacheAppType(context); + final GoRouterWidgetBuilder? errorBuilder = this.errorBuilder; return errorPageBuilder != null ? errorPageBuilder!(context, state) - : _pageBuilder( + : buildPage( context, state, - errorBuilder ?? _errorBuilderForAppType!, + errorBuilder != null + ? errorBuilder(context, state) + : _errorBuilderForAppType!(context, state), ); } } + +typedef _PageBuilderForAppType = Page Function({ + required LocalKey key, + required String? name, + required Object? arguments, + required String restorationId, + required Widget child, +}); + +/// An error that occurred while building the app's UI based on the route +/// matches. +class _RouteBuilderError extends Error { + /// Constructs a [_RouteBuilderError]. + _RouteBuilderError(this.message, {this.exception}); + + /// The error message. + final String message; + + /// The exception that occurred. + final Exception? exception; + + @override + String toString() { + return '$message ${exception ?? ""}'; + } +} + +/// An error that occurred while building the app's UI based on the route +/// matches. +class _RouteBuilderException implements Exception { + /// Constructs a [_RouteBuilderException]. + //ignore: unused_element + _RouteBuilderException(this.message, {this.exception}); + + /// The error message. + final String message; + + /// The exception that occurred. + final Exception? exception; + + @override + String toString() { + return '$message ${exception ?? ""}'; + } +} diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index 47262db28d65..fa7261b470f9 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/widgets.dart'; + import 'configuration.dart'; import 'logging.dart'; import 'path_utils.dart'; @@ -17,24 +19,79 @@ class RouteConfiguration { required this.routes, required this.redirectLimit, required this.topRedirect, + required this.navigatorKey, }) { _cacheNameToPath('', routes); + log.info(_debugKnownRoutes()); + assert(() { - log.info(_debugKnownRoutes()); - return true; - }()); + for (final RouteBase route in routes) { + if (route is GoRoute && !route.path.startsWith('/')) { + assert(route.path.startsWith('/'), + 'top-level path must start with "/": ${route.path}'); + } else if (route is ShellRoute) { + for (final RouteBase route in routes) { + if (route is GoRoute) { + assert(route.path.startsWith('/'), + 'top-level path must start with "/": ${route.path}'); + } + } + } + } - for (final GoRoute route in routes) { - if (!route.path.startsWith('/')) { - throw RouteConfigurationError( - 'top-level path must start with "/": ${route.path}'); + // Check that each parentNavigatorKey refers to either a ShellRoute's + // navigatorKey or the root navigator key. + void checkParentNavigatorKeys( + List routes, List> allowedKeys) { + for (final RouteBase route in routes) { + if (route is GoRoute) { + final GlobalKey? parentKey = + route.parentNavigatorKey; + if (parentKey != null) { + // Verify that the root navigator or a ShellRoute ancestor has a + // matching navigator key. + assert( + allowedKeys.contains(parentKey), + 'parentNavigatorKey $parentKey must refer to' + " an ancestor ShellRoute's navigatorKey or GoRouter's" + ' navigatorKey'); + + checkParentNavigatorKeys( + route.routes, + >[ + // Once a parentNavigatorKey is used, only the navigator keys + // above it can be used. + ...allowedKeys.sublist(0, allowedKeys.indexOf(parentKey)), + ], + ); + } else { + checkParentNavigatorKeys( + route.routes, + >[ + ...allowedKeys, + ], + ); + } + } else if (route is ShellRoute && route.navigatorKey != null) { + checkParentNavigatorKeys( + route.routes, + >[ + ...allowedKeys..add(route.navigatorKey) + ], + ); + } + } } - } + + checkParentNavigatorKeys( + routes, >[navigatorKey]); + return true; + }()); } /// The list of top level routes used by [GoRouterDelegate]. - final List routes; + final List routes; /// The limit for the number of consecutive redirects. final int redirectLimit; @@ -42,6 +99,9 @@ class RouteConfiguration { /// Top level page redirect. final GoRouterRedirect topRedirect; + /// The key to use when building the root [Navigator]. + final GlobalKey navigatorKey; + final Map _nameToPath = {}; /// Looks up the url location by a [GoRoute]'s name. @@ -106,43 +166,39 @@ class RouteConfiguration { return sb.toString(); } - void _debugFullPathsFor( - List routes, String parentFullpath, int depth, StringBuffer sb) { - for (final GoRoute route in routes) { - final String fullpath = concatenatePaths(parentFullpath, route.path); - sb.writeln(' => ${''.padLeft(depth * 2)}$fullpath'); - _debugFullPathsFor(route.routes, fullpath, depth + 1, sb); + void _debugFullPathsFor(List routes, String parentFullpath, + int depth, StringBuffer sb) { + for (final RouteBase route in routes) { + if (route is GoRoute) { + final String fullpath = concatenatePaths(parentFullpath, route.path); + sb.writeln(' => ${''.padLeft(depth * 2)}$fullpath'); + _debugFullPathsFor(route.routes, fullpath, depth + 1, sb); + } } } - void _cacheNameToPath(String parentFullPath, List childRoutes) { - for (final GoRoute route in childRoutes) { - final String fullPath = concatenatePaths(parentFullPath, route.path); - - if (route.name != null) { - final String name = route.name!.toLowerCase(); - assert( - !_nameToPath.containsKey(name), - 'duplication fullpaths for name ' - '"$name":${_nameToPath[name]}, $fullPath'); - _nameToPath[name] = fullPath; - } - - if (route.routes.isNotEmpty) { - _cacheNameToPath(fullPath, route.routes); + void _cacheNameToPath(String parentFullPath, List childRoutes) { + for (final RouteBase route in childRoutes) { + if (route is GoRoute) { + final String fullPath = concatenatePaths(parentFullPath, route.path); + + if (route.name != null) { + final String name = route.name!.toLowerCase(); + assert( + !_nameToPath.containsKey(name), + 'duplication fullpaths for name ' + '"$name":${_nameToPath[name]}, $fullPath'); + _nameToPath[name] = fullPath; + } + + if (route.routes.isNotEmpty) { + _cacheNameToPath(fullPath, route.routes); + } + } else if (route is ShellRoute) { + if (route.routes.isNotEmpty) { + _cacheNameToPath(parentFullPath, route.routes); + } } } } } - -/// Thrown when the [RouteConfiguration] is invalid. -class RouteConfigurationError extends Error { - /// [RouteConfigurationError] constructor. - RouteConfigurationError(this.message); - - /// The error message. - final String message; - - @override - String toString() => 'Route configuration error: $message'; -} diff --git a/packages/go_router/lib/src/delegate.dart b/packages/go_router/lib/src/delegate.dart index de0f81aa2083..06abcc2d0d04 100644 --- a/packages/go_router/lib/src/delegate.dart +++ b/packages/go_router/lib/src/delegate.dart @@ -11,6 +11,7 @@ import 'builder.dart'; import 'configuration.dart'; import 'match.dart'; import 'matching.dart'; +import 'misc/errors.dart'; import 'typedefs.dart'; /// GoRouter implementation of [RouterDelegate]. @@ -26,7 +27,8 @@ class GoRouterDelegate extends RouterDelegate required List observers, required this.routerNeglect, String? restorationScopeId, - }) : builder = RouteBuilder( + }) : _configuration = configuration, + builder = RouteBuilder( configuration: configuration, builderWithNav: builderWithNav, errorPageBuilder: errorPageBuilder, @@ -42,13 +44,48 @@ class GoRouterDelegate extends RouterDelegate /// Set to true to disable creating history entries on the web. final bool routerNeglect; - final GlobalKey _key = GlobalKey(); - - RouteMatchList _matches = RouteMatchList.empty(); + RouteMatchList _matchList = RouteMatchList.empty(); final Map _pushCounts = {}; + final RouteConfiguration _configuration; + + @override + Future popRoute() async { + // Iterate backwards through the RouteMatchList until seeing a GoRoute with + // a non-null parentNavigatorKey or a ShellRoute with a non-null + // parentNavigatorKey and pop from that Navigator instead of the root. + final int matchCount = _matchList.matches.length; + for (int i = matchCount - 1; i >= 0; i -= 1) { + final RouteMatch match = _matchList.matches[i]; + final RouteBase route = match.route; + + if (route is GoRoute && route.parentNavigatorKey != null) { + // It should not be possible for a GoRoute with parentNavigatorKey to be + // the only page, so maybePop should never return false in this case. + assert(await route.parentNavigatorKey!.currentState!.maybePop()); + return true; + } else if (route is ShellRoute) { + assert(await route.navigatorKey.currentState!.maybePop()); + return true; + } + } + + // Use the root navigator if no ShellRoute Navigators were found and didn't + // pop + final NavigatorState? navigator = navigatorKey.currentState; + + if (navigator == null) { + return SynchronousFuture(false); + } + + return navigator.maybePop(); + } /// Pushes the given location onto the page stack void push(RouteMatch match) { + if (match.route is ShellRoute) { + throw GoError('ShellRoutes cannot be pushed'); + } + // Remap the pageKey to allow any number of the same page on the stack final String fullPath = match.fullpath; final int count = (_pushCounts[fullPath] ?? 0) + 1; @@ -66,18 +103,37 @@ class GoRouterDelegate extends RouterDelegate pageKey: pageKey, ); - _matches.push(newPageKeyMatch); + _matchList.push(newPageKeyMatch); notifyListeners(); } - /// Returns `true` if there is more than 1 page on the stack. + /// Returns `true` if the active Navigator can pop. bool canPop() { - return _matches.canPop(); + // Loop through navigators in reverse and call canPop() + final int matchCount = _matchList.matches.length; + for (int i = matchCount - 1; i >= 0; i -= 1) { + final RouteMatch match = _matchList.matches[i]; + final RouteBase route = match.route; + if (route is GoRoute && route.parentNavigatorKey != null) { + final bool canPop = route.parentNavigatorKey!.currentState!.canPop(); + // Similar to popRoute, it should not be possible for a GoRoute with + // parentNavigatorKey to be the only page, so canPop should return true + // in this case. + assert(canPop); + return canPop; + } else if (route is ShellRoute) { + final bool canPop = route.navigatorKey.currentState!.canPop(); + if (canPop) { + return canPop; + } + } + } + return navigatorKey.currentState?.canPop() ?? false; } /// Pop the top page off the GoRouter's page stack. void pop() { - _matches.pop(); + _matchList.pop(); notifyListeners(); } @@ -86,36 +142,37 @@ class GoRouterDelegate extends RouterDelegate /// See also: /// * [push] which pushes the given location onto the page stack. void replace(RouteMatch match) { - _matches.matches.last = match; + _matchList.matches.last = match; notifyListeners(); } /// For internal use; visible for testing only. @visibleForTesting - RouteMatchList get matches => _matches; + RouteMatchList get matches => _matchList; /// For use by the Router architecture as part of the RouterDelegate. @override - GlobalKey get navigatorKey => _key; + GlobalKey get navigatorKey => _configuration.navigatorKey; /// For use by the Router architecture as part of the RouterDelegate. @override - RouteMatchList get currentConfiguration => _matches; + RouteMatchList get currentConfiguration => _matchList; /// For use by the Router architecture as part of the RouterDelegate. @override - Widget build(BuildContext context) => builder.build( - context, - _matches, - pop, - navigatorKey, - routerNeglect, - ); + Widget build(BuildContext context) { + return builder.build( + context, + _matchList, + pop, + routerNeglect, + ); + } /// For use by the Router architecture as part of the RouterDelegate. @override Future setNewRoutePath(RouteMatchList configuration) { - _matches = configuration; + _matchList = configuration; // Use [SynchronousFuture] so that the initial url is processed // synchronously and remove unwanted initial animations on deep-linking return SynchronousFuture(null); diff --git a/packages/go_router/lib/src/match.dart b/packages/go_router/lib/src/match.dart index d65533cd6157..7d0849211943 100644 --- a/packages/go_router/lib/src/match.dart +++ b/packages/go_router/lib/src/match.dart @@ -3,15 +3,15 @@ // found in the LICENSE file. import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'matching.dart'; import 'path_utils.dart'; import 'route.dart'; -/// Each RouteMatch instance represents an instance of a GoRoute for a specific -/// portion of a location. +/// An instance of a GoRoute plus information about the current location. class RouteMatch { - /// Constructor for [RouteMatch], each instance represents an instance of a - /// [GoRoute] for a specific portion of a location. + /// Constructor for [RouteMatch]. RouteMatch({ required this.route, required this.subloc, @@ -23,9 +23,7 @@ class RouteMatch { required this.error, this.pageKey, }) : fullUriString = _addQueryParams(subloc, queryParametersAll), - assert(subloc.startsWith('/')), assert(Uri.parse(subloc).queryParameters.isEmpty), - assert(fullpath.startsWith('/')), assert(Uri.parse(fullpath).queryParameters.isEmpty), assert(() { for (final MapEntry p in encodedParams.entries) { @@ -37,7 +35,7 @@ class RouteMatch { // ignore: public_member_api_docs static RouteMatch? match({ - required GoRoute route, + required RouteBase route, required String restLoc, // e.g. person/p1 required String parentSubloc, // e.g. /family/f2 required String fullpath, // e.g. /family/:fid/person/:pid @@ -45,35 +43,52 @@ class RouteMatch { required Map> queryParametersAll, required Object? extra, }) { - assert(!route.path.contains('//')); - - final RegExpMatch? match = route.matchPatternAsPrefix(restLoc); - if (match == null) { - return null; + if (route is ShellRoute) { + return RouteMatch( + route: route, + subloc: restLoc, + fullpath: '', + encodedParams: {}, + queryParams: queryParams, + queryParametersAll: queryParametersAll, + extra: extra, + error: null, + // Provide a unique pageKey to ensure that the page for this ShellRoute is + // reused. + pageKey: ValueKey(route.hashCode.toString()), + ); + } else if (route is GoRoute) { + assert(!route.path.contains('//')); + + final RegExpMatch? match = route.matchPatternAsPrefix(restLoc); + if (match == null) { + return null; + } + + final Map encodedParams = route.extractPathParams(match); + final String pathLoc = patternToPath(route.path, encodedParams); + final String subloc = concatenatePaths(parentSubloc, pathLoc); + return RouteMatch( + route: route, + subloc: subloc, + fullpath: fullpath, + encodedParams: encodedParams, + queryParams: queryParams, + queryParametersAll: queryParametersAll, + extra: extra, + error: null, + ); } - - final Map encodedParams = route.extractPathParams(match); - final String pathLoc = patternToPath(route.path, encodedParams); - final String subloc = concatenatePaths(parentSubloc, pathLoc); - return RouteMatch( - route: route, - subloc: subloc, - fullpath: fullpath, - encodedParams: encodedParams, - queryParams: queryParams, - queryParametersAll: queryParametersAll, - extra: extra, - error: null, - ); + throw MatcherError('Unexpected route type: $route', restLoc); } /// The matched route. - final GoRoute route; + final RouteBase route; - /// Matched sub-location. + /// The matched location. final String subloc; // e.g. /family/f2 - /// Matched full path. + /// The matched template. final String fullpath; // e.g. /family/:fid /// Parameters for the matched route, URI-encoded. diff --git a/packages/go_router/lib/src/matching.dart b/packages/go_router/lib/src/matching.dart index b7681c69957e..060bd3bc474b 100644 --- a/packages/go_router/lib/src/matching.dart +++ b/packages/go_router/lib/src/matching.dart @@ -71,15 +71,15 @@ class RouteMatchList { /// Removes the last match. void pop() { _matches.removeLast(); - assert( - _matches.isNotEmpty, - 'You have popped the last page off of the stack,' - ' there are no pages left to show'); - } - /// Returns true if [pop] can safely be called. - bool canPop() { - return _matches.length > 1; + _debugAssertNotEmpty(); + + // Also pop ShellRoutes when there are no subsequent route matches + while (_matches.isNotEmpty && _matches.last.route is ShellRoute) { + _matches.removeLast(); + } + + _debugAssertNotEmpty(); } /// An optional object provided by the app during navigation. @@ -96,6 +96,13 @@ class RouteMatchList { /// Returns the error that this match intends to display. Exception? get error => matches.first.error; + + void _debugAssertNotEmpty() { + assert( + _matches.isNotEmpty, + 'You have popped the last page off of the stack,' + ' there are no pages left to show'); + } } /// An error that occurred during matching. @@ -119,7 +126,7 @@ List _getLocRouteRecursively({ required String loc, required String restLoc, required String parentSubloc, - required List routes, + required List routes, required String parentFullpath, required Map queryParams, required Map> queryParametersAll, @@ -132,8 +139,14 @@ List _getLocRouteRecursively({ }()); final List> result = >[]; // find the set of matches at this level of the tree - for (final GoRoute route in routes) { - final String fullpath = concatenatePaths(parentFullpath, route.path); + for (final RouteBase route in routes) { + late final String fullpath; + if (route is GoRoute) { + fullpath = concatenatePaths(parentFullpath, route.path); + } else if (route is ShellRoute) { + fullpath = parentFullpath; + } + final RouteMatch? match = RouteMatch.match( route: route, restLoc: restLoc, @@ -147,7 +160,9 @@ List _getLocRouteRecursively({ if (match == null) { continue; } - if (match.subloc.toLowerCase() == loc.toLowerCase()) { + + if (match.route is GoRoute && + match.subloc.toLowerCase() == loc.toLowerCase()) { // If it is a complete match, then return the matched route // NOTE: need a lower case match because subloc is canonicalized to match // the path case whereas the location can be of any case and still match @@ -157,15 +172,24 @@ List _getLocRouteRecursively({ continue; } else { // Otherwise, recurse - final String childRestLoc = - loc.substring(match.subloc.length + (match.subloc == '/' ? 0 : 1)); - assert(loc.startsWith(match.subloc)); - assert(restLoc.isNotEmpty); + final String childRestLoc; + final String newParentSubLoc; + if (match.route is ShellRoute) { + childRestLoc = restLoc; + newParentSubLoc = parentSubloc; + } else { + assert(loc.startsWith(match.subloc)); + assert(restLoc.isNotEmpty); + + childRestLoc = + loc.substring(match.subloc.length + (match.subloc == '/' ? 0 : 1)); + newParentSubLoc = match.subloc; + } final List subRouteMatch = _getLocRouteRecursively( loc: loc, restLoc: childRestLoc, - parentSubloc: match.subloc, + parentSubloc: newParentSubLoc, routes: route.routes, parentFullpath: fullpath, queryParams: queryParams, diff --git a/packages/go_router/lib/src/misc/errors.dart b/packages/go_router/lib/src/misc/errors.dart new file mode 100644 index 000000000000..7aca0d89951c --- /dev/null +++ b/packages/go_router/lib/src/misc/errors.dart @@ -0,0 +1,15 @@ +// Copyright 2013 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. + +/// Thrown when [GoRouter] is used incorrectly. +class GoError extends Error { + /// Constructs a [GoError] + GoError(this.message); + + /// The error message. + final String message; + + @override + String toString() => 'GoError: $message'; +} diff --git a/packages/go_router/lib/src/redirection.dart b/packages/go_router/lib/src/redirection.dart index e494ee6f6331..03d27be05c83 100644 --- a/packages/go_router/lib/src/redirection.dart +++ b/packages/go_router/lib/src/redirection.dart @@ -71,13 +71,18 @@ RouteMatchList redirect(RouteMatchList prevMatchList, if (top == null) { break; } - final String? topRouteLocation = top.route.redirect( + + final RouteBase topRoute = top.route; + assert(topRoute is GoRoute, + 'Last RouteMatch should contain a GoRoute, but was ${topRoute.runtimeType}'); + final GoRoute topGoRoute = topRoute as GoRoute; + final String? topRouteLocation = topGoRoute.redirect( GoRouterState( configuration, location: currentMatches.location.toString(), subloc: top.subloc, - name: top.route.name, - path: top.route.path, + name: topGoRoute.name, + path: topGoRoute.path, fullpath: top.fullpath, extra: top.extra, params: top.decodedParams, diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index d7790f574f52..ca0497c2350e 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -5,32 +5,141 @@ import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; +import 'configuration.dart'; +import 'pages/custom_transition_page.dart'; import 'path_utils.dart'; -import 'state.dart'; import 'typedefs.dart'; -/// A declarative mapping between a route path and a page builder. -class GoRoute { - /// Default constructor used to create mapping between a route path and a page - /// builder. +/// The base class for [GoRoute] and [ShellRoute]. +/// +/// Routes are defined in a tree such that parent routes must match the +/// current location for their child route to be considered a match. For +/// example the location "/home/user/12" matches with parent route "/home" and +/// child route "user/:userId". +/// +/// To create sub-routes for a route, provide them as a [GoRoute] list +/// with the sub routes. +/// +/// For example these routes: +/// ``` +/// / => HomePage() +/// family/f1 => FamilyPage('f1') +/// person/p2 => PersonPage('f1', 'p2') ← showing this page, Back pops ↑ +/// ``` +/// +/// Can be represented as: +/// +/// ``` +/// final GoRouter _router = GoRouter( +/// routes: [ +/// GoRoute( +/// path: '/', +/// pageBuilder: (BuildContext context, GoRouterState state) => MaterialPage( +/// key: state.pageKey, +/// child: HomePage(families: Families.data), +/// ), +/// routes: [ +/// GoRoute( +/// path: 'family/:fid', +/// pageBuilder: (BuildContext context, GoRouterState state) { +/// final Family family = Families.family(state.params['fid']!); +/// return MaterialPage( +/// key: state.pageKey, +/// child: FamilyPage(family: family), +/// ); +/// }, +/// routes: [ +/// GoRoute( +/// path: 'person/:pid', +/// pageBuilder: (BuildContext context, GoRouterState state) { +/// final Family family = Families.family(state.params['fid']!); +/// final Person person = family.person(state.params['pid']!); +/// return MaterialPage( +/// key: state.pageKey, +/// child: PersonPage(family: family, person: person), +/// ); +/// }, +/// ), +/// ], +/// ), +/// ], +/// ), +/// ], +/// ); +/// +/// If there are multiple routes that match the location, the first match is used. +/// To make predefined routes to take precedence over dynamic routes eg. '/:id' +/// consider adding the dynamic route at the end of the routes +/// For example: +/// ``` +/// final GoRouter _router = GoRouter( +/// routes: [ +/// GoRoute( +/// path: '/', +/// redirect: (_) => '/family/${Families.data[0].id}', +/// ), +/// GoRoute( +/// path: '/family', +/// pageBuilder: (BuildContext context, GoRouterState state) => ..., +/// ), +/// GoRoute( +/// path: '/:username', +/// pageBuilder: (BuildContext context, GoRouterState state) => ..., +/// ), +/// ], +/// ); +/// ``` +/// In the above example, if /family route is matched, it will be used. +/// else /:username route will be used. +/// +/// See [Sub-routes](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/sub_routes.dart) +/// for a complete runnable example. +@immutable +abstract class RouteBase { + const RouteBase._({ + this.routes = const [], + }); + + /// The list of child routes associated with this route. + final List routes; +} + +/// A route that is displayed visually above the matching parent route using the +/// [Navigator]. +/// +/// The widget returned by [builder] is wrapped in [Page] and provided to the +/// root Navigator, the nearest ShellRoute ancestor's Navigator, or the +/// Navigator with a matching [parentNavigatorKey]. +/// +/// The Page depends on the application type: [MaterialPage] for +/// [MaterialApp], [CupertinoPage] for [CupertinoApp], or +/// [NoTransitionPage] for [WidgetsApp]. +class GoRoute extends RouteBase { + /// Constructs a [GoRoute]. + /// - [path] and [name] cannot be empty strings. + /// - One of either [builder] or [pageBuilder] must be provided. GoRoute({ required this.path, this.name, + this.builder, this.pageBuilder, - this.builder = _invalidBuilder, - this.routes = const [], - this.redirect = _noRedirection, + this.parentNavigatorKey, + this.redirect = _emptyRedirect, + List routes = const [], }) : assert(path.isNotEmpty, 'GoRoute path cannot be empty'), assert(name == null || name.isNotEmpty, 'GoRoute name cannot be empty'), + assert(!(builder == null && pageBuilder == null), + 'builder or pageBuilder must be provided'), assert( pageBuilder != null || builder != _invalidBuilder || redirect != _noRedirection, - 'GoRoute builder parameter not set\n' - 'See gorouter.dev/redirection#considerations for details') { + 'GoRoute builder parameter not set\n'), + super._( + routes: routes, + ) { // cache the path regexp and parameters _pathRE = patternToRegExp(path, _pathParams); - assert(() { // check path params final Map> groupedParams = @@ -44,20 +153,19 @@ class GoRoute { 'duplicate path params: ${dupParams.keys.join(', ')}'); // check sub-routes - for (final GoRoute route in routes) { + for (final RouteBase route in routes) { // check paths - assert( - route.path == '/' || - (!route.path.startsWith('/') && !route.path.endsWith('/')), - 'sub-route path may not start or end with /: ${route.path}'); + if (route is GoRoute) { + assert( + route.path == '/' || + (!route.path.startsWith('/') && !route.path.endsWith('/')), + 'sub-route path may not start or end with /: ${route.path}'); + } } return true; }()); } - final List _pathParams = []; - late final RegExp _pathRE; - /// Optional name of the route. /// /// If used, a unique string name must be provided and it can not be empty. @@ -152,88 +260,7 @@ class GoRoute { /// ), /// ``` /// - final GoRouterWidgetBuilder builder; - - /// A list of sub go routes for this route. - /// - /// To create sub-routes for a route, provide them as a [GoRoute] list - /// with the sub routes. - /// - /// For example these routes: - /// ``` - /// / => HomePage() - /// family/f1 => FamilyPage('f1') - /// person/p2 => PersonPage('f1', 'p2') ← showing this page, Back pops ↑ - /// ``` - /// - /// Can be represented as: - /// - /// ``` - /// final GoRouter _router = GoRouter( - /// routes: [ - /// GoRoute( - /// path: '/', - /// pageBuilder: (BuildContext context, GoRouterState state) => MaterialPage( - /// key: state.pageKey, - /// child: HomePage(families: Families.data), - /// ), - /// routes: [ - /// GoRoute( - /// path: 'family/:fid', - /// pageBuilder: (BuildContext context, GoRouterState state) { - /// final Family family = Families.family(state.params['fid']!); - /// return MaterialPage( - /// key: state.pageKey, - /// child: FamilyPage(family: family), - /// ); - /// }, - /// routes: [ - /// GoRoute( - /// path: 'person/:pid', - /// pageBuilder: (BuildContext context, GoRouterState state) { - /// final Family family = Families.family(state.params['fid']!); - /// final Person person = family.person(state.params['pid']!); - /// return MaterialPage( - /// key: state.pageKey, - /// child: PersonPage(family: family, person: person), - /// ); - /// }, - /// ), - /// ], - /// ), - /// ], - /// ), - /// ], - /// ); - /// - /// If there are multiple routes that match the location, the first match is used. - /// To make predefined routes to take precedence over dynamic routes eg. '/:id' - /// consider adding the dynamic route at the end of the routes - /// For example: - /// ``` - /// final GoRouter _router = GoRouter( - /// routes: [ - /// GoRoute( - /// path: '/', - /// redirect: (_) => '/family/${Families.data[0].id}', - /// ), - /// GoRoute( - /// path: '/family', - /// pageBuilder: (BuildContext context, GoRouterState state) => ..., - /// ), - /// GoRoute( - /// path: '/:username', - /// pageBuilder: (BuildContext context, GoRouterState state) => ..., - /// ), - /// ], - /// ); - /// ``` - /// In the above example, if /family route is matched, it will be used. - /// else /:username route will be used. - /// - /// See [Sub-routes](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/sub_routes.dart) - /// for a complete runnable example. - final List routes; + final GoRouterWidgetBuilder? builder; /// An optional redirect function for this route. /// @@ -263,6 +290,13 @@ class GoRoute { /// for a complete runnable example. final GoRouterRedirect redirect; + /// An optional key specifying which Navigator to display this route's screen + /// onto. + /// + /// Specifying the root Navigator will stack this route onto that + /// Navigator instead of the nearest ShellRoute ancestor. + final GlobalKey? parentNavigatorKey; + /// Match this route against a location. RegExpMatch? matchPatternAsPrefix(String loc) => _pathRE.matchAsPrefix(loc) as RegExpMatch?; @@ -271,6 +305,12 @@ class GoRoute { Map extractPathParams(RegExpMatch match) => extractPathParameters(_pathParams, match); + static String? _emptyRedirect(GoRouterState state) => null; + + final List _pathParams = []; + + late final RegExp _pathRE; + static String? _noRedirection(GoRouterState state) => null; static Widget _invalidBuilder( @@ -279,3 +319,134 @@ class GoRoute { ) => const SizedBox.shrink(); } + +/// A route that displays a UI shell around the matching child route. +/// +/// When a ShellRoute is added to the list of routes on GoRouter or GoRoute, a +/// new Navigator that is used to display any matching sub-routes, instead of +/// placing them on the root Navigator. +/// +/// To display a child route on a different Navigator, provide it with a +/// [parentNavigatorKey] that matches the key provided to either the [GoRouter] +/// or [ShellRoute] constructor. In this example, the _rootNavigator key is +/// passed to the /b/details route so that it displays on the root Navigator +/// instead of the ShellRoute's Navigator: +/// +/// ``` +/// final GlobalKey _rootNavigatorKey = +// GlobalKey(); +/// +/// final GoRouter _router = GoRouter( +/// navigatorKey: _rootNavigatorKey, +/// initialLocation: '/a', +/// routes: [ +/// ShellRoute( +/// navigatorKey: _shellNavigatorKey, +/// builder: (context, state, child) { +/// return ScaffoldWithNavBar(child: child); +/// }, +/// routes: [ +/// // This screen is displayed on the ShellRoute's Navigator. +/// GoRoute( +/// path: '/a', +/// builder: (context, state) { +/// return const ScreenA(); +/// }, +/// routes: [ +/// // This screen is displayed on the ShellRoute's Navigator. +/// GoRoute( +/// path: 'details', +/// builder: (BuildContext context, GoRouterState state) { +/// return const DetailsScreen(label: 'A'); +/// }, +/// ), +/// ], +/// ), +/// // Displayed ShellRoute's Navigator. +/// GoRoute( +/// path: '/b', +/// builder: (BuildContext context, GoRouterState state) { +/// return const ScreenB(); +/// }, +/// routes: [ +/// // Displayed on the root Navigator by specifying the +/// // [parentNavigatorKey]. +/// GoRoute( +/// path: 'details', +/// parentNavigatorKey: _rootNavigatorKey, +/// builder: (BuildContext context, GoRouterState state) { +/// return const DetailsScreen(label: 'B'); +/// }, +/// ), +/// ], +/// ), +/// ], +/// ), +/// ], +/// ); +/// ``` +/// +/// The widget built by the matching sub-route becomes the child parameter +/// of the [builder]. +/// +/// For example: +/// +/// ``` +/// ShellRoute( +/// path: '/', +/// builder: (BuildContext context, GoRouterState state, Widget child) { +/// return Scaffold( +/// appBar: AppBar( +/// title: Text('App Shell') +/// ), +/// body: Center( +/// child: child, +/// ), +/// ); +/// }, +/// routes: [ +/// GoRoute( +/// path: 'a' +/// builder: (BuildContext context, GoRouterState state) { +/// return Text('Child Route "/a"'); +/// } +/// ), +/// ], +/// ), +/// ``` +/// +class ShellRoute extends RouteBase { + /// Constructs a [ShellRoute]. + ShellRoute({ + this.builder, + this.pageBuilder, + super.routes, + GlobalKey? navigatorKey, + }) : assert(routes.isNotEmpty), + navigatorKey = navigatorKey ?? GlobalKey(), + super._() { + for (final RouteBase route in routes) { + assert(route is GoRoute); + assert((route as GoRoute).parentNavigatorKey == null); + } + } + + /// The widget builder for a shell route. + /// + /// Similar to GoRoute builder, but with an additional child parameter. This + /// child parameter is the Widget built by calling the matching sub-route's + /// builder. + final ShellRouteBuilder? builder; + + /// The page builder for a shell route. + /// + /// Similar to GoRoute pageBuilder, but with an additional child parameter. + /// This child parameter is the Widget built by calling the matching + /// sub-route's builder. + final ShellRoutePageBuilder? pageBuilder; + + /// The [GlobalKey] to be used by the [Navigator] built for this route. + /// All ShellRoutes build a Navigator by default. Child GoRoutes + /// are placed onto this Navigator instead of the root Navigator. + final GlobalKey navigatorKey; +} diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 95a26a57ef3f..486a71713b34 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -41,7 +41,7 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { /// /// The `routes` must not be null and must contain an [GoRouter] to match `/`. GoRouter({ - required List routes, + required List routes, // TODO(johnpryan): Change to a route, improve error API // See https://github.com/flutter/flutter/issues/108144 GoRouterPageBuilder? errorPageBuilder, @@ -59,6 +59,7 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { // TODO(johnpryan): Deprecate this parameter // See https://github.com/flutter/flutter/issues/108145 GoRouterNavigatorBuilder? navigatorBuilder, + GlobalKey? navigatorKey, String? restorationScopeId, }) { if (urlPathStrategy != null) { @@ -68,10 +69,13 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { setLogging(enabled: debugLogDiagnostics); WidgetsFlutterBinding.ensureInitialized(); + navigatorKey ??= GlobalKey(); + _routeConfiguration = RouteConfiguration( routes: routes, topRedirect: redirect ?? (_) => null, redirectLimit: redirectLimit, + navigatorKey: navigatorKey, ); _routeInformationParser = GoRouteInformationParser( @@ -137,8 +141,6 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { /// Get a location from route name and parameters. /// This is useful for redirecting to a named location. - // TODO(johnpryan): Deprecate this API - // See https://github.com/flutter/flutter/issues/107729 String namedLocation( String name, { Map params = const {}, diff --git a/packages/go_router/lib/src/state.dart b/packages/go_router/lib/src/state.dart index 34b6449fe2e9..7529c22a036c 100644 --- a/packages/go_router/lib/src/state.dart +++ b/packages/go_router/lib/src/state.dart @@ -24,13 +24,12 @@ class GoRouterState { this.extra, this.error, ValueKey? pageKey, - }) : pageKey = pageKey ?? + }) : pageKey = pageKey ?? ValueKey(error != null ? 'error' : fullpath != null && fullpath.isNotEmpty ? fullpath - : subloc), - assert((path ?? '').isEmpty == (fullpath ?? '').isEmpty); + : subloc); // TODO(johnpryan): remove once namedLocation is removed from go_router. // See https://github.com/flutter/flutter/issues/107729 diff --git a/packages/go_router/lib/src/typedefs.dart b/packages/go_router/lib/src/typedefs.dart index 2f8789e6f72e..ab540f6d9e36 100644 --- a/packages/go_router/lib/src/typedefs.dart +++ b/packages/go_router/lib/src/typedefs.dart @@ -6,18 +6,32 @@ import 'package:flutter/widgets.dart'; import 'configuration.dart'; -/// The signature of the widget builder callback for a matched GoRoute. +/// The widget builder for [GoRoute]. typedef GoRouterWidgetBuilder = Widget Function( BuildContext context, GoRouterState state, ); -/// The signature of the page builder callback for a matched GoRoute. -typedef GoRouterPageBuilder = Page Function( +/// The page builder for [GoRoute]. +typedef GoRouterPageBuilder = Page Function( BuildContext context, GoRouterState state, ); +/// The widget builder for [ShellRoute]. +typedef ShellRouteBuilder = Widget Function( + BuildContext context, + GoRouterState state, + Widget child, +); + +/// The page builder for [ShellRoute]. +typedef ShellRoutePageBuilder = Page Function( + BuildContext context, + GoRouterState state, + Widget child, +); + /// The signature of the navigatorBuilder callback. typedef GoRouterNavigatorBuilder = Widget Function( BuildContext context, diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 36f314565799..119e415b2395 100644 --- a/packages/go_router/pubspec.yaml +++ b/packages/go_router/pubspec.yaml @@ -1,7 +1,7 @@ name: go_router description: A declarative router for Flutter based on Navigation 2 supporting deep linking, data-driven routes and more -version: 4.4.1 +version: 4.5.0 repository: https://github.com/flutter/packages/tree/main/packages/go_router issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22 diff --git a/packages/go_router/test/builder_test.dart b/packages/go_router/test/builder_test.dart new file mode 100644 index 000000000000..19830b04288d --- /dev/null +++ b/packages/go_router/test/builder_test.dart @@ -0,0 +1,358 @@ +// Copyright 2013 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 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/src/builder.dart'; +import 'package:go_router/src/configuration.dart'; +import 'package:go_router/src/match.dart'; +import 'package:go_router/src/matching.dart'; + +void main() { + group('RouteBuilder', () { + testWidgets('Builds GoRoute', (WidgetTester tester) async { + final RouteConfiguration config = RouteConfiguration( + routes: [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) { + return _DetailsScreen(); + }, + ), + ], + redirectLimit: 10, + topRedirect: (GoRouterState state) { + return null; + }, + navigatorKey: GlobalKey(), + ); + + final RouteMatchList matches = RouteMatchList([ + RouteMatch( + route: config.routes.first as GoRoute, + subloc: '/', + fullpath: '/', + encodedParams: {}, + queryParams: {}, + queryParametersAll: >{}, + extra: null, + error: null, + ), + ]); + + await tester.pumpWidget( + _BuilderTestWidget( + routeConfiguration: config, + matches: matches, + ), + ); + + expect(find.byType(_DetailsScreen), findsOneWidget); + }); + + testWidgets('Builds ShellRoute', (WidgetTester tester) async { + final RouteConfiguration config = RouteConfiguration( + routes: [ + ShellRoute( + builder: + (BuildContext context, GoRouterState state, Widget child) { + return _DetailsScreen(); + }, + routes: [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) { + return _DetailsScreen(); + }, + ), + ]), + ], + redirectLimit: 10, + topRedirect: (GoRouterState state) { + return null; + }, + navigatorKey: GlobalKey(), + ); + + final RouteMatchList matches = RouteMatchList([ + RouteMatch( + route: config.routes.first, + subloc: '/', + fullpath: '/', + encodedParams: {}, + queryParams: {}, + queryParametersAll: >{}, + extra: null, + error: null, + ), + ]); + + await tester.pumpWidget( + _BuilderTestWidget( + routeConfiguration: config, + matches: matches, + ), + ); + + expect(find.byType(_DetailsScreen), findsOneWidget); + }); + + testWidgets('Uses the correct navigatorKey', (WidgetTester tester) async { + final GlobalKey rootNavigatorKey = + GlobalKey(); + final RouteConfiguration config = RouteConfiguration( + navigatorKey: rootNavigatorKey, + routes: [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) { + return _DetailsScreen(); + }, + ), + ], + redirectLimit: 10, + topRedirect: (GoRouterState state) { + return null; + }, + ); + + final RouteMatchList matches = RouteMatchList([ + RouteMatch( + route: config.routes.first as GoRoute, + subloc: '/', + fullpath: '/', + encodedParams: {}, + queryParams: {}, + queryParametersAll: >{}, + extra: null, + error: null, + ), + ]); + + await tester.pumpWidget( + _BuilderTestWidget( + routeConfiguration: config, + matches: matches, + ), + ); + + expect(find.byKey(rootNavigatorKey), findsOneWidget); + }); + + testWidgets('Builds a Navigator for ShellRoute', + (WidgetTester tester) async { + final GlobalKey rootNavigatorKey = + GlobalKey(debugLabel: 'root'); + final GlobalKey shellNavigatorKey = + GlobalKey(debugLabel: 'shell'); + final RouteConfiguration config = RouteConfiguration( + navigatorKey: rootNavigatorKey, + routes: [ + ShellRoute( + builder: (BuildContext context, GoRouterState state, Widget child) { + return _HomeScreen( + child: child, + ); + }, + navigatorKey: shellNavigatorKey, + routes: [ + GoRoute( + path: '/details', + builder: (BuildContext context, GoRouterState state) { + return _DetailsScreen(); + }, + ), + ], + ), + ], + redirectLimit: 10, + topRedirect: (GoRouterState state) { + return null; + }, + ); + + final RouteMatchList matches = RouteMatchList([ + RouteMatch( + route: config.routes.first, + subloc: '', + fullpath: '', + encodedParams: {}, + queryParams: {}, + queryParametersAll: >{}, + extra: null, + error: null, + ), + RouteMatch( + route: config.routes.first.routes.first, + subloc: '/details', + fullpath: '/details', + encodedParams: {}, + queryParams: {}, + queryParametersAll: >{}, + extra: null, + error: null, + ), + ]); + + await tester.pumpWidget( + _BuilderTestWidget( + routeConfiguration: config, + matches: matches, + ), + ); + + expect(find.byType(_HomeScreen, skipOffstage: false), findsOneWidget); + expect(find.byType(_DetailsScreen), findsOneWidget); + expect(find.byKey(rootNavigatorKey), findsOneWidget); + expect(find.byKey(shellNavigatorKey), findsOneWidget); + }); + + testWidgets('Builds a Navigator for ShellRoute with parentNavigatorKey', + (WidgetTester tester) async { + final GlobalKey rootNavigatorKey = + GlobalKey(debugLabel: 'root'); + final GlobalKey shellNavigatorKey = + GlobalKey(debugLabel: 'shell'); + final RouteConfiguration config = RouteConfiguration( + navigatorKey: rootNavigatorKey, + routes: [ + ShellRoute( + builder: (BuildContext context, GoRouterState state, Widget child) { + return _HomeScreen( + child: child, + ); + }, + navigatorKey: shellNavigatorKey, + routes: [ + GoRoute( + path: '/a', + builder: (BuildContext context, GoRouterState state) { + return _DetailsScreen(); + }, + routes: [ + GoRoute( + path: 'details', + builder: (BuildContext context, GoRouterState state) { + return _DetailsScreen(); + }, + // This screen should stack onto the root navigator. + parentNavigatorKey: rootNavigatorKey, + ), + ], + ), + ], + ), + ], + redirectLimit: 10, + topRedirect: (GoRouterState state) { + return null; + }, + ); + + final RouteMatchList matches = RouteMatchList([ + RouteMatch( + route: config.routes.first.routes.first as GoRoute, + subloc: '/a/details', + fullpath: '/a/details', + encodedParams: {}, + queryParams: {}, + queryParametersAll: >{}, + extra: null, + error: null, + ), + ]); + + await tester.pumpWidget( + _BuilderTestWidget( + routeConfiguration: config, + matches: matches, + ), + ); + + // The Details screen should be visible, but the HomeScreen should be + // offstage (underneath) the DetailsScreen. + expect(find.byType(_HomeScreen), findsNothing); + expect(find.byType(_DetailsScreen), findsOneWidget); + }); + }); +} + +class _HomeScreen extends StatelessWidget { + const _HomeScreen({ + required this.child, + }); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + children: [ + const Text('Home Screen'), + Expanded(child: child), + ], + ), + ); + } +} + +class _DetailsScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Text('Details Screen'), + ); + } +} + +class _BuilderTestWidget extends StatelessWidget { + _BuilderTestWidget({ + required this.routeConfiguration, + required this.matches, + }) : builder = _routeBuilder(routeConfiguration); + + final RouteConfiguration routeConfiguration; + final RouteBuilder builder; + final RouteMatchList matches; + + /// Builds a [RouteBuilder] for tests + static RouteBuilder _routeBuilder(RouteConfiguration configuration) { + return RouteBuilder( + configuration: configuration, + builderWithNav: ( + BuildContext context, + GoRouterState state, + Navigator navigator, + ) { + return navigator; + }, + errorPageBuilder: ( + BuildContext context, + GoRouterState state, + ) { + return MaterialPage( + child: Text('Error: ${state.error}'), + ); + }, + errorBuilder: ( + BuildContext context, + GoRouterState state, + ) { + return Text('Error: ${state.error}'); + }, + restorationScopeId: null, + observers: [], + ); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: builder.tryBuild( + context, matches, () {}, false, routeConfiguration.navigatorKey), + // builder: (context, child) => , + ); + } +} diff --git a/packages/go_router/test/configuration_test.dart b/packages/go_router/test/configuration_test.dart new file mode 100644 index 000000000000..b44d50898295 --- /dev/null +++ b/packages/go_router/test/configuration_test.dart @@ -0,0 +1,464 @@ +// Copyright 2013 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 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/src/configuration.dart'; + +void main() { + group('RouteConfiguration', () { + test('throws when parentNavigatorKey is not an ancestor', () { + final GlobalKey root = + GlobalKey(debugLabel: 'root'); + final GlobalKey a = + GlobalKey(debugLabel: 'a'); + final GlobalKey b = + GlobalKey(debugLabel: 'b'); + + expect( + () { + RouteConfiguration( + navigatorKey: root, + routes: [ + GoRoute( + path: '/a', + builder: _mockScreenBuilder, + routes: [ + ShellRoute( + navigatorKey: a, + builder: _mockShellBuilder, + routes: [ + GoRoute( + path: 'b', + builder: _mockScreenBuilder, + ) + ], + ), + ShellRoute( + navigatorKey: b, + builder: _mockShellBuilder, + routes: [ + GoRoute( + path: 'c', + parentNavigatorKey: a, + builder: _mockScreenBuilder, + ) + ], + ), + ], + ), + ], + redirectLimit: 10, + topRedirect: (GoRouterState state) { + return null; + }, + ); + }, + throwsAssertionError, + ); + }); + + test('throws when ShellRoute has no children', () { + final GlobalKey root = + GlobalKey(debugLabel: 'root'); + final List shellRouteChildren = []; + expect( + () { + RouteConfiguration( + navigatorKey: root, + routes: [ + ShellRoute(routes: shellRouteChildren), + ], + redirectLimit: 10, + topRedirect: (GoRouterState state) { + return null; + }, + ); + }, + throwsAssertionError, + ); + }); + + test( + 'throws when there is a GoRoute ancestor with a different parentNavigatorKey', + () { + final GlobalKey root = + GlobalKey(debugLabel: 'root'); + final GlobalKey shell = + GlobalKey(debugLabel: 'shell'); + expect( + () { + RouteConfiguration( + navigatorKey: root, + routes: [ + ShellRoute( + navigatorKey: shell, + routes: [ + GoRoute( + path: '/', + builder: _mockScreenBuilder, + parentNavigatorKey: root, + routes: [ + GoRoute( + path: 'a', + builder: _mockScreenBuilder, + parentNavigatorKey: shell, + ), + ], + ), + ], + ), + ], + redirectLimit: 10, + topRedirect: (GoRouterState state) { + return null; + }, + ); + }, + throwsAssertionError, + ); + }); + + test( + 'Does not throw with valid parentNavigatorKey configuration', + () { + final GlobalKey root = + GlobalKey(debugLabel: 'root'); + final GlobalKey shell = + GlobalKey(debugLabel: 'shell'); + final GlobalKey shell2 = + GlobalKey(debugLabel: 'shell2'); + RouteConfiguration( + navigatorKey: root, + routes: [ + ShellRoute( + navigatorKey: shell, + routes: [ + GoRoute( + path: '/', + builder: _mockScreenBuilder, + routes: [ + GoRoute( + path: 'a', + builder: _mockScreenBuilder, + parentNavigatorKey: root, + routes: [ + ShellRoute( + navigatorKey: shell2, + routes: [ + GoRoute( + path: 'b', + builder: _mockScreenBuilder, + routes: [ + GoRoute( + path: 'b', + builder: _mockScreenBuilder, + parentNavigatorKey: shell2, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + redirectLimit: 10, + topRedirect: (GoRouterState state) { + return null; + }, + ); + }, + ); + + test( + 'Throws when parentNavigatorKeys are overlapping', + () { + final GlobalKey root = + GlobalKey(debugLabel: 'root'); + final GlobalKey shell = + GlobalKey(debugLabel: 'shell'); + expect( + () => RouteConfiguration( + navigatorKey: root, + routes: [ + ShellRoute( + navigatorKey: shell, + routes: [ + GoRoute( + path: '/', + builder: _mockScreenBuilder, + routes: [ + GoRoute( + path: 'a', + builder: _mockScreenBuilder, + parentNavigatorKey: root, + routes: [ + GoRoute( + path: 'b', + builder: _mockScreenBuilder, + routes: [ + GoRoute( + path: 'b', + builder: _mockScreenBuilder, + parentNavigatorKey: shell, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + redirectLimit: 10, + topRedirect: (GoRouterState state) { + return null; + }, + ), + throwsAssertionError, + ); + }, + ); + + test( + 'Does not throw when parentNavigatorKeys are overlapping correctly', + () { + final GlobalKey root = + GlobalKey(debugLabel: 'root'); + final GlobalKey shell = + GlobalKey(debugLabel: 'shell'); + RouteConfiguration( + navigatorKey: root, + routes: [ + ShellRoute( + navigatorKey: shell, + routes: [ + GoRoute( + path: '/', + builder: _mockScreenBuilder, + routes: [ + GoRoute( + path: 'a', + builder: _mockScreenBuilder, + parentNavigatorKey: shell, + routes: [ + GoRoute( + path: 'b', + builder: _mockScreenBuilder, + routes: [ + GoRoute( + path: 'b', + builder: _mockScreenBuilder, + parentNavigatorKey: root, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + redirectLimit: 10, + topRedirect: (GoRouterState state) { + return null; + }, + ); + }, + ); + + test( + 'throws when a GoRoute with a different parentNavigatorKey ' + 'exists between a GoRoute with a parentNavigatorKey and ' + 'its ShellRoute ancestor', + () { + final GlobalKey root = + GlobalKey(debugLabel: 'root'); + final GlobalKey shell = + GlobalKey(debugLabel: 'shell'); + final GlobalKey shell2 = + GlobalKey(debugLabel: 'shell2'); + expect( + () => RouteConfiguration( + navigatorKey: root, + routes: [ + ShellRoute( + navigatorKey: shell, + routes: [ + GoRoute( + path: '/', + builder: _mockScreenBuilder, + routes: [ + GoRoute( + path: 'a', + parentNavigatorKey: root, + builder: _mockScreenBuilder, + routes: [ + ShellRoute( + navigatorKey: shell2, + routes: [ + GoRoute( + path: 'b', + builder: _mockScreenBuilder, + routes: [ + GoRoute( + path: 'c', + builder: _mockScreenBuilder, + parentNavigatorKey: shell, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + redirectLimit: 10, + topRedirect: (GoRouterState state) { + return null; + }, + ), + throwsAssertionError, + ); + }, + ); + }); + + test( + 'Does not throw with valid parentNavigatorKey configuration', + () { + final GlobalKey root = + GlobalKey(debugLabel: 'root'); + final GlobalKey shell = + GlobalKey(debugLabel: 'shell'); + final GlobalKey shell2 = + GlobalKey(debugLabel: 'shell2'); + RouteConfiguration( + navigatorKey: root, + routes: [ + ShellRoute( + navigatorKey: shell, + routes: [ + GoRoute( + path: '/', + builder: _mockScreenBuilder, + routes: [ + GoRoute( + path: 'a', + builder: _mockScreenBuilder, + parentNavigatorKey: root, + routes: [ + ShellRoute( + navigatorKey: shell2, + routes: [ + GoRoute( + path: 'b', + builder: _mockScreenBuilder, + routes: [ + GoRoute( + path: 'b', + builder: _mockScreenBuilder, + parentNavigatorKey: shell2, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + redirectLimit: 10, + topRedirect: (GoRouterState state) { + return null; + }, + ); + }, + ); + + test('throws when ShellRoute contains a ShellRoute', () { + final GlobalKey root = + GlobalKey(debugLabel: 'root'); + expect( + () { + RouteConfiguration( + navigatorKey: root, + routes: [ + ShellRoute(routes: [ + ShellRoute( + routes: [ + GoRoute( + path: '/a', + builder: _mockScreenBuilder, + ), + ], + ), + ]), + ], + redirectLimit: 10, + topRedirect: (GoRouterState state) { + return null; + }, + ); + }, + throwsAssertionError, + ); + }); + + test('throws when ShellRoute contains a GoRoute with a parentNavigatorKey', + () { + final GlobalKey root = + GlobalKey(debugLabel: 'root'); + expect( + () { + RouteConfiguration( + navigatorKey: root, + routes: [ + ShellRoute( + routes: [ + GoRoute( + path: '/a', + builder: _mockScreenBuilder, + parentNavigatorKey: root, + ), + ], + ), + ], + redirectLimit: 10, + topRedirect: (GoRouterState state) { + return null; + }, + ); + }, + throwsAssertionError, + ); + }); +} + +class _MockScreen extends StatelessWidget { + const _MockScreen({super.key}); + + @override + Widget build(BuildContext context) => const Placeholder(); +} + +Widget _mockScreenBuilder(BuildContext context, GoRouterState state) => + _MockScreen(key: state.pageKey); + +Widget _mockShellBuilder( + BuildContext context, GoRouterState state, Widget child) => + child; diff --git a/packages/go_router/test/delegate_test.dart b/packages/go_router/test/delegate_test.dart index 75025a982502..d227c3f7ccf0 100644 --- a/packages/go_router/test/delegate_test.dart +++ b/packages/go_router/test/delegate_test.dart @@ -95,6 +95,7 @@ void main() { (WidgetTester tester) async { final GoRouter goRouter = await createGoRouter(tester); + await tester.pumpAndSettle(); expect(goRouter.routerDelegate.matches.matches.length, 1); expect(goRouter.routerDelegate.canPop(), false); }, @@ -103,8 +104,9 @@ void main() { 'It should return true if there is more than 1 match in the stack', (WidgetTester tester) async { final GoRouter goRouter = await createGoRouter(tester) - ..push('/error'); + ..push('/a'); + await tester.pumpAndSettle(); expect(goRouter.routerDelegate.matches.matches.length, 2); expect(goRouter.routerDelegate.canPop(), true); }, @@ -209,7 +211,7 @@ void main() { '/page-1', ) .having( - (RouteMatch match) => match.route.name, + (RouteMatch match) => (match.route as GoRoute).name, 'match.route.name', 'page1', ), diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart index 20346881f12f..0c66068129de 100644 --- a/packages/go_router/test/go_router_test.dart +++ b/packages/go_router/test/go_router_test.dart @@ -8,9 +8,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/go_router.dart'; import 'package:go_router/src/delegate.dart'; import 'package:go_router/src/match.dart'; +import 'package:go_router/src/misc/extensions.dart'; +import 'package:go_router/src/route.dart'; +import 'package:go_router/src/router.dart'; +import 'package:go_router/src/state.dart'; import 'package:logging/logging.dart'; import 'test_helpers.dart'; @@ -187,7 +190,8 @@ void main() { testWidgets('match top level route when location has trailing / (2)', (WidgetTester tester) async { final List routes = [ - GoRoute(path: '/profile', redirect: (_) => '/profile/foo'), + GoRoute( + path: '/profile', builder: dummy, redirect: (_) => '/profile/foo'), GoRoute(path: '/profile/:kind', builder: dummy), ]; @@ -202,7 +206,8 @@ void main() { testWidgets('match top level route when location has trailing / (3)', (WidgetTester tester) async { final List routes = [ - GoRoute(path: '/profile', redirect: (_) => '/profile/foo'), + GoRoute( + path: '/profile', builder: dummy, redirect: (_) => '/profile/foo'), GoRoute(path: '/profile/:kind', builder: dummy), ]; @@ -492,6 +497,120 @@ void main() { expect(matches, hasLength(1)); expect(router.screenFor(matches.first).runtimeType, DummyScreen); }); + + testWidgets('Handles the Android back button correctly', + (WidgetTester tester) async { + final List routes = [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) { + return const Scaffold( + body: Text('Screen A'), + ); + }, + routes: [ + GoRoute( + path: 'b', + builder: (BuildContext context, GoRouterState state) { + return const Scaffold( + body: Text('Screen B'), + ); + }, + ), + ], + ), + ]; + + await createRouter(routes, tester, initialLocation: '/b'); + expect(find.text('Screen A'), findsNothing); + expect(find.text('Screen B'), findsOneWidget); + + await simulateAndroidBackButton(); + await tester.pumpAndSettle(); + expect(find.text('Screen A'), findsOneWidget); + expect(find.text('Screen B'), findsNothing); + }); + + testWidgets('Handles the Android back button correctly with ShellRoute', + (WidgetTester tester) async { + final GlobalKey rootNavigatorKey = + GlobalKey(); + + final List routes = [ + ShellRoute( + builder: (BuildContext context, GoRouterState state, Widget child) { + return Scaffold( + appBar: AppBar(title: const Text('Shell')), + body: child, + ); + }, + routes: [ + GoRoute( + path: '/a', + builder: (BuildContext context, GoRouterState state) { + return const Scaffold( + body: Text('Screen A'), + ); + }, + routes: [ + GoRoute( + path: 'b', + builder: (BuildContext context, GoRouterState state) { + return const Scaffold( + body: Text('Screen B'), + ); + }, + routes: [ + GoRoute( + path: 'c', + builder: (BuildContext context, GoRouterState state) { + return const Scaffold( + body: Text('Screen C'), + ); + }, + routes: [ + GoRoute( + path: 'd', + parentNavigatorKey: rootNavigatorKey, + builder: (BuildContext context, GoRouterState state) { + return const Scaffold( + body: Text('Screen D'), + ); + }, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ]; + + await createRouter(routes, tester, + initialLocation: '/a/b/c/d', navigatorKey: rootNavigatorKey); + expect(find.text('Shell'), findsNothing); + expect(find.text('Screen A'), findsNothing); + expect(find.text('Screen B'), findsNothing); + expect(find.text('Screen C'), findsNothing); + expect(find.text('Screen D'), findsOneWidget); + + await simulateAndroidBackButton(); + await tester.pumpAndSettle(); + expect(find.text('Shell'), findsOneWidget); + expect(find.text('Screen A'), findsNothing); + expect(find.text('Screen B'), findsNothing); + expect(find.text('Screen C'), findsOneWidget); + expect(find.text('Screen D'), findsNothing); + + await simulateAndroidBackButton(); + await tester.pumpAndSettle(); + expect(find.text('Shell'), findsOneWidget); + expect(find.text('Screen A'), findsNothing); + expect(find.text('Screen B'), findsOneWidget); + expect(find.text('Screen C'), findsNothing); + }); }); group('named routes', () { @@ -713,6 +832,7 @@ void main() { final List routes = [ GoRoute( path: '/', + builder: dummy, redirect: (_) => '/family/f2', ), GoRoute( @@ -965,10 +1085,12 @@ void main() { [ GoRoute( path: '/', + builder: dummy, redirect: (GoRouterState state) => '/login', ), GoRoute( path: '/login', + builder: dummy, redirect: (GoRouterState state) => '/', ), ], @@ -988,6 +1110,7 @@ void main() { [ GoRoute( path: '/login', + builder: dummy, redirect: (GoRouterState state) => '/', ), ], @@ -1034,6 +1157,7 @@ void main() { ), GoRoute( path: '/dummy', + builder: dummy, redirect: (GoRouterState state) => '/', ), ]; @@ -1193,7 +1317,7 @@ void main() { expect(state.extra, isNotNull); return null; }, - routes: [], + routes: const [], ), ], ), @@ -1254,6 +1378,7 @@ void main() { ), GoRoute( path: '/dummy', + builder: dummy, redirect: (GoRouterState state) => '/', ), ]; @@ -1884,77 +2009,483 @@ void main() { }); }); - testWidgets('pop triggers pop on routerDelegate', - (WidgetTester tester) async { - final GoRouter router = await createGoRouter(tester) - ..push('/error'); - router.routerDelegate.addListener(expectAsync0(() {})); - router.pop(); - await tester.pump(); + group('ShellRoute', () { + testWidgets('defaultRoute', (WidgetTester tester) async { + final List routes = [ + ShellRoute( + builder: (BuildContext context, GoRouterState state, Widget child) { + return Scaffold( + body: child, + ); + }, + routes: [ + GoRoute( + path: '/a', + builder: (BuildContext context, GoRouterState state) { + return const Scaffold( + body: Text('Screen A'), + ); + }, + ), + GoRoute( + path: '/b', + builder: (BuildContext context, GoRouterState state) { + return const Scaffold( + body: Text('Screen B'), + ); + }, + ), + ], + ), + ]; + + await createRouter(routes, tester, initialLocation: '/b'); + expect(find.text('Screen B'), findsOneWidget); + }); + + testWidgets( + 'Pops from the correct Navigator when the Android back button is pressed', + (WidgetTester tester) async { + final List routes = [ + ShellRoute( + builder: (BuildContext context, GoRouterState state, Widget child) { + return Scaffold( + body: Column( + children: [ + const Text('Screen A'), + Expanded(child: child), + ], + ), + ); + }, + routes: [ + GoRoute( + path: '/b', + builder: (BuildContext context, GoRouterState state) { + return const Scaffold( + body: Text('Screen B'), + ); + }, + routes: [ + GoRoute( + path: 'c', + builder: (BuildContext context, GoRouterState state) { + return const Scaffold( + body: Text('Screen C'), + ); + }, + ), + ], + ), + ], + ), + ]; + + await createRouter(routes, tester, initialLocation: '/b/c'); + expect(find.text('Screen A'), findsOneWidget); + expect(find.text('Screen B'), findsNothing); + expect(find.text('Screen C'), findsOneWidget); + + await simulateAndroidBackButton(); + await tester.pumpAndSettle(); + + expect(find.text('Screen A'), findsOneWidget); + expect(find.text('Screen B'), findsOneWidget); + expect(find.text('Screen C'), findsNothing); + }); + + testWidgets( + 'Pops from the correct navigator when a sub-route is placed on ' + 'the root Navigator', (WidgetTester tester) async { + final GlobalKey rootNavigatorKey = + GlobalKey(); + final GlobalKey shellNavigatorKey = + GlobalKey(); + + final List routes = [ + ShellRoute( + navigatorKey: shellNavigatorKey, + builder: (BuildContext context, GoRouterState state, Widget child) { + return Scaffold( + body: Column( + children: [ + const Text('Screen A'), + Expanded(child: child), + ], + ), + ); + }, + routes: [ + GoRoute( + path: '/b', + builder: (BuildContext context, GoRouterState state) { + return const Scaffold( + body: Text('Screen B'), + ); + }, + routes: [ + GoRoute( + path: 'c', + parentNavigatorKey: rootNavigatorKey, + builder: (BuildContext context, GoRouterState state) { + return const Scaffold( + body: Text('Screen C'), + ); + }, + ), + ], + ), + ], + ), + ]; + + await createRouter(routes, tester, + initialLocation: '/b/c', navigatorKey: rootNavigatorKey); + expect(find.text('Screen A'), findsNothing); + expect(find.text('Screen B'), findsNothing); + expect(find.text('Screen C'), findsOneWidget); + + await simulateAndroidBackButton(); + await tester.pumpAndSettle(); + + expect(find.text('Screen A'), findsOneWidget); + expect(find.text('Screen B'), findsOneWidget); + expect(find.text('Screen C'), findsNothing); + }); }); - testWidgets('didPush notifies listeners', (WidgetTester tester) async { - await createGoRouter(tester) - ..addListener(expectAsync0(() {})) - ..didPush( - MaterialPageRoute(builder: (_) => const Text('Current route')), - MaterialPageRoute(builder: (_) => const Text('Previous route')), + group('Imperative navigation', () { + testWidgets('pop triggers pop on routerDelegate', + (WidgetTester tester) async { + final GoRouter router = await createGoRouter(tester) + ..push('/error'); + router.routerDelegate.addListener(expectAsync0(() {})); + router.pop(); + await tester.pump(); + }); + + testWidgets('didPush notifies listeners', (WidgetTester tester) async { + await createGoRouter(tester) + ..addListener(expectAsync0(() {})) + ..didPush( + MaterialPageRoute(builder: (_) => const Text('Current route')), + MaterialPageRoute(builder: (_) => const Text('Previous route')), + ); + }); + + testWidgets('didPop notifies listeners', (WidgetTester tester) async { + await createGoRouter(tester) + ..addListener(expectAsync0(() {})) + ..didPop( + MaterialPageRoute(builder: (_) => const Text('Current route')), + MaterialPageRoute(builder: (_) => const Text('Previous route')), + ); + }); + + testWidgets('didRemove notifies listeners', (WidgetTester tester) async { + await createGoRouter(tester) + ..addListener(expectAsync0(() {})) + ..didRemove( + MaterialPageRoute(builder: (_) => const Text('Current route')), + MaterialPageRoute(builder: (_) => const Text('Previous route')), + ); + }); + + testWidgets('didReplace notifies listeners', (WidgetTester tester) async { + await createGoRouter(tester) + ..addListener(expectAsync0(() {})) + ..didReplace( + newRoute: MaterialPageRoute( + builder: (_) => const Text('Current route'), + ), + oldRoute: MaterialPageRoute( + builder: (_) => const Text('Previous route'), + ), + ); + }); + + group('canPop', () { + testWidgets( + 'It should return false if Navigator.canPop() returns false.', + (WidgetTester tester) async { + final GlobalKey navigatorKey = + GlobalKey(); + final GoRouter router = GoRouter( + initialLocation: '/', + navigatorKey: navigatorKey, + routes: [ + GoRoute( + path: '/', + builder: (BuildContext context, _) { + return Scaffold( + body: TextButton( + onPressed: () async { + navigatorKey.currentState!.push( + MaterialPageRoute( + builder: (BuildContext context) { + return const Scaffold( + body: Text('pageless route'), + ); + }, + ), + ); + }, + child: const Text('Push'), + ), + ); + }, + ), + GoRoute(path: '/a', builder: (_, __) => const DummyScreen()), + ], + ); + + await tester.pumpWidget( + MaterialApp.router( + routeInformationProvider: router.routeInformationProvider, + routeInformationParser: router.routeInformationParser, + routerDelegate: router.routerDelegate), + ); + + expect(router.canPop(), false); + + await tester.tap(find.text('Push')); + await tester.pumpAndSettle(); + + expect( + find.text('pageless route', skipOffstage: false), findsOneWidget); + expect(router.canPop(), true); + }, ); - }); - testWidgets('didPop notifies listeners', (WidgetTester tester) async { - await createGoRouter(tester) - ..addListener(expectAsync0(() {})) - ..didPop( - MaterialPageRoute(builder: (_) => const Text('Current route')), - MaterialPageRoute(builder: (_) => const Text('Previous route')), + testWidgets( + 'It checks if ShellRoute navigators can pop', + (WidgetTester tester) async { + final GlobalKey shellNavigatorKey = + GlobalKey(); + final GoRouter router = GoRouter( + initialLocation: '/a', + routes: [ + ShellRoute( + navigatorKey: shellNavigatorKey, + builder: + (BuildContext context, GoRouterState state, Widget child) { + return Scaffold( + appBar: AppBar(title: const Text('Shell')), + body: child, + ); + }, + routes: [ + GoRoute( + path: '/a', + builder: (BuildContext context, _) { + return Scaffold( + body: TextButton( + onPressed: () async { + shellNavigatorKey.currentState!.push( + MaterialPageRoute( + builder: (BuildContext context) { + return const Scaffold( + body: Text('pageless route'), + ); + }, + ), + ); + }, + child: const Text('Push'), + ), + ); + }, + ), + ], + ), + ], + ); + + await tester.pumpWidget( + MaterialApp.router( + routeInformationProvider: router.routeInformationProvider, + routeInformationParser: router.routeInformationParser, + routerDelegate: router.routerDelegate), + ); + + expect(router.canPop(), false); + expect(find.text('Push'), findsOneWidget); + + await tester.tap(find.text('Push')); + await tester.pumpAndSettle(); + + expect( + find.text('pageless route', skipOffstage: false), findsOneWidget); + expect(router.canPop(), true); + }, ); - }); - testWidgets('didRemove notifies listeners', (WidgetTester tester) async { - await createGoRouter(tester) - ..addListener(expectAsync0(() {})) - ..didRemove( - MaterialPageRoute(builder: (_) => const Text('Current route')), - MaterialPageRoute(builder: (_) => const Text('Previous route')), + testWidgets( + 'It checks if ShellRoute navigators can pop', + (WidgetTester tester) async { + final GlobalKey shellNavigatorKey = + GlobalKey(); + final GoRouter router = GoRouter( + initialLocation: '/a', + routes: [ + ShellRoute( + navigatorKey: shellNavigatorKey, + builder: + (BuildContext context, GoRouterState state, Widget child) { + return Scaffold( + appBar: AppBar(title: const Text('Shell')), + body: child, + ); + }, + routes: [ + GoRoute( + path: '/a', + builder: (BuildContext context, _) { + return Scaffold( + body: TextButton( + onPressed: () async { + shellNavigatorKey.currentState!.push( + MaterialPageRoute( + builder: (BuildContext context) { + return const Scaffold( + body: Text('pageless route'), + ); + }, + ), + ); + }, + child: const Text('Push'), + ), + ); + }, + ), + ], + ), + ], + ); + + await tester.pumpWidget( + MaterialApp.router( + routeInformationProvider: router.routeInformationProvider, + routeInformationParser: router.routeInformationParser, + routerDelegate: router.routerDelegate), + ); + + expect(router.canPop(), false); + expect(find.text('Push'), findsOneWidget); + + await tester.tap(find.text('Push')); + await tester.pumpAndSettle(); + + expect( + find.text('pageless route', skipOffstage: false), findsOneWidget); + expect(router.canPop(), true); + }, ); - }); + }); + group('pop', () { + testWidgets( + 'Should pop from the correct navigator when parentNavigatorKey is set', + (WidgetTester tester) async { + final GlobalKey root = + GlobalKey(debugLabel: 'root'); + final GlobalKey shell = + GlobalKey(debugLabel: 'shell'); + + final GoRouter router = GoRouter( + initialLocation: '/a/b', + navigatorKey: root, + routes: [ + GoRoute( + path: '/', + builder: (BuildContext context, _) { + return const Scaffold( + body: Text('Home'), + ); + }, + routes: [ + ShellRoute( + navigatorKey: shell, + builder: (BuildContext context, GoRouterState state, + Widget child) { + return Scaffold( + body: Center( + child: Column( + children: [ + const Text('Shell'), + Expanded(child: child), + ], + ), + ), + ); + }, + routes: [ + GoRoute( + path: 'a', + builder: (_, __) => const Text('A Screen'), + routes: [ + GoRoute( + parentNavigatorKey: root, + path: 'b', + builder: (_, __) => const Text('B Screen'), + ), + ], + ), + ], + ), + ], + ), + ], + ); - testWidgets('didReplace notifies listeners', (WidgetTester tester) async { - await createGoRouter(tester) - ..addListener(expectAsync0(() {})) - ..didReplace( - newRoute: MaterialPageRoute( - builder: (_) => const Text('Current route'), - ), - oldRoute: MaterialPageRoute( - builder: (_) => const Text('Previous route'), - ), + await tester.pumpWidget( + MaterialApp.router( + routeInformationProvider: router.routeInformationProvider, + routeInformationParser: router.routeInformationParser, + routerDelegate: router.routerDelegate), + ); + + expect(router.canPop(), isTrue); + expect(find.text('B Screen'), findsOneWidget); + expect(find.text('A Screen'), findsNothing); + expect(find.text('Shell'), findsNothing); + expect(find.text('Home'), findsNothing); + router.pop(); + await tester.pumpAndSettle(); + expect(find.text('A Screen'), findsOneWidget); + expect(find.text('Shell'), findsOneWidget); + expect(router.canPop(), isTrue); + router.pop(); + await tester.pumpAndSettle(); + expect(find.text('Home'), findsOneWidget); + expect(find.text('Shell'), findsNothing); + }, ); - }); + }); - testWidgets('uses navigatorBuilder when provided', - (WidgetTester tester) async { - final Func3 navigatorBuilder = - expectAsync3(fakeNavigationBuilder); - final GoRouter router = GoRouter( - initialLocation: '/', - routes: [ - GoRoute(path: '/', builder: (_, __) => const DummyStatefulWidget()), - GoRoute( - path: '/error', - builder: (_, __) => TestErrorScreen(TestFailure('exception')), - ), - ], - navigatorBuilder: navigatorBuilder, - ); + testWidgets('uses navigatorBuilder when provided', + (WidgetTester tester) async { + final Func3 + navigatorBuilder = expectAsync3(fakeNavigationBuilder); + final GoRouter router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute(path: '/', builder: (_, __) => const DummyStatefulWidget()), + GoRoute( + path: '/error', + builder: (_, __) => TestErrorScreen(TestFailure('exception')), + ), + ], + navigatorBuilder: navigatorBuilder, + ); - final GoRouterDelegate delegate = router.routerDelegate; - delegate.builder.builderWithNav( - DummyBuildContext(), - GoRouterState(router.routeConfiguration, - location: '/foo', subloc: '/bar', name: 'baz'), - const Navigator(), - ); + final GoRouterDelegate delegate = router.routerDelegate; + delegate.builder.builderWithNav( + DummyBuildContext(), + GoRouterState(router.routeConfiguration, + location: '/foo', subloc: '/bar', name: 'baz'), + const Navigator(), + ); + }); }); } diff --git a/packages/go_router/test/match_test.dart b/packages/go_router/test/match_test.dart new file mode 100644 index 000000000000..69e6eaca1ae0 --- /dev/null +++ b/packages/go_router/test/match_test.dart @@ -0,0 +1,123 @@ +// Copyright 2013 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 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:go_router/src/match.dart'; + +void main() { + group('RouteMatch', () { + test('simple', () { + final GoRoute route = GoRoute( + path: '/users/:userId', + builder: _builder, + ); + final RouteMatch? match = RouteMatch.match( + route: route, + restLoc: '/users/123', + parentSubloc: '', + fullpath: '/users/:userId', + queryParams: {}, + extra: const _Extra('foo'), + queryParametersAll: >{ + 'bar': ['baz', 'biz'], + }, + ); + if (match == null) { + fail('Null match'); + } + expect(match.route, route); + expect(match.subloc, '/users/123'); + expect(match.fullpath, '/users/:userId'); + expect(match.encodedParams['userId'], '123'); + expect(match.queryParams['foo'], isNull); + expect(match.queryParametersAll['bar'], ['baz', 'biz']); + expect(match.extra, const _Extra('foo')); + expect(match.error, isNull); + expect(match.pageKey, isNull); + expect(match.fullUriString, '/users/123?bar=baz&bar=biz'); + }); + test('subloc', () { + final GoRoute route = GoRoute( + path: 'users/:userId', + builder: _builder, + ); + final RouteMatch? match = RouteMatch.match( + route: route, + restLoc: 'users/123', + parentSubloc: '/home', + fullpath: '/home/users/:userId', + queryParams: { + 'foo': 'bar', + }, + queryParametersAll: >{ + 'foo': ['bar'], + }, + extra: const _Extra('foo'), + ); + if (match == null) { + fail('Null match'); + } + expect(match.route, route); + expect(match.subloc, '/home/users/123'); + expect(match.fullpath, '/home/users/:userId'); + expect(match.encodedParams['userId'], '123'); + expect(match.queryParams['foo'], 'bar'); + expect(match.extra, const _Extra('foo')); + expect(match.error, isNull); + expect(match.pageKey, isNull); + expect(match.fullUriString, '/home/users/123?foo=bar'); + }); + test('ShellRoute has a unique pageKey', () { + final ShellRoute route = ShellRoute( + builder: _shellBuilder, + routes: [ + GoRoute( + path: '/users/:userId', + builder: _builder, + ), + ], + ); + final RouteMatch? match = RouteMatch.match( + route: route, + restLoc: 'users/123', + parentSubloc: '/home', + fullpath: '/home/users/:userId', + queryParams: { + 'foo': 'bar', + }, + queryParametersAll: >{ + 'foo': ['bar'], + }, + extra: const _Extra('foo'), + ); + if (match == null) { + fail('Null match'); + } + expect(match.pageKey, isNotNull); + }); + }); +} + +@immutable +class _Extra { + const _Extra(this.value); + + final String value; + + @override + bool operator ==(Object other) { + return other is _Extra && other.value == value; + } + + @override + int get hashCode => value.hashCode; +} + +Widget _builder(BuildContext context, GoRouterState state) => + const Placeholder(); + +Widget _shellBuilder(BuildContext context, GoRouterState state, Widget child) => + const Placeholder(); diff --git a/packages/go_router/test/parser_test.dart b/packages/go_router/test/parser_test.dart index bed6c277d0d2..aa67ad08b117 100644 --- a/packages/go_router/test/parser_test.dart +++ b/packages/go_router/test/parser_test.dart @@ -28,6 +28,7 @@ void main() { routes: routes, redirectLimit: 100, topRedirect: (_) => null, + navigatorKey: GlobalKey(), ), ); @@ -90,6 +91,7 @@ void main() { routes: routes, redirectLimit: 100, topRedirect: (_) => null, + navigatorKey: GlobalKey(), ); expect(configuration.namedLocation('lowercase'), '/abc'); @@ -132,6 +134,7 @@ void main() { routes: routes, redirectLimit: 100, topRedirect: (_) => null, + navigatorKey: GlobalKey(), ); expect( @@ -162,6 +165,7 @@ void main() { routes: routes, redirectLimit: 100, topRedirect: (_) => null, + navigatorKey: GlobalKey(), ), ); @@ -195,6 +199,7 @@ void main() { routes: routes, redirectLimit: 100, topRedirect: (_) => null, + navigatorKey: GlobalKey(), ), ); @@ -242,6 +247,7 @@ void main() { } return null; }, + navigatorKey: GlobalKey(), ), ); @@ -282,6 +288,7 @@ void main() { routes: routes, redirectLimit: 100, topRedirect: (_) => null, + navigatorKey: GlobalKey(), ), ); @@ -310,6 +317,7 @@ void main() { routes: routes, redirectLimit: 100, topRedirect: (_) => null, + navigatorKey: GlobalKey(), ), ); @@ -333,6 +341,7 @@ void main() { routes: routes, redirectLimit: 5, topRedirect: (_) => null, + navigatorKey: GlobalKey(), ), ); @@ -343,4 +352,49 @@ void main() { expect(matches, hasLength(1)); expect(matches.first.error, isNotNull); }); + + test('Creates a match for ShellRoute', () async { + final List routes = [ + ShellRoute( + builder: (BuildContext context, GoRouterState state, Widget child) { + return Scaffold( + body: child, + ); + }, + routes: [ + GoRoute( + path: '/a', + builder: (BuildContext context, GoRouterState state) { + return const Scaffold( + body: Text('Screen A'), + ); + }, + ), + GoRoute( + path: '/b', + builder: (BuildContext context, GoRouterState state) { + return const Scaffold( + body: Text('Screen B'), + ); + }, + ), + ], + ), + ]; + final GoRouteInformationParser parser = GoRouteInformationParser( + configuration: RouteConfiguration( + routes: routes, + redirectLimit: 5, + topRedirect: (_) => null, + navigatorKey: GlobalKey(), + ), + ); + + final RouteMatchList matchesObj = await parser + .parseRouteInformation(const RouteInformation(location: '/a')); + final List matches = matchesObj.matches; + + expect(matches, hasLength(2)); + expect(matches.first.error, isNull); + }); } diff --git a/packages/go_router/test/test_helpers.dart b/packages/go_router/test/test_helpers.dart index 0078f55023b3..78be1ebfd763 100644 --- a/packages/go_router/test/test_helpers.dart +++ b/packages/go_router/test/test_helpers.dart @@ -5,10 +5,12 @@ // ignore_for_file: cascade_invocations, diagnostic_describe_all_properties import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/src/foundation/diagnostics.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:go_router/src/match.dart'; +import 'package:go_router/src/matching.dart'; import 'package:go_router/src/typedefs.dart'; Future createGoRouter( @@ -156,11 +158,12 @@ class GoRouterRefreshStreamSpy extends GoRouterRefreshStream { } Future createRouter( - List routes, + List routes, WidgetTester tester, { GoRouterRedirect? redirect, String initialLocation = '/', int redirectLimit = 5, + GlobalKey? navigatorKey, }) async { final GoRouter goRouter = GoRouter( routes: routes, @@ -169,6 +172,7 @@ Future createRouter( redirectLimit: redirectLimit, errorBuilder: (BuildContext context, GoRouterState state) => TestErrorScreen(state.error!), + navigatorKey: navigatorKey, ); await tester.pumpWidget( MaterialApp.router( @@ -182,6 +186,7 @@ Future createRouter( class TestErrorScreen extends DummyScreen { const TestErrorScreen(this.ex, {super.key}); + final Exception ex; } @@ -203,16 +208,19 @@ class LoginScreen extends DummyScreen { class FamilyScreen extends DummyScreen { const FamilyScreen(this.fid, {super.key}); + final String fid; } class FamiliesScreen extends DummyScreen { const FamiliesScreen({required this.selectedFid, super.key}); + final String selectedFid; } class PersonScreen extends DummyScreen { const PersonScreen(this.fid, this.pid, {super.key}); + final String fid; final String pid; } @@ -231,12 +239,21 @@ class DummyScreen extends StatelessWidget { Widget dummy(BuildContext context, GoRouterState state) => const DummyScreen(); +final GlobalKey navigatorKey = GlobalKey(); + extension Extension on GoRouter { Page _pageFor(RouteMatch match) { - final List matches = routerDelegate.matches.matches; - final int i = matches.indexOf(match); - final List> pages = - routerDelegate.builder.getPages(DummyBuildContext(), matches).toList(); + final RouteMatchList matchList = routerDelegate.matches; + final int i = matchList.matches.indexOf(match); + final List> pages = routerDelegate.builder + .buildPages( + DummyBuildContext(), + matchList, + () {}, + false, + navigatorKey, + ) + .toList(); return pages[i]; } @@ -346,3 +363,10 @@ class DummyStatefulWidgetState extends State { @override Widget build(BuildContext context) => Container(); } + +Future simulateAndroidBackButton() async { + final ByteData message = + const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute')); + await ServicesBinding.instance.defaultBinaryMessenger + .handlePlatformMessage('flutter/navigation', message, (_) {}); +}