Files
rippr/docs/ui-redesign/UI-01-tab-shell-background-map.md
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

12 KiB

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 <!-- TopAppBar --> 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 AppBars 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 NavigationDestinations 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 GoRoutes compile and resolve correctly. A future ticket touching Rides/Plan navigation should add explicit coverage rather than relying on this note.