diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 471351f7a2d..ac2843a6a13 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,10 +1,14 @@ -## NEXT +## 4.3.0 +- Adds ShellRoute class, which displays the screens for child routes in an inner + Navigator. +- Adds navigatorKey parameter to GoRouter and GoRoute, which can be used to + specify which Navigator to stack onto when ShellRoute is used. - Cleans up examples ## 4.2.7 -- Update README +- Updates the README ## 4.2.6 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 00000000000..8dd46a6f57d --- /dev/null +++ b/packages/go_router/example/lib/shell_route.dart @@ -0,0 +1,225 @@ +// 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'; +import 'package:go_router_examples/books/main.dart'; + +final GlobalKey _rootNavigatorKey = GlobalKey(); + +// This example demonstrates how to configure a nested navigation stack using +// [ShellRoute]. +void main() => runApp(App()); + +/// The main app. +class App extends StatelessWidget { + /// Creates an [App]. + App({Key? key}) : super(key: key); + + /// The title of the app. + static const String title = 'GoRouter Example: Declarative Routes'; + + @override + Widget build(BuildContext context) => MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, + routeInformationParser: _router.routeInformationParser, + routerDelegate: _router.routerDelegate, + title: title, + ); + + final GoRouter _router = GoRouter( + navigatorKey: _rootNavigatorKey, + routes: [ + ShellRoute( + path: '/', + defaultRoute: 'a', + builder: (BuildContext context, GoRouterState state, Widget child) { + return AppScaffold( + child: child, + ); + }, + routes: [ + GoRoute( + path: 'a', + pageBuilder: (BuildContext context, GoRouterState state) { + return FadeTransitionPage( + key: state.pageKey, + child: Screen( + title: 'Screen A', + detailsPath: '/a/details', + backgroundColor: Colors.red.shade100), + ); + }, + routes: [ + GoRoute( + path: 'details', + builder: (BuildContext context, GoRouterState state) { + return const DetailsScreen(label: 'A'); + }, + ) + ], + ), + GoRoute( + path: 'b', + pageBuilder: (BuildContext context, GoRouterState state) { + return FadeTransitionPage( + key: state.pageKey, + child: Screen( + title: 'Screen B', + detailsPath: '/b/details', + backgroundColor: Colors.green.shade100, + ), + ); + }, + routes: [ + GoRoute( + path: 'details', + // Stack this route on the root navigator, instead of the + // nearest ShellRoute ancestor. + navigatorKey: _rootNavigatorKey, + builder: (BuildContext context, GoRouterState state) { + return const DetailsScreen(label: 'B'); + }, + ) + ], + ), + ], + ), + ], + ); +} + +/// The "shell" of this app. +class AppScaffold extends StatelessWidget { + /// AppScaffold constructor. [child] will + const AppScaffold({ + required this.child, + Key? key, + }) : super(key: key); + + /// The child widget to display in the body of the scaffold. In this sample, + /// it is the inner Navigator configured by GoRouter. + final Widget child; + + @override + Widget build(BuildContext context) { + final int selectedIndex = _calculateSelectedIndex(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: selectedIndex, + onTap: (int idx) => _onItemTapped(idx, context), + ), + ); + } + + static int _calculateSelectedIndex(BuildContext context) { + final GoRouter route = GoRouter.of(context); + final String location = route.location; + if (location != null) { + if (location.startsWith('/a')) { + return 0; + } + if (location.startsWith('/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 screen of the second page. +class Screen extends StatelessWidget { + /// Creates a screen with a given title + const Screen({ + required this.title, + required this.detailsPath, + required this.backgroundColor, + Key? key, + }) : super(key: key); + + /// The title of the screen. + final String title; + + /// The path to navigate to when the user presses the View Details button. + final String detailsPath; + + /// The background color. + final Color backgroundColor; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('This AppBar is in the inner navigator'), + ), + backgroundColor: backgroundColor, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + title, + style: Theme.of(context).textTheme.headline4, + ), + TextButton( + onPressed: () { + GoRouter.of(context).go(detailsPath); + }, + child: const Text('View 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.headline4, + ), + ), + ); + } +} diff --git a/packages/go_router/lib/go_router.dart b/packages/go_router/lib/go_router.dart index efb52fd5501..aedebcc33d7 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 GoRouterState, GoRoute; +export 'src/configuration.dart' + show GoRouterState, GoRoute, ShellRoute, RouteBase; 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 4e27220066f..31233858c73 100644 --- a/packages/go_router/lib/src/builder.dart +++ b/packages/go_router/lib/src/builder.dart @@ -47,89 +47,204 @@ 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( + return tryBuild( + context, matchList, pop, routerNeglect, configuration.navigatorKey); + } on RouteBuilderError catch (e) { + return buildErrorNavigator( context, - () => pages = getPages(context, matchesList).toList(), - ); - } else { - pages = getPages(context, matchesList).toList(); - } + e, + Uri.parse(matchList.location.toString()), + pop, + configuration.navigatorKey); + } + } - // 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( - context, - GoRouterState( - configuration, - location: location, - subloc: uri.path, - name: null, - queryParams: uri.queryParameters, - error: error, - ), - ), + /// 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 _buildRecursive(context, matchList, 0, pop, routerNeglect, + navigatorKey, {}).widget; + } + + /// Returns the top-level pages instead of the root navigator. Used for + /// testing. + @visibleForTesting + List> buildPages( + BuildContext context, RouteMatchList matchList) { + try { + return _buildRecursive(context, matchList, 0, () {}, false, + GlobalKey(), {}).pages; + } on RouteBuilderError catch (e) { + return >[ + buildErrorPage(context, e, matchList.location), ]; } + } - // we should've set pages to something by now - assert(pages != null); + _RecursiveBuildResult _buildRecursive( + BuildContext context, + RouteMatchList matchList, + int startIndex, + VoidCallback pop, + bool routerNeglect, // TODO(johnpryan): Is this working? Needs a test... + Key navigatorKey, + Map params, + ) { + final List> pages = >[]; + final List<_OutOfScopePage> pagesForOutOfScopeNavigator = + <_OutOfScopePage>[]; + + for (int i = startIndex; i < matchList.matches.length; i++) { + final RouteMatch match = matchList.matches[i]; - // pass either the match error or the build error along to the navigator - // builder, preferring the match error - if (matches.isError) { - error = matches.error; + 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 + }; + if (route is GoRoute) { + final GoRouterState state = buildState(match, newParams); + final Page page = buildGoRoutePage(context, state, match); + + // If this GoRoute is for a different Navigator, add it to the + // pagesForOutOfScopeNavigator list instead. + if (route.navigatorKey != null) { + pagesForOutOfScopeNavigator + .add(_OutOfScopePage(page, route.navigatorKey!)); + } else { + pages.add(page); + } + } else if (route is ShellRoute) { + final GoRouterState state = buildState(match, newParams); + final _RecursiveBuildResult result = _buildRecursive( + context, + matchList, + i + 1, + pop, + routerNeglect, + // Use a default unique key for the Navigator if none is provided. + route.shellNavigatorKey, + newParams); + final Widget child = result.widget; + + final Page page = buildPage(context, state, + callRouteBuilder(context, state, match, childWidget: child)); + + // If this ShellRoute is for a different Navigator, add it to the + // pagesForOutOfScopeNavigator list instead. + if (route.navigatorKey != null) { + pagesForOutOfScopeNavigator + .add(_OutOfScopePage(page, route.navigatorKey!)); + } else { + pages.add(page); + } + + // If any descendent GoRoutes have pages that are out of scope for this + // navigator, Add them to the list of routes that are out of scope. If + // they are in scope, add them to the list of pages for this navigator. + final List<_OutOfScopePage> pagesOutOfScopeForChildNavigator = + result.pagesForOutOfScopeNavigator; + for (final _OutOfScopePage outOfScopePage + in pagesOutOfScopeForChildNavigator) { + if (outOfScopePage.navigatorKey == route.shellNavigatorKey) { + pages.add(page); + } else { + pagesForOutOfScopeNavigator.add(outOfScopePage); + } + } + + i = result.newIndex; + } } - // wrap the returned Navigator to enable GoRouter.of(context).go() - final Uri uri = Uri.parse(location); - return builderWithNav( - context, - GoRouterState( - configuration, - location: location, - // no name available at the top level - 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, - // pass along the error, if there is one - error: error, - ), - Navigator( - restorationScopeId: restorationScopeId, + // Add any pages that were out of scope to this Navigator if the keys match. + for (final _OutOfScopePage outOfScopePage in pagesForOutOfScopeNavigator) { + if (outOfScopePage.navigatorKey == navigatorKey) { + pages.add(outOfScopePage.page); + } + } + + Widget? child; + if (pages.isNotEmpty) { + child = buildNavigator( + context, + Uri.parse(matchList.location.toString()), + matchList.isError ? matchList.error : null, + navigatorKey, + pop, + pages, + root: startIndex == 0, + ); + } else if (startIndex == 0) { + // It's an error to have an empty pages list on the root Navigator. + throw RouteBuilderError('No pages built for root Navigator'); + } + + return _RecursiveBuildResult(child ?? const SizedBox.shrink(), pages, + pagesForOutOfScopeNavigator, matchList.matches.length); + } + + /// Helper method that calls [builderWithNav] with the [GoRouterState] + @visibleForTesting + Widget buildNavigator(BuildContext context, Uri uri, Exception? exception, + Key navigatorKey, VoidCallback pop, List> pages, + {bool root = true}) { + if (root) { + return builderWithNav( + context, + GoRouterState( + configuration, + location: uri.toString(), + // no name available at the top level + 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, + // pass along the error, if there is one + error: exception, + ), + Navigator( + restorationScopeId: restorationScopeId, + key: navigatorKey, + pages: pages, + observers: observers, + onPopPage: (Route route, dynamic result) { + if (!route.didPop(result)) { + return false; + } + pop(); + return true; + }, + ), + ); + } else { + // return Router(routerDelegate: _delegate,); + return Navigator( key: navigatorKey, - // needed to enable Android system Back button - pages: pages!, - observers: observers, + pages: pages, onPopPage: (Route route, dynamic result) { if (!route.didPop(result)) { return false; @@ -137,74 +252,80 @@ class RouteBuilder { 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) ] + /// Helper method that buidls a [GoRouterState] object for the given [match] + /// and [params]. @visibleForTesting - Iterable> getPages( - 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, - extra: match.extra, - pageKey: match.pageKey, // push() remaps the page key for uniqueness - ); - if (match.error != null) { - yield _errorPageBuilder(context, state); - break; - } + GoRouterState buildState(RouteMatch match, Map params) { + return 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, + extra: match.extra, + pageKey: match.pageKey, // push() remaps the page key for uniqueness + ); + } - final GoRouterPageBuilder? pageBuilder = match.route.pageBuilder; - Page? page; - if (pageBuilder != null) { - page = pageBuilder(context, state); - if (page is NoOpPage) { - page = null; - } + /// Builds a [Page] for [StackedRoute] + Page buildGoRoutePage( + BuildContext context, GoRouterState state, RouteMatch match) { + final RouteBase route = match.route; + if (route is! GoRoute) { + throw RouteBuilderError( + 'Unexpected route type in buildStackedRoute: $route'); + } + + // Call the pageBuilder if it's non-null + final GoRouterPageBuilder? pageBuilder = + (match.route as GoRoute).pageBuilder; + Page? page; + if (pageBuilder != null) { + page = pageBuilder(context, state); + 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)); + } + + /// 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'); + } - yield page ?? _pageBuilder(context, state, match.route.builder); + if (route is GoRoute) { + final GoRouterWidgetBuilder? builder = route.builder; + if (builder != null) { + // Use a Builder to ensure the route gets the correct BuildContext. + return builder(context, state); + } + } else if (route is ShellRoute) { + if (childWidget == null) { + throw RouteBuilderError( + 'Attempt to build ShellRoute without a child widget'); + } + // Use a Builder to ensure the route gets the correct BuildContext. + return route.builder(context, state, childWidget); } + + throw UnimplementedError('Unsupported route type $route'); } Page Function({ @@ -229,26 +350,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); @@ -259,11 +371,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); @@ -272,7 +385,7 @@ class RouteBuilder { name: state.name ?? state.fullpath, arguments: {...state.params, ...state.queryParams}, restorationId: state.pageKey.value, - child: builder(context, state), + child: child, ); } @@ -292,22 +405,84 @@ 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( + context, uri, Exception(e), navigatorKey, pop, >[ + buildErrorPage(context, e, uri), + ]); + } + + /// 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, + 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), ); } } + +class _RecursiveBuildResult { + _RecursiveBuildResult( + this.widget, this.pages, this.pagesForOutOfScopeNavigator, this.newIndex); + + final Widget widget; + + /// List of pages placed on the navigator. Used for testing. + final List> pages; + + /// List of pages placed on a Navigator that isn't in the current scope (such as the root navigator) + final List<_OutOfScopePage> pagesForOutOfScopeNavigator; + + final int newIndex; +} + +class _OutOfScopePage { + _OutOfScopePage(this.page, this.navigatorKey); + + final Page page; + final GlobalKey navigatorKey; +} + +/// 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 ?? ""}'; + } +} diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index 321541d3201..ba8574e9edd 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,6 +19,7 @@ class RouteConfiguration { required this.routes, required this.redirectLimit, required this.topRedirect, + required this.navigatorKey, }) { _cacheNameToPath('', routes); @@ -25,7 +28,7 @@ class RouteConfiguration { return true; }()); - for (final GoRoute route in routes) { + for (final RouteBase route in routes) { if (!route.path.startsWith('/')) { throw RouteConfigurationError( 'top-level path must start with "/": ${route.path}'); @@ -34,7 +37,7 @@ class RouteConfiguration { } /// 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 +45,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. @@ -103,17 +109,17 @@ class RouteConfiguration { return sb.toString(); } - void _debugFullPathsFor( - List routes, String parentFullpath, int depth, StringBuffer sb) { - for (final GoRoute route in routes) { + void _debugFullPathsFor(List routes, String parentFullpath, + int depth, StringBuffer sb) { + for (final RouteBase route in routes) { 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) { + void _cacheNameToPath(String parentFullPath, List childRoutes) { + for (final RouteBase route in childRoutes) { final String fullPath = concatenatePaths(parentFullPath, route.path); if (route.name != null) { diff --git a/packages/go_router/lib/src/delegate.dart b/packages/go_router/lib/src/delegate.dart index 29f0f251f33..2e506df9b59 100644 --- a/packages/go_router/lib/src/delegate.dart +++ b/packages/go_router/lib/src/delegate.dart @@ -26,7 +26,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,10 +43,42 @@ 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() { + // Iterate backwards through the RouteMatchList until seeing a GoRoute + // with a non-null navigatorKey or a ShellRoute with a non-null navigatorKey + // and pop from that Navigator instead of the root. + NavigatorState? navigator; + final int matchCount = _matchList.matches.length; + for (int i = matchCount - 1; i >= 0; i--) { + final RouteMatch match = _matchList.matches[i]; + final RouteBase route = match.route; + final GlobalKey? key = route.navigatorKey; + + // If this is a ShellRoute, then pop one of the subsequent GoRoutes, if + // there are any. + if (route is ShellRoute && (matchCount - i) > 2) { + // Pop from this navigator. + navigator = route.shellNavigatorKey.currentState; + break; + } else if (key != null) { + navigator = key.currentState; + break; + } + } + + navigator ??= navigatorKey.currentState; + + if (navigator == null) { + return SynchronousFuture(false); + } + + return navigator.maybePop(); + } /// Pushes the given location onto the page stack void push(RouteMatch match) { @@ -65,18 +98,18 @@ 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. bool canPop() { - return _matches.canPop(); + return _matchList.canPop(); } /// Pop the top page off the GoRouter's page stack. void pop() { - _matches.pop(); + _matchList.pop(); notifyListeners(); } @@ -85,36 +118,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 d7f096e8a1b..24334d6f87f 100644 --- a/packages/go_router/lib/src/match.dart +++ b/packages/go_router/lib/src/match.dart @@ -11,7 +11,7 @@ import 'route.dart'; /// portion of a location. class RouteMatch { /// Constructor for [RouteMatch], each instance represents an instance of a - /// [GoRoute] for a specific portion of a location. + /// [RouteBase] for a specific portion of a location. RouteMatch({ required this.route, required this.subloc, @@ -36,7 +36,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 @@ -65,7 +65,7 @@ class RouteMatch { } /// The matched route. - final GoRoute route; + final RouteBase route; /// Matched sub-location. final String subloc; // e.g. /family/f2 diff --git a/packages/go_router/lib/src/matching.dart b/packages/go_router/lib/src/matching.dart index 755aed67078..ffe82186fa1 100644 --- a/packages/go_router/lib/src/matching.dart +++ b/packages/go_router/lib/src/matching.dart @@ -119,7 +119,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 Object? extra, @@ -131,7 +131,7 @@ List _getLocRouteRecursively({ }()); final List> result = >[]; // find the set of matches at this level of the tree - for (final GoRoute route in routes) { + for (final RouteBase route in routes) { final String fullpath = concatenatePaths(parentFullpath, route.path); final RouteMatch? match = RouteMatch.match( route: route, @@ -188,6 +188,34 @@ List _getLocRouteRecursively({ return []; } + /// Add matches for ShellRoute defaultRoute + final RouteMatch lastMatch = result.first.last; + final RouteBase lastRoute = lastMatch.route; + if (lastRoute is ShellRoute) { + final String? defaultRoute = lastRoute.defaultRoute; + if (defaultRoute != null) { + // find the sub-route + for (final RouteBase subRoute in lastRoute.routes) { + if (subRoute.path == defaultRoute) { + final String fullpath = + concatenatePaths(lastMatch.subloc, subRoute.path); + final RouteMatch? match = RouteMatch.match( + route: subRoute, + restLoc: defaultRoute, + parentSubloc: lastMatch.subloc, + fullpath: fullpath, + queryParams: queryParams, + extra: extra, + ); + if (match == null) { + continue; + } + result.first.add(match); + } + } + } + } + // If there are multiple routes that match the location, returning the first one. // To make predefined routes to take precedence over dynamic routes eg. '/:id' // consider adding the dynamic route at the end of the routes diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index d7790f574f5..3401c455d16 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -5,32 +5,23 @@ 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. - GoRoute({ +/// The base class for [GoRoute] and [ShellRoute]. +@immutable +abstract class RouteBase { + RouteBase._({ required this.path, this.name, - this.pageBuilder, - this.builder = _invalidBuilder, - this.routes = const [], - this.redirect = _noRedirection, - }) : assert(path.isNotEmpty, 'GoRoute path cannot be empty'), - assert(name == null || name.isNotEmpty, 'GoRoute name cannot be empty'), - assert( - pageBuilder != null || - builder != _invalidBuilder || - redirect != _noRedirection, - 'GoRoute builder parameter not set\n' - 'See gorouter.dev/redirection#considerations for details') { + this.routes = const [], + this.redirect = _emptyRedirect, + this.navigatorKey, + }) { // cache the path regexp and parameters _pathRE = patternToRegExp(path, _pathParams); - assert(() { // check path params final Map> groupedParams = @@ -44,7 +35,7 @@ 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 == '/' || @@ -121,40 +112,12 @@ class GoRoute { /// to learn more about parameters. final String path; - /// A page builder for this route. - /// - /// Typically a MaterialPage, as in: - /// ``` - /// GoRoute( - /// path: '/', - /// pageBuilder: (BuildContext context, GoRouterState state) => MaterialPage( - /// key: state.pageKey, - /// child: HomePage(families: Families.data), - /// ), - /// ), - /// ``` - /// - /// You can also use CupertinoPage, and for a custom page builder to use - /// custom page transitions, you can use [CustomTransitionPage]. - final GoRouterPageBuilder? pageBuilder; - - /// A custom builder for this route. - /// - /// For example: - /// ``` - /// GoRoute( - /// path: '/', - /// builder: (BuildContext context, GoRouterState state) => FamilyPage( - /// families: Families.family( - /// state.params['id'], - /// ), - /// ), - /// ), - /// ``` + /// The list of child routes associated with this route. /// - final GoRouterWidgetBuilder builder; - - /// A list of sub go routes for this route. + /// 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. @@ -233,7 +196,7 @@ class GoRoute { /// /// 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 List routes; /// An optional redirect function for this route. /// @@ -263,6 +226,11 @@ 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? navigatorKey; + /// Match this route against a location. RegExpMatch? matchPatternAsPrefix(String loc) => _pathRE.matchAsPrefix(loc) as RegExpMatch?; @@ -271,6 +239,81 @@ class GoRoute { Map extractPathParams(RegExpMatch match) => extractPathParameters(_pathParams, match); + static String? _emptyRedirect(GoRouterState state) => null; +} + +/// 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 [navigatorKey]. +/// +/// The Page will be either a [MaterialPage] (for [MaterialApp]), +/// [CupertinoPage] (for [CupertinoApp], or [NoTransitionPage] (for +/// [WidgetsApp]) depending on the application type. +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 String path, + this.builder, + this.pageBuilder, + GlobalKey? navigatorKey, + super.name, + GoRouterRedirect redirect = RouteBase._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'), + super._( + path: path, + routes: routes, + redirect: redirect, + navigatorKey: navigatorKey, + ); + + /// The path template for this route. For example "users/:userId" or + /// "settings". + /// + /// Typically a MaterialPage, as in: + /// ``` + /// GoRoute( + /// path: '/', + /// pageBuilder: (BuildContext context, GoRouterState state) => MaterialPage( + /// key: state.pageKey, + /// child: HomePage(families: Families.data), + /// ), + /// ), + /// ``` + /// + /// You can also use CupertinoPage, and for a custom page builder to use + /// custom page transitions, you can use [CustomTransitionPage]. + final GoRouterPageBuilder? pageBuilder; + + /// A custom builder for this route. + /// + /// For example: + /// ``` + /// GoRoute( + /// path: '/', + /// builder: (BuildContext context, GoRouterState state) => FamilyPage( + /// families: Families.family( + /// state.params['id'], + /// ), + /// ), + /// ), + /// ``` + /// + final GoRouterWidgetBuilder? builder; + static String? _noRedirection(GoRouterState state) => null; static Widget _invalidBuilder( @@ -279,3 +322,64 @@ class GoRoute { ) => const SizedBox.shrink(); } + +/// A route that displays a UI shell around the matching child route. Builds +/// 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 +/// `navigatorKey` that matches the key provided to either the `GoRouter` or +/// `ShellRoute` constructor. +/// +/// The widget built by the matching child route becomes to 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, +/// ), +/// ); +/// } +/// ), +/// ``` +/// +class ShellRoute extends RouteBase { + /// Constructs a [ShellRoute]. + ShellRoute({ + required String path, + required this.builder, + this.defaultRoute, + GoRouterRedirect redirect = RouteBase._emptyRedirect, + List routes = const [], + GlobalKey? navigatorKey, + GlobalKey? shellNavigatorKey, + }) : shellNavigatorKey = shellNavigatorKey ?? GlobalKey(), + super._( + path: path, + routes: routes, + redirect: redirect, + navigatorKey: navigatorKey, + ); + + /// The widget builder for a shell route. + final ShellRouteBuilder builder; + + /// The relative path to the child route to navigate to when this route is + /// displayed. This allows the default child route to be specified without + /// using redirection. + final String? defaultRoute; + + /// The [GlobalKey] to be used to 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. + late final GlobalKey shellNavigatorKey; +} diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 644480fe656..96fbff87593 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( diff --git a/packages/go_router/lib/src/typedefs.dart b/packages/go_router/lib/src/typedefs.dart index 2f8789e6f72..75e66f9e2d1 100644 --- a/packages/go_router/lib/src/typedefs.dart +++ b/packages/go_router/lib/src/typedefs.dart @@ -6,12 +6,19 @@ import 'package:flutter/widgets.dart'; import 'configuration.dart'; -/// The signature of the widget builder callback for a matched GoRoute. +/// Builder for [GoRoute] typedef GoRouterWidgetBuilder = Widget Function( BuildContext context, GoRouterState state, ); +/// Builder for [ShellRoute] +typedef ShellRouteBuilder = Widget Function( + BuildContext context, + GoRouterState state, + Widget child, +); + /// The signature of the page builder callback for a matched GoRoute. typedef GoRouterPageBuilder = Page Function( BuildContext context, diff --git a/packages/go_router/test/builder_test.dart b/packages/go_router/test/builder_test.dart new file mode 100644 index 00000000000..363fd98d891 --- /dev/null +++ b/packages/go_router/test/builder_test.dart @@ -0,0 +1,350 @@ +// 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, + subloc: '/', + fullpath: '/', + encodedParams: {}, + queryParams: {}, + 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( + path: '/', + builder: (BuildContext context, GoRouterState state, Widget child) { + return _DetailsScreen(); + }, + ), + ], + redirectLimit: 10, + topRedirect: (GoRouterState state) { + return null; + }, + navigatorKey: GlobalKey(), + ); + + final RouteMatchList matches = RouteMatchList([ + RouteMatch( + route: config.routes.first, + subloc: '/', + fullpath: '/', + encodedParams: {}, + queryParams: {}, + 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, + subloc: '/', + fullpath: '/', + encodedParams: {}, + queryParams: {}, + 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( + path: '/', + builder: (BuildContext context, GoRouterState state, Widget child) { + return _HomeScreen( + child: child, + ); + }, + shellNavigatorKey: 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: {}, + extra: null, + error: null, + ), + RouteMatch( + route: config.routes.first.routes.first, + subloc: '/details', + fullpath: '/details', + encodedParams: {}, + queryParams: {}, + 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', + (WidgetTester tester) async { + final GlobalKey rootNavigatorKey = + GlobalKey(debugLabel: 'root'); + final GlobalKey shellNavigatorKey = + GlobalKey(debugLabel: 'shell'); + final RouteConfiguration config = RouteConfiguration( + navigatorKey: rootNavigatorKey, + routes: [ + ShellRoute( + path: '/', + builder: (BuildContext context, GoRouterState state, Widget child) { + return _HomeScreen( + child: child, + ); + }, + shellNavigatorKey: shellNavigatorKey, + routes: [ + GoRoute( + path: 'details', + builder: (BuildContext context, GoRouterState state) { + return _DetailsScreen(); + }, + // This screen should stack onto the root navigator. + navigatorKey: rootNavigatorKey, + ), + ], + ), + ], + redirectLimit: 10, + topRedirect: (GoRouterState state) { + return null; + }, + ); + + final RouteMatchList matches = RouteMatchList([ + RouteMatch( + route: config.routes.first, + subloc: '/', + fullpath: '/', + encodedParams: {}, + queryParams: {}, + extra: null, + error: null, + ), + RouteMatch( + route: config.routes.first.routes.first, + subloc: '/details', + fullpath: '/details', + encodedParams: {}, + queryParams: {}, + 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, + Key? key, + }) : builder = _routeBuilder(routeConfiguration), + super(key: key); + + 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/go_router_test.dart b/packages/go_router/test/go_router_test.dart index 5758a580aa4..23c8f03fdc7 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,39 @@ 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); + }); }); group('named routes', () { @@ -713,6 +751,7 @@ void main() { final List routes = [ GoRoute( path: '/', + builder: dummy, redirect: (_) => '/family/f2', ), GoRoute( @@ -965,10 +1004,12 @@ void main() { [ GoRoute( path: '/', + builder: dummy, redirect: (GoRouterState state) => '/login', ), GoRoute( path: '/login', + builder: dummy, redirect: (GoRouterState state) => '/', ), ], @@ -988,6 +1029,7 @@ void main() { [ GoRoute( path: '/login', + builder: dummy, redirect: (GoRouterState state) => '/', ), ], @@ -1034,6 +1076,7 @@ void main() { ), GoRoute( path: '/dummy', + builder: dummy, redirect: (GoRouterState state) => '/', ), ]; @@ -1193,7 +1236,7 @@ void main() { expect(state.extra, isNotNull); return null; }, - routes: [], + routes: const [], ), ], ), @@ -1254,6 +1297,7 @@ void main() { ), GoRoute( path: '/dummy', + builder: dummy, redirect: (GoRouterState state) => '/', ), ]; @@ -1784,6 +1828,155 @@ void main() { }); }); + group('ShellRoute', () { + testWidgets('defaultRoute', (WidgetTester tester) async { + final List routes = [ + ShellRoute( + path: '/', + builder: (BuildContext context, GoRouterState state, Widget child) { + return Scaffold( + body: child, + ); + }, + defaultRoute: 'b', + 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); + 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( + path: '/', + 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( + path: '/', + shellNavigatorKey: 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', + navigatorKey: 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('pop triggers pop on routerDelegate', (WidgetTester tester) async { final GoRouter router = await createGoRouter(tester) diff --git a/packages/go_router/test/parser_test.dart b/packages/go_router/test/parser_test.dart index 5d164839dbc..54317213c82 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?'); @@ -118,6 +120,7 @@ void main() { routes: routes, redirectLimit: 100, topRedirect: (_) => null, + navigatorKey: GlobalKey(), ), ); @@ -151,6 +154,7 @@ void main() { routes: routes, redirectLimit: 100, topRedirect: (_) => null, + navigatorKey: GlobalKey(), ), ); @@ -198,6 +202,7 @@ void main() { } return null; }, + navigatorKey: GlobalKey(), ), ); @@ -238,6 +243,7 @@ void main() { routes: routes, redirectLimit: 100, topRedirect: (_) => null, + navigatorKey: GlobalKey(), ), ); @@ -266,6 +272,7 @@ void main() { routes: routes, redirectLimit: 100, topRedirect: (_) => null, + navigatorKey: GlobalKey(), ), ); @@ -289,6 +296,7 @@ void main() { routes: routes, redirectLimit: 5, topRedirect: (_) => null, + navigatorKey: GlobalKey(), ), ); @@ -299,4 +307,51 @@ void main() { expect(matches, hasLength(1)); expect(matches.first.error, isNotNull); }); + + test('Creates a match for ShellRoute defaultChild', () async { + final List routes = [ + ShellRoute( + path: '/', + builder: (BuildContext context, GoRouterState state, Widget child) { + return Scaffold( + body: child, + ); + }, + defaultRoute: 'b', + 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: '/')); + 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 5361ceff76f..3dd3e7ca378 100644 --- a/packages/go_router/test/test_helpers.dart +++ b/packages/go_router/test/test_helpers.dart @@ -7,10 +7,12 @@ import 'dart:async'; 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( @@ -160,21 +162,22 @@ 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, - redirect: redirect, - initialLocation: initialLocation, - redirectLimit: redirectLimit, - errorBuilder: (BuildContext context, GoRouterState state) => - TestErrorScreen(state.error!), - debugLogDiagnostics: false, - ); + routes: routes, + redirect: redirect, + initialLocation: initialLocation, + redirectLimit: redirectLimit, + errorBuilder: (BuildContext context, GoRouterState state) => + TestErrorScreen(state.error!), + debugLogDiagnostics: false, + navigatorKey: navigatorKey); await tester.pumpWidget( MaterialApp.router( routeInformationProvider: goRouter.routeInformationProvider, @@ -233,10 +236,11 @@ Widget dummy(BuildContext context, GoRouterState state) => const DummyScreen(); 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) + .toList(); return pages[i]; } @@ -346,3 +350,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, (_) {}); +}