# UI-01 — Persistent tab shell with an always-visible background map **Depends on** nothing · **Size** L · **Status** Not started ## 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.