diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index edd933b41e2..a611c95da4a 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 14.6.0 + +- Allows going to a path relatively by prefixing `./` + ## 14.5.0 - Adds preload support to StatefulShellRoute, configurable via `preload` parameter on StatefulShellBranch. 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..72076dcbea0 --- /dev/null +++ b/packages/go_router/example/lib/go_relative.dart @@ -0,0 +1,128 @@ +// 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 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: [ + 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 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: Column( + children: [ + TextButton( + onPressed: () { + context.pop(); + }, + child: const Text('Go back'), + ), + ], + ), + ); + } +} 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..ee18d01dd76 --- /dev/null +++ b/packages/go_router/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_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); + + 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/lib/src/information_provider.dart b/packages/go_router/lib/src/information_provider.dart index 75d9dec0113..400ead006f7 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. /// @@ -139,11 +140,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 4684c75badc..8bcb62a3c38 100644 --- a/packages/go_router/lib/src/path_utils.dart +++ b/packages/go_router/lib/src/path_utils.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'misc/errors.dart'; import 'route.dart'; final RegExp _parameterRegExp = RegExp(r':(\w+)(\((?:\\.|[^\\()])+\))?'); @@ -112,6 +113,54 @@ String concatenatePaths(String parentPath, String childPath) { return '/${segments.join('/')}'; } +/// 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 = childUri.replace( + path: concatenatePaths(parentUri.path, childUri.path), + ); + + // 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) { + throw GoException('Location cannot be empty.'); + } + String canon = Uri.parse(loc).toString(); + canon = canon.endsWith('?') ? canon.substring(0, canon.length - 1) : canon; + final Uri uri = Uri.parse(canon); + + // remove trailing slash except for when you shouldn't, e.g. + // /profile/ => /profile + // / => / + // /login?from=/ => /login?from=/ + canon = uri.path.endsWith('/') && + uri.path != '/' && + !uri.hasQuery && + !uri.hasFragment + ? canon.substring(0, canon.length - 1) + : canon; + + // replace '/?', except for first occurrence, from path only + // /login/?from=/ => /login?from=/ + // /?from=/ => /?from=/ + final int pathStartIndex = uri.host.isNotEmpty + ? uri.toString().indexOf(uri.host) + uri.host.length + : uri.hasScheme + ? uri.toString().indexOf(uri.scheme) + uri.scheme.length + : 0; + if (pathStartIndex < canon.length) { + canon = canon.replaceFirst('/?', '?', pathStartIndex + 1); + } + + return canon; +} + /// Builds an absolute path for the provided route. String? fullPathForRoute( RouteBase targetRoute, String parentFullpath, List routes) { diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 94c1899e3df..8179c8a0297 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.5.0 +version: 14.6.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/go_router_test.dart b/packages/go_router/test/go_router_test.dart index 77928e71aa3..2e92c560a67 100644 --- a/packages/go_router/test/go_router_test.dart +++ b/packages/go_router/test/go_router_test.dart @@ -1845,6 +1845,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 5b0b1234329..b13599c74a5 100644 --- a/packages/go_router/test/path_utils_test.dart +++ b/packages/go_router/test/path_utils_test.dart @@ -86,4 +86,47 @@ void main() { verify('/', '/', '/'); verify('', '', '/'); }); + + 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 with fragment + verify('/a#f', 'b/c#f2', '/a/b/c#f2'); + + // Test with fragment and parameters + verify('/a?fid=f1#f', 'b/c?pid=p2#', '/a/b/c?pid=p2#'); + }); + + test('canonicalUri', () { + void verify(String path, String expected) => + expect(canonicalUri(path), expected); + verify('/a', '/a'); + verify('/a/', '/a'); + verify('/', '/'); + verify('/a/b/', '/a/b'); + verify('https://www.example.com/', 'https://www.example.com/'); + verify('https://www.example.com/a', 'https://www.example.com/a'); + verify('https://www.example.com/a/', 'https://www.example.com/a'); + verify('https://www.example.com/a/b/', 'https://www.example.com/a/b'); + verify('https://www.example.com/?', 'https://www.example.com/'); + verify('https://www.example.com/?a=b', 'https://www.example.com/?a=b'); + verify('https://www.example.com/?a=/', 'https://www.example.com/?a=/'); + verify('https://www.example.com/a/?b=c', 'https://www.example.com/a?b=c'); + verify('https://www.example.com/#a/', 'https://www.example.com/#a/'); + + expect(() => canonicalUri('::::'), throwsA(isA())); + expect(() => canonicalUri(''), throwsA(anything)); + }); }