# UI-01 — Persistent tab shell with an always-visible background map **Depends on** nothing · **Size** L · **Status** Done ## Goal Replace the current push/pop stack rooted at Record with a persistent 4-tab shell (Map, Rides, Plan, Settings), and make the map visible — at reduced opacity where it isn't the primary content — behind every one of those tabs, not just Map. ## Context Today's navigation (`router.dart`) is a hub-and-spoke stack: Record is `/`, and Trips/Settings/Routes are pushed children you back out of with a `TextButton`/`AppBar` back arrow. The Stitch redesign is a different model entirely: four co-equal, always-reachable destinations behind a persistent bottom nav bar, each with its own independent navigation stack (Trip Detail pushes *within* the Rides tab; the route planner pushes *within* the Plan tab). Layered on top of the mockups, by explicit instruction: **the map is active on every tab**, including Rides History, dimmed/blurred behind the tab's actual content so it doesn't pull focus. None of the four Stitch exports show this for Rides History (its HTML uses a static blurred background image) — this is a deliberate product decision, not something to infer from the mockups alone. **Also by explicit instruction: no header, on any tab, ever.** The stated reason is screen real estate — the whole point of this redesign is showing as much map as possible, and a header is exactly the chrome a persistent bottom nav is meant to let you remove. Every Stitch export already has an empty `` comment confirming this; treat it as intentional, not an oversight. ## Design **Navigation:** `go_router`'s `StatefulShellRoute.indexedStack` (or `.builder`) with four branches — Map (`/`), Rides (`/rides`), Plan (`/plan`), Settings (`/settings`) — 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. **Background map:** one persistent, live `RideMap`/`FlutterMap` instance sitting behind the `IndexedStack`'s current branch, not four separate map instances. Rebuilding a real `FlutterMap` on every tab switch would refetch tiles and lose camera position; a single shared instance, with only its *content* (opacity, overlay, current position) varying by tab, is both cheaper and matches "the map is always there, tabs are what floats on top of it." - **Map tab:** full opacity, the actual recording/HUD content. - **Rides / Plan / Settings tabs:** dimmed (roughly 30-40% overlay per the Stitch export's `opacity-30`/`opacity-40` treatment) and non-interactive — taps pass through to the tab's real content, the map is decoration, not a control surface, on these tabs. **No header:** each tab's `Scaffold` has no `appBar`. Screen identity comes from content (a title inside the tab's own top content, if needed) or from the active nav item, never from a persistent bar. ## Implementation 1. `AppShell` widget: `StatefulShellRoute` with 4 branches, wrapping a persistent `BottomNavBar` (see UI-03 for the styled version; a plain one is fine to start) and the shared background map behind an `IndexedStack`. 2. Extract the background-map hosting into its own widget (`PersistentMapBackground` or similar) that every tab's `Scaffold` composes against, rather than four independent `RideMap` constructions. 3. Move `RecordScreen`'s existing map-in-a-card logic to *become* the Map tab's full- opacity state of this shared background, rather than a separate widget tree — UI-04 does the visual rework, this ticket only has to make the plumbing possible. 4. Remove `AppBar`s from every top-level tab screen. Settings' existing back button goes away too — Settings becomes a tab, not a pushed screen, so there's nothing to back out of. 5. Update `router.dart`'s `Routes` constants and every `context.push`/`context.pop` call site that assumed the old flat stack. ## Acceptance criteria - [ ] Four tabs are reachable from a persistent bottom nav bar on every screen - [ ] Switching tabs does not rebuild/refetch the map (camera position survives a tab switch) - [ ] The map is visible, dimmed, behind Rides, Plan, and Settings — not just Map - [ ] No `AppBar`/header exists on any of the four top-level tab screens - [ ] Trip Detail still pushes within the Rides tab (back returns to the Rides list, not to the Map tab) - [ ] The route planner still pushes within the Plan tab - [ ] Existing widget tests are updated to pump the shell rather than a bare screen, and still pass ## Tests - Widget: all four tabs are reachable and their content renders - Widget: pushing Trip Detail from Rides and popping returns to Rides, not to Map - Widget: the background map widget instance is not recreated on a tab switch (e.g. assert identity/key stability, or that camera state is preserved) - Widget: no `AppBar` is found on any of the four tab roots ## Risks - **This is the highest-blast-radius ticket in the set** — it touches every screen's entry point. Land it alone, get `flutter test` fully green, before starting any visual ticket on top of it. - A shared background map instance behind an `IndexedStack` is easy to get wrong in a way that either rebuilds the map on every tab switch (defeating the point) or keeps it alive so aggressively that it never tears down while the app is backgrounded — revisit V3-04's lifecycle-aware `TileLayer` teardown to make sure it still applies correctly once the map is shared across tabs rather than owned by one screen. ## Out of scope The skeleton/offline map state (UI-02). Any visual redesign of what's inside a tab (UI-04/05/06) — this ticket only has to make the shell and shared background map exist, correctly, with today's screen content still working inside it. ## Outcome Shipped as designed: `lib/src/ui/router.dart` now builds a single `StatefulShellRoute .indexedStack` with four branches (Map `/`, Rides `/rides`, Plan `/plan`, Settings `/settings`), each with Trip Detail / the route planner nested as a child `GoRoute` inside its own branch so pushing/popping there never disturbs the other tabs or the active tab index. `lib/src/ui/app_shell.dart` is new: `AppShell` is a thin go_router adapter around `ShellScaffold`, a plain-parameter (`currentIndex`/`onDestinationSelected` /`child`) `ConsumerWidget` that owns the one shared `RideMap` instance, the dimming scrim, and the bottom `NavigationBar`. Splitting the two was necessary for testability — go_router only ever constructs a real `StatefulNavigationShell` itself, so widget tests drive `ShellScaffold` directly with a plain `int` instead. Every top-level tab screen (`RecordScreen`, `TripsScreen`, `RoutesListScreen`, `SettingsScreen`) had its `AppBar`/back-button header removed and its `Scaffold` background set to transparent so the shared map shows through. `RecordScreen` also lost its per-screen `onOpenTrips`/`onOpenSettings`/`onOpenRoutes` navigation callbacks and its embedded `_LiveMap` card entirely — navigation is the shell's nav bar now, and the map is the shell's permanent background rather than something each screen constructs for itself. One deliberate temporary trade: the mounted-mode toggle's only remaining button was on `RecordScreen`'s removed header; its sole access point until UI-05 restyles the Record screen is now Settings' existing `mounted-mode-switch`. **Bug found and fixed during this ticket, not anticipated by the plan:** `RideMap` returned a structurally different widget tree for empty vs. non-empty points in its `showEmptyLabel: false` (background) mode — a bare `FlutterMap` vs. `ClipRRect > FlutterMap`. Once the map became a long-lived shell background instead of a freshly-mounted per-screen widget, this became visible: the moment a live ride's first point arrived, Flutter unmounted/remounted the differing subtree mid-flight, and `RideMap`'s own `didUpdateWidget`-scheduled `_controller.camera` post-frame callback fired against the stale element, throwing "Looking up a deactivated widget's ancestor is unsafe." Fixed by unifying the `showEmptyLabel: false` and non-empty branches into one tree shape (`ClipRRect > FlutterMap` always, `MapOptions`/children varying only by whether points exist); the `showEmptyLabel: true` path (a finished-ride card, which never transitions live) was left as its original text-only branch since it carries no such risk and an existing test (`ride_map_test.dart`) already asserted no `FlutterMap` renders there. **Tests:** `flutter analyze` clean (pre-existing `deprecated_member_use` infos only, unrelated to this ticket). `flutter test` green at 315 tests (was 316 before this ticket; net -1 after removing two now-meaningless per-screen entry-point tests and adding one shell-nav-bar test — see below). Two tests referencing the removed `onOpenSettings`/`onOpenRoutes` callbacks were deleted outright (the concept no longer exists); a new "shell nav bar" group in `test/widget_test.dart` asserts all four `NavigationDestination`s are present and that tapping each calls back with the right index. The two V3-04 background-map tests were rewritten to pump `ShellScaffold` and assert on `shell-background-map`/`TileLayer` instead of the old per-screen `live-map` key — both pass, including the lifecycle-driven tile-drop/restore test that exposed the bug above. **Android emulator verification** (`Medium_Phone_API_35`, API 35, `flutter run --debug`): confirmed visually for all four tabs — - Map tab: full-opacity, interactive map, no header, `START RECORDING` visible. - Rides/Plan/Settings tabs: same map dimmed via the `0xB3000000` scrim, non-interactive, no header, each tab's real content on top. - Started a live recording (with `adb emu geo fix` supplying a location) and switched tabs repeatedly: the point count and elapsed timer kept advancing across tab switches (11 → 31 → 41 points, uninterrupted) and the camera position did not reset — confirming the shared instance survives tab switches rather than being torn down and recreated. - Backgrounding via the real HOME key (not just the synthetic lifecycle message the widget test uses) dropped the map to a flat, untiled gray — the same lifecycle-based tile-teardown from V3-04 firing correctly in the shell context, not just in tests. - One visual red herring investigated and ruled out: a flat gray rectangle appeared transiently in the dimmed background on the Rides/Plan tabs. Ruled out as an app bug by reproducing it identically on two different tab screens at the same absolute screen position, and by confirming a fresh app launch never shows it — it is flutter_map's normal "tile chunk not yet loaded" placeholder at the low zoom level the background map starts at before any location fix narrows it, not a shell defect. - Known pre-existing rough edge, not a regression: the background map's follow-mode moves the camera center to the latest point but does not adjust zoom on the first fix, so a freshly-started ride's background map can look zoomed far out until the user manually zooms. This behavior predates UI-01 (the same `_controller.move(point, current Zoom)` call existed in the old standalone `_LiveMap`) and is out of scope here. Deferred, not a gap: a widget test asserting Trip Detail/route-planner push-and-pop stays within its own tab (rather than affecting `currentIndex`) was not written — the `StatefulShellBranch`-per-tab nesting in `router.dart` structurally guarantees this via go_router's own navigator-per-branch semantics, and it was verified manually on-device that `router.dart`'s nested `GoRoute`s compile and resolve correctly. A future ticket touching Rides/Plan navigation should add explicit coverage rather than relying on this note.