[go_router_builder] Accept required parameters not in path (#4039)

This PR allows required/positional parameters to not be in the path.

Fixes https://github.com/flutter/flutter/issues/126796

*If you had to change anything in the [flutter/tests] repo, include a link to the migration guide as per the [breaking change policy].*
diff --git a/packages/go_router_builder/CHANGELOG.md b/packages/go_router_builder/CHANGELOG.md
index 7ebb576..81a4e7d 100644
--- a/packages/go_router_builder/CHANGELOG.md
+++ b/packages/go_router_builder/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 2.1.0
+
+* Supports required/positional parameters that are not in the path.
+
 ## 2.0.2
 
 * Fixes unawaited_futures violations.
diff --git a/packages/go_router_builder/README.md b/packages/go_router_builder/README.md
index 18a4ea0..0eff1f0 100644
--- a/packages/go_router_builder/README.md
+++ b/packages/go_router_builder/README.md
@@ -89,9 +89,6 @@
 }
 ```
 
-Required parameters are pulled from the route's `path` defined in the route
-tree.
-
 ## Route tree
 
 The tree of routes is defined as an attribute on each of the top-level routes:
@@ -178,9 +175,10 @@
 
 ## Query parameters
 
-Optional parameters (named or positional) indicate query parameters:
+Parameters (named or positional) not listed in the path of `TypedGoRoute` indicate query parameters:
 
 ```dart
