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
183 lines
12 KiB
Markdown
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.
|