Files
samplez/rippr-flutter-src/docs/ui-redesign/UI-01-tab-shell-background-map.md
uhryniuk 587f75eab4 Refresh the Flutter snapshot: UI-01 through UI-09, plus a fresh installable APK
Full UI redesign pass complete: persistent tab shell with an always-visible
background map, Modern Professional Dark theme, monochrome dark map tiles,
offline skeleton map, a shared GlassPanel/FloatingPill component kit,
customizable HUD telemetry widgets, and the Map HUD / Plan & Route Planning /
Rides History screen rebuilds. 374 tests passing, up from 316.

The APK is a fresh release build (debug-signed, no release signing config
exists yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
2026-08-24 14:41:35 -05:00

183 lines
12 KiB
Markdown

# 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 `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.