diff --git a/RIPPR.md b/RIPPR.md
index 3251919..ca31dae 100644
--- a/RIPPR.md
+++ b/RIPPR.md
@@ -9,7 +9,7 @@ only off-machine copies of their history.
| `~/dojo/rippr` | The original **native Android** app (Kotlin, v1 → v2.0.1). **Superseded**, but still the only version that has recorded real rides. |
| `~/dojo/rippr-flutter` | The **Flutter port** — Android *and* iOS. Feature-complete, better tested, never yet ridden. |
-**Snapshots taken at:** native `ba57a92`, Flutter `70d68b2`.
+**Snapshots taken at:** native `ba57a92`, Flutter `04127d7`.
## What is here
diff --git a/rippr-flutter-1.0-debug.apk b/rippr-flutter-1.0-debug.apk
index 5ac3a57..82d5fc9 100644
Binary files a/rippr-flutter-1.0-debug.apk and b/rippr-flutter-1.0-debug.apk differ
diff --git a/rippr-flutter-history.bundle b/rippr-flutter-history.bundle
index 3243649..28da521 100644
Binary files a/rippr-flutter-history.bundle and b/rippr-flutter-history.bundle differ
diff --git a/rippr-flutter-src/docs/BACKLOG.md b/rippr-flutter-src/docs/BACKLOG.md
index 19d1f1b..3d919a3 100644
--- a/rippr-flutter-src/docs/BACKLOG.md
+++ b/rippr-flutter-src/docs/BACKLOG.md
@@ -13,7 +13,7 @@
---
-# Backlog — v3 and v4
+# Backlog — v3 and the ROADMAP
Everything known-outstanding, with enough context to pick up cold. Nothing here is
committed to — it is a menu, roughly ordered by value.
@@ -21,9 +21,12 @@ committed to — it is a menu, roughly ordered by value.
**v3 is now broken out into tickets: [v3/README.md](v3/README.md).** This document stays
as the reasoning; the tickets are the executable form.
-**v3 is everything that can be built with no server.** **v4 is everything that cannot.**
-That split is the most useful thing in this document: it means v3 can proceed indefinitely
-without anyone deciding to run infrastructure or hold other people's location data.
+**v3 is everything that can be built with no server. The ROADMAP is everything that
+cannot, plus whatever bugs and features come up that need triage before the next v3-style
+ticket batch.** The no-server split is still the most useful structural fact in this
+document: it means v3 can proceed indefinitely without anyone deciding to run
+infrastructure or hold other people's location data. The ROADMAP is where everything else
+gets managed first.
**Before planning anything: run the real-ride checklist in
[the real-ride checklist](port/REAL-RIDE-CHECKLIST.md).** Several items below may turn out to be non-issues, and
@@ -230,8 +233,8 @@ Terse on purpose. Unshaped, to be consolidated later.
### Threads running through these
Sign-up, cloud backup and group ride are one programme, not three: they all need a server,
-identity, and a privacy stance. They have therefore been moved out to **v4** below, and
-should be scoped together or not at all.
+identity, and a privacy stance. They have therefore been moved out to the **ROADMAP**
+below, and should be scoped together or not at all.
Activity type (now section 4) and theming are independent and much cheaper — either could
ship alone, and activity type is the natural first v3 task because it forces the migration
@@ -246,7 +249,15 @@ entirely standalone.
---
-# v4 — everything that needs a server
+# ROADMAP
+
+Formerly "v4." Renamed because it now holds more than the server-dependent programme
+below: it's also where Dylan's running list of bugs and features gets triaged before
+becoming its own ticket batch, the way v3 was broken out. That triage takes priority over
+the items below — nothing here is blocking, all of it has been waiting since before v3
+started.
+
+## The server-dependent programme
Split out from v3 deliberately. These three are **one programme, not three items**: each
needs a server, an identity system, and a privacy stance, and none of them is worth
diff --git a/rippr-flutter-src/docs/design/stitch-export/README.md b/rippr-flutter-src/docs/design/stitch-export/README.md
new file mode 100644
index 0000000..0e3820d
--- /dev/null
+++ b/rippr-flutter-src/docs/design/stitch-export/README.md
@@ -0,0 +1,62 @@
+# Stitch export — Rippr Minimalist Activity Tracker
+
+Pulled from [stitch.withgoogle.com](https://stitch.withgoogle.com) project
+`8187200995279352789` via the Stitch MCP server on 2026-08-23.
+
+## Correction
+
+An earlier version of this README got the design systems backwards. There are **three**
+distinct design systems in this project (`list_design_systems`), and the four screens
+below actually render with the one this doc originally called "stale." That's now fixed.
+
+| Asset | Display name | Mode | Primary | Fonts | Used by the 4 screens below? |
+|---|---|---|---|---|---|
+| `assets/e73325c5365a4a56b93823236777e95a` | **Modern Professional Dark** | Dark | Blue `#1978e5` (rendered `#4090fe`/`#aac7ff`) | Inter (everywhere) | **Yes — confirmed by hex-matching the downloaded HTML** |
+| `assets/41670631a4b64b118fb9ea1b54f1bd60` | Rippr (neon HUD) | Dark | Electric green `#00FF41` | Sora / Inter / JetBrains Mono | No |
+| `assets/3938003907261369831` | Modern Professional | Light | Blue `#1275e2` | Inter | No |
+
+**The one you specifically asked for** (screen ID
+`asset-stub-assets_e73325c5365a4a56b93823236777e95a`, i.e.
+`assets/e73325c5365a4a56b93823236777e95a`) is **Modern Professional Dark** — deep
+charcoal/navy (`#0a0a0c` base, `#131313` surface, `#16161a` cards), Professional Blue
+primary, Inter throughout, 8px rounding, tonal-layer depth (no shadows, lighter surface
+= more elevated), and a soft blue glow on elevated/active elements instead of a shadow.
+This is `design-system-modern-professional-dark.md` below, and it's the design system
+actually used by all four screens already pulled.
+
+`design-system-rippr-neon.md` is kept for reference — it's a real design system in the
+project (`get_project`'s top-level `designTheme`, i.e. whatever a brand-new screen would
+default to), but it is **not** what any of the four fetched screens render with.
+
+## Contents
+
+| File | What it is |
+|---|---|
+| `design-system-modern-professional-dark.md` | **The design system actually applied to the four screens below.** Full spec: color tokens, typography, spacing, elevation, shape, components. |
+| `design-system-rippr-neon.md` | A second, unused-by-these-screens design system also present in the project (green/cyan HUD aesthetic). Reference only. |
+| `screens/export-summary.md` | Stitch's own generated project overview. Its "Design System: Modern Professional Dark" section was correct all along. |
+| `screens/map-hud-dark.html` + `.png` | Map HUD - Dark Mode: active-recording map screen, floating telemetry cards (speed, avg speed, distance, time), Pause/Stop controls. |
+| `screens/plan-screen-dark.html` + `.png` | Plan Screen - Dark Mode: empty-state route planner, "Tap to drop a pin" prompt. |
+| `screens/route-planning-dark.html` + `.png` | Route Planning - Dark Mode: pins dropped, distance/time/pin-count header, dotted route line, Start Route button. |
+| `screens/rides-history-dark.html` + `.png` | Rides History - Dark Mode: search bar, unit toggle, ride cards with a mini-map thumbnail, date, distance, time, avg speed. |
+
+Each `.html` is Stitch's generated markup (Tailwind CSS, vanilla JS) for that screen — a
+reference for structure and interaction, not something to drop into the Flutter app
+as-is.
+
+## Not pulled as a screen/image
+
+"Design System" (`assets_e73325c5365a4a56b93823236777e95a`) is a **design-system asset
+instance**, not a regular screen — Stitch's `get_screen` call only works on
+`projects/{project}/screens/{screen}` resources and returns `invalid argument` for an
+`assets/{id}` resource. There is no separate HTML/screenshot file for it the way there is
+for a screen; its full content (structured theme tokens + style-guideline prose) is
+fetched via `list_design_systems` instead, and that's what
+`design-system-modern-professional-dark.md` is built from.
+
+## Next step
+
+This is raw material, not yet applied to the app. `lib/src/ui/theme.dart`'s current
+palette (safety-orange accent, warm near-black ground — see V3-16) is a different
+direction from **either** of these Stitch design systems. Reconciling or replacing is a
+decision for whoever picks up the actual UI work on this branch.
diff --git a/rippr-flutter-src/docs/design/stitch-export/design-system-modern-professional-dark.md b/rippr-flutter-src/docs/design/stitch-export/design-system-modern-professional-dark.md
new file mode 100644
index 0000000..4b722fb
--- /dev/null
+++ b/rippr-flutter-src/docs/design/stitch-export/design-system-modern-professional-dark.md
@@ -0,0 +1,171 @@
+---
+name: Modern Professional Dark
+colors:
+ surface: '#131313'
+ surface-dim: '#131313'
+ surface-bright: '#393939'
+ surface-container-lowest: '#0e0e0e'
+ surface-container-low: '#1c1b1b'
+ surface-container: '#201f1f'
+ surface-container-high: '#2a2a2a'
+ surface-container-highest: '#353534'
+ on-surface: '#e5e2e1'
+ on-surface-variant: '#c1c6d5'
+ inverse-surface: '#e5e2e1'
+ inverse-on-surface: '#313030'
+ outline: '#8b919f'
+ outline-variant: '#414753'
+ surface-tint: '#aac7ff'
+ primary: '#aac7ff'
+ on-primary: '#002f64'
+ primary-container: '#4090fe'
+ on-primary-container: '#002958'
+ inverse-primary: '#005db8'
+ secondary: '#aec7f6'
+ on-secondary: '#143057'
+ secondary-container: '#2e476f'
+ on-secondary-container: '#9db6e4'
+ tertiary: '#ffb68c'
+ on-tertiary: '#532200'
+ tertiary-container: '#e3711f'
+ on-tertiary-container: '#481d00'
+ error: '#ffb4ab'
+ on-error: '#690005'
+ error-container: '#93000a'
+ on-error-container: '#ffdad6'
+ primary-fixed: '#d6e3ff'
+ primary-fixed-dim: '#aac7ff'
+ on-primary-fixed: '#001b3e'
+ on-primary-fixed-variant: '#00458d'
+ secondary-fixed: '#d6e3ff'
+ secondary-fixed-dim: '#aec7f6'
+ on-secondary-fixed: '#001b3d'
+ on-secondary-fixed-variant: '#2e476f'
+ tertiary-fixed: '#ffdbc9'
+ tertiary-fixed-dim: '#ffb68c'
+ on-tertiary-fixed: '#321200'
+ on-tertiary-fixed-variant: '#763400'
+ background: '#131313'
+ on-background: '#e5e2e1'
+ surface-variant: '#353534'
+ surface-main: '#0a0a0c'
+ surface-card: '#16161a'
+ accent-error: '#ffb4ab'
+ on-surface-bright: '#ffffff'
+ on-surface-muted: '#9ca3af'
+typography:
+ headline-lg:
+ fontFamily: Inter
+ fontSize: 32px
+ fontWeight: '700'
+ lineHeight: 40px
+ letterSpacing: -0.02em
+ headline-lg-mobile:
+ fontFamily: Inter
+ fontSize: 28px
+ fontWeight: '700'
+ lineHeight: 36px
+ letterSpacing: -0.01em
+ headline-md:
+ fontFamily: Inter
+ fontSize: 24px
+ fontWeight: '600'
+ lineHeight: 32px
+ headline-sm:
+ fontFamily: Inter
+ fontSize: 20px
+ fontWeight: '600'
+ lineHeight: 28px
+ body-lg:
+ fontFamily: Inter
+ fontSize: 16px
+ fontWeight: '400'
+ lineHeight: 24px
+ body-md:
+ fontFamily: Inter
+ fontSize: 14px
+ fontWeight: '400'
+ lineHeight: 20px
+ label-md:
+ fontFamily: Inter
+ fontSize: 12px
+ fontWeight: '500'
+ lineHeight: 16px
+ letterSpacing: 0.5px
+ label-sm:
+ fontFamily: Inter
+ fontSize: 11px
+ fontWeight: '500'
+ lineHeight: 16px
+ letterSpacing: 0.5px
+rounded:
+ sm: 0.25rem
+ DEFAULT: 0.5rem
+ md: 0.75rem
+ lg: 1rem
+ xl: 1.5rem
+ full: 9999px
+spacing:
+ base: 4px
+ gutter: 24px
+ margin-mobile: 16px
+ margin-desktop: 32px
+ max-width: 1280px
+---
+
+## Brand & Style
+
+This design system embodies a **Corporate / Modern** aesthetic reimagined for a premium dark-mode experience. The brand personality is authoritative, precise, and sophisticated. By shifting to a deep charcoal and navy foundation, the UI evokes a sense of focused calm and high-end engineering.
+
+The visual style leverages subtle luminescence and high-contrast accents to guide the user's eye, moving away from traditional light surfaces to a more immersive, "command-center" feel. It is tailored for professional environments where clarity and reduced eye strain are paramount.
+
+## Colors
+
+The palette is anchored by a **Deep Dark Gray (#0a0a0c)** background to provide a true premium feel without the harshness of pure black.
+
+- **Primary:** The Professional Blue (#1978e5) is the soul of the interface, used for key actions and brand presence.
+- **Secondary:** A muted Slate Blue (#465f88) handles auxiliary UI elements and decorative accents.
+- **Surface Strategy:** We use a "stepped" dark palette. The base is the darkest, while interactive containers or cards use a slightly lighter gray (#16161a) to create perceived depth.
+- **Contrast:** All functional text is kept at a high luminance (Off-white to Pure White) to ensure AAA accessibility standards against the dark backdrop.
+
+## Typography
+
+This design system relies exclusively on **Inter** to maintain a systematic, utilitarian, and clean appearance.
+
+- **Weight & Scale:** Headlines utilize tighter letter spacing and heavier weights to command attention against the dark background.
+- **Readability:** Body text uses a generous line-height to prevent "halation"—where light text on dark backgrounds appears to bleed or blur.
+- **Labels:** Small labels use all-caps or increased letter-spacing to maintain legibility at micro-scales.
+
+## Layout & Spacing
+
+The design system employs a **Fixed Grid** philosophy for desktop to maintain structural integrity, transitioning to a fluid model for smaller screens.
+
+- **Grid:** A 12-column grid is used for desktop (1280px max-width) with 24px gutters.
+- **Rhythm:** An 8px spatial system governs all padding and margins, ensuring a predictable and professional vertical rhythm.
+- **Breakpoints:**
+ - **Mobile:** < 600px (4 columns, 16px margins)
+ - **Tablet:** 600px - 1024px (8 columns, 24px margins)
+ - **Desktop:** > 1024px (12 columns, 32px margins)
+
+## Elevation & Depth
+
+In dark mode, shadows are less effective. Instead, this design system uses **Tonal Layers** and **Low-Contrast Outlines**.
+
+- **Elevation Levels:** Higher elevation is communicated by lighter surface colors. A "floating" modal will have a lighter hex than the background.
+- **Borders:** Subtle 1px borders (#2d3037) are used to define boundaries where tonal shifts are too subtle.
+- **Glow:** Highly elevated elements (like active primary buttons) may use a soft blue outer glow rather than a black shadow to emphasize their importance.
+
+## Shapes
+
+The shape language is defined as **Rounded (Level 2)**, striking a balance between the rigidity of corporate software and the approachability of modern SaaS.
+
+- **Small Components:** Buttons and inputs use a 0.5rem (8px) radius.
+- **Large Containers:** Cards and modals use a 1rem (16px) radius to soften the layout and create clear content groupings.
+
+## Components
+
+- **Buttons:** Primary buttons are solid Professional Blue with white text. Secondary buttons use a ghost style with a subtle white border and no fill.
+- **Input Fields:** Backgrounds should be slightly darker or lighter than the container surface, with a 1px border that glows Professional Blue on focus.
+- **Cards:** Cards use a #16161a background with a very subtle 1px border (#ffffff10) to separate them from the main background.
+- **Chips:** Used for metadata, chips feature a desaturated secondary blue-grey background with high-contrast labels.
+- **Lists:** Items are separated by hair-line dividers (#ffffff08) to maintain a clean vertical scan-path.
diff --git a/rippr-flutter-src/docs/design/stitch-export/design-system-rippr-neon.md b/rippr-flutter-src/docs/design/stitch-export/design-system-rippr-neon.md
new file mode 100644
index 0000000..c85a417
--- /dev/null
+++ b/rippr-flutter-src/docs/design/stitch-export/design-system-rippr-neon.md
@@ -0,0 +1,171 @@
+---
+name: Rippr
+colors:
+ surface: '#131314'
+ surface-dim: '#131314'
+ surface-bright: '#3a393a'
+ surface-container-lowest: '#0e0e0f'
+ surface-container-low: '#1c1b1c'
+ surface-container: '#201f20'
+ surface-container-high: '#2a2a2b'
+ surface-container-highest: '#353436'
+ on-surface: '#e5e2e3'
+ on-surface-variant: '#b9ccb2'
+ inverse-surface: '#e5e2e3'
+ inverse-on-surface: '#313031'
+ outline: '#84967e'
+ outline-variant: '#3b4b37'
+ surface-tint: '#00e639'
+ primary: '#ebffe2'
+ on-primary: '#003907'
+ primary-container: '#00ff41'
+ on-primary-container: '#007117'
+ inverse-primary: '#006e16'
+ secondary: '#b9f1ff'
+ on-secondary: '#00363f'
+ secondary-container: '#00e0ff'
+ on-secondary-container: '#005f6d'
+ tertiary: '#fff8f4'
+ on-tertiary: '#442b10'
+ tertiary-container: '#ffd5ae'
+ on-tertiary-container: '#7a5b3c'
+ error: '#ffb4ab'
+ on-error: '#690005'
+ error-container: '#93000a'
+ on-error-container: '#ffdad6'
+ primary-fixed: '#72ff70'
+ primary-fixed-dim: '#00e639'
+ on-primary-fixed: '#002203'
+ on-primary-fixed-variant: '#00530e'
+ secondary-fixed: '#a5eeff'
+ secondary-fixed-dim: '#00daf8'
+ on-secondary-fixed: '#001f25'
+ on-secondary-fixed-variant: '#004e5a'
+ tertiary-fixed: '#ffdcbd'
+ tertiary-fixed-dim: '#e7bf99'
+ on-tertiary-fixed: '#2c1701'
+ on-tertiary-fixed-variant: '#5d4124'
+ background: '#131314'
+ on-background: '#e5e2e3'
+ surface-variant: '#353436'
+typography:
+ display-telemetry:
+ fontFamily: JetBrains Mono
+ fontSize: 64px
+ fontWeight: '700'
+ lineHeight: 64px
+ letterSpacing: -0.04em
+ headline-lg:
+ fontFamily: Sora
+ fontSize: 32px
+ fontWeight: '600'
+ lineHeight: 40px
+ headline-md:
+ fontFamily: Sora
+ fontSize: 24px
+ fontWeight: '600'
+ lineHeight: 32px
+ body-lg:
+ fontFamily: Inter
+ fontSize: 18px
+ fontWeight: '400'
+ lineHeight: 28px
+ body-md:
+ fontFamily: Inter
+ fontSize: 16px
+ fontWeight: '400'
+ lineHeight: 24px
+ label-caps:
+ fontFamily: JetBrains Mono
+ fontSize: 12px
+ fontWeight: '500'
+ lineHeight: 16px
+ letterSpacing: 0.1em
+ display-telemetry-mobile:
+ fontFamily: JetBrains Mono
+ fontSize: 48px
+ fontWeight: '700'
+ lineHeight: 48px
+rounded:
+ sm: 0.125rem
+ DEFAULT: 0.25rem
+ md: 0.375rem
+ lg: 0.5rem
+ xl: 0.75rem
+ full: 9999px
+spacing:
+ unit: 4px
+ container-margin: 20px
+ gutter: 12px
+ stack-sm: 8px
+ stack-md: 24px
+ stack-lg: 48px
+---
+
+## Brand & Style
+The design system centers on a high-performance, dark-mode aesthetic tailored for real-time activity tracking. The brand personality is kinetic, precise, and undistracted. It utilizes a **Minimalist-Technical** style, stripping away non-essential chrome to focus entirely on telemetry and movement.
+
+The visual narrative is driven by a "HUD" (Heads-Up Display) concept: information is layered over the user's environment or activity maps using subtle glassmorphism and high-contrast data points. The emotional response should be one of focus and momentum, making the user feel like a precision instrument.
+
+## Colors
+The palette is dominated by **Pitch Black (#0A0A0B)** to preserve OLED battery life and minimize ocular strain during night activities.
+
+- **Primary (Electric Neon):** Used exclusively for active states, completion goals, and primary "Go" actions.
+- **Secondary (Cyan Telemetry):** Reserved for secondary data streams, such as cadence or heart rate, to provide visual separation from primary distance/time metrics.
+- **Surface Strategy:** Backgrounds use deep charcoal. Overlays use a semi-transparent version of the surface color with a 20px background blur to create the HUD effect.
+
+## Typography
+The typography system uses a functional split: **Sora** for UI headers and brand moments, **Inter** for standard interface text, and **JetBrains Mono** for all numerical data and telemetry.
+
+- **Data Legibility:** All numeric values must use the monospaced font to prevent "jumping" layouts during real-time updates.
+- **Visual Hierarchy:** Use `label-caps` for units (e.g., KM/H, BPM) placed immediately adjacent to large telemetry displays.
+- **Scale:** On mobile devices, telemetry data should prioritize width; if values exceed 5 digits, scale the font size down dynamically to fit the viewport width minus margins.
+
+## Layout & Spacing
+This design system employs a **Fluid HUD Grid**. Elements are anchored to the corners of the screen to keep the center clear for maps or camera feeds.
+
+- **Grid Model:** A 6-column grid for mobile, 12-column for desktop.
+- **Padding:** Use a generous 20px "Safe Zone" margin on all edges of the screen to ensure interactive elements are reachable and not clipped by hardware notches or rounded device corners.
+- **Density:** High density for data readouts (8px spacing between related metrics) and low density for navigation/settings (24px+ spacing) to prevent accidental taps during motion.
+
+## Elevation & Depth
+Depth is expressed through **translucency** rather than shadows.
+
+1. **Level 0 (Base):** Pure black (#0A0A0B).
+2. **Level 1 (Substrate):** Dark charcoal (#161618) with 0% transparency.
+3. **Level 2 (HUD Panels):** Surface color at 60% opacity with a `backdrop-filter: blur(20px)`.
+4. **Level 3 (Modals):** Surface color at 80% opacity with a subtle 1px inner border in a low-opacity white (10%) to define the edge against dark backgrounds.
+
+Shadows should be avoided entirely to maintain the "lightweight" digital feel.
+
+## Shapes
+The shape language is **Technical-Precision**.
+
+- **Corners:** Use 0.25rem (4px) as the base radius for all containers to maintain a sharp, engineered appearance.
+- **Interactive Elements:** Buttons and inputs follow the same 4px rule.
+- **Progress Indicators:** Use sharp, non-rounded ends for progress bars to emphasize a "segmental" or "digital" look.
+- **Iconography:** Use 2px stroke weights with squared-off ends to match the monospaced typographic theme.
+
+## Components
+
+### Buttons
+- **Primary:** Solid Electric Neon background with Black text. No rounding beyond the base 4px.
+- **Ghost:** 1px Neon border with transparent background. Text matches border color.
+- **Telemetry Toggle:** Small, square buttons using monospaced labels.
+
+### Cards & HUD Panels
+- All cards use the Level 2 Elevation (Glassmorphism).
+- Remove all drop shadows. Use a 1px stroke of #FFFFFF at 10% opacity for definition.
+
+### Data Inputs
+- Underlined style only (no bounding box) for a lighter footprint.
+- Active state changes the underline to the Primary Neon color.
+- Use JetBrains Mono for all numeric input fields.
+
+### Progress & Gauges
+- **Circular Gauges:** Use thin 4px strokes. The "track" should be 5% opacity white; the "fill" should be the Primary Neon.
+- **Pulse:** Active heart rate or GPS signals should use a "scanning" animation—a vertical line moving across the element—rather than a standard fade.
+
+### Navigation
+- A bottom-anchored, floating "Dock" with a glassmorphic background.
+- Active icons are highlighted with a single Neon pixel-dot underneath.
diff --git a/rippr-flutter-src/docs/design/stitch-export/screens/export-summary.md b/rippr-flutter-src/docs/design/stitch-export/screens/export-summary.md
new file mode 100644
index 0000000..37c0096
--- /dev/null
+++ b/rippr-flutter-src/docs/design/stitch-export/screens/export-summary.md
@@ -0,0 +1,50 @@
+# Rippr Project Export
+
+## Project Overview
+Rippr is a minimalist activity tracker designed for cyclists and runners, featuring an immersive map-centric interface, real-time telemetry, and streamlined route planning.
+
+## Design System: Modern Professional Dark
+**ID:** {{DATA:DESIGN_SYSTEM:DESIGN_SYSTEM_3}}
+
+### Theme Tokens
+- **Color Mode:** Dark
+- **Primary Color:** #1978e5 (Professional Blue)
+- **Typography:** Inter
+- **Roundness:** 8px (Round Eight)
+
+### Core Components
+- **TopAppBar:** Minimalist, often hidden for immersive map views.
+- **BottomNavBar:** 4-icon layout (Map, History, Plan, Settings) with a signature active indicator dot.
+- **Telemetry Cards:** Floating, semi-transparent modules (`bg-surface/60`) with `backdrop-blur-xl` and `white/10` borders.
+
+---
+
+## Screen Inventory
+
+### 1. Map HUD (Active Navigation)
+- **ID:** {{DATA:SCREEN:SCREEN_8}}
+- **Description:** Fullscreen map interface with floating telemetry cards at the top showing Speed, Avg Speed, Distance, and Time. Units are integrated inline (e.g., "24.5 km/h").
+- **Controls:** Detached Pause (Yellow) and Stop (Red) buttons positioned above the bottom navigation.
+
+### 2. Plan Screen (Ready state)
+- **ID:** {{DATA:SCREEN:SCREEN_7}}
+- **Description:** Clean map interface identical to the HUD but without telemetry or ride controls. Features a subtle floating tooltip: "Tap to drop a pin".
+
+### 3. Route Planning (Active Pins)
+- **ID:** {{DATA:SCREEN:SCREEN_5}}
+- **Description:** Secondary state of the Plan screen showing 5 dropped pins connected by a blue dotted line. A floating header displays total distance, estimated time, and pin count.
+
+### 4. Rides History
+- **ID:** {{DATA:SCREEN:SCREEN_6}}
+- **Description:** List of previous routes displayed as cards over a blurred live map background. Each card shows a mini-map preview, date, distance, time, and average speed.
+
+### 5. Settings / Profile
+- **ID:** {{DATA:SCREEN:SCREEN_4}}
+- **Description:** Structured list of user preferences, account details, and cloud sync options. Designed with clean hierarchy and professional dark mode styling.
+
+---
+
+## Technical Specifications
+- **Framework:** HTML5 / CSS3 (Tailwind CSS)
+- **Interactivity:** Vanilla JavaScript for map state and telemetry updates.
+- **Visual Effects:** Backdrop filters for UI depth and high-contrast vector paths for route visualization.
diff --git a/rippr-flutter-src/docs/design/stitch-export/screens/map-hud-dark.html b/rippr-flutter-src/docs/design/stitch-export/screens/map-hud-dark.html
new file mode 100644
index 0000000..92d1be4
--- /dev/null
+++ b/rippr-flutter-src/docs/design/stitch-export/screens/map-hud-dark.html
@@ -0,0 +1,317 @@
+
+
+
+
+
+RIPPR - Record Activity
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+map
+
+book
+
+route
+
+
+settings
+
+
+
+
+
+map
+ Map
+
+
+directions_bike
+ Rides
+
+
+route
+ Plan
+
+
+
+
\ No newline at end of file
diff --git a/rippr-flutter-src/docs/design/stitch-export/screens/map-hud-dark.png b/rippr-flutter-src/docs/design/stitch-export/screens/map-hud-dark.png
new file mode 100644
index 0000000..917c442
Binary files /dev/null and b/rippr-flutter-src/docs/design/stitch-export/screens/map-hud-dark.png differ
diff --git a/rippr-flutter-src/docs/design/stitch-export/screens/plan-screen-dark.html b/rippr-flutter-src/docs/design/stitch-export/screens/plan-screen-dark.html
new file mode 100644
index 0000000..8abe974
--- /dev/null
+++ b/rippr-flutter-src/docs/design/stitch-export/screens/plan-screen-dark.html
@@ -0,0 +1,336 @@
+
+
+
+
+
+RIPPR - Plan Route
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+location_on
+Tap to drop a pin
+
+
+
+
+
+
+map
+Map
+
+
+
+directions_bike
+Rides
+
+
+
+route
+Plan
+
+
+
+
+
+
\ No newline at end of file
diff --git a/rippr-flutter-src/docs/design/stitch-export/screens/plan-screen-dark.png b/rippr-flutter-src/docs/design/stitch-export/screens/plan-screen-dark.png
new file mode 100644
index 0000000..dabac36
Binary files /dev/null and b/rippr-flutter-src/docs/design/stitch-export/screens/plan-screen-dark.png differ
diff --git a/rippr-flutter-src/docs/design/stitch-export/screens/rides-history-dark.html b/rippr-flutter-src/docs/design/stitch-export/screens/rides-history-dark.html
new file mode 100644
index 0000000..3097c34
--- /dev/null
+++ b/rippr-flutter-src/docs/design/stitch-export/screens/rides-history-dark.html
@@ -0,0 +1,307 @@
+
+
+
+
+
+RIPPR - Rides
+
+
+
+
+
+
+
+
+
+
+
+
+
+search
+
+
+
+tune
+
+
+
+
+
Total Dist 482KM
+
Total Dist 482KM
+
Total Dist 482KM
+
Total Dist 482KM
+
+
+
+
+
+
+
+
+
+
+
+
Morning Circuit
+
Oct 24, 06:30 AM
+
+
+
+DISTANCE
+42.5 KM
+
+
+TIME
+1:45:22
+
+
+AVG SPD
+24.2 KPH
+
+
+
+
+
+
+
+
+
+
+
+
+
Hill Repeats
+
Oct 22, 17:15 PM
+
+
+
+DISTANCE
+28.1 KM
+
+
+TIME
+1:12:05
+
+
+AVG SPD
+23.4 KPH
+
+
+
+
+
+
+
+
+
+
+
+
+
Coastal Cruise
+
Oct 18, 08:00 AM
+
+
+
+DISTANCE
+85.4 KM
+
+
+TIME
+3:24:10
+
+
+AVG SPD
+25.1 KPH
+
+
+
+
+
+
+
+map book route settings
+
+
+
+map
+Map
+
+book Journal
+
+route
+Plan
+
+
+
+
\ No newline at end of file
diff --git a/rippr-flutter-src/docs/design/stitch-export/screens/rides-history-dark.png b/rippr-flutter-src/docs/design/stitch-export/screens/rides-history-dark.png
new file mode 100644
index 0000000..5afa2db
Binary files /dev/null and b/rippr-flutter-src/docs/design/stitch-export/screens/rides-history-dark.png differ
diff --git a/rippr-flutter-src/docs/design/stitch-export/screens/route-planning-dark.html b/rippr-flutter-src/docs/design/stitch-export/screens/route-planning-dark.html
new file mode 100644
index 0000000..0e504d4
--- /dev/null
+++ b/rippr-flutter-src/docs/design/stitch-export/screens/route-planning-dark.html
@@ -0,0 +1,275 @@
+
+
+
+
+
+RIPPR - Plan Route
+
+
+
+
+
+
+
+
+
+
+
+
+
+Distance
+12.4km
+
+
+
+Est. Time
+45m
+
+
+
+Pins
+5
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Start Route
+
+
+
+
+map
+
+
+book
+
+
+route
+
+
+settings
+
+
+
+
+
+
\ No newline at end of file
diff --git a/rippr-flutter-src/docs/design/stitch-export/screens/route-planning-dark.png b/rippr-flutter-src/docs/design/stitch-export/screens/route-planning-dark.png
new file mode 100644
index 0000000..ba43aab
Binary files /dev/null and b/rippr-flutter-src/docs/design/stitch-export/screens/route-planning-dark.png differ
diff --git a/rippr-flutter-src/docs/ui-redesign/README.md b/rippr-flutter-src/docs/ui-redesign/README.md
new file mode 100644
index 0000000..33b0e43
--- /dev/null
+++ b/rippr-flutter-src/docs/ui-redesign/README.md
@@ -0,0 +1,112 @@
+# UI redesign tickets
+
+Same shape as `docs/v3/`: one file per feature, Goal · Context · Design · Implementation ·
+Acceptance criteria · Tests · Risks · Out of scope. Written before implementing.
+
+Source material: `docs/design/stitch-export/` — four Stitch-generated screens (Map HUD,
+Plan, Route Planning, Rides History) plus the "Modern Professional Dark" design system
+actually used by all four. See that directory's `README.md` for how they were fetched and
+a correction of an earlier mislabeling.
+
+This is a **navigation-model change**, not a reskin: today's app is a stack rooted at
+Record, with Trips/Settings/Routes as pushed children. The new design is a persistent
+4-tab shell (Map / Rides / Plan / Settings) with the map visible, at some opacity, behind
+every tab — not just the Map tab. Sequenced so the riskiest, highest-blast-radius piece
+(the shell) lands first and `flutter test` stays green throughout, the same discipline
+v3 held to.
+
+## The tickets
+
+| # | Ticket | Size | Depends on | Status |
+|---|---|---|---|---|
+| [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 | Done |
+| [UI-03](UI-03-glass-component-kit.md) | Shared floating-glass component kit | M | UI-08 | Done |
+| [UI-04](UI-04-customizable-hud-widgets.md) | Customizable HUD telemetry widgets (drag, resize, visibility toggle) | L | UI-03 | Done |
+| [UI-05](UI-05-map-hud-record-screen.md) | Map HUD: Record screen redesign | M | UI-01, UI-03, UI-04 | Done |
+| [UI-06](UI-06-plan-and-route-planning.md) | Plan & Route Planning redesign | M | UI-01, UI-03 | Done |
+| [UI-07](UI-07-rides-history.md) | Rides History redesign | M | UI-01, UI-03 | Done |
+| [UI-08](UI-08-theme-modern-professional-dark.md) | Theme migration to Modern Professional Dark | S | — | Done |
+| [UI-09](UI-09-monochrome-dark-map-tiles.md) | Monochrome dark map tiles | S | — | Done |
+
+## Dependencies
+
+```
+UI-01 ──┬──► UI-02
+ ├──► UI-05
+ ├──► UI-06
+ └──► UI-07
+UI-08 ──► UI-03 ──┬──► UI-04 ──► UI-05
+ ├──► UI-06
+ └──► UI-07
+UI-09 ──┬──► UI-05
+ ├──► UI-06
+ └──► UI-07
+
+no dependencies: UI-01 · UI-08 · UI-09
+```
+
+## Suggested order
+
+1. **UI-01** — the shell. Highest blast radius (touches every screen's entry point), so
+ get it merged and green before any visual work starts.
+2. **UI-08** — theme. Every screen ticket after this needs the palette settled once, not
+ patched per-screen. Supersedes V3-16's safety-orange direction — see that ticket's
+ note.
+3. **UI-09** — dark map tiles. Small, no dependencies, and every screen with a map looks
+ wrong without it regardless of how correct the HUD chrome on top is.
+4. **UI-02** — skeleton map. Small, and exercises the background-map plumbing UI-01 just
+ built while it's fresh.
+5. **UI-03** — the component kit (`GlassPanel`, `FloatingPill`, `PulsingLocationMarker`).
+ Build once, consumed by every screen ticket.
+6. **UI-04** — the customizable HUD widget system (drag/resize/persist layout, a
+ visibility toggle in Settings). This is its own ticket, not folded into UI-05, because
+ drag-and-resize-with-persisted-layout is a genuinely separate piece of engineering
+ from "redesign the record screen" — a small widget-layout editor in miniature.
+7. **UI-05 / UI-06 / UI-07** — the three screen redesigns, in any order once everything
+ above is done. Each is independently shippable.
+
+## Things named explicitly by the person who commissioned this, not inferred from the mockups
+
+- **The map is active on every tab**, including Rides History — blurred/dimmed behind
+ the content cards so it doesn't compete for attention, not hidden. This wasn't visible
+ in the Stitch export (Rides History's HTML uses a static blurred background image, not
+ a live map) — it's a deliberate product decision layered on top of the mockups.
+- **No header, anywhere, ever** — the stated reason is screen real estate: the point of
+ the redesign is to show as much map as possible, and a persistent bottom nav plus zero
+ header chrome is what buys that space back.
+- **A no-connection skeleton map** (UI-02), rather than a blank or broken map, since the
+ map is now a permanent background presence on every tab rather than something opened
+ deliberately.
+- **The telemetry HUD widgets are user-customizable** (UI-04): draggable and resizable by
+ the rider, and which metrics show up live at all is a Settings toggle. The Stitch
+ mockup's tap-to-expand interaction is a fixed layout with one temporary focus state;
+ this goes further — the rider chooses the layout and which stats exist on screen at
+ all, and it persists.
+- **The map itself is monochrome dark** (UI-09) — today's default OSM tan/cream tiles are
+ nothing like the mockups regardless of how correct the HUD chrome on top is. A real
+ tile-provider decision, not a color filter thrown on as an afterthought — see that
+ ticket for the trade-off against just filtering OSM's existing tiles.
+
+## The Map screen is the design north star
+
+The four Stitch exports don't agree with each other on styling — expected, since each
+was a separate prompt and the AI generation drifted between them. **Map HUD is the
+canonical reference** for color usage, icon choice/fill-state, spacing, and component
+pattern across the whole redesign. Where another screen's mockup disagrees with Map
+HUD on any of those, Map HUD wins.
+
+Plan and Route Planning are correct **in principle** — fullscreen map, floating pill
+summaries, pin-drop interaction, no header — but their specific styling (icon fill
+states, exact nav bar treatment, some color choices) drifted from Map HUD across
+prompts and should be restyled to match it, not copied as-is. UI-06 says this again at
+the point where it matters.
+
+## What the mockups got generated inconsistently — don't copy as intentional
+
+- Bottom nav shows 4 icons on Map HUD and Rides History, only 3 (no Settings) on the Plan
+ screen. Build one nav bar with 4 destinations, always.
+- Rides History's summary row is 4 copies of the same "Total Dist" card. Real, distinct
+ summary stats are UI-06's job to define.
+- Route Planning's bottom nav highlights "Rides" as active while the screen shown is
+ Plan — a generation slip, not a deliberate cross-tab indicator.
diff --git a/rippr-flutter-src/docs/ui-redesign/UI-01-tab-shell-background-map.md b/rippr-flutter-src/docs/ui-redesign/UI-01-tab-shell-background-map.md
new file mode 100644
index 0000000..3398d4a
--- /dev/null
+++ b/rippr-flutter-src/docs/ui-redesign/UI-01-tab-shell-background-map.md
@@ -0,0 +1,182 @@
+# 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 `` 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.
diff --git a/rippr-flutter-src/docs/ui-redesign/UI-02-offline-skeleton-map.md b/rippr-flutter-src/docs/ui-redesign/UI-02-offline-skeleton-map.md
new file mode 100644
index 0000000..81ad726
--- /dev/null
+++ b/rippr-flutter-src/docs/ui-redesign/UI-02-offline-skeleton-map.md
@@ -0,0 +1,148 @@
+# UI-02 — Offline / no-connection skeleton map
+
+**Depends on** UI-01 · **Size** S · **Status** Done
+
+## Goal
+When the map has no tiles to show — no cached tiles for the current view and no network
+to fetch fresh ones — show an animated skeleton in place of a broken or blank map, on
+every tab, since UI-01 makes the map a permanent background presence rather than
+something a rider opts into per-screen.
+
+## Context
+By explicit instruction: *"If we do not have connection for the map, we should just show
+an animated skeleton map so it doesn't look weird."* Once UI-01 ships, the map is always
+on screen somewhere — a blank grey rectangle or a grid of broken-image icons behind every
+tab, all the time, on a phone with no signal, is a much worse look than it was when the
+map only appeared on the one screen a rider explicitly opened.
+
+This composes with existing infrastructure rather than duplicating it:
+`CachedTileProvider` (V3-11) already tries the offline tile cache before the network, so
+"no connection" in practice means *both* the cache and the network failed for the tiles
+currently in view.
+
+## Design
+Detect at the `CachedTileProvider`/`TileLayer` level, not by asking the OS for
+connectivity state directly — a connectivity API can report "online" while the actual
+tile fetch still times out (captive portal, degraded connection), and the tile fetch's
+own success/failure is the only thing that actually matters here.
+
+- Track a rolling failure count for in-flight tile fetches (cache miss **and** network
+ fetch failed) over a short window. Crossing a small threshold (e.g. 3 consecutive
+ failures) flips the map into skeleton mode; a single successful fetch flips it back.
+- Skeleton mode replaces the `TileLayer` with a static, non-fetching placeholder — never
+ a widget that itself keeps trying and failing in a loop.
+- Placeholder look: a shimmer/gradient-sweep animation over a neutral grid pattern
+ (reuse the "technical grid overlay" treatment already present in the Stitch exports'
+ `map-grid-overlay` CSS — 40px faint grid lines — as the static base under the shimmer),
+ not a spinner. A map-shaped thing that is clearly still loading, not an error state.
+- Recheck periodically (not on every frame) so the map recovers automatically the moment
+ connectivity returns, without the rider having to do anything.
+
+## Implementation
+1. Wrap `CachedTileProvider` (or add a sibling) that reports fetch outcomes to a small
+ `MapConnectivityState` — rolling window, threshold, debounced flip.
+2. `SkeletonMapLayer` widget: the grid + shimmer, sized to fill the same space a
+ `TileLayer` would.
+3. `RideMap`/`PersistentMapBackground` (from UI-01) watches `MapConnectivityState` and
+ swaps `TileLayer` for `SkeletonMapLayer` when in skeleton mode — markers/polylines
+ (the rider's own path, planned route) keep rendering on top either way, since those
+ come from local data, not tiles.
+4. A cap on retry frequency while in skeleton mode — this must not turn into a tile-
+ fetch retry loop that itself violates OSM's usage policy the way V3-11's design
+ explicitly guards against.
+
+## Acceptance criteria
+- [ ] With no cached tiles and no network, the map area shows an animated skeleton, not
+ blank space or broken-image icons
+- [ ] Recovery is automatic: once tiles become fetchable again, the skeleton is replaced
+ without user action
+- [ ] The rider's live position/path and any planned route still render on top of the
+ skeleton (only the base tiles are missing)
+- [ ] Skeleton mode does not itself hammer the tile server with retries
+- [ ] Behaves correctly across all four tabs (UI-01's shared background map), not just
+ the Map tab
+
+## Tests
+- Unit: the failure-count/threshold/debounce logic that decides skeleton vs. live,
+ exercised with fakes (no real network) — mirrors V3-11's `tile_downloader_test.dart`
+ pattern of injecting fake fetch outcomes
+- Widget: `SkeletonMapLayer` renders when connectivity state says offline; `TileLayer`
+ renders when it says online
+- Widget: markers/polylines still render in skeleton mode
+
+## Risks
+- Flapping between skeleton and live on a marginal connection would be worse than
+ either state alone — the debounce window is what prevents this; tune it based on real
+ behavior, not a guess (echoes V3-13's "measure, don't tune blind" discipline).
+- Must not let skeleton-mode retries become the kind of bulk/rapid tile request V3-11's
+ design explicitly exists to avoid.
+
+## Out of scope
+General offline-mode UX beyond the map itself (e.g. graying out map-dependent buttons).
+Manual retry controls — automatic recovery is the whole point.
+
+## Outcome
+
+Built exactly to the ticket's Design section. `lib/src/tiles/map_connectivity.dart`'s
+`MapConnectivityState` tracks consecutive tile-fetch failures (`skeletonFailureThreshold
+= 3`), flips to skeleton mode on the threshold, and recovers instantly on a single
+success — either an ordinary fetch succeeding (while still below threshold, resetting
+the run) or, once already in skeleton mode, a periodic single-tile probe
+(`skeletonProbeInterval = 15s`) succeeding. The probe is a deliberate design choice: once
+skeleton mode starts, `RideMap`/the route planner fully unmount their `TileLayer` (no
+ongoing requests at all, per the ticket's "never a widget that itself keeps trying and
+failing in a loop"), so nothing generates ordinary fetch outcomes to recover from — the
+probe exists specifically to test the water on the map's behalf, at a low, fixed
+cadence, using one deterministic tile (the zoom-0 whole-world overview) rather than
+whatever happened to be in view.
+
+`CachedTileProvider` (`lib/src/tiles/cached_tile_provider.dart`) now takes an optional
+`MapConnectivityState? connectivity` and reports outcomes around its network fetch only
+— a cache hit is silently skipped since it says nothing about current connectivity
+either way, matching the ticket's "cache miss *and* network fetch failed" definition of
+a failure exactly. `mapConnectivityProvider` (`app/providers.dart`) is the one shared
+instance every map watches, per the class's own reasoning: connectivity is a fact about
+the network, not about which map widget happens to be on screen.
+
+`SkeletonMapLayer` (`lib/src/ui/components/skeleton_map_layer.dart`) is the 40px faint
+grid (reusing the Stitch exports' `.map-grid-overlay` treatment directly, `rgba(255,255,
+255,0.03-0.05)` → `Color(0x0DFFFFFF)`) with a `LinearGradient` shimmer sweeping across it
+on a 1800ms repeating cycle, painted via `ShaderMask`/`CustomPainter` rather than a
+translated widget so it doesn't need to know its own pixel size. `RideMap` gained a
+`skeletonMode` bool (a plain constructor param, not a `ConsumerWidget` watch — consistent
+with how `tileProvider` is already handed down rather than looked up) that swaps
+`TileLayer` for `SkeletonMapLayer` while leaving `PolylineLayer`/markers untouched, since
+those come from local data. The route planner manages its own independent `FlutterMap`
+and does the same swap itself, watching `mapConnectivityProvider` directly.
+
+**Refactor along the way:** moved `tileUrlTemplate`/`tileSubdomains`/`tileMaxNativeZoom`/
+`tileUserAgent` out of `ride_map.dart` into a new `lib/src/tiles/tile_config.dart`, and
+had `ride_map.dart` re-export them for its existing importers. `app/providers.dart` (the
+composition root) needed these constants for the connectivity probe's URL, and importing
+a `ui/` file from the `app/` layer would have been a real layering violation the tiles
+layer itself doesn't have a reason to accept — better to give the constants a home in
+the layer they actually describe (tile fetching) than to route around the smell.
+
+**Bug caught by the first real test run:** `mapConnectivityProvider`'s initial
+implementation called `ref.onDispose(state.dispose)` in addition to
+`ChangeNotifierProvider`'s own automatic disposal of the notifier it returns — a
+double-dispose that threw "A MapConnectivityState was used after being disposed" and
+failed five `route_planner_screen_test.dart` tests outright. Fixed by removing the
+redundant manual dispose call.
+
+**Tests:** `flutter analyze` clean. `flutter test` green at 328 tests (316 + 3 new
+`ride_map_test.dart` skeleton-mode cases + 9 new `map_connectivity_test.dart` unit cases
+covering threshold behavior, the reset-on-success case, notification-only-on-actual-
+change, and the probe loop's recovery and its refusal to run at all while still live).
+
+**Android emulator verification** (`Medium_Phone_API_35`) — a full end-to-end real-
+network test, not just widget tests: cut the emulator's actual network
+(`svc wifi disable` + `svc data disable`, confirmed via a failing `ping`), cleared the
+offline tile cache via Settings, and navigated to an uncached view (the route planner's
+default zoom-14 view, which the shell's already-cached Calgary-area background tiles
+didn't cover). After the threshold of real failed fetches, the skeleton grid rendered
+correctly — clearly a "still loading" placeholder, not blank space or broken-image
+icons. Re-enabled the network and confirmed automatic recovery within one probe interval
+with no user action, exactly per the acceptance criteria. The shared shell background
+map (already displaying previously-cached tiles from memory) was unaffected throughout,
+as expected since it never needed a fresh fetch during the test window.
diff --git a/rippr-flutter-src/docs/ui-redesign/UI-03-glass-component-kit.md b/rippr-flutter-src/docs/ui-redesign/UI-03-glass-component-kit.md
new file mode 100644
index 0000000..7ebd040
--- /dev/null
+++ b/rippr-flutter-src/docs/ui-redesign/UI-03-glass-component-kit.md
@@ -0,0 +1,137 @@
+# UI-03 — Shared floating-glass component kit
+
+**Depends on** UI-08 (theme tokens) · **Size** M · **Status** Done
+
+## Goal
+Build once, use everywhere: the small set of visual primitives every Stitch screen
+reuses, so UI-04/05/06/07 compose them instead of each reinventing blur-panel styling.
+
+## Context
+All four Stitch exports lean on the same handful of visual patterns repeatedly:
+glassmorphic panels (`bg-surface/80 backdrop-blur-xl border border-white/10`), a floating
+centered pill for a compact stat summary, and a pulsing/radar-style location marker.
+Building these once means every consuming screen ticket is "compose these," not
+"reimplement blur and border styling a fourth time."
+
+## Design
+Three widgets, all pure presentation (no data-fetching, no business logic):
+
+**`GlassPanel`** — the workhorse. `BackdropFilter` + `ImageFilter.blur` inside a
+`ClipRRect`, a translucent surface-color fill, a 1px low-opacity border. Takes a `child`
+and behaves like a styled `Container`. Every floating card, tooltip, and control in the
+redesign is one of these underneath.
+
+**`FloatingPill`** — a `GlassPanel` shaped as a horizontally-centered, rounded-full
+capsule holding a row of labeled stat columns (see Route Planning's Distance/Est. Time/
+Pins header). Takes a list of `(label, value)` pairs and lays them out with vertical
+dividers between them, matching the Stitch export exactly.
+
+**`PulsingLocationMarker`** — the expanding-ring-plus-glow-dot from the Plan/Route
+Planning exports. A `CustomPainter` or layered `AnimatedContainer`s driving an
+`AnimationController` in a loop (scale 0.8→1.0, opacity 0.8→0, repeating) — respect
+`MediaQuery.disableAnimations`/`prefers-reduced-motion` equivalent by falling back to a
+static dot when animations are disabled system-wide.
+
+## Implementation
+1. `lib/src/ui/components/glass_panel.dart` — `GlassPanel`, with blur radius, fill
+ opacity, and border opacity as named constants (not magic numbers scattered per call
+ site), sourced from UI-08's theme tokens.
+2. `lib/src/ui/components/floating_pill.dart` — `FloatingPill`, built on `GlassPanel`.
+3. `lib/src/ui/components/pulsing_location_marker.dart` — `PulsingLocationMarker`,
+ wrapping its `AnimationController` lifecycle correctly (dispose on unmount, same
+ discipline as `RideMap`'s existing lifecycle observer from V3-04).
+4. Widget tests render each in isolation against both themes (if UI-08 keeps a light
+ variant) to catch a black-on-black-style contrast regression early, the same
+ discipline V3-16 established.
+
+## Acceptance criteria
+- [ ] `GlassPanel` renders a blurred, bordered, translucent container matching the
+ Stitch reference visually
+- [ ] `FloatingPill` renders an arbitrary number of stat columns with dividers between
+ them, not hardcoded to exactly three
+- [ ] `PulsingLocationMarker` animates continuously without leaking its
+ `AnimationController` across widget rebuilds or disposal
+- [ ] Reduced-motion setting is respected by `PulsingLocationMarker`
+
+## Tests
+- Widget: each component renders with representative content and takes a screenshot-
+ comparable snapshot of its structure (no golden files per V3-16's precedent — assert
+ structure/color, not pixels)
+- Widget: `PulsingLocationMarker`'s animation controller is disposed when the widget is
+ removed from the tree (a `flutter_test` pending-timer/ticker check, mirroring the
+ Drift stream-query keep-alive pattern already documented in `widget_test.dart`)
+
+## Risks
+- `BackdropFilter` is one of the more expensive Flutter widgets to composite; stacking
+ several `GlassPanel`s over a live, animating map (per UI-01/UI-02) could visibly cost
+ frame time on lower-end devices. Worth a real-device check once UI-05 assembles them
+ together, not just in isolation.
+
+## Out of scope
+The customizable drag/resize telemetry widgets (UI-04) — those consume `GlassPanel` as
+their visual shell but the interaction logic is a separate, larger ticket.
+
+## Outcome
+
+All three values (blur radius, opacities, dimensions, colors) were lifted directly from
+the Route Planning export's own glass treatment and `pulse-ring` keyframes rather than
+guessed — `docs/design/stitch-export/screens/route-planning-dark.html`'s `bg-surface/90
+backdrop-blur-xl border border-outline-variant/30` and its `@keyframes pulse-ring`
+(scale 0.8→1.0, opacity 0.8→0, eased) map directly onto `GlassPanel.blurSigma`/
+`fillOpacity`/`borderOpacity` and `PulsingLocationMarker`'s animation curve.
+
+`GlassPanel` (`lib/src/ui/components/glass_panel.dart`) is `ClipRRect > BackdropFilter >
+DecoratedBox`, taking `child`/`borderRadius`/`padding` and reading its fill/border colors
+from the theme (`colors.surface`, `colors.outlineVariant` — the latter newly added to
+`ripprColors` in `theme.dart` for this ticket, since UI-08 hadn't needed it before now).
+`FloatingPill` (`floating_pill.dart`) is a `GlassPanel` shaped as a stadium capsule
+around a `Row` of `PillStat` columns with 1px dividers between them — genuinely
+arbitrary-length, not hardcoded to three, verified by a 4-stat test case.
+`PulsingLocationMarker` (`pulsing_location_marker.dart`) layers a static faint ring, an
+animated expanding-and-fading ring, and a glowing center dot; it checks
+`MediaQuery.disableAnimations` in `didChangeDependencies` (not `initState`, which
+Flutter forbids for inherited-widget lookups) and stops the controller entirely when
+reduced motion is requested, falling back to a static dot.
+
+**Test-writing bug caught and fixed:** the first version of the "legible in both
+themes" test pumped `ripprTheme()` then `ripprMountedTheme()` sequentially in a single
+`testWidgets` loop. Because both produced an identically-shaped widget tree
+(`MaterialApp > Scaffold > GlassPanel > Text`), Flutter reused the first pump's elements
+across the second `pumpWidget` call rather than rebuilding fresh, so the second
+iteration silently re-read the *first* theme's already-resolved paint color — the test
+would have falsely failed for a real theme-following bug and, worse, could have
+falsely passed one due to reading stale data. Split into two independent `testWidgets`
+blocks instead, which is the correct way to exercise two themes against the same
+component.
+
+**Tests:** `flutter analyze` clean. `flutter test` green at 336 tests (328 + 8 new in
+`test/glass_component_kit_test.dart`): `GlassPanel`'s blur/translucency/border
+structure and its resolved-paint-color contrast in both themes; `FloatingPill`'s
+divider count scaling with stat count (and the zero-divider single-stat case);
+`PulsingLocationMarker`'s continuous animation, correct `AnimationController` disposal
+on removal (caught automatically by `flutter_test`'s own ticker-leak check, not a
+manual timer assertion), and the reduced-motion fallback.
+
+**Android emulator verification**: no consuming screen exists yet (that's UI-04/05/06),
+so verification used a throwaway preview entry point
+(`lib/main_ui03_preview.dart`, deleted after use) rendering all three components over a
+map-colored gradient background. All three matched the Stitch reference's look: the
+`FloatingPill`'s blur/translucency/dividers/blue values rendered crisply; the
+`PulsingLocationMarker`'s glow and ring were visible and animating. One red herring
+during this check: `GlassPanel`'s content briefly appeared to render illegibly when the
+preview used `Theme.of(context).textTheme.headlineSmall` for a heading, despite that
+style's `color` property independently verified (via a debug test) to already be the
+correct ink value with full alpha. Switching to an explicit inline `TextStyle` (no
+ambient text-theme role) rendered crisply instead. This was isolated to the scratch
+preview file, not `GlassPanel` itself — the shipped components' own tests all render
+content through explicit styles or the already-verified `bodyMedium` role, the same
+discipline every real screen in this codebase already follows — but it's worth flagging
+as a `headlineSmall`-specific rendering quirk to watch for if UI-05/06 lean on that
+particular text-theme role for real headings.
+
+## Risks note (revisited)
+
+The ticket's own risk — `BackdropFilter` compositing cost when several `GlassPanel`s
+stack over a live, animating map — was not measurable in this ticket's isolated preview
+(no map, no stacking). Deferred to UI-05, as the ticket itself anticipated, where the
+Record screen actually assembles multiple glass panels over `RideMap`.
diff --git a/rippr-flutter-src/docs/ui-redesign/UI-04-customizable-hud-widgets.md b/rippr-flutter-src/docs/ui-redesign/UI-04-customizable-hud-widgets.md
new file mode 100644
index 0000000..e528e5b
--- /dev/null
+++ b/rippr-flutter-src/docs/ui-redesign/UI-04-customizable-hud-widgets.md
@@ -0,0 +1,197 @@
+# UI-04 — Customizable HUD telemetry widgets
+
+**Depends on** UI-03 · **Size** L · **Status** Done
+
+## Goal
+Let the rider drag each floating telemetry widget (Speed, Distance, etc.) to wherever
+they want it on screen, resize it, and choose in Settings which metrics appear live at
+all — a persisted, personal HUD layout rather than a fixed one.
+
+## Context
+The Stitch Map HUD mockup has a fixed horizontal row of four telemetry cards with one
+interaction: tap a card to temporarily expand it, tap again (or tap another) to collapse
+it back. That's a reasonable default, but by explicit instruction the actual goal is
+further than that: **the rider owns the layout.** Hold-and-drag to reposition, resize by
+dragging a handle, and a Settings screen listing every available metric with a toggle for
+whether it's shown at all. This supersedes the mockup's tap-to-expand interaction rather
+than coexisting with it — see Design.
+
+## Design
+
+**Data model.** A `HudWidgetLayout` per metric:
+```
+{ metricId, x, y, width, height, visible }
+```
+`x`/`y`/`width`/`height` stored as fractions of the available HUD area (0.0-1.0), not
+absolute pixels, so a layout saved on one device/orientation still makes sense on
+another. `metricId` is a stable enum (`speed`, `avgSpeed`, `distance`, `elapsedTime`,
+`maxSpeed`, `elevationGain`, ...) — the same set `RecordUiState`/`Trip` already expose,
+not a new data source.
+
+**Available metrics vs. visible metrics.** Every metric the app already tracks is a
+candidate; `visible` (toggled in Settings, see below) controls whether it currently has a
+HUD widget at all. A metric toggled off entirely has no position/size to speak of until
+turned back on, at which point it gets a sane default slot (not wherever it happened to
+be last, unless a position was already saved).
+
+**Interaction:**
+- **Move:** long-press-and-drag anywhere on a widget's `GlassPanel` body. A brief haptic
+ and a subtle scale-up on press-start signal "this is now grabbable," matching the
+ weight of a real physical action rather than an accidental tap.
+- **Resize:** a small drag handle in one corner, visible only while a widget is in "edit
+ mode" (see below), not in every-day use — a resize handle sitting on screen permanently
+ during a live ride is visual noise the rider doesn't need mid-ride.
+- **Edit mode:** entered explicitly (e.g. a long-press anywhere on the HUD area that
+ isn't a specific widget, or a dedicated "Edit HUD" toggle from Settings/a long-press on
+ empty HUD space) rather than every telemetry widget always being draggable — an
+ always-draggable widget risks an accidental drag mid-ride when the rider meant to tap
+ it for something else. Exiting edit mode (tap "Done", or tap empty space) persists the
+ layout.
+- **Constraints:** every widget clamps to stay fully within the safe HUD area on drag/
+ resize end — never under the bottom nav bar, never off-screen, never smaller than a
+ legibility floor (the number must stay readable) or larger than some sane ceiling.
+
+**Settings integration.** A new "Live HUD stats" section: a checkbox/switch per available
+metric, controlling `visible`. Order in the list is stable (doesn't reflect current HUD
+position) so it's easy to scan.
+
+**Persistence.** One `Config` field (JSON-encoded map of `metricId` → `HudWidgetLayout`),
+same pattern as every other `Config` preference in this app — see `mountedMode`,
+`crashReportingEnabled` for the shape to follow.
+
+## Implementation
+1. `HudWidgetLayout` model + JSON encode/decode, with defaults for every known metric
+ (a sensible starting grid, e.g. the Stitch mockup's horizontal row) so a fresh install
+ has a working, if plain, HUD before the rider customizes anything.
+2. `Config.hudLayout` getter/setter, following the established `Config` pattern.
+3. `HudLayoutController` (or a `Riverpod` `StateNotifier`) holding the current layout in
+ memory, seeded from `Config`, written back to `Config` on every edit-mode exit — not
+ on every drag frame, to avoid hammering `SharedPreferences` mid-drag.
+4. `DraggableResizableHudWidget`: wraps a telemetry `GlassPanel`, handles the long-press-
+ to-grab gesture, the resize handle, and the clamp-on-release logic.
+5. `HudEditOverlay`: the edit-mode chrome (resize handles, a "Done" affordance, maybe a
+ subtle grid/snap guide) shown only while editing.
+6. Settings section: "Live HUD stats," one row per metric with a switch bound to
+ `visible`.
+
+## Acceptance criteria
+- [ ] A telemetry widget can be dragged to a new position and the new position survives
+ an app restart
+- [ ] A telemetry widget can be resized within sane min/max bounds
+- [ ] Dragging or resizing never leaves a widget partially off-screen or behind the nav
+ bar
+- [ ] Toggling a metric off in Settings removes its HUD widget immediately; toggling it
+ back on restores it (at its last saved position if one exists, otherwise a default)
+- [ ] Outside of edit mode, a normal tap on a telemetry widget does not move it
+ (no accidental drags during a ride)
+- [ ] Layout is per-install (`Config`), not per-ride
+
+## Tests
+- Unit: `HudWidgetLayout` JSON round-trips exactly; unknown/missing metric ids on load
+ fall back to defaults rather than crashing
+- Unit: clamp logic — a drag/resize ending outside allowed bounds is corrected to the
+ nearest valid position/size, exercised with fixed geometry inputs (no real gestures
+ needed to test the math)
+- Widget: drag gesture moves a widget and the moved position is what gets persisted
+ (simulate via `TestGesture`, matching how other drag interactions in this codebase are
+ tested)
+- Widget: toggling a metric's Settings switch adds/removes its HUD widget
+- Widget: a tap outside edit mode does not trigger a move
+
+## Risks
+- **Scope creep toward a general-purpose layout editor.** Keep the interaction minimal:
+ drag, resize, toggle visibility. No z-ordering, no custom widget shapes, no per-metric
+ color customization — those are separate future tickets if wanted, not this one.
+- Persisting on every drag frame would thrash `SharedPreferences`; persist only on
+ edit-mode exit, keep in-memory state authoritative during an active drag.
+- This ticket removes the Stitch mockup's tap-to-expand interaction rather than layering
+ on top of it — a widget the rider has manually resized should not also silently resize
+ itself on tap, which would fight the rider's own choice.
+
+## Out of scope
+Z-ordering/overlap resolution between widgets (widgets simply clamp to stay on-screen;
+overlapping each other is the rider's own choice to avoid). Per-metric colour
+customization. Sharing/exporting a HUD layout between devices.
+
+## Outcome
+
+Built the full generic infrastructure the ticket scopes, deliberately stopping short of
+wiring it into the real Record screen -- that assembly (real metric values from
+`RecordUiState`, replacing the fixed stats card) is UI-05's job, per the dependency
+graph. `HudMetric` (`lib/src/hud/hud_metric.dart`) is the stable 8-value enum;
+critically, the first four are ordered to match the Stitch mockup's fixed row exactly,
+since `HudWidgetLayout.defaultFor` derives both default grid position and which metrics
+start visible directly from enum index.
+
+**Correction (UI-05):** this ticket originally ordered those first four as
+`speed, distance, elapsedTime, maxSpeed` -- a guess made without having read the actual
+Map HUD mockup HTML closely yet. Implementing UI-05 against that same HTML directly
+showed the real fixed row is Speed/**Avg Speed**/Dist/Time. The enum was reordered to
+`speed, avgSpeed, distance, elapsedTime, maxSpeed, movingTime, elevationGain,
+pointsCaptured` and every test here that had asserted the old order was updated
+alongside it -- see UI-05's own Outcome for the full account.
+
+`HudWidgetLayout` (`hud_widget_layout.dart`) holds fractional `x/y/width/height` +
+`visible`, with `clamped()` enforcing a legibility floor and sane ceiling (size clamped
+first, then position re-clamped against the now-bounded size, so a resize that would
+push a widget off-screen shrinks it rather than silently relocating it) and
+`fromJson`/`defaultFor` degrading to sane defaults on any malformed or missing data
+rather than crashing Settings on launch. `Config.hudLayout`/`setHudLayout`
+(`config/config.dart`) follow the exact JSON-map-of-a-stable-key pattern already
+established there. `HudLayoutController` (a `StateNotifier`, `hud_layout_controller.dart`)
+holds the authoritative in-memory layout during an edit session and only calls
+`Config.setHudLayout` on `persist()` -- never per drag frame, the ticket's own named
+risk.
+
+`DraggableResizableHudWidget` and `HudEditOverlay` (`lib/src/ui/components/`) are the
+interactive pieces. A widget only attaches a drag/resize `GestureDetector` at all when
+`editing` is true -- "a normal tap never moves a widget outside edit mode" is guaranteed
+structurally by the absence of a recognizer, not by an internal flag a future edit could
+weaken. Edit mode itself is entered by a long-press on *empty* HUD space and exited by
+"Done" or a tap on empty space, both handled by `HudEditOverlay`'s own background
+`GestureDetector`.
+
+**Bug found and fixed during testing, not anticipated by the plan:** outside edit mode,
+`DraggableResizableHudWidget` initially attached no gesture detector at all, meaning a
+long-press *on a widget* was free to bubble up through the gesture arena to
+`HudEditOverlay`'s background long-press handler and wrongly enter edit mode from a
+touch that landed on a specific widget, not the empty area the design explicitly calls
+for ("a long-press anywhere on the HUD area *that isn't a specific widget*"). Fixed by
+giving the non-editing state a no-op `GestureDetector(onLongPress: () {})` -- an inner
+recognizer of the same gesture type wins the arena over the outer one, absorbing the
+press instead of letting it propagate. Caught by a widget test that intentionally
+dragged directly on a widget while not editing and asserted edit mode never engaged.
+
+**Tests:** `flutter analyze` clean. `flutter test` green at 365 tests (336 + 29 new,
+across `hud_widget_layout_test.dart`, `hud_layout_controller_test.dart`,
+`hud_edit_overlay_test.dart`, and additions to `config_test.dart`/
+`settings_screen_test.dart`) -- covering JSON round-trips and malformed-data fallback,
+every clamp edge case with fixed geometry (no gestures needed), the controller's
+in-memory-until-persist discipline, `TestGesture`-simulated long-press-drag actually
+moving and persisting a widget, and the Settings toggle writing through to `Config`
+immediately. One pre-existing-test collateral fix: adding 8 new `SwitchListTile`s
+pushed everything after them below the test viewport's initial fold (a plain
+`ListView(children:)` still lazily builds via a sliver, same as `.builder` -- an
+assumption several existing tests unknowingly depended on). Moved the new section to
+the very end of the list (after "About") so no earlier section's position changed, and
+added `scrollUntilVisible` to the two new tests that need to reach it.
+
+**Android emulator verification** (`Medium_Phone_API_35`): used a throwaway preview
+entry point (`lib/main_ui04_preview.dart`, deleted after use) since no consuming screen
+exists yet. Confirmed on-device: the default four-card row renders exactly like the
+mockup; a long-press on a widget does nothing (the arena-fix above); a long-press on
+empty space enters edit mode, showing every resize handle and a "Done" pill; dragging a
+resize handle (a plain pan, not gated by long-press, so directly reproducible via `adb
+input swipe`) visibly grows a widget; tapping "Done" hides the edit chrome and keeps the
+new size. **Full end-to-end persistence was verified across a real process restart**,
+not just via the widget-test's in-memory assertions: resized Speed, tapped Done,
+force-stopped the app, relaunched it fresh, and the enlarged Speed widget was still
+enlarged. The long-press-*then*-drag move gesture itself could not be reproduced via
+`adb input swipe` (its linear interpolation moves throughout the whole gesture rather
+than holding still for the ~500ms long-press window first, so Flutter's arena resolves
+it as a rejected pan rather than a recognized long-press) -- that exact interaction is
+what the `TestGesture`-based widget test (which holds the pointer down, waits out
+`kLongPressTimeout`, then moves) verifies precisely, and is trusted as the ground truth
+for that specific gesture. Also confirmed via the real (non-preview) app that the new
+Settings section renders correctly alongside every existing section and that toggling
+"Average speed" flips its switch immediately.
diff --git a/rippr-flutter-src/docs/ui-redesign/UI-05-map-hud-record-screen.md b/rippr-flutter-src/docs/ui-redesign/UI-05-map-hud-record-screen.md
new file mode 100644
index 0000000..ddd8eb2
--- /dev/null
+++ b/rippr-flutter-src/docs/ui-redesign/UI-05-map-hud-record-screen.md
@@ -0,0 +1,169 @@
+# UI-05 — Map HUD: Record screen redesign
+
+**Depends on** UI-01, UI-03, UI-04 · **Size** M · **Status** Done
+
+## Goal
+Rebuild the Record screen as the fullscreen Map HUD from the Stitch export — this screen
+is the **design north star** for the whole redesign (see `README.md`): every other
+screen ticket restyles toward what this one establishes, not the other way around.
+
+## Context
+Today's Record screen is a scrolling `Column`: a 220px map card, a stats `Card` below it,
+full-width Pause/Stop/Resume/Discard buttons, and a "RIPPR" wordmark header with Rides/
+Settings/Routes buttons. The redesign inverts this entirely — the map is the full-bleed
+canvas (via UI-01's shared background, at full opacity/interactivity on this tab only),
+and everything else floats on top of it via `GlassPanel`/HUD widgets.
+
+## Design
+- **Header:** none, per UI-01.
+- **Telemetry:** `DraggableResizableHudWidget`s from UI-04, not a fixed row — this screen
+ is where those widgets actually live during a ride. Default layout mirrors the Stitch
+ mockup's horizontal row (Speed/Avg Speed/Dist/Time) until the rider customizes it.
+- **Pause/Stop:** two icon-only, half-width, full-bleed buttons directly above the bottom
+ nav bar, matching the mockup exactly — `tertiary-container` (pause) and
+ `error-container` (stop) per UI-08's palette. Resume/Discard: the mockup doesn't show
+ these explicitly on this screen; keep them reachable (e.g. Discard behind a
+ confirmation from the paused state, matching today's existing safeguard against a
+ gloved mis-tap next to Pause) rather than dropping the affordance V3-05/the original
+ port already earned.
+- **Route rendering:** both the planned route (dashed, neutral `outline` color) and the
+ ridden-so-far path (colored by the existing speed gradient from `RideMap`) drawn
+ simultaneously, matching the mockup — this is new: today only the ridden path renders
+ live.
+- **Location marker:** `PulsingLocationMarker` (UI-03) replaces the current plain dot.
+- **Mounted mode (V3-05) interaction with this redesign:** the high-contrast mounted
+ theme and larger touch targets still apply, layered on top of this screen's new
+ layout, not replaced by it — confirm the mounted theme's colors still read correctly
+ against `GlassPanel`'s blur (a light theme through blurred content behaves differently
+ than the current dark-on-dark case V3-05 was built against).
+
+## Implementation
+1. Rebuild `RecordScreen`'s body against UI-01's full-opacity Map tab background instead
+ of an embedded `RideMap` card.
+2. Replace the current `StatRow`-based stats card with UI-04's HUD widgets, wired to the
+ same `RecordUiState`/live telemetry stream already powering the old layout — no new
+ data plumbing, only new presentation.
+3. Rebuild `_Controls`/`_PrimaryButton`/`_SecondaryButton` as the two full-bleed icon
+ buttons; keep the existing state-machine logic (`RecordUiState.isIdle/isRecording/
+ isPaused`) driving which controls show, per the current `_Controls` widget.
+4. Wire both route paths (planned + ridden) into the shared background map's overlay,
+ sourced from `RoutePlanRepository` (if a route is being followed — V3-09 groundwork,
+ not required for this ticket if no route is active) and the existing live-points
+ stream.
+5. Re-verify V3-05's mounted-mode contrast/legibility guarantees against the new
+ `GlassPanel`-based layout specifically, since that's a real visual change from the
+ opaque `Card` mounted mode was built and tested against.
+
+## Acceptance criteria
+- [ ] Map fills the screen behind the HUD widgets and controls, full opacity, on this
+ tab
+- [ ] Existing record/pause/resume/stop/discard state machine behavior is unchanged —
+ this is a visual rebuild, not a behavior change
+- [ ] HUD telemetry widgets are the UI-04 draggable/resizable kind, not a fixed layout
+- [ ] Mounted mode (V3-05) still passes its existing contrast tests against the new
+ layout
+- [ ] The black-on-black text-color regression guard (from V3-16/the original port bug)
+ still passes against `GlassPanel`'s blur background
+
+## Tests
+- All existing `record screen` widget tests updated to the new layout and still passing
+ (state-machine behavior, mounted-mode toggle, wake lock, live-map visibility)
+- Widget: HUD widgets render over the map, not replacing it
+- Widget: Pause/Stop buttons trigger the same engine calls as today
+
+## Risks
+- The biggest visual departure of the whole redesign happens here first — expect this
+ ticket to surface layout issues (`GlassPanel` blur cost stacked with a live animating
+ map, HUD widgets overlapping controls on small screens) that UI-06/07 then inherit or
+ avoid having created themselves.
+
+## Out of scope
+Following a planned route turn-by-turn (V3-09). Anything about Plan/Route Planning/Rides
+History screens — those are UI-06/UI-07, restyled toward what this ticket establishes.
+
+## Outcome
+
+Rebuilt `RecordScreen`'s body entirely against UI-01's shared background map, keeping
+every line of the existing state-machine logic (the ticker, wakelock handling, speed
+subscription, `_guard`/error handling) completely untouched -- only the returned widget
+tree changed. The idle state keeps its own compact layout (a single `GlassPanel` with
+`BigStat` speed + "Ready"/"See Rides", exactly the old content, just re-skinned) rather
+than switching to HUD widgets while there's no ride to show numbers for -- deliberately
+preserving the pre-existing "a resting screen must not look like a ride going nowhere"
+guarantee rather than reinterpreting it. Once a ride exists (recording or paused),
+`HudEditOverlay` (UI-04) takes over, wired to a new `_HudMetricValue` that reads
+`RecordUiState`/`UnitSystem` and renders through `HudMetric`'s existing set -- no new
+data plumbing, exactly as the ticket's Implementation section asked for. Added
+`RecordUiState.avgSpeedKmh` (distance-over-moving-time, zero before any movement) since
+that metric didn't previously exist anywhere in the app.
+
+**Colour convention decision:** the Map HUD mockup colours its four default metrics
+individually (Speed → primary, Distance → secondary, Avg Speed/Time → plain ink) rather
+than following this app's usual reference-vs-live split. `_HudMetricValue` matches the
+mockup literally rather than inventing a new convention, since the mockup is this
+ticket's explicit design source for exactly this screen.
+
+**Correction found and fixed while implementing this ticket:** UI-04's `HudMetric` enum
+ordered its "first four, default-visible" metrics as Speed/Distance/Elapsed/Max Speed --
+a guess made before the actual Map HUD mockup HTML had been read closely. Reading
+`docs/design/stitch-export/screens/map-hud-dark.html`'s own `#telemetry-container`
+directly for this ticket showed the real fixed row is Speed/**Avg Speed**/Dist/Time.
+Reordered the enum to match and updated every UI-04 test that asserted the old order
+(five call sites across `hud_widget_layout_test.dart`, `hud_edit_overlay_test.dart`, and
+`settings_screen_test.dart`) -- a real, disclosed correction, not a silent one.
+
+**Control bar:** `_ControlBar`/`_Segment` render the full-bleed, icon-only bar the
+mockup specifies for Pause (`tertiaryContainer`/`onTertiaryContainer`) and Stop
+(`errorContainer`/`onErrorContainer`) -- both added explicitly to `ripprColors` in this
+ticket, since `ColorScheme.dark`'s auto-derived tonal palette wouldn't have matched the
+design system's literal hex values otherwise. Idle keeps a labelled "START RECORDING"
+segment (an icon alone risked ambiguity for a rider's very first, highest-stakes tap,
+a deliberate deviation from strict icon-only fidelity). Paused extends the same visual
+language into three segments (Resume 50%, Stop 25%, Discard 25%) since the mockup itself
+doesn't depict a paused state — Discard stays behind this dedicated slot, reachable only
+while paused, preserving the existing gloved-mis-tap safeguard from V3-05 rather than
+dropping it.
+
+**Location marker:** `RideMap` gained a `showLocationMarker` bool; when true and points
+exist, a `MarkerLayer` places `PulsingLocationMarker` (UI-03) at the latest point.
+`ShellScaffold` passes `showLocationMarker: isMapTab` so the animation only ticks on the
+tab where it's actually visible.
+
+**Out of scope, confirmed at implementation time, not just planned:** the ticket allows
+skipping planned-route rendering when V3-09 (route-following) doesn't exist yet, and it
+doesn't -- there is no "route currently being followed" concept anywhere in the app to
+source a planned-route polyline from. Left unimplemented rather than fabricating a data
+source; `RideMap`'s existing ridden-path speed-gradient polyline is unaffected and still
+renders live.
+
+**Mounted mode re-verified against `GlassPanel` specifically**, per the ticket's own
+named risk (mounted mode was built and tested against an opaque `Card`, not a blurred
+translucent surface). Added a test asserting the actual contrast ratio of the mounted
+speed digit against `GlassPanel`'s mounted-theme surface color (not just "differs from
+the wrong, dark-theme ground" the way the pre-existing test checked) -- passes at
+roughly 4.5:1+ AA with no colour changes needed.
+
+**Tests:** `flutter analyze` clean. `flutter test` green at 369 tests. Every pre-existing
+`record screen` test passed unchanged against the rebuilt screen with zero edits needed
+-- the idle-state structural parity was deliberate and it paid off. Added: two tests that
+actually tap Start/Pause/Stop and verify the underlying trip state changes (not just key
+presence, which the pre-existing tests already covered), one confirming HUD widgets
+render over the shared background map rather than replacing it, and the mounted-mode/
+GlassPanel contrast test above.
+
+**Android emulator verification** (`Medium_Phone_API_35`) exercised the full state
+machine live on-device: Start → Pause → Resume → Stop, confirmed visually correct at
+each transition (colours, icon changes, the "Paused" pill appearing/disappearing, the
+HUD widgets updating), with the full-bleed live map behind everything on the Map tab
+exactly matching the design intent. This surfaced and resolved a real debugging episode
+worth recording: initial manual taps on the control bar appeared to do nothing across
+many attempts (varied coordinates, map on/off, a full emulator+host reboot, a Gradle-
+daemon memory-pressure cleanup, and a `Container`→`Ink` widget refactor along the way).
+The eventual root cause was mundane and entirely on the verification side, not the
+app: the button's true on-screen position was being mis-estimated from a scaled-down
+screenshot preview, roughly 500px off from its actual location. Sampling pixel colours
+directly from the raw screenshot file (via PIL) to find the button's exact bounds
+resolved it immediately, and every interaction has worked correctly since. The emulator
+reboot and Gradle-daemon stop were not the fix, but were a reasonable, low-risk
+housekeeping step taken while investigating and are left in place as a genuine
+improvement to the session's remaining build performance.
diff --git a/rippr-flutter-src/docs/ui-redesign/UI-06-plan-and-route-planning.md b/rippr-flutter-src/docs/ui-redesign/UI-06-plan-and-route-planning.md
new file mode 100644
index 0000000..9dd75af
--- /dev/null
+++ b/rippr-flutter-src/docs/ui-redesign/UI-06-plan-and-route-planning.md
@@ -0,0 +1,170 @@
+# UI-06 — Plan & Route Planning redesign
+
+**Depends on** UI-01, UI-03 · **Size** M · **Status** Done
+
+## Goal
+Rebuild the Plan tab (empty state) and the route planner (pins dropped) as fullscreen
+map canvases with floating controls — correct in principle per the Stitch mockups, but
+**restyled to match Map HUD (UI-05), not copied as-is** — see `README.md`'s note on why
+these two mockups drifted from the design north star.
+
+## Context
+Today's `RoutePlannerScreen` is a `Scaffold` with an `AppBar` (title, rename/delete/
+download actions) and the map beneath it. The mockups drop the header entirely (per
+UI-01) and float everything — a pin-drop tooltip, a stat summary pill, a Start Route
+button — over a fullscreen map, with a bottom nav bar instead of an app bar's back
+button (the planner becomes a screen reached *within* the Plan tab, not a separately
+titled pushed screen).
+
+**What to take from the mockups as-is:** the fullscreen-map-plus-floating-controls
+pattern, the pin-drop ripple micro-interaction, `PulsingLocationMarker` for the rider's
+own position, the marching-ants animated route line, the floating stat pill
+(Distance/Est. Time/Pins) replacing today's pre-download confirmation dialog as a
+persistent readout instead of a one-time modal.
+
+**What to restyle rather than copy:** icon fill-states, the nav bar's exact background/
+blur treatment, and any color choice that doesn't match UI-08's actual palette as
+established on the Map HUD screen. The Route Planning mockup specifically shows the
+"Rides" nav icon active while viewing Plan — a generation error, not a real cross-tab
+indicator; the shipped nav bar must correctly highlight whichever tab is actually active,
+matching Map HUD's nav bar exactly (same widget, in fact — see UI-01).
+
+## Design
+- **Plan tab (no pins yet):** fullscreen map, technical grid overlay, floating "Tap to
+ drop a pin" tooltip (gentle float animation), `PulsingLocationMarker`.
+- **Route Planning (pins dropped):** same canvas, plus the floating stat pill (`FloatingPill`
+ from UI-03) showing Distance / Est. Time / Pin count, updating live as pins are added/
+ moved/removed (already true of the underlying `RoutePlanRepository` data — this ticket
+ is presentation only). "Start Route" as a floating pill-shaped button above the nav
+ bar, replacing today's app-bar-driven actions.
+- **Existing actions (rename, delete, download offline tiles)** need a new home now that
+ there's no app bar to hang icon buttons off of — a small floating overflow/glass menu
+ button is the natural fit, styled per Map HUD's `GlassPanel` language, not invented
+ fresh.
+- **Drag-to-move / tap-to-delete pins:** unchanged interaction from today's
+ `RoutePlannerScreen`; only the chrome around it changes.
+
+## Implementation
+1. Rebuild `RoutePlannerScreen`'s `Scaffold`/`AppBar` as a headerless canvas within the
+ Plan tab's branch navigator (per UI-01), map at full opacity/interactivity here
+ (unlike the dimmed treatment other tabs get for the *background* map — this screen's
+ map *is* the primary content, same as Map HUD's).
+2. Replace the pre-download confirmation dialog (V3-11) with the persistent `FloatingPill`
+ stat summary; keep the actual download flow (count/size check, cancellable progress)
+ as-is underneath — presentation change only.
+3. Add the pin-drop ripple micro-interaction and marching-ants route line as new,
+ currently-nonexistent polish.
+4. Rebuild the Plan tab's empty state (no `RoutePlan` selected/created yet) as the
+ fullscreen tooltip-and-map view, wired to whatever "create a new route" entry point
+ replaces today's `RoutesListScreen` FAB (see UI-07 — Rides History's tab likely
+ absorbs or sits alongside a route list; confirm final IA when that ticket lands, since
+ "Rides" and "Plan" may end up sharing more list/history UI than the current
+ `RoutesListScreen`/`TripsScreen` split assumes).
+5. Move rename/delete/download actions to a floating glass menu, styled per Map HUD.
+
+## Acceptance criteria
+- [ ] Both Plan states (empty, pins dropped) render as fullscreen map canvases with no
+ header
+- [ ] The nav bar on this screen is the exact same widget/styling as Map HUD's, with the
+ Plan tab correctly shown active (not the generation-error "Rides active" state
+ from the mockup)
+- [ ] Existing pin add/move/delete, rename, delete-route, and offline-download behavior
+ all still work, restyled only
+- [ ] Icon fill-states and colors match Map HUD, not the Stitch export's drifted values
+
+## Tests
+- All existing `route_planner_screen_test.dart` / `route_plan_repository_test.dart`
+ behavior tests still pass against the new presentation layer
+- Widget: nav bar active-tab indicator is correct on this screen
+- Widget: the floating stat pill updates live as pins are added/removed (already covered
+ conceptually by existing repository tests; add a presentation-level assertion that the
+ pill reflects it)
+
+## Risks
+- The IA question flagged in Implementation step 4 (how "create a new route" is reached
+ without a `RoutesListScreen`-style list-with-FAB) may turn out to need its own small
+ design decision once UI-07 is underway — don't block this ticket on it if the existing
+ entry point can be preserved with new styling in the meantime.
+
+## Out of scope
+Road-snapped routing (V3-08/V3-09, deferred). Any change to the underlying route-planning
+data model or repository — this is presentation only.
+
+## Outcome
+
+**IA question (Risk section) resolved by deferring, as the ticket explicitly allowed:**
+`RoutesListScreen` stays a list-with-`+`-button (the existing entry point), restyled with
+`GlassPanel`-wrapped rows instead of plain `Card`s. The mockup's own "fullscreen map +
+tooltip" language turned out, on close reading, to describe `RoutePlannerScreen`'s
+*own* empty-pins state (the tooltip literally instructs "tap to drop a pin" — an action
+that happens on the canvas, not on a list of named routes), not the routes list. Both
+screens got the restyle their actual role calls for.
+
+`RoutePlannerScreen` is now a headerless, full-opacity map canvas (its own map, not
+UI-01's dimmed shell background — this screen's map *is* the primary content, matching
+Map HUD). Implemented: the floating "TAP TO DROP A PIN" tooltip (`GlassPanel`, gentle
+3s float) shown only while `waypoints.isEmpty`; `FloatingPill` (UI-03) showing Distance/
+Est. Time/Pins once pins exist, replacing the old pre-download confirmation dialog's
+role as the primary stat display (the confirmation dialog itself is untouched --
+presentation only, per scope); a "START ROUTE" floating pill button; a `GlassPanel`
+overflow menu (`PopupMenuButton`) holding Rename/Download offline tiles/Delete route,
+replacing the removed `AppBar`'s actions row; and a pin-drop ripple micro-interaction
+(a short-lived expanding-and-fading ring at the tap's local screen position).
+
+**"Start Route," given real, working behaviour rather than shipped as a dead button:**
+turn-by-turn following (V3-09) doesn't exist anywhere in the app to wire this to, and
+inventing that data model here would violate this ticket's own "presentation only"
+scope. Tapping it starts an ordinary recording and switches to the Map tab -- a real
+action a rider can use today, not a placeholder.
+
+**Est. Time honestly shows "--"** when `RoutePlan.estimatedMillis` is null (always, until
+V3-08 adds road-snapped routing) rather than fabricating a straight-line estimate the
+model doesn't back.
+
+**Marching-ants route-line animation was deliberately dropped from scope.** It isn't in
+this ticket's acceptance criteria (only the pin-drop ripple is named there), and
+implementing it properly means animating a dash phase while continuously reprojecting
+through `MapCamera.latLngToScreenOffset` under live pan/zoom -- real complexity for a
+purely decorative effect. The existing static dashed polyline (already "visibly distinct
+from a recorded path," the actual acceptance-relevant property) is unchanged.
+
+**Bug found and fixed, unrelated to this ticket's own changes but discovered while
+working in this file:** `_DownloadDialog`'s tile fetch still hardcoded
+`https://tile.openstreetmap.org/...` directly -- a leftover from before UI-09 switched
+the live map to CARTO's dark tiles. Downloading offline tiles would have silently cached
+OSM's tan tiles under the same `(z, x, y)` keys UI-09's cache versioning was specifically
+designed to keep separate from CARTO's, meaning a "successful" download would never
+actually serve anything to the live dark map. Fixed to build the URL from the same
+`tileUrlTemplate`/`tileSubdomains` every other tile fetch in the app already uses.
+
+**Nav bar correctness:** no separate implementation needed -- `RoutePlannerScreen` is
+pushed *within* the Plan branch's own navigator (UI-01's `StatefulShellBranch`
+architecture), so the shell's persistent nav bar already shows Plan active automatically,
+structurally ruling out the mockup's own "Rides active while viewing Plan" generation
+error. Added a test asserting this directly rather than trusting the architecture alone.
+
+**Tests:** `flutter analyze` clean. `flutter test` green at 370 tests. Updated every
+existing `route_planner_screen_test.dart` assertion that referenced now-removed `AppBar`
+structure (the `IconButton` keys are `PopupMenuItem`s behind the overflow menu now; there
+is no app-bar title to assert renaming against, so that test now reads the repository
+directly instead) plus one real test bug fixed along the way: two close-together
+waypoints in the "tapping a pin deletes it" test happened to place one pin directly
+under the new floating "Start Route" button in that test's fixed camera geometry, so the
+tap intended for the pin actually hit the button underneath it (confirmed via
+`WidgetController`'s own hit-test-mismatch warning, not assumed) -- reduced to a single
+waypoint, which the camera centres exactly on, clear of the button. `PopupMenuButton`/
+`PopupMenuItem` interactions needed bounded pumps rather than `pumpAndSettle`, same
+reason the map's own tests already avoid it (a live `TileLayer` never goes idle in this
+harness). Also fixed a real, unrelated bug the new `GlassPanel`-wrapped `ListTile` in
+`RoutesListScreen` surfaced immediately: Flutter's own framework assertion that a
+`ListTile` needs a `Material` ancestor to paint its ink splash correctly, which
+`GlassPanel`'s `DecoratedBox` was sitting between it and -- fixed with a
+`Material(type: MaterialType.transparency)` wrapper.
+
+**Android emulator verification** (`Medium_Phone_API_35`): confirmed the empty-tooltip
+state, dropping a pin (ripple visible, `FloatingPill` and "Start Route" appearing
+immediately), and the overflow menu opening with all three actions -- all matching the
+mockup and the Design section precisely. The Plan tab's nav-bar pill correctly followed
+the active tab (a mid-transition-animation screenshot briefly looked wrong immediately
+after tapping the tab; a screenshot taken a moment later confirmed it settles correctly
+-- a timing artifact of screenshotting mid-animation, not a real bug).
diff --git a/rippr-flutter-src/docs/ui-redesign/UI-07-rides-history.md b/rippr-flutter-src/docs/ui-redesign/UI-07-rides-history.md
new file mode 100644
index 0000000..43049d3
--- /dev/null
+++ b/rippr-flutter-src/docs/ui-redesign/UI-07-rides-history.md
@@ -0,0 +1,148 @@
+# UI-07 — Rides History redesign
+
+**Depends on** UI-01, UI-03 · **Size** M · **Status** Done
+
+## Goal
+Rebuild the Trips list as "Rides History": a dimmed live map background (per UI-01, not
+a static image), a search bar, and richer ride cards with a real route-thumbnail map —
+restyled to Map HUD's (UI-05) established colors/icons, not the Stitch export's mockup
+styling.
+
+## Context
+Today's `TripsScreen` is a plain `ListView` of `_TripTile`s (icon, name, one-line
+subtitle) over a solid background, reached by pushing off Record. The mockup adds a
+search bar, a filter button, a summary-stats row, and cards with an actual map-thumbnail
+image per ride. By explicit instruction, the background here is the same live map every
+other tab has (dimmed), not the mockup's static blurred screenshot — see UI-01.
+
+**Generation artifact to fix, not copy:** the mockup's summary row is four identical
+"Total Dist: 482 KM" cards. This ticket defines real, distinct summary stats.
+
+## Design
+- **Background:** UI-01's shared dimmed map, same as Settings and Plan get when not the
+ active-content tab.
+- **Search + filter:** a text field over ride name/date, plus a filter affordance (by
+ activity type — V3-01 already has this data — and/or date range). Filtering logic is
+ new; nothing today searches or filters the trips list.
+- **Summary row:** real distinct stats — total distance, total moving time, ride count,
+ and one more genuinely useful figure (e.g. average speed across all rides, or longest
+ ride) rather than repeating one number four times. Computed from existing `Trip`
+ aggregate columns already stored on every completed ride — no new data needed, only a
+ query across all of them.
+- **Ride cards:** title, date, and a distance/time/avg-speed stat row (matching the
+ mockup), plus a **real map thumbnail** — a small, non-interactive `RideMap` (or a
+ lightweight static rendering of the stored path) rather than a stock image, since we
+ actually have the ride's own path data, unlike the mockup's placeholder photos.
+- **Colors/icons:** Map HUD's palette and icon fill-states, per the design north star —
+ not the mockup's card-specific styling choices (e.g. the third card's grayscale/
+ reduced-opacity treatment, which reads as an arbitrary "older ride" decoration with no
+ defined rule — drop it unless a real rule for it is specified).
+
+## Implementation
+1. Rebuild `TripsScreen`'s background to sit over UI-01's dimmed shared map instead of a
+ plain scaffold background.
+2. Add search (text query against `Trip.name`/date) and filter (activity type at
+ minimum, reusing V3-01's `Activity` enum and `activityIcon`/`activityLabel` helpers).
+3. Add a summary-stats query/provider computing totals across `watchCompletedTrips()`
+ (or a dedicated aggregate query if computing it client-side over a large ride history
+ becomes a real cost — measure before optimizing).
+4. Rebuild `_TripTile` as a card with a small live/static `RideMap` thumbnail (reusing
+ the existing `RideMap` widget at a small size and non-interactive, rather than
+ building a second map-rendering path) plus the distance/time/avg-speed row.
+5. Confirm this screen's tab identity/name in the nav bar — "Rides" (mockup's label) vs.
+ "History" vs. keeping "Rides" as used elsewhere in the codebase already (V3-07's
+ Routes screen already uses "Rides" as the trips-tab label on the record screen) —
+ match whatever UI-01's nav bar ships with.
+
+## Acceptance criteria
+- [x] Background is the shared live map (dimmed), not a static image
+- [x] Search narrows the list by name/date
+- [x] Filter narrows the list by activity type
+- [x] Summary row shows four distinct, real statistics, not one number repeated
+- [x] Each ride card shows an actual thumbnail of that ride's own path, not a stock photo
+- [x] Merge/delete/rename actions from today's `TripsScreen` still work
+- [x] Colors and icon fill-states match Map HUD, not the Stitch export's card styling
+
+## Tests
+- Widget: search filters the visible list correctly
+- Widget: filter-by-activity narrows correctly
+- Unit: summary-stats aggregation matches a hand-computed total over a fixed set of
+ seeded trips
+- All existing `trips list` widget tests (empty state, newest-first ordering, active-ride
+ exclusion, merge-at-exactly-two-selections, delete) updated to the new layout and still
+ passing
+
+## Risks
+- Rendering a small `RideMap` thumbnail per card in a long list could be a real
+ performance cost (many simultaneous `FlutterMap` instances) — consider a lightweight
+ static polyline-on-canvas rendering instead of a full interactive map per thumbnail if
+ this proves too expensive; measure with a real ride history of realistic length before
+ deciding.
+
+## Out of scope
+Any change to trip data, merge, or split logic (V3-10) — presentation and search/filter
+only.
+
+## Outcome
+
+`TripsScreen` is now a headerless screen (per UI-01) over the shared dimmed background
+map, with a `GlassPanel`-wrapped search bar (`ride-search`) plus a `PopupMenuButton`
+activity filter (`activity-filter`), a four-card distinct summary row, and `_TripCard`s
+carrying a real 88x88 `RideMap` thumbnail of that ride's own recorded path.
+
+**`RideHistorySummary`** is a plain pure function (`RideHistorySummary.compute(List)`),
+not a Riverpod provider — folding over an already-fetched list is cheap regardless of ride
+count, and being a pure function is what made it directly unit-testable against a
+hand-computed total, per the ticket's own Tests section. Its average speed is
+distance-weighted across the whole history (not an average of each ride's own average),
+so a handful of long rides isn't drowned out by many short ones. The summary reflects the
+full unfiltered history, not the currently-searched/filtered subset — it's an overview
+stat, not a count of what's visible below it.
+
+**Bug found and fixed while wiring the thumbnails:** the shared `RideMap` widget
+unconditionally rendered `TileAttribution` (added in UI-09) inside every `FlutterMap`,
+including tiny 88x88 list thumbnails — both a real UX problem (attribution controls
+cluttering dozens of small thumbnails) and an actual test collision (`find.byType(Icon)`
+scoped to one trip's keyed subtree started matching the attribution button's own icon
+too, once thumbnails were added). Fixed by adding a `showAttribution` bool parameter to
+`RideMap` (default `true`, so every existing full-size map call site — Map tab
+background, Route Planner, Trip Detail — is unaffected) and passing `showAttribution:
+false` specifically for `_TripCard`'s thumbnail.
+
+**The same `GlassPanel`-needs-`Material`-ancestor bug UI-06 already hit** recurred
+identically in `_TripCard` (rebuilt from `StatelessWidget` to `ConsumerWidget` to watch
+the per-trip point/segment streams for its thumbnail) and was fixed the same way:
+`Material(type: MaterialType.transparency)` wrapping the `InkWell`.
+
+**Rename is correctly out of scope for this file**: it lives on `TripDetailScreen`
+(`_rename`), not the list, and was never touched here — the ticket's "merge/delete/rename
+still work" line is satisfied by leaving Trip Detail's own rename alone while restyling
+only the list's merge/delete selection toolbar.
+
+**Tests:** `flutter analyze` clean. `flutter test` green at **374 tests** (up from 370),
+adding: a widget test that search narrows the list to matching rides, a widget test that
+filtering by activity narrows the list, and two unit tests for `RideHistorySummary.compute`
+(a hand-computed multi-trip total, and the empty-history zero-division guard) in a new
+`test/ride_history_summary_test.dart`. Two pre-existing `widget_test.dart` assertions
+needed updating for the new card structure: `'an active ride does not appear in the
+list'` asserted `find.byType(Card)` (now `find.byType(RideMap)`, unique to a trip card
+since `_SearchAndFilter`/`_SummaryStatCard` don't render one), and the icon-count
+collision above resolved once attribution was suppressed on thumbnails.
+
+**Android emulator verification** (`Medium_Phone_API_35`): confirmed end-to-end after
+working around significant, unrelated host resource pressure during this session (the
+emulator repeatedly hit System UI/app ANRs under low host memory; resolved by freeing
+host RAM, a full emulator restart, and a `force-stop`+relaunch of the app — see session
+notes, not an app defect). Verified on-device: search field narrows the list correctly
+(a query matching neither ride's name/date shows "No rides match your search.", clearing
+it restores both); the activity filter's `PopupMenuButton` opens with all seven
+activities plus "All activities", correctly highlights the currently-active choice,
+narrows to zero rides when filtered to an activity neither seeded ride has (Bicycle),
+and shows both again when filtered to the activity they actually have (Motorcycle); the
+four summary cards (Total Dist/Total Time/Rides/Avg Speed) render as genuinely distinct
+values, not the mockup's four-identical-cards artifact; both ride cards render real,
+distinct polyline thumbnails with no attribution icon visible; long-pressing a card
+enters selection mode with the Cancel/count/Merge/Delete toolbar, Merge correctly stays
+disabled at one selection and enables at exactly two. Colors/icons match Map HUD's
+established palette (translucent `GlassPanel` cards, blue accent, same activity icon set)
+throughout.
diff --git a/rippr-flutter-src/docs/ui-redesign/UI-08-theme-modern-professional-dark.md b/rippr-flutter-src/docs/ui-redesign/UI-08-theme-modern-professional-dark.md
new file mode 100644
index 0000000..407277a
--- /dev/null
+++ b/rippr-flutter-src/docs/ui-redesign/UI-08-theme-modern-professional-dark.md
@@ -0,0 +1,160 @@
+# UI-08 — Theme migration to Modern Professional Dark
+
+**Depends on** nothing · **Size** S · **Status** Done
+
+## Goal
+Replace `theme.dart`'s current safety-orange palette with "Modern Professional Dark" —
+the design system actually backing every fetched Stitch screen — so every screen ticket
+in this set has one settled palette to build against instead of guessing or patching
+colors per screen.
+
+## Context
+V3-16 (last shipped) deliberately kept and refined the original safety-orange-on-warm-
+charcoal identity, with an instrument-blue tertiary for reference readings. That work is
+good and tested, but it is **not** the palette the Stitch mockups use — confirmed by
+hex-matching every color in the four fetched screens' HTML against all three design
+systems in the Stitch project (`docs/design/stitch-export/README.md`). If this redesign
+ships, V3-16's direction is superseded, not extended.
+
+Full spec: `docs/design/stitch-export/design-system-modern-professional-dark.md`.
+
+## Design
+- **Ground:** deep dark gray `#0a0a0c` (not pure black — "a true premium feel without
+ the harshness of pure black," per the design system's own doc), stepped up through
+ `#131313` (surface) and `#16161a` (cards) for elevation.
+- **Primary:** Professional Blue — rendered as `#4090fe` (container) / `#aac7ff`
+ (on-dark-surface primary) / `#002f64` (on-primary text). This replaces safety-orange as
+ *the* accent everywhere: live telemetry, active nav indicator, primary buttons,
+ the user's own location dot.
+- **Secondary/tertiary:** muted slate-blue (`#aec7f6`/`#2e476f`) and a warm orange
+ (`#ffb68c`/`#e3711f`) reserved for tertiary accents (the design system's own spec
+ doesn't define a strict "reference vs. live" split the way V3-16's tertiary did —
+ decide during implementation whether to keep that instinct using this palette's
+ secondary color, or drop it; either is defensible, but pick one and apply it
+ consistently rather than leaving it ambiguous per screen).
+- **Typography:** Inter, exclusively, for every role (headline/body/label) — this drops
+ the multi-font split V3-16 and the original theme used (monospace for numbers via
+ `monoDigits` is a separate, orthogonal decision from V3-16 worth keeping regardless of
+ which color palette wins, since "digits must not jitter" is a real constraint, not a
+ branding choice — retain `monoDigits` layered under Inter-family theming).
+- **Shape:** 8px rounding (`ROUND_EIGHT`), up from the current 4px.
+- **Depth:** tonal layering + a soft blue glow on elevated/active elements, no shadows —
+ matches V3-16's existing "no shadows, translucency for depth" instinct, just with a
+ different accent color for the glow.
+- **Contrast:** the design system's own doc claims AAA-level contrast for functional
+ text against the dark backdrop — verify this the same way V3-16 did (a
+ `contrastRatio()` helper and real assertions), don't take the claim on faith.
+
+## Implementation
+1. Update `theme.dart`'s `ripprColors`/`ColorScheme` to the Modern Professional Dark
+ values (ground, surface, cards, primary/secondary/tertiary, outline).
+2. Update `bodyFont`/`headlineFont`/`labelFont` to Inter throughout; keep `monoDigits` as
+ a distinct style applied specifically to numeric displays, unchanged in spirit from
+ today.
+3. Update `roundness` usage (button/card corner radii) from 4px to 8px.
+4. Re-run V3-16's `contrastRatio()` assertions against the new palette; adjust any color
+ that fails AA before shipping, exactly as V3-16 did for the palette it replaces.
+5. Decide and document the reference-vs-live color convention (see Design) rather than
+ leaving V3-16's tertiary-for-reference-readings instinct undecided under the new
+ palette.
+6. **Mounted mode (V3-05) needs its own pass**, not an automatic inheritance — it's a
+ separate high-contrast daylight theme with its own palette, tuned against a real
+ sunlight/visor constraint. Confirm it still holds up in spirit (still legible, still
+ AA-compliant) under the new brand direction; V3-13's real-ride verification is still
+ the only way to confirm the *actual* sunlight legibility claim, same caveat V3-16
+ already recorded.
+
+## Acceptance criteria
+- [ ] `ripprColors` matches Modern Professional Dark's token values
+- [ ] Every screen using `Theme.of(context).colorScheme` picks up the new palette with
+ no per-screen hardcoded color left over from the old theme
+- [ ] `contrastRatio()` assertions pass against the new palette (AA normal/large, same
+ thresholds V3-16 established)
+- [ ] `monoDigits` still applies to every numeric display
+- [ ] Mounted mode re-verified against the new brand direction
+
+## Tests
+- All of V3-16's `theme_test.dart` contrast assertions, re-pointed at the new color
+ values, still pass
+- The existing black-on-black regression guard (`text is legible against the dark
+ ground`) still passes
+- Widget: a representative sample of screens render with the new palette (no leftover
+ hardcoded orange/old-tertiary-blue literals)
+
+## Risks
+- **Regressing the black-on-black guard is the named risk V3-16 itself called out** —
+ this ticket touches the same `textTheme`/`bodyColor`/`displayColor` wiring that bug
+ came from originally. Do not remove the explicit color-naming discipline while
+ restyling.
+- Grep for hardcoded hex literals from the old palette (`0xFFFF5722` and friends) across
+ the UI layer before considering this done — a few call sites (e.g. `RideMap`'s speed
+ gradient) reference specific hex values directly rather than through the theme, and
+ those need a deliberate decision, not an accidental miss.
+
+## Out of scope
+Any screen-specific redesign (UI-05/06/07) — this ticket only changes the token layer
+those screens then build against.
+
+## Outcome
+
+`lib/src/ui/theme.dart`'s `ripprColors` now carries Modern Professional Dark's values:
+ground `#0a0a0c` (the design system's own `surface-main`, not its plain `background`
+token — the spec's prose is explicit that `#0a0a0c` is *the* background, with `#131313`
+one step up and `#16161a` a second step up for cards, a three-level stack the old
+two-level `_ground`/`_surface` pair didn't have room for). Added `ripprCardColor`
+(`#16161a`) to carry the third level, since `ColorScheme` has no free surface slot for it
+once `surfaceContainerHighest` is spent on the selected/highlighted state. Primary is
+Professional Blue (`#4090fe`); tertiary is a warm orange (`#ffb68c`).
+
+**Decision recorded per the ticket's own prompt:** the design system doesn't specify a
+reference-vs-live split itself, so one was chosen and applied everywhere consistently —
+tertiary orange for `StatRow`'s `reference` values (a max, an average), inverted from
+V3-16 where blue held that role, because blue is now the *primary* colour and reusing it
+for reference readings would have made the two indistinguishable. `RideMap`'s speed
+polyline gradient — flagged explicitly in the ticket's Risks as a hardcoded hex literal —
+now lerps `colors.secondary` (the theme's own cool slate-blue) to `colors.primary`
+instead of a hardcoded `0xFF4FC3F7`, for the same reason: the old literal was itself
+blue, and lerping blue-to-blue under the new primary would have washed the gradient into
+one hue.
+
+**Shape:** added `ripprRadiusSmall` (8px) and `ripprRadiusLarge` (16px) constants
+matching the spec's small-component/large-container split, and a `CardThemeData` using
+the large radius and `ripprCardColor`. `RideMap`'s card-mode `ClipRRect` (previously a
+hardcoded 12px) now uses `ripprRadiusLarge`. Deliberately did **not** add a global
+`ElevatedButtonTheme`/`OutlinedButtonTheme` shape override — `RecordScreen`'s
+Start/Pause/Stop/Discard buttons rely on Material 3's default `StadiumBorder` for their
+pill shape, which already matches the Stitch mockups' own pill buttons; forcing an 8px
+rectangle there would have been an unrequested regression, not a spec application.
+
+**Inter was not wired in.** The design system calls for it exclusively, but the codebase
+has no bundled font asset and no existing `bodyFont`/`headlineFont` abstraction to retarget
+— setting `TextTheme.apply(fontFamily: 'Inter')` with nothing registered under that name
+would silently fall back to the platform default (Roboto), which is exactly the class of
+invisible failure `ripprTheme()`'s own `bodyColor`/`displayColor` fix exists to prevent
+(the "Compose `Surface`" comment in the file). Left as a documented, deliberate follow-up
+rather than shipping a fontFamily string that resolves to nothing. `monoDigits` is
+untouched and still applies to every numeric display, as required.
+
+**Contrast:** re-ran `theme_test.dart`'s `contrastRatio()` assertions against the new
+palette (no values needed adjusting — primary-on-ground, tertiary-on-ground, and
+onSurface-on-ground all clear their AA thresholds with considerable margin: roughly
+6.3:1, 11.6:1, and 15.3:1 respectively, well past the 4.5:1/3:1 bars). Mounted mode
+(V3-05) was left unchanged — its own separate high-contrast daylight palette isn't
+covered by the Modern Professional Dark spec, its orange accent isn't a brand clash the
+way the pocketed theme's safety-orange was, and V3-13's real-ride sunlight-legibility
+claim depends on the specific values already tuned there. Its existing contrast tests
+were re-run, unchanged, and still pass.
+
+**Tests:** `flutter analyze` clean. `flutter test` green at 315 tests, no count change —
+this ticket touched no test files, only re-pointed `ripprColors`'s literals and reused
+the same `theme_test.dart`/`ride_map_test.dart` assertions the diff didn't need to alter
+because they read `ripprTheme().colorScheme` rather than hardcoding old hex values.
+
+**Android emulator verification** (`Medium_Phone_API_35`): checked Map/Settings/Rides
+tabs. Buttons, switches, the segmented Metric/Imperial control, section labels, and the
+`Save`/`Clear` text actions all render in Professional Blue; the reference "Max speed"
+figure renders in the new tertiary orange, clearly distinct from the live "0" headline
+figure (unchanged ink) and from the Pause button's blue; the bottom nav's active-tab
+indicator picked up a blue-tinted pill automatically (Material 3 derives it from the
+`ColorScheme`, not something this ticket configured directly) rather than the old
+neutral grey. No leftover orange/old-instrument-blue literals were visible anywhere.
diff --git a/rippr-flutter-src/docs/ui-redesign/UI-09-monochrome-dark-map-tiles.md b/rippr-flutter-src/docs/ui-redesign/UI-09-monochrome-dark-map-tiles.md
new file mode 100644
index 0000000..5b0e9cb
--- /dev/null
+++ b/rippr-flutter-src/docs/ui-redesign/UI-09-monochrome-dark-map-tiles.md
@@ -0,0 +1,154 @@
+# UI-09 — Monochrome dark map tiles
+
+**Depends on** nothing · **Size** S · **Status** Done
+
+## Goal
+Replace the tan/cream default OpenStreetMap raster style with a monochrome dark map, so
+the map actually looks like it belongs in a dark-mode HUD instead of a light basemap
+punched through a dark overlay.
+
+## Context
+`RideMap`, the Plan/Route Planning canvas, and every Stitch mockup all assume a dark or
+near-monochrome map underneath the HUD. Today's `TileLayer` (`ride_map.dart`) points at
+`tile.openstreetmap.org`, OSM's standard light/tan default style — the actual on-device
+result is nothing like the mockups, regardless of how dark the chrome on top of it is.
+
+This affects every screen that shows a map (Map HUD, Plan, Route Planning, and UI-01's
+dimmed background on every other tab), so it's worth settling once, early, rather than
+each screen ticket discovering the same mismatch independently.
+
+## Design
+Two real options, not one obvious answer:
+
+**Option A — switch tile provider to CARTO's "Dark Matter" basemap.**
+`https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png` (subdomains a-d, retina
+`{r}` variant available, max native zoom 20). Purpose-built dark, desaturated/monochrome
+cartography — closest to the mockups with the least engineering. Free for reasonable
+usage same as OSM's own tiles, but it's a **different third party with its own usage
+policy and attribution requirement** ("© OpenStreetMap contributors © CARTO", not just
+OSM's own attribution) — a real dependency to add, not a style tweak.
+
+**Option B — keep OSM tiles, apply a color filter client-side.** Wrap `TileLayer` in a
+`ColorFiltered` widget using a `ColorFilter.matrix` that desaturates and inverts/darkens
+the tan basemap into a monochrome dark look. No new third party, no new attribution, and
+V3-11's offline tile cache keeps working against the exact tiles it already knows about
+— but the result is a filtered light map, not tiles actually designed for dark
+presentation, and will read as slightly "off" next to CARTO's purpose-built version
+(road/building contrast tuned for light backgrounds doesn't always invert cleanly).
+
+**Recommendation: Option A.** The mockups' whole aesthetic depends on the map itself
+being dark, not just tinted dark, and CARTO's dark tiles are a well-established, free,
+no-API-key option already widely used in the Flutter/Leaflet ecosystem for exactly this.
+The added attribution line and a second host to trust are a small, one-time cost.
+
+**V3-11 interaction:** the offline tile cache (`FileTileCache`/`CachedTileProvider`) is
+already keyed by `TileKey(z, x, y)` with no provider-specific data baked in — but that
+means a cache built against OSM tiles and a cache built against CARTO tiles are
+**indistinguishable to the cache** despite being visually different. Switching providers
+without a plan here would silently serve stale, wrong-looking cached OSM tiles once
+CARTO's URL is live. This needs a cache-busting or provider-tagging fix as part of this
+ticket, not a separate one — see Implementation.
+
+## Implementation
+1. Add a `provider`-qualified cache key (or a cache-version bump that invalidates
+ existing V3-11 cache contents wholesale) so switching tile sources can't silently
+ mix cached tiles from two visually different sources.
+2. Update `RideMap`'s `TileLayer` (and the Route Planner's separate `TileLayer` instance
+ — see `route_planner_screen.dart`) to CARTO's dark tile URL template, correct
+ `subdomains`, and `maxNativeZoom: 20`.
+3. Update the attribution requirement — check whether `flutter_map`'s
+ `RichAttributionWidget`/`AttributionWidget` is in use anywhere already, or add one;
+ OSM-only attribution is no longer sufficient once CARTO tiles are in the mix.
+4. Update `tileUserAgent`/any OSM-specific comments in `ride_map.dart` that assumed OSM's
+ tile servers specifically (the "respect OSM's usage policy" reasoning still applies in
+ spirit to CARTO's own policy, but the specific server being addressed changes).
+5. Re-verify V3-11's tile math/download flow still works end-to-end against the new URL
+ template (tile enumeration and caching are provider-agnostic already; only the fetch
+ URL construction needs updating).
+
+## Acceptance criteria
+- [ ] The map renders in a dark, desaturated/monochrome style matching the Stitch
+ mockups, not OSM's default tan basemap
+- [ ] Correct attribution for both OpenStreetMap and CARTO is shown wherever the map is
+ shown
+- [ ] Switching tile providers does not silently serve stale, visually-mismatched
+ cached tiles from before the switch (V3-11's cache is invalidated or
+ provider-tagged)
+- [ ] Both `RideMap` and the Route Planner's independent `TileLayer` are updated
+ consistently — one dark style everywhere, not just on the screens that happened to
+ get touched first
+
+## Tests
+- Existing `ride_map`/route-planner widget tests updated for the new tile URL and still
+ passing
+- Unit: cache-key/versioning change is exercised the same way V3-11's eviction tests
+ are — a cache built under the old scheme does not get served for the new provider
+
+## Risks
+- **A second third-party tile host is a real dependency, not a free color swap** — its
+ own usage policy, its own risk of being blocked if misused, and its own attribution
+ obligation. Treat it with the same care V3-11 already applies to OSM (rate-limit
+ respecting, no bulk prefetch, real user agent).
+- If CARTO's free tier ever proves insufficient or is discontinued, Option B (a color
+ filter over OSM tiles) is the fallback with no new third-party dependency — worth
+ keeping in mind as a documented Plan B, not re-deriving from scratch later.
+
+## Out of scope
+A fully custom/self-hosted vector tile style — much larger scope, and not needed to hit
+"looks like the mockups" today.
+
+## Outcome
+
+Took the recommended Option A. `ride_map.dart` now defines `tileUrlTemplate`
+(`https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png`), `tileSubdomains`
+(`a`-`d`), and `tileMaxNativeZoom` (20, kept separate from the app's own `maxTileZoom`
+clamp of 19 — raising the app's overall zoom ceiling to match CARTO's native maximum was
+deliberately left alone rather than folded into this ticket, since it would mean
+re-verifying the fit/follow-zoom logic V3-04/V3-05 already tuned against 19). Both
+`RideMap` and `route_planner_screen.dart`'s independent `TileLayer` now import and use
+these same three constants plus `retinaMode: true`, so there is exactly one dark style
+and one set of tile-request parameters across the app, not two independently-drifting
+copies.
+
+**Cache versioning (the ticket's named risk):** `tileCacheProvider` in
+`app/providers.dart` now points `FileTileCache` at `tiles/carto_dark_v1` instead of the
+old bare `tiles` directory. `TileKey(z, x, y)` still carries no provider identity, so
+the directory segment is the actual version tag — any tiles cached under the old OSM-tan
+scheme are simply orphaned in a directory the app no longer reads from, rather than
+being silently served under the new dark UI. Added a unit test
+(`test/tile_cache_test.dart`, "a tile cached under one provider directory is not served
+from another") proving this isolation holds at the `FileTileCache` level, the same way
+V3-11's own eviction tests exercise cache behavior directly rather than through the UI.
+
+**Attribution:** added a shared `TileAttribution` widget (`ride_map.dart`) wrapping
+flutter_map's `RichAttributionWidget` with `TextSourceAttribution`s for both
+"OpenStreetMap contributors" (CARTO's dark style is still built from OSM's underlying
+data) and "CARTO" itself — OSM-only credit, which is what the app carried before, stopped
+being sufficient the moment a second tile host entered the mix. Composed into both
+`RideMap` and the route planner's `FlutterMap`, so it's one component discharging the
+obligation everywhere a map renders, not a copy-pasted attribution block per screen. Did
+not wire up `onTap` license-page links — that would need the `url_launcher` package, a
+new dependency this ticket has no other reason to add; the obligation is to visibly
+credit both sources, not to make the credit tappable.
+
+**Tests:** `flutter analyze` clean. `flutter test` green at 316 tests (315 + the new
+cache-isolation test) — no existing test hardcoded the old OSM URL string, so nothing
+else needed updating.
+
+**Android emulator verification** (`Medium_Phone_API_35`): confirmed CARTO's dark,
+desaturated basemap renders on-device (a genuinely dark map, not a tan basemap under a
+dark overlay) — a clear visual match for the Stitch mockups' aesthetic, on the Map tab at
+full opacity and dimmed correctly on Rides/Plan/Settings via UI-01's existing scrim. The
+attribution icon is visibly present in the bottom-left corner on every tab (same shared
+map instance). Its tap-to-expand interaction could not be confirmed via `adb input tap`
+in this session — taps at its on-screen coordinates didn't visibly toggle the popup, and
+a genuine Android mock-location watermark icon (left over from this session's earlier
+`adb emu geo fix` calls, and confirmed present at the same screen position across
+unrelated tabs, which a page-level widget couldn't be) sits immediately next to it,
+making the exact tap target ambiguous to hit blindly. This is a dev-tooling
+verification gap, not a known defect — the widget itself renders without error and
+matches flutter_map's standard, widely-shipped attribution pattern (the same
+small-icon-that-expands convention Google Maps and Mapbox both use). Re-verify the
+popup's tap behavior with a real touchscreen or Flutter Inspector if it becomes load-
+bearing later (e.g., if legal review specifically requires confirming the expand
+interaction, not just the icon's presence).
diff --git a/rippr-flutter-src/docs/v3/README.md b/rippr-flutter-src/docs/v3/README.md
index 6590165..8ad8626 100644
--- a/rippr-flutter-src/docs/v3/README.md
+++ b/rippr-flutter-src/docs/v3/README.md
@@ -7,7 +7,7 @@ implementing, so the risks are on paper before they are walked into.
Menu, not a commitment. Nothing here is scheduled.
**Everything in v3 is buildable with no server.** Group rides, accounts and paid cloud
-backup are v4 — see [../BACKLOG.md](../BACKLOG.md).
+backup are on the ROADMAP (formerly "v4") — see [../BACKLOG.md](../BACKLOG.md).
> **Before starting anything:** [../port/REAL-RIDE-CHECKLIST.md](../port/REAL-RIDE-CHECKLIST.md).
> The port has never recorded a real ride, and item I3 may still force a change of GPS
@@ -33,7 +33,7 @@ backup are v4 — see [../BACKLOG.md](../BACKLOG.md).
| [V3-14](V3-14-gpx-interop.md) | GPX interoperability | S | V3-01 | Partially done (code only; needs real-device verification) |
| [V3-15](V3-15-auto-pause.md) | Auto-pause | M | V3-13 *(gated)* | Not started |
| [V3-16](V3-16-visual-identity.md) | Visual identity | M | V3-04, V3-05 | Partially done (token-level identity shipped; needs outdoor device verification) |
-| [V3-17](V3-17-osrm-hosting.md) | Self-hosted OSRM: investigate and stand one up | M | — *(needs a server — see the ticket's own note on the v3/v4 boundary)* | Not started |
+| [V3-17](V3-17-osrm-hosting.md) | Self-hosted OSRM: investigate and stand one up | M | — *(needs a server — see the ticket's own note on the v3/ROADMAP boundary)* | Not started |
## Dependencies
@@ -52,7 +52,7 @@ no dependencies: V3-01 · V3-02 · V3-04 · V3-06 · V3-07 · V3-10 · V3-12 ·
```
**V3-08/V3-09 are deferred, on request**, pending V3-17 (self-hosted OSRM). See V3-17's
-own note on why that also puts them in tension with this document's v3/v4 boundary —
+own note on why that also puts them in tension with this document's v3/ROADMAP boundary —
unresolved by design, not an oversight.
## Three that carry more weight than their size suggests
diff --git a/rippr-flutter-src/docs/v3/V3-02-settings-screen.md b/rippr-flutter-src/docs/v3/V3-02-settings-screen.md
index b70e264..f2103ef 100644
--- a/rippr-flutter-src/docs/v3/V3-02-settings-screen.md
+++ b/rippr-flutter-src/docs/v3/V3-02-settings-screen.md
@@ -46,7 +46,7 @@ Keep it plain. This is not a screen anyone should spend time in.
without introducing a second source of truth is the only subtle part.
## Out of scope
-Account settings (v4). Theme selection (V3-16).
+Account settings (ROADMAP). Theme selection (V3-16).
## Outcome
diff --git a/rippr-flutter-src/docs/v3/V3-04-live-map.md b/rippr-flutter-src/docs/v3/V3-04-live-map.md
index 61a3e8e..fc53800 100644
--- a/rippr-flutter-src/docs/v3/V3-04-live-map.md
+++ b/rippr-flutter-src/docs/v3/V3-04-live-map.md
@@ -59,7 +59,7 @@ Behind the existing map toggle, off by default while it is unproven on battery.
stream, which is already batched.
## Out of scope
-Mounted mode (V3-05). Other riders on the map (v4).
+Mounted mode (V3-05). Other riders on the map (ROADMAP).
## Outcome
Shipped as designed. `AppDatabase.watchPointsForTrip`/`watchSegmentsForTrip` feed two
diff --git a/rippr-flutter-src/docs/v3/V3-17-osrm-hosting.md b/rippr-flutter-src/docs/v3/V3-17-osrm-hosting.md
index 1eb7f8a..e9c6f63 100644
--- a/rippr-flutter-src/docs/v3/V3-17-osrm-hosting.md
+++ b/rippr-flutter-src/docs/v3/V3-17-osrm-hosting.md
@@ -15,20 +15,22 @@ Directions) or the public OSRM demo (explicitly not for production use).
**This ticket exists because that decision itself has a wrinkle worth naming up front:**
this whole v3 backlog's organizing principle, stated in `docs/BACKLOG.md`, is *"v3 is
-everything that can be built with no server. v4 is everything that cannot."* A
-self-hosted OSRM instance is a server. Strictly, that makes V3-08 and V3-09 — anything
-that depends on this ticket — v4 work by the project's own definition, not v3, even
-though they're filed under `docs/v3/` today and the routing itself has nothing to do
-with the group-rides/accounts/backup programme that currently defines v4. Whether to
-formally renumber them is a documentation decision for whoever picks this up next; this
-ticket does not resolve it, only flags it so it isn't silently glossed over.
+everything that can be built with no server. The ROADMAP (formerly "v4") is everything
+that cannot."* A self-hosted OSRM instance is a server. Strictly, that makes V3-08 and
+V3-09 — anything that depends on this ticket — ROADMAP work by the project's own
+definition, not v3, even though they're filed under `docs/v3/` today and the routing
+itself has nothing to do with the group-rides/accounts/backup programme that currently
+anchors the ROADMAP. Whether to formally renumber them is a documentation decision for
+whoever picks this up next; this ticket does not resolve it, only flags it so it isn't
+silently glossed over.
## Design
Two separable questions:
1. **Where does it run?** A small VPS (the same shape of box that would eventually host
- the v4 group-ride server, so this could double as an early step toward that) versus
- something serverless/managed. OSRM's own Docker image is the standard path either way.
+ the ROADMAP's group-ride server, so this could double as an early step toward that)
+ versus something serverless/managed. OSRM's own Docker image is the standard path
+ either way.
2. **What data does it need?** A regional OSM extract, not the planet — start with
whatever region actually gets ridden (per `docs/LAUNCH.md`, this is presently a
friends-and-family app, so the region is small and known). [Geofabrik](https://download.geofabrik.de/)
diff --git a/rippr-flutter-src/test/config_test.dart b/rippr-flutter-src/test/config_test.dart
index 0497a77..1e8bf1c 100644
--- a/rippr-flutter-src/test/config_test.dart
+++ b/rippr-flutter-src/test/config_test.dart
@@ -1,6 +1,8 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/config/config.dart';
import 'package:rippr/src/domain/models.dart';
+import 'package:rippr/src/hud/hud_metric.dart';
+import 'package:rippr/src/hud/hud_widget_layout.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// `Config` had no dedicated tests before V3-03 — every existing widget test leaves
@@ -81,6 +83,57 @@ void main() {
});
});
+ group('hudLayout (UI-04)', () {
+ test('a fresh install gets a default layout for every metric', () async {
+ final config = await freshConfig();
+ final layout = config.hudLayout;
+ expect(layout.keys.toSet(), HudMetric.values.toSet());
+ });
+
+ test('round-trips an edited layout through set/get', () async {
+ final config = await freshConfig();
+ final edited = {
+ ...config.hudLayout,
+ HudMetric.speed: const HudWidgetLayout(
+ metric: HudMetric.speed,
+ x: 0.33,
+ y: 0.44,
+ width: 0.25,
+ height: 0.1,
+ visible: true,
+ ),
+ };
+
+ await config.setHudLayout(edited);
+ final reloaded = config.hudLayout;
+
+ expect(reloaded[HudMetric.speed]!.x, 0.33);
+ expect(reloaded[HudMetric.speed]!.y, 0.44);
+ });
+
+ test('a metric missing from a saved layout (e.g. one added in a later app '
+ 'version) falls back to its default rather than being omitted', () async {
+ SharedPreferences.setMockInitialValues({
+ 'hud_layout': '{"speed": {"x": 0.1, "y": 0.1, "width": 0.2, "height": 0.1, '
+ '"visible": true}}',
+ });
+ final config = await Config.load();
+ final layout = config.hudLayout;
+
+ expect(layout.keys.toSet(), HudMetric.values.toSet());
+ final expectedDefault = HudWidgetLayout.defaultFor(HudMetric.distance);
+ expect(layout[HudMetric.distance]!.x, expectedDefault.x);
+ expect(layout[HudMetric.distance]!.visible, expectedDefault.visible);
+ });
+
+ test('corrupted stored JSON falls back to defaults rather than crashing',
+ () async {
+ SharedPreferences.setMockInitialValues({'hud_layout': 'not json at all'});
+ final config = await Config.load();
+ expect(config.hudLayout.keys.toSet(), HudMetric.values.toSet());
+ });
+ });
+
group('deviceId', () {
test('is generated once and then stable across reads', () async {
final config = await freshConfig();
diff --git a/rippr-flutter-src/test/glass_component_kit_test.dart b/rippr-flutter-src/test/glass_component_kit_test.dart
new file mode 100644
index 0000000..3baf1c6
--- /dev/null
+++ b/rippr-flutter-src/test/glass_component_kit_test.dart
@@ -0,0 +1,152 @@
+import 'dart:ui';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:rippr/src/ui/components/floating_pill.dart';
+import 'package:rippr/src/ui/components/glass_panel.dart';
+import 'package:rippr/src/ui/components/pulsing_location_marker.dart';
+import 'package:rippr/src/ui/theme.dart';
+
+/// UI-03: structure/colour assertions, not pixels -- no golden files, per V3-16's own
+/// precedent (see that ticket's Tests section for the reasoning).
+void main() {
+ group('GlassPanel', () {
+ testWidgets('renders a blurred, bordered, translucent container', (tester) async {
+ await tester.pumpWidget(MaterialApp(
+ theme: ripprTheme(),
+ home: const Scaffold(body: GlassPanel(child: Text('content'))),
+ ));
+
+ expect(find.byType(BackdropFilter), findsOneWidget);
+ expect(find.text('content'), findsOneWidget);
+
+ final backdrop = tester.widget(find.byType(BackdropFilter));
+ expect(backdrop.filter, isA());
+
+ final box = tester.widget(find.byType(DecoratedBox).first);
+ final decoration = box.decoration as BoxDecoration;
+ expect(decoration.color, isNotNull);
+ expect(decoration.color!.a, closeTo(GlassPanel.fillOpacity, 0.01),
+ reason: 'the fill must be translucent, not a solid card');
+ expect(decoration.border, isNotNull);
+ });
+
+ Future expectLegible(WidgetTester tester, ThemeData theme) async {
+ await tester.pumpWidget(MaterialApp(
+ theme: theme,
+ home: const Scaffold(body: GlassPanel(child: Text('Distance'))),
+ ));
+ final colors = theme.colorScheme;
+ // The actually-resolved paint colour, not the (null, since the Text above sets
+ // no style of its own) `Text.style` -- this is what the black-on-black bug the
+ // rest of the theme tests guard against would have shown up as.
+ final paragraph = tester.renderObject(find.text('Distance'));
+ final resolvedColor = paragraph.text.style?.color;
+ expect(resolvedColor, isNotNull,
+ reason: 'text inside a GlassPanel must not fall back to an undefined '
+ 'colour -- the same failure mode the ambient bodyColor fix in '
+ 'ripprTheme() exists to prevent');
+ expect(contrastRatio(resolvedColor!, colors.surface), greaterThanOrEqualTo(4.5));
+ }
+
+ // Two separate tests, not one test looping over both themes and re-pumping into
+ // the same tester -- pumping a second MaterialApp with an identically-shaped tree
+ // reuses the first pump's elements rather than rebuilding from the new theme,
+ // which silently re-asserted the first theme's already-resolved colour twice.
+ testWidgets('is legible against the dark ground in the pocketed theme', (tester) {
+ return expectLegible(tester, ripprTheme());
+ });
+
+ testWidgets('is legible against the dark ground in the mounted theme', (tester) {
+ return expectLegible(tester, ripprMountedTheme());
+ });
+ });
+
+ group('FloatingPill', () {
+ testWidgets('renders an arbitrary number of stat columns with dividers between '
+ 'them', (tester) async {
+ await tester.pumpWidget(MaterialApp(
+ theme: ripprTheme(),
+ home: const Scaffold(
+ body: FloatingPill(
+ stats: [
+ PillStat(label: 'Distance', value: '12.4', unit: 'km'),
+ PillStat(label: 'Est. Time', value: '45', unit: 'm'),
+ PillStat(label: 'Pins', value: '5'),
+ PillStat(label: 'Elevation', value: '210', unit: 'm'),
+ ],
+ ),
+ ),
+ ));
+
+ expect(find.text('DISTANCE'), findsOneWidget);
+ expect(find.text('EST. TIME'), findsOneWidget);
+ expect(find.text('PINS'), findsOneWidget);
+ expect(find.text('ELEVATION'), findsOneWidget);
+ expect(find.text('12.4'), findsOneWidget);
+ expect(find.text('km'), findsOneWidget);
+ expect(find.text('5'), findsOneWidget);
+
+ // n stats -> n-1 dividers, whatever n is -- not hardcoded to three.
+ final dividers = tester
+ .widgetList(find.byType(Container))
+ .where((c) => c.constraints?.maxWidth == 1)
+ .toList();
+ expect(dividers.length, 3);
+ });
+
+ testWidgets('renders correctly with a single stat and no dividers', (tester) async {
+ await tester.pumpWidget(MaterialApp(
+ theme: ripprTheme(),
+ home: const Scaffold(
+ body: FloatingPill(stats: [PillStat(label: 'Pins', value: '0')]),
+ ),
+ ));
+
+ expect(find.text('PINS'), findsOneWidget);
+ expect(find.text('0'), findsOneWidget);
+ });
+ });
+
+ group('PulsingLocationMarker', () {
+ testWidgets('animates continuously', (tester) async {
+ await tester.pumpWidget(const MaterialApp(
+ home: Scaffold(body: PulsingLocationMarker()),
+ ));
+
+ await tester.pump(const Duration(milliseconds: 500));
+ await tester.pump(const Duration(milliseconds: 500));
+
+ // Still ticking after a full second -- a one-shot animation would have
+ // completed and stopped producing new frames by now.
+ expect(tester.hasRunningAnimations, isTrue);
+ });
+
+ testWidgets('disposes its AnimationController when removed from the tree',
+ (tester) async {
+ await tester.pumpWidget(const MaterialApp(
+ home: Scaffold(body: PulsingLocationMarker()),
+ ));
+ await tester.pump(const Duration(milliseconds: 100));
+
+ await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox())));
+ await tester.pumpAndSettle();
+
+ // No lingering ticker/timer -- flutter_test's own binding fails the test at
+ // tearDown if a ticker from the removed widget is still registered.
+ expect(tester.hasRunningAnimations, isFalse);
+ });
+
+ testWidgets('falls back to a static dot when reduced motion is enabled',
+ (tester) async {
+ await tester.pumpWidget(const MediaQuery(
+ data: MediaQueryData(disableAnimations: true),
+ child: MaterialApp(home: Scaffold(body: PulsingLocationMarker())),
+ ));
+ await tester.pump(const Duration(milliseconds: 500));
+
+ expect(tester.hasRunningAnimations, isFalse);
+ });
+ });
+}
diff --git a/rippr-flutter-src/test/hud_edit_overlay_test.dart b/rippr-flutter-src/test/hud_edit_overlay_test.dart
new file mode 100644
index 0000000..328a9e3
--- /dev/null
+++ b/rippr-flutter-src/test/hud_edit_overlay_test.dart
@@ -0,0 +1,113 @@
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:rippr/src/app/providers.dart';
+import 'package:rippr/src/config/config.dart';
+import 'package:rippr/src/hud/hud_metric.dart';
+import 'package:rippr/src/ui/components/hud_edit_overlay.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+/// UI-04: drag/toggle behaviour of the customizable HUD, simulated via `TestGesture`
+/// the same way other drag interactions in this codebase are tested (see
+/// `route_planner_screen_test.dart`'s waypoint-drag coverage).
+void main() {
+ late Config config;
+
+ setUp(() async {
+ SharedPreferences.setMockInitialValues({});
+ config = await Config.load();
+ });
+
+ Widget host() => ProviderScope(
+ overrides: [configProvider.overrideWith((ref) => config)],
+ child: MaterialApp(
+ home: Scaffold(
+ body: SizedBox.expand(
+ child: HudEditOverlay(
+ metricBuilder: (context, metric) => Text(metric.label),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ testWidgets('a tap outside edit mode does not move a widget', (tester) async {
+ await tester.pumpWidget(host());
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('hud-edit-done')), findsNothing,
+ reason: 'not editing yet');
+
+ await tester.tap(find.text('Speed'));
+ await tester.pump();
+
+ expect(find.byKey(const Key('hud-edit-done')), findsNothing,
+ reason: 'a plain tap on a widget must never enter edit mode or move it');
+ });
+
+ testWidgets('a long-press on empty space enters edit mode, showing resize handles '
+ 'and Done', (tester) async {
+ await tester.pumpWidget(host());
+ await tester.pumpAndSettle();
+
+ await tester.longPress(find.byKey(const Key('hud-edit-background')));
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('hud-edit-done')), findsOneWidget);
+ expect(find.byKey(const Key('hud-resize-handle')), findsWidgets);
+ });
+
+ testWidgets('a long-press-and-drag on a widget moves it, and Done persists the new '
+ 'position', (tester) async {
+ await tester.pumpWidget(host());
+ await tester.pumpAndSettle();
+
+ await tester.longPress(find.byKey(const Key('hud-edit-background')));
+ await tester.pumpAndSettle();
+
+ final before = config.hudLayout[HudMetric.speed]!;
+
+ final speedFinder = find.text('Speed');
+ final gesture = await tester.startGesture(tester.getCenter(speedFinder));
+ await tester.pump(kLongPressTimeout + kPressTimeout);
+ await gesture.moveBy(const Offset(40, 60));
+ await tester.pump();
+ await gesture.up();
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byKey(const Key('hud-edit-done')));
+ await tester.pumpAndSettle();
+
+ final after = config.hudLayout[HudMetric.speed]!;
+ expect(after.x, isNot(before.x));
+ expect(after.y, isNot(before.y));
+ });
+
+ testWidgets('a drag while not editing does not move the widget', (tester) async {
+ await tester.pumpWidget(host());
+ await tester.pumpAndSettle();
+
+ final before = config.hudLayout[HudMetric.speed]!;
+ final speedFinder = find.text('Speed');
+
+ final gesture = await tester.startGesture(tester.getCenter(speedFinder));
+ await tester.pump(kLongPressTimeout + kPressTimeout);
+ await gesture.moveBy(const Offset(40, 60));
+ await gesture.up();
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('hud-edit-done')), findsNothing);
+ final after = config.hudLayout[HudMetric.speed]!;
+ expect(after.x, before.x);
+ expect(after.y, before.y);
+ });
+
+ testWidgets('only visible metrics render a widget', (tester) async {
+ await tester.pumpWidget(host());
+ await tester.pumpAndSettle();
+
+ expect(find.text('Speed'), findsOneWidget, reason: 'visible by default');
+ expect(find.text('Max speed'), findsNothing, reason: 'hidden by default');
+ });
+}
diff --git a/rippr-flutter-src/test/hud_layout_controller_test.dart b/rippr-flutter-src/test/hud_layout_controller_test.dart
new file mode 100644
index 0000000..3363407
--- /dev/null
+++ b/rippr-flutter-src/test/hud_layout_controller_test.dart
@@ -0,0 +1,71 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:rippr/src/config/config.dart';
+import 'package:rippr/src/hud/hud_layout_controller.dart';
+import 'package:rippr/src/hud/hud_metric.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+void main() {
+ Future freshConfig() async {
+ SharedPreferences.setMockInitialValues({});
+ return Config.load();
+ }
+
+ group('HudLayoutController', () {
+ test('updatePosition updates only the given metric, clamped', () async {
+ final controller = HudLayoutController(await freshConfig());
+ final before = controller.state[HudMetric.distance]!;
+
+ controller.updatePosition(HudMetric.speed, 5.0, 5.0);
+
+ expect(controller.state[HudMetric.speed]!.x, lessThanOrEqualTo(1.0));
+ expect(controller.state[HudMetric.distance]!.x, before.x,
+ reason: 'moving one metric must not disturb another');
+ });
+
+ test('updateSize clamps to the legibility floor/ceiling', () async {
+ final controller = HudLayoutController(await freshConfig());
+
+ controller.updateSize(HudMetric.speed, 0.0, 0.0);
+ expect(controller.state[HudMetric.speed]!.width, greaterThan(0.0));
+ expect(controller.state[HudMetric.speed]!.height, greaterThan(0.0));
+ });
+
+ test('setVisible(false) then setVisible(true) restores the last saved position, '
+ 'not a fresh default', () async {
+ final controller = HudLayoutController(await freshConfig());
+ controller.updatePosition(HudMetric.avgSpeed, 0.6, 0.6);
+ final movedX = controller.state[HudMetric.avgSpeed]!.x;
+
+ controller.setVisible(HudMetric.avgSpeed, false);
+ expect(controller.state[HudMetric.avgSpeed]!.visible, isFalse);
+
+ controller.setVisible(HudMetric.avgSpeed, true);
+ expect(controller.state[HudMetric.avgSpeed]!.visible, isTrue);
+ expect(controller.state[HudMetric.avgSpeed]!.x, movedX,
+ reason: 'a metric with a real saved position keeps it when re-enabled');
+ });
+
+ test('persist writes the current state to Config', () async {
+ final config = await freshConfig();
+ final controller = HudLayoutController(config);
+ controller.updatePosition(HudMetric.speed, 0.4, 0.4);
+
+ await controller.persist();
+
+ final reloaded = config.hudLayout;
+ expect(reloaded[HudMetric.speed]!.x, 0.4);
+ });
+
+ test('edits before persist are not visible to a fresh read of Config', () async {
+ final config = await freshConfig();
+ final controller = HudLayoutController(config);
+ controller.updatePosition(HudMetric.speed, 0.4, 0.4);
+
+ // No persist() call yet.
+ final reloaded = config.hudLayout;
+ expect(reloaded[HudMetric.speed]!.x, isNot(0.4),
+ reason: 'a drag in progress must only touch in-memory state -- '
+ 'persisting every frame is the ticket\'s own named risk');
+ });
+ });
+}
diff --git a/rippr-flutter-src/test/hud_widget_layout_test.dart b/rippr-flutter-src/test/hud_widget_layout_test.dart
new file mode 100644
index 0000000..d0b9ed5
--- /dev/null
+++ b/rippr-flutter-src/test/hud_widget_layout_test.dart
@@ -0,0 +1,128 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:rippr/src/hud/hud_metric.dart';
+import 'package:rippr/src/hud/hud_widget_layout.dart';
+
+/// UI-04: the JSON round-trip and clamp math are exercised directly with fixed inputs
+/// -- no real gestures needed to test either, per the ticket's own Tests section.
+void main() {
+ group('JSON round trip', () {
+ test('encodes and decodes exactly', () {
+ const original = HudWidgetLayout(
+ metric: HudMetric.speed,
+ x: 0.1,
+ y: 0.2,
+ width: 0.3,
+ height: 0.15,
+ visible: true,
+ );
+
+ final decoded = HudWidgetLayout.fromJson(HudMetric.speed, original.toJson());
+
+ expect(decoded.x, original.x);
+ expect(decoded.y, original.y);
+ expect(decoded.width, original.width);
+ expect(decoded.height, original.height);
+ expect(decoded.visible, original.visible);
+ });
+
+ test('a malformed entry falls back to the default rather than crashing', () {
+ final decoded = HudWidgetLayout.fromJson(HudMetric.distance, {'x': 'not a number'});
+ final default_ = HudWidgetLayout.defaultFor(HudMetric.distance);
+
+ expect(decoded.x, default_.x);
+ expect(decoded.width, default_.width);
+ });
+
+ test('a missing field falls back to the default rather than crashing', () {
+ final decoded = HudWidgetLayout.fromJson(HudMetric.maxSpeed, {'x': 0.1, 'y': 0.1});
+ final default_ = HudWidgetLayout.defaultFor(HudMetric.maxSpeed);
+
+ expect(decoded.x, default_.x);
+ });
+ });
+
+ group('defaultFor', () {
+ test('every metric gets a distinct default position', () {
+ final positions = HudMetric.values
+ .map(HudWidgetLayout.defaultFor)
+ .map((l) => '${l.x},${l.y}')
+ .toSet();
+ expect(positions.length, HudMetric.values.length,
+ reason: 'no two metrics should default to overlapping positions');
+ });
+
+ test('the first four metrics (the Map HUD mockup\'s fixed row: Speed/Avg Speed/'
+ 'Dist/Time) start visible', () {
+ expect(HudWidgetLayout.defaultFor(HudMetric.speed).visible, isTrue);
+ expect(HudWidgetLayout.defaultFor(HudMetric.avgSpeed).visible, isTrue);
+ expect(HudWidgetLayout.defaultFor(HudMetric.distance).visible, isTrue);
+ expect(HudWidgetLayout.defaultFor(HudMetric.elapsedTime).visible, isTrue);
+ });
+
+ test('metrics beyond the fixed row start hidden', () {
+ expect(HudWidgetLayout.defaultFor(HudMetric.maxSpeed).visible, isFalse);
+ expect(HudWidgetLayout.defaultFor(HudMetric.elevationGain).visible, isFalse);
+ });
+
+ test('every default is already within the valid clamp bounds', () {
+ for (final metric in HudMetric.values) {
+ final layout = HudWidgetLayout.defaultFor(metric);
+ expect(layout.clamped().x, layout.x);
+ expect(layout.clamped().y, layout.y);
+ expect(layout.clamped().width, layout.width);
+ expect(layout.clamped().height, layout.height);
+ }
+ });
+ });
+
+ group('clamped', () {
+ const base = HudWidgetLayout(
+ metric: HudMetric.speed,
+ x: 0.5,
+ y: 0.5,
+ width: 0.3,
+ height: 0.15,
+ visible: true,
+ );
+
+ test('a position dragged past the right/bottom edge is pulled back on-screen', () {
+ final result = base.copyWith(x: 1.5, y: 1.5).clamped();
+ expect(result.x, 1.0 - base.width);
+ expect(result.y, 1.0 - base.height);
+ });
+
+ test('a position dragged past the left/top edge is pulled back on-screen', () {
+ final result = base.copyWith(x: -0.5, y: -0.5).clamped();
+ expect(result.x, 0.0);
+ expect(result.y, 0.0);
+ });
+
+ test('a resize below the legibility floor is corrected up to the minimum', () {
+ final result = base.copyWith(width: 0.01, height: 0.01).clamped();
+ expect(result.width, hudMinWidthFraction);
+ expect(result.height, hudMinHeightFraction);
+ });
+
+ test('a resize above the sane ceiling is corrected down to the maximum', () {
+ final result = base.copyWith(width: 5.0, height: 5.0).clamped();
+ expect(result.width, hudMaxWidthFraction);
+ expect(result.height, hudMaxHeightFraction);
+ });
+
+ test('shrinking to fit happens before repositioning, so a widget resized past '
+ 'the edge shrinks rather than silently relocates', () {
+ final result = base.copyWith(x: 0.9, width: 0.7).clamped();
+ expect(result.width, hudMaxWidthFraction);
+ // x itself is still clamped against the (now-bounded) width so the widget
+ // never sits partially off-screen either.
+ expect(result.x + result.width, lessThanOrEqualTo(1.0));
+ });
+
+ test('an already-valid layout is unchanged', () {
+ expect(base.clamped().x, base.x);
+ expect(base.clamped().y, base.y);
+ expect(base.clamped().width, base.width);
+ expect(base.clamped().height, base.height);
+ });
+ });
+}
diff --git a/rippr-flutter-src/test/map_connectivity_test.dart b/rippr-flutter-src/test/map_connectivity_test.dart
new file mode 100644
index 0000000..56dc944
--- /dev/null
+++ b/rippr-flutter-src/test/map_connectivity_test.dart
@@ -0,0 +1,116 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:rippr/src/tiles/map_connectivity.dart';
+
+/// UI-02: exercises the failure-count/threshold/probe logic with a fake probe -- no
+/// real network -- mirroring V3-11's `tile_downloader_test.dart` pattern of injecting
+/// fake fetch outcomes rather than hitting a real tile host from a test.
+void main() {
+ late bool probeResult;
+ late int probeCalls;
+ late MapConnectivityState state;
+
+ setUp(() {
+ probeResult = false;
+ probeCalls = 0;
+ state = MapConnectivityState(
+ probe: () async {
+ probeCalls++;
+ return probeResult;
+ },
+ probeInterval: const Duration(milliseconds: 20),
+ );
+ });
+
+ tearDown(() {
+ state.dispose();
+ });
+
+ test('starts in live mode', () {
+ expect(state.skeletonMode, isFalse);
+ });
+
+ test('fewer than the threshold of failures stays live', () {
+ state.reportFailure();
+ state.reportFailure();
+ expect(state.skeletonMode, isFalse);
+ });
+
+ test('reaching the threshold of consecutive failures switches to skeleton mode', () {
+ for (var i = 0; i < skeletonFailureThreshold; i++) {
+ state.reportFailure();
+ }
+ expect(state.skeletonMode, isTrue);
+ });
+
+ test('a success before the threshold resets the failure count', () {
+ state.reportFailure();
+ state.reportFailure();
+ state.reportSuccess();
+ state.reportFailure();
+ state.reportFailure();
+ expect(state.skeletonMode, isFalse,
+ reason: 'the success should have reset the run -- two more failures is not '
+ 'the same as four in a row');
+ });
+
+ test('notifies listeners exactly when skeletonMode actually changes', () {
+ var notifications = 0;
+ state.addListener(() => notifications++);
+
+ state.reportFailure();
+ state.reportFailure();
+ expect(notifications, 0, reason: 'still below threshold -- no state change yet');
+
+ state.reportFailure();
+ expect(notifications, 1);
+ expect(state.skeletonMode, isTrue);
+ });
+
+ test('a single success while already in skeleton mode recovers immediately', () {
+ for (var i = 0; i < skeletonFailureThreshold; i++) {
+ state.reportFailure();
+ }
+ expect(state.skeletonMode, isTrue);
+
+ state.reportSuccess();
+ expect(state.skeletonMode, isFalse);
+ });
+
+ test('further failures are ignored once already in skeleton mode', () {
+ for (var i = 0; i < skeletonFailureThreshold; i++) {
+ state.reportFailure();
+ }
+ var notifications = 0;
+ state.addListener(() => notifications++);
+
+ // Recovery is the probe loop's job from here -- more failure reports (e.g. from a
+ // TileLayer that hadn't yet unmounted) must not do anything further.
+ state.reportFailure();
+ state.reportFailure();
+
+ expect(notifications, 0);
+ expect(state.skeletonMode, isTrue);
+ });
+
+ test('the probe loop recovers automatically once it starts succeeding', () async {
+ for (var i = 0; i < skeletonFailureThreshold; i++) {
+ state.reportFailure();
+ }
+ expect(state.skeletonMode, isTrue);
+
+ probeResult = true;
+ // Give the periodic probe timer a couple of intervals to fire.
+ await Future.delayed(const Duration(milliseconds: 60));
+
+ expect(state.skeletonMode, isFalse);
+ expect(probeCalls, greaterThan(0));
+ });
+
+ test('the probe loop does not run while already live', () async {
+ await Future.delayed(const Duration(milliseconds: 60));
+ expect(probeCalls, 0,
+ reason: 'a TileLayer that is live and working generates its own fetch '
+ 'reports -- a periodic probe on top of that would be exactly the '
+ 'excess-request behaviour this ticket exists to avoid');
+ });
+}
diff --git a/rippr-flutter-src/test/ride_history_summary_test.dart b/rippr-flutter-src/test/ride_history_summary_test.dart
new file mode 100644
index 0000000..eec9c58
--- /dev/null
+++ b/rippr-flutter-src/test/ride_history_summary_test.dart
@@ -0,0 +1,43 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:rippr/src/domain/models.dart';
+import 'package:rippr/src/ui/trips/trips_screen.dart';
+
+/// UI-07: `RideHistorySummary.compute` is a pure function specifically so it can be
+/// tested directly against a hand-computed total, rather than through a screen.
+void main() {
+ test('compute matches a hand-computed total over a fixed set of trips', () {
+ final trips = [
+ const Trip(
+ id: 1,
+ startedAt: 0,
+ endedAt: 3600000,
+ distanceM: 20000,
+ movingMillis: 3600000,
+ ),
+ const Trip(
+ id: 2,
+ startedAt: 0,
+ endedAt: 1800000,
+ distanceM: 5000,
+ movingMillis: 1800000,
+ ),
+ ];
+
+ final summary = RideHistorySummary.compute(trips);
+
+ expect(summary.rideCount, 2);
+ expect(summary.totalDistanceM, 25000);
+ expect(summary.totalMovingMillis, 5400000);
+ // Distance-weighted, not an average of each ride's own average: 25 km over 1.5 h.
+ expect(summary.avgSpeedKmh, closeTo(25 / 1.5, 0.0001));
+ });
+
+ test('an empty history has zero average speed, not a division error', () {
+ final summary = RideHistorySummary.compute(const []);
+
+ expect(summary.rideCount, 0);
+ expect(summary.totalDistanceM, 0);
+ expect(summary.totalMovingMillis, 0);
+ expect(summary.avgSpeedKmh, 0);
+ });
+}
diff --git a/rippr-flutter-src/test/ride_map_test.dart b/rippr-flutter-src/test/ride_map_test.dart
index 2ff907f..969b07e 100644
--- a/rippr-flutter-src/test/ride_map_test.dart
+++ b/rippr-flutter-src/test/ride_map_test.dart
@@ -3,6 +3,7 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/ui/components/ride_map.dart';
+import 'package:rippr/src/ui/components/skeleton_map_layer.dart';
import 'package:rippr/src/ui/theme.dart';
/// Tiles are never fetched here — a widget test cannot serve them — but the polyline
@@ -117,4 +118,67 @@ void main() {
expect(map.options.maxZoom, maxTileZoom,
reason: 'exceeding OSM max tile zoom renders an empty grid');
});
+
+ group('skeleton mode (UI-02)', () {
+ testWidgets('shows the skeleton and no TileLayer when skeletonMode is true',
+ (tester) async {
+ await tester.pumpWidget(MaterialApp(
+ theme: ripprTheme(),
+ home: const Scaffold(
+ body: RideMap(points: [], segments: [], showEmptyLabel: false, skeletonMode: true),
+ ),
+ ));
+ await tester.pump();
+
+ expect(find.byType(SkeletonMapLayer), findsOneWidget);
+ expect(find.byType(TileLayer), findsNothing);
+ });
+
+ testWidgets('shows the TileLayer and no skeleton when skeletonMode is false',
+ (tester) async {
+ await tester.pumpWidget(MaterialApp(
+ theme: ripprTheme(),
+ home: const Scaffold(
+ body: RideMap(points: [], segments: [], showEmptyLabel: false),
+ ),
+ ));
+ await tester.pump();
+
+ expect(find.byType(TileLayer), findsOneWidget);
+ expect(find.byType(SkeletonMapLayer), findsNothing);
+ });
+
+ testWidgets('the recorded path still renders on top of the skeleton',
+ (tester) async {
+ final points = [
+ for (var i = 0; i < 4; i++)
+ TrackPoint(
+ id: i + 1,
+ tripId: 1,
+ segmentId: 1,
+ timestamp: 1000 + i * 1000,
+ latitude: 51.0 + i * 0.0005,
+ longitude: -114.0,
+ speedKmh: 40,
+ altitudeM: 1000,
+ ),
+ ];
+ const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)];
+
+ await tester.pumpWidget(MaterialApp(
+ theme: ripprTheme(),
+ home: Scaffold(
+ body: RideMap(points: points, segments: segments, skeletonMode: true),
+ ),
+ ));
+ await tester.pump();
+
+ expect(find.byType(SkeletonMapLayer), findsOneWidget);
+ expect(find.byType(TileLayer), findsNothing);
+ final layer = tester.widget(find.byType(PolylineLayer));
+ expect(layer.polylines, isNotEmpty,
+ reason: 'the path comes from local data, not tiles -- it must not vanish '
+ 'just because the tile fetch is failing');
+ });
+ });
}
diff --git a/rippr-flutter-src/test/route_planner_screen_test.dart b/rippr-flutter-src/test/route_planner_screen_test.dart
index 1606518..2fa6bec 100644
--- a/rippr-flutter-src/test/route_planner_screen_test.dart
+++ b/rippr-flutter-src/test/route_planner_screen_test.dart
@@ -7,6 +7,9 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/app/providers.dart';
import 'package:rippr/src/data/database.dart';
import 'package:rippr/src/data/route_plan_repository.dart';
+import 'package:rippr/src/ui/app_shell.dart';
+import 'package:rippr/src/ui/components/floating_pill.dart';
+import 'package:rippr/src/ui/format.dart';
import 'package:rippr/src/ui/routes/route_planner_screen.dart';
import 'package:rippr/src/ui/routes/routes_list_screen.dart';
import 'package:rippr/src/ui/theme.dart';
@@ -107,8 +110,10 @@ void main() {
final id = await repo.createRoutePlan(1000);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
- expect(find.text('0 m'), findsOneWidget);
- expect(find.textContaining('0 pins'), findsOneWidget);
+ // UI-06: no pins yet -- the "tap to drop a pin" tooltip shows, not a stat pill
+ // (there is nothing to summarise yet).
+ expect(find.textContaining('TAP TO DROP A PIN'), findsOneWidget);
+ expect(find.byType(FloatingPill), findsNothing);
await tester.tapAt(tester.getCenter(find.byType(FlutterMap)));
await tester.pump(const Duration(milliseconds: 50));
@@ -119,35 +124,54 @@ void main() {
final waypoints = await repo.waypointsFor(id);
expect(waypoints, hasLength(2));
- expect(find.textContaining('2 pins'), findsOneWidget);
- final label = tester.widget(find.byKey(const Key('route-distance')));
- expect(label.data, isNot('0 m'));
+ // The tooltip is gone and the stat pill has taken its place, showing the real
+ // pin count and a non-zero distance. Read the pill's own `stats` directly
+ // rather than `find.text('2')` -- the second waypoint pin's own marker label
+ // is also "2", so that text is no longer unique on screen.
+ expect(find.textContaining('TAP TO DROP A PIN'), findsNothing);
+ final pill = tester.widget(find.byType(FloatingPill));
+ final pinsStat = pill.stats.firstWhere((s) => s.label == 'Pins');
+ final distanceStat = pill.stats.firstWhere((s) => s.label == 'Distance');
+ expect(pinsStat.value, '2');
+ expect(distanceStat.value, isNot(formatDistance(0)),
+ reason: 'the distance stat must reflect the two real pins, not stay zeroed');
});
screenTest('tapping a pin deletes it', (tester) async {
+ // UI-06: a single waypoint (rather than two) -- with only one pin, the camera
+ // centres exactly on it, keeping it clear of the floating "Start Route" button
+ // now anchored at the bottom of the screen; two close pins previously landed
+ // one of them directly underneath it in this test's fixed viewport.
final id = await repo.createRoutePlan(1000);
await repo.addWaypoint(id, 51.0, -114.0);
- await repo.addWaypoint(id, 51.01, -114.0);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
// flutter_map's `Marker` is a plain data class, not a Widget -- it never appears
// in the tree itself. `CircleAvatar` is what `_WaypointPin` actually renders.
- expect(find.byType(CircleAvatar), findsNWidgets(2));
+ expect(find.byType(CircleAvatar), findsOneWidget);
- await tester.tap(find.text('1')); // the first pin's label
+ // Not `find.text('1')`: the FloatingPill's "Pins" stat also reads "1" with a
+ // single waypoint, so the pin's own label text is no longer a unique match.
+ await tester.tap(find.byType(CircleAvatar));
await tester.pump();
- expect(await repo.waypointsFor(id), hasLength(1));
+ expect(await repo.waypointsFor(id), isEmpty);
});
- screenTest('renaming updates the app bar title', (tester) async {
+ screenTest('renaming writes through to the repository (UI-06: no app bar title '
+ 'to display it on any more)', (tester) async {
final id = await repo.createRoutePlan(1000, name: 'Old name');
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
- await tester.tap(find.byKey(const Key('rename-route')));
+ // Rename now lives behind the floating overflow menu -- open it first.
+ await tester.tap(find.byKey(const Key('route-overflow-menu')));
await tester.pump();
+ await tester.pump(const Duration(milliseconds: 300));
+ await tester.pump();
+ await tester.tap(find.byKey(const Key('rename-route')));
+ await tester.pump(const Duration(milliseconds: 300));
await tester.enterText(
find.byKey(const Key('route-name-field')),
'Sunday coast run',
@@ -155,7 +179,8 @@ void main() {
await tester.tap(find.text('Save'));
await tester.pump();
- expect(find.text('Sunday coast run'), findsOneWidget);
+ final renamed = await repo.routePlanById(id);
+ expect(renamed?.name, 'Sunday coast run');
});
screenTest('a missing route says so instead of a blank map', (tester) async {
@@ -165,16 +190,21 @@ void main() {
expect(find.textContaining('no longer exists'), findsOneWidget);
});
- screenTest('the offline-tiles download button is disabled with no pins '
- '(V3-11)', (tester) async {
+ screenTest('the offline-tiles download menu item is disabled with no pins '
+ '(V3-11, UI-06: now a PopupMenuItem behind the overflow menu)',
+ (tester) async {
final id = await repo.createRoutePlan(1000);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
- final button = tester.widget(
+ await tester.tap(find.byKey(const Key('route-overflow-menu')));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 300));
+ await tester.pump();
+
+ final item = tester.widget>(
find.byKey(const Key('download-tiles')),
);
- expect(button.onPressed, isNull);
- expect(button.tooltip, contains('Add pins'));
+ expect(item.enabled, isFalse);
});
screenTest('downloading shows a tile count and size estimate before any '
@@ -184,10 +214,15 @@ void main() {
await repo.addWaypoint(id, 51.01, -114.0);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
- final button = tester.widget(
+ await tester.tap(find.byKey(const Key('route-overflow-menu')));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 300));
+ await tester.pump();
+
+ final item = tester.widget>(
find.byKey(const Key('download-tiles')),
);
- expect(button.onPressed, isNotNull);
+ expect(item.enabled, isTrue);
await tester.tap(find.byKey(const Key('download-tiles')));
// Bounded, not pumpAndSettle: the confirmation dialog is safe to settle (no
@@ -201,5 +236,25 @@ void main() {
expect(find.textContaining('tiles?'), findsOneWidget);
expect(find.textContaining('MB'), findsOneWidget);
});
+
+ screenTest('the shell nav bar correctly shows Plan active on this screen '
+ '(UI-06)', (tester) async {
+ final id = await repo.createRoutePlan(1000);
+ await pumpMap(
+ tester,
+ host(
+ ShellScaffold(
+ currentIndex: 2, // Map, Rides, Plan, Settings -- Plan is index 2.
+ onDestinationSelected: (_) {},
+ child: RoutePlannerScreen(routeId: id),
+ ),
+ ),
+ );
+
+ final navBar = tester.widget(find.byKey(const Key('shell-nav-bar')));
+ expect(navBar.selectedIndex, 2,
+ reason: 'not the mockup\'s own generation error (it showed Rides active '
+ 'while viewing Plan) -- the shipped nav bar must reflect the real tab');
+ });
});
}
diff --git a/rippr-flutter-src/test/settings_screen_test.dart b/rippr-flutter-src/test/settings_screen_test.dart
index f3ffee3..509203c 100644
--- a/rippr-flutter-src/test/settings_screen_test.dart
+++ b/rippr-flutter-src/test/settings_screen_test.dart
@@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/app/providers.dart';
import 'package:rippr/src/config/config.dart';
import 'package:rippr/src/domain/models.dart';
+import 'package:rippr/src/hud/hud_metric.dart';
import 'package:rippr/src/ui/settings/settings_screen.dart';
import 'package:rippr/src/ui/theme.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -196,6 +197,46 @@ void main() {
expect(tester.takeException(), isNull);
});
+ group('Live HUD stats (UI-04)', () {
+ // The section sits at the bottom of a long ListView -- these rows are not built
+ // until scrolled into view (a plain `ListView(children:)` still lazily materialises
+ // via a sliver, same as `.builder`), so every test here scrolls first.
+ Future scrollTo(WidgetTester tester, Key key) => tester.scrollUntilVisible(
+ find.byKey(key),
+ 200,
+ scrollable: find.byType(Scrollable).first,
+ );
+
+ testWidgets('a metric switch reflects and writes through to Config', (tester) async {
+ await tester.pumpWidget(host());
+ await tester.pumpAndSettle();
+ await scrollTo(tester, const Key('hud-visible-maxSpeed'));
+
+ final initial = tester
+ .widget(find.byKey(const Key('hud-visible-maxSpeed')))
+ .value;
+ expect(initial, isFalse, reason: 'metrics beyond the fixed row start hidden');
+
+ await tester.tap(find.byKey(const Key('hud-visible-maxSpeed')));
+ await tester.pumpAndSettle();
+
+ expect(config.hudLayout[HudMetric.maxSpeed]!.visible, isTrue,
+ reason: 'the switch must write through to Config immediately, not wait '
+ 'for a HUD edit-mode session to end');
+ });
+
+ testWidgets('a metric already visible by default shows as on', (tester) async {
+ await tester.pumpWidget(host());
+ await tester.pumpAndSettle();
+ await scrollTo(tester, const Key('hud-visible-speed'));
+
+ final speedSwitch = tester
+ .widget(find.byKey(const Key('hud-visible-speed')))
+ .value;
+ expect(speedSwitch, isTrue);
+ });
+ });
+
testWidgets('shows a spinner rather than crashing while Config is still loading',
(tester) async {
await tester.pumpWidget(
diff --git a/rippr-flutter-src/test/tile_cache_test.dart b/rippr-flutter-src/test/tile_cache_test.dart
index 03b33df..a524409 100644
--- a/rippr-flutter-src/test/tile_cache_test.dart
+++ b/rippr-flutter-src/test/tile_cache_test.dart
@@ -89,4 +89,26 @@ void main() {
expect(await reopened.get(const TileKey(5, 10, 10)), isNotNull);
expect(await reopened.sizeBytes(), 64);
});
+
+ test('a tile cached under one provider directory is not served from another '
+ '(UI-09)', () async {
+ // `TileKey` carries no provider identity -- (z, x, y) alone can't tell an OSM tan
+ // tile from a CARTO dark one at the same coordinates. `tileCacheProvider` (see
+ // app/providers.dart) relies entirely on giving each tile source its own
+ // subdirectory to prevent a switch from silently serving stale, visually-mismatched
+ // tiles. This proves that isolation actually holds at the `FileTileCache` level.
+ final osm = FileTileCache(
+ directory: Directory('${tempDir.path}/osm'),
+ maxBytes: 1024 * 1024,
+ );
+ final cartoDark = FileTileCache(
+ directory: Directory('${tempDir.path}/carto_dark_v1'),
+ maxBytes: 1024 * 1024,
+ );
+
+ await osm.put(const TileKey(5, 10, 10), bytesOfSize(64));
+
+ expect(await cartoDark.get(const TileKey(5, 10, 10)), isNull,
+ reason: 'a tile cached under the old provider must not leak into the new one');
+ });
}
diff --git a/rippr-flutter-src/test/widget_test.dart b/rippr-flutter-src/test/widget_test.dart
index eac3148..dbf2efd 100644
--- a/rippr-flutter-src/test/widget_test.dart
+++ b/rippr-flutter-src/test/widget_test.dart
@@ -12,6 +12,9 @@ import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/ui/activity_display.dart';
import 'package:rippr/src/recording/location_source.dart';
import 'package:rippr/src/recording/wakelock_controller.dart';
+import 'package:rippr/src/ui/app_shell.dart';
+import 'package:rippr/src/ui/components/glass_panel.dart';
+import 'package:rippr/src/ui/components/ride_map.dart';
import 'package:rippr/src/ui/detail/trip_detail_screen.dart';
import 'package:rippr/src/ui/format.dart';
import 'package:rippr/src/ui/record/record_screen.dart';
@@ -184,6 +187,51 @@ void main() {
reason: 'cancelling must not delete the ride');
});
+ screenTest('tapping START actually starts a trip (UI-05)', (tester) async {
+ await tester.pumpWidget(host(const RecordScreen(), map: false));
+ await tester.pumpAndSettle();
+
+ expect(await repo.activeTrip(), isNull);
+ await tester.tap(find.byKey(const Key('start')));
+ await tester.pump();
+
+ expect(await repo.activeTrip(), isNotNull,
+ reason: 'the new icon-only control bar must still drive the same engine '
+ 'call the old text button did');
+ });
+
+ screenTest('tapping PAUSE and then STOP actually pauses and completes the trip '
+ '(UI-05)', (tester) async {
+ await repo.startTrip(1000);
+ await pumpLive(tester, host(const RecordScreen(), map: false));
+
+ await tester.tap(find.byKey(const Key('pause')));
+ await tester.pump();
+ final paused = await repo.activeTrip();
+ expect(paused?.state, TripState.paused);
+
+ await tester.tap(find.byKey(const Key('stop')));
+ await tester.pump();
+ expect(await repo.activeTrip(), isNull,
+ reason: 'stopping completes the trip -- it is no longer the active one');
+ });
+
+ screenTest('HUD widgets render over the map, not replacing it (UI-05)',
+ (tester) async {
+ await repo.startTrip(1000);
+ await pumpLive(
+ tester,
+ host(
+ ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
+ ),
+ );
+
+ expect(find.byKey(const Key('shell-background-map')), findsOneWidget,
+ reason: 'the map is still there, underneath the HUD');
+ expect(find.text('SPEED'), findsOneWidget);
+ expect(find.text('DISTANCE'), findsOneWidget);
+ });
+
screenTest('text is legible against the dark ground', (tester) async {
// The regression this exists for: removing Compose's Surface left LocalContentColor
// black, and a 64sp figure rendered invisibly on a near-black background. No logic
@@ -203,58 +251,37 @@ void main() {
expect(colour, isNot(Colors.black));
});
- screenTest('a settings entry point exists and is wired (V3-02)', (tester) async {
- var opened = false;
- await tester.pumpWidget(
- host(RecordScreen(onOpenSettings: () => opened = true)),
- );
- await tester.pumpAndSettle();
+ // UI-01: Settings/Routes/Rides entry points are no longer per-screen callback
+ // buttons on RecordScreen -- they're destinations on the shell's persistent bottom
+ // nav bar. See the `AppShell`/`ShellScaffold` group below for their coverage.
- expect(find.byKey(const Key('open-settings')), findsOneWidget);
- await tester.tap(find.byKey(const Key('open-settings')));
- await tester.pumpAndSettle();
-
- expect(opened, isTrue,
- reason: 'the button must actually invoke the callback that navigates');
- });
-
- screenTest('a routes entry point exists and is wired (V3-07)', (tester) async {
- var opened = false;
- await tester.pumpWidget(
- host(RecordScreen(onOpenRoutes: () => opened = true)),
- );
- await tester.pumpAndSettle();
-
- expect(find.byKey(const Key('open-routes')), findsOneWidget);
- await tester.tap(find.byKey(const Key('open-routes')));
- await tester.pumpAndSettle();
-
- expect(opened, isTrue);
- });
-
- screenTest('the live map appears only while recording and the toggle is on '
- '(V3-04)', (tester) async {
- // Idle, toggle on: no trip to draw, so no map at all.
- await tester.pumpWidget(host(const RecordScreen()));
- await tester.pumpAndSettle();
- expect(find.byKey(const Key('live-map')), findsNothing);
-
- // Recording, toggle off: RecordingEngine has produced a trip, but the map must
- // not be constructed at all -- not just hidden.
+ screenTest('the background map appears only while the map toggle is on '
+ '(V3-04, moved to the shell by UI-01)', (tester) async {
+ // Toggle off: the shell must not construct the map at all -- not just hidden.
await repo.startTrip(1000);
- await tester.pumpWidget(const SizedBox.shrink());
- await pumpLive(tester, host(const RecordScreen(), map: false));
- expect(find.byKey(const Key('live-map')), findsNothing);
+ await pumpLive(
+ tester,
+ host(
+ ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
+ map: false,
+ ),
+ );
+ expect(find.byKey(const Key('shell-background-map')), findsNothing);
expect(find.byType(FlutterMap), findsNothing);
- // Recording, toggle on: the map is drawn.
+ // Toggle on: the map is drawn.
await tester.pumpWidget(const SizedBox.shrink());
- await pumpLive(tester, host(const RecordScreen()));
- expect(find.byKey(const Key('live-map')), findsOneWidget);
+ await pumpLive(
+ tester,
+ host(
+ ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
+ ),
+ );
+ expect(find.byKey(const Key('shell-background-map')), findsOneWidget);
});
- screenTest('backgrounding the app drops the tile layer (V3-04)',
- (tester) async {
+ screenTest('backgrounding the app drops the tile layer (V3-04, moved to the '
+ 'shell by UI-01)', (tester) async {
final h = await repo.startTrip(1000);
await repo.appendPoints([
TrackPoint(
@@ -267,7 +294,12 @@ void main() {
altitudeM: 1000.0,
),
]);
- await pumpLive(tester, host(const RecordScreen()));
+ await pumpLive(
+ tester,
+ host(
+ ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
+ ),
+ );
expect(find.byType(TileLayer), findsOneWidget,
reason: 'foregrounded: tiles render normally');
@@ -278,6 +310,7 @@ void main() {
await tester.binding.defaultBinaryMessenger
.handlePlatformMessage('flutter/lifecycle', message, (_) {});
await tester.pump();
+ await tester.pump();
expect(find.byType(TileLayer), findsNothing,
reason: 'backgrounded: no tile layer means no tile request can fire');
@@ -288,6 +321,7 @@ void main() {
await tester.binding.defaultBinaryMessenger
.handlePlatformMessage('flutter/lifecycle', resumed, (_) {});
await tester.pump();
+ await tester.pump();
expect(find.byType(TileLayer), findsOneWidget,
reason: 'foregrounding again must resume tiles');
});
@@ -362,6 +396,69 @@ void main() {
expect(start.height, 96,
reason: 'V3-05: 72dp is not enough at speed, with gloves');
});
+
+ screenTest('the mounted-mode speed digit is legible against GlassPanel\'s own '
+ 'translucent surface, not just different from the dark ground (UI-05)',
+ (tester) async {
+ // UI-05's own named risk: mounted mode was built and tested against an opaque
+ // Card, not a blurred, translucent GlassPanel -- a light theme through blurred
+ // content behaves differently than the dark-on-dark case V3-05 originally
+ // guarded against. This asserts the real contrast ratio, not just "differs from
+ // the wrong ground" the way the test above does.
+ await tester.pumpWidget(host(const RecordScreen(), mountedMode: true));
+ await tester.pumpAndSettle();
+
+ final speed = tester.widget(
+ find.descendant(of: find.byKey(const Key('speed')), matching: find.text('0')),
+ );
+ final panel = tester.widget(find.byType(GlassPanel).first);
+ final mountedColors = ripprMountedTheme().colorScheme;
+ // GlassPanel fills with `colors.surface` at `GlassPanel.fillOpacity` -- since it
+ // is a solid, near-opaque fill (not a transparency composited over unknown
+ // content), the panel's own surface colour is what the text is actually read
+ // against in practice.
+ expect(panel.child, isNotNull);
+ expect(
+ contrastRatio(speed.style!.color!, mountedColors.surface),
+ greaterThanOrEqualTo(4.5),
+ reason: 'AA normal text against GlassPanel\'s mounted-theme surface',
+ );
+ });
+ });
+
+ group('shell nav bar', () {
+ // UI-01: Settings/Routes/Rides are no longer per-screen callback buttons on
+ // RecordScreen -- they're destinations on the shell's persistent bottom nav bar.
+ // This replaces the old "a settings/routes entry point exists and is wired"
+ // per-screen tests.
+ screenTest('all four destinations are present and switching tabs calls back '
+ 'with the tapped index', (tester) async {
+ var lastIndex = -1;
+ await tester.pumpWidget(host(
+ ShellScaffold(
+ currentIndex: 0,
+ onDestinationSelected: (i) => lastIndex = i,
+ child: const RecordScreen(),
+ ),
+ map: false,
+ ));
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('shell-nav-bar')), findsOneWidget);
+ expect(find.byKey(const Key('nav-map')), findsOneWidget);
+ expect(find.byKey(const Key('nav-rides')), findsOneWidget);
+ expect(find.byKey(const Key('nav-plan')), findsOneWidget);
+ expect(find.byKey(const Key('nav-settings')), findsOneWidget);
+
+ await tester.tap(find.byKey(const Key('nav-settings')));
+ expect(lastIndex, 3);
+
+ await tester.tap(find.byKey(const Key('nav-rides')));
+ expect(lastIndex, 1);
+
+ await tester.tap(find.byKey(const Key('nav-plan')));
+ expect(lastIndex, 2);
+ });
});
group('trips list', () {
@@ -397,8 +494,86 @@ void main() {
expect(find.byKey(const Key('trip-1')), findsOneWidget);
expect(find.text('Finished'), findsOneWidget);
- // Only the completed one.
- expect(find.byType(Card), findsOneWidget);
+ // Only the completed one. UI-07: trip cards are GlassPanel-based, not Card --
+ // RideMap's thumbnail is unique to a trip card (unlike GlassPanel/Material, which
+ // other chrome on this screen also uses).
+ expect(find.byType(RideMap), findsOneWidget);
+ });
+
+ screenTest('search narrows the visible list to matching rides (UI-07)',
+ (tester) async {
+ await seedCompletedTrip(
+ startedAt: 1000, endedAt: 5000, name: 'Coast loop');
+ await seedCompletedTrip(
+ startedAt: 100000, endedAt: 200000, name: 'Mountain climb');
+
+ await tester.pumpWidget(host(const TripsScreen()));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Coast loop'), findsOneWidget);
+ expect(find.text('Mountain climb'), findsOneWidget);
+
+ await tester.enterText(find.byKey(const Key('ride-search')), 'coast');
+ await tester.pump();
+
+ expect(find.text('Coast loop'), findsOneWidget);
+ expect(find.text('Mountain climb'), findsNothing);
+ });
+
+ screenTest('filtering by activity narrows the visible list (UI-07)',
+ (tester) async {
+ await repo.startTrip(1000, activity: Activity.bicycle).then(
+ (h) async {
+ await repo.appendPoints([
+ TrackPoint(
+ tripId: h.tripId,
+ segmentId: h.segmentId,
+ timestamp: 1000,
+ latitude: 51.0,
+ longitude: -114.0,
+ speedKmh: 20,
+ altitudeM: 1000,
+ ),
+ ]);
+ await repo.renameTrip(h.tripId, 'Bike ride');
+ await repo.completeTrip(5000);
+ await repo.recomputeAggregates(h.tripId);
+ });
+ await repo.startTrip(100000, activity: Activity.motorcycle).then(
+ (h) async {
+ await repo.appendPoints([
+ TrackPoint(
+ tripId: h.tripId,
+ segmentId: h.segmentId,
+ timestamp: 100000,
+ latitude: 51.0,
+ longitude: -114.0,
+ speedKmh: 60,
+ altitudeM: 1000,
+ ),
+ ]);
+ await repo.renameTrip(h.tripId, 'Moto ride');
+ await repo.completeTrip(200000);
+ await repo.recomputeAggregates(h.tripId);
+ });
+
+ await tester.pumpWidget(host(const TripsScreen()));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Bike ride'), findsOneWidget);
+ expect(find.text('Moto ride'), findsOneWidget);
+
+ await tester.tap(find.byKey(const Key('activity-filter')));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 300));
+ await tester.pump();
+ await tester.tap(find.byKey(const Key('activity-filter-bicycle')));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 300));
+ await tester.pump();
+
+ expect(find.text('Bike ride'), findsOneWidget);
+ expect(find.text('Moto ride'), findsNothing);
});
screenTest('Merge enables at exactly two selections', (tester) async {