diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index c8eaab75a70..1e03cbf98dd 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 14.2.1 +- Allows going to a path relatively by prefixing `./` +- Adds `TypedRelativeGoRoute` + ## 14.2.0 - Added proper `redirect` handling for `ShellRoute.$route` and `StatefulShellRoute.$route` for proper redirection handling in case of code generation. diff --git a/packages/go_router/example/lib/go_relative.dart b/packages/go_router/example/lib/go_relative.dart new file mode 100644 index 00000000000..f59faa9e76c --- /dev/null +++ b/packages/go_router/example/lib/go_relative.dart @@ -0,0 +1,120 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// This sample app demonstrates how to use go relatively with GoRouter.go('./$path'). +void main() => runApp(const MyApp()); + +/// The route configuration. +final GoRouter _router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) { + return const HomeScreen(); + }, + routes: [ + GoRoute( + path: 'details', + builder: (BuildContext context, GoRouterState state) { + return const DetailsScreen(); + }, + routes: [ + GoRoute( + path: 'settings', + builder: (BuildContext context, GoRouterState state) { + return const SettingsScreen(); + }, + ), + ], + ), + ], + ), + ], +); + +/// The main app. +class MyApp extends StatelessWidget { + /// Constructs a [MyApp] + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + routerConfig: _router, + ); + } +} + +/// The home screen +class HomeScreen extends StatelessWidget { + /// Constructs a [HomeScreen] + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Home Screen')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => context.go('./details'), + child: const Text('Go to the Details screen'), + ), + ], + ), + ), + ); + } +} + +/// The details screen +class DetailsScreen extends StatelessWidget { + /// Constructs a [DetailsScreen] + const DetailsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Details Screen')), + body: Center( + child: Column( + children: [ + TextButton( + onPressed: () { + context.pop(); + }, + child: const Text('go back'), + ), + TextButton( + onPressed: () { + context.go('./settings'); + }, + child: const Text('Go to the Settings screen'), + ), + ], + )), + ); + } +} + +/// The settings screen +class SettingsScreen extends StatelessWidget { + /// Constructs a [SettingsScreen] + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Settings Screen')), + body: const Center( + child: Text('Settings'), + ), + ); + } +} diff --git a/packages/go_router/example/test/go_relative_test.dart b/packages/go_router/example/test/go_relative_test.dart new file mode 100644 index 00000000000..fbc884d5559 --- /dev/null +++ b/packages/go_router/example/test/go_relative_test.dart @@ -0,0 +1,21 @@ +// 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_test/flutter_test.dart'; +import 'package:go_router_examples/go_relative.dart' as example; + +void main() { + testWidgets('example works', (WidgetTester tester) async { + await tester.pumpWidget(const example.MyApp()); + expect(find.byType(example.HomeScreen), findsOneWidget); + + await tester.tap(find.text('Go to the Details screen')); + await tester.pumpAndSettle(); + expect(find.byType(example.DetailsScreen), findsOneWidget); + + await tester.tap(find.text('Go to the Settings screen')); + await tester.pumpAndSettle(); + expect(find.byType(example.SettingsScreen), findsOneWidget); + }); +} diff --git a/packages/go_router/lib/src/information_provider.dart b/packages/go_router/lib/src/information_provider.dart index dc979193b32..a019761922f 100644 --- a/packages/go_router/lib/src/information_provider.dart +++ b/packages/go_router/lib/src/information_provider.dart @@ -10,6 +10,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'match.dart'; +import 'path_utils.dart'; /// The type of the navigation. /// @@ -135,11 +136,16 @@ class GoRouteInformationProvider extends RouteInformationProvider } void _setValue(String location, Object state) { - final Uri uri = Uri.parse(location); + Uri uri = Uri.parse(location); + + // Check for relative location + if (location.startsWith('./')) { + uri = concatenateUris(_value.uri, uri); + } final bool shouldNotify = _valueHasChanged(newLocationUri: uri, newState: state); - _value = RouteInformation(uri: Uri.parse(location), state: state); + _value = RouteInformation(uri: uri, state: state); if (shouldNotify) { notifyListeners(); } diff --git a/packages/go_router/lib/src/path_utils.dart b/packages/go_router/lib/src/path_utils.dart index 49a7b33b3eb..247f17db14c 100644 --- a/packages/go_router/lib/src/path_utils.dart +++ b/packages/go_router/lib/src/path_utils.dart @@ -119,6 +119,20 @@ String concatenatePaths(String parentPath, String childPath) { return '${parentPath == '/' ? '' : parentPath}/$childPath'; } +/// Concatenates two Uri. It will [concatenatePaths] the parent's and the child's paths, and take only the child's parameters. +/// +/// e.g: pathA = /a?fid=f1, pathB = c/d?pid=p2, concatenatePaths(pathA, pathB) = /a/c/d?pid=2. +Uri concatenateUris(Uri parentUri, Uri childUri) { + Uri newUri = parentUri.replace( + path: concatenatePaths(parentUri.path, childUri.path), + queryParameters: childUri.queryParameters, + ); + + // Parse the new normalized uri to remove unnecessary parts, like the trailing '?'. + newUri = Uri.parse(canonicalUri(newUri.toString())); + return newUri; +} + /// Normalizes the location string. String canonicalUri(String loc) { if (loc.isEmpty) { diff --git a/packages/go_router/lib/src/route_data.dart b/packages/go_router/lib/src/route_data.dart index afe6159cecb..c6c2e13845b 100644 --- a/packages/go_router/lib/src/route_data.dart +++ b/packages/go_router/lib/src/route_data.dart @@ -390,6 +390,28 @@ class TypedGoRoute extends TypedRoute { final List> routes; } +/// A superclass for each typed go route descendant +@Target({TargetKind.library, TargetKind.classType}) +class TypedRelativeGoRoute extends TypedRoute { + /// Default const constructor + const TypedRelativeGoRoute({ + required this.path, + this.routes = const >[], + }); + + /// The relative path that corresponds to this route. + /// + /// See [GoRoute.path]. + /// + /// + final String path; + + /// Child route definitions. + /// + /// See [RouteBase.routes]. + final List> routes; +} + /// 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 eec6806cf6d..fc4f3508345 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: 14.2.0 +version: 14.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_test.dart b/packages/go_router/test/go_router_test.dart index f395faf906f..eea3a8fdef6 100644 --- a/packages/go_router/test/go_router_test.dart +++ b/packages/go_router/test/go_router_test.dart @@ -1791,6 +1791,283 @@ void main() { }); }); + group('go relative', () { + testWidgets('from default route', (WidgetTester tester) async { + final List routes = [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) => + const HomeScreen(), + routes: [ + GoRoute( + path: 'login', + builder: (BuildContext context, GoRouterState state) => + const LoginScreen(), + ), + ], + ), + ]; + + final GoRouter router = await createRouter(routes, tester); + router.go('./login'); + await tester.pumpAndSettle(); + expect(find.byType(LoginScreen), findsOneWidget); + }); + + testWidgets('from non-default route', (WidgetTester tester) async { + final List routes = [ + GoRoute( + path: '/home', + builder: (BuildContext context, GoRouterState state) => + const HomeScreen(), + routes: [ + GoRoute( + path: 'login', + builder: (BuildContext context, GoRouterState state) => + const LoginScreen(), + ), + ], + ), + ]; + + final GoRouter router = await createRouter(routes, tester); + router.go('/home'); + router.go('./login'); + await tester.pumpAndSettle(); + expect(find.byType(LoginScreen), findsOneWidget); + }); + + testWidgets('match w/ path params', (WidgetTester tester) async { + const String fid = 'f2'; + const String pid = 'p1'; + + final List routes = [ + GoRoute( + path: '/home', + builder: (BuildContext context, GoRouterState state) => + const HomeScreen(), + routes: [ + GoRoute( + path: 'family/:fid', + builder: (BuildContext context, GoRouterState state) => + const FamilyScreen('dummy'), + routes: [ + GoRoute( + name: 'person', + path: 'person/:pid', + builder: (BuildContext context, GoRouterState state) { + expect(state.pathParameters, + {'fid': fid, 'pid': pid}); + return const PersonScreen('dummy', 'dummy'); + }, + ), + ], + ), + ], + ), + ]; + + final GoRouter router = + await createRouter(routes, tester, initialLocation: '/home'); + + router.go('./family/$fid'); + await tester.pumpAndSettle(); + expect(find.byType(FamilyScreen), findsOneWidget); + + router.go('./person/$pid'); + await tester.pumpAndSettle(); + expect(find.byType(PersonScreen), findsOneWidget); + }); + + testWidgets('match w/ query params', (WidgetTester tester) async { + const String fid = 'f2'; + const String pid = 'p1'; + + final List routes = [ + GoRoute( + path: '/home', + builder: (BuildContext context, GoRouterState state) => + const HomeScreen(), + routes: [ + GoRoute( + path: 'family', + builder: (BuildContext context, GoRouterState state) => + const FamilyScreen('dummy'), + routes: [ + GoRoute( + path: 'person', + builder: (BuildContext context, GoRouterState state) { + expect(state.uri.queryParameters, + {'pid': pid}); + return const PersonScreen('dummy', 'dummy'); + }, + ), + ], + ), + ], + ), + ]; + + final GoRouter router = + await createRouter(routes, tester, initialLocation: '/home'); + + router.go('./family?fid=$fid'); + await tester.pumpAndSettle(); + expect(find.byType(FamilyScreen), findsOneWidget); + + router.go('./person?pid=$pid'); + await tester.pumpAndSettle(); + expect(find.byType(PersonScreen), findsOneWidget); + }); + + testWidgets('too few params', (WidgetTester tester) async { + const String pid = 'p1'; + + final List routes = [ + GoRoute( + path: '/home', + builder: (BuildContext context, GoRouterState state) => + const HomeScreen(), + routes: [ + GoRoute( + path: 'family/:fid', + builder: (BuildContext context, GoRouterState state) => + const FamilyScreen('dummy'), + routes: [ + GoRoute( + path: 'person/:pid', + builder: (BuildContext context, GoRouterState state) => + const PersonScreen('dummy', 'dummy'), + ), + ], + ), + ], + ), + ]; + // await expectLater(() async { + final GoRouter router = await createRouter( + routes, + tester, + initialLocation: '/home', + errorBuilder: (BuildContext context, GoRouterState state) => + TestErrorScreen(state.error!), + ); + router.go('./family/person/$pid'); + await tester.pumpAndSettle(); + expect(find.byType(TestErrorScreen), findsOneWidget); + + final List matches = + router.routerDelegate.currentConfiguration.matches; + expect(matches, hasLength(0)); + }); + + testWidgets('match no route', (WidgetTester tester) async { + final List routes = [ + GoRoute( + path: '/home', + builder: (BuildContext context, GoRouterState state) => + const HomeScreen(), + routes: [ + GoRoute( + path: 'family', + builder: (BuildContext context, GoRouterState state) => + const FamilyScreen('dummy'), + routes: [ + GoRoute( + path: 'person', + builder: (BuildContext context, GoRouterState state) => + const PersonScreen('dummy', 'dummy'), + ), + ], + ), + ], + ), + ]; + + final GoRouter router = await createRouter( + routes, + tester, + initialLocation: '/home', + errorBuilder: (BuildContext context, GoRouterState state) => + TestErrorScreen(state.error!), + ); + router.go('person'); + + await tester.pumpAndSettle(); + expect(find.byType(TestErrorScreen), findsOneWidget); + + final List matches = + router.routerDelegate.currentConfiguration.matches; + expect(matches, hasLength(0)); + }); + + testWidgets('preserve path param spaces and slashes', + (WidgetTester tester) async { + const String param1 = 'param w/ spaces and slashes'; + final List routes = [ + GoRoute( + path: '/home', + builder: dummy, + routes: [ + GoRoute( + path: 'page1/:param1', + builder: (BuildContext c, GoRouterState s) { + expect(s.pathParameters['param1'], param1); + return const DummyScreen(); + }, + ), + ], + ) + ]; + + final GoRouter router = + await createRouter(routes, tester, initialLocation: '/home'); + final String loc = 'page1/${Uri.encodeComponent(param1)}'; + router.go('./$loc'); + + await tester.pumpAndSettle(); + expect(find.byType(DummyScreen), findsOneWidget); + + final RouteMatchList matches = router.routerDelegate.currentConfiguration; + expect(matches.pathParameters['param1'], param1); + }); + + testWidgets('preserve query param spaces and slashes', + (WidgetTester tester) async { + const String param1 = 'param w/ spaces and slashes'; + final List routes = [ + GoRoute( + path: '/home', + builder: dummy, + routes: [ + GoRoute( + path: 'page1', + builder: (BuildContext c, GoRouterState s) { + expect(s.uri.queryParameters['param1'], param1); + return const DummyScreen(); + }, + ), + ], + ) + ]; + + final GoRouter router = + await createRouter(routes, tester, initialLocation: '/home'); + + final String loc = Uri( + path: 'page1', + queryParameters: {'param1': param1}, + ).toString(); + router.go('./$loc'); + + await tester.pumpAndSettle(); + expect(find.byType(DummyScreen), findsOneWidget); + + final RouteMatchList matches = router.routerDelegate.currentConfiguration; + expect(matches.uri.queryParameters['param1'], param1); + }); + }); + group('redirects', () { testWidgets('top-level redirect', (WidgetTester tester) async { final List routes = [ diff --git a/packages/go_router/test/path_utils_test.dart b/packages/go_router/test/path_utils_test.dart index 5a656df135e..d25a56f246e 100644 --- a/packages/go_router/test/path_utils_test.dart +++ b/packages/go_router/test/path_utils_test.dart @@ -93,6 +93,22 @@ void main() { verifyThrows('', ''); }); + test('concatenateUris', () { + void verify(String pathA, String pathB, String expected) { + final String result = + concatenateUris(Uri.parse(pathA), Uri.parse(pathB)).toString(); + expect(result, expected); + } + + verify('/a', 'b/c', '/a/b/c'); + verify('/', 'b', '/b'); + + // Test with parameters + verify('/a?fid=f1', 'b/c', '/a/b/c'); + verify('/a', 'b/c?pid=p2', '/a/b/c?pid=p2'); + verify('/a?fid=f1', 'b/c?pid=p2', '/a/b/c?pid=p2'); + }); + test('canonicalUri', () { void verify(String path, String expected) => expect(canonicalUri(path), expected); diff --git a/packages/go_router_builder/CHANGELOG.md b/packages/go_router_builder/CHANGELOG.md index 3332a70e1b8..8357de4d3f0 100644 --- a/packages/go_router_builder/CHANGELOG.md +++ b/packages/go_router_builder/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.7.1 + +- Adds `TypedRelativeGoRoute` annotation which supports relative routes. + ## 2.7.0 - Adds an example and a test with `onExit`. diff --git a/packages/go_router_builder/example/lib/go_relative.dart b/packages/go_router_builder/example/lib/go_relative.dart new file mode 100644 index 00000000000..640523161bf --- /dev/null +++ b/packages/go_router_builder/example/lib/go_relative.dart @@ -0,0 +1,156 @@ +// 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. + +// ignore_for_file: public_member_api_docs, unreachable_from_main + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +part 'go_relative.g.dart'; + +void main() => runApp(const MyApp()); + +/// The main app. +class MyApp extends StatelessWidget { + /// Constructs a [MyApp] + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + routerConfig: _router, + ); + } +} + +/// The route configuration. +final GoRouter _router = GoRouter( + routes: $appRoutes, +); +const TypedRelativeGoRoute detailRoute = + TypedRelativeGoRoute( + path: 'details/:detailId', + routes: >[ + TypedRelativeGoRoute(path: 'settings/:settingId'), + ], +); + +@TypedGoRoute( + path: '/', + routes: >[detailRoute], +) +class HomeRoute extends GoRouteData { + @override + Widget build(BuildContext context, GoRouterState state) { + return const HomeScreen(); + } +} + +class DetailsRoute extends GoRouteData { + const DetailsRoute({required this.detailId}); + final String detailId; + + @override + Widget build(BuildContext context, GoRouterState state) { + return DetailsScreen(id: detailId); + } +} + +class SettingsRoute extends GoRouteData { + const SettingsRoute({ + required this.settingId, + }); + final String settingId; + + @override + Widget build(BuildContext context, GoRouterState state) { + return SettingsScreen(id: settingId); + } +} + +/// The home screen +class HomeScreen extends StatefulWidget { + /// Constructs a [HomeScreen] + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Home Screen')), + body: Center( + child: ElevatedButton( + onPressed: () { + const DetailsRoute(detailId: 'DetailsId').go(context); + }, + child: const Text('Go to the Details screen'), + ), + ), + ); + } +} + +/// The details screen +class DetailsScreen extends StatelessWidget { + /// Constructs a [DetailsScreen] + const DetailsScreen({ + super.key, + required this.id, + }); + + final String id; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Details Screen $id')), + body: Center( + child: Column( + children: [ + ElevatedButton( + onPressed: () => HomeRoute().go(context), + child: const Text('Go back'), + ), + ElevatedButton( + onPressed: () => const SettingsRoute( + settingId: 'SettingsId', + ).go(context), + child: const Text('Go to the Settings screen'), + ), + ], + ), + ), + ); + } +} + +/// The details screen +class SettingsScreen extends StatelessWidget { + /// Constructs a [SettingsScreen] + const SettingsScreen({ + super.key, + required this.id, + }); + + final String id; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Settings Screen $id')), + body: Center( + child: TextButton( + onPressed: () { + context.pop(); + }, + child: const Text('Go back'), + ), + ), + ); + } +} diff --git a/packages/go_router_builder/example/lib/go_relative.g.dart b/packages/go_router_builder/example/lib/go_relative.g.dart new file mode 100644 index 00000000000..2df63906645 --- /dev/null +++ b/packages/go_router_builder/example/lib/go_relative.g.dart @@ -0,0 +1,71 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: always_specify_types, public_member_api_docs + +part of 'go_relative.dart'; + +// ************************************************************************** +// GoRouterGenerator +// ************************************************************************** + +List get $appRoutes => [ + $homeRoute, + ]; + +RouteBase get $homeRoute => GoRouteData.$route( + path: '/', + factory: $HomeRouteExtension._fromState, + routes: [ + GoRouteData.$route( + path: 'details/:detailId', + factory: $DetailsRouteExtension._fromState, + routes: [ + GoRouteData.$route( + path: 'settings/:settingId', + factory: $SettingsRouteExtension._fromState, + ), + ], + ), + ], + ); + +extension $HomeRouteExtension on HomeRoute { + static HomeRoute _fromState(GoRouterState state) => HomeRoute(); + + String get location => GoRouteData.$location( + '/', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + +extension $DetailsRouteExtension on DetailsRoute { + static DetailsRoute _fromState(GoRouterState state) => DetailsRoute( + detailId: state.pathParameters['detailId']!, + ); + + String get location => './${GoRouteData.$location( + 'details/${Uri.encodeComponent(detailId)}', + )}'; + + void go(BuildContext context) => context.go(location); +} + +extension $SettingsRouteExtension on SettingsRoute { + static SettingsRoute _fromState(GoRouterState state) => SettingsRoute( + settingId: state.pathParameters['settingId']!, + ); + + String get location => './${GoRouteData.$location( + 'settings/${Uri.encodeComponent(settingId)}', + )}'; + + void go(BuildContext context) => context.go(location); +} diff --git a/packages/go_router_builder/example/pubspec.yaml b/packages/go_router_builder/example/pubspec.yaml index bc02b13402e..18e225d0681 100644 --- a/packages/go_router_builder/example/pubspec.yaml +++ b/packages/go_router_builder/example/pubspec.yaml @@ -21,5 +21,9 @@ dev_dependencies: path: .. test: ^1.17.0 +dependency_overrides: + go_router: + path: ../../go_router + flutter: uses-material-design: true diff --git a/packages/go_router_builder/example/test/go_relative_test.dart b/packages/go_router_builder/example/test/go_relative_test.dart new file mode 100644 index 00000000000..23292e8ffd7 --- /dev/null +++ b/packages/go_router_builder/example/test/go_relative_test.dart @@ -0,0 +1,29 @@ +// 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_test/flutter_test.dart'; +import 'package:go_router_builder_example/go_relative.dart' as example; + +void main() { + testWidgets('example works', (WidgetTester tester) async { + await tester.pumpWidget(const example.MyApp()); + expect(find.byType(example.HomeScreen), findsOneWidget); + + await tester.tap(find.text('Go to the Details screen')); + await tester.pumpAndSettle(); + expect(find.byType(example.DetailsScreen), findsOneWidget); + + await tester.tap(find.text('Go to the Settings screen')); + await tester.pumpAndSettle(); + expect(find.byType(example.SettingsScreen), findsOneWidget); + + await tester.tap(find.text('Go back')); + await tester.pumpAndSettle(); + expect(find.byType(example.DetailsScreen), findsOneWidget); + + await tester.tap(find.text('Go back')); + await tester.pumpAndSettle(); + expect(find.byType(example.HomeScreen), findsOneWidget); + }); +} diff --git a/packages/go_router_builder/lib/src/go_router_generator.dart b/packages/go_router_builder/lib/src/go_router_generator.dart index e094d1f98ed..1032b78873d 100644 --- a/packages/go_router_builder/lib/src/go_router_generator.dart +++ b/packages/go_router_builder/lib/src/go_router_generator.dart @@ -15,6 +15,7 @@ const String _routeDataUrl = 'package:go_router/src/route_data.dart'; const Map _annotations = { 'TypedGoRoute': 'GoRouteData', + 'TypedRelativeRoute': 'GoRouteData', 'TypedShellRoute': 'ShellRouteData', 'TypedStatefulShellBranch': 'StatefulShellBranchData', 'TypedStatefulShellRoute': 'StatefulShellRouteData', diff --git a/packages/go_router_builder/lib/src/route_config.dart b/packages/go_router_builder/lib/src/route_config.dart index 0cfa7a3928c..1236dc26534 100644 --- a/packages/go_router_builder/lib/src/route_config.dart +++ b/packages/go_router_builder/lib/src/route_config.dart @@ -209,8 +209,10 @@ class GoRouteConfig extends RouteBaseConfig { RouteBaseConfig? config = this; while (config != null) { - if (config is GoRouteConfig) { - pathSegments.add(config.path); + if (config + case GoRouteConfig(:final String path) || + GoRelativeRouteConfig(:final String path)) { + pathSegments.add(path); } config = config.parent; } @@ -424,6 +426,229 @@ extension $_extensionName on $_className { String get dataConvertionFunctionName => r'$route'; } +/// The configuration to generate class declarations for a GoRouteData. +class GoRelativeRouteConfig extends RouteBaseConfig { + GoRelativeRouteConfig._({ + required this.path, + required this.parentNavigatorKey, + required super.routeDataClass, + required super.parent, + }) : super._(); + + /// The path of the GoRoute to be created by this configuration. + final String path; + + /// The parent navigator key. + final String? parentNavigatorKey; + + late final Set _pathParams = pathParametersFromPattern(path); + + // construct path bits using parent bits + // if there are any queryParam objects, add in the `queryParam` bits + String get _locationArgs { + final Map pathParameters = Map.fromEntries( + _pathParams.map((String pathParameter) { + // Enum types are encoded using a map, so we need a nullability check + // here to ensure it matches Uri.encodeComponent nullability + final DartType? type = _field(pathParameter)?.returnType; + final String value = + '\${Uri.encodeComponent(${_encodeFor(pathParameter)}${type?.isEnum ?? false ? '!' : ''})}'; + return MapEntry(pathParameter, value); + }), + ); + final String location = patternToPath(path, pathParameters); + return "'$location'"; + } + + ParameterElement? get _extraParam => _ctor.parameters + .singleWhereOrNull((ParameterElement element) => element.isExtraField); + + String get _fromStateConstructor { + final StringBuffer buffer = StringBuffer('=>'); + if (_ctor.isConst && + _ctorParams.isEmpty && + _ctorQueryParams.isEmpty && + _extraParam == null) { + buffer.writeln('const '); + } + + buffer.writeln('$_className('); + for (final ParameterElement param in [ + ..._ctorParams, + ..._ctorQueryParams, + if (_extraParam != null) _extraParam!, + ]) { + buffer.write(_decodeFor(param)); + } + buffer.writeln(');'); + + return buffer.toString(); + } + + String _decodeFor(ParameterElement element) { + if (element.isRequired) { + if (element.type.nullabilitySuffix == NullabilitySuffix.question && + _pathParams.contains(element.name)) { + throw InvalidGenerationSourceError( + 'Required parameters in the path cannot be nullable.', + element: element, + ); + } + } + final String fromStateExpression = decodeParameter(element, _pathParams); + + if (element.isPositional) { + return '$fromStateExpression,'; + } + + if (element.isNamed) { + return '${element.name}: $fromStateExpression,'; + } + + throw InvalidGenerationSourceError( + '$likelyIssueMessage (param not named or positional)', + element: element, + ); + } + + String _encodeFor(String fieldName) { + final PropertyAccessorElement? field = _field(fieldName); + if (field == null) { + throw InvalidGenerationSourceError( + 'Could not find a field for the path parameter "$fieldName".', + element: routeDataClass, + ); + } + + return encodeField(field); + } + + String get _locationQueryParams { + if (_ctorQueryParams.isEmpty) { + return ''; + } + + final StringBuffer buffer = StringBuffer('queryParams: {\n'); + + for (final ParameterElement param in _ctorQueryParams) { + final String parameterName = param.name; + + final List conditions = []; + if (param.hasDefaultValue) { + if (param.type.isNullableType) { + throw NullableDefaultValueError(param); + } + conditions.add('$parameterName != ${param.defaultValueCode!}'); + } else if (param.type.isNullableType) { + conditions.add('$parameterName != null'); + } + String line = ''; + if (conditions.isNotEmpty) { + line = 'if (${conditions.join(' && ')}) '; + } + line += '${escapeDartString(parameterName.kebab)}: ' + '${_encodeFor(parameterName)},'; + + buffer.writeln(line); + } + + buffer.writeln('},'); + + return buffer.toString(); + } + + late final List _ctorParams = + _ctor.parameters.where((ParameterElement element) { + if (_pathParams.contains(element.name)) { + return true; + } + return false; + }).toList(); + + late final List _ctorQueryParams = _ctor.parameters + .where((ParameterElement element) => + !_pathParams.contains(element.name) && !element.isExtraField) + .toList(); + + ConstructorElement get _ctor { + final ConstructorElement? ctor = routeDataClass.unnamedConstructor; + + if (ctor == null) { + throw InvalidGenerationSourceError( + 'Missing default constructor', + element: routeDataClass, + ); + } + return ctor; + } + + @override + Iterable classDeclarations() => [ + _extensionDefinition, + ..._enumDeclarations(), + ]; + + String get _extensionDefinition => ''' +extension $_extensionName on $_className { + static $_className _fromState(GoRouterState state) $_fromStateConstructor + + String get location => GoRouteData.\$location($_locationArgs,$_locationQueryParams); + String get relativeLocation => './\$location'; + + void go(BuildContext context) => + context.go(relativeLocation${_extraParam != null ? ', extra: $extraFieldName' : ''}); + + Future push(BuildContext context) => + context.push(relativeLocation${_extraParam != null ? ', extra: $extraFieldName' : ''}); + + void pushReplacement(BuildContext context) => + context.pushReplacement(relativeLocation${_extraParam != null ? ', extra: $extraFieldName' : ''}); + + void replace(BuildContext context) => + context.replace(relativeLocation${_extraParam != null ? ', extra: $extraFieldName' : ''}); +} +'''; + + /// Returns code representing the constant maps that contain the `enum` to + /// [String] mapping for each referenced enum. + Iterable _enumDeclarations() { + final Set enumParamTypes = {}; + + for (final ParameterElement ctorParam in [ + ..._ctorParams, + ..._ctorQueryParams, + ]) { + DartType potentialEnumType = ctorParam.type; + if (potentialEnumType is ParameterizedType && + (ctorParam.type as ParameterizedType).typeArguments.isNotEmpty) { + potentialEnumType = + (ctorParam.type as ParameterizedType).typeArguments.first; + } + + if (potentialEnumType.isEnum) { + enumParamTypes.add(potentialEnumType as InterfaceType); + } + } + return enumParamTypes.map(_enumMapConst); + } + + @override + String get factorConstructorParameters => + 'factory: $_extensionName._fromState,'; + + @override + String get routeConstructorParameters => ''' + path: ${escapeDartString(path)}, + ${parentNavigatorKey == null ? '' : 'parentNavigatorKey: $parentNavigatorKey,'} +'''; + + @override + String get routeDataClassName => 'GoRouteData'; + + @override + String get dataConvertionFunctionName => r'$route'; +} + /// Represents a `TypedGoRoute` annotation to the builder. abstract class RouteBaseConfig { RouteBaseConfig._({ @@ -550,6 +775,23 @@ abstract class RouteBaseConfig { parameterName: r'$parentNavigatorKey', ), ); + case 'TypedRelativeGoRoute': + final ConstantReader pathValue = reader.read('path'); + if (pathValue.isNull) { + throw InvalidGenerationSourceError( + 'Missing `path` value on annotation.', + element: element, + ); + } + value = GoRelativeRouteConfig._( + path: pathValue.stringValue, + routeDataClass: classElement, + parent: parent, + parentNavigatorKey: _generateParameterGetterCode( + classElement, + parameterName: r'$parentNavigatorKey', + ), + ); default: throw UnsupportedError('Unrecognized type $typeName'); } diff --git a/packages/go_router_builder/pubspec.yaml b/packages/go_router_builder/pubspec.yaml index 0e6e10b6b3d..76d904c065a 100644 --- a/packages/go_router_builder/pubspec.yaml +++ b/packages/go_router_builder/pubspec.yaml @@ -2,7 +2,7 @@ name: go_router_builder description: >- A builder that supports generated strongly-typed route helpers for package:go_router -version: 2.7.0 +version: 2.8.0 repository: https://github.com/flutter/packages/tree/main/packages/go_router_builder issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router_builder%22 @@ -29,6 +29,10 @@ dev_dependencies: go_router: ^14.0.0 test: ^1.20.0 +dependency_overrides: + go_router: + path: ../go_router + topics: - codegen - deep-linking diff --git a/packages/go_router_builder/test_inputs/go_relative.dart b/packages/go_router_builder/test_inputs/go_relative.dart new file mode 100644 index 00000000000..f45c0f2569c --- /dev/null +++ b/packages/go_router_builder/test_inputs/go_relative.dart @@ -0,0 +1,37 @@ +// 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:go_router/go_router.dart'; + +const TypedRelativeGoRoute relativeRoute = + TypedRelativeGoRoute( + path: 'relative-route', + routes: >[ + TypedRelativeGoRoute(path: 'inner-relative-route') + ], +); + +@TypedGoRoute( + path: 'route-1', + routes: >[relativeRoute], +) +class Route1 extends GoRouteData { + const Route1(); +} + +@TypedGoRoute( + path: 'route-2', + routes: >[relativeRoute], +) +class Route2 extends GoRouteData { + const Route2(); +} + +class RelativeRoute extends GoRouteData { + const RelativeRoute(); +} + +class InnerRelativeRoute extends GoRouteData { + const InnerRelativeRoute(); +} diff --git a/packages/go_router_builder/test_inputs/go_relative.dart.expect b/packages/go_router_builder/test_inputs/go_relative.dart.expect new file mode 100644 index 00000000000..d491d6562ad --- /dev/null +++ b/packages/go_router_builder/test_inputs/go_relative.dart.expect @@ -0,0 +1,88 @@ +RouteBase get $route1 => GoRouteData.$route( + path: 'route-1', + factory: $Route1Extension._fromState, + routes: [ + GoRouteData.$route( + path: 'relative-route', + factory: $RelativeRouteExtension._fromState, + routes: [ + GoRouteData.$route( + path: 'inner-relative-route', + factory: $InnerRelativeRouteExtension._fromState, + ), + ], + ), + ], + ); + +extension $Route1Extension on Route1 { + static Route1 _fromState(GoRouterState state) => const Route1(); + + String get location => GoRouteData.$location( + 'route-1', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + +extension $RelativeRouteExtension on RelativeRoute { + static RelativeRoute _fromState(GoRouterState state) => const RelativeRoute(); + + String get location => './${GoRouteData.$location( + 'relative-route', + )}'; + + void go(BuildContext context) => context.go(location); +} + +extension $InnerRelativeRouteExtension on InnerRelativeRoute { + static InnerRelativeRoute _fromState(GoRouterState state) => + const InnerRelativeRoute(); + + String get location => './${GoRouteData.$location( + 'inner-relative-route', + )}'; + + void go(BuildContext context) => context.go(location); +} + +RouteBase get $route2 => GoRouteData.$route( + path: 'route-2', + factory: $Route2Extension._fromState, + routes: [ + GoRouteData.$route( + path: 'relative-route', + factory: $RelativeRouteExtension._fromState, + routes: [ + GoRouteData.$route( + path: 'inner-relative-route', + factory: $InnerRelativeRouteExtension._fromState, + ), + ], + ), + ], + ); + +extension $Route2Extension on Route2 { + static Route2 _fromState(GoRouterState state) => const Route2(); + + String get location => GoRouteData.$location( + 'route-2', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +}