[go_router] Add `topRoute` to `GoRouterState` (#5736)

This PR exposes the current top route to `GoRouterState`, this allows `ShellRoute` to know what is the current child and process the state accordingly.

- Issue: https://github.com/flutter/flutter/issues/140297

This could be used like this, given that each `GoRoute` had the `name` parameter given
```dart
StatefulShellRoute.indexedStack(
  parentNavigatorKey: rootNavigatorKey,
  builder: (
    BuildContext context,
    GoRouterState state,
    StatefulNavigationShell navigationShell,
  ) {
    final String? routeName =
        GoRouterState.of(context).topRoute.name;
    final String title = switch (routeName) {
      'a' => 'A',
      'b' => 'B',
      _ => 'Unknown',
    };
    return Column(
      children: <Widget>[
        Text(title),
        Expanded(child: navigationShell),
      ],
    );
  },
  ...
}
```

*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/CHANGELOG.md b/packages/go_router/CHANGELOG.md
index e551d26..36d5e84 100644
--- a/packages/go_router/CHANGELOG.md
+++ b/packages/go_router/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 13.1.0
+
+- Adds `topRoute` to `GoRouterState`
+- Adds `lastOrNull` to `RouteMatchList`
+
 ## 13.0.1
 
 * Fixes new lint warnings.
diff --git a/packages/go_router/example/lib/shell_route_top_route.dart b/packages/go_router/example/lib/shell_route_top_route.dart
new file mode 100644
index 0000000..3502129
--- /dev/null
+++ b/packages/go_router/example/lib/shell_route_top_route.dart
@@ -0,0 +1,305 @@
+// 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/material.dart';
+import 'package:go_router/go_router.dart';
+
+final GlobalKey<NavigatorState> _rootNavigatorKey =
+    GlobalKey<NavigatorState>(debugLabel: 'root');
+final GlobalKey<NavigatorState> _shellNavigatorKey =
+    GlobalKey<NavigatorState>(debugLabel: 'shell');
+
+// This scenario demonstrates how to set up nested navigation using ShellRoute,
+// which is a pattern where an additional Navigator is placed in the widget tree
+// to be used instead of the root navigator. This allows deep-links to display
+// pages along with other UI components such as a BottomNavigationBar.
+//
+// This example demonstrates how use topRoute in a ShellRoute to create the
+// title in the AppBar above the child, which is different for each GoRoute.
+
+void main() {
+  runApp(ShellRouteExampleApp());
+}
+
+/// An example demonstrating how to use [ShellRoute]
+class ShellRouteExampleApp extends StatelessWidget {
+  /// Creates a [ShellRouteExampleApp]
+  ShellRouteExampleApp({super.key});
+
+  final GoRouter _router = GoRouter(
+    navigatorKey: _rootNavigatorKey,
+    initialLocation: '/a',
+    debugLogDiagnostics: true,
+    routes: <RouteBase>[
+      /// Application shell
+      ShellRoute(
+        navigatorKey: _shellNavigatorKey,
+        builder: (BuildContext context, GoRouterState state, Widget child) {
+          final String? routeName = GoRouterState.of(context).topRoute?.name;
+          // This title could also be created using a route's path parameters in GoRouterState
+          final String title = switch (routeName) {
+            'a' => 'A Screen',
+            'a.details' => 'A Details',
+            'b' => 'B Screen',
+            'b.details' => 'B Details',
+            'c' => 'C Screen',
+            'c.details' => 'C Details',
+            _ => 'Unknown',
+          };
+          return ScaffoldWithNavBar(title: title, child: child);
+        },
+        routes: <RouteBase>[
+          /// The first screen to display in the bottom navigation bar.
+          GoRoute(
+            // The name of this route used to determine the title in the ShellRoute.
+            name: 'a',
+            path: '/a',
+            builder: (BuildContext context, GoRouterState state) {
+              return const ScreenA();
+            },
+            routes: <RouteBase>[
+              // The details screen to display stacked on the inner Navigator.
+              // This will cover screen A but not the application shell.
+              GoRoute(
+                // The name of this route used to determine the title in the ShellRoute.
+                name: 'a.details',
+                path: 'details',
+                builder: (BuildContext context, GoRouterState state) {
+                  return const DetailsScreen(label: 'A');
+                },
+              ),
+            ],
+          ),
+
+          /// Displayed when the second item in the the bottom navigation bar is
+          /// selected.
+          GoRoute(
+            // The name of this route used to determine the title in the ShellRoute.
+            name: 'b',
+            path: '/b',
+            builder: (BuildContext context, GoRouterState state) {
+              return const ScreenB();
+            },
+            routes: <RouteBase>[
+              // The details screen to display stacked on the inner Navigator.
+              // This will cover screen B but not the application shell.
+              GoRoute(
+                // The name of this route used to determine the title in the ShellRoute.
+                name: 'b.details',
+                path: 'details',
+                builder: (BuildContext context, GoRouterState state) {
+                  return const DetailsScreen(label: 'B');
+                },
+              ),
+            ],
+          ),
+
+          /// The third screen to display in the bottom navigation bar.
+          GoRoute(
+            // The name of this route used to determine the title in the ShellRoute.
+            name: 'c',
+            path: '/c',
+            builder: (BuildContext context, GoRouterState state) {
+              return const ScreenC();
+            },
+            routes: <RouteBase>[
+              // The details screen to display stacked on the inner Navigator.
+              // This will cover screen C but not the application shell.
+              GoRoute(
+                // The name of this route used to determine the title in the ShellRoute.
+                name: 'c.details',
+                path: 'details',
+                builder: (BuildContext context, GoRouterState state) {
+                  return const DetailsScreen(label: 'C');
+                },
+              ),
+            ],
+          ),
+        ],
+      ),
+    ],
+  );
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp.router(
+      title: 'Flutter Demo',
+      theme: ThemeData(
+        primarySwatch: Colors.blue,
+      ),
+      routerConfig: _router,
+    );
+  }
+}
+
+/// Builds the "shell" for the app by building a Scaffold with a
+/// BottomNavigationBar, where [child] is placed in the body of the Scaffold.
+class ScaffoldWithNavBar extends StatelessWidget {
+  /// Constructs an [ScaffoldWithNavBar].
+  const ScaffoldWithNavBar({
+    super.key,
+    required this.title,
+    required this.child,
+  });
+
+  /// The title to display in the AppBar.
+  final String title;
+
+  /// The widget to display in the body of the Scaffold.
+  /// In this sample, it is a Navigator.
+  final Widget child;
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      body: child,
+      appBar: AppBar(
+        title: Text(title),
+        leading: _buildLeadingButton(context),
+      ),
+      bottomNavigationBar: BottomNavigationBar(
+        items: const <BottomNavigationBarItem>[
+          BottomNavigationBarItem(
+            icon: Icon(Icons.home),
+            label: 'A Screen',
+          ),
+          BottomNavigationBarItem(
+            icon: Icon(Icons.business),
+            label: 'B Screen',
+          ),
+          BottomNavigationBarItem(
+            icon: Icon(Icons.notification_important_rounded),
+            label: 'C Screen',
+          ),
+        ],
+        currentIndex: _calculateSelectedIndex(context),
+        onTap: (int idx) => _onItemTapped(idx, context),
+      ),
+    );
+  }
+
+  /// Builds the app bar leading button using the current location [Uri].
+  ///
+  /// The [Scaffold]'s default back button cannot be used because it doesn't
+  /// have the context of the current child.
+  Widget? _buildLeadingButton(BuildContext context) {
+    final RouteMatchList currentConfiguration =
+        GoRouter.of(context).routerDelegate.currentConfiguration;
+    final RouteMatch lastMatch = currentConfiguration.last;
+    final Uri location = lastMatch is ImperativeRouteMatch
+        ? lastMatch.matches.uri
+        : currentConfiguration.uri;
+    final bool canPop = location.pathSegments.length > 1;
+    return canPop ? BackButton(onPressed: GoRouter.of(context).pop) : null;
+  }
+
+  static int _calculateSelectedIndex(BuildContext context) {
+    final String location = GoRouterState.of(context).uri.toString();
+    if (location.startsWith('/a')) {
+      return 0;
+    }
+    if (location.startsWith('/b')) {
+      return 1;
+    }
+    if (location.startsWith('/c')) {
+      return 2;
+    }
+    return 0;
+  }
+
+  void _onItemTapped(int index, BuildContext context) {
+    switch (index) {
+      case 0:
+        GoRouter.of(context).go('/a');
+      case 1:
+        GoRouter.of(context).go('/b');
+      case 2:
+        GoRouter.of(context).go('/c');
+    }
+  }
+}
+
+/// The first screen in the bottom navigation bar.
+class ScreenA extends StatelessWidget {
+  /// Constructs a [ScreenA] widget.
+  const ScreenA({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      body: Center(
+        child: TextButton(
+          onPressed: () {
+            GoRouter.of(context).go('/a/details');
+          },
+          child: const Text('View A details'),
+        ),
+      ),
+    );
+  }
+}
+
+/// The second screen in the bottom navigation bar.
+class ScreenB extends StatelessWidget {
+  /// Constructs a [ScreenB] widget.
+  const ScreenB({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      body: Center(
+        child: TextButton(
+          onPressed: () {
+            GoRouter.of(context).go('/b/details');
+          },
+          child: const Text('View B details'),
+        ),
+      ),
+    );
+  }
+}
+
+/// The third screen in the bottom navigation bar.
+class ScreenC extends StatelessWidget {
+  /// Constructs a [ScreenC] widget.
+  const ScreenC({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      body: Center(
+        child: TextButton(
+          onPressed: () {
+            GoRouter.of(context).go('/c/details');
+          },
+          child: const Text('View C details'),
+        ),
+      ),
+    );
+  }
+}
+
+/// The details screen for either the A, B or C screen.
+class DetailsScreen extends StatelessWidget {
+  /// Constructs a [DetailsScreen].
+  const DetailsScreen({
+    required this.label,
+    super.key,
+  });
+
+  /// The label to display in the center of the screen.
+  final String label;
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      body: Center(
+        child: Text(
+          'Details for $label',
+          style: Theme.of(context).textTheme.headlineMedium,
+        ),
+      ),
+    );
+  }
+}
diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart
index a7bbc87..bcd79c4 100644
--- a/packages/go_router/lib/src/builder.dart
+++ b/packages/go_router/lib/src/builder.dart
@@ -377,6 +377,7 @@
       pathParameters: matchList.pathParameters,
       error: matchList.error,
       pageKey: ValueKey<String>('${matchList.uri}(error)'),
