diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index b8a19b4bbb4..9a5c554f356 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -4,6 +4,10 @@ ## 14.1.3 +- Adds `GoRouter.goRelative` + +## 14.1.3 + - Improves the logging of routes when `debugLogDiagnostics` is enabled or `debugKnownRoutes() is called. Explains the position of shell routes in the route tree. Prints the widget name of the routes it is building. ## 14.1.2 diff --git a/packages/go_router/lib/src/information_provider.dart b/packages/go_router/lib/src/information_provider.dart index dc979193b32..999cd530a25 100644 --- a/packages/go_router/lib/src/information_provider.dart +++ b/packages/go_router/lib/src/information_provider.dart @@ -172,6 +172,33 @@ class GoRouteInformationProvider extends RouteInformationProvider ); } + /// Relatively go to [relativeLocation]. + void goRelative(String relativeLocation, {Object? extra}) { + assert( + !relativeLocation.startsWith('/'), + "Relative locations must not start with a '/'.", + ); + + final Uri currentUri = value.uri; + Uri newUri = Uri.parse( + currentUri.path.endsWith('/') + ? '${currentUri.path}$relativeLocation' + : '${currentUri.path}/$relativeLocation', + ); + newUri = newUri.replace(queryParameters: { + ...currentUri.queryParameters, + ...newUri.queryParameters, + }); + + _setValue( + newUri.toString(), + RouteInformationState( + extra: extra, + type: NavigatingType.go, + ), + ); + } + /// Restores the current route matches with the `matchList`. void restore(String location, {required RouteMatchList matchList}) { _setValue( diff --git a/packages/go_router/lib/src/misc/extensions.dart b/packages/go_router/lib/src/misc/extensions.dart index c137022b802..f5f654ce475 100644 --- a/packages/go_router/lib/src/misc/extensions.dart +++ b/packages/go_router/lib/src/misc/extensions.dart @@ -24,6 +24,13 @@ extension GoRouterHelper on BuildContext { void go(String location, {Object? extra}) => GoRouter.of(this).go(location, extra: extra); + /// Navigate relative to a location. + void goRelative(String location, {Object? extra}) => + GoRouter.of(this).goRelative( + location, + extra: extra, + ); + /// Navigate to a named route. void goNamed( String name, { diff --git a/packages/go_router/lib/src/route_data.dart b/packages/go_router/lib/src/route_data.dart index b10f2b11158..59ff3893052 100644 --- a/packages/go_router/lib/src/route_data.dart +++ b/packages/go_router/lib/src/route_data.dart @@ -365,6 +365,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/lib/src/router.dart b/packages/go_router/lib/src/router.dart index dc6d88057eb..0146a1ca29c 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -340,6 +340,15 @@ class GoRouter implements RouterConfig { routeInformationProvider.go(location, extra: extra); } + /// Navigate to a URI location by appending [relativeLocation] to the current [GoRouterState.matchedLocation] w/ optional query parameters, e.g. + void goRelative( + String relativeLocation, { + Object? extra, + }) { + log('going relative to $relativeLocation'); + routeInformationProvider.goRelative(relativeLocation, extra: extra); + } + /// Restore the RouteMatchList void restore(RouteMatchList matchList) { log('restoring ${matchList.uri}'); diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart index f395faf906f..3598bfe2ade 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.goRelative('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.goRelative('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('/'); + + router.goRelative('family/$fid'); + await tester.pumpAndSettle(); + expect(find.byType(FamilyScreen), findsOneWidget); + + router.goRelative('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, + {'fid': fid, 'pid': pid}); + return const PersonScreen('dummy', 'dummy'); + }, + ), + ], + ), + ], + ), + ]; + + final GoRouter router = + await createRouter(routes, tester, initialLocation: '/home'); + + router.goRelative('family?fid=$fid'); + await tester.pumpAndSettle(); + expect(find.byType(FamilyScreen), findsOneWidget); + + router.goRelative('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.goRelative('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.goRelative(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'); + + router.goRelative(Uri( + path: 'page1', + queryParameters: {'param1': param1}, + ).toString()); + + 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_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/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..d3724ca42ed 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,224 @@ 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.name, + 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 name of the GoRoute to be created by this configuration. + final String? name; + + /// 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); + + void goRelative(BuildContext context) => + context.goRelative(location${_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)}, + ${name != null ? 'name: ${escapeDartString(name!)},' : ''} + ${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 +770,25 @@ 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, + ); + } + final ConstantReader nameValue = reader.read('name'); + value = GoRelativeRouteConfig._( + path: pathValue.stringValue, + name: nameValue.isNull ? null : nameValue.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..6fba2f8dc9f 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.7.1 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