diff --git a/packages/go_router/.gitignore b/packages/go_router/.gitignore new file mode 100644 index 000000000000..96486fd93024 --- /dev/null +++ b/packages/go_router/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index a2fc66f55999..9ce2c9e015b6 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.2.1 + +- Refactors internal classes and methods + ## 4.2.0 - Adds `void replace()` and `replaceNamed` to `GoRouterDelegate`, `GoRouter` and `GoRouterHelper`. diff --git a/packages/go_router/example/lib/books/src/auth.dart b/packages/go_router/example/lib/books/src/auth.dart index 2b71e4c896b7..2f5d8ce4e800 100644 --- a/packages/go_router/example/lib/books/src/auth.dart +++ b/packages/go_router/example/lib/books/src/auth.dart @@ -4,7 +4,7 @@ import 'package:flutter/widgets.dart'; -/// A mock authentication service +/// A mock authentication service. class BookstoreAuth extends ChangeNotifier { bool _signedIn = false; diff --git a/packages/go_router/example/lib/books/src/screens/scaffold.dart b/packages/go_router/example/lib/books/src/screens/scaffold.dart index a3f8134f63cf..a5ff831eef32 100644 --- a/packages/go_router/example/lib/books/src/screens/scaffold.dart +++ b/packages/go_router/example/lib/books/src/screens/scaffold.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -/// The enum for scaffold tab +/// The enum for scaffold tab. enum ScaffoldTab { /// The books tab. books, diff --git a/packages/go_router/example/lib/cupertino.dart b/packages/go_router/example/lib/cupertino.dart index 3f4bd1018406..6a8a704a6f34 100644 --- a/packages/go_router/example/lib/cupertino.dart +++ b/packages/go_router/example/lib/cupertino.dart @@ -7,7 +7,7 @@ import 'package:go_router/go_router.dart'; void main() => runApp(App()); -/// The main app +/// The main app. class App extends StatelessWidget { /// Creates an [App]. App({Key? key}) : super(key: key); diff --git a/packages/go_router/example/lib/error_screen.dart b/packages/go_router/example/lib/error_screen.dart index a578a832e926..b1c4fa9a3541 100644 --- a/packages/go_router/example/lib/error_screen.dart +++ b/packages/go_router/example/lib/error_screen.dart @@ -85,7 +85,7 @@ class Page2Screen extends StatelessWidget { ); } -/// The screen of the error page +/// The screen of the error page. class ErrorScreen extends StatelessWidget { /// Creates an [ErrorScreen]. const ErrorScreen(this.error, {Key? key}) : super(key: key); diff --git a/packages/go_router/example/lib/router_stream_refresh.dart b/packages/go_router/example/lib/router_stream_refresh.dart index 2d00dbe4ef7f..7a3ecf4f58f2 100644 --- a/packages/go_router/example/lib/router_stream_refresh.dart +++ b/packages/go_router/example/lib/router_stream_refresh.dart @@ -84,6 +84,8 @@ class _AppState extends State { return null; }, // changes on the listenable will cause the router to refresh it's route + // TODO(johnpryan): Deprecate GoRouterRefreshStream + // See https://github.com/flutter/flutter/issues/108128 refreshListenable: GoRouterRefreshStream(loggedInState.stream), ); super.initState(); diff --git a/packages/go_router/example/lib/shared_scaffold.dart b/packages/go_router/example/lib/shared_scaffold.dart index 3dc0c109b3a0..fd2e72c4c8a0 100644 --- a/packages/go_router/example/lib/shared_scaffold.dart +++ b/packages/go_router/example/lib/shared_scaffold.dart @@ -79,7 +79,7 @@ class SharedScaffold extends StatefulWidget { Key? key, }) : super(key: key); - /// The selected index + /// The selected index. final int selectedIndex; /// The body of the page. @@ -173,7 +173,7 @@ class Page2View extends StatelessWidget { /// The error scaffold. class ErrorScaffold extends StatelessWidget { - /// Creates an [ErrorScaffold] + /// Creates an [ErrorScaffold]. const ErrorScaffold({ required this.body, Key? key, diff --git a/packages/go_router/example/lib/transitions.dart b/packages/go_router/example/lib/transitions.dart index 08c8faa28dc8..555e070acdda 100644 --- a/packages/go_router/example/lib/transitions.dart +++ b/packages/go_router/example/lib/transitions.dart @@ -137,7 +137,7 @@ class ExampleTransitionsScreen extends StatelessWidget { /// The color of the container. final Color color; - /// The transition kind + /// The transition kind. final String kind; @override diff --git a/packages/go_router/lib/go_router.dart b/packages/go_router/lib/go_router.dart index a4fa36aa04e5..4d0390172faa 100644 --- a/packages/go_router/lib/go_router.dart +++ b/packages/go_router/lib/go_router.dart @@ -3,106 +3,15 @@ // found in the LICENSE file. /// A declarative router for Flutter based on Navigation 2 supporting -/// deep linking, data-driven routes and more +/// deep linking, data-driven routes and more. library go_router; -import 'package:flutter/widgets.dart'; - -import 'src/go_router.dart'; - -export 'src/custom_transition_page.dart'; -export 'src/go_route.dart'; -export 'src/go_router.dart'; -export 'src/go_router_refresh_stream.dart'; -export 'src/go_router_state.dart'; -export 'src/inherited_go_router.dart'; -export 'src/route_data.dart' show GoRouteData, TypedGoRoute; +export 'src/configuration.dart' show GoRouterState, GoRoute; +export 'src/misc/extensions.dart'; +export 'src/misc/inherited_router.dart'; +export 'src/misc/refresh_stream.dart'; +export 'src/pages/custom_transition_page.dart'; +export 'src/platform.dart' show UrlPathStrategy; +export 'src/router.dart'; +export 'src/typed_routing.dart' show GoRouteData, TypedGoRoute; export 'src/typedefs.dart' show GoRouterPageBuilder, GoRouterRedirect; -export 'src/url_path_strategy.dart'; - -/// Dart extension to add navigation function to a BuildContext object, e.g. -/// context.go('/'); -// NOTE: adding this here instead of in /src to work-around a Dart analyzer bug -// and fix: https://github.com/csells/go_router/issues/116 -extension GoRouterHelper on BuildContext { - /// Get a location from route name and parameters. - String namedLocation( - String name, { - Map params = const {}, - Map queryParams = const {}, - }) => - GoRouter.of(this) - .namedLocation(name, params: params, queryParams: queryParams); - - /// Navigate to a location. - void go(String location, {Object? extra}) => - GoRouter.of(this).go(location, extra: extra); - - /// Navigate to a named route. - void goNamed( - String name, { - Map params = const {}, - Map queryParams = const {}, - Object? extra, - }) => - GoRouter.of(this).goNamed( - name, - params: params, - queryParams: queryParams, - extra: extra, - ); - - /// Push a location onto the page stack. - void push(String location, {Object? extra}) => - GoRouter.of(this).push(location, extra: extra); - - /// Navigate to a named route onto the page stack. - void pushNamed( - String name, { - Map params = const {}, - Map queryParams = const {}, - Object? extra, - }) => - GoRouter.of(this).pushNamed( - name, - params: params, - queryParams: queryParams, - extra: extra, - ); - - /// Replaces the top-most page of the page stack with the given URL location - /// w/ optional query parameters, e.g. `/family/f2/person/p1?color=blue`. - /// - /// See also: - /// * [go] which navigates to the location. - /// * [push] which pushes the location onto the page stack. - void replace(String location, {Object? extra}) => - GoRouter.of(this).replace(location, extra: extra); - - /// Replaces the top-most page of the page stack with the named route w/ - /// optional parameters, e.g. `name='person', params={'fid': 'f2', 'pid': - /// 'p1'}`. - /// - /// See also: - /// * [goNamed] which navigates a named route. - /// * [pushNamed] which pushes a named route onto the page stack. - void replaceNamed( - String name, { - Map params = const {}, - Map queryParams = const {}, - Object? extra, - }) => - GoRouter.of(this).replaceNamed( - name, - params: params, - queryParams: queryParams, - extra: extra, - ); - - /// Returns `true` if there is more than 1 page on the stack. - bool canPop() => GoRouter.of(this).canPop(); - - /// Pop the top page off the Navigator's page stack by calling - /// [Navigator.pop]. - void pop() => GoRouter.of(this).pop(); -} diff --git a/packages/go_router/lib/src/go_router_delegate.dart b/packages/go_router/lib/src/builder.dart similarity index 66% rename from packages/go_router/lib/src/go_router_delegate.dart rename to packages/go_router/lib/src/builder.dart index 25830de77586..28ef9b76a78e 100644 --- a/packages/go_router/lib/src/go_router_delegate.dart +++ b/packages/go_router/lib/src/builder.dart @@ -2,40 +2,31 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import 'custom_transition_page.dart'; -import 'go_route_information_parser.dart'; -import 'go_route_match.dart'; -import 'go_router_cupertino.dart'; -import 'go_router_error_page.dart'; -import 'go_router_material.dart'; -import 'go_router_state.dart'; +import 'configuration.dart'; import 'logging.dart'; -import 'route_data.dart'; +import 'match.dart'; +import 'matching.dart'; +import 'misc/error_screen.dart'; +import 'pages/cupertino.dart'; +import 'pages/custom_transition_page.dart'; +import 'pages/material.dart'; +import 'typed_routing.dart'; import 'typedefs.dart'; -/// GoRouter implementation of the RouterDelegate base class. -class GoRouterDelegate extends RouterDelegate> - with PopNavigatorRouterDelegateMixin>, ChangeNotifier { - /// Constructor for GoRouter's implementation of the - /// RouterDelegate base class. - GoRouterDelegate( - this._parser, { +/// Builds the top-level Navigator for GoRouter. +class RouteBuilder { + /// [RouteBuilder] constructor. + RouteBuilder({ + required this.configuration, required this.builderWithNav, required this.errorPageBuilder, required this.errorBuilder, + required this.restorationScopeId, required this.observers, - required this.routerNeglect, - this.restorationScopeId, }); - // TODO(chunhtai): remove this once namedLocation is removed from go_router. - final GoRouteInformationParser _parser; - /// Builder function for a go router with Navigator. final GoRouterBuilderWithNav builderWithNav; @@ -45,86 +36,39 @@ class GoRouterDelegate extends RouterDelegate> /// Error widget builder for the go router delegate. final GoRouterWidgetBuilder? errorBuilder; - /// NavigatorObserver used to receive change notifications when - /// navigation changes. - final List observers; - - /// Set to true to disable creating history entries on the web. - final bool routerNeglect; + /// The route configuration for the app. + final RouteConfiguration configuration; /// Restoration ID to save and restore the state of the navigator, including /// its history. final String? restorationScopeId; - final GlobalKey _key = GlobalKey(); - List _matches = const []; - - /// Push the given location onto the page stack - void push(GoRouteMatch match) { - _matches.add(match); - notifyListeners(); - } - - /// Replaces the top-most page of the page stack with the given one. - /// - /// See also: - /// * [push] which pushes the given location onto the page stack. - void replace(GoRouteMatch match) { - _matches.last = match; - notifyListeners(); - } - - /// Returns `true` if there is more than 1 page on the stack. - bool canPop() { - return _matches.length > 1; - } - - /// Pop the top page off the GoRouter's page stack. - void pop() { - _matches.remove(_matches.last); - assert(_matches.isNotEmpty, - 'have popped the last page off of the stack; there are no pages left to show'); - notifyListeners(); - } - - /// For internal use; visible for testing only. - @visibleForTesting - List get matches => _matches; - - /// For use by the Router architecture as part of the RouterDelegate. - @override - GlobalKey get navigatorKey => _key; - - /// For use by the Router architecture as part of the RouterDelegate. - @override - List get currentConfiguration => _matches; - - /// For use by the Router architecture as part of the RouterDelegate. - @override - Widget build(BuildContext context) => _builder(context, _matches); - - /// For use by the Router architecture as part of the RouterDelegate. - @override - Future setNewRoutePath(List configuration) { - _matches = configuration; - // Use [SynchronousFuture] so that the initial url is processed - // synchronously and remove unwanted initial animations on deep-linking - return SynchronousFuture(null); - } + /// NavigatorObserver used to receive notifications when navigating in between routes. + /// changes. + final List observers; - Widget _builder(BuildContext context, Iterable matches) { + /// Builds the top-level Navigator by invoking the build method on each + /// matching route + Widget build( + BuildContext context, + RouteMatchList matches, + VoidCallback pop, + Key navigatorKey, + bool routerNeglect, + ) { List>? pages; Exception? error; - final String location = matches.last.fullUriString; + 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, matches.toList()).toList(), + () => pages = getPages(context, matchesList).toList(), ); } else { - pages = getPages(context, matches.toList()).toList(); + pages = getPages(context, matchesList).toList(); } // note that we need to catch it this way to get all the info, e.g. the @@ -144,7 +88,7 @@ class GoRouterDelegate extends RouterDelegate> _errorPageBuilder( context, GoRouterState( - _parser, + configuration, location: location, subloc: uri.path, name: null, @@ -160,8 +104,8 @@ class GoRouterDelegate extends RouterDelegate> // pass either the match error or the build error along to the navigator // builder, preferring the match error - if (matches.length == 1 && matches.first.error != null) { - error = matches.first.error; + if (matches.isError) { + error = matches.error; } // wrap the returned Navigator to enable GoRouter.of(context).go() @@ -169,9 +113,10 @@ class GoRouterDelegate extends RouterDelegate> return builderWithNav( context, GoRouterState( - _parser, + configuration, location: location, - name: null, // no name available at the top level + // 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 @@ -181,7 +126,8 @@ class GoRouterDelegate extends RouterDelegate> ), Navigator( restorationScopeId: restorationScopeId, - key: _key, // needed to enable Android system Back button + key: navigatorKey, + // needed to enable Android system Back button pages: pages!, observers: observers, onPopPage: (Route route, dynamic result) { @@ -196,7 +142,8 @@ class GoRouterDelegate extends RouterDelegate> } /// Get the stack of sub-routes that matches the location and turn it into a - /// stack of pages, e.g. + /// stack of pages, for example: + /// /// routes: [ /// / /// family/:fid @@ -218,19 +165,19 @@ class GoRouterDelegate extends RouterDelegate> @visibleForTesting Iterable> getPages( BuildContext context, - List matches, + List matches, ) sync* { assert(matches.isNotEmpty); Map params = {}; - for (final GoRouteMatch match in matches) { + 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( - _parser, + configuration, location: match.fullUriString, subloc: match.subloc, name: match.route.name, @@ -287,16 +234,16 @@ class GoRouterDelegate extends RouterDelegate> return true; }()); _pageBuilderForAppType = pageBuilderForMaterialApp; - _errorBuilderForAppType = (BuildContext c, GoRouterState s) => - GoRouterMaterialErrorScreen(s.error); + _errorBuilderForAppType = + (BuildContext c, GoRouterState s) => MaterialErrorScreen(s.error); } else if (elem != null && isCupertinoApp(elem)) { assert(() { log.info('CupertinoApp found'); return true; }()); _pageBuilderForAppType = pageBuilderForCupertinoApp; - _errorBuilderForAppType = (BuildContext c, GoRouterState s) => - GoRouterCupertinoErrorScreen(s.error); + _errorBuilderForAppType = + (BuildContext c, GoRouterState s) => CupertinoErrorScreen(s.error); } else { assert(() { log.info('WidgetsApp found'); @@ -304,7 +251,7 @@ class GoRouterDelegate extends RouterDelegate> }()); _pageBuilderForAppType = pageBuilderForWidgetApp; _errorBuilderForAppType = - (BuildContext c, GoRouterState s) => GoRouterErrorScreen(s.error); + (BuildContext c, GoRouterState s) => ErrorScreen(s.error); } } diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart new file mode 100644 index 000000000000..321541d32016 --- /dev/null +++ b/packages/go_router/lib/src/configuration.dart @@ -0,0 +1,145 @@ +// 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 'configuration.dart'; +import 'logging.dart'; +import 'path_utils.dart'; +import 'typedefs.dart'; + +export 'route.dart'; +export 'state.dart'; + +/// The route configuration for GoRouter configured by the app. +class RouteConfiguration { + /// Constructs a [RouteConfiguration]. + RouteConfiguration({ + required this.routes, + required this.redirectLimit, + required this.topRedirect, + }) { + _cacheNameToPath('', routes); + + assert(() { + log.info(_debugKnownRoutes()); + return true; + }()); + + for (final GoRoute route in routes) { + if (!route.path.startsWith('/')) { + throw RouteConfigurationError( + 'top-level path must start with "/": ${route.path}'); + } + } + } + + /// The list of top level routes used by [GoRouterDelegate]. + final List routes; + + /// The limit for the number of consecutive redirects. + final int redirectLimit; + + /// Top level page redirect. + final GoRouterRedirect topRedirect; + + final Map _nameToPath = {}; + + /// Looks up the url location by a [GoRoute]'s name. + String namedLocation( + String name, { + Map params = const {}, + Map queryParams = const {}, + }) { + assert(() { + log.info('getting location for name: ' + '"$name"' + '${params.isEmpty ? '' : ', params: $params'}' + '${queryParams.isEmpty ? '' : ', queryParams: $queryParams'}'); + return true; + }()); + final String keyName = name.toLowerCase(); + assert(_nameToPath.containsKey(keyName), 'unknown route name: $name'); + final String path = _nameToPath[keyName]!; + assert(() { + // Check that all required params are present + final List paramNames = []; + patternToRegExp(path, paramNames); + for (final String paramName in paramNames) { + assert(params.containsKey(paramName), + 'missing param "$paramName" for $path'); + } + + // Check that there are no extra params + for (final String key in params.keys) { + assert(paramNames.contains(key), 'unknown param "$key" for $path'); + } + return true; + }()); + final Map encodedParams = { + for (final MapEntry param in params.entries) + param.key: Uri.encodeComponent(param.value) + }; + final String location = patternToPath(path, encodedParams); + return Uri(path: location, queryParameters: queryParams).toString(); + } + + @override + String toString() { + return 'RouterConfiguration: $routes'; + } + + String _debugKnownRoutes() { + final StringBuffer sb = StringBuffer(); + sb.writeln('Full paths for routes:'); + _debugFullPathsFor(routes, '', 0, sb); + + if (_nameToPath.isNotEmpty) { + sb.writeln('known full paths for route names:'); + for (final MapEntry e in _nameToPath.entries) { + sb.writeln(' ${e.key} => ${e.value}'); + } + } + + 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 _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); + } + } + } +} + +/// 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 new file mode 100644 index 000000000000..1af969c53765 --- /dev/null +++ b/packages/go_router/lib/src/delegate.dart @@ -0,0 +1,105 @@ +// 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 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'builder.dart'; +import 'configuration.dart'; +import 'match.dart'; +import 'matching.dart'; +import 'typedefs.dart'; + +/// GoRouter implementation of [RouterDelegate]. +class GoRouterDelegate extends RouterDelegate + with PopNavigatorRouterDelegateMixin, ChangeNotifier { + /// Constructor for GoRouter's implementation of the RouterDelegate base + /// class. + GoRouterDelegate({ + required RouteConfiguration configuration, + required GoRouterBuilderWithNav builderWithNav, + required GoRouterPageBuilder? errorPageBuilder, + required GoRouterWidgetBuilder? errorBuilder, + required List observers, + required this.routerNeglect, + String? restorationScopeId, + }) : builder = RouteBuilder( + configuration: configuration, + builderWithNav: builderWithNav, + errorPageBuilder: errorPageBuilder, + errorBuilder: errorBuilder, + restorationScopeId: restorationScopeId, + observers: observers, + ); + + /// Builds the top-level Navigator given a configuration and location. + @visibleForTesting + final RouteBuilder builder; + + /// Set to true to disable creating history entries on the web. + final bool routerNeglect; + + final GlobalKey _key = GlobalKey(); + + RouteMatchList _matches = RouteMatchList.empty(); + + /// Push the given location onto the page stack + void push(RouteMatch match) { + _matches.push(match); + notifyListeners(); + } + + /// Returns `true` if there is more than 1 page on the stack. + bool canPop() { + return _matches.canPop(); + } + + /// Pop the top page off the GoRouter's page stack. + void pop() { + _matches.pop(); + notifyListeners(); + } + + /// Replaces the top-most page of the page stack with the given one. + /// + /// See also: + /// * [push] which pushes the given location onto the page stack. + void replace(RouteMatch match) { + _matches.matches.last = match; + notifyListeners(); + } + + /// For internal use; visible for testing only. + @visibleForTesting + RouteMatchList get matches => _matches; + + /// For use by the Router architecture as part of the RouterDelegate. + @override + GlobalKey get navigatorKey => _key; + + /// For use by the Router architecture as part of the RouterDelegate. + @override + RouteMatchList get currentConfiguration => _matches; + + /// For use by the Router architecture as part of the RouterDelegate. + @override + Widget build(BuildContext context) => builder.build( + context, + _matches, + pop, + navigatorKey, + routerNeglect, + ); + + /// For use by the Router architecture as part of the RouterDelegate. + @override + Future setNewRoutePath(RouteMatchList configuration) { + _matches = 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/go_route_information_parser.dart b/packages/go_router/lib/src/go_route_information_parser.dart deleted file mode 100644 index a52fc6bc0acf..000000000000 --- a/packages/go_router/lib/src/go_route_information_parser.dart +++ /dev/null @@ -1,445 +0,0 @@ -// 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/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:go_router/src/go_route_information_provider.dart'; - -import 'go_route.dart'; -import 'go_route_match.dart'; -import 'go_router_state.dart'; -import 'logging.dart'; -import 'path_parser.dart'; -import 'typedefs.dart'; - -class _ParserError extends Error implements UnsupportedError { - _ParserError(this.message); - - @override - final String? message; -} - -/// GoRouter implementation of the RouteInformationParser base class -class GoRouteInformationParser - extends RouteInformationParser> { - /// Creates a [GoRouteInformationParser]. - GoRouteInformationParser({ - required this.routes, - required this.redirectLimit, - required this.topRedirect, - this.debugRequireGoRouteInformationProvider = false, - }) : assert(() { - // check top-level route paths are valid - for (final GoRoute route in routes) { - assert(route.path.startsWith('/'), - 'top-level path must start with "/": ${route.path}'); - } - return true; - }()) { - _cacheNameToPath('', routes); - assert(() { - _debugLogKnownRoutes(); - return true; - }()); - } - - /// List of top level routes used by the go router delegate. - final List routes; - - /// The limit for the number of consecutive redirects. - final int redirectLimit; - - /// Top level page redirect. - final GoRouterRedirect topRedirect; - - /// A debug property to assert [GoRouteInformationProvider] is in use along - /// with this parser. - /// - /// An assertion error will be thrown if this property set to true and the - /// [GoRouteInformationProvider] is not in use. - /// - /// Defaults to false. - final bool debugRequireGoRouteInformationProvider; - - final Map _nameToPath = {}; - - 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); - } - } - } - - /// Looks up the url location by a [GoRoute]'s name. - String namedLocation( - String name, { - Map params = const {}, - Map queryParams = const {}, - }) { - assert(() { - log.info('getting location for name: ' - '"$name"' - '${params.isEmpty ? '' : ', params: $params'}' - '${queryParams.isEmpty ? '' : ', queryParams: $queryParams'}'); - return true; - }()); - final String keyName = name.toLowerCase(); - assert(_nameToPath.containsKey(keyName), 'unknown route name: $name'); - final String path = _nameToPath[keyName]!; - assert(() { - // Check that all required params are present. - final List paramNames = []; - patternToRegExp(path, paramNames); - for (final String paramName in paramNames) { - assert(params.containsKey(paramName), - 'missing param "$paramName" for $path'); - } - - // Check that there are no extra params - for (final String key in params.keys) { - assert(paramNames.contains(key), 'unknown param "$key" for $path'); - } - return true; - }()); - final Map encodedParams = { - for (final MapEntry param in params.entries) - param.key: Uri.encodeComponent(param.value) - }; - final String location = patternToPath(path, encodedParams); - return Uri(path: location, queryParameters: queryParams).toString(); - } - - /// Concatenates two paths. - /// - /// e.g: pathA = /a, pathB = c/d, concatenatePaths(pathA, pathB) = /a/c/d. - static String concatenatePaths(String parentPath, String childPath) { - // at the root, just return the path - if (parentPath.isEmpty) { - assert(childPath.startsWith('/')); - assert(childPath == '/' || !childPath.endsWith('/')); - return childPath; - } - - // not at the root, so append the parent path - assert(childPath.isNotEmpty); - assert(!childPath.startsWith('/')); - assert(!childPath.endsWith('/')); - return '${parentPath == '/' ? '' : parentPath}/$childPath'; - } - - /// for use by the Router architecture as part of the RouteInformationParser - @override - Future> parseRouteInformation( - RouteInformation routeInformation, - ) { - assert(() { - if (debugRequireGoRouteInformationProvider) { - assert( - routeInformation is DebugGoRouteInformation, - 'This GoRouteInformationParser needs to be used with ' - 'GoRouteInformationProvider, did you forget to pass in ' - 'GoRouter.routeInformationProvider to the Router constructor?'); - } - return true; - }()); - final List matches = - _getLocRouteMatchesWithRedirects(routeInformation); - // Use [SynchronousFuture] so that the initial url is processed - // synchronously and remove unwanted initial animations on deep-linking - return SynchronousFuture>(matches); - } - - List _getLocRouteMatchesWithRedirects( - RouteInformation routeInformation) { - // start redirecting from the initial location - List matches; - final String location = routeInformation.location!; - try { - // watch redirects for loops - final List redirects = [_canonicalUri(location)]; - bool redirected(String? redir) { - if (redir == null) { - return false; - } - - assert(() { - if (Uri.tryParse(redir) == null) { - throw _ParserError('invalid redirect: $redir'); - } - if (redirects.contains(redir)) { - throw _ParserError('redirect loop detected: ${[ - ...redirects, - redir - ].join(' => ')}'); - } - if (redirects.length > redirectLimit) { - throw _ParserError('too many redirects: ${[ - ...redirects, - redir - ].join(' => ')}'); - } - return true; - }()); - - redirects.add(redir); - assert(() { - log.info('redirecting to $redir'); - return true; - }()); - return true; - } - - // keep looping till we're done redirecting - while (true) { - final String loc = redirects.last; - - // check for top-level redirect - final Uri uri = Uri.parse(loc); - if (redirected( - topRedirect( - GoRouterState( - this, - location: loc, - name: null, // no name available at the top level - // 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, - extra: routeInformation.state, - ), - ), - )) { - continue; - } - - // get stack of route matches - matches = _getLocRouteMatches(loc, routeInformation.state); - - // merge new params to keep params from previously matched paths, e.g. - // /family/:fid/person/:pid provides fid and pid to person/:pid - Map previouslyMatchedParams = {}; - for (final GoRouteMatch match in matches) { - assert( - !previouslyMatchedParams.keys.any(match.encodedParams.containsKey), - 'Duplicated parameter names', - ); - match.encodedParams.addAll(previouslyMatchedParams); - previouslyMatchedParams = match.encodedParams; - } - - // check top route for redirect - final GoRouteMatch top = matches.last; - if (redirected( - top.route.redirect( - GoRouterState( - this, - location: loc, - subloc: top.subloc, - name: top.route.name, - path: top.route.path, - fullpath: top.fullpath, - params: top.decodedParams, - queryParams: top.queryParams, - ), - ), - )) { - continue; - } - - // no more redirects! - break; - } - - // 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 - } on _ParserError catch (err) { - // create a match that routes to the error page - final Exception error = Exception(err.message); - final Uri uri = Uri.parse(location); - matches = [ - GoRouteMatch( - subloc: uri.path, - fullpath: uri.path, - encodedParams: {}, - queryParams: uri.queryParameters, - extra: null, - error: error, - route: GoRoute( - path: location, - pageBuilder: (BuildContext context, GoRouterState state) { - throw UnimplementedError(); - }), - ), - ]; - } - assert(matches.isNotEmpty); - return matches; - } - - List _getLocRouteMatches(String location, Object? extra) { - final Uri uri = Uri.parse(location); - final List result = _getLocRouteRecursively( - loc: uri.path, - restLoc: uri.path, - routes: routes, - parentFullpath: '', - parentSubloc: '', - queryParams: uri.queryParameters, - extra: extra, - ); - - if (result.isEmpty) { - throw _ParserError('no routes for location: $location'); - } - - return result; - } - - static List _getLocRouteRecursively({ - required String loc, - required String restLoc, - required String parentSubloc, - required List routes, - required String parentFullpath, - required Map queryParams, - required Object? extra, - }) { - bool debugGatherAllMatches = false; - assert(() { - debugGatherAllMatches = true; - return true; - }()); - 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); - final GoRouteMatch? match = GoRouteMatch.match( - route: route, - restLoc: restLoc, - parentSubloc: parentSubloc, - fullpath: fullpath, - queryParams: queryParams, - extra: extra, - ); - - if (match == null) { - continue; - } - if (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 - result.add([match]); - } else if (route.routes.isEmpty) { - // If it is partial match but no sub-routes, bail. - 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 List subRouteMatch = _getLocRouteRecursively( - loc: loc, - restLoc: childRestLoc, - parentSubloc: match.subloc, - routes: route.routes, - parentFullpath: fullpath, - queryParams: queryParams, - extra: extra, - ).toList(); - - // if there's no sub-route matches, there is no match for this - // location - if (subRouteMatch.isEmpty) { - continue; - } - result.add([match, ...subRouteMatch]); - } - // Should only reach here if there is a match. - if (debugGatherAllMatches) { - continue; - } else { - break; - } - } - - if (result.isEmpty) { - return []; - } - - // 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 - return result.first; - } - - void _debugLogKnownRoutes() { - log.info('known full paths for routes:'); - _debugLogFullPathsFor(routes, '', 0); - - if (_nameToPath.isNotEmpty) { - log.info('known full paths for route names:'); - for (final MapEntry e in _nameToPath.entries) { - log.info(' ${e.key} => ${e.value}'); - } - } - } - - void _debugLogFullPathsFor( - List routes, - String parentFullpath, - int depth, - ) { - for (final GoRoute route in routes) { - final String fullpath = concatenatePaths(parentFullpath, route.path); - assert(() { - log.info(' => ${''.padLeft(depth * 2)}$fullpath'); - return true; - }()); - _debugLogFullPathsFor(route.routes, fullpath, depth + 1); - } - } - - /// for use by the Router architecture as part of the RouteInformationParser - @override - RouteInformation restoreRouteInformation(List configuration) { - return RouteInformation( - location: configuration.last.fullUriString, - state: configuration.last.extra); - } -} - -/// Normalizes the location string. -String _canonicalUri(String loc) { - String canon = Uri.parse(loc).toString(); - canon = canon.endsWith('?') ? canon.substring(0, canon.length - 1) : canon; - - // remove trailing slash except for when you shouldn't, e.g. - // /profile/ => /profile - // / => / - // /login?from=/ => login?from=/ - canon = canon.endsWith('/') && canon != '/' && !canon.contains('?') - ? canon.substring(0, canon.length - 1) - : canon; - - // /login/?from=/ => /login?from=/ - // /?from=/ => /?from=/ - canon = canon.replaceFirst('/?', '?', 1); - - return canon; -} diff --git a/packages/go_router/lib/src/go_route_information_provider.dart b/packages/go_router/lib/src/information_provider.dart similarity index 93% rename from packages/go_router/lib/src/go_route_information_provider.dart rename to packages/go_router/lib/src/information_provider.dart index 80c5a369b5a6..0afeee561a23 100644 --- a/packages/go_router/lib/src/go_route_information_provider.dart +++ b/packages/go_router/lib/src/information_provider.dart @@ -4,9 +4,9 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; -import 'package:go_router/src/go_route_information_parser.dart'; +import 'parser.dart'; -/// The route information provider created by go_router +/// The [RouteInformationProvider] created by go_router. class GoRouteInformationProvider extends RouteInformationProvider with WidgetsBindingObserver, ChangeNotifier { /// Creates a [GoRouteInformationProvider]. @@ -32,8 +32,8 @@ class GoRouteInformationProvider extends RouteInformationProvider (type == RouteInformationReportingType.none && _valueInEngine.location == routeInformation.location); SystemNavigator.selectMultiEntryHistory(); - // TODO(chunhtai): should report extra to the browser through state if - // possible. + // TODO(chunhtai): report extra to browser through state if possible + // See https://github.com/flutter/flutter/issues/108142 SystemNavigator.routeInformationUpdated( location: routeInformation.location!, replace: replace, @@ -48,6 +48,7 @@ class GoRouteInformationProvider extends RouteInformationProvider state: _value.state, ); RouteInformation _value; + set value(RouteInformation other) { final bool shouldNotify = _value.location != other.location || _value.state != other.state; @@ -113,7 +114,7 @@ class GoRouteInformationProvider extends RouteInformationProvider /// A debug class that is used for asserting the [GoRouteInformationProvider] is /// in use with the [GoRouteInformationParser]. class DebugGoRouteInformation extends RouteInformation { - /// Creates a [DebugGoRouteInformation] + /// Creates a [DebugGoRouteInformation]. DebugGoRouteInformation({String? location, Object? state}) : super(location: location, state: state); } diff --git a/packages/go_router/lib/src/go_route_match.dart b/packages/go_router/lib/src/match.dart similarity index 82% rename from packages/go_router/lib/src/go_route_match.dart rename to packages/go_router/lib/src/match.dart index 35a03aa9c603..d7f096e8a1b4 100644 --- a/packages/go_router/lib/src/go_route_match.dart +++ b/packages/go_router/lib/src/match.dart @@ -4,16 +4,15 @@ import 'package:flutter/foundation.dart'; -import 'go_route.dart'; -import 'go_route_information_parser.dart'; -import 'path_parser.dart'; - -/// Each GoRouteMatch instance represents an instance of a GoRoute for a -/// specific portion of a location. -class GoRouteMatch { - /// Constructor for GoRouteMatch, each instance represents an instance of a - /// GoRoute for a specific portion of a location. - GoRouteMatch({ +import 'path_utils.dart'; +import 'route.dart'; + +/// Each RouteMatch instance represents an instance of a GoRoute for a specific +/// portion of a location. +class RouteMatch { + /// Constructor for [RouteMatch], each instance represents an instance of a + /// [GoRoute] for a specific portion of a location. + RouteMatch({ required this.route, required this.subloc, required this.fullpath, @@ -36,7 +35,7 @@ class GoRouteMatch { }()); // ignore: public_member_api_docs - static GoRouteMatch? match({ + static RouteMatch? match({ required GoRoute route, required String restLoc, // e.g. person/p1 required String parentSubloc, // e.g. /family/f2 @@ -53,9 +52,8 @@ class GoRouteMatch { final Map encodedParams = route.extractPathParams(match); final String pathLoc = patternToPath(route.path, encodedParams); - final String subloc = - GoRouteInformationParser.concatenatePaths(parentSubloc, pathLoc); - return GoRouteMatch( + final String subloc = concatenatePaths(parentSubloc, pathLoc); + return RouteMatch( route: route, subloc: subloc, fullpath: fullpath, @@ -108,7 +106,7 @@ class GoRouteMatch { param.key: Uri.decodeComponent(param.value) }; - /// for use by the Router architecture as part of the GoRouteMatch + /// For use by the Router architecture as part of the RouteMatch @override - String toString() => 'GoRouteMatch($fullpath, $encodedParams)'; + String toString() => 'RouteMatch($fullpath, $encodedParams)'; } diff --git a/packages/go_router/lib/src/matching.dart b/packages/go_router/lib/src/matching.dart new file mode 100644 index 000000000000..755aed67078a --- /dev/null +++ b/packages/go_router/lib/src/matching.dart @@ -0,0 +1,195 @@ +// 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 'configuration.dart'; +import 'match.dart'; +import 'path_utils.dart'; + +/// Converts a location into a list of [RouteMatch] objects. +class RouteMatcher { + /// [RouteMatcher] constructor. + RouteMatcher(this.configuration); + + /// The route configuration. + final RouteConfiguration configuration; + + /// Finds the routes that matched the given URL. + RouteMatchList findMatch(String location, {Object? extra}) { + final String canonicalLocation = canonicalUri(location); + final List matches = + _getLocRouteMatches(canonicalLocation, extra); + return RouteMatchList(matches); + } + + List _getLocRouteMatches(String location, Object? extra) { + final Uri uri = Uri.parse(location); + final List result = _getLocRouteRecursively( + loc: uri.path, + restLoc: uri.path, + routes: configuration.routes, + parentFullpath: '', + parentSubloc: '', + queryParams: uri.queryParameters, + extra: extra, + ); + + if (result.isEmpty) { + throw MatcherError('no routes for location', location); + } + + return result; + } +} + +/// The list of [RouteMatch] objects. +class RouteMatchList { + /// RouteMatchList constructor. + RouteMatchList(this._matches); + + /// Constructs an empty matches object. + factory RouteMatchList.empty() => RouteMatchList([]); + + final List _matches; + + /// Returns true if there are no matches. + bool get isEmpty => _matches.isEmpty; + + /// Returns true if there are matches. + bool get isNotEmpty => _matches.isNotEmpty; + + /// The original URL that was matched. + Uri get location => + _matches.isEmpty ? Uri() : Uri.parse(_matches.last.fullUriString); + + /// Pushes a match onto the list of matches. + void push(RouteMatch match) { + _matches.add(match); + } + + /// 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; + } + + /// An optional object provided by the app during navigation. + Object? get extra => _matches.last.extra; + + /// The last matching route. + RouteMatch get last => _matches.last; + + /// The route matches. + List get matches => _matches; + + /// Returns true if the current match intends to display an error screen. + bool get isError => matches.length == 1 && matches.first.error != null; + + /// Returns the error that this match intends to display. + Exception? get error => matches.first.error; +} + +/// An error that occurred during matching. +class MatcherError extends Error { + /// Constructs a [MatcherError]. + MatcherError(String message, this.location) + : message = message + ': $location'; + + /// The error message. + final String message; + + /// The location that failed to match. + final String location; + + @override + String toString() { + return message; + } +} + +List _getLocRouteRecursively({ + required String loc, + required String restLoc, + required String parentSubloc, + required List routes, + required String parentFullpath, + required Map queryParams, + required Object? extra, +}) { + bool debugGatherAllMatches = false; + assert(() { + debugGatherAllMatches = true; + return true; + }()); + 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); + final RouteMatch? match = RouteMatch.match( + route: route, + restLoc: restLoc, + parentSubloc: parentSubloc, + fullpath: fullpath, + queryParams: queryParams, + extra: extra, + ); + + if (match == null) { + continue; + } + if (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 + result.add([match]); + } else if (route.routes.isEmpty) { + // If it is partial match but no sub-routes, bail. + 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 List subRouteMatch = _getLocRouteRecursively( + loc: loc, + restLoc: childRestLoc, + parentSubloc: match.subloc, + routes: route.routes, + parentFullpath: fullpath, + queryParams: queryParams, + extra: extra, + ).toList(); + + // If there's no sub-route matches, there is no match for this location + if (subRouteMatch.isEmpty) { + continue; + } + result.add([match, ...subRouteMatch]); + } + // Should only reach here if there is a match. + if (debugGatherAllMatches) { + continue; + } else { + break; + } + } + + if (result.isEmpty) { + return []; + } + + // 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 + return result.first; +} diff --git a/packages/go_router/lib/src/go_router_error_page.dart b/packages/go_router/lib/src/misc/error_screen.dart similarity index 90% rename from packages/go_router/lib/src/go_router_error_page.dart rename to packages/go_router/lib/src/misc/error_screen.dart index f9c1e68b0f2b..4572b977de4e 100644 --- a/packages/go_router/lib/src/go_router_error_page.dart +++ b/packages/go_router/lib/src/misc/error_screen.dart @@ -2,15 +2,14 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// ignore_for_file: diagnostic_describe_all_properties - import 'package:flutter/widgets.dart'; -import '../go_router.dart'; + +import 'extensions.dart'; /// Default error page implementation for WidgetsApp. -class GoRouterErrorScreen extends StatelessWidget { +class ErrorScreen extends StatelessWidget { /// Provide an exception to this page for it to be displayed. - const GoRouterErrorScreen(this.error, {Key? key}) : super(key: key); + const ErrorScreen(this.error, {Key? key}) : super(key: key); /// The exception to be displayed. final Exception? error; diff --git a/packages/go_router/lib/src/misc/extensions.dart b/packages/go_router/lib/src/misc/extensions.dart new file mode 100644 index 000000000000..c818c2cd5705 --- /dev/null +++ b/packages/go_router/lib/src/misc/extensions.dart @@ -0,0 +1,92 @@ +// 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 '../router.dart'; + +/// Dart extension to add navigation function to a BuildContext object, e.g. +/// context.go('/'); +extension GoRouterHelper on BuildContext { + /// Get a location from route name and parameters. + String namedLocation( + String name, { + Map params = const {}, + Map queryParams = const {}, + }) => + GoRouter.of(this) + .namedLocation(name, params: params, queryParams: queryParams); + + /// Navigate to a location. + void go(String location, {Object? extra}) => + GoRouter.of(this).go(location, extra: extra); + + /// Navigate to a named route. + void goNamed( + String name, { + Map params = const {}, + Map queryParams = const {}, + Object? extra, + }) => + GoRouter.of(this).goNamed( + name, + params: params, + queryParams: queryParams, + extra: extra, + ); + + /// Push a location onto the page stack. + void push(String location, {Object? extra}) => + GoRouter.of(this).push(location, extra: extra); + + /// Navigate to a named route onto the page stack. + void pushNamed( + String name, { + Map params = const {}, + Map queryParams = const {}, + Object? extra, + }) => + GoRouter.of(this).pushNamed( + name, + params: params, + queryParams: queryParams, + extra: extra, + ); + + /// Returns `true` if there is more than 1 page on the stack. + bool canPop() => GoRouter.of(this).canPop(); + + /// Pop the top page off the Navigator's page stack by calling + /// [Navigator.pop]. + void pop() => GoRouter.of(this).pop(); + + /// Replaces the top-most page of the page stack with the given URL location + /// w/ optional query parameters, e.g. `/family/f2/person/p1?color=blue`. + /// + /// See also: + /// * [go] which navigates to the location. + /// * [push] which pushes the location onto the page stack. + void replace(String location, {Object? extra}) => + GoRouter.of(this).replace(location, extra: extra); + + /// Replaces the top-most page of the page stack with the named route w/ + /// optional parameters, e.g. `name='person', params={'fid': 'f2', 'pid': + /// 'p1'}`. + /// + /// See also: + /// * [goNamed] which navigates a named route. + /// * [pushNamed] which pushes a named route onto the page stack. + void replaceNamed( + String name, { + Map params = const {}, + Map queryParams = const {}, + Object? extra, + }) => + GoRouter.of(this).replaceNamed( + name, + params: params, + queryParams: queryParams, + extra: extra, + ); +} diff --git a/packages/go_router/lib/src/inherited_go_router.dart b/packages/go_router/lib/src/misc/inherited_router.dart similarity index 98% rename from packages/go_router/lib/src/inherited_go_router.dart rename to packages/go_router/lib/src/misc/inherited_router.dart index b746612116fe..3aec90220c51 100644 --- a/packages/go_router/lib/src/inherited_go_router.dart +++ b/packages/go_router/lib/src/misc/inherited_router.dart @@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import 'go_router.dart'; +import '../router.dart'; /// GoRouter implementation of InheritedWidget. /// diff --git a/packages/go_router/lib/src/go_router_refresh_stream.dart b/packages/go_router/lib/src/misc/refresh_stream.dart similarity index 81% rename from packages/go_router/lib/src/go_router_refresh_stream.dart rename to packages/go_router/lib/src/misc/refresh_stream.dart index 78606409cdde..01acb43a6e4d 100644 --- a/packages/go_router/lib/src/go_router_refresh_stream.dart +++ b/packages/go_router/lib/src/misc/refresh_stream.dart @@ -7,11 +7,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'go_router.dart'; - -/// This class can be used to make `refreshListenable` react to events in the -/// the provided stream. This allows you to listen to stream based state -/// management solutions like for example BLoC. +/// Converts a [Stream] into a [Listenable] /// /// {@tool snippet} /// Typical usage is as follows: diff --git a/packages/go_router/lib/src/go_router_cupertino.dart b/packages/go_router/lib/src/pages/cupertino.dart similarity index 90% rename from packages/go_router/lib/src/go_router_cupertino.dart rename to packages/go_router/lib/src/pages/cupertino.dart index 0314718be177..6c9d1a97abd4 100644 --- a/packages/go_router/lib/src/go_router_cupertino.dart +++ b/packages/go_router/lib/src/pages/cupertino.dart @@ -5,7 +5,7 @@ // ignore_for_file: diagnostic_describe_all_properties import 'package:flutter/cupertino.dart'; -import '../go_router.dart'; +import '../misc/extensions.dart'; /// Checks for CupertinoApp in the widget tree. bool isCupertinoApp(Element elem) => @@ -28,9 +28,9 @@ CupertinoPage pageBuilderForCupertinoApp({ ); /// Default error page implementation for Cupertino. -class GoRouterCupertinoErrorScreen extends StatelessWidget { +class CupertinoErrorScreen extends StatelessWidget { /// Provide an exception to this page for it to be displayed. - const GoRouterCupertinoErrorScreen(this.error, {Key? key}) : super(key: key); + const CupertinoErrorScreen(this.error, {Key? key}) : super(key: key); /// The exception to be displayed. final Exception? error; diff --git a/packages/go_router/lib/src/custom_transition_page.dart b/packages/go_router/lib/src/pages/custom_transition_page.dart similarity index 100% rename from packages/go_router/lib/src/custom_transition_page.dart rename to packages/go_router/lib/src/pages/custom_transition_page.dart diff --git a/packages/go_router/lib/src/go_router_material.dart b/packages/go_router/lib/src/pages/material.dart similarity index 89% rename from packages/go_router/lib/src/go_router_material.dart rename to packages/go_router/lib/src/pages/material.dart index 1b01920a2661..3eb656b4a32b 100644 --- a/packages/go_router/lib/src/go_router_material.dart +++ b/packages/go_router/lib/src/pages/material.dart @@ -5,7 +5,8 @@ // ignore_for_file: diagnostic_describe_all_properties import 'package:flutter/material.dart'; -import '../go_router.dart'; + +import '../misc/extensions.dart'; /// Checks for MaterialApp in the widget tree. bool isMaterialApp(Element elem) => @@ -28,9 +29,9 @@ MaterialPage pageBuilderForMaterialApp({ ); /// Default error page implementation for Material. -class GoRouterMaterialErrorScreen extends StatelessWidget { +class MaterialErrorScreen extends StatelessWidget { /// Provide an exception to this page for it to be displayed. - const GoRouterMaterialErrorScreen(this.error, {Key? key}) : super(key: key); + const MaterialErrorScreen(this.error, {Key? key}) : super(key: key); /// The exception to be displayed. final Exception? error; diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart new file mode 100644 index 000000000000..3f716e44c7b1 --- /dev/null +++ b/packages/go_router/lib/src/parser.dart @@ -0,0 +1,127 @@ +// 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/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'configuration.dart'; +import 'information_provider.dart'; +import 'logging.dart'; +import 'match.dart'; +import 'matching.dart'; +import 'redirection.dart'; + +/// Converts between incoming URLs and a [RouteMatchList] using [RouteMatcher]. +/// Also performs redirection using [RouteRedirector]. +class GoRouteInformationParser extends RouteInformationParser { + /// Creates a [GoRouteInformationParser]. + GoRouteInformationParser({ + required this.configuration, + this.debugRequireGoRouteInformationProvider = false, + }) : matcher = RouteMatcher(configuration), + redirector = redirect; + + /// The route configuration for the app. + final RouteConfiguration configuration; + + /// The route matcher. + final RouteMatcher matcher; + + /// The route redirector. + final RouteRedirector redirector; + + /// A debug property to assert [GoRouteInformationProvider] is in use along + /// with this parser. + /// + /// An assertion error will be thrown if this property set to true and the + /// [GoRouteInformationProvider] is not in use. + /// + /// Defaults to false. + final bool debugRequireGoRouteInformationProvider; + + /// Called by the [Router]. The + @override + Future parseRouteInformation( + RouteInformation routeInformation, + ) { + assert(() { + if (debugRequireGoRouteInformationProvider) { + assert( + routeInformation is DebugGoRouteInformation, + 'This GoRouteInformationParser needs to be used with ' + 'GoRouteInformationProvider, did you forget to pass in ' + 'GoRouter.routeInformationProvider to the Router constructor?', + ); + } + return true; + }()); + try { + late final RouteMatchList initialMatches; + try { + initialMatches = matcher.findMatch(routeInformation.location!, + extra: routeInformation.state); + } on MatcherError { + log.info('No initial matches: ${routeInformation.location}'); + + // If there is a matching error for the initial location, we should + // still try to process the top-level redirects. + initialMatches = RouteMatchList.empty(); + } + final RouteMatchList matches = redirector( + initialMatches, configuration, matcher, + extra: routeInformation.state); + if (matches.isEmpty) { + return SynchronousFuture(_errorScreen( + Uri.parse(routeInformation.location!), + MatcherError('no routes for location', routeInformation.location!) + .toString())); + } + + // Use [SynchronousFuture] so that the initial url is processed + // synchronously and remove unwanted initial animations on deep-linking + return SynchronousFuture(matches); + } on RedirectionError catch (e) { + log.info('Redirection error: ${e.message}'); + final Uri uri = e.location; + return SynchronousFuture(_errorScreen(uri, e.message)); + } on MatcherError catch (e) { + // The RouteRedirector uses the matcher to find the match, so a match + // exception can happen during redirection. For example, the redirector + // redirects from `/a` to `/b`, it needs to get the matches for `/b`. + log.info('Match error: ${e.message}'); + final Uri uri = Uri.parse(e.location); + return SynchronousFuture(_errorScreen(uri, e.message)); + } + } + + /// for use by the Router architecture as part of the RouteInformationParser + @override + RouteInformation restoreRouteInformation(RouteMatchList configuration) { + return RouteInformation( + location: configuration.location.toString(), + state: configuration.extra, + ); + } + + /// Creates a match that routes to the error page. + RouteMatchList _errorScreen(Uri uri, String errorMessage) { + final Exception error = Exception(errorMessage); + return RouteMatchList([ + RouteMatch( + subloc: uri.path, + fullpath: uri.path, + encodedParams: {}, + queryParams: uri.queryParameters, + extra: null, + error: error, + route: GoRoute( + path: uri.toString(), + pageBuilder: (BuildContext context, GoRouterState state) { + throw UnimplementedError(); + }, + ), + ), + ]); + } +} diff --git a/packages/go_router/lib/src/path_parser.dart b/packages/go_router/lib/src/path_utils.dart similarity index 74% rename from packages/go_router/lib/src/path_parser.dart rename to packages/go_router/lib/src/path_utils.dart index bf126330921b..b10a6cc1077f 100644 --- a/packages/go_router/lib/src/path_parser.dart +++ b/packages/go_router/lib/src/path_utils.dart @@ -94,3 +94,41 @@ Map extractPathParameters( parameters[i]: match.namedGroup(parameters[i])! }; } + +/// Concatenates two paths. +/// +/// e.g: pathA = /a, pathB = c/d, concatenatePaths(pathA, pathB) = /a/c/d. +String concatenatePaths(String parentPath, String childPath) { + // at the root, just return the path + if (parentPath.isEmpty) { + assert(childPath.startsWith('/')); + assert(childPath == '/' || !childPath.endsWith('/')); + return childPath; + } + + // not at the root, so append the parent path + assert(childPath.isNotEmpty); + assert(!childPath.startsWith('/')); + assert(!childPath.endsWith('/')); + return '${parentPath == '/' ? '' : parentPath}/$childPath'; +} + +/// Normalizes the location string. +String canonicalUri(String loc) { + String canon = Uri.parse(loc).toString(); + canon = canon.endsWith('?') ? canon.substring(0, canon.length - 1) : canon; + + // remove trailing slash except for when you shouldn't, e.g. + // /profile/ => /profile + // / => / + // /login?from=/ => login?from=/ + canon = canon.endsWith('/') && canon != '/' && !canon.contains('?') + ? canon.substring(0, canon.length - 1) + : canon; + + // /login/?from=/ => /login?from=/ + // /?from=/ => /?from=/ + canon = canon.replaceFirst('/?', '?', 1); + + return canon; +} diff --git a/packages/go_router/lib/src/platform.dart b/packages/go_router/lib/src/platform.dart new file mode 100644 index 000000000000..db4c7b8a8a60 --- /dev/null +++ b/packages/go_router/lib/src/platform.dart @@ -0,0 +1,8 @@ +// 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. + +// TODO(johnpryan): Remove this API +export 'platform/path_strategy_nonweb.dart' + if (dart.library.html) 'platform/path_strategy_web.dart'; +export 'platform/url_path_strategy.dart'; diff --git a/packages/go_router/lib/src/path_strategy_nonweb.dart b/packages/go_router/lib/src/platform/path_strategy_nonweb.dart similarity index 86% rename from packages/go_router/lib/src/path_strategy_nonweb.dart rename to packages/go_router/lib/src/platform/path_strategy_nonweb.dart index 3b9240031f46..8caf922b680b 100644 --- a/packages/go_router/lib/src/path_strategy_nonweb.dart +++ b/packages/go_router/lib/src/platform/path_strategy_nonweb.dart @@ -4,7 +4,8 @@ import 'url_path_strategy.dart'; -/// no-op implementation of the URL path strategy for non-web target platforms +/// no-op implementation of the URL path strategy for non-web target platforms. +// TODO(johnpryan): Remove this API void setUrlPathStrategyImpl(UrlPathStrategy strategy) { // no-op } diff --git a/packages/go_router/lib/src/path_strategy_web.dart b/packages/go_router/lib/src/platform/path_strategy_web.dart similarity index 86% rename from packages/go_router/lib/src/path_strategy_web.dart rename to packages/go_router/lib/src/platform/path_strategy_web.dart index ede76ec4350d..c4dc30bdb1e7 100644 --- a/packages/go_router/lib/src/path_strategy_web.dart +++ b/packages/go_router/lib/src/platform/path_strategy_web.dart @@ -7,8 +7,8 @@ import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'url_path_strategy.dart'; -/// forwarding implementation of the URL path strategy for the web target -/// platform +/// Forwarding implementation of the URL path strategy for the web target +/// platform. void setUrlPathStrategyImpl(UrlPathStrategy strategy) { setUrlStrategy(strategy == UrlPathStrategy.path ? PathUrlStrategy() diff --git a/packages/go_router/lib/src/url_path_strategy.dart b/packages/go_router/lib/src/platform/url_path_strategy.dart similarity index 100% rename from packages/go_router/lib/src/url_path_strategy.dart rename to packages/go_router/lib/src/platform/url_path_strategy.dart diff --git a/packages/go_router/lib/src/redirection.dart b/packages/go_router/lib/src/redirection.dart new file mode 100644 index 000000000000..29b541d786c2 --- /dev/null +++ b/packages/go_router/lib/src/redirection.dart @@ -0,0 +1,145 @@ +// 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 'configuration.dart'; +import 'logging.dart'; +import 'match.dart'; +import 'matching.dart'; + +/// A GoRouter redirector function. +// TODO(johnpryan): make redirector async +// See https://github.com/flutter/flutter/issues/105808 +typedef RouteRedirector = RouteMatchList Function(RouteMatchList matches, + RouteConfiguration configuration, RouteMatcher matcher, + {Object? extra}); + +/// Processes redirects by returning a new [RouteMatchList] representing the new +/// location. +RouteMatchList redirect(RouteMatchList prevMatchList, + RouteConfiguration configuration, RouteMatcher matcher, + {Object? extra}) { + RouteMatchList matches; + + // Store each redirect to detect loops + final List redirects = [prevMatchList]; + + // Keep looping until redirecting is done + while (true) { + final RouteMatchList currentMatches = redirects.last; + + // Check for top-level redirect + final Uri uri = currentMatches.location; + final String? topRedirectLocation = configuration.topRedirect( + GoRouterState( + configuration, + location: currentMatches.location.toString(), + name: null, + // No name available at the top level trim the query params off the + // sub-location to match route.redirect + subloc: uri.path, + queryParams: uri.queryParameters, + extra: extra, + ), + ); + + if (topRedirectLocation != null) { + final RouteMatchList newMatch = matcher.findMatch(topRedirectLocation); + _addRedirect(redirects, newMatch, prevMatchList.location, + configuration.redirectLimit); + continue; + } + + // If there's no top-level redirect, keep the matches the same as before. + matches = currentMatches; + + // Merge new params to keep params from previously matched paths, e.g. + // /users/:userId/book/:bookId provides userId and bookId to book/:bookId + Map previouslyMatchedParams = {}; + for (final RouteMatch match in currentMatches.matches) { + assert( + !previouslyMatchedParams.keys.any(match.encodedParams.containsKey), + 'Duplicated parameter names', + ); + match.encodedParams.addAll(previouslyMatchedParams); + previouslyMatchedParams = match.encodedParams; + } + + // check top route for redirect + final RouteMatch? top = matches.isNotEmpty ? matches.last : null; + if (top == null) { + break; + } + final String? topRouteLocation = top.route.redirect( + GoRouterState( + configuration, + location: currentMatches.location.toString(), + subloc: top.subloc, + name: top.route.name, + path: top.route.path, + fullpath: top.fullpath, + params: top.decodedParams, + queryParams: top.queryParams, + ), + ); + + if (topRouteLocation == null) { + break; + } + + final RouteMatchList newMatchList = matcher.findMatch(topRouteLocation); + _addRedirect(redirects, newMatchList, prevMatchList.location, + configuration.redirectLimit); + continue; + } + return matches; +} + +/// A configuration error detected while processing redirects. +class RedirectionError extends Error implements UnsupportedError { + /// RedirectionError constructor. + RedirectionError(this.message, this.matches, this.location); + + /// The matches that were found while processing redirects. + final List matches; + + @override + final String message; + + /// The location that was originally navigated to, before redirection began. + final Uri location; + + @override + String toString() => + super.toString() + + ' ' + + [ + ...matches.map( + (RouteMatchList routeMatches) => routeMatches.location.toString()), + ].join(' => '); +} + +/// Adds the redirect to [redirects] if it is valid. +void _addRedirect(List redirects, RouteMatchList newMatch, + Uri prevLocation, int redirectLimit) { + // Verify that the redirect can be parsed and is not already + // in the list of redirects + assert(() { + if (redirects.contains(newMatch)) { + throw RedirectionError('redirect loop detected', + [...redirects, newMatch], prevLocation); + } + if (redirects.length > redirectLimit) { + throw RedirectionError('too many redirects', + [...redirects, newMatch], prevLocation); + } + return true; + }()); + + redirects.add(newMatch); + + assert(() { + log.info('redirecting to $newMatch'); + return true; + }()); +} diff --git a/packages/go_router/lib/src/go_route.dart b/packages/go_router/lib/src/route.dart similarity index 95% rename from packages/go_router/lib/src/go_route.dart rename to packages/go_router/lib/src/route.dart index 832c4e8ae874..d7533132ef25 100644 --- a/packages/go_router/lib/src/go_route.dart +++ b/packages/go_router/lib/src/route.dart @@ -5,15 +5,14 @@ import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; -import 'custom_transition_page.dart'; -import 'go_router_state.dart'; -import 'path_parser.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. + /// Default constructor used to create mapping between a route path and a page + /// builder. GoRoute({ required this.path, this.name, @@ -27,7 +26,8 @@ class GoRoute { pageBuilder != null || builder != _invalidBuilder || redirect != _noRedirection, - 'GoRoute builder parameter not set\nSee gorouter.dev/redirection#considerations for details') { + 'GoRoute builder parameter not set\n' + 'See gorouter.dev/redirection#considerations for details') { // cache the path regexp and parameters _pathRE = patternToRegExp(path, _pathParams); diff --git a/packages/go_router/lib/src/go_router.dart b/packages/go_router/lib/src/router.dart similarity index 74% rename from packages/go_router/lib/src/go_router.dart rename to packages/go_router/lib/src/router.dart index 902c10f2e7c9..c49c3563675c 100644 --- a/packages/go_router/lib/src/go_router.dart +++ b/packages/go_router/lib/src/router.dart @@ -4,18 +4,15 @@ import 'package:flutter/widgets.dart'; -import 'go_route.dart'; -import 'go_route_information_parser.dart'; -import 'go_route_information_provider.dart'; -import 'go_route_match.dart'; -import 'go_router_delegate.dart'; -import 'go_router_state.dart'; -import 'inherited_go_router.dart'; +import 'configuration.dart'; +import 'delegate.dart'; +import 'information_provider.dart'; import 'logging.dart'; -import 'path_strategy_nonweb.dart' - if (dart.library.html) 'path_strategy_web.dart'; +import 'matching.dart'; +import 'misc/inherited_router.dart'; +import 'parser.dart'; +import 'platform.dart'; import 'typedefs.dart'; -import 'url_path_strategy.dart'; /// The top-level go router class. /// @@ -26,6 +23,8 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { /// and an error page builder. GoRouter({ required List routes, + // TODO(johnpryan): Change to a route, improve error API + // See https://github.com/flutter/flutter/issues/108144 GoRouterPageBuilder? errorPageBuilder, GoRouterWidgetBuilder? errorBuilder, GoRouterRedirect? redirect, @@ -33,9 +32,13 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { int redirectLimit = 5, bool routerNeglect = false, String? initialLocation, + // TODO(johnpryan): Deprecate this parameter + // See https://github.com/flutter/flutter/issues/108132 UrlPathStrategy? urlPathStrategy, List? observers, bool debugLogDiagnostics = false, + // TODO(johnpryan): Deprecate this parameter + // See https://github.com/flutter/flutter/issues/108145 GoRouterNavigatorBuilder? navigatorBuilder, String? restorationScopeId, }) { @@ -46,19 +49,24 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { setLogging(enabled: debugLogDiagnostics); WidgetsFlutterBinding.ensureInitialized(); - routeInformationParser = GoRouteInformationParser( + _routeConfiguration = RouteConfiguration( routes: routes, topRedirect: redirect ?? (_) => null, redirectLimit: redirectLimit, + ); + + _routeInformationParser = GoRouteInformationParser( + configuration: _routeConfiguration, debugRequireGoRouteInformationProvider: true, ); - routeInformationProvider = GoRouteInformationProvider( + + _routeInformationProvider = GoRouteInformationProvider( initialRouteInformation: RouteInformation( location: _effectiveInitialLocation(initialLocation)), refreshListenable: refreshListenable); - routerDelegate = GoRouterDelegate( - routeInformationParser, + _routerDelegate = GoRouterDelegate( + configuration: _routeConfiguration, errorPageBuilder: errorPageBuilder, errorBuilder: errorBuilder, routerNeglect: routerNeglect, @@ -82,26 +90,42 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { }()); } - /// The route information parser used by the go router. - late final GoRouteInformationParser routeInformationParser; + late final RouteConfiguration _routeConfiguration; + late final GoRouteInformationParser _routeInformationParser; + late final GoRouterDelegate _routerDelegate; + late final GoRouteInformationProvider _routeInformationProvider; - /// The router delegate used by the go router. - late final GoRouterDelegate routerDelegate; + /// The router delegate. Provide this to the MaterialApp or CupertinoApp's + /// `.router()` constructor + GoRouterDelegate get routerDelegate => _routerDelegate; - /// The route information provider used by the go router. - late final GoRouteInformationProvider routeInformationProvider; + /// The route information provider used by [GoRouter]. + GoRouteInformationProvider get routeInformationProvider => + _routeInformationProvider; + + /// The route information parser used by [GoRouter]. + GoRouteInformationParser get routeInformationParser => + _routeInformationParser; + + /// The route configuration. Used for testing. + // TODO(johnpryan): Remove this, integration tests shouldn't need access + @visibleForTesting + RouteConfiguration get routeConfiguration => _routeConfiguration; /// Get the current location. - String get location => routerDelegate.currentConfiguration.last.fullUriString; + String get location => + _routerDelegate.currentConfiguration.location.toString(); /// 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 {}, Map queryParams = const {}, }) => - routeInformationParser.namedLocation( + _routeInformationParser.configuration.namedLocation( name, params: params, queryParams: queryParams, @@ -114,7 +138,7 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { log.info('going to $location'); return true; }()); - routeInformationProvider.value = + _routeInformationProvider.value = RouteInformation(location: location, state: extra); } @@ -139,11 +163,11 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { log.info('pushing $location'); return true; }()); - routeInformationParser + _routeInformationParser .parseRouteInformation( DebugGoRouteInformation(location: location, state: extra)) - .then((List matches) { - routerDelegate.push(matches.last); + .then((RouteMatchList matches) { + _routerDelegate.push(matches.last); }); } @@ -171,8 +195,8 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { .parseRouteInformation( DebugGoRouteInformation(location: location, state: extra), ) - .then((List matches) { - routerDelegate.replace(matches.last); + .then((RouteMatchList matchList) { + routerDelegate.replace(matchList.matches.last); }); } @@ -196,7 +220,7 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { } /// Returns `true` if there is more than 1 page on the stack. - bool canPop() => routerDelegate.canPop(); + bool canPop() => _routerDelegate.canPop(); /// Pop the top page off the GoRouter's page stack. void pop() { @@ -204,7 +228,7 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { log.info('popping $location'); return true; }()); - routerDelegate.pop(); + _routerDelegate.pop(); } /// Refresh the route. @@ -213,7 +237,7 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { log.info('refreshing $location'); return true; }()); - routeInformationProvider.notifyListeners(); + _routeInformationProvider.notifyListeners(); } /// Set the app's URL path strategy (defaults to hash). call before runApp(). @@ -250,8 +274,8 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { @override void dispose() { - routeInformationProvider.dispose(); - routerDelegate.dispose(); + _routeInformationProvider.dispose(); + _routerDelegate.dispose(); super.dispose(); } diff --git a/packages/go_router/lib/src/go_router_state.dart b/packages/go_router/lib/src/state.dart similarity index 84% rename from packages/go_router/lib/src/go_router_state.dart rename to packages/go_router/lib/src/state.dart index 9db78342fdce..e616dc4a68bf 100644 --- a/packages/go_router/lib/src/go_router_state.dart +++ b/packages/go_router/lib/src/state.dart @@ -4,13 +4,13 @@ import 'package:flutter/foundation.dart'; -import 'go_route_information_parser.dart'; +import 'configuration.dart'; /// The route state during routing. class GoRouterState { /// Default constructor for creating route state during routing. GoRouterState( - this._delegate, { + this._configuration, { required this.location, required this.subloc, required this.name, @@ -29,8 +29,9 @@ class GoRouterState { : subloc), assert((path ?? '').isEmpty == (fullpath ?? '').isEmpty); - // TODO(chunhtai): remove this once namedLocation is removed from go_router. - final GoRouteInformationParser _delegate; + // TODO(johnpryan): remove once namedLocation is removed from go_router. + // See https://github.com/flutter/flutter/issues/107729 + final RouteConfiguration _configuration; /// The full location of the route, e.g. /family/f2/person/p1 final String location; @@ -64,12 +65,14 @@ class GoRouterState { /// Get a location from route name and parameters. /// This is useful for redirecting to a named location. + // TODO(johnpryan): deprecate namedLocation API + // See https://github.com/flutter/flutter/issues/10772 String namedLocation( String name, { Map params = const {}, Map queryParams = const {}, }) { - return _delegate.namedLocation(name, + return _configuration.namedLocation(name, params: params, queryParams: queryParams); } } diff --git a/packages/go_router/lib/src/route_data.dart b/packages/go_router/lib/src/typed_routing.dart similarity index 98% rename from packages/go_router/lib/src/route_data.dart rename to packages/go_router/lib/src/typed_routing.dart index f9a95e8b3c1c..0f9f7b50f93c 100644 --- a/packages/go_router/lib/src/route_data.dart +++ b/packages/go_router/lib/src/typed_routing.dart @@ -6,8 +6,8 @@ import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; import 'package:meta/meta_meta.dart'; -import 'go_route.dart'; -import 'go_router_state.dart'; +import 'route.dart'; +import 'state.dart'; /// Baseclass for supporting /// [typed routing](https://gorouter.dev/typed-routing). diff --git a/packages/go_router/lib/src/typedefs.dart b/packages/go_router/lib/src/typedefs.dart index bec6eaa0fcfb..2f8789e6f72e 100644 --- a/packages/go_router/lib/src/typedefs.dart +++ b/packages/go_router/lib/src/typedefs.dart @@ -4,20 +4,12 @@ import 'package:flutter/widgets.dart'; -import 'go_route_match.dart'; -import 'go_router_state.dart'; +import 'configuration.dart'; -/// Signature of a go router builder function with matchers. -typedef GoRouterBuilderWithMatches = Widget Function( - BuildContext context, - Iterable matches, -); - -/// Signature of a go router builder function with navigator. -typedef GoRouterBuilderWithNav = Widget Function( +/// The signature of the widget builder callback for a matched GoRoute. +typedef GoRouterWidgetBuilder = Widget Function( BuildContext context, GoRouterState state, - Navigator navigator, ); /// The signature of the page builder callback for a matched GoRoute. @@ -26,18 +18,19 @@ typedef GoRouterPageBuilder = Page Function( GoRouterState state, ); -/// The signature of the widget builder callback for a matched GoRoute. -typedef GoRouterWidgetBuilder = Widget Function( +/// The signature of the navigatorBuilder callback. +typedef GoRouterNavigatorBuilder = Widget Function( BuildContext context, GoRouterState state, + Widget child, ); -/// The signature of the redirect callback. -typedef GoRouterRedirect = String? Function(GoRouterState state); - -/// The signature of the navigatorBuilder callback. -typedef GoRouterNavigatorBuilder = Widget Function( +/// Signature of a go router builder function with navigator. +typedef GoRouterBuilderWithNav = Widget Function( BuildContext context, GoRouterState state, - Widget child, + Navigator navigator, ); + +/// The signature of the redirect callback. +typedef GoRouterRedirect = String? Function(GoRouterState state); diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index ec58f361a912..1433f92d9050 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.2.0 +version: 4.2.1 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/go_router_cupertino_test.dart b/packages/go_router/test/cupertino_test.dart similarity index 92% rename from packages/go_router/test/go_router_cupertino_test.dart rename to packages/go_router/test/cupertino_test.dart index 594a865000e7..4413d6a3dd2a 100644 --- a/packages/go_router/test/go_router_cupertino_test.dart +++ b/packages/go_router/test/cupertino_test.dart @@ -5,9 +5,9 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/src/go_router_cupertino.dart'; +import 'package:go_router/src/pages/cupertino.dart'; -import 'error_screen_helpers.dart'; +import 'helpers/error_screen_helpers.dart'; void main() { group('isCupertinoApp', () { @@ -63,7 +63,7 @@ void main() { 'shows "page not found" by default', testPageNotFound( widget: const CupertinoApp( - home: GoRouterCupertinoErrorScreen(null), + home: CupertinoErrorScreen(null), ), ), ); @@ -74,7 +74,7 @@ void main() { testPageShowsExceptionMessage( exception: exception, widget: CupertinoApp( - home: GoRouterCupertinoErrorScreen(exception), + home: CupertinoErrorScreen(exception), ), ), ); @@ -85,7 +85,7 @@ void main() { buttonFinder: find.byType(CupertinoButton), appRouterBuilder: cupertinoAppRouterBuilder, widget: const CupertinoApp( - home: GoRouterCupertinoErrorScreen(null), + home: CupertinoErrorScreen(null), ), ), ); diff --git a/packages/go_router/test/go_router_delegate_test.dart b/packages/go_router/test/delegate_test.dart similarity index 83% rename from packages/go_router/test/go_router_delegate_test.dart rename to packages/go_router/test/delegate_test.dart index 2716e1852eb8..a85d53668a36 100644 --- a/packages/go_router/test/go_router_delegate_test.dart +++ b/packages/go_router/test/delegate_test.dart @@ -5,8 +5,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; -import 'package:go_router/src/go_route_match.dart'; -import 'package:go_router/src/go_router_error_page.dart'; +import 'package:go_router/src/match.dart'; +import 'package:go_router/src/misc/error_screen.dart'; Future createGoRouter( WidgetTester tester, { @@ -18,7 +18,7 @@ Future createGoRouter( GoRoute(path: '/', builder: (_, __) => const DummyStatefulWidget()), GoRoute( path: '/error', - builder: (_, __) => const GoRouterErrorScreen(null), + builder: (_, __) => const ErrorScreen(null), ), ], refreshListenable: refreshListenable, @@ -37,10 +37,10 @@ void main() { ..push('/error'); goRouter.routerDelegate.addListener(expectAsync0(() {})); - final GoRouteMatch last = goRouter.routerDelegate.matches.last; + final RouteMatch last = goRouter.routerDelegate.matches.matches.last; goRouter.routerDelegate.pop(); - expect(goRouter.routerDelegate.matches.length, 1); - expect(goRouter.routerDelegate.matches.contains(last), false); + expect(goRouter.routerDelegate.matches.matches.length, 1); + expect(goRouter.routerDelegate.matches.matches.contains(last), false); }); testWidgets('throws when it pops more than matches count', @@ -62,7 +62,7 @@ void main() { (WidgetTester tester) async { final GoRouter goRouter = await createGoRouter(tester); - expect(goRouter.routerDelegate.matches.length, 1); + expect(goRouter.routerDelegate.matches.matches.length, 1); expect(goRouter.routerDelegate.canPop(), false); }, ); @@ -72,7 +72,7 @@ void main() { final GoRouter goRouter = await createGoRouter(tester) ..push('/error'); - expect(goRouter.routerDelegate.matches.length, 2); + expect(goRouter.routerDelegate.matches.matches.length, 2); expect(goRouter.routerDelegate.canPop(), true); }, ); @@ -101,12 +101,12 @@ void main() { goRouter.push('/page-0'); goRouter.routerDelegate.addListener(expectAsync0(() {})); - final GoRouteMatch first = goRouter.routerDelegate.matches.first; - final GoRouteMatch last = goRouter.routerDelegate.matches.last; + final RouteMatch first = goRouter.routerDelegate.matches.matches.first; + final RouteMatch last = goRouter.routerDelegate.matches.last; goRouter.replace('/page-1'); - expect(goRouter.routerDelegate.matches.length, 2); + expect(goRouter.routerDelegate.matches.matches.length, 2); expect( - goRouter.routerDelegate.matches.first, + goRouter.routerDelegate.matches.matches.first, first, reason: 'The first match should still be in the list of matches', ); @@ -153,12 +153,12 @@ void main() { goRouter.pushNamed('page0'); goRouter.routerDelegate.addListener(expectAsync0(() {})); - final GoRouteMatch first = goRouter.routerDelegate.matches.first; - final GoRouteMatch last = goRouter.routerDelegate.matches.last; + final RouteMatch first = goRouter.routerDelegate.matches.matches.first; + final RouteMatch last = goRouter.routerDelegate.matches.last; goRouter.replaceNamed('page1'); - expect(goRouter.routerDelegate.matches.length, 2); + expect(goRouter.routerDelegate.matches.matches.length, 2); expect( - goRouter.routerDelegate.matches.first, + goRouter.routerDelegate.matches.matches.first, first, reason: 'The first match should still be in the list of matches', ); @@ -169,14 +169,14 @@ void main() { ); expect( goRouter.routerDelegate.matches.last, - isA() + isA() .having( - (GoRouteMatch match) => match.fullpath, + (RouteMatch match) => match.fullpath, 'match.fullpath', '/page-1', ) .having( - (GoRouteMatch match) => match.route.name, + (RouteMatch match) => match.route.name, 'match.route.name', 'page1', ), @@ -199,6 +199,7 @@ void main() { class FakeRefreshListenable extends ChangeNotifier { bool unsubscribed = false; + @override void removeListener(VoidCallback listener) { unsubscribed = true; diff --git a/packages/go_router/test/go_router_error_page_test.dart b/packages/go_router/test/error_page_test.dart similarity index 86% rename from packages/go_router/test/go_router_error_page_test.dart rename to packages/go_router/test/error_page_test.dart index 3e5b2fdc7b10..5028dcc7af80 100644 --- a/packages/go_router/test/go_router_error_page_test.dart +++ b/packages/go_router/test/error_page_test.dart @@ -4,16 +4,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/src/go_router_error_page.dart'; +import 'package:go_router/src/misc/error_screen.dart'; -import 'error_screen_helpers.dart'; +import 'helpers/error_screen_helpers.dart'; void main() { testWidgets( 'shows "page not found" by default', testPageNotFound( widget: widgetsAppBuilder( - home: const GoRouterErrorScreen(null), + home: const ErrorScreen(null), ), ), ); @@ -24,7 +24,7 @@ void main() { testPageShowsExceptionMessage( exception: exception, widget: widgetsAppBuilder( - home: GoRouterErrorScreen(exception), + home: ErrorScreen(exception), ), ), ); @@ -35,7 +35,7 @@ void main() { buttonFinder: find.byWidgetPredicate((Widget widget) => widget is GestureDetector), widget: widgetsAppBuilder( - home: const GoRouterErrorScreen(null), + home: const ErrorScreen(null), ), ), ); diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart index 83c3c23c9c44..15b23c27da6c 100644 --- a/packages/go_router/test/go_router_test.dart +++ b/packages/go_router/test/go_router_test.dart @@ -9,9 +9,8 @@ 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/go_route_match.dart'; -import 'package:go_router/src/go_router_delegate.dart'; -import 'package:go_router/src/go_router_error_page.dart'; +import 'package:go_router/src/delegate.dart'; +import 'package:go_router/src/match.dart'; import 'package:logging/logging.dart'; import 'test_helpers.dart'; @@ -33,7 +32,7 @@ void main() { ]; final GoRouter router = await createRouter(routes, tester); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); expect(matches.first.fullpath, '/'); expect(router.screenFor(matches.first).runtimeType, HomeScreen); @@ -48,7 +47,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/'); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); expect(matches.first.fullpath, '/'); expect(router.screenFor(matches.first).runtimeType, DummyScreen); @@ -108,9 +107,9 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/foo'); await tester.pumpAndSettle(); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); - expect(router.screenFor(matches.first).runtimeType, ErrorScreen); + expect(router.screenFor(matches.first).runtimeType, TestErrorScreen); }); testWidgets('match 2nd top level route', (WidgetTester tester) async { @@ -127,7 +126,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/login'); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); expect(matches.first.subloc, '/login'); expect(router.screenFor(matches.first).runtimeType, LoginScreen); @@ -155,7 +154,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/login'); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); expect(matches.first.subloc, '/login'); expect(router.screenFor(matches.first).runtimeType, LoginScreen); @@ -178,7 +177,8 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/login/'); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; + print(matches); expect(matches, hasLength(1)); expect(matches.first.subloc, '/login'); expect(router.screenFor(matches.first).runtimeType, LoginScreen); @@ -193,7 +193,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/profile/'); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); expect(matches.first.subloc, '/profile/foo'); expect(router.screenFor(matches.first).runtimeType, DummyScreen); @@ -208,7 +208,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/profile/?bar=baz'); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); expect(matches.first.subloc, '/profile/foo'); expect(router.screenFor(matches.first).runtimeType, DummyScreen); @@ -232,7 +232,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/login'); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches.length, 2); expect(matches.first.subloc, '/'); expect(router.screenFor(matches.first).runtimeType, HomeScreen); @@ -270,7 +270,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); { - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); expect(matches.first.fullpath, '/'); expect(router.screenFor(matches.first).runtimeType, HomeScreen); @@ -278,7 +278,7 @@ void main() { router.go('/login'); { - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches.length, 2); expect(matches.first.subloc, '/'); expect(router.screenFor(matches.first).runtimeType, HomeScreen); @@ -288,7 +288,7 @@ void main() { router.go('/family/f2'); { - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches.length, 2); expect(matches.first.subloc, '/'); expect(router.screenFor(matches.first).runtimeType, HomeScreen); @@ -298,7 +298,7 @@ void main() { router.go('/family/f2/person/p1'); { - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches.length, 3); expect(matches.first.subloc, '/'); expect(router.screenFor(matches.first).runtimeType, HomeScreen); @@ -345,17 +345,17 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/bar'); - List matches = router.routerDelegate.matches; + List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(2)); expect(router.screenFor(matches[1]).runtimeType, Page1Screen); router.go('/foo/bar'); - matches = router.routerDelegate.matches; + matches = router.routerDelegate.matches.matches; expect(matches, hasLength(2)); expect(router.screenFor(matches[1]).runtimeType, FamilyScreen); router.go('/foo'); - matches = router.routerDelegate.matches; + matches = router.routerDelegate.matches.matches; expect(matches, hasLength(2)); expect(router.screenFor(matches[1]).runtimeType, Page2Screen); }); @@ -465,7 +465,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); const String loc = '/FaMiLy/f2'; router.go(loc); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; // NOTE: match the lower case, since subloc is canonicalized to match the // path case whereas the location can be any case; so long as the path @@ -488,7 +488,7 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/user'); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); expect(router.screenFor(matches.first).runtimeType, DummyScreen); }); @@ -738,7 +738,7 @@ void main() { router.goNamed('person', params: {'fid': 'f2', 'pid': 'p1'}); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(router.screenFor(matches.last).runtimeType, PersonScreen); }); @@ -762,7 +762,7 @@ void main() { log.info('loc= $loc'); router.go(loc); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; log.info('param1= ${matches.first.decodedParams['param1']}'); expect(router.screenFor(matches.first).runtimeType, DummyScreen); expect(matches.first.decodedParams['param1'], param1); @@ -787,7 +787,7 @@ void main() { queryParams: {'param1': param1}); router.go(loc); await tester.pump(); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(router.screenFor(matches.first).runtimeType, DummyScreen); expect(matches.first.queryParams['param1'], param1); }); @@ -952,11 +952,12 @@ void main() { ? '/' : null); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); - expect(router.screenFor(matches.first).runtimeType, ErrorScreen); - expect((router.screenFor(matches.first) as ErrorScreen).ex, isNotNull); - log.info((router.screenFor(matches.first) as ErrorScreen).ex); + expect(router.screenFor(matches.first).runtimeType, TestErrorScreen); + expect( + (router.screenFor(matches.first) as TestErrorScreen).ex, isNotNull); + log.info((router.screenFor(matches.first) as TestErrorScreen).ex); }); testWidgets('route-level redirect loop', (WidgetTester tester) async { @@ -974,11 +975,12 @@ void main() { tester, ); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); - expect(router.screenFor(matches.first).runtimeType, ErrorScreen); - expect((router.screenFor(matches.first) as ErrorScreen).ex, isNotNull); - log.info((router.screenFor(matches.first) as ErrorScreen).ex); + expect(router.screenFor(matches.first).runtimeType, TestErrorScreen); + expect( + (router.screenFor(matches.first) as TestErrorScreen).ex, isNotNull); + log.info((router.screenFor(matches.first) as TestErrorScreen).ex); }); testWidgets('mixed redirect loop', (WidgetTester tester) async { @@ -994,11 +996,12 @@ void main() { state.subloc == '/' ? '/login' : null, ); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); - expect(router.screenFor(matches.first).runtimeType, ErrorScreen); - expect((router.screenFor(matches.first) as ErrorScreen).ex, isNotNull); - log.info((router.screenFor(matches.first) as ErrorScreen).ex); + expect(router.screenFor(matches.first).runtimeType, TestErrorScreen); + expect( + (router.screenFor(matches.first) as TestErrorScreen).ex, isNotNull); + log.info((router.screenFor(matches.first) as TestErrorScreen).ex); }); testWidgets('top-level redirect loop w/ query params', @@ -1013,11 +1016,12 @@ void main() { : null, ); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); - expect(router.screenFor(matches.first).runtimeType, ErrorScreen); - expect((router.screenFor(matches.first) as ErrorScreen).ex, isNotNull); - log.info((router.screenFor(matches.first) as ErrorScreen).ex); + expect(router.screenFor(matches.first).runtimeType, TestErrorScreen); + expect( + (router.screenFor(matches.first) as TestErrorScreen).ex, isNotNull); + log.info((router.screenFor(matches.first) as TestErrorScreen).ex); }); testWidgets('expect null path/fullpath on top-level redirect', @@ -1072,7 +1076,7 @@ void main() { }, ); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); expect(router.screenFor(matches.first).runtimeType, LoginScreen); }); @@ -1101,7 +1105,7 @@ void main() { initialLocation: loc, ); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); expect(router.screenFor(matches.first).runtimeType, HomeScreen); }); @@ -1142,7 +1146,7 @@ void main() { initialLocation: '/family/f2/person/p1', ); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches.length, 3); expect(router.screenFor(matches.first).runtimeType, HomeScreen); expect(router.screenFor(matches[1]).runtimeType, FamilyScreen); @@ -1155,15 +1159,16 @@ void main() { final GoRouter router = await createRouter( [], tester, - redirect: (GoRouterState state) => '${state.location}+', + redirect: (GoRouterState state) => '/${state.location}+', redirectLimit: 10, ); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); - expect(router.screenFor(matches.first).runtimeType, ErrorScreen); - expect((router.screenFor(matches.first) as ErrorScreen).ex, isNotNull); - log.info((router.screenFor(matches.first) as ErrorScreen).ex); + expect(router.screenFor(matches.first).runtimeType, TestErrorScreen); + expect( + (router.screenFor(matches.first) as TestErrorScreen).ex, isNotNull); + log.info((router.screenFor(matches.first) as TestErrorScreen).ex); }); }); @@ -1264,7 +1269,7 @@ void main() { for (final String fid in ['f2', 'F2']) { final String loc = '/family/$fid'; router.go(loc); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(router.location, loc); expect(matches, hasLength(1)); @@ -1292,7 +1297,7 @@ void main() { for (final String fid in ['f2', 'F2']) { final String loc = '/family?fid=$fid'; router.go(loc); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(router.location, loc); expect(matches, hasLength(1)); @@ -1318,7 +1323,7 @@ void main() { final String loc = '/page1/${Uri.encodeComponent(param1)}'; router.go(loc); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; log.info('param1= ${matches.first.decodedParams['param1']}'); expect(router.screenFor(matches.first).runtimeType, DummyScreen); expect(matches.first.decodedParams['param1'], param1); @@ -1340,14 +1345,14 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/page1?param1=$param1'); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(router.screenFor(matches.first).runtimeType, DummyScreen); expect(matches.first.queryParams['param1'], param1); final String loc = '/page1?param1=${Uri.encodeQueryComponent(param1)}'; router.go(loc); - final List matches2 = router.routerDelegate.matches; + final List matches2 = router.routerDelegate.matches.matches; expect(router.screenFor(matches2[0]).runtimeType, DummyScreen); expect(matches2[0].queryParams['param1'], param1); }); @@ -1362,7 +1367,7 @@ void main() { ), ], errorBuilder: (BuildContext context, GoRouterState state) => - ErrorScreen(state.error!), + TestErrorScreen(state.error!), initialLocation: '/0/1/2/0/1', ); expect(false, true); @@ -1388,7 +1393,7 @@ void main() { tester, initialLocation: '/?id=0&id=1', ); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); expect(matches.first.fullpath, '/'); expect(router.screenFor(matches.first).runtimeType, HomeScreen); @@ -1411,7 +1416,7 @@ void main() { router.go('/0?id=1'); await tester.pump(); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(matches, hasLength(1)); expect(matches.first.fullpath, '/:id'); expect(router.screenFor(matches.first).runtimeType, HomeScreen); @@ -1445,11 +1450,12 @@ void main() { router.push('/person?fid=f2&pid=p1'); await tester.pump(); final FamilyScreen page1 = - router.screenFor(router.routerDelegate.matches.first) as FamilyScreen; + router.screenFor(router.routerDelegate.matches.matches.first) + as FamilyScreen; expect(page1.fid, 'f2'); - final PersonScreen page2 = - router.screenFor(router.routerDelegate.matches[1]) as PersonScreen; + final PersonScreen page2 = router + .screenFor(router.routerDelegate.matches.matches[1]) as PersonScreen; expect(page2.fid, 'f2'); expect(page2.pid, 'p1'); }); @@ -1482,11 +1488,12 @@ void main() { router.push('/person', extra: {'fid': 'f2', 'pid': 'p1'}); await tester.pump(); final FamilyScreen page1 = - router.screenFor(router.routerDelegate.matches.first) as FamilyScreen; + router.screenFor(router.routerDelegate.matches.matches.first) + as FamilyScreen; expect(page1.fid, 'f2'); - final PersonScreen page2 = - router.screenFor(router.routerDelegate.matches[1]) as PersonScreen; + final PersonScreen page2 = router + .screenFor(router.routerDelegate.matches.matches[1]) as PersonScreen; expect(page2.fid, 'f2'); expect(page2.pid, 'p1'); }); @@ -1523,7 +1530,7 @@ void main() { router.push(loc); await tester.pump(); - final List matches = router.routerDelegate.matches; + final List matches = router.routerDelegate.matches.matches; expect(router.location, loc); expect(matches, hasLength(2)); @@ -1788,16 +1795,16 @@ void main() { GoRoute(path: '/', builder: (_, __) => const DummyStatefulWidget()), GoRoute( path: '/error', - builder: (_, __) => const GoRouterErrorScreen(null), + builder: (_, __) => TestErrorScreen(TestFailure('exception')), ), ], navigatorBuilder: navigatorBuilder, ); final GoRouterDelegate delegate = router.routerDelegate; - delegate.builderWithNav( + delegate.builder.builderWithNav( DummyBuildContext(), - GoRouterState(router.routeInformationParser, + GoRouterState(router.routeConfiguration, location: '/foo', subloc: '/bar', name: 'baz'), const Navigator(), ); diff --git a/packages/go_router/test/error_screen_helpers.dart b/packages/go_router/test/helpers/error_screen_helpers.dart similarity index 98% rename from packages/go_router/test/error_screen_helpers.dart rename to packages/go_router/test/helpers/error_screen_helpers.dart index 6139eb09b63b..f804f44187fb 100644 --- a/packages/go_router/test/error_screen_helpers.dart +++ b/packages/go_router/test/helpers/error_screen_helpers.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; -import 'test_helpers.dart'; +import '../test_helpers.dart'; WidgetTesterCallback testPageNotFound({required Widget widget}) { return (WidgetTester tester) async { diff --git a/packages/go_router/test/go_route_information_provider_test.dart b/packages/go_router/test/information_provider_test.dart similarity index 94% rename from packages/go_router/test/go_route_information_provider_test.dart rename to packages/go_router/test/information_provider_test.dart index fd9d4e790726..e6ce09455088 100644 --- a/packages/go_router/test/go_route_information_provider_test.dart +++ b/packages/go_router/test/information_provider_test.dart @@ -4,7 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/src/go_route_information_provider.dart'; +import 'package:go_router/src/information_provider.dart'; const RouteInformation initialRoute = RouteInformation(location: '/'); const RouteInformation newRoute = RouteInformation(location: '/new'); diff --git a/packages/go_router/test/inherited_go_router_test.dart b/packages/go_router/test/inherited_test.dart similarity index 100% rename from packages/go_router/test/inherited_go_router_test.dart rename to packages/go_router/test/inherited_test.dart diff --git a/packages/go_router/test/go_router_material_test.dart b/packages/go_router/test/material_test.dart similarity index 92% rename from packages/go_router/test/go_router_material_test.dart rename to packages/go_router/test/material_test.dart index 8a0c595474fa..15e4ee0829fd 100644 --- a/packages/go_router/test/go_router_material_test.dart +++ b/packages/go_router/test/material_test.dart @@ -5,9 +5,9 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/src/go_router_material.dart'; +import 'package:go_router/src/pages/material.dart'; -import 'error_screen_helpers.dart'; +import 'helpers/error_screen_helpers.dart'; void main() { group('isMaterialApp', () { @@ -63,7 +63,7 @@ void main() { 'shows "page not found" by default', testPageNotFound( widget: const MaterialApp( - home: GoRouterMaterialErrorScreen(null), + home: MaterialErrorScreen(null), ), ), ); @@ -74,7 +74,7 @@ void main() { testPageShowsExceptionMessage( exception: exception, widget: MaterialApp( - home: GoRouterMaterialErrorScreen(exception), + home: MaterialErrorScreen(exception), ), ), ); @@ -84,7 +84,7 @@ void main() { testClickingTheButtonRedirectsToRoot( buttonFinder: find.byType(TextButton), widget: const MaterialApp( - home: GoRouterMaterialErrorScreen(null), + home: MaterialErrorScreen(null), ), ), ); diff --git a/packages/go_router/test/go_route_information_parser_test.dart b/packages/go_router/test/parser_test.dart similarity index 71% rename from packages/go_router/test/go_route_information_parser_test.dart rename to packages/go_router/test/parser_test.dart index 6d333652f01b..5d164839dbc7 100644 --- a/packages/go_router/test/go_route_information_parser_test.dart +++ b/packages/go_router/test/parser_test.dart @@ -4,9 +4,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/go_router.dart'; -import 'package:go_router/src/go_route_information_parser.dart'; -import 'package:go_router/src/go_route_match.dart'; +import 'package:go_router/src/configuration.dart'; +import 'package:go_router/src/match.dart'; +import 'package:go_router/src/matching.dart'; +import 'package:go_router/src/parser.dart'; void main() { test('GoRouteInformationParser can parse route', () async { @@ -23,13 +24,16 @@ void main() { ), ]; final GoRouteInformationParser parser = GoRouteInformationParser( - routes: routes, - redirectLimit: 100, - topRedirect: (_) => null, + configuration: RouteConfiguration( + routes: routes, + redirectLimit: 100, + topRedirect: (_) => null, + ), ); - List matches = await parser + RouteMatchList matchesObj = await parser .parseRouteInformation(const RouteInformation(location: '/')); + List matches = matchesObj.matches; expect(matches.length, 1); expect(matches[0].queryParams.isEmpty, isTrue); expect(matches[0].extra, isNull); @@ -38,8 +42,9 @@ void main() { expect(matches[0].route, routes[0]); final Object extra = Object(); - matches = await parser.parseRouteInformation( + matchesObj = await parser.parseRouteInformation( RouteInformation(location: '/abc?def=ghi', state: extra)); + matches = matchesObj.matches; expect(matches.length, 2); expect(matches[0].queryParams.length, 1); expect(matches[0].queryParams['def'], 'ghi'); @@ -80,18 +85,19 @@ void main() { ], ), ]; - final GoRouteInformationParser parser = GoRouteInformationParser( + + final RouteConfiguration configuration = RouteConfiguration( routes: routes, redirectLimit: 100, topRedirect: (_) => null, ); - expect(parser.namedLocation('lowercase'), '/abc?'); - expect(parser.namedLocation('LOWERCASE'), '/abc?'); - expect(parser.namedLocation('camelCase'), '/efg?'); - expect(parser.namedLocation('camelcase'), '/efg?'); - expect(parser.namedLocation('snake_case'), '/hij?'); - expect(parser.namedLocation('SNAKE_CASE'), '/hij?'); + expect(configuration.namedLocation('lowercase'), '/abc?'); + expect(configuration.namedLocation('LOWERCASE'), '/abc?'); + expect(configuration.namedLocation('camelCase'), '/efg?'); + expect(configuration.namedLocation('camelcase'), '/efg?'); + expect(configuration.namedLocation('snake_case'), '/hij?'); + expect(configuration.namedLocation('SNAKE_CASE'), '/hij?'); }); test('GoRouteInformationParser returns error when unknown route', () async { @@ -108,13 +114,16 @@ void main() { ), ]; final GoRouteInformationParser parser = GoRouteInformationParser( - routes: routes, - redirectLimit: 100, - topRedirect: (_) => null, + configuration: RouteConfiguration( + routes: routes, + redirectLimit: 100, + topRedirect: (_) => null, + ), ); - final List matches = await parser + final RouteMatchList matchesObj = await parser .parseRouteInformation(const RouteInformation(location: '/def')); + final List matches = matchesObj.matches; expect(matches.length, 1); expect(matches[0].queryParams.isEmpty, isTrue); expect(matches[0].extra, isNull); @@ -138,13 +147,17 @@ void main() { ), ]; final GoRouteInformationParser parser = GoRouteInformationParser( - routes: routes, - redirectLimit: 100, - topRedirect: (_) => null, + configuration: RouteConfiguration( + routes: routes, + redirectLimit: 100, + topRedirect: (_) => null, + ), ); - final List matches = await parser.parseRouteInformation( + final RouteMatchList matchesObj = await parser.parseRouteInformation( const RouteInformation(location: '/123/family/456')); + final List matches = matchesObj.matches; + expect(matches.length, 2); expect(matches[0].queryParams.isEmpty, isTrue); expect(matches[0].extra, isNull); @@ -160,7 +173,9 @@ void main() { expect(matches[1].encodedParams['fid'], '456'); }); - test('GoRouteInformationParser can do top level redirect', () async { + test( + 'GoRouteInformationParser processes top level redirect when there is no match', + () async { final List routes = [ GoRoute( path: '/', @@ -174,18 +189,22 @@ void main() { ), ]; final GoRouteInformationParser parser = GoRouteInformationParser( - routes: routes, - redirectLimit: 100, - topRedirect: (GoRouterState state) { - if (state.location != '/123/family/345') { - return '/123/family/345'; - } - return null; - }, + configuration: RouteConfiguration( + routes: routes, + redirectLimit: 100, + topRedirect: (GoRouterState state) { + if (state.location != '/123/family/345') { + return '/123/family/345'; + } + return null; + }, + ), ); - final List matches = await parser + final RouteMatchList matchesObj = await parser .parseRouteInformation(const RouteInformation(location: '/random/uri')); + final List matches = matchesObj.matches; + expect(matches.length, 2); expect(matches[0].fullUriString, '/'); expect(matches[0].subloc, '/'); @@ -194,7 +213,9 @@ void main() { expect(matches[1].subloc, '/123/family/345'); }); - test('GoRouteInformationParser can do route level redirect', () async { + test( + 'GoRouteInformationParser can do route level redirect when there is a match', + () async { final List routes = [ GoRoute( path: '/', @@ -213,13 +234,17 @@ void main() { ), ]; final GoRouteInformationParser parser = GoRouteInformationParser( - routes: routes, - redirectLimit: 100, - topRedirect: (_) => null, + configuration: RouteConfiguration( + routes: routes, + redirectLimit: 100, + topRedirect: (_) => null, + ), ); - final List matches = await parser + final RouteMatchList matchesObj = await parser .parseRouteInformation(const RouteInformation(location: '/redirect')); + final List matches = matchesObj.matches; + expect(matches.length, 2); expect(matches[0].fullUriString, '/'); expect(matches[0].subloc, '/'); @@ -237,9 +262,11 @@ void main() { ), ]; final GoRouteInformationParser parser = GoRouteInformationParser( - routes: routes, - redirectLimit: 100, - topRedirect: (_) => null, + configuration: RouteConfiguration( + routes: routes, + redirectLimit: 100, + topRedirect: (_) => null, + ), ); expect(() async { @@ -258,13 +285,17 @@ void main() { ), ]; final GoRouteInformationParser parser = GoRouteInformationParser( - routes: routes, - redirectLimit: 5, - topRedirect: (_) => null, + configuration: RouteConfiguration( + routes: routes, + redirectLimit: 5, + topRedirect: (_) => null, + ), ); - final List matches = await parser - .parseRouteInformation(const RouteInformation(location: '/abc')); + final RouteMatchList matchesObj = await parser + .parseRouteInformation(const RouteInformation(location: '/abd')); + final List matches = matchesObj.matches; + expect(matches, hasLength(1)); expect(matches.first.error, isNotNull); }); diff --git a/packages/go_router/test/path_parser_test.dart b/packages/go_router/test/path_utils_test.dart similarity index 74% rename from packages/go_router/test/path_parser_test.dart rename to packages/go_router/test/path_utils_test.dart index 5d5ff8934a0c..5c519e0294e2 100644 --- a/packages/go_router/test/path_parser_test.dart +++ b/packages/go_router/test/path_utils_test.dart @@ -3,7 +3,7 @@ // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/src/path_parser.dart'; +import 'package:go_router/src/path_utils.dart'; void main() { test('patternToRegExp without path parameter', () async { @@ -72,4 +72,36 @@ void main() { expect(url, restoredUrl); }); + + test('concatenatePaths', () { + void _verify(String pathA, String pathB, String expected) { + final String result = concatenatePaths(pathA, pathB); + expect(result, expected); + } + + void _verifyThrows(String pathA, String pathB) { + expect( + () => concatenatePaths(pathA, pathB), throwsA(isA())); + } + + _verify('/a', 'b/c', '/a/b/c'); + _verify('/', 'b', '/b'); + _verifyThrows('/a', '/b'); + _verifyThrows('/a', '/'); + _verifyThrows('/', '/'); + _verifyThrows('/', ''); + _verifyThrows('', ''); + }); + + test('canonicalUri', () { + void _verify(String path, String expected) => + expect(canonicalUri(path), expected); + _verify('/a', '/a'); + _verify('/a/', '/a'); + _verify('/', '/'); + _verify('/a/b/', '/a/b'); + + expect(() => canonicalUri('::::'), throwsA(isA())); + expect(() => canonicalUri(''), throwsA(anything)); + }); } diff --git a/packages/go_router/test/test_helpers.dart b/packages/go_router/test/test_helpers.dart index 8f3ca2643b0e..5361ceff76fb 100644 --- a/packages/go_router/test/test_helpers.dart +++ b/packages/go_router/test/test_helpers.dart @@ -10,8 +10,7 @@ import 'package:flutter/material.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/go_route_match.dart'; -import 'package:go_router/src/go_router_error_page.dart'; +import 'package:go_router/src/match.dart'; import 'package:go_router/src/typedefs.dart'; Future createGoRouter( @@ -24,7 +23,7 @@ Future createGoRouter( GoRoute(path: '/', builder: (_, __) => const DummyStatefulWidget()), GoRoute( path: '/error', - builder: (_, __) => const GoRouterErrorScreen(null), + builder: (_, __) => TestErrorScreen(TestFailure('Exception')), ), ], navigatorBuilder: navigatorBuilder, @@ -173,7 +172,7 @@ Future createRouter( initialLocation: initialLocation, redirectLimit: redirectLimit, errorBuilder: (BuildContext context, GoRouterState state) => - ErrorScreen(state.error!), + TestErrorScreen(state.error!), debugLogDiagnostics: false, ); await tester.pumpWidget( @@ -186,8 +185,8 @@ Future createRouter( return goRouter; } -class ErrorScreen extends DummyScreen { - const ErrorScreen(this.ex, {Key? key}) : super(key: key); +class TestErrorScreen extends DummyScreen { + const TestErrorScreen(this.ex, {Key? key}) : super(key: key); final Exception ex; } @@ -233,15 +232,15 @@ class DummyScreen extends StatelessWidget { Widget dummy(BuildContext context, GoRouterState state) => const DummyScreen(); extension Extension on GoRouter { - Page _pageFor(GoRouteMatch match) { - final List matches = routerDelegate.matches; + Page _pageFor(RouteMatch match) { + final List matches = routerDelegate.matches.matches; final int i = matches.indexOf(match); final List> pages = - routerDelegate.getPages(DummyBuildContext(), matches).toList(); + routerDelegate.builder.getPages(DummyBuildContext(), matches).toList(); return pages[i]; } - Widget screenFor(GoRouteMatch match) => + Widget screenFor(RouteMatch match) => (_pageFor(match) as MaterialPage).child; }