+@TypedGoRoute(path: '/login')
 class LoginRoute extends GoRouteData {
   LoginRoute({this.from});
   final String? from;
@@ -195,6 +193,7 @@
 For query parameters with a **non-nullable** type, you can define a default value:
 
 ```dart
+@TypedGoRoute(path: '/my-route')
 class MyRoute extends GoRouteData {
   MyRoute({this.queryParameter = 'defaultValue'});
   final String queryParameter;
@@ -237,6 +236,7 @@
 You can, of course, combine the use of path, query and $extra parameters:
 
 ```dart
+@TypedGoRoute<HotdogRouteWithEverything>(path: '/:ketchup')
 class HotdogRouteWithEverything extends GoRouteData {
   HotdogRouteWithEverything(this.ketchup, this.mustard, this.$extra);
   final bool ketchup; // required path parameter
diff --git a/packages/go_router_builder/lib/src/route_config.dart b/packages/go_router_builder/lib/src/route_config.dart
index a67f33b..3e53cb1 100644
--- a/packages/go_router_builder/lib/src/route_config.dart
+++ b/packages/go_router_builder/lib/src/route_config.dart
@@ -369,21 +369,15 @@
 
   String _decodeFor(ParameterElement element) {
     if (element.isRequired) {
-      if (element.type.nullabilitySuffix == NullabilitySuffix.question) {
+      if (element.type.nullabilitySuffix == NullabilitySuffix.question &&
+          _pathParams.contains(element.name)) {
         throw InvalidGenerationSourceError(
-          'Required parameters cannot be nullable.',
-          element: element,
-        );
-      }
-
-      if (!_pathParams.contains(element.name) && !element.isExtraField) {
-        throw InvalidGenerationSourceError(
-          'Missing param `${element.name}` in path.',
+          'Required parameters in the path cannot be nullable.',
           element: element,
         );
       }
     }
-    final String fromStateExpression = decodeParameter(element);
+    final String fromStateExpression = decodeParameter(element, _pathParams);
 
     if (element.isPositional) {
       return '$fromStateExpression,';
diff --git a/packages/go_router_builder/lib/src/type_helpers.dart b/packages/go_router_builder/lib/src/type_helpers.dart
index 1ed8507..cea332a 100644
--- a/packages/go_router_builder/lib/src/type_helpers.dart
+++ b/packages/go_router_builder/lib/src/type_helpers.dart
@@ -44,15 +44,15 @@
 /// Returns the decoded [String] value for [element], if its type is supported.
 ///
 /// Otherwise, throws an [InvalidGenerationSourceError].
-String decodeParameter(ParameterElement element) {
+String decodeParameter(ParameterElement element, Set<String> pathParameters) {
   if (element.isExtraField) {
-    return 'state.${_stateValueAccess(element)}';
+    return 'state.${_stateValueAccess(element, pathParameters)}';
   }
 
   final DartType paramType = element.type;
   for (final _TypeHelper helper in _helpers) {
     if (helper._matchesType(paramType)) {
-      String decoded = helper._decode(element);
+      String decoded = helper._decode(element, pathParameters);
       if (element.isOptional && element.hasDefaultValue) {
         if (element.type.isNullableType) {
           throw NullableDefaultValueError(element);
@@ -92,30 +92,30 @@
 // ignore: deprecated_member_use
 String enumMapName(InterfaceType type) => '_\$${type.element.name}EnumMap';
 
-String _stateValueAccess(ParameterElement element) {
+String _stateValueAccess(ParameterElement element, Set<String> pathParameters) {
   if (element.isExtraField) {
     return 'extra as ${element.type.getDisplayString(withNullability: element.isOptional)}';
   }
 
-  if (element.isRequired) {
-    return 'pathParameters[${escapeDartString(element.name)}]!';
+  late String access;
+  if (pathParameters.contains(element.name)) {
+    access = 'pathParameters[${escapeDartString(element.name)}]';
+  } else {
+    access = 'queryParameters[${escapeDartString(element.name.kebab)}]';
+  }
+  if (pathParameters.contains(element.name) ||
+      (!element.type.isNullableType && !element.hasDefaultValue)) {
+    access += '!';
   }
 
-  if (element.isOptional) {
-    return 'queryParameters[${escapeDartString(element.name.kebab)}]';
-  }
-
-  throw InvalidGenerationSourceError(
-    '$likelyIssueMessage (param not required or optional)',
-    element: element,
-  );
+  return access;
 }
 
 abstract class _TypeHelper {
   const _TypeHelper();
 
   /// Decodes the value from its string representation in the URL.
-  String _decode(ParameterElement parameterElement);
+  String _decode(ParameterElement parameterElement, Set<String> pathParameters);
 
   /// Encodes the value from its string representation in the URL.
   String _encode(String fieldName, DartType type);
@@ -228,8 +228,9 @@
   const _TypeHelperString();
 
   @override
-  String _decode(ParameterElement parameterElement) =>
-      'state.${_stateValueAccess(parameterElement)}';
+  String _decode(
+          ParameterElement parameterElement, Set<String> pathParameters) =>
+      'state.${_stateValueAccess(parameterElement, pathParameters)}';
 
   @override
   String _encode(String fieldName, DartType type) => fieldName;
@@ -257,7 +258,8 @@
   const _TypeHelperIterable();
 
   @override
-  String _decode(ParameterElement parameterElement) {
+  String _decode(
+      ParameterElement parameterElement, Set<String> pathParameters) {
     if (parameterElement.type is ParameterizedType) {
       final DartType iterableType =
           (parameterElement.type as ParameterizedType).typeArguments.first;
@@ -323,17 +325,20 @@
   String helperName(DartType paramType);
 
   @override
-  String _decode(ParameterElement parameterElement) {
+  String _decode(
+      ParameterElement parameterElement, Set<String> pathParameters) {
     final DartType paramType = parameterElement.type;
+    final String parameterName = parameterElement.name;
 
-    if (!parameterElement.isRequired) {
+    if (!pathParameters.contains(parameterName) &&
+        (paramType.isNullableType || parameterElement.hasDefaultValue)) {
       return '$convertMapValueHelperName('
-          '${escapeDartString(parameterElement.name.kebab)}, '
+          '${escapeDartString(parameterName.kebab)}, '
           'state.queryParameters, '
           '${helperName(paramType)})';
     }
     return '${helperName(paramType)}'
-        '(state.${_stateValueAccess(parameterElement)})';
+        '(state.${_stateValueAccess(parameterElement, pathParameters)})';
   }
 }
 
diff --git a/packages/go_router_builder/pubspec.yaml b/packages/go_router_builder/pubspec.yaml
index 322428d..027b79e 100644
--- a/packages/go_router_builder/pubspec.yaml
+++ b/packages/go_router_builder/pubspec.yaml
@@ -2,7 +2,7 @@
 description: >-
   A builder that supports generated strongly-typed route helpers for
   package:go_router
-version: 2.0.2
+version: 2.1.0
 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
 
diff --git a/packages/go_router_builder/test/builder_test.dart b/packages/go_router_builder/test/builder_test.dart
index 056d854..53df1f0 100644
--- a/packages/go_router_builder/test/builder_test.dart
+++ b/packages/go_router_builder/test/builder_test.dart
@@ -26,10 +26,11 @@
   'BadPathParam',
   'ExtraValueRoute',
   'RequiredExtraValueRoute',
-  'MissingPathParam',
   'MissingPathValue',
   'MissingTypeAnnotation',
-  'NullableRequiredParam',
+  'NullableRequiredParamInPath',
+  'NullableRequiredParamNotInPath',
+  'NonNullableRequiredParamNotInPath',
   'UnsupportedType',
   'theAnswer',
   'EnumParam',
diff --git a/packages/go_router_builder/test/test_inputs/_go_router_builder_test_input.dart b/packages/go_router_builder/test/test_inputs/_go_router_builder_test_input.dart
index 678a528..016d2c8 100644
--- a/packages/go_router_builder/test/test_inputs/_go_router_builder_test_input.dart
+++ b/packages/go_router_builder/test/test_inputs/_go_router_builder_test_input.dart
@@ -45,21 +45,83 @@
 }
 
 @ShouldThrow(
-  'Required parameters cannot be nullable.',
+  'Required parameters in the path cannot be nullable.',
 )
-@TypedGoRoute<NullableRequiredParam>(path: 'bob/:id')
-class NullableRequiredParam extends GoRouteData {
-  NullableRequiredParam({required this.id});
+@TypedGoRoute<NullableRequiredParamInPath>(path: 'bob/:id')
+class NullableRequiredParamInPath extends GoRouteData {
+  NullableRequiredParamInPath({required this.id});
   final int? id;
 }
 
-@ShouldThrow(
-  'Missing param `id` in path.',
-)
-@TypedGoRoute<MissingPathParam>(path: 'bob/')
-class MissingPathParam extends GoRouteData {
-  MissingPathParam({required this.id});
-  final String id;
+@ShouldGenerate(r'''
+RouteBase get $nullableRequiredParamNotInPath => GoRouteData.$route(
+      path: 'bob',
+      factory: $NullableRequiredParamNotInPathExtension._fromState,
+    );
+
+extension $NullableRequiredParamNotInPathExtension
+    on NullableRequiredParamNotInPath {
+  static NullableRequiredParamNotInPath _fromState(GoRouterState state) =>
+      NullableRequiredParamNotInPath(
+        id: _$convertMapValue('id', state.queryParameters, int.parse),
+      );
+
+  String get location => GoRouteData.$location(
+        'bob',
+      );
+
+  void go(BuildContext context) => context.go(location);
+
+  Future<T?> push<T>(BuildContext context) => context.push<T>(location);
+
+  void pushReplacement(BuildContext context) =>
+      context.pushReplacement(location);
+}
+
+T? _$convertMapValue<T>(
+  String key,
+  Map<String, String> map,
+  T Function(String) converter,
+) {
+  final value = map[key];
+  return value == null ? null : converter(value);
+}
+''')
+@TypedGoRoute<NullableRequiredParamNotInPath>(path: 'bob')
+class NullableRequiredParamNotInPath extends GoRouteData {
+  NullableRequiredParamNotInPath({required this.id});
+  final int? id;
+}
+
+@ShouldGenerate(r'''
+RouteBase get $nonNullableRequiredParamNotInPath => GoRouteData.$route(
+      path: 'bob',
+      factory: $NonNullableRequiredParamNotInPathExtension._fromState,
+    );
+
+extension $NonNullableRequiredParamNotInPathExtension
+    on NonNullableRequiredParamNotInPath {
+  static NonNullableRequiredParamNotInPath _fromState(GoRouterState state) =>
+      NonNullableRequiredParamNotInPath(
+        id: int.parse(state.queryParameters['id']!),
+      );
+
+  String get location => GoRouteData.$location(
+        'bob',
+      );
+
+  void go(BuildContext context) => context.go(location);
+
+  Future<T?> push<T>(BuildContext context) => context.push<T>(location);
+
+  void pushReplacement(BuildContext context) =>
+      context.pushReplacement(location);
+}
+''')
+@TypedGoRoute<NonNullableRequiredParamNotInPath>(path: 'bob')
+class NonNullableRequiredParamNotInPath extends GoRouteData {
+  NonNullableRequiredParamNotInPath({required this.id});
+  final int id;
 }
 
 @ShouldGenerate(r'''