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:
2026-08-23 19:28:25 -05:00
parent 3e7878b6e1
commit 5ab4934a4d
10 changed files with 511 additions and 317 deletions

View File

@@ -19,7 +19,7 @@ v3 held to.
| # | Ticket | Size | Depends on | Status |
|---|---|---|---|---|
| [UI-01](UI-01-tab-shell-background-map.md) | Persistent tab shell with an always-visible background map | L | — | Not started |
| [UI-01](UI-01-tab-shell-background-map.md) | Persistent tab shell with an always-visible background map | L | — | Done |
| [UI-02](UI-02-offline-skeleton-map.md) | Offline / no-connection skeleton map | S | UI-01 | Not started |
| [UI-03](UI-03-glass-component-kit.md) | Shared floating-glass component kit | M | UI-08 | Not started |
| [UI-04](UI-04-customizable-hud-widgets.md) | Customizable HUD telemetry widgets (drag, resize, visibility toggle) | L | UI-03 | Not started |

View File

@@ -1,6 +1,6 @@
# UI-01 — Persistent tab shell with an always-visible background map
**Depends on** nothing · **Size** L · **Status** Not started
**Depends on** nothing · **Size** L · **Status** Done
## Goal
Replace the current push/pop stack rooted at Record with a persistent 4-tab shell (Map,
@@ -98,3 +98,85 @@ from a persistent bar.
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.