diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 6791a2c917e7e..63e9cdbf4ba9d 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 12.1.0 + +- Adds an ability to add a custom codec for serializing/deserializing extra. + ## 12.0.3 - Fixes crashes when dynamically updates routing tables with named routes. diff --git a/packages/go_router/doc/navigation.md b/packages/go_router/doc/navigation.md index f5fa16ac4bafe..f351d2b821d41 100644 --- a/packages/go_router/doc/navigation.md +++ b/packages/go_router/doc/navigation.md @@ -84,5 +84,25 @@ Returning a value: onTap: () => context.pop(true) ``` +## Using extra +You can provide additional data along with navigation. + +```dart +context.go('/123, extra: 'abc'); +``` + +and retrieve the data from GoRouterState + +```dart +final String extraString = GoRouterState.of(context).extra! as String; +``` + +The extra data will go through serialization when it is stored in the browser. +If you plan to use complex data as extra, consider also providing a codec +to GoRouter so that it won't get dropped during serialization. + +For an example on how to use complex data in extra with a codec, see +[extra_codec.dart](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/extra_codec.dart). + [Named routes]: https://pub.dev/documentation/go_router/latest/topics/Named%20routes-topic.html diff --git a/packages/go_router/example/README.md b/packages/go_router/example/README.md index 55f75bca6a6a7..cac540fa0f879 100644 --- a/packages/go_router/example/README.md +++ b/packages/go_router/example/README.md @@ -41,6 +41,11 @@ An example to demonstrate how to use a `StatefulShellRoute` to create stateful n An example to demonstrate how to handle exception in go_router. +## [Extra Codec](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/extra_codec.dart) +`flutter run lib/extra_codec.dart` + +An example to demonstrate how to use a complex object as extra. + ## [Books app](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/books) `flutter run lib/books/main.dart` diff --git a/packages/go_router/example/ios/Runner.xcodeproj/project.pbxproj b/packages/go_router/example/ios/Runner.xcodeproj/project.pbxproj index 0841413a1fd27..8f3ef0d66bf07 100644 --- a/packages/go_router/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/go_router/example/ios/Runner.xcodeproj/project.pbxproj @@ -156,7 +156,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1430; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { diff --git a/packages/go_router/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/go_router/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 3db53b6e1fb7f..b52b2e698b7e7 100644 --- a/packages/go_router/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/go_router/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ runApp(const MyApp()); + +/// The router configuration. +final GoRouter _router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) => + const HomeScreen(), + ), + ], + extraCodec: const MyExtraCodec(), +); + +/// 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: [ + const Text( + "If running in web, use the browser's backward and forward button to test extra codec after setting extra several times."), + Text( + 'The extra for this page is: ${GoRouterState.of(context).extra}'), + ElevatedButton( + onPressed: () => context.go('/', extra: ComplexData1('data')), + child: const Text('Set extra to ComplexData1'), + ), + ElevatedButton( + onPressed: () => context.go('/', extra: ComplexData2('data')), + child: const Text('Set extra to ComplexData2'), + ), + ], + ), + ), + ); + } +} + +/// A complex class. +class ComplexData1 { + /// Create a complex object. + ComplexData1(this.data); + + /// The data. + final String data; + + @override + String toString() => 'ComplexData1(data: $data)'; +} + +/// A complex class. +class ComplexData2 { + /// Create a complex object. + ComplexData2(this.data); + + /// The data. + final String data; + + @override + String toString() => 'ComplexData2(data: $data)'; +} + +/// A codec that can serialize both [ComplexData1] and [ComplexData2]. +class MyExtraCodec extends Codec { + /// Create a codec. + const MyExtraCodec(); + @override + Converter get decoder => const _MyExtraDecoder(); + + @override + Converter get encoder => const _MyExtraEncoder(); +} + +class _MyExtraDecoder extends Converter { + const _MyExtraDecoder(); + @override + Object? convert(Object? input) { + if (input == null) { + return null; + } + final List inputAsList = input as List; + if (inputAsList[0] == 'ComplexData1') { + return ComplexData1(inputAsList[1]! as String); + } + if (inputAsList[0] == 'ComplexData2') { + return ComplexData2(inputAsList[1]! as String); + } + throw FormatException('Unable tp parse input: $input'); + } +} + +class _MyExtraEncoder extends Converter { + const _MyExtraEncoder(); + @override + Object? convert(Object? input) { + if (input == null) { + return null; + } + switch (input.runtimeType) { + case ComplexData1: + return ['ComplexData1', (input as ComplexData1).data]; + case ComplexData2: + return ['ComplexData2', (input as ComplexData2).data]; + default: + throw FormatException('Cannot encode type ${input.runtimeType}'); + } + } +} diff --git a/packages/go_router/example/test/extra_codec_test.dart b/packages/go_router/example/test/extra_codec_test.dart new file mode 100644 index 0000000000000..7358cfc614ed9 --- /dev/null +++ b/packages/go_router/example/test/extra_codec_test.dart @@ -0,0 +1,23 @@ +// 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/extra_codec.dart' as example; + +void main() { + testWidgets('example works', (WidgetTester tester) async { + await tester.pumpWidget(const example.MyApp()); + expect(find.text('The extra for this page is: null'), findsOneWidget); + + await tester.tap(find.text('Set extra to ComplexData1')); + await tester.pumpAndSettle(); + expect(find.text('The extra for this page is: ComplexData1(data: data)'), + findsOneWidget); + + await tester.tap(find.text('Set extra to ComplexData2')); + await tester.pumpAndSettle(); + expect(find.text('The extra for this page is: ComplexData2(data: data)'), + findsOneWidget); + }); +} diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index a5ff0fda5b43a..5149d18e8520d 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -25,6 +26,7 @@ class RouteConfiguration { RouteConfiguration( this._routingConfig, { required this.navigatorKey, + this.extraCodec, }) { _onRoutingTableChanged(); _routingConfig.addListener(_onRoutingTableChanged); @@ -232,6 +234,19 @@ class RouteConfiguration { /// The global key for top level navigator. final GlobalKey navigatorKey; + /// The codec used to encode and decode extra into a serializable format. + /// + /// When navigating using [GoRouter.go] or [GoRouter.push], one can provide + /// an `extra` parameter along with it. If the extra contains complex data, + /// consider provide a codec for serializing and deserializing the extra data. + /// + /// See also: + /// * [Navigation](https://pub.dev/documentation/go_router/latest/topics/Navigation-topic.html) + /// topic. + /// * [extra_codec](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/extra_codec.dart) + /// example. + final Codec? extraCodec; + final Map _nameToPath = {}; /// Looks up the url location by a [GoRoute]'s name. diff --git a/packages/go_router/lib/src/logging.dart b/packages/go_router/lib/src/logging.dart index 3f39e4dc804d5..7f0a8ce5a7a9b 100644 --- a/packages/go_router/lib/src/logging.dart +++ b/packages/go_router/lib/src/logging.dart @@ -16,9 +16,9 @@ final Logger logger = Logger('GoRouter'); bool _enabled = false; /// Logs the message if logging is enabled. -void log(String message) { +void log(String message, {Level level = Level.INFO}) { if (_enabled) { - logger.info(message); + logger.log(level, message); } } diff --git a/packages/go_router/lib/src/match.dart b/packages/go_router/lib/src/match.dart index e5ffdec31d702..3b7a94688692d 100644 --- a/packages/go_router/lib/src/match.dart +++ b/packages/go_router/lib/src/match.dart @@ -8,9 +8,11 @@ import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import 'configuration.dart'; +import 'logging.dart'; import 'misc/errors.dart'; import 'path_utils.dart'; import 'route.dart'; @@ -358,21 +360,25 @@ class RouteMatchList { /// Handles encoding and decoding of [RouteMatchList] objects to a format /// suitable for using with [StandardMessageCodec]. /// -/// The primary use of this class is for state restoration. +/// The primary use of this class is for state restoration and browser history. @internal class RouteMatchListCodec extends Codec> { /// Creates a new [RouteMatchListCodec] object. RouteMatchListCodec(RouteConfiguration configuration) - : decoder = _RouteMatchListDecoder(configuration); + : decoder = _RouteMatchListDecoder(configuration), + encoder = _RouteMatchListEncoder(configuration); static const String _locationKey = 'location'; static const String _extraKey = 'state'; static const String _imperativeMatchesKey = 'imperativeMatches'; static const String _pageKey = 'pageKey'; + static const String _codecKey = 'codec'; + static const String _jsonCodecName = 'json'; + static const String _customCodecName = 'custom'; + static const String _encodedKey = 'encoded'; @override - final Converter> encoder = - const _RouteMatchListEncoder(); + final Converter> encoder; @override final Converter, RouteMatchList> decoder; @@ -380,7 +386,9 @@ class RouteMatchListCodec extends Codec> { class _RouteMatchListEncoder extends Converter> { - const _RouteMatchListEncoder(); + const _RouteMatchListEncoder(this.configuration); + + final RouteConfiguration configuration; @override Map convert(RouteMatchList input) { final List> imperativeMatches = input.matches @@ -394,15 +402,36 @@ class _RouteMatchListEncoder imperativeMatches: imperativeMatches); } - static Map _toPrimitives(String location, Object? extra, + Map _toPrimitives(String location, Object? extra, {List>? imperativeMatches, String? pageKey}) { - String? encodedExtra; - try { - encodedExtra = json.encoder.convert(extra); - } on JsonUnsupportedObjectError {/* give up if not serializable */} + Map encodedExtra; + if (configuration.extraCodec != null) { + encodedExtra = { + RouteMatchListCodec._codecKey: RouteMatchListCodec._customCodecName, + RouteMatchListCodec._encodedKey: + configuration.extraCodec?.encode(extra), + }; + } else { + String jsonEncodedExtra; + try { + jsonEncodedExtra = json.encoder.convert(extra); + } on JsonUnsupportedObjectError { + jsonEncodedExtra = json.encoder.convert(null); + log( + 'An extra with complex data type ${extra.runtimeType} is provided ' + 'without a codec. Consider provide a codec to GoRouter to ' + 'prevent extra being dropped during serialization.', + level: Level.WARNING); + } + encodedExtra = { + RouteMatchListCodec._codecKey: RouteMatchListCodec._jsonCodecName, + RouteMatchListCodec._encodedKey: jsonEncodedExtra, + }; + } + return { RouteMatchListCodec._locationKey: location, - if (encodedExtra != null) RouteMatchListCodec._extraKey: encodedExtra, + RouteMatchListCodec._extraKey: encodedExtra, if (imperativeMatches != null) RouteMatchListCodec._imperativeMatchesKey: imperativeMatches, if (pageKey != null) RouteMatchListCodec._pageKey: pageKey, @@ -420,13 +449,17 @@ class _RouteMatchListDecoder RouteMatchList convert(Map input) { final String rootLocation = input[RouteMatchListCodec._locationKey]! as String; - final String? encodedExtra = - input[RouteMatchListCodec._extraKey] as String?; + final Map encodedExtra = + input[RouteMatchListCodec._extraKey]! as Map; final Object? extra; - if (encodedExtra != null) { - extra = json.decoder.convert(encodedExtra); + + if (encodedExtra[RouteMatchListCodec._codecKey] == + RouteMatchListCodec._jsonCodecName) { + extra = json.decoder + .convert(encodedExtra[RouteMatchListCodec._encodedKey]! as String); } else { - extra = null; + extra = configuration.extraCodec + ?.decode(encodedExtra[RouteMatchListCodec._encodedKey]); } RouteMatchList matchList = configuration.findMatch(rootLocation, extra: extra); diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index f7f8e2b0f3aa7..0f27dcf48c40c 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -121,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, + Codec? extraCodec, GoExceptionHandler? onException, GoRouterPageBuilder? errorPageBuilder, GoRouterWidgetBuilder? errorBuilder, @@ -144,6 +146,7 @@ class GoRouter implements RouterConfig { redirect: redirect ?? RoutingConfig._defaultRedirect, redirectLimit: redirectLimit), ), + extraCodec: extraCodec, onException: onException, errorPageBuilder: errorPageBuilder, errorBuilder: errorBuilder, @@ -165,6 +168,7 @@ class GoRouter implements RouterConfig { /// See [routing_config.dart](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/routing_config.dart). GoRouter.routingConfig({ required ValueListenable routingConfig, + Codec? extraCodec, GoExceptionHandler? onException, GoRouterPageBuilder? errorPageBuilder, GoRouterWidgetBuilder? errorBuilder, @@ -201,6 +205,7 @@ class GoRouter implements RouterConfig { configuration = RouteConfiguration( _routingConfig, navigatorKey: navigatorKey, + extraCodec: extraCodec, ); final ParserExceptionHandler? parserExceptionHandler; diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index e2c5cb5aa0b95..f37ba325b304b 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: 12.0.3 +version: 12.1.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/extra_codec_test.dart b/packages/go_router/test/extra_codec_test.dart new file mode 100644 index 0000000000000..3c858cad17d8e --- /dev/null +++ b/packages/go_router/test/extra_codec_test.dart @@ -0,0 +1,111 @@ +// 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 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'test_helpers.dart'; + +void main() { + testWidgets('router rebuild with extra codec works', + (WidgetTester tester) async { + const String initialString = 'some string'; + const String empty = 'empty'; + final GoRouter router = GoRouter( + initialLocation: '/', + extraCodec: ComplexDataCodec(), + initialExtra: ComplexData(initialString), + routes: [ + GoRoute( + path: '/', + builder: (_, GoRouterState state) { + return Text((state.extra as ComplexData?)?.data ?? empty); + }), + ], + redirect: (BuildContext context, _) { + // Set up dependency. + SimpleDependencyProvider.of(context); + return null; + }, + ); + final SimpleDependency dependency = SimpleDependency(); + addTearDown(() => dependency.dispose()); + + await tester.pumpWidget( + SimpleDependencyProvider( + dependency: dependency, + child: MaterialApp.router( + routerConfig: router, + ), + ), + ); + expect(find.text(initialString), findsOneWidget); + dependency.boolProperty = !dependency.boolProperty; + + await tester.pumpAndSettle(); + expect(find.text(initialString), findsOneWidget); + }); + + testWidgets('Restores state correctly', (WidgetTester tester) async { + const String initialString = 'some string'; + const String empty = 'empty'; + final List routes = [ + GoRoute( + path: '/', + builder: (_, GoRouterState state) { + return Text((state.extra as ComplexData?)?.data ?? empty); + }, + ), + ]; + + await createRouter( + routes, + tester, + initialExtra: ComplexData(initialString), + restorationScopeId: 'test', + extraCodec: ComplexDataCodec(), + ); + expect(find.text(initialString), findsOneWidget); + + await tester.restartAndRestore(); + + await tester.pumpAndSettle(); + expect(find.text(initialString), findsOneWidget); + }); +} + +class ComplexData { + ComplexData(this.data); + final String data; +} + +class ComplexDataCodec extends Codec { + @override + Converter get decoder => ComplexDataDecoder(); + @override + Converter get encoder => ComplexDataEncoder(); +} + +class ComplexDataDecoder extends Converter { + @override + ComplexData? convert(Object? input) { + if (input == null) { + return null; + } + return ComplexData(input as String); + } +} + +class ComplexDataEncoder extends Converter { + @override + Object? convert(ComplexData? input) { + if (input == null) { + return null; + } + return input.data; + } +} diff --git a/packages/go_router/test/test_helpers.dart b/packages/go_router/test/test_helpers.dart index 76ec2874a7a25..3db0b45790867 100644 --- a/packages/go_router/test/test_helpers.dart +++ b/packages/go_router/test/test_helpers.dart @@ -4,6 +4,8 @@ // ignore_for_file: cascade_invocations, diagnostic_describe_all_properties +import 'dart:convert'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -167,6 +169,7 @@ Future createRouter( GlobalKey? navigatorKey, GoRouterWidgetBuilder? errorBuilder, String? restorationScopeId, + Codec? extraCodec, GoExceptionHandler? onException, bool requestFocus = true, bool overridePlatformDefaultLocation = false, @@ -174,6 +177,7 @@ Future createRouter( final GoRouter goRouter = GoRouter( routes: routes, redirect: redirect, + extraCodec: extraCodec, initialLocation: initialLocation, onException: onException, initialExtra: initialExtra, @@ -391,3 +395,27 @@ RouteConfiguration createRouteConfiguration({ )), navigatorKey: navigatorKey); } + +class SimpleDependencyProvider extends InheritedNotifier { + const SimpleDependencyProvider( + {super.key, required SimpleDependency dependency, required super.child}) + : super(notifier: dependency); + + static SimpleDependency of(BuildContext context) { + final SimpleDependencyProvider result = + context.dependOnInheritedWidgetOfExactType()!; + return result.notifier!; + } +} + +class SimpleDependency extends ChangeNotifier { + bool get boolProperty => _boolProperty; + bool _boolProperty = true; + set boolProperty(bool value) { + if (value == _boolProperty) { + return; + } + _boolProperty = value; + notifyListeners(); + } +}