Files
rippr/lib/src/ui/router.dart
uhryniuk 5ab4934a4d UI-01: persistent 4-tab shell with a shared background map
Replaces the push/pop stack rooted at Record with a StatefulShellRoute
(Map/Rides/Plan/Settings), each tab keeping its own navigator so Trip Detail
and the route planner push within their own branch. AppShell/ShellScaffold
host one shared RideMap instance behind every tab -- full opacity and
interactive on Map, dimmed and non-interactive elsewhere -- so the camera
position and live path survive a tab switch instead of being refetched.
Every tab's AppBar/header is removed per the redesign's no-chrome mandate.

Fixes a bug this surfaced: RideMap rendered a structurally different tree
for empty vs. non-empty points, which crashed once the map became a
long-lived shell background instead of a fresh per-screen widget. Unified
the background-map tree shape so it no longer remounts mid-recording.

Verified on Medium_Phone_API_35: all four tabs, live recording surviving
tab switches with the camera preserved, and the real HOME-key lifecycle
tile-drop from V3-04 still firing correctly in the shell context.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
2026-08-23 19:28:25 -05:00

113 lines
3.7 KiB
Dart

/// UI-01: a persistent 4-tab shell (`StatefulShellRoute`), replacing the push/pop stack
/// rooted at Record that this app used through v3. Each branch keeps its own navigator,
/// so pushing Trip Detail from Rides -- or the route planner from Plan -- doesn't
/// disturb the other tabs' state or the active tab index.
library;
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'app_shell.dart';
import 'detail/trip_detail_screen.dart';
import 'record/record_screen.dart';
import 'routes/route_planner_screen.dart';
import 'routes/routes_list_screen.dart';
import 'settings/settings_screen.dart';
import 'trips/trips_screen.dart';
abstract final class Routes {
static const record = '/';
static const trips = '/rides';
static const tripDetail = '/rides/:tripId';
static const settings = '/settings';
static const routes = '/plan';
static const routePlanner = '/plan/:routeId';
static String detailFor(int tripId) => '/rides/$tripId';
static String plannerFor(int routeId) => '/plan/$routeId';
}
GoRouter buildRouter() => GoRouter(
initialLocation: Routes.record,
routes: [
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) =>
AppShell(navigationShell: navigationShell),
branches: [
// Branch 0: Map. The Record screen, headerless -- see AppShell's doc comment.
StatefulShellBranch(
routes: [
GoRoute(path: Routes.record, builder: (context, state) => const RecordScreen()),
],
),
// Branch 1: Rides. Trip Detail pushes within this branch's own navigator.
StatefulShellBranch(
routes: [
GoRoute(
path: Routes.trips,
builder: (context, state) => TripsScreen(
onOpenTrip: (id) => context.push(Routes.detailFor(id)),
),
routes: [
GoRoute(
path: ':tripId',
builder: (context, state) {
final id = int.tryParse(state.pathParameters['tripId'] ?? '');
if (id == null) return const _NotFound();
return TripDetailScreen(tripId: id, onBack: () => context.pop());
},
),
],
),
],
),
// Branch 2: Plan. The route planner pushes within this branch's own navigator.
StatefulShellBranch(
routes: [
GoRoute(
path: Routes.routes,
builder: (context, state) => RoutesListScreen(
onOpenRoute: (id) => context.push(Routes.plannerFor(id)),
),
routes: [
GoRoute(
path: ':routeId',
builder: (context, state) {
final id = int.tryParse(state.pathParameters['routeId'] ?? '');
if (id == null) return const _NotFound();
return RoutePlannerScreen(routeId: id, onBack: () => context.pop());
},
),
],
),
],
),
// Branch 3: Settings.
StatefulShellBranch(
routes: [
GoRoute(
path: Routes.settings,
builder: (context, state) => const SettingsScreen(),
),
],
),
],
),
],
errorBuilder: (context, state) => const _NotFound(),
);
class _NotFound extends StatelessWidget {
const _NotFound();
@override
Widget build(BuildContext context) => Scaffold(
body: Center(
child: TextButton(
onPressed: () => context.canPop() ? context.pop() : context.go(Routes.record),
child: const Text('That ride could not be found. Back'),
),
),
);
}