From 6be9a5d320a07249fde49cd868b40ccb036e4021 Mon Sep 17 00:00:00 2001 From: Omar Hanafy Date: Sun, 22 Dec 2024 16:22:07 +0200 Subject: [PATCH] [go_router] Added top level onEnter callback. Added onEnter callback to enable route interception and demonstrate usage in example. --- packages/go_router/CHANGELOG.md | 3 +- .../example/lib/top_level_on_enter.dart | 154 ++++++++++++++++++ packages/go_router/lib/src/configuration.dart | 33 ++++ packages/go_router/lib/src/parser.dart | 153 +++++++++++------ packages/go_router/lib/src/router.dart | 6 + packages/go_router/test/parser_test.dart | 54 ++++++ 6 files changed, 356 insertions(+), 47 deletions(-) create mode 100644 packages/go_router/example/lib/top_level_on_enter.dart diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 9327b97442bb..77068dffb830 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT -* Updates minimum supported SDK version to Flutter 3.22/Dart 3.4. +* Updated the minimum supported SDK version to Flutter 3.22/Dart 3.4. +* Added new top level `onEnter` callback for controlling incoming route navigation. ## 14.6.2 diff --git a/packages/go_router/example/lib/top_level_on_enter.dart b/packages/go_router/example/lib/top_level_on_enter.dart new file mode 100644 index 000000000000..011af13c0a5a --- /dev/null +++ b/packages/go_router/example/lib/top_level_on_enter.dart @@ -0,0 +1,154 @@ +// Copyright 2013 The Flutter Authors. +// 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'; + +void main() => runApp(const App()); + +/// The main application widget. +class App extends StatelessWidget { + /// Constructs an [App]. + const App({super.key}); + + /// The title of the app. + static const String title = 'GoRouter Example: Top-level onEnter'; + + @override + Widget build(BuildContext context) => MaterialApp.router( + routerConfig: GoRouter( + initialLocation: '/home', + + /// A callback invoked for every route navigation attempt. + /// + /// If the callback returns `false`, the navigation is blocked. + /// Use this to handle authentication, referrals, or other route-based logic. + onEnter: (BuildContext context, GoRouterState state) { + // Save the referral code (if provided) and block navigation to the /referral route. + if (state.uri.path == '/referral') { + saveReferralCode(context, state.uri.queryParameters['code']); + return false; + } + + return true; // Allow navigation for all other routes. + }, + + /// The list of application routes. + routes: [ + GoRoute( + path: '/login', + builder: (BuildContext context, GoRouterState state) => + const LoginScreen(), + ), + GoRoute( + path: '/home', + builder: (BuildContext context, GoRouterState state) => + const HomeScreen(), + ), + GoRoute( + path: '/settings', + builder: (BuildContext context, GoRouterState state) => + const SettingsScreen(), + ), + ], + ), + title: title, + ); +} + +/// The login screen widget. +class LoginScreen extends StatelessWidget { + /// Constructs a [LoginScreen]. + const LoginScreen({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text(App.title)), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => context.go('/home'), + child: const Text('Go to Home'), + ), + ElevatedButton( + onPressed: () => context.go('/settings'), + child: const Text('Go to Settings'), + ), + ], + ), + ), + ); +} + +/// The home screen widget. +class HomeScreen extends StatelessWidget { + /// Constructs a [HomeScreen]. + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text(App.title)), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => context.go('/login'), + child: const Text('Go to Login'), + ), + ElevatedButton( + onPressed: () => context.go('/settings'), + child: const Text('Go to Settings'), + ), + ElevatedButton( + // This would typically be triggered by an incoming deep link. + onPressed: () => context.go('/referral?code=12345'), + child: const Text('Save Referral Code'), + ), + ], + ), + ), + ); +} + +/// The settings screen widget. +class SettingsScreen extends StatelessWidget { + /// Constructs a [SettingsScreen]. + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text(App.title)), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => context.go('/login'), + child: const Text('Go to Login'), + ), + ElevatedButton( + onPressed: () => context.go('/home'), + child: const Text('Go to Home'), + ), + ], + ), + ), + ); +} + +/// Saves a referral code. +/// +/// Displays a [SnackBar] with the referral code for demonstration purposes. +/// Replace this with real referral handling logic. +void saveReferralCode(BuildContext context, String? code) { + if (code != null) { + // Here you can implement logic to save the referral code as needed. + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Referral code saved: $code')), + ); + } +} diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index cc671066218d..acf6f86271d1 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -20,6 +20,9 @@ import 'state.dart'; typedef GoRouterRedirect = FutureOr Function( BuildContext context, GoRouterState state); +/// The signature of the onEnter callback. +typedef OnEnter = bool Function(BuildContext context, GoRouterState state); + /// The route configuration for GoRouter configured by the app. class RouteConfiguration { /// Constructs a [RouteConfiguration]. @@ -27,6 +30,7 @@ class RouteConfiguration { this._routingConfig, { required this.navigatorKey, this.extraCodec, + this.onEnter, }) { _onRoutingTableChanged(); _routingConfig.addListener(_onRoutingTableChanged); @@ -246,6 +250,35 @@ class RouteConfiguration { /// example. final Codec? extraCodec; + /// A callback invoked for every incoming route before it is processed. + /// + /// This callback allows you to control navigation by inspecting the incoming + /// route and conditionally preventing the navigation. If the callback returns + /// `true`, the GoRouter proceeds with the regular navigation and redirection + /// logic. If the callback returns `false`, the navigation is canceled. + /// + /// When a deep link opens the app and `onEnter` returns `false`, GoRouter + /// will automatically redirect to the initial route or '/'. + /// + /// Example: + /// ```dart + /// final GoRouter router = GoRouter( + /// routes: [...], + /// onEnter: (BuildContext context, Uri uri) { + /// if (uri.path == '/login' && isUserLoggedIn()) { + /// return false; // Prevent navigation to /login + /// } + /// if (uri.path == '/referral') { + /// // Save the referral code and prevent navigation + /// saveReferralCode(uri.queryParameters['code']); + /// return false; + /// } + /// return true; // Allow navigation + /// }, + /// ); + /// ``` + final OnEnter? onEnter; + final Map _nameToPath = {}; /// Looks up the url location by a [GoRoute]'s name. diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index d1981898a8bb..af3c99329e90 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -1,3 +1,4 @@ +// ignore_for_file: use_build_context_synchronously // 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. @@ -8,11 +9,9 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import 'configuration.dart'; -import 'information_provider.dart'; +import '../go_router.dart'; import 'logging.dart'; import 'match.dart'; -import 'router.dart'; /// The function signature of [GoRouteInformationParser.onParserException]. /// @@ -32,8 +31,10 @@ class GoRouteInformationParser extends RouteInformationParser { /// Creates a [GoRouteInformationParser]. GoRouteInformationParser({ required this.configuration, + required String? initialLocation, required this.onParserException, - }) : _routeMatchListCodec = RouteMatchListCodec(configuration); + }) : _routeMatchListCodec = RouteMatchListCodec(configuration), + _initialLocation = initialLocation; /// The route configuration used for parsing [RouteInformation]s. final RouteConfiguration configuration; @@ -45,8 +46,10 @@ class GoRouteInformationParser extends RouteInformationParser { final ParserExceptionHandler? onParserException; final RouteMatchListCodec _routeMatchListCodec; + final String? _initialLocation; - final Random _random = Random(); + // Store the last successful match list so we can truly "stay" on the same route. + RouteMatchList? _lastMatchList; /// The future of current route parsing. /// @@ -54,81 +57,129 @@ class GoRouteInformationParser extends RouteInformationParser { @visibleForTesting Future? debugParserFuture; + final Random _random = Random(); + /// Called by the [Router]. The @override Future parseRouteInformationWithDependencies( RouteInformation routeInformation, BuildContext context, ) { - assert(routeInformation.state != null); - final Object state = routeInformation.state!; + // 1) Defensive check: if we get a null state, just return empty (unlikely). + if (routeInformation.state == null) { + return SynchronousFuture(RouteMatchList.empty); + } - if (state is! RouteInformationState) { - // This is a result of browser backward/forward button or state - // restoration. In this case, the route match list is already stored in - // the state. + final Object infoState = routeInformation.state!; + + // 2) If state is not RouteInformationState => typically browser nav or state restoration + // => decode an existing match from the saved Map. + if (infoState is! RouteInformationState) { final RouteMatchList matchList = - _routeMatchListCodec.decode(state as Map); - return debugParserFuture = _redirect(context, matchList) - .then((RouteMatchList value) { + _routeMatchListCodec.decode(infoState as Map); + + return debugParserFuture = + _redirect(context, matchList).then((RouteMatchList value) { if (value.isError && onParserException != null) { - // TODO(chunhtai): Figure out what to return if context is invalid. - // ignore: use_build_context_synchronously return onParserException!(context, value); } + _lastMatchList = value; // store after success return value; }); } + // 3) If there's an `onEnter` callback, let's see if we want to short-circuit. + // (Note that .host.isNotEmpty check is optional — depends on your scenario.) + + if (configuration.onEnter != null) { + final RouteMatchList onEnterMatches = configuration.findMatch( + routeInformation.uri, + extra: infoState.extra, + ); + + final GoRouterState state = + configuration.buildTopLevelGoRouterState(onEnterMatches); + + final bool canEnter = configuration.onEnter!( + context, + state, + ); + + if (!canEnter) { + // The user "handled" the deep link => do NOT navigate. + // Return our *last known route* if possible. + if (_lastMatchList != null) { + return SynchronousFuture(_lastMatchList!); + } else { + // Fallback if we've never parsed a route before: + final Uri defaultUri = Uri.parse(_initialLocation ?? '/'); + final RouteMatchList fallbackMatches = configuration.findMatch( + defaultUri, + extra: infoState.extra, + ); + _lastMatchList = fallbackMatches; + return SynchronousFuture(fallbackMatches); + } + } + } + + // 4) Otherwise, do normal route matching: Uri uri = routeInformation.uri; if (uri.hasEmptyPath) { uri = uri.replace(path: '/'); } else if (uri.path.length > 1 && uri.path.endsWith('/')) { - // Remove trailing `/`. uri = uri.replace(path: uri.path.substring(0, uri.path.length - 1)); } + final RouteMatchList initialMatches = configuration.findMatch( uri, - extra: state.extra, + extra: infoState.extra, ); if (initialMatches.isError) { log('No initial matches: ${routeInformation.uri.path}'); } - return debugParserFuture = _redirect( - context, - initialMatches, - ).then((RouteMatchList matchList) { + // 5) Possibly do a redirect: + return debugParserFuture = + _redirect(context, initialMatches).then((RouteMatchList matchList) { + // If error, call parser exception if any if (matchList.isError && onParserException != null) { - // TODO(chunhtai): Figure out what to return if context is invalid. - // ignore: use_build_context_synchronously return onParserException!(context, matchList); } + // 6) Check for redirect-only route leftover assert(() { if (matchList.isNotEmpty) { - assert(!matchList.last.route.redirectOnly, - 'A redirect-only route must redirect to location different from itself.\n The offending route: ${matchList.last.route}'); + assert( + !matchList.last.route.redirectOnly, + 'A redirect-only route must redirect to a different location.\n' + 'Offending route: ${matchList.last.route}'); } return true; }()); - return _updateRouteMatchList( + + // 7) If it's a push/replace etc., handle that + final RouteMatchList updated = _updateRouteMatchList( matchList, - baseRouteMatchList: state.baseRouteMatchList, - completer: state.completer, - type: state.type, + baseRouteMatchList: infoState.baseRouteMatchList, + completer: infoState.completer, + type: infoState.type, ); + + // 8) Save as our "last known good" config + _lastMatchList = updated; + return updated; }); } @override Future parseRouteInformation( RouteInformation routeInformation) { + // Not used in go_router, so we can unimplement or throw: throw UnimplementedError( - 'use parseRouteInformationWithDependencies instead'); + 'Use parseRouteInformationWithDependencies instead'); } - /// for use by the Router architecture as part of the RouteInformationParser @override RouteInformation? restoreRouteInformation(RouteMatchList configuration) { if (configuration.isEmpty) { @@ -139,7 +190,6 @@ class GoRouteInformationParser extends RouteInformationParser { (configuration.matches.last is ImperativeRouteMatch || configuration.matches.last is ShellRouteMatch)) { RouteMatchBase route = configuration.matches.last; - while (route is! ImperativeRouteMatch) { if (route is ShellRouteMatch && route.matches.isNotEmpty) { route = route.matches.last; @@ -147,7 +197,6 @@ class GoRouteInformationParser extends RouteInformationParser { break; } } - if (route case final ImperativeRouteMatch safeRoute) { location = safeRoute.matches.uri.toString(); } @@ -158,16 +207,22 @@ class GoRouteInformationParser extends RouteInformationParser { ); } + // Just calls configuration.redirect, wrapped in synchronous future if needed. Future _redirect( - BuildContext context, RouteMatchList routeMatch) { - final FutureOr redirectedFuture = configuration - .redirect(context, routeMatch, redirectHistory: []); - if (redirectedFuture is RouteMatchList) { - return SynchronousFuture(redirectedFuture); + BuildContext context, RouteMatchList matchList) { + final FutureOr result = configuration.redirect( + context, + matchList, + redirectHistory: [], + ); + if (result is RouteMatchList) { + return SynchronousFuture(result); } - return redirectedFuture; + return result; } + // If the user performed push/pushReplacement, etc., we might wrap newMatches + // in ImperativeRouteMatches. RouteMatchList _updateRouteMatchList( RouteMatchList newMatchList, { required RouteMatchList? baseRouteMatchList, @@ -212,15 +267,21 @@ class GoRouteInformationParser extends RouteInformationParser { case NavigatingType.go: return newMatchList; case NavigatingType.restore: - // Still need to consider redirection. - return baseRouteMatchList!.uri.toString() != newMatchList.uri.toString() - ? newMatchList - : baseRouteMatchList; + // If the URIs differ, we might want the new one; if they're the same, + // keep the old. + if (baseRouteMatchList!.uri.toString() != newMatchList.uri.toString()) { + return newMatchList; + } else { + return baseRouteMatchList; + } } } ValueKey _getUniqueValueKey() { - return ValueKey(String.fromCharCodes( - List.generate(32, (_) => _random.nextInt(33) + 89))); + return ValueKey( + String.fromCharCodes( + List.generate(32, (_) => _random.nextInt(33) + 89), + ), + ); } } diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 11f40f505cac..9cc856c20ace 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -122,6 +122,7 @@ class GoRouter implements RouterConfig { /// The `routes` must not be null and must contain an [GoRouter] to match `/`. factory GoRouter({ required List routes, + OnEnter? onEnter, Codec? extraCodec, GoExceptionHandler? onException, GoRouterPageBuilder? errorPageBuilder, @@ -146,6 +147,7 @@ class GoRouter implements RouterConfig { redirect: redirect ?? RoutingConfig._defaultRedirect, redirectLimit: redirectLimit), ), + onEnter: onEnter, extraCodec: extraCodec, onException: onException, errorPageBuilder: errorPageBuilder, @@ -169,6 +171,7 @@ class GoRouter implements RouterConfig { GoRouter.routingConfig({ required ValueListenable routingConfig, Codec? extraCodec, + OnEnter? onEnter, GoExceptionHandler? onException, GoRouterPageBuilder? errorPageBuilder, GoRouterWidgetBuilder? errorBuilder, @@ -206,6 +209,7 @@ class GoRouter implements RouterConfig { _routingConfig, navigatorKey: navigatorKey, extraCodec: extraCodec, + onEnter: onEnter, ); final ParserExceptionHandler? parserExceptionHandler; @@ -224,6 +228,7 @@ class GoRouter implements RouterConfig { routeInformationParser = GoRouteInformationParser( onParserException: parserExceptionHandler, configuration: configuration, + initialLocation: initialLocation, ); routeInformationProvider = GoRouteInformationProvider( @@ -565,6 +570,7 @@ class GoRouter implements RouterConfig { /// A routing config that is never going to change. class _ConstantRoutingConfig extends ValueListenable { const _ConstantRoutingConfig(this.value); + @override void addListener(VoidCallback listener) { // Intentionally empty because listener will never be called. diff --git a/packages/go_router/test/parser_test.dart b/packages/go_router/test/parser_test.dart index 9cb4aa2a071f..c7f1d1c443a8 100644 --- a/packages/go_router/test/parser_test.dart +++ b/packages/go_router/test/parser_test.dart @@ -636,4 +636,58 @@ void main() { expect(match.matches, hasLength(1)); expect(matchesObj.error, isNull); }); + + testWidgets( + 'GoRouteInformationParser short-circuits if onEnter returns false', + (WidgetTester tester) async { + bool onEnterCalled = false; + final GoRouter router = GoRouter( + // Provide a custom onEnter callback that always returns true. + onEnter: (BuildContext context, GoRouterState state) { + onEnterCalled = true; + return false; // Always prevent entering new uris. + }, + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute(path: 'abc', builder: (_, __) => const Placeholder()), + ], + ), + ], + ); + addTearDown(router.dispose); + + // Pump the widget so the router is actually in the tree. + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + + // Grab the parser we want to test. + final GoRouteInformationParser parser = router.routeInformationParser; + + final BuildContext context = tester.element(find.byType(Router)); + // Save what we consider "old route" (the route we're currently on). + final RouteMatchList oldConfiguration = + router.routerDelegate.currentConfiguration; + + // Attempt to parse a new deep link: "/abc" + final RouteInformation routeInfo = RouteInformation( + uri: Uri.parse('/abc'), + state: RouteInformationState(type: NavigatingType.go), + ); + final RouteMatchList newMatch = + await parser.parseRouteInformationWithDependencies( + routeInfo, + context, + ); + + // Because our onEnter returned `true`, we expect we "did nothing." + // => Check that the parser short-circuited (did not produce a new route). + expect(onEnterCalled, isTrue, reason: 'onEnter was not called.'); + expect( + newMatch, + equals(oldConfiguration), + reason: 'Expected the parser to short-circuit and keep the old route.', + ); + }); }