diff --git a/packages/go_router_builder/CHANGELOG.md b/packages/go_router_builder/CHANGELOG.md index 2b9e741d8c67..8f30c0cfb61f 100644 --- a/packages/go_router_builder/CHANGELOG.md +++ b/packages/go_router_builder/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.1.2 + +* Adds support for Iterables, Lists and Sets in query params for TypedGoRoute. [#108437](https://github.com/flutter/flutter/issues/108437). + ## 1.1.1 * Support for the generation of the pushReplacement method has been added. diff --git a/packages/go_router_builder/example/lib/all_types.dart b/packages/go_router_builder/example/lib/all_types.dart index c62130840293..21945d6d2dff 100644 --- a/packages/go_router_builder/example/lib/all_types.dart +++ b/packages/go_router_builder/example/lib/all_types.dart @@ -24,6 +24,7 @@ part 'all_types.g.dart'; path: 'enhanced-enum-route/:requiredEnumField'), TypedGoRoute(path: 'string-route/:requiredStringField'), TypedGoRoute(path: 'uri-route/:requiredUriField'), + TypedGoRoute(path: 'iterable-route'), ]) @immutable class AllTypesBaseRoute extends GoRouteData { @@ -33,7 +34,6 @@ class AllTypesBaseRoute extends GoRouteData { Widget build(BuildContext context, GoRouterState state) => const BasePage( dataTitle: 'Root', - param: null, ); } @@ -290,17 +290,69 @@ class UriRoute extends GoRouteData { ); } +class IterableRoute extends GoRouteData { + IterableRoute({ + this.intIterableField, + this.doubleIterableField, + this.stringIterableField, + this.boolIterableField, + this.enumIterableField, + this.intListField, + this.doubleListField, + this.stringListField, + this.boolListField, + this.enumListField, + this.intSetField, + this.doubleSetField, + this.stringSetField, + this.boolSetField, + this.enumSetField, + }); + + final Iterable? intIterableField; + final List? intListField; + final Set? intSetField; + + final Iterable? doubleIterableField; + final List? doubleListField; + final Set? doubleSetField; + + final Iterable? stringIterableField; + final List? stringListField; + final Set? stringSetField; + + final Iterable? boolIterableField; + final List? boolListField; + final Set? boolSetField; + + final Iterable? enumIterableField; + final List? enumListField; + final Set? enumSetField; + + @override + Widget build(BuildContext context, GoRouterState state) => + const BasePage( + dataTitle: 'IterableRoute', + ); + + Widget drawerTile(BuildContext context) => ListTile( + title: const Text('IterableRoute'), + onTap: () => go(context), + selected: GoRouter.of(context).location == location, + ); +} + class BasePage extends StatelessWidget { const BasePage({ required this.dataTitle, - required this.param, + this.param, this.queryParam, this.queryParamWithDefaultValue, super.key, }); final String dataTitle; - final T param; + final T? param; final T? queryParam; final T? queryParamWithDefaultValue; @@ -352,6 +404,32 @@ class BasePage extends StatelessWidget { requiredUriField: Uri.parse('https://dart.dev'), uriField: Uri.parse('https://dart.dev'), ).drawerTile(context), + IterableRoute( + intIterableField: [1, 2, 3], + doubleIterableField: [.3, .4, .5], + stringIterableField: ['quo usque tandem'], + boolIterableField: [true, false, false], + enumIterableField: [ + SportDetails.football, + SportDetails.hockey, + ], + intListField: [1, 2, 3], + doubleListField: [.3, .4, .5], + stringListField: ['quo usque tandem'], + boolListField: [true, false, false], + enumListField: [ + SportDetails.football, + SportDetails.hockey, + ], + intSetField: {1, 2, 3}, + doubleSetField: {.3, .4, .5}, + stringSetField: {'quo usque tandem'}, + boolSetField: {true, false}, + enumSetField: { + SportDetails.football, + SportDetails.hockey, + }, + ).drawerTile(context), ], )), body: Center( diff --git a/packages/go_router_builder/example/lib/all_types.g.dart b/packages/go_router_builder/example/lib/all_types.g.dart index 84f652da0628..c5ab5cf392bc 100644 --- a/packages/go_router_builder/example/lib/all_types.g.dart +++ b/packages/go_router_builder/example/lib/all_types.g.dart @@ -60,6 +60,10 @@ GoRoute get $allTypesBaseRoute => GoRouteData.$route( path: 'uri-route/:requiredUriField', factory: $UriRouteExtension._fromState, ), + GoRouteData.$route( + path: 'iterable-route', + factory: $IterableRouteExtension._fromState, + ), ], ); @@ -254,10 +258,10 @@ extension $EnumRouteExtension on EnumRoute { '/enum-route/${Uri.encodeComponent(_$PersonDetailsEnumMap[requiredEnumField]!)}', queryParams: { if (enumField != null) - 'enum-field': _$PersonDetailsEnumMap[enumField!]!, + 'enum-field': _$PersonDetailsEnumMap[enumField!], if (enumFieldWithDefaultValue != PersonDetails.favoriteFood) 'enum-field-with-default-value': - _$PersonDetailsEnumMap[enumFieldWithDefaultValue]!, + _$PersonDetailsEnumMap[enumFieldWithDefaultValue], }, ); @@ -286,10 +290,10 @@ extension $EnhancedEnumRouteExtension on EnhancedEnumRoute { '/enhanced-enum-route/${Uri.encodeComponent(_$SportDetailsEnumMap[requiredEnumField]!)}', queryParams: { if (enumField != null) - 'enum-field': _$SportDetailsEnumMap[enumField!]!, + 'enum-field': _$SportDetailsEnumMap[enumField!], if (enumFieldWithDefaultValue != SportDetails.football) 'enum-field-with-default-value': - _$SportDetailsEnumMap[enumFieldWithDefaultValue]!, + _$SportDetailsEnumMap[enumFieldWithDefaultValue], }, ); @@ -313,7 +317,7 @@ extension $StringRouteExtension on StringRoute { String get location => GoRouteData.$location( '/string-route/${Uri.encodeComponent(requiredStringField)}', queryParams: { - if (stringField != null) 'string-field': stringField!, + if (stringField != null) 'string-field': stringField, if (stringFieldWithDefaultValue != 'defaultValue') 'string-field-with-default-value': stringFieldWithDefaultValue, }, @@ -348,6 +352,101 @@ extension $UriRouteExtension on UriRoute { context.pushReplacement(location, extra: this); } +extension $IterableRouteExtension on IterableRoute { + static IterableRoute _fromState(GoRouterState state) => IterableRoute( + intIterableField: + state.queryParametersAll['int-iterable-field']?.map(int.parse), + doubleIterableField: state.queryParametersAll['double-iterable-field'] + ?.map(double.parse), + stringIterableField: + state.queryParametersAll['string-iterable-field']?.map((e) => e), + boolIterableField: state.queryParametersAll['bool-iterable-field'] + ?.map(_$boolConverter), + enumIterableField: state.queryParametersAll['enum-iterable-field'] + ?.map(_$SportDetailsEnumMap._$fromName), + intListField: + state.queryParametersAll['int-list-field']?.map(int.parse).toList(), + doubleListField: state.queryParametersAll['double-list-field'] + ?.map(double.parse) + .toList(), + stringListField: state.queryParametersAll['string-list-field'] + ?.map((e) => e) + .toList(), + boolListField: state.queryParametersAll['bool-list-field'] + ?.map(_$boolConverter) + .toList(), + enumListField: state.queryParametersAll['enum-list-field'] + ?.map(_$SportDetailsEnumMap._$fromName) + .toList(), + intSetField: + state.queryParametersAll['int-set-field']?.map(int.parse).toSet(), + doubleSetField: state.queryParametersAll['double-set-field'] + ?.map(double.parse) + .toSet(), + stringSetField: + state.queryParametersAll['string-set-field']?.map((e) => e).toSet(), + boolSetField: state.queryParametersAll['bool-set-field'] + ?.map(_$boolConverter) + .toSet(), + enumSetField: state.queryParametersAll['enum-set-field'] + ?.map(_$SportDetailsEnumMap._$fromName) + .toSet(), + ); + + String get location => GoRouteData.$location( + '/iterable-route', + queryParams: { + if (intIterableField != null) + 'int-iterable-field': + intIterableField?.map((e) => e.toString()).toList(), + if (doubleIterableField != null) + 'double-iterable-field': + doubleIterableField?.map((e) => e.toString()).toList(), + if (stringIterableField != null) + 'string-iterable-field': + stringIterableField?.map((e) => e).toList(), + if (boolIterableField != null) + 'bool-iterable-field': + boolIterableField?.map((e) => e.toString()).toList(), + if (enumIterableField != null) + 'enum-iterable-field': enumIterableField + ?.map((e) => _$SportDetailsEnumMap[e]) + .toList(), + if (intListField != null) + 'int-list-field': intListField?.map((e) => e.toString()).toList(), + if (doubleListField != null) + 'double-list-field': + doubleListField?.map((e) => e.toString()).toList(), + if (stringListField != null) + 'string-list-field': stringListField?.map((e) => e).toList(), + if (boolListField != null) + 'bool-list-field': boolListField?.map((e) => e.toString()).toList(), + if (enumListField != null) + 'enum-list-field': + enumListField?.map((e) => _$SportDetailsEnumMap[e]).toList(), + if (intSetField != null) + 'int-set-field': intSetField?.map((e) => e.toString()).toList(), + if (doubleSetField != null) + 'double-set-field': + doubleSetField?.map((e) => e.toString()).toList(), + if (stringSetField != null) + 'string-set-field': stringSetField?.map((e) => e).toList(), + if (boolSetField != null) + 'bool-set-field': boolSetField?.map((e) => e.toString()).toList(), + if (enumSetField != null) + 'enum-set-field': + enumSetField?.map((e) => _$SportDetailsEnumMap[e]).toList(), + }, + ); + + void go(BuildContext context) => context.go(location, extra: this); + + void push(BuildContext context) => context.push(location, extra: this); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: this); +} + const _$PersonDetailsEnumMap = { PersonDetails.hobbies: 'hobbies', PersonDetails.favoriteFood: 'favorite-food', diff --git a/packages/go_router_builder/example/lib/main.g.dart b/packages/go_router_builder/example/lib/main.g.dart index 74b89bc933a8..6f61344e9d70 100644 --- a/packages/go_router_builder/example/lib/main.g.dart +++ b/packages/go_router_builder/example/lib/main.g.dart @@ -131,7 +131,7 @@ extension $LoginRouteExtension on LoginRoute { String get location => GoRouteData.$location( '/login', queryParams: { - if (fromPage != null) 'from-page': fromPage!, + if (fromPage != null) 'from-page': fromPage, }, ); diff --git a/packages/go_router_builder/example/test/all_types_test.dart b/packages/go_router_builder/example/test/all_types_test.dart index 89f5bd7a4754..ff3bff1ac697 100644 --- a/packages/go_router_builder/example/test/all_types_test.dart +++ b/packages/go_router_builder/example/test/all_types_test.dart @@ -125,5 +125,15 @@ void main() { expect(find.text('UriRoute'), findsOneWidget); expect(find.text('Param: https://dart.dev'), findsOneWidget); expect(find.text('Query param: https://dart.dev'), findsOneWidget); + + IterableRoute( + intListField: [1, 2, 3], + ).go(scaffoldState.context); + await tester.pumpAndSettle(); + expect(find.text('IterableRoute'), findsOneWidget); + expect( + find.text( + '/iterable-route?int-list-field=1&int-list-field=2&int-list-field=3'), + findsOneWidget); }); } diff --git a/packages/go_router_builder/lib/src/route_config.dart b/packages/go_router_builder/lib/src/route_config.dart index 38f7a8c2cebb..f9577343ab15 100644 --- a/packages/go_router_builder/lib/src/route_config.dart +++ b/packages/go_router_builder/lib/src/route_config.dart @@ -215,7 +215,10 @@ GoRoute get $_routeGetterName => ${_routeDefinition()}; String get _locationArgs { final Iterable pathItems = _parsedPath.map((Token e) { if (e is ParameterToken) { - return '\${Uri.encodeComponent(${_encodeFor(e.name)})}'; + // 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(e.name)?.returnType; + return '\${Uri.encodeComponent(${_encodeFor(e.name)}${type?.isEnum ?? false ? '!' : ''})}'; } if (e is PathToken) { return e.value; diff --git a/packages/go_router_builder/lib/src/type_helpers.dart b/packages/go_router_builder/lib/src/type_helpers.dart index c537f5448206..a0adb2ee595e 100644 --- a/packages/go_router_builder/lib/src/type_helpers.dart +++ b/packages/go_router_builder/lib/src/type_helpers.dart @@ -38,6 +38,7 @@ const List<_TypeHelper> _helpers = <_TypeHelper>[ _TypeHelperNum(), _TypeHelperString(), _TypeHelperUri(), + _TypeHelperIterable(), ]; /// Returns the decoded [String] value for [element], if its type is supported. @@ -189,7 +190,7 @@ class _TypeHelperEnum extends _TypeHelperWithHelper { @override String _encode(String fieldName, DartType type) => - '${enumMapName(type as InterfaceType)}[$fieldName${type.ensureNotNull}]!'; + '${enumMapName(type as InterfaceType)}[$fieldName${type.ensureNotNull}]'; @override bool _matchesType(DartType type) => type.isEnum; @@ -231,8 +232,7 @@ class _TypeHelperString extends _TypeHelper { 'state.${_stateValueAccess(parameterElement)}'; @override - String _encode(String fieldName, DartType type) => - '$fieldName${type.ensureNotNull}'; + String _encode(String fieldName, DartType type) => fieldName; @override bool _matchesType(DartType type) => type.isDartCoreString; @@ -253,6 +253,69 @@ class _TypeHelperUri extends _TypeHelperWithHelper { const TypeChecker.fromRuntime(Uri).isAssignableFromType(type); } +class _TypeHelperIterable extends _TypeHelper { + const _TypeHelperIterable(); + + @override + String _decode(ParameterElement parameterElement) { + if (parameterElement.type is ParameterizedType) { + final DartType iterableType = + (parameterElement.type as ParameterizedType).typeArguments.first; + + // get a type converter for values in iterable + String entriesTypeDecoder = '(e) => e'; + for (final _TypeHelper helper in _helpers) { + if (helper._matchesType(iterableType) && + helper is _TypeHelperWithHelper) { + entriesTypeDecoder = helper.helperName(iterableType); + } + } + + // get correct type for iterable + String iterableCaster = ''; + if (const TypeChecker.fromRuntime(List) + .isAssignableFromType(parameterElement.type)) { + iterableCaster = '.toList()'; + } else if (const TypeChecker.fromRuntime(Set) + .isAssignableFromType(parameterElement.type)) { + iterableCaster = '.toSet()'; + } + + return ''' +state.queryParametersAll[ + ${escapeDartString(parameterElement.name.kebab)}] + ?.map($entriesTypeDecoder)$iterableCaster'''; + } + return ''' +state.queryParametersAll[${escapeDartString(parameterElement.name.kebab)}]'''; + } + + @override + String _encode(String fieldName, DartType type) { + if (type is ParameterizedType) { + final DartType iterableType = type.typeArguments.first; + + // get a type encoder for values in iterable + String entriesTypeEncoder = ''; + for (final _TypeHelper helper in _helpers) { + if (helper._matchesType(iterableType)) { + entriesTypeEncoder = ''' +?.map((e) => ${helper._encode('e', iterableType)}).toList()'''; + } + } + return ''' +$fieldName$entriesTypeEncoder'''; + } + + return ''' +$fieldName?.map((e) => e.toString()).toList()'''; + } + + @override + bool _matchesType(DartType type) => + const TypeChecker.fromRuntime(Iterable).isAssignableFromType(type); +} + abstract class _TypeHelperWithHelper extends _TypeHelper { const _TypeHelperWithHelper(); @@ -277,7 +340,9 @@ abstract class _TypeHelperWithHelper extends _TypeHelper { } } -extension on DartType { +/// Extension helpers on [DartType]. +extension DartTypeExtension on DartType { + /// Convenient helper for nullability checks. String get ensureNotNull => isNullableType ? '!' : ''; } diff --git a/packages/go_router_builder/pubspec.yaml b/packages/go_router_builder/pubspec.yaml index b4ae5b169823..1eead91f4cd8 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: 1.1.1 +version: 1.1.2 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