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
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
/// Ported from `com.rippr.ui.RipprNavHost`.
|
||||
///
|
||||
/// Three destinations, same shape as the Compose original. `go_router` replaces
|
||||
/// `navigation-compose`; the path parameter is parsed explicitly, mirroring the Kotlin
|
||||
/// comment about ids arriving as the wrong type when the argument type is left implicit.
|
||||
/// 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';
|
||||
@@ -17,65 +17,81 @@ import 'trips/trips_screen.dart';
|
||||
|
||||
abstract final class Routes {
|
||||
static const record = '/';
|
||||
static const trips = '/trips';
|
||||
static const tripDetail = '/trip/:tripId';
|
||||
static const trips = '/rides';
|
||||
static const tripDetail = '/rides/:tripId';
|
||||
static const settings = '/settings';
|
||||
static const routes = '/routes';
|
||||
static const routePlanner = '/routes/:routeId';
|
||||
static const routes = '/plan';
|
||||
static const routePlanner = '/plan/:routeId';
|
||||
|
||||
static String detailFor(int tripId) => '/trip/$tripId';
|
||||
static String plannerFor(int routeId) => '/routes/$routeId';
|
||||
static String detailFor(int tripId) => '/rides/$tripId';
|
||||
static String plannerFor(int routeId) => '/plan/$routeId';
|
||||
}
|
||||
|
||||
GoRouter buildRouter() => GoRouter(
|
||||
initialLocation: Routes.record,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.record,
|
||||
builder: (context, state) => RecordScreen(
|
||||
onOpenTrips: () => context.push(Routes.trips),
|
||||
onOpenSettings: () => context.push(Routes.settings),
|
||||
onOpenRoutes: () => context.push(Routes.routes),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.routes,
|
||||
builder: (context, state) => RoutesListScreen(
|
||||
onOpenRoute: (id) => context.push(Routes.plannerFor(id)),
|
||||
onBack: () => context.pop(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.routePlanner,
|
||||
builder: (context, state) {
|
||||
final id = int.tryParse(state.pathParameters['routeId'] ?? '');
|
||||
if (id == null) return const _NotFound();
|
||||
return RoutePlannerScreen(routeId: id, onBack: () => context.pop());
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.settings,
|
||||
builder: (context, state) =>
|
||||
SettingsScreen(onBack: () => context.pop()),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.trips,
|
||||
builder: (context, state) => TripsScreen(
|
||||
onOpenTrip: (id) => context.push(Routes.detailFor(id)),
|
||||
onBack: () => context.pop(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.tripDetail,
|
||||
builder: (context, state) {
|
||||
// Parsed explicitly. A malformed or missing id must land on a real screen
|
||||
// saying so, never on a blank one or a crash.
|
||||
final id = int.tryParse(state.pathParameters['tripId'] ?? '');
|
||||
if (id == null) {
|
||||
return const _NotFound();
|
||||
}
|
||||
return TripDetailScreen(tripId: id, onBack: () => context.pop());
|
||||
},
|
||||
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(),
|
||||
@@ -86,13 +102,11 @@ class _NotFound extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () =>
|
||||
context.canPop() ? context.pop() : context.go(Routes.record),
|
||||
body: Center(
|
||||
child: TextButton(
|
||||
onPressed: () => context.canPop() ? context.pop() : context.go(Routes.record),
|
||||
child: const Text('That ride could not be found. Back'),
|
||||
),
|
||||
),
|
||||
body: const Center(child: Text('That ride could not be found.')),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user