+      topRoute: matchList.lastOrNull?.route,
     );
   }
 
diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart
index e7ab54c..0976474 100644
--- a/packages/go_router/lib/src/configuration.dart
+++ b/packages/go_router/lib/src/configuration.dart
@@ -216,6 +216,7 @@
       matchedLocation: matchList.uri.path,
       extra: matchList.extra,
       pageKey: const ValueKey<String>('topLevel'),
+      topRoute: matchList.lastOrNull?.route,
     );
   }
 
diff --git a/packages/go_router/lib/src/match.dart b/packages/go_router/lib/src/match.dart
index fd83610..fba08f8 100644
--- a/packages/go_router/lib/src/match.dart
+++ b/packages/go_router/lib/src/match.dart
@@ -325,6 +325,7 @@
       name: route.name,
       path: route.path,
       extra: matches.extra,
+      topRoute: matches.lastOrNull?.route,
     );
   }
 }
@@ -382,6 +383,7 @@
       pathParameters: matches.pathParameters,
       pageKey: pageKey,
       extra: matches.extra,
+      topRoute: matches.lastOrNull?.route,
     );
   }
 
@@ -720,6 +722,8 @@
   /// If the last RouteMatchBase from [matches] is a ShellRouteMatch, it
   /// recursively goes into its [ShellRouteMatch.matches] until it reach the leaf
   /// [RouteMatch].
+  ///
+  /// Throws a [StateError] if [matches] is empty.
   RouteMatch get last {
     if (matches.last is RouteMatch) {
       return matches.last as RouteMatch;
@@ -727,6 +731,18 @@
     return (matches.last as ShellRouteMatch)._lastLeaf;
   }
 
+  /// The last leaf route or null if [matches] is empty
+  ///
+  /// If the last RouteMatchBase from [matches] is a ShellRouteMatch, it
+  /// recursively goes into its [ShellRouteMatch.matches] until it reach the leaf
+  /// [RouteMatch].
+  RouteMatch? get lastOrNull {
+    if (matches.isEmpty) {
+      return null;
+    }
+    return last;
+  }
+
   /// Returns true if the current match intends to display an error screen.
   bool get isError => error != null;
 
diff --git a/packages/go_router/lib/src/state.dart b/packages/go_router/lib/src/state.dart
index 2a0e3b0..09f1cce 100644
--- a/packages/go_router/lib/src/state.dart
+++ b/packages/go_router/lib/src/state.dart
@@ -7,6 +7,7 @@
 
 import 'configuration.dart';
 import 'misc/errors.dart';
+import 'route.dart';
 
 /// The route state during routing.
 ///
@@ -25,6 +26,7 @@
     this.extra,
     this.error,
     required this.pageKey,
+    this.topRoute,
   });
   final RouteConfiguration _configuration;
 
@@ -74,6 +76,13 @@
   /// ```
   final ValueKey<String> pageKey;
 
+  /// The current matched top route associated with this state.
+  ///
+  /// If this state represents a [ShellRoute], the top [GoRoute] will be the current
+  /// matched location associated with the [ShellRoute]. This allows the [ShellRoute]'s
+  /// associated GoRouterState to be uniquely identified using [GoRoute.name]
+  final GoRoute? topRoute;
+
   /// Gets the [GoRouterState] from context.
   ///
   /// The returned [GoRouterState] will depends on which [GoRoute] or
diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml
index 7ecbbbc..7701072 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: 13.0.1
+version: 13.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/go_router_state_test.dart b/packages/go_router/test/go_router_state_test.dart
index f27eb17..7a1b73d 100644
--- a/packages/go_router/test/go_router_state_test.dart
+++ b/packages/go_router/test/go_router_state_test.dart
@@ -186,5 +186,98 @@
       expect(registry.registry.length, 1);
       expect(find.byKey(key), findsNothing);
     });
+
+    testWidgets('GoRouterState topRoute accessible from StatefulShellRoute',
+        (WidgetTester tester) async {
+      final GlobalKey<NavigatorState> rootNavigatorKey =
+          GlobalKey<NavigatorState>();
+      final GlobalKey<NavigatorState> shellNavigatorKey =
+          GlobalKey<NavigatorState>();
+      final List<RouteBase> routes = <RouteBase>[
+        ShellRoute(
+          navigatorKey: shellNavigatorKey,
+          builder: (BuildContext context, GoRouterState state, Widget child) {
+            return Scaffold(
+              body: Column(
+                children: <Widget>[
+                  const Text('Screen 0'),
+                  Expanded(child: child),
+                ],
+              ),
+            );
+          },
+          routes: <RouteBase>[
+            GoRoute(
+              name: 'root',
+              path: '/',
+              builder: (BuildContext context, GoRouterState state) {
+                return const Scaffold(
+                  body: Text('Screen 1'),
+                );
+              },
+              routes: <RouteBase>[
+                StatefulShellRoute.indexedStack(
+                  parentNavigatorKey: rootNavigatorKey,
+                  builder: (
+                    BuildContext context,
+                    GoRouterState state,
+                    StatefulNavigationShell navigationShell,
+                  ) {
+                    final String? routeName =
+                        GoRouterState.of(context).topRoute?.name;
+                    final String title = switch (routeName) {
+                      'a' => 'A',
+                      'b' => 'B',
+                      _ => 'Unknown',
+                    };
+                    return Column(
+                      children: <Widget>[
+                        Text(title),
+                        Expanded(child: navigationShell),
+                      ],
+                    );
+                  },
+                  branches: <StatefulShellBranch>[
+                    StatefulShellBranch(
+                      routes: <RouteBase>[
+                        GoRoute(
+                          name: 'a',
+                          path: 'a',
+                          builder: (BuildContext context, GoRouterState state) {
+                            return const Scaffold(
+                              body: Text('Screen 2'),
+                            );
+                          },
+                        ),
+                      ],
+                    ),
+                    StatefulShellBranch(
+                      routes: <RouteBase>[
+                        GoRoute(
+                          name: 'b',
+                          path: 'b',
+                          builder: (BuildContext context, GoRouterState state) {
+                            return const Scaffold(
+                              body: Text('Screen 2'),
+                            );
+                          },
+                        ),
+                      ],
+                    )
+                  ],
+                ),
+              ],
+            )
+          ],
+        ),
+      ];
+      final GoRouter router = await createRouter(routes, tester,
+          initialLocation: '/a', navigatorKey: rootNavigatorKey);
+      expect(find.text('A'), findsOneWidget);
+
+      router.go('/b');
+      await tester.pumpAndSettle();
+      expect(find.text('B'), findsOneWidget);
+    });
   });
 }