Skip to content

Commit

Permalink
[go_router] Adds an ability to add a custom codec for serializing/des… (
Browse files Browse the repository at this point in the history
#5288)

�erializing extra

fixes flutter/flutter#99099
fixes flutter/flutter#137248
  • Loading branch information
chunhtai committed Nov 3, 2023
1 parent cccf5d2 commit e890f6b
Show file tree
Hide file tree
Showing 14 changed files with 404 additions and 21 deletions.
4 changes: 4 additions & 0 deletions packages/go_router/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
20 changes: 20 additions & 0 deletions packages/go_router/doc/navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions packages/go_router/example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1300;
LastUpgradeCheck = 1430;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
LastUpgradeVersion = "1430"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
Expand Down
139 changes: 139 additions & 0 deletions packages/go_router/example/lib/extra_codec.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// 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:go_router/go_router.dart';

/// This sample app demonstrates how to provide a codec for complex extra data.
void main() => runApp(const MyApp());

/// The router configuration.
final GoRouter _router = GoRouter(
routes: <RouteBase>[
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: <Widget>[
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<Object?, Object?> {
/// Create a codec.
const MyExtraCodec();
@override
Converter<Object?, Object?> get decoder => const _MyExtraDecoder();

@override
Converter<Object?, Object?> get encoder => const _MyExtraEncoder();
}

class _MyExtraDecoder extends Converter<Object?, Object?> {
const _MyExtraDecoder();
@override
Object? convert(Object? input) {
if (input == null) {
return null;
}
final List<Object?> inputAsList = input as List<Object?>;
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<Object?, Object?> {
const _MyExtraEncoder();
@override
Object? convert(Object? input) {
if (input == null) {
return null;
}
switch (input.runtimeType) {
case ComplexData1:
return <Object?>['ComplexData1', (input as ComplexData1).data];
case ComplexData2:
return <Object?>['ComplexData2', (input as ComplexData2).data];
default:
throw FormatException('Cannot encode type ${input.runtimeType}');
}
}
}
23 changes: 23 additions & 0 deletions packages/go_router/example/test/extra_codec_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
}
15 changes: 15 additions & 0 deletions packages/go_router/lib/src/configuration.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,6 +26,7 @@ class RouteConfiguration {
RouteConfiguration(
this._routingConfig, {
required this.navigatorKey,
this.extraCodec,
}) {
_onRoutingTableChanged();
_routingConfig.addListener(_onRoutingTableChanged);
Expand Down Expand Up @@ -232,6 +234,19 @@ class RouteConfiguration {
/// The global key for top level navigator.
final GlobalKey<NavigatorState> 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<Object?, Object?>? extraCodec;

final Map<String, String> _nameToPath = <String, String>{};

/// Looks up the url location by a [GoRoute]'s name.
Expand Down
4 changes: 2 additions & 2 deletions packages/go_router/lib/src/logging.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
65 changes: 49 additions & 16 deletions packages/go_router/lib/src/match.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -358,29 +360,35 @@ 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<RouteMatchList, Map<Object?, Object?>> {
/// 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<RouteMatchList, Map<Object?, Object?>> encoder =
const _RouteMatchListEncoder();
final Converter<RouteMatchList, Map<Object?, Object?>> encoder;

@override
final Converter<Map<Object?, Object?>, RouteMatchList> decoder;
}

class _RouteMatchListEncoder
extends Converter<RouteMatchList, Map<Object?, Object?>> {
const _RouteMatchListEncoder();
const _RouteMatchListEncoder(this.configuration);

final RouteConfiguration configuration;
@override
Map<Object?, Object?> convert(RouteMatchList input) {
final List<Map<Object?, Object?>> imperativeMatches = input.matches
Expand All @@ -394,15 +402,36 @@ class _RouteMatchListEncoder
imperativeMatches: imperativeMatches);
}

static Map<Object?, Object?> _toPrimitives(String location, Object? extra,
Map<Object?, Object?> _toPrimitives(String location, Object? extra,
{List<Map<Object?, Object?>>? imperativeMatches, String? pageKey}) {
String? encodedExtra;
try {
encodedExtra = json.encoder.convert(extra);
} on JsonUnsupportedObjectError {/* give up if not serializable */}
Map<String, Object?> encodedExtra;
if (configuration.extraCodec != null) {
encodedExtra = <String, Object?>{
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 = <String, Object?>{
RouteMatchListCodec._codecKey: RouteMatchListCodec._jsonCodecName,
RouteMatchListCodec._encodedKey: jsonEncodedExtra,
};
}

return <Object?, Object?>{
RouteMatchListCodec._locationKey: location,
if (encodedExtra != null) RouteMatchListCodec._extraKey: encodedExtra,
RouteMatchListCodec._extraKey: encodedExtra,
if (imperativeMatches != null)
RouteMatchListCodec._imperativeMatchesKey: imperativeMatches,
if (pageKey != null) RouteMatchListCodec._pageKey: pageKey,
Expand All @@ -420,13 +449,17 @@ class _RouteMatchListDecoder
RouteMatchList convert(Map<Object?, Object?> input) {
final String rootLocation =
input[RouteMatchListCodec._locationKey]! as String;
final String? encodedExtra =
input[RouteMatchListCodec._extraKey] as String?;
final Map<Object?, Object?> encodedExtra =
input[RouteMatchListCodec._extraKey]! as Map<Object?, Object?>;
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);
Expand Down
Loading

0 comments on commit e890f6b

Please sign in to comment.