diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 413efb10461..f3c115ff348 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,8 +1,10 @@ -## NEXT +## 16.2.0 -* Updates minimum supported SDK version to Flutter 3.29/Dart 3.7. +- Adds `RelativeGoRouteData` and `TypedRelativeGoRoute`. +- Updates minimum supported SDK version to Flutter 3.29/Dart 3.7. ## 16.1.0 + - Adds annotation for go_router_builder that enable custom string encoder/decoder [#110781](https://github.com/flutter/flutter/issues/110781). **Requires go_router_builder >= 3.1.0**. ## 16.0.0 diff --git a/packages/go_router/lib/src/route_data.dart b/packages/go_router/lib/src/route_data.dart index c8e30bacd68..815f659eccc 100644 --- a/packages/go_router/lib/src/route_data.dart +++ b/packages/go_router/lib/src/route_data.dart @@ -8,6 +8,7 @@ import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; import 'package:meta/meta_meta.dart'; +import 'configuration.dart'; import 'route.dart'; import 'state.dart'; @@ -18,17 +19,10 @@ abstract class RouteData { const RouteData(); } -/// A class to represent a [GoRoute] in -/// [Type-safe routing](https://pub.dev/documentation/go_router/latest/topics/Type-safe%20routes-topic.html). -/// -/// Subclasses must override one of [build], [buildPage], or -/// [redirect]. -/// {@category Type-safe routes} -abstract class GoRouteData extends RouteData { - /// Allows subclasses to have `const` constructors. - /// - /// [GoRouteData] is abstract and cannot be instantiated directly. - const GoRouteData(); +/// A base class for [GoRouteData] and [RelativeGoRouteData] that provides +/// common functionality for type-safe routing. +abstract class _GoRouteDataBase extends RouteData { + const _GoRouteDataBase(); /// Creates the [Widget] for `this` route. /// @@ -68,17 +62,93 @@ abstract class GoRouteData extends RouteData { /// Corresponds to [GoRoute.onExit]. FutureOr onExit(BuildContext context, GoRouterState state) => true; + /// The error thrown when a user-facing method is not implemented by the + /// generated code. + static UnimplementedError get shouldBeGeneratedError => UnimplementedError( + 'Should be generated using [Type-safe routing](https://pub.dev/documentation/go_router/latest/topics/Type-safe%20routes-topic.html).', + ); + + /// Used to cache [_GoRouteDataBase] that corresponds to a given [GoRouterState] + /// to minimize the number of times it has to be deserialized. + static final Expando<_GoRouteDataBase> stateObjectExpando = + Expando<_GoRouteDataBase>('GoRouteState to _GoRouteDataBase expando'); +} + +/// Helper to build a location string from a path and query parameters. +String _buildLocation(String path, {Map? queryParams}) => + Uri.parse(path) + .replace( + queryParameters: + // Avoid `?` in generated location if `queryParams` is empty + queryParams?.isNotEmpty ?? false ? queryParams : null, + ) + .toString(); + +/// Holds the parameters for constructing a [GoRoute]. +class _GoRouteParameters { + const _GoRouteParameters({ + required this.builder, + required this.pageBuilder, + required this.redirect, + required this.onExit, + }); + + final GoRouterWidgetBuilder builder; + final GoRouterPageBuilder pageBuilder; + final GoRouterRedirect redirect; + final ExitCallback onExit; +} + +/// Helper to create [GoRoute] parameters from a factory function and an Expando. +_GoRouteParameters _createGoRouteParameters({ + required T Function(GoRouterState) factory, + required Expando<_GoRouteDataBase> expando, +}) { + T factoryImpl(GoRouterState state) { + final Object? extra = state.extra; + + // If the "extra" value is of type `T` then we know it's the source + // instance, so it doesn't need to be recreated. + if (extra is T) { + return extra; + } + + return (expando[state] ??= factory(state)) as T; + } + + return _GoRouteParameters( + builder: + (BuildContext context, GoRouterState state) => + factoryImpl(state).build(context, state), + pageBuilder: + (BuildContext context, GoRouterState state) => + factoryImpl(state).buildPage(context, state), + redirect: + (BuildContext context, GoRouterState state) => + factoryImpl(state).redirect(context, state), + onExit: + (BuildContext context, GoRouterState state) => + factoryImpl(state).onExit(context, state), + ); +} + +/// A class to represent a [GoRoute] in +/// [Type-safe routing](https://pub.dev/documentation/go_router/latest/topics/Type-safe%20routes-topic.html). +/// +/// Subclasses must override one of [build], [buildPage], or +/// [redirect]. +/// {@category Type-safe routes} +abstract class GoRouteData extends _GoRouteDataBase { + /// Allows subclasses to have `const` constructors. + /// + /// [GoRouteData] is abstract and cannot be instantiated directly. + const GoRouteData(); + /// A helper function used by generated code. /// /// Should not be used directly. static String $location(String path, {Map? queryParams}) => - Uri.parse(path) - .replace( - queryParameters: - // Avoid `?` in generated location if `queryParams` is empty - queryParams?.isNotEmpty ?? false ? queryParams : null, - ) - .toString(); + _buildLocation(path, queryParams: queryParams); /// A helper function used by generated code. /// @@ -91,60 +161,111 @@ abstract class GoRouteData extends RouteData { GlobalKey? parentNavigatorKey, List routes = const [], }) { - T factoryImpl(GoRouterState state) { - final Object? extra = state.extra; + final _GoRouteParameters params = _createGoRouteParameters( + factory: factory, + expando: _GoRouteDataBase.stateObjectExpando, + ); - // If the "extra" value is of type `T` then we know it's the source - // instance of `GoRouteData`, so it doesn't need to be recreated. - if (extra is T) { - return extra; - } + return GoRoute( + path: path, + name: name, + caseSensitive: caseSensitive, + builder: params.builder, + pageBuilder: params.pageBuilder, + redirect: params.redirect, + routes: routes, + parentNavigatorKey: parentNavigatorKey, + onExit: params.onExit, + ); + } - return (_stateObjectExpando[state] ??= factory(state)) as T; - } + /// The location of this route, e.g. /family/f2/person/p1 + String get location => throw _GoRouteDataBase.shouldBeGeneratedError; + + /// Navigate to the route. + void go(BuildContext context) => + throw _GoRouteDataBase.shouldBeGeneratedError; - Widget builder(BuildContext context, GoRouterState state) => - factoryImpl(state).build(context, state); + /// Push the route onto the page stack. + Future push(BuildContext context) => + throw _GoRouteDataBase.shouldBeGeneratedError; - Page pageBuilder(BuildContext context, GoRouterState state) => - factoryImpl(state).buildPage(context, state); + /// Replaces the top-most page of the page stack with the route. + void pushReplacement(BuildContext context) => + throw _GoRouteDataBase.shouldBeGeneratedError; - FutureOr redirect(BuildContext context, GoRouterState state) => - factoryImpl(state).redirect(context, state); + /// Replaces the top-most page of the page stack with the route but treats + /// it as the same page. + /// + /// The page key will be reused. This will preserve the state and not run any + /// page animation. + /// + void replace(BuildContext context) => + throw _GoRouteDataBase.shouldBeGeneratedError; +} - FutureOr onExit(BuildContext context, GoRouterState state) => - factoryImpl(state).onExit(context, state); +/// A class to represent a relative [GoRoute] in +/// [Type-safe routing](https://pub.dev/documentation/go_router/latest/topics/Type-safe%20routes-topic.html). +/// +/// Subclasses must override one of [build], [buildPage], or +/// [redirect]. +/// {@category Type-safe routes} +abstract class RelativeGoRouteData extends _GoRouteDataBase { + /// Allows subclasses to have `const` constructors. + /// + /// [RelativeGoRouteData] is abstract and cannot be instantiated directly. + const RelativeGoRouteData(); + + /// A helper function used by generated code. + /// + /// Should not be used directly. + static String $location(String path, {Map? queryParams}) => + _buildLocation(path, queryParams: queryParams); + + /// A helper function used by generated code. + /// + /// Should not be used directly. + static GoRoute $route({ + required String path, + bool caseSensitive = true, + required T Function(GoRouterState) factory, + GlobalKey? parentNavigatorKey, + List routes = const [], + }) { + final _GoRouteParameters params = _createGoRouteParameters( + factory: factory, + expando: _GoRouteDataBase.stateObjectExpando, + ); return GoRoute( path: path, - name: name, caseSensitive: caseSensitive, - builder: builder, - pageBuilder: pageBuilder, - redirect: redirect, + builder: params.builder, + pageBuilder: params.pageBuilder, + redirect: params.redirect, routes: routes, parentNavigatorKey: parentNavigatorKey, - onExit: onExit, + onExit: params.onExit, ); } - /// Used to cache [GoRouteData] that corresponds to a given [GoRouterState] - /// to minimize the number of times it has to be deserialized. - static final Expando _stateObjectExpando = Expando( - 'GoRouteState to GoRouteData expando', - ); + /// The sub-location of this route, e.g. person/p1 + String get subLocation => throw _GoRouteDataBase.shouldBeGeneratedError; - /// The location of this route. - String get location => throw _shouldBeGeneratedError; + /// The relative location of this route, e.g. ./person/p1 + String get relativeLocation => throw _GoRouteDataBase.shouldBeGeneratedError; /// Navigate to the route. - void go(BuildContext context) => throw _shouldBeGeneratedError; + void goRelative(BuildContext context) => + throw _GoRouteDataBase.shouldBeGeneratedError; /// Push the route onto the page stack. - Future push(BuildContext context) => throw _shouldBeGeneratedError; + Future pushRelative(BuildContext context) => + throw _GoRouteDataBase.shouldBeGeneratedError; /// Replaces the top-most page of the page stack with the route. - void pushReplacement(BuildContext context) => throw _shouldBeGeneratedError; + void pushReplacementRelative(BuildContext context) => + throw _GoRouteDataBase.shouldBeGeneratedError; /// Replaces the top-most page of the page stack with the route but treats /// it as the same page. @@ -152,11 +273,8 @@ abstract class GoRouteData extends RouteData { /// The page key will be reused. This will preserve the state and not run any /// page animation. /// - void replace(BuildContext context) => throw _shouldBeGeneratedError; - - static UnimplementedError get _shouldBeGeneratedError => UnimplementedError( - 'Should be generated using [Type-safe routing](https://pub.dev/documentation/go_router/latest/topics/Type-safe%20routes-topic.html).', - ); + void replaceRelative(BuildContext context) => + throw _GoRouteDataBase.shouldBeGeneratedError; } /// A class to represent a [ShellRoute] in @@ -402,6 +520,41 @@ class TypedGoRoute extends TypedRoute { final bool caseSensitive; } +/// A superclass for each typed relative go route descendant +@Target({TargetKind.library, TargetKind.classType}) +class TypedRelativeGoRoute + extends TypedRoute { + /// Default const constructor + const TypedRelativeGoRoute({ + required this.path, + this.routes = const >[], + this.caseSensitive = true, + }); + + /// The relative path that corresponds to this route. + /// + /// See [GoRoute.path]. + /// + /// + final String path; + + /// Child route definitions. + /// + /// See [RouteBase.routes]. + final List> routes; + + /// Determines whether the route matching is case sensitive. + /// + /// When `true`, the path must match the specified case. For example, + /// a route with `path: '/family/:fid'` will not match `/FaMiLy/f2`. + /// + /// When `false`, the path matching is case insensitive. The route + /// with `path: '/family/:fid'` will match `/FaMiLy/f2`. + /// + /// Defaults to `true`. + final bool caseSensitive; +} + /// A superclass for each typed shell route descendant @Target({TargetKind.library, TargetKind.classType}) class TypedShellRoute extends TypedRoute { diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 24064f28914..ff0f253797f 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: 16.1.0 +version: 16.2.0 repository: https://github.com/flutter/packages/tree/main/packages/go_router issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22 diff --git a/packages/go_router/test/route_data_test.dart b/packages/go_router/test/route_data_test.dart index 61b31e7cb8c..b14313c3b4a 100644 --- a/packages/go_router/test/route_data_test.dart +++ b/packages/go_router/test/route_data_test.dart @@ -17,6 +17,14 @@ class _GoRouteDataBuild extends GoRouteData { const SizedBox(key: Key('build')); } +class _RelativeGoRouteDataBuild extends RelativeGoRouteData { + const _RelativeGoRouteDataBuild(); + + @override + Widget build(BuildContext context, GoRouterState state) => + const SizedBox(key: Key('build')); +} + class _ShellRouteDataRedirectPage extends ShellRouteData { const _ShellRouteDataRedirectPage(); @@ -57,6 +65,11 @@ final GoRoute _goRouteDataBuild = GoRouteData.$route( factory: (GoRouterState state) => const _GoRouteDataBuild(), ); +final GoRoute _relativeGoRouteDataBuild = RelativeGoRouteData.$route( + path: 'build', + factory: (GoRouterState state) => const _RelativeGoRouteDataBuild(), +); + final ShellRoute _shellRouteDataBuilder = ShellRouteData.$route( factory: (GoRouterState state) => const _ShellRouteDataBuilder(), routes: [ @@ -75,6 +88,14 @@ class _GoRouteDataBuildPage extends GoRouteData { const MaterialPage(child: SizedBox(key: Key('buildPage'))); } +class _RelativeGoRouteDataBuildPage extends RelativeGoRouteData { + const _RelativeGoRouteDataBuildPage(); + + @override + Page buildPage(BuildContext context, GoRouterState state) => + const MaterialPage(child: SizedBox(key: Key('buildPage'))); +} + class _ShellRouteDataPageBuilder extends ShellRouteData { const _ShellRouteDataPageBuilder(); @@ -101,6 +122,11 @@ final GoRoute _goRouteDataBuildPage = GoRouteData.$route( factory: (GoRouterState state) => const _GoRouteDataBuildPage(), ); +final GoRoute _relativeGoRouteDataBuildPage = RelativeGoRouteData.$route( + path: 'build-page', + factory: (GoRouterState state) => const _RelativeGoRouteDataBuildPage(), +); + final ShellRoute _shellRouteDataPageBuilder = ShellRouteData.$route( factory: (GoRouterState state) => const _ShellRouteDataPageBuilder(), routes: [ @@ -189,11 +215,24 @@ class _GoRouteDataRedirectPage extends GoRouteData { '/build-page'; } +class _RelativeGoRouteDataRedirectPage extends RelativeGoRouteData { + const _RelativeGoRouteDataRedirectPage(); + + @override + FutureOr redirect(BuildContext context, GoRouterState state) => + '/build-page'; +} + final GoRoute _goRouteDataRedirect = GoRouteData.$route( path: '/redirect', factory: (GoRouterState state) => const _GoRouteDataRedirectPage(), ); +final GoRoute _relativeGoRouteDataRedirect = RelativeGoRouteData.$route( + path: 'redirect', + factory: (GoRouterState state) => const _RelativeGoRouteDataRedirectPage(), +); + final List _routes = [ _goRouteDataBuild, _goRouteDataBuildPage, @@ -208,6 +247,18 @@ String toBase64(String value) { return base64.encode(const Utf8Encoder().convert(value)); } +final List _relativeRoutes = [ + GoRouteData.$route( + path: '/', + factory: (GoRouterState state) => const _GoRouteDataBuild(), + routes: [ + _relativeGoRouteDataBuild, + _relativeGoRouteDataBuildPage, + _relativeGoRouteDataRedirect, + ], + ), +]; + void main() { group('GoRouteData', () { testWidgets('It should build the page from the overridden build method', ( @@ -262,7 +313,7 @@ void main() { }, ); - testWidgets('It should throw beacuase there is no code generated', ( + testWidgets('It should throw because there is no code generated', ( WidgetTester tester, ) async { final List errors = []; @@ -272,61 +323,164 @@ void main() { const String errorText = 'Should be generated'; - Widget buildWidget(void Function(BuildContext) onTap) { - return MaterialApp( - home: Builder( - builder: - (BuildContext context) => GestureDetector( - child: const Text('Tap'), - onTap: () => onTap(context), - ), + Future expectUnimplementedError( + void Function(BuildContext) onTap, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: + (BuildContext context) => GestureDetector( + child: const Text('Tap'), + onTap: () => onTap(context), + ), + ), ), ); + await tester.tap(find.text('Tap')); + + expect(errors.first.exception, isA()); + expect(errors.first.exception.toString(), contains(errorText)); + + errors.clear(); } - final Widget pushThrower = buildWidget((BuildContext context) { + await expectUnimplementedError((BuildContext context) { + const _GoRouteDataBuild().location; + }); + + await expectUnimplementedError((BuildContext context) { const _GoRouteDataBuild().push(context); }); - await tester.pumpWidget(pushThrower); - await tester.tap(find.text('Tap')); - expect(errors.first.exception, isA()); - expect(errors.first.exception.toString(), contains(errorText)); + await expectUnimplementedError((BuildContext context) { + const _GoRouteDataBuild().go(context); + }); - errors.clear(); + await expectUnimplementedError((BuildContext context) { + const _GoRouteDataBuild().pushReplacement(context); + }); - final Widget goThrower = buildWidget((BuildContext context) { - const _GoRouteDataBuild().go(context); + await expectUnimplementedError((BuildContext context) { + const _GoRouteDataBuild().replace(context); }); - await tester.pumpWidget(goThrower); - await tester.tap(find.text('Tap')); - expect(errors.first.exception, isA()); - expect(errors.first.exception.toString(), contains(errorText)); + FlutterError.onError = FlutterError.dumpErrorToConsole; + }); + }); - errors.clear(); + group('RelativeGoRouteData', () { + testWidgets('It should build the page from the overridden build method', ( + WidgetTester tester, + ) async { + final GoRouter goRouter = GoRouter( + initialLocation: '/build', + routes: _relativeRoutes, + ); + addTearDown(goRouter.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: goRouter)); + expect(find.byKey(const Key('build')), findsOneWidget); + expect(find.byKey(const Key('buildPage')), findsNothing); + }); - final Widget pushReplacementThrower = buildWidget((BuildContext context) { - const _GoRouteDataBuild().pushReplacement(context); + testWidgets( + 'It should build the page from the overridden buildPage method', + (WidgetTester tester) async { + final GoRouter goRouter = GoRouter( + initialLocation: '/build-page', + routes: _relativeRoutes, + ); + addTearDown(goRouter.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: goRouter)); + expect(find.byKey(const Key('build')), findsNothing); + expect(find.byKey(const Key('buildPage')), findsOneWidget); + }, + ); + + testWidgets( + 'It should build a go route with the default case sensitivity', + (WidgetTester tester) async { + final GoRoute routeWithDefaultCaseSensitivity = + RelativeGoRouteData.$route( + path: 'path', + factory: + (GoRouterState state) => const _RelativeGoRouteDataBuild(), + ); + + expect(routeWithDefaultCaseSensitivity.caseSensitive, true); + }, + ); + + testWidgets( + 'It should build a go route with the overridden case sensitivity', + (WidgetTester tester) async { + final GoRoute routeWithDefaultCaseSensitivity = + RelativeGoRouteData.$route( + path: 'path', + caseSensitive: false, + factory: + (GoRouterState state) => const _RelativeGoRouteDataBuild(), + ); + + expect(routeWithDefaultCaseSensitivity.caseSensitive, false); + }, + ); + + testWidgets('It should throw because there is no code generated', ( + WidgetTester tester, + ) async { + final List errors = []; + + FlutterError.onError = + (FlutterErrorDetails details) => errors.add(details); + + const String errorText = 'Should be generated'; + + Future expectUnimplementedError( + void Function(BuildContext) onTap, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: + (BuildContext context) => GestureDetector( + child: const Text('Tap'), + onTap: () => onTap(context), + ), + ), + ), + ); + await tester.tap(find.text('Tap')); + + expect(errors.first.exception, isA()); + expect(errors.first.exception.toString(), contains(errorText)); + + errors.clear(); + } + + await expectUnimplementedError((BuildContext context) { + const _RelativeGoRouteDataBuild().subLocation; }); - await tester.pumpWidget(pushReplacementThrower); - await tester.tap(find.text('Tap')); - expect(errors.first.exception, isA()); - expect(errors.first.exception.toString(), contains(errorText)); + await expectUnimplementedError((BuildContext context) { + const _RelativeGoRouteDataBuild().relativeLocation; + }); - errors.clear(); + await expectUnimplementedError((BuildContext context) { + const _RelativeGoRouteDataBuild().pushRelative(context); + }); - final Widget replaceThrower = buildWidget((BuildContext context) { - const _GoRouteDataBuild().pushReplacement(context); + await expectUnimplementedError((BuildContext context) { + const _RelativeGoRouteDataBuild().goRelative(context); }); - await tester.pumpWidget(replaceThrower); - await tester.tap(find.text('Tap')); - expect(errors.first.exception, isA()); - expect(errors.first.exception.toString(), contains(errorText)); + await expectUnimplementedError((BuildContext context) { + const _RelativeGoRouteDataBuild().pushReplacementRelative(context); + }); - errors.clear(); + await expectUnimplementedError((BuildContext context) { + const _RelativeGoRouteDataBuild().replaceRelative(context); + }); FlutterError.onError = FlutterError.dumpErrorToConsole; }); @@ -613,4 +767,46 @@ void main() { expect(customParameterCodec.encode, toBase64); expect(customParameterCodec.decode, fromBase64); }); + + test('TypedRelativeGoRoute with default parameters', () { + const TypedRelativeGoRoute typedGoRoute = + TypedRelativeGoRoute(path: 'path'); + + expect(typedGoRoute.path, 'path'); + expect(typedGoRoute.caseSensitive, true); + expect(typedGoRoute.routes, isEmpty); + }); + + test('TypedRelativeGoRoute with provided parameters', () { + const TypedRelativeGoRoute typedGoRoute = + TypedRelativeGoRoute( + path: 'path', + caseSensitive: false, + routes: >[ + TypedRelativeGoRoute( + path: 'sub-path', + caseSensitive: false, + ), + ], + ); + + expect(typedGoRoute.path, 'path'); + expect(typedGoRoute.caseSensitive, false); + expect(typedGoRoute.routes, hasLength(1)); + expect( + typedGoRoute.routes.single, + isA>() + .having( + (TypedRelativeGoRoute route) => route.path, + 'path', + 'sub-path', + ) + .having( + (TypedRelativeGoRoute route) => + route.caseSensitive, + 'caseSensitive', + false, + ), + ); + }); }