Refresh the Flutter snapshot: UI-01 through UI-09, plus a fresh installable APK

Full UI redesign pass complete: persistent tab shell with an always-visible
background map, Modern Professional Dark theme, monochrome dark map tiles,
offline skeleton map, a shared GlassPanel/FloatingPill component kit,
customizable HUD telemetry widgets, and the Map HUD / Plan & Route Planning /
Rides History screen rebuilds. 374 tests passing, up from 316.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
This commit is contained in:
2026-08-24 14:41:35 -05:00
parent 4c634354bd
commit 587f75eab4
42 changed files with 4401 additions and 89 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -0,0 +1,317 @@
<!DOCTYPE html>
<html class="dark" lang="en" style=""><head></head><body class="antialiased selection:bg-primary-container selection:text-on-primary-container flex flex-col h-screen relative text-on-surface bg-surface"><svg aria-hidden="true" class="inline-defs-container" style="position:absolute;width:0;height:0;overflow:hidden"></svg>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport"/>
<title>RIPPR - Record Activity</title>
<link href="https://fonts.googleapis.com" rel="preconnect"/>
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;family=JetBrains+Mono:wght@400;500;700&amp;family=Sora:wght@400;600;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"secondary-fixed-dim": "#aec7f6",
"primary-fixed": "#d6e3ff",
"tertiary-fixed-dim": "#ffb68c",
"inverse-surface": "#e5e2e1",
"on-surface-muted": "#9ca3af",
"error": "#ffb4ab",
"inverse-primary": "#005db8",
"on-primary-fixed-variant": "#00458d",
"secondary": "#aec7f6",
"primary-fixed-dim": "#aac7ff",
"surface-container": "#201f1f",
"primary": "#aac7ff",
"tertiary-container": "#e3711f",
"surface-variant": "#353534",
"on-background": "#e5e2e1",
"on-secondary-fixed-variant": "#2e476f",
"primary-container": "#4090fe",
"outline-variant": "#414753",
"background": "#131313",
"surface": "#131313",
"on-tertiary": "#532200",
"on-tertiary-fixed": "#321200",
"on-surface": "#e5e2e1",
"surface-container-low": "#1c1b1b",
"on-primary-container": "#002958",
"secondary-fixed": "#d6e3ff",
"accent-error": "#ffb4ab",
"on-primary-fixed": "#001b3e",
"surface-container-lowest": "#0e0e0e",
"surface-tint": "#aac7ff",
"error-container": "#93000a",
"secondary-container": "#2e476f",
"surface-main": "#0a0a0c",
"tertiary-fixed": "#ffdbc9",
"surface-container-high": "#2a2a2a",
"on-primary": "#002f64",
"on-surface-bright": "#ffffff",
"on-tertiary-container": "#481d00",
"on-error": "#690005",
"surface-card": "#16161a",
"surface-container-highest": "#353534",
"surface-bright": "#393939",
"on-secondary-container": "#9db6e4",
"on-tertiary-fixed-variant": "#763400",
"on-error-container": "#ffdad6",
"on-secondary-fixed": "#001b3d",
"tertiary": "#ffb68c",
"outline": "#8b919f",
"surface-dim": "#131313",
"on-secondary": "#143057",
"inverse-on-surface": "#313030",
"on-surface-variant": "#c1c6d5"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
"spacing": {
"margin-mobile": "16px",
"gutter": "24px",
"margin-desktop": "32px",
"base": "4px",
"max-width": "1280px"
},
"fontFamily": {
"headline-md": [
"Inter"
],
"label-sm": [
"Inter"
],
"body-lg": [
"Inter"
],
"body-md": [
"Inter"
],
"headline-sm": [
"Inter"
],
"headline-lg": [
"Inter"
],
"headline-lg-mobile": [
"Inter"
],
"label-md": [
"Inter"
],
"display-telemetry": ["Inter"],
"label-caps": ["Inter"]
},
"fontSize": {
"headline-md": [
"24px",
{
"lineHeight": "32px",
"fontWeight": "600"
}
],
"label-sm": [
"11px",
{
"lineHeight": "16px",
"letterSpacing": "0.5px",
"fontWeight": "500"
}
],
"body-lg": [
"16px",
{
"lineHeight": "24px",
"fontWeight": "400"
}
],
"body-md": [
"14px",
{
"lineHeight": "20px",
"fontWeight": "400"
}
],
"headline-sm": [
"20px",
{
"lineHeight": "28px",
"fontWeight": "600"
}
],
"headline-lg": [
"32px",
{
"lineHeight": "40px",
"letterSpacing": "-0.02em",
"fontWeight": "700"
}
],
"headline-lg-mobile": [
"28px",
{
"lineHeight": "36px",
"letterSpacing": "-0.01em",
"fontWeight": "700"
}
],
"label-md": [
"12px",
{
"lineHeight": "16px",
"letterSpacing": "0.5px",
"fontWeight": "500"
}
],
"display-telemetry": ["64px", { "lineHeight": "64px", "letterSpacing": "-0.04em", "fontWeight": "700" }],
"label-caps": ["12px", { "lineHeight": "16px", "letterSpacing": "0.1em", "fontWeight": "500" }]
}
},
},
}
</script>
<style>
body { margin: 0; overflow: hidden; background-color: #131313; color: #e5e2e1; min-height: max(884px, 100dvh); }
.hud-panel { background: rgba(32, 31, 31, 0.8); backdrop-filter: blur(20px); border: 1px solid rgba(255, 255, 255, 0.1); }
.glass-btn { background: rgba(32, 31, 31, 0.9); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.1); }
</style>
<!-- Background Map Placeholder -->
<div class="absolute inset-0 z-0 bg-surface">
<div class="w-full h-full bg-cover bg-center opacity-40 mix-blend-luminosity" data-alt="A light mode stylized city map view for a fitness tracking application. Minimalist aesthetic with high contrast blue routes highlighting paths against light gray blocks representing buildings and terrain. The map appears like a clean interface overlay." data-location="Tokyo" style="background-image: url('https://lh3.googleusercontent.com/aida-public/AB6AXuDr5_s-KgoDdw3V0jHBFYtHVB3aikGStl0j7TM7kPJnauzs3U1eRnVMaMh4ywSZTMLAo_LxEZnu9rTzBPq-EGev_TiD3t0VY9osDcW0xKKUZxL2rx7TPn0qh2lor7H-TT1FWRur2eVHGsIsu-YT3ht-zwG1VrgIArB7Dxx5IgNThsHCroZ6oFbBB_Uo1Y8rIjPn8NPkxuUVxmKjpNze6RAmNCuYj9V3ivWdnkUNS_Z6ybPWERqbvRA4')"></div>
<!-- Routes and Location Overlay -->
<svg class="absolute inset-0 w-full h-full pointer-events-none" preserveaspectratio="xMidYMid slice" viewbox="0 0 400 800">
<defs>
<lineargradient id="speedGradient" x1="10%" x2="50%" y1="90%" y2="50%">
<stop offset="0%" stop-color="#4090fe"></stop>
<stop offset="40%" stop-color="#aac7ff"></stop>
<stop offset="70%" stop-color="#ffb4ab"></stop>
<stop offset="100%" stop-color="#ffb4ab"></stop>
</lineargradient>
</defs>
<!-- Planned Route -->
<path d="M 200 400 C 250 250, 150 150, 300 50" fill="none" opacity="0.8" stroke="#8b919f" stroke-dasharray="10 10" stroke-linecap="round" stroke-linejoin="round" stroke-width="6"></path>
<!-- Completed Route -->
<path d="M 100 750 C 150 600, 100 500, 200 400" fill="none" stroke="url(#speedGradient)" stroke-linecap="round" stroke-linejoin="round" stroke-width="6"></path>
<!-- Pulsing Location Dot -->
<circle cx="200" cy="400" fill="#4090fe" r="8"></circle>
<circle cx="200" cy="400" fill="none" r="8" stroke="#4090fe" stroke-width="2">
<animate attributename="r" dur="1.5s" from="8" repeatcount="indefinite" to="24"></animate>
<animate attributename="opacity" dur="1.5s" from="1" repeatcount="indefinite" to="0"></animate>
</circle>
</svg>
</div>
<!-- TopAppBar JSON Component -->
<!-- Main Content Canvas (HUD Layout) -->
<main class="flex-1 relative z-10 flex flex-col pb-40 px-2 md:px-margin-desktop w-full max-w-7xl mx-auto md:ml-24 md:w-[calc(100%-6rem)] p-2">
<!-- Compact Telemetry Row -->
<div class="flex flex-row gap-1 w-full" id="telemetry-container">
<div class="metric-card flex-1 hud-panel p-2 rounded-lg flex flex-col items-center justify-center cursor-pointer transition-all duration-300 overflow-hidden min-w-0">
<span class="metric-title font-label-caps text-[9px] sm:text-[10px] text-on-surface-variant uppercase whitespace-nowrap truncate w-full text-center">Speed</span>
<div class="metric-value font-display-telemetry text-base text-primary mt-1 transition-all duration-300 whitespace-nowrap">24.5 km/h</div>
</div>
<div class="metric-card flex-1 hud-panel p-2 rounded-lg flex flex-col items-center justify-center cursor-pointer transition-all duration-300 overflow-hidden min-w-0">
<span class="metric-title font-label-caps text-[9px] sm:text-[10px] text-on-surface-variant uppercase whitespace-nowrap truncate w-full text-center">Avg Speed</span>
<div class="metric-value font-display-telemetry text-lg text-on-surface mt-1 transition-all duration-300">22.1</div>
</div>
<div class="metric-card flex-1 hud-panel p-2 rounded-lg flex flex-col items-center justify-center cursor-pointer transition-all duration-300 overflow-hidden min-w-0">
<span class="metric-title font-label-caps text-[9px] sm:text-[10px] text-on-surface-variant uppercase whitespace-nowrap truncate w-full text-center">Dist</span>
<div class="metric-value font-display-telemetry text-base text-secondary mt-1 transition-all duration-300 whitespace-nowrap">12.4 km</div>
</div>
<div class="metric-card flex-1 hud-panel p-2 rounded-lg flex flex-col items-center justify-center cursor-pointer transition-all duration-300 overflow-hidden min-w-0">
<span class="metric-title font-label-caps text-[9px] sm:text-[10px] text-on-surface-variant uppercase whitespace-nowrap truncate w-full text-center">Time</span>
<div class="metric-value font-display-telemetry text-lg text-on-surface mt-1 transition-all duration-300">45:12</div>
</div>
</div>
</main>
<!-- Full Width Recording Control Bar -->
<div class="fixed bottom-[80px] md:bottom-0 md:left-24 w-full md:w-[calc(100%-6rem)] z-40">
<div class="flex flex-row w-full h-16 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.5)]">
<button class="flex-1 bg-tertiary-container text-on-tertiary-container font-headline-md flex items-center justify-center transition-colors duration-300 border-r border-white/10"><span class="material-symbols-outlined text-[32px]" style='font-variation-settings: "FILL" 1;'>pause</span></button>
<button class="flex-1 bg-error-container text-on-error-container font-headline-md flex items-center justify-center transition-colors duration-300"><span class="material-symbols-outlined text-[32px]" style='font-variation-settings: "FILL" 1;'>stop</span></button>
</div>
</div>
<!-- BottomNavBar JSON Component -->
<nav class="bg-surface/80 backdrop-blur-xl fixed bottom-0 w-full z-50 pb-safe border-t border-white/10 md:hidden">
<div class="flex justify-around items-center h-20 px-margin-mobile font-label-caps text-label-caps font-display-telemetry"><button class="flex flex-col items-center justify-center text-primary relative after:content-[''] after:absolute after:-bottom-1 after:w-1 after:h-1 after:bg-primary after:rounded-full hover:text-primary/80 transition-transform duration-150 scale-95">
<span class="material-symbols-outlined" style='font-variation-settings: "FILL" 1;'>map</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary/80 transition-transform duration-150"><span class="material-symbols-outlined" style='font-variation-settings: "FILL" 0;'>book</span></button>
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary/80 transition-transform duration-150">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 0;">route</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary/80 transition-transform duration-150">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 0;">settings</span>
</button></div>
</nav>
<!-- Desktop Side Nav (Visible only on md+) -->
<aside class="hidden md:flex flex-col fixed left-0 top-16 bottom-0 w-24 bg-surface/80 backdrop-blur-xl border-r border-white/10 z-40 items-center py-8 gap-12">
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary/80 transition-colors w-full py-4 font-label-caps text-label-caps">
<span class="material-symbols-outlined mb-2 text-2xl">map</span>
Map
</button>
<button class="flex flex-col items-center justify-center text-primary relative after:content-[''] after:absolute after:left-0 after:top-1/2 after:-translate-y-1/2 after:w-1 after:h-8 after:bg-primary after:rounded-r-full hover:text-primary/80 transition-colors w-full py-4 font-label-caps text-label-caps bg-white/5">
<span class="material-symbols-outlined mb-2 text-2xl" style="font-variation-settings: 'FILL' 1;">directions_bike</span>
Rides
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary/80 transition-colors w-full py-4 font-label-caps text-label-caps">
<span class="material-symbols-outlined mb-2 text-2xl">route</span>
Plan
</button>
</aside>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Recording Toggle Logic
const toggleBtn = document.getElementById('recordingToggle');
const toggleIcon = document.getElementById('toggleIcon');
const toggleText = document.getElementById('toggleText');
let isRecording = true;
toggleBtn?.addEventListener('click', () => {
isRecording = !isRecording;
if(isRecording) {
toggleBtn.classList.remove('bg-error-container', 'text-on-error-container');
toggleBtn.classList.add('bg-primary-container', 'text-on-primary-container');
toggleIcon.textContent = 'stop_circle';
toggleText.textContent = 'Stop Recording';
} else {
toggleBtn.classList.remove('bg-primary-container', 'text-on-primary-container');
toggleBtn.classList.add('bg-error-container', 'text-on-error-container');
toggleIcon.textContent = 'play_arrow';
toggleText.textContent = 'Start Recording';
}
});
// Telemetry Expansion Logic
const metrics = document.querySelectorAll('.metric-card');
metrics.forEach(metric => {
metric.addEventListener('click', () => {
const isExpanded = metric.classList.contains('flex-[2.5]');
// Reset all
metrics.forEach(m => {
m.classList.remove('flex-[2.5]', 'bg-surface-container-highest');
m.classList.add('flex-1');
m.querySelector('.metric-value').classList.remove('text-2xl', 'md:text-3xl');
m.querySelector('.metric-value').classList.add('text-lg');
});
// Expand if it wasn't already expanded
if (!isExpanded) {
metric.classList.remove('flex-1');
metric.classList.add('flex-[2.5]', 'bg-surface-container-highest');
metric.querySelector('.metric-value').classList.remove('text-lg');
metric.querySelector('.metric-value').classList.add('text-2xl', 'md:text-3xl');
}
});
});
});
</script>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -0,0 +1,336 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>RIPPR - Plan Route</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&amp;family=JetBrains+Mono:wght@400;500;700&amp;family=Sora:wght@600;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"secondary-fixed-dim": "#aec7f6",
"primary-fixed": "#d6e3ff",
"tertiary-fixed-dim": "#ffb68c",
"inverse-surface": "#e5e2e1",
"on-surface-muted": "#9ca3af",
"error": "#ffb4ab",
"inverse-primary": "#005db8",
"on-primary-fixed-variant": "#00458d",
"secondary": "#aec7f6",
"primary-fixed-dim": "#aac7ff",
"surface-container": "#201f1f",
"primary": "#aac7ff",
"tertiary-container": "#e3711f",
"surface-variant": "#353534",
"on-background": "#e5e2e1",
"on-secondary-fixed-variant": "#2e476f",
"primary-container": "#4090fe",
"outline-variant": "#414753",
"background": "#131313",
"surface": "#131313",
"on-tertiary": "#532200",
"on-tertiary-fixed": "#321200",
"on-surface": "#e5e2e1",
"surface-container-low": "#1c1b1b",
"on-primary-container": "#002958",
"secondary-fixed": "#d6e3ff",
"accent-error": "#ffb4ab",
"on-primary-fixed": "#001b3e",
"surface-container-lowest": "#0e0e0e",
"surface-tint": "#aac7ff",
"error-container": "#93000a",
"secondary-container": "#2e476f",
"surface-main": "#0a0a0c",
"tertiary-fixed": "#ffdbc9",
"surface-container-high": "#2a2a2a",
"on-primary": "#002f64",
"on-surface-bright": "#ffffff",
"on-tertiary-container": "#481d00",
"on-error": "#690005",
"surface-card": "#16161a",
"surface-container-highest": "#353534",
"surface-bright": "#393939",
"on-secondary-container": "#9db6e4",
"on-tertiary-fixed-variant": "#763400",
"on-error-container": "#ffdad6",
"on-secondary-fixed": "#001b3d",
"tertiary": "#ffb68c",
"outline": "#8b919f",
"surface-dim": "#131313",
"on-secondary": "#143057",
"inverse-on-surface": "#313030",
"on-surface-variant": "#c1c6d5"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
"spacing": {
"margin-mobile": "16px",
"gutter": "24px",
"margin-desktop": "32px",
"base": "4px",
"max-width": "1280px",
"unit": "4px",
"stack-sm": "8px",
"stack-lg": "48px",
"stack-md": "24px",
"container-margin": "20px"
},
"fontFamily": {
"headline-md": [
"Inter"
],
"label-sm": [
"Inter"
],
"body-lg": [
"Inter"
],
"body-md": [
"Inter"
],
"headline-sm": [
"Inter"
],
"headline-lg": [
"Inter"
],
"headline-lg-mobile": [
"Inter"
],
"label-md": [
"Inter"
],
"label-caps": ["JetBrains Mono"],
"display-telemetry-mobile": ["JetBrains Mono"],
"display-telemetry": ["JetBrains Mono"]
},
"fontSize": {
"headline-md": [
"24px",
{
"lineHeight": "32px",
"fontWeight": "600"
}
],
"label-sm": [
"11px",
{
"lineHeight": "16px",
"letterSpacing": "0.5px",
"fontWeight": "500"
}
],
"body-lg": [
"16px",
{
"lineHeight": "24px",
"fontWeight": "400"
}
],
"body-md": [
"14px",
{
"lineHeight": "20px",
"fontWeight": "400"
}
],
"headline-sm": [
"20px",
{
"lineHeight": "28px",
"fontWeight": "600"
}
],
"headline-lg": [
"32px",
{
"lineHeight": "40px",
"letterSpacing": "-0.02em",
"fontWeight": "700"
}
],
"headline-lg-mobile": [
"28px",
{
"lineHeight": "36px",
"letterSpacing": "-0.01em",
"fontWeight": "700"
}
],
"label-md": [
"12px",
{
"lineHeight": "16px",
"letterSpacing": "0.5px",
"fontWeight": "500"
}
],
"label-caps": ["12px", { "lineHeight": "16px", "letterSpacing": "0.1em", "fontWeight": "500" }],
"display-telemetry-mobile": ["48px", { "lineHeight": "48px", "fontWeight": "700" }],
"display-telemetry": ["64px", { "lineHeight": "64px", "letterSpacing": "-0.04em", "fontWeight": "700" }]
}
},
},
}
</script>
<style>
.map-grid-overlay {
background-image:
linear-gradient(to right, rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 40px 40px;
}
.pulse-ring {
animation: pulse-ring 2s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
}
@keyframes pulse-ring {
0% {
transform: scale(0.8);
opacity: 0.8;
box-shadow: 0 0 0 0 rgba(170, 199, 255, 0.7);
}
70% {
transform: scale(1);
opacity: 0;
box-shadow: 0 0 0 20px rgba(170, 199, 255, 0);
}
100% {
transform: scale(0.8);
opacity: 0;
box-shadow: 0 0 0 0 rgba(170, 199, 255, 0);
}
}
.route-line {
stroke-dasharray: 10, 10;
animation: dash 20s linear infinite;
}
@keyframes dash {
to {
stroke-dashoffset: -1000;
}
}
.tooltip-float {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0% { transform: translateY(0px); }
50% { transform: translateY(-8px); }
100% { transform: translateY(0px); }
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background text-on-surface h-screen w-full overflow-hidden relative selection:bg-primary-container selection:text-on-primary-container antialiased flex flex-col dark">
<!-- Fullscreen Map Canvas -->
<main class="flex-grow relative h-full w-full z-0 overflow-hidden">
<!-- Base Map Layer -->
<div class="absolute inset-0 z-0 bg-surface-container-lowest">
<img class="w-full h-full object-cover opacity-60 mix-blend-screen invert" data-alt="A dark mode digital map showing a top-down view of city streets and terrain. The map is rendered in dark grey and black tones with subtle lighter outlines for roads." data-location="San Francisco, CA" src="https://lh3.googleusercontent.com/aida-public/AB6AXuA-oqjvzL6bUKZVJD2vvMu-0LPsY-GmGnGrfOmA0Pv5d3jBrTEuOXQ2lhZUXb9SvIGOnr2Wz64OSx0LGVbxce3zkTseW9Q62p9djWl96s-LaFizAtCBJ7okbDdDDBZVyS8RutWkvabaIwEi_lE9yq26OZuanhZ7NS6Ny4AZx69Sah-DvoE1nzwD2AEEpWJXbfX_KRFa3iGbCUa02sKaAUWzoirpWg5yU6Gk6u2OWNRefC6YtASxYmBq"/>
</div>
<!-- Technical Grid Overlay -->
<div class="absolute inset-0 map-grid-overlay z-10 pointer-events-none"></div>
<!-- Planned Route Overlay (SVG) -->
<svg class="absolute inset-0 w-full h-full z-20 pointer-events-none" preserveaspectratio="xMidYMid slice" viewbox="0 0 1000 1000">
<!-- Path connecting dropped pins - Primary Blue -->
<path class="route-line opacity-80" d="M 300 700 Q 400 650 450 500 T 700 300" fill="none" stroke="#aac7ff" stroke-linecap="round" stroke-linejoin="round" stroke-width="6"></path>
<!-- Waypoint 1 (Dropped Pin) -->
<circle cx="450" cy="500" fill="#aac7ff" r="6" stroke="#131313" stroke-width="2"></circle>
<!-- Waypoint 2 (Destination) -->
<circle cx="700" cy="300" fill="#131313" r="8" stroke="#aac7ff" stroke-width="3"></circle>
</svg>
<!-- User Location Marker (Pulsing) -->
<div class="absolute top-[70%] left-[30%] z-30 transform -translate-x-1/2 -translate-y-1/2 flex items-center justify-center pointer-events-none">
<div class="absolute w-4 h-4 bg-primary rounded-full opacity-30"></div>
<div class="absolute w-3 h-3 bg-primary rounded-full pulse-ring"></div>
<div class="w-2 h-2 bg-primary rounded-full relative z-10 shadow-[0_0_10px_rgba(170,199,255,0.8)]"></div>
<!-- Vision cone indicator -->
<div class="absolute -top-12 left-1/2 -translate-x-1/2 w-0 h-0 border-l-[20px] border-l-transparent border-r-[20px] border-r-transparent border-t-[40px] border-t-primary/20 origin-bottom transform rotate-45"></div>
</div>
<!-- Interactive Map Canvas Layer (Invisible, captures clicks for dropping pins) -->
<div class="absolute inset-0 z-40 cursor-crosshair" id="map-interaction-layer"></div>
</main>
<!-- Floating HUD Elements Layer -->
<div class="absolute inset-0 z-50 pointer-events-none flex flex-col justify-end">
<!-- Tooltip -->
<div class="w-full flex justify-center mb-stack-md pointer-events-none px-container-margin">
<div class="tooltip-float bg-surface-container-high/90 backdrop-blur-xl border border-outline-variant/30 rounded-full px-6 py-3 flex items-center gap-2 shadow-sm">
<span class="material-symbols-outlined text-primary text-xl" style="font-variation-settings: 'FILL' 1;">location_on</span>
<span class="font-label-caps text-label-caps text-on-surface">Tap to drop a pin</span>
</div>
</div>
<!-- Bottom Navigation Bar -->
<nav class="bg-surface-container/90 backdrop-blur-xl border-t border-outline-variant/20 w-full pb-safe flex justify-around items-center h-20 px-container-margin pointer-events-auto shrink-0 relative shadow-[0_-4px_20px_rgba(0,0,0,0.5)]">
<!-- Map (Inactive) -->
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary transition-colors duration-200 w-16 h-full gap-1 group">
<span class="material-symbols-outlined text-2xl group-active:scale-95 transition-transform duration-150">map</span>
<span class="font-label-caps text-[10px] uppercase tracking-widest">Map</span>
</button>
<!-- Rides (Inactive) -->
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary transition-colors duration-200 w-16 h-full gap-1 group">
<span class="material-symbols-outlined text-2xl group-active:scale-95 transition-transform duration-150">directions_bike</span>
<span class="font-label-caps text-[10px] uppercase tracking-widest">Rides</span>
</button>
<!-- Plan (Active) -->
<button class="flex flex-col items-center justify-center text-primary relative after:content-[''] after:absolute after:bottom-2 after:w-1 after:h-1 after:bg-primary after:rounded-full hover:text-primary transition-colors duration-200 w-16 h-full gap-1 group">
<span class="material-symbols-outlined text-2xl group-active:scale-95 transition-transform duration-150" style="font-variation-settings: 'FILL' 1;">route</span>
<span class="font-label-caps text-[10px] uppercase tracking-widest text-primary">Plan</span>
</button>
</nav>
</div>
<!-- Micro-interaction Script for Pin Dropping Simulation -->
<script>
document.addEventListener('DOMContentLoaded', () => {
const mapLayer = document.getElementById('map-interaction-layer');
const svgLayer = document.querySelector('svg');
let pinCount = 0;
mapLayer.addEventListener('click', (e) => {
const rect = mapLayer.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Visual feedback of drop
const ripple = document.createElement('div');
ripple.className = 'absolute rounded-full border-2 border-primary bg-primary/20 pointer-events-none transform -translate-x-1/2 -translate-y-1/2';
ripple.style.left = `${x}px`;
ripple.style.top = `${y}px`;
ripple.style.width = '0px';
ripple.style.height = '0px';
ripple.style.opacity = '1';
ripple.style.transition = 'all 0.5s cubic-bezier(0.165, 0.84, 0.44, 1)';
mapLayer.parentElement.appendChild(ripple);
// Trigger animation
requestAnimationFrame(() => {
ripple.style.width = '60px';
ripple.style.height = '60px';
ripple.style.opacity = '0';
});
// Clean up ripple
setTimeout(() => ripple.remove(), 500);
});
});
</script>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

View File

@@ -0,0 +1,307 @@
<!DOCTYPE html>
<html class="dark" lang="en" style=""><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>RIPPR - Rides</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;family=JetBrains+Mono:wght@400;500;700&amp;family=Sora:wght@400;600;700&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"secondary-fixed-dim": "#aec7f6",
"primary-fixed": "#d6e3ff",
"tertiary-fixed-dim": "#ffb68c",
"inverse-surface": "#e5e2e1",
"on-surface-muted": "#9ca3af",
"error": "#ffb4ab",
"inverse-primary": "#005db8",
"on-primary-fixed-variant": "#00458d",
"secondary": "#aec7f6",
"primary-fixed-dim": "#aac7ff",
"surface-container": "#201f1f",
"primary": "#aac7ff",
"tertiary-container": "#e3711f",
"surface-variant": "#353534",
"on-background": "#e5e2e1",
"on-secondary-fixed-variant": "#2e476f",
"primary-container": "#4090fe",
"outline-variant": "#414753",
"background": "#131313",
"surface": "#131313",
"on-tertiary": "#532200",
"on-tertiary-fixed": "#321200",
"on-surface": "#e5e2e1",
"surface-container-low": "#1c1b1b",
"on-primary-container": "#002958",
"secondary-fixed": "#d6e3ff",
"accent-error": "#ffb4ab",
"on-primary-fixed": "#001b3e",
"surface-container-lowest": "#0e0e0e",
"surface-tint": "#aac7ff",
"error-container": "#93000a",
"secondary-container": "#2e476f",
"surface-main": "#0a0a0c",
"tertiary-fixed": "#ffdbc9",
"surface-container-high": "#2a2a2a",
"on-primary": "#002f64",
"on-surface-bright": "#ffffff",
"on-tertiary-container": "#481d00",
"on-error": "#690005",
"surface-card": "#16161a",
"surface-container-highest": "#353534",
"surface-bright": "#393939",
"on-secondary-container": "#9db6e4",
"on-tertiary-fixed-variant": "#763400",
"on-error-container": "#ffdad6",
"on-secondary-fixed": "#001b3d",
"tertiary": "#ffb68c",
"outline": "#8b919f",
"surface-dim": "#131313",
"on-secondary": "#143057",
"inverse-on-surface": "#313030",
"on-surface-variant": "#c1c6d5"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
"spacing": {
"margin-mobile": "16px",
"gutter": "24px",
"margin-desktop": "32px",
"base": "4px",
"max-width": "1280px"
},
"fontFamily": {
"headline-md": [
"Inter"
],
"label-sm": [
"Inter"
],
"body-lg": [
"Inter"
],
"body-md": [
"Inter"
],
"headline-sm": [
"Inter"
],
"headline-lg": [
"Inter"
],
"headline-lg-mobile": [
"Inter"
],
"label-md": [
"Inter"
]
},
"fontSize": {
"headline-md": [
"24px",
{
"lineHeight": "32px",
"fontWeight": "600"
}
],
"label-sm": [
"11px",
{
"lineHeight": "16px",
"letterSpacing": "0.5px",
"fontWeight": "500"
}
],
"body-lg": [
"16px",
{
"lineHeight": "24px",
"fontWeight": "400"
}
],
"body-md": [
"14px",
{
"lineHeight": "20px",
"fontWeight": "400"
}
],
"headline-sm": [
"20px",
{
"lineHeight": "28px",
"fontWeight": "600"
}
],
"headline-lg": [
"32px",
{
"lineHeight": "40px",
"letterSpacing": "-0.02em",
"fontWeight": "700"
}
],
"headline-lg-mobile": [
"28px",
{
"lineHeight": "36px",
"letterSpacing": "-0.01em",
"fontWeight": "700"
}
],
"label-md": [
"12px",
{
"lineHeight": "16px",
"letterSpacing": "0.5px",
"fontWeight": "500"
}
]
}
},
},
}
</script>
<style type="text/tailwindcss">
@layer utilities {
.glass-panel {
@apply bg-surface/80 backdrop-blur-[20px] border border-outline-variant;
}
}
</style>
</head>
<body class="bg-background text-on-surface min-h-screen pb-[100px]"><div class="fixed inset-0 z-0 bg-surface"><div class="w-full h-full bg-cover bg-center opacity-30 backdrop-blur-md backdrop-blur-xl" data-alt="A light mode stylized city map view for a fitness tracking application." data-location="Tokyo" style='background-image: url("https://lh3.googleusercontent.com/aida-public/AB6AXuDr5_s-KgoDdw3V0jHBFYtHVB3aikGStl0j7TM7kPJnauzs3U1eRnVMaMh4ywSZTMLAo_LxEZnu9rTzBPq-EGev_TiD3t0VY9osDcW0xKKUZxL2rx7TPn0qh2lor7H-TT1FWRur2eVHGsIsu-YT3ht-zwG1VrgIArB7Dxx5IgNThsHCroZ6oFbBB_Uo1Y8rIjPn8NPkxuUVxmKjpNze6RAmNCuYj9V3ivWdnkUNS_Z6ybPWERqbvRA4");'></div><svg class="absolute inset-0 w-full h-full pointer-events-none" preserveaspectratio="xMidYMid slice" viewbox="0 0 400 800"><path d="M 200 400 C 250 250, 150 150, 300 50" fill="none" opacity="0.3" stroke="#aac7ff" stroke-dasharray="10 10" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"></path></svg></div>
<!-- TopAppBar -->
<!-- Main Content -->
<main class="px-margin-mobile max-w-4xl mx-auto flex flex-col gap-6 pt-2 relative z-10">
<!-- Search & Filter -->
<div class="flex gap-gutter items-center p-2 rounded-lg border border-outline-variant bg-surface-container-lowest shadow-sm">
<div class="flex-1 relative">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant">search</span>
<input class="w-full bg-surface-container-lowest border-b border-outline-variant focus:border-primary text-on-surface pl-10 pr-4 py-2 font-body-md text-body-md outline-none transition-colors placeholder:text-on-surface-variant/70" placeholder="Search rides..." type="text"/>
</div>
<button class="w-10 h-10 flex items-center justify-center rounded hover:bg-surface-container-highest transition-colors text-primary border border-outline-variant shadow-sm bg-surface-container-lowest">
<span class="material-symbols-outlined">tune</span>
</button>
</div>
<!-- Summary Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-gutter mb-2 z-10">
<div class="p-4 rounded-lg flex flex-col gap-1 bg-surface-container-lowest border border-outline-variant shadow-sm"><span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant uppercase">Total Dist</span><span class="font-headline-md text-headline-md text-primary">482<span class="text-label-sm tracking-wider ml-1 text-on-surface-variant">KM</span></span></div>
<div class="p-4 rounded-lg flex flex-col gap-1 bg-surface-container-lowest border border-outline-variant shadow-sm"><span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant uppercase">Total Dist</span><span class="font-headline-md text-headline-md text-primary">482<span class="text-label-sm tracking-wider ml-1 text-on-surface-variant">KM</span></span></div>
<div class="p-4 rounded-lg flex flex-col gap-1 bg-surface-container-lowest border border-outline-variant shadow-sm"><span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant uppercase">Total Dist</span><span class="font-headline-md text-headline-md text-primary">482<span class="text-label-sm tracking-wider ml-1 text-on-surface-variant">KM</span></span></div>
<div class="p-4 rounded-lg flex flex-col gap-1 bg-surface-container-lowest border border-outline-variant shadow-sm"><span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant uppercase">Total Dist</span><span class="font-headline-md text-headline-md text-primary">482<span class="text-label-sm tracking-wider ml-1 text-on-surface-variant">KM</span></span></div>
</div>
<!-- Rides List -->
<div class="flex flex-col gap-gutter">
<!-- Ride Item 1 -->
<div class="rounded-lg flex flex-col md:flex-row overflow-hidden group cursor-pointer hover:border-primary/50 transition-colors bg-surface-container-lowest border border-outline-variant shadow-sm">
<div class="h-32 md:h-auto md:w-48 bg-surface-container-highest relative">
<img class="w-full h-full object-cover opacity-90 group-hover:opacity-100 transition-opacity" data-alt="A dark mode minimalist map view showing a neon green GPS route line traversing through city blocks. High contrast, technical aesthetic, muted dark charcoal background." data-location="San Francisco" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAx1uDTgkMgXUBwI-JLBmH1_3Mw1sk91ovk-19wP19I5wktrsuJ9nM757sCThvGGNFlWi88pEzXj9cB_wlHAhQoTdkoXYp1csaGHd6e_iulV84cPEhZNlj6_M-EWzoEdv7CT-q9BJLlkOBPq9D5IaX6mQk1nqZQDfeR0k535iIYJ2rXJFCyAAgCEF0gKEYyumLsUopkQMD2ig13W01XANoAJPCP--li-S1iln0VfzpqY-vmyHLZsO9H"/>
<div class="absolute inset-0 bg-gradient-to-t from-surface/80 to-transparent md:hidden"></div>
</div>
<div class="p-4 flex flex-col justify-between flex-1 gap-4">
<div>
<h3 class="font-headline-sm text-headline-sm text-on-surface mb-1">Morning Circuit</h3>
<p class="font-body-md text-body-md text-on-surface-variant">Oct 24, 06:30 AM</p>
</div>
<div class="flex gap-6">
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">DISTANCE</span>
<span class="font-headline-sm text-headline-sm text-primary">42.5 KM</span>
</div>
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">TIME</span>
<span class="font-headline-sm text-headline-sm text-primary">1:45:22</span>
</div>
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">AVG SPD</span>
<span class="font-headline-sm text-headline-sm text-primary">24.2 KPH</span>
</div>
</div>
</div>
</div>
<!-- Ride Item 2 -->
<div class="rounded-lg flex flex-col md:flex-row overflow-hidden group cursor-pointer hover:border-primary/50 transition-colors bg-surface-container-lowest border border-outline-variant shadow-sm">
<div class="h-32 md:h-auto md:w-48 bg-surface-container-highest relative">
<img class="w-full h-full object-cover opacity-90 group-hover:opacity-100 transition-opacity" data-alt="A dark mode minimalist map view showing a neon green GPS route line winding through a mountainous region. High contrast, technical aesthetic, deep black background." data-location="Marin Headlands" src="https://lh3.googleusercontent.com/aida-public/AB6AXuA8dTKlnX4_8dnTgJ0UQpocYH5zvSgIxrAirD88D7z-dcBw6SziTL2p8niEuvn-Q-z1M3AwecI2VoHbFXE70wCOQEymO_yZatnGuP5BkKz-orUNu3Lpmy3ZnXrxYVUOF77CdUnY6WWo098sJre88SDaK82aid7U4mo6vcV5DtxUqQ-H9D0N6RvTzWc0gmev36tcOCKDZqlUsRiHMarZOD6QHiPfL7HzGu_KN1gdDF1n-7G0vLs6CzmA"/>
<div class="absolute inset-0 bg-gradient-to-t from-surface/80 to-transparent md:hidden"></div>
</div>
<div class="p-4 flex flex-col justify-between flex-1 gap-4">
<div>
<h3 class="font-headline-sm text-headline-sm text-on-surface mb-1">Hill Repeats</h3>
<p class="font-body-md text-body-md text-on-surface-variant">Oct 22, 17:15 PM</p>
</div>
<div class="flex gap-6">
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">DISTANCE</span>
<span class="font-headline-sm text-headline-sm text-primary">28.1 KM</span>
</div>
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">TIME</span>
<span class="font-headline-sm text-headline-sm text-primary">1:12:05</span>
</div>
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">AVG SPD</span>
<span class="font-headline-sm text-headline-sm text-primary">23.4 KPH</span>
</div>
</div>
</div>
</div>
<!-- Ride Item 3 -->
<div class="rounded-lg flex flex-col md:flex-row overflow-hidden group cursor-pointer hover:border-primary/50 transition-colors opacity-75 bg-surface-container-lowest border border-outline-variant shadow-sm">
<div class="h-32 md:h-auto md:w-48 bg-surface-container-highest relative">
<img class="w-full h-full object-cover opacity-90 group-hover:opacity-100 transition-opacity grayscale group-hover:grayscale-0" data-alt="A dark mode minimalist map view showing a neon green GPS route line along a coastal highway. High contrast, technical aesthetic, muted dark charcoal background." data-location="Highway 1" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAr-gumM8Tc0ayTzCw3s39Z1j0cydRKq7oGECkbJNXERMpAQaP4xrvoVkTVHzGe7mG83pDzxMMZGy9SpTk19uSKlnM5Vjoz3zurdx6AbWLUisqVcuB6CjXS4wt_hJlUK4TfOICbZdPN2UzCkVYUHClJ1jwfeRwA0SFVxWC2gfneSIXny-aaJMfddGQCFtCB7Xjh6hoFigOGUZB-2yjvcHcgwh_u3hZXLDETlfCtPZmyedY1zuZJ9Lft"/>
<div class="absolute inset-0 bg-gradient-to-t from-surface/80 to-transparent md:hidden"></div>
</div>
<div class="p-4 flex flex-col justify-between flex-1 gap-4">
<div>
<h3 class="font-headline-sm text-headline-sm text-on-surface mb-1">Coastal Cruise</h3>
<p class="font-body-md text-body-md text-on-surface-variant">Oct 18, 08:00 AM</p>
</div>
<div class="flex gap-6">
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">DISTANCE</span>
<span class="font-headline-sm text-headline-sm text-primary">85.4 KM</span>
</div>
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">TIME</span>
<span class="font-headline-sm text-headline-sm text-primary">3:24:10</span>
</div>
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">AVG SPD</span>
<span class="font-headline-sm text-headline-sm text-primary">25.1 KPH</span>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- BottomNavBar -->
<nav class="fixed bottom-0 w-full z-50 pb-safe bg-surface-container-lowest/80 backdrop-blur-xl border-t border-outline-variant flex justify-around items-center h-20 px-margin-mobile md:hidden"><button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary/80 transition-transform duration-150"><span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 0;">map</span></button><button class="flex flex-col items-center justify-center text-primary relative after:content-[''] after:absolute after:-bottom-1 after:w-1 after:h-1 after:bg-primary after:rounded-full hover:text-primary/80 transition-transform duration-150 scale-95"><span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">book</span></button><button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary/80 transition-transform duration-150"><span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 0;">route</span></button><button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary/80 transition-transform duration-150"><span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 0;">settings</span></button></nav>
<!-- Desktop Side Nav Overlay (Hidden on Mobile, replaces Bottom Nav visually in a real app, keeping it simple here) -->
<nav class="hidden md:flex fixed left-0 top-16 bottom-0 w-24 bg-surface-container-lowest/80 backdrop-blur-md border-r border-outline-variant flex-col items-center py-6 gap-6 z-40">
<button class="w-12 h-12 flex items-center justify-center rounded hover:bg-surface-container-highest text-on-surface-variant transition-colors group relative">
<span class="material-symbols-outlined">map</span>
<span class="absolute left-14 bg-surface-container px-2 py-1 rounded font-label-sm text-label-sm tracking-wider text-on-surface opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">Map</span>
</button>
<button class="w-12 h-12 flex items-center justify-center rounded bg-primary-container/50 text-primary transition-colors group relative border border-primary/20"><span class="material-symbols-outlined" style='font-variation-settings: "FILL" 1;'>book</span><span class="absolute left-14 bg-surface-container px-2 py-1 rounded font-label-sm text-label-sm tracking-wider text-on-surface opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">Journal</span></button>
<button class="w-12 h-12 flex items-center justify-center rounded hover:bg-surface-container-highest text-on-surface-variant transition-colors group relative">
<span class="material-symbols-outlined">route</span>
<span class="absolute left-14 bg-surface-container px-2 py-1 rounded font-label-sm text-label-sm tracking-wider text-on-surface opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">Plan</span>
</button>
</nav>
<style>
@media (min-width: 768px) {
body { padding-left: 96px; pb: 0; }
}
</style>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -0,0 +1,275 @@
<!DOCTYPE html>
<html class="dark" lang="en" style=""><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>RIPPR - Plan Route</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&amp;family=JetBrains+Mono:wght@400;500;700&amp;family=Sora:wght@600;700&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"secondary-fixed-dim": "#aec7f6",
"primary-fixed": "#d6e3ff",
"tertiary-fixed-dim": "#ffb68c",
"inverse-surface": "#e5e2e1",
"on-surface-muted": "#9ca3af",
"error": "#ffb4ab",
"inverse-primary": "#005db8",
"on-primary-fixed-variant": "#00458d",
"secondary": "#aec7f6",
"primary-fixed-dim": "#aac7ff",
"surface-container": "#201f1f",
"primary": "#aac7ff",
"tertiary-container": "#e3711f",
"surface-variant": "#353534",
"on-background": "#e5e2e1",
"on-secondary-fixed-variant": "#2e476f",
"primary-container": "#4090fe",
"outline-variant": "#414753",
"background": "#131313",
"surface": "#131313",
"on-tertiary": "#532200",
"on-tertiary-fixed": "#321200",
"on-surface": "#e5e2e1",
"surface-container-low": "#1c1b1b",
"on-primary-container": "#002958",
"secondary-fixed": "#d6e3ff",
"accent-error": "#ffb4ab",
"on-primary-fixed": "#001b3e",
"surface-container-lowest": "#0e0e0e",
"surface-tint": "#aac7ff",
"error-container": "#93000a",
"secondary-container": "#2e476f",
"surface-main": "#0a0a0c",
"tertiary-fixed": "#ffdbc9",
"surface-container-high": "#2a2a2a",
"on-primary": "#002f64",
"on-surface-bright": "#ffffff",
"on-tertiary-container": "#481d00",
"on-error": "#690005",
"surface-card": "#16161a",
"surface-container-highest": "#353534",
"surface-bright": "#393939",
"on-secondary-container": "#9db6e4",
"on-tertiary-fixed-variant": "#763400",
"on-error-container": "#ffdad6",
"on-secondary-fixed": "#001b3d",
"tertiary": "#ffb68c",
"outline": "#8b919f",
"surface-dim": "#131313",
"on-secondary": "#143057",
"inverse-on-surface": "#313030",
"on-surface-variant": "#c1c6d5"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
"spacing": {
"margin-mobile": "16px",
"gutter": "24px",
"margin-desktop": "32px",
"base": "4px",
"max-width": "1280px",
"unit": "4px",
"stack-sm": "8px",
"stack-lg": "48px",
"stack-md": "24px",
"container-margin": "20px"
},
"fontFamily": {
"headline-md": ["Inter"],
"label-sm": ["Inter"],
"body-lg": ["Inter"],
"body-md": ["Inter"],
"headline-sm": ["Inter"],
"headline-lg": ["Inter"],
"headline-lg-mobile": ["Inter"],
"label-md": ["Inter"],
"label-caps": ["JetBrains Mono"]
},
"fontSize": {
"headline-md": ["24px", { "lineHeight": "32px", "fontWeight": "600" }],
"label-sm": ["11px", { "lineHeight": "16px", "letterSpacing": "0.5px", "fontWeight": "500" }],
"body-lg": ["16px", { "lineHeight": "24px", "fontWeight": "400" }],
"body-md": ["14px", { "lineHeight": "20px", "fontWeight": "400" }],
"headline-sm": ["20px", { "lineHeight": "28px", "fontWeight": "600" }],
"headline-lg": ["32px", { "lineHeight": "40px", "letterSpacing": "-0.02em", "fontWeight": "700" }],
"headline-lg-mobile": ["28px", { "lineHeight": "36px", "letterSpacing": "-0.01em", "fontWeight": "700" }],
"label-md": ["12px", { "lineHeight": "16px", "letterSpacing": "0.5px", "fontWeight": "500" }],
"label-caps": ["12px", { "lineHeight": "16px", "letterSpacing": "0.1em", "fontWeight": "500" }]
}
}
}
}
</script>
<style>
.map-grid-overlay {
background-image:
linear-gradient(to right, rgba(255,255,255,0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.05) 1px, transparent 1px);
background-size: 40px 40px;
}
.pulse-ring {
animation: pulse-ring 2s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
}
@keyframes pulse-ring {
0% {
transform: scale(0.8);
opacity: 0.8;
box-shadow: 0 0 0 0 rgba(170, 199, 255, 0.5);
}
70% {
transform: scale(1);
opacity: 0;
box-shadow: 0 0 0 20px rgba(170, 199, 255, 0);
}
100% {
transform: scale(0.8);
opacity: 0;
box-shadow: 0 0 0 0 rgba(170, 199, 255, 0);
}
}
.route-line {
stroke-dasharray: 10, 10;
animation: dash 20s linear infinite;
}
@keyframes dash {
to {
stroke-dashoffset: -1000;
}
}
.tooltip-float {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0% { transform: translateY(0px); }
50% { transform: translateY(-8px); }
100% { transform: translateY(0px); }
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background text-on-surface h-screen w-full overflow-hidden relative selection:bg-primary-container selection:text-on-primary-container antialiased flex flex-col">
<!-- Fullscreen Map Canvas -->
<main class="flex-grow relative h-full w-full z-0 overflow-hidden">
<div class="absolute top-4 left-1/2 -translate-x-1/2 z-50 w-auto px-container-margin pointer-events-none mt-4">
<div class="bg-surface/90 backdrop-blur-xl border border-outline-variant/30 rounded-full px-8 py-4 flex items-center gap-8 shadow-sm pointer-events-auto">
<div class="flex flex-col items-center">
<span class="text-on-surface-variant font-headline-md text-[10px] uppercase tracking-widest mb-1">Distance</span>
<span class="font-headline-md text-primary-container">12.4<span class="text-sm ml-1">km</span></span>
</div>
<div class="w-px h-8 bg-outline-variant/30"></div>
<div class="flex flex-col items-center">
<span class="text-on-surface-variant font-headline-md text-[10px] uppercase tracking-widest mb-1">Est. Time</span>
<span class="font-headline-md text-primary-container">45<span class="text-sm ml-1">m</span></span>
</div>
<div class="w-px h-8 bg-outline-variant/30"></div>
<div class="flex flex-col items-center">
<span class="text-on-surface-variant font-headline-md text-[10px] uppercase tracking-widest mb-1">Pins</span>
<span class="font-headline-md text-primary-container">5</span>
</div>
</div>
</div>
<!-- Base Map Layer -->
<div class="absolute inset-0 z-0 bg-surface-container">
<img class="w-full h-full object-cover opacity-30 mix-blend-screen" data-alt="A digital map showing a top-down view of city streets and terrain." data-location="San Francisco, CA" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDtux0iJ6r19x5fCEWn0L1Ai3ziybnKhwYCd-8xyKm8ebAQHTJPdulojF6Pi_31mtsUlhqpI5ejusGg8Rk7y9c20eWK9sqj3uubky70kqSRiHsd37IJzOiRUTYdHCyOg2g7K5uADAg-39nR48gfWmfotW13eKGb1I9OMrc8MJBtj6lD9rtqapC2DG8kLoTz50c_QCt_AVShQyLrft6yW1SShV4mZXP2kwhf4AZsh3bvkzeCQzfIB43E"/>
</div>
<!-- Technical Grid Overlay -->
<div class="absolute inset-0 map-grid-overlay z-10 pointer-events-none"></div>
<!-- Planned Route Overlay (SVG) -->
<svg class="absolute inset-0 w-full h-full z-20 pointer-events-none" preserveaspectratio="xMidYMid slice" viewbox="0 0 1000 1000">
<path class="route-line opacity-80" d="M 400 400 L 450 420 L 500 380 L 550 430 L 600 400" fill="none" stroke="#4090fe" stroke-dasharray="10, 10" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"></path>
<circle cx="400" cy="400" fill="#4090fe" r="6" stroke="#131313" stroke-width="2"></circle>
<circle cx="450" cy="420" fill="#4090fe" r="6" stroke="#131313" stroke-width="2"></circle>
<circle cx="500" cy="380" fill="#4090fe" r="6" stroke="#131313" stroke-width="2"></circle>
<circle cx="550" cy="430" fill="#4090fe" r="6" stroke="#131313" stroke-width="2"></circle>
<circle cx="600" cy="400" fill="#4090fe" r="6" stroke="#131313" stroke-width="2"></circle>
</svg>
<!-- User Location Marker (Pulsing) -->
<div class="absolute top-[70%] left-[30%] z-30 transform -translate-x-1/2 -translate-y-1/2 flex items-center justify-center pointer-events-none">
<div class="absolute w-4 h-4 bg-primary-container rounded-full opacity-30"></div>
<div class="absolute w-3 h-3 bg-primary-container rounded-full pulse-ring"></div>
<div class="w-2 h-2 bg-primary-container rounded-full relative z-10 shadow-[0_0_10px_rgba(64,144,254,0.5)]"></div>
<!-- Vision cone indicator -->
<div class="absolute -top-12 left-1/2 -translate-x-1/2 w-0 h-0 border-l-[20px] border-l-transparent border-r-[20px] border-r-transparent border-t-[40px] border-t-primary-container/20 origin-bottom transform rotate-45"></div>
</div>
<!-- Interactive Map Canvas Layer (Invisible, captures clicks for dropping pins) -->
<div class="absolute inset-0 z-40 cursor-crosshair" id="map-interaction-layer"></div>
</main>
<!-- Floating HUD Elements Layer -->
<div class="absolute inset-0 z-50 pointer-events-none flex flex-col justify-end">
<!-- Tooltip -->
<div class="w-full flex justify-center mb-stack-md pointer-events-none px-container-margin">
<button class="pointer-events-auto bg-primary-container text-on-primary-container font-headline-md text-label-caps px-12 py-4 rounded-xl shadow-md uppercase tracking-widest hover:brightness-110 active:scale-95 transition-all">Start Route</button>
</div>
<!-- Bottom Navigation Bar (Generated from JSON) -->
<nav class="bg-surface border-t border-outline-variant/30 w-full pb-safe flex justify-around items-center h-20 px-container-margin pointer-events-auto shrink-0 relative shadow-[0_-4px_20px_rgba(0,0,0,0.2)]">
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary-container transition-colors duration-150">
<span class="material-symbols-outlined mb-1" style="font-variation-settings: 'FILL' 0;">map</span>
</button>
<button class="flex flex-col items-center justify-center text-primary-container relative after:content-[''] after:absolute after:-bottom-1 after:w-1 after:h-1 after:bg-primary-container after:rounded-full hover:text-primary-container transition-colors duration-150 scale-95">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">book</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary-container transition-colors duration-150">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 0;">route</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary-container transition-colors duration-150">
<span class="material-symbols-outlined mb-1" style="font-variation-settings: 'FILL' 0;">settings</span>
</button>
</nav>
</div>
<!-- Micro-interaction Script for Pin Dropping Simulation -->
<script>
document.addEventListener('DOMContentLoaded', () => {
const mapLayer = document.getElementById('map-interaction-layer');
const svgLayer = document.querySelector('svg');
let pinCount = 0;
mapLayer.addEventListener('click', (e) => {
const rect = mapLayer.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Visual feedback of drop
const ripple = document.createElement('div');
ripple.className = 'absolute rounded-full border-2 border-primary-container bg-primary-container/20 pointer-events-none transform -translate-x-1/2 -translate-y-1/2';
ripple.style.left = `${x}px`;
ripple.style.top = `${y}px`;
ripple.style.width = '0px';
ripple.style.height = '0px';
ripple.style.opacity = '1';
ripple.style.transition = 'all 0.5s cubic-bezier(0.165, 0.84, 0.44, 1)';
mapLayer.parentElement.appendChild(ripple);
// Trigger animation
requestAnimationFrame(() => {
ripple.style.width = '60px';
ripple.style.height = '60px';
ripple.style.opacity = '0';
});
// Clean up ripple
setTimeout(() => ripple.remove(), 500);
});
});
</script>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -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.

View File

@@ -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 `<!-- TopAppBar -->` comment confirming
this; treat it as intentional, not an oversight.
## Design
**Navigation:** `go_router`'s `StatefulShellRoute.indexedStack` (or `.builder`) with four
branches — Map (`/`), Rides (`/rides`), Plan (`/plan`), Settings (`/settings`) — each
branch keeps its own navigator so pushing Trip Detail from Rides, or the route planner
from Plan, doesn't disturb the other tabs' state or the active tab index.
**Background map:** one persistent, live `RideMap`/`FlutterMap` instance sitting behind
the `IndexedStack`'s current branch, not four separate map instances. Rebuilding a real
`FlutterMap` on every tab switch would refetch tiles and lose camera position; a single
shared instance, with only its *content* (opacity, overlay, current position) varying by
tab, is both cheaper and matches "the map is always there, tabs are what floats on top of
it."
- **Map tab:** full opacity, the actual recording/HUD content.
- **Rides / Plan / Settings tabs:** dimmed (roughly 30-40% overlay per the Stitch
export's `opacity-30`/`opacity-40` treatment) and non-interactive — taps pass through
to the tab's real content, the map is decoration, not a control surface, on these tabs.
**No header:** each tab's `Scaffold` has no `appBar`. Screen identity comes from content
(a title inside the tab's own top content, if needed) or from the active nav item, never
from a persistent bar.
## Implementation
1. `AppShell` widget: `StatefulShellRoute` with 4 branches, wrapping a persistent
`BottomNavBar` (see UI-03 for the styled version; a plain one is fine to start) and the
shared background map behind an `IndexedStack`.
2. Extract the background-map hosting into its own widget (`PersistentMapBackground` or
similar) that every tab's `Scaffold` composes against, rather than four independent
`RideMap` constructions.
3. Move `RecordScreen`'s existing map-in-a-card logic to *become* the Map tab's full-
opacity state of this shared background, rather than a separate widget tree — UI-04
does the visual rework, this ticket only has to make the plumbing possible.
4. Remove `AppBar`s from every top-level tab screen. Settings' existing back button goes
away too — Settings becomes a tab, not a pushed screen, so there's nothing to back out
of.
5. Update `router.dart`'s `Routes` constants and every `context.push`/`context.pop` call
site that assumed the old flat stack.
## Acceptance criteria
- [ ] Four tabs are reachable from a persistent bottom nav bar on every screen
- [ ] Switching tabs does not rebuild/refetch the map (camera position survives a tab
switch)
- [ ] The map is visible, dimmed, behind Rides, Plan, and Settings — not just Map
- [ ] No `AppBar`/header exists on any of the four top-level tab screens
- [ ] Trip Detail still pushes within the Rides tab (back returns to the Rides list, not
to the Map tab)
- [ ] The route planner still pushes within the Plan tab
- [ ] Existing widget tests are updated to pump the shell rather than a bare screen, and
still pass
## Tests
- Widget: all four tabs are reachable and their content renders
- Widget: pushing Trip Detail from Rides and popping returns to Rides, not to Map
- Widget: the background map widget instance is not recreated on a tab switch (e.g.
assert identity/key stability, or that camera state is preserved)
- Widget: no `AppBar` is found on any of the four tab roots
## Risks
- **This is the highest-blast-radius ticket in the set** — it touches every screen's
entry point. Land it alone, get `flutter test` fully green, before starting any visual
ticket on top of it.
- A shared background map instance behind an `IndexedStack` is easy to get wrong in a way
that either rebuilds the map on every tab switch (defeating the point) or keeps it
alive so aggressively that it never tears down while the app is backgrounded — revisit
V3-04's lifecycle-aware `TileLayer` teardown to make sure it still applies correctly
once the map is shared across tabs rather than owned by one screen.
## Out of scope
The skeleton/offline map state (UI-02). Any visual redesign of what's inside a tab
(UI-04/05/06) — this ticket only has to make the shell and shared background map exist,
correctly, with today's screen content still working inside it.
## Outcome
Shipped as designed: `lib/src/ui/router.dart` now builds a single `StatefulShellRoute
.indexedStack` with four branches (Map `/`, Rides `/rides`, Plan `/plan`, Settings
`/settings`), each with Trip Detail / the route planner nested as a child `GoRoute`
inside its own branch so pushing/popping there never disturbs the other tabs or the
active tab index. `lib/src/ui/app_shell.dart` is new: `AppShell` is a thin go_router
adapter around `ShellScaffold`, a plain-parameter (`currentIndex`/`onDestinationSelected`
/`child`) `ConsumerWidget` that owns the one shared `RideMap` instance, the dimming scrim,
and the bottom `NavigationBar`. Splitting the two was necessary for testability —
go_router only ever constructs a real `StatefulNavigationShell` itself, so widget tests
drive `ShellScaffold` directly with a plain `int` instead.
Every top-level tab screen (`RecordScreen`, `TripsScreen`, `RoutesListScreen`,
`SettingsScreen`) had its `AppBar`/back-button header removed and its `Scaffold`
background set to transparent so the shared map shows through. `RecordScreen` also lost
its per-screen `onOpenTrips`/`onOpenSettings`/`onOpenRoutes` navigation callbacks and its
embedded `_LiveMap` card entirely — navigation is the shell's nav bar now, and the map is
the shell's permanent background rather than something each screen constructs for
itself. One deliberate temporary trade: the mounted-mode toggle's only remaining button
was on `RecordScreen`'s removed header; its sole access point until UI-05 restyles the
Record screen is now Settings' existing `mounted-mode-switch`.
**Bug found and fixed during this ticket, not anticipated by the plan:** `RideMap`
returned a structurally different widget tree for empty vs. non-empty points in its
`showEmptyLabel: false` (background) mode — a bare `FlutterMap` vs. `ClipRRect >
FlutterMap`. Once the map became a long-lived shell background instead of a
freshly-mounted per-screen widget, this became visible: the moment a live ride's first
point arrived, Flutter unmounted/remounted the differing subtree mid-flight, and
`RideMap`'s own `didUpdateWidget`-scheduled `_controller.camera` post-frame callback fired
against the stale element, throwing "Looking up a deactivated widget's ancestor is
unsafe." Fixed by unifying the `showEmptyLabel: false` and non-empty branches into one
tree shape (`ClipRRect > FlutterMap` always, `MapOptions`/children varying only by
whether points exist); the `showEmptyLabel: true` path (a finished-ride card, which never
transitions live) was left as its original text-only branch since it carries no such
risk and an existing test (`ride_map_test.dart`) already asserted no `FlutterMap` renders
there.
**Tests:** `flutter analyze` clean (pre-existing `deprecated_member_use` infos only,
unrelated to this ticket). `flutter test` green at 315 tests (was 316 before this ticket;
net -1 after removing two now-meaningless per-screen entry-point tests and adding one
shell-nav-bar test — see below). Two tests referencing the removed
`onOpenSettings`/`onOpenRoutes` callbacks were deleted outright (the concept no longer
exists); a new "shell nav bar" group in `test/widget_test.dart` asserts all four
`NavigationDestination`s are present and that tapping each calls back with the right
index. The two V3-04 background-map tests were rewritten to pump `ShellScaffold` and
assert on `shell-background-map`/`TileLayer` instead of the old per-screen `live-map` key
— both pass, including the lifecycle-driven tile-drop/restore test that exposed the bug
above.
**Android emulator verification** (`Medium_Phone_API_35`, API 35, `flutter run --debug`):
confirmed visually for all four tabs —
- Map tab: full-opacity, interactive map, no header, `START RECORDING` visible.
- Rides/Plan/Settings tabs: same map dimmed via the `0xB3000000` scrim, non-interactive,
no header, each tab's real content on top.
- Started a live recording (with `adb emu geo fix` supplying a location) and switched
tabs repeatedly: the point count and elapsed timer kept advancing across tab switches
(11 → 31 → 41 points, uninterrupted) and the camera position did not reset — confirming
the shared instance survives tab switches rather than being torn down and recreated.
- Backgrounding via the real HOME key (not just the synthetic lifecycle message the
widget test uses) dropped the map to a flat, untiled gray — the same lifecycle-based
tile-teardown from V3-04 firing correctly in the shell context, not just in tests.
- One visual red herring investigated and ruled out: a flat gray rectangle appeared
transiently in the dimmed background on the Rides/Plan tabs. Ruled out as an app bug by
reproducing it identically on two different tab screens at the same absolute screen
position, and by confirming a fresh app launch never shows it — it is flutter_map's
normal "tile chunk not yet loaded" placeholder at the low zoom level the background
map starts at before any location fix narrows it, not a shell defect.
- Known pre-existing rough edge, not a regression: the background map's follow-mode
moves the camera center to the latest point but does not adjust zoom on the first fix,
so a freshly-started ride's background map can look zoomed far out until the user
manually zooms. This behavior predates UI-01 (the same `_controller.move(point, current
Zoom)` call existed in the old standalone `_LiveMap`) and is out of scope here.
Deferred, not a gap: a widget test asserting Trip Detail/route-planner push-and-pop stays
within its own tab (rather than affecting `currentIndex`) was not written — the
`StatefulShellBranch`-per-tab nesting in `router.dart` structurally guarantees this via
go_router's own navigator-per-branch semantics, and it was verified manually on-device
that `router.dart`'s nested `GoRoute`s compile and resolve correctly. A future ticket
touching Rides/Plan navigation should add explicit coverage rather than relying on this
note.

View File

@@ -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.

View File

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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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).

View File

@@ -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<Trip>)`),
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.

View File

@@ -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.

View File

@@ -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).

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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/)

View File

@@ -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();

View File

@@ -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<BackdropFilter>(find.byType(BackdropFilter));
expect(backdrop.filter, isA<ImageFilter>());
final box = tester.widget<DecoratedBox>(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<void> 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<RenderParagraph>(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<Container>(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);
});
});
}

View File

@@ -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');
});
}

View File

@@ -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<Config> 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');
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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<void>.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<void>.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');
});
}

View File

@@ -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);
});
}

View File

@@ -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<PolylineLayer>(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');
});
});
}

View File

@@ -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<Text>(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<FloatingPill>(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<IconButton>(
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<PopupMenuItem<String>>(
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<IconButton>(
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<PopupMenuItem<String>>(
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<NavigationBar>(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');
});
});
}

View File

@@ -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<void> 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<SwitchListTile>(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<SwitchListTile>(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(

View File

@@ -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');
});
}

View File

@@ -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<Text>(
find.descendant(of: find.byKey(const Key('speed')), matching: find.text('0')),
);
final panel = tester.widget<GlassPanel>(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 {