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
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-40treatment) 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
AppShellwidget:StatefulShellRoutewith 4 branches, wrapping a persistentBottomNavBar(see UI-03 for the styled version; a plain one is fine to start) and the shared background map behind anIndexedStack.- Extract the background-map hosting into its own widget (
PersistentMapBackgroundor similar) that every tab'sScaffoldcomposes against, rather than four independentRideMapconstructions. - 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. - 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. - Update
router.dart'sRoutesconstants and everycontext.push/context.popcall 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
AppBaris 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 testfully green, before starting any visual ticket on top of it. - A shared background map instance behind an
IndexedStackis 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-awareTileLayerteardown 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 RECORDINGvisible. - Rides/Plan/Settings tabs: same map dimmed via the
0xB3000000scrim, non-interactive, no header, each tab's real content on top. - Started a live recording (with
adb emu geo fixsupplying 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.