Compare commits

..

10 Commits

Author SHA1 Message Date
04127d7237 Rebuild release APK with the UI redesign (UI-01 through UI-09)
All nine ui-redesign tickets are now complete: persistent tab shell,
Modern Professional Dark theme, monochrome dark map tiles, offline skeleton
map, the GlassPanel/FloatingPill component kit, customizable HUD widgets,
and the Map HUD / Plan / Rides History screen rebuilds. app-release.apk
force-added past .gitignore's build/ exclusion, same as the prior release
commit, so it can be pushed once a remote exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
2026-08-24 14:35:17 -05:00
7c1d6570a2 UI-07: rebuild Rides History with search, activity filter, and real ride thumbnails
Restyles TripsScreen toward Map HUD's GlassPanel language: a search bar and
activity-filter dropdown, four distinct summary stat cards (replacing the
mockup's four-identical-cards artifact), and per-ride RideMap thumbnails
showing each ride's own recorded path instead of stock photos.

RideHistorySummary is a pure function rather than a provider, since folding
over an already-fetched trip list is cheap regardless of history length, and
it makes the aggregation directly unit-testable. Average speed is
distance-weighted across the whole history rather than averaging each ride's
own average.

Adds a showAttribution flag to RideMap (default true) to suppress the
TileAttribution control on tiny list thumbnails, where it was both a UX
problem and a test collision with each card's own activity icon.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
2026-08-24 14:32:00 -05:00
dccccb68b6 UI-06: rebuild Plan & Route Planning as a fullscreen map canvas
Replaces RoutePlannerScreen's AppBar-driven layout with a headerless,
full-opacity map canvas restyled toward Map HUD rather than copied from
this screen's own (drifted) Stitch export: a floating "tap to drop a pin"
tooltip before any pins exist, FloatingPill (Distance/Est. Time/Pins) once
they do, a "Start Route" floating button, a GlassPanel overflow menu
replacing the old app-bar actions (rename/delete/download), and a
pin-drop ripple micro-interaction. RoutesListScreen keeps its existing
list-with-+-button entry point (the IA question in the ticket's Risk
section, deferred as explicitly permitted) but restyles its rows as
GlassPanel cards.

"Start Route" gets real behavior instead of shipping as a dead button:
turn-by-turn route following doesn't exist yet (V3-09), so tapping it
starts an ordinary recording and switches to the Map tab -- useful today,
not a placeholder for a feature that isn't there. Est. Time honestly
shows "--" rather than fabricating an estimate the data model doesn't
back. Marching-ants route-line animation was dropped from scope: it isn't
in the acceptance criteria, and animating a dash phase under live map
pan/zoom is real complexity for a purely decorative effect.

Fixes a real, pre-existing bug found while working in this file: the
offline-tile download dialog still fetched from tile.openstreetmap.org
directly, a leftover from before UI-09 switched the live map to CARTO's
dark tiles -- a "successful" download would have cached tiles under keys
the dark-tile cache never reads from.

Also fixes a live Flutter framework warning the new GlassPanel-wrapped
ListTile surfaced (ink splashes need a Material ancestor, which
GlassPanel's DecoratedBox was blocking) and a real test bug (two pins
placed one directly under the new floating Start Route button in a
fixed test camera, causing a tap meant for the pin to hit the button
instead).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
2026-08-24 10:18:39 -05:00
fa6b1b8ef2 UI-05: rebuild Record screen as the Map HUD (design north star)
Replaces the scrolling stats-card layout with the full-bleed Map HUD:
UI-04's draggable/resizable HUD widgets float over UI-01's shared
background map (full opacity on this tab), and the old Pause/Stop/Resume/
Discard buttons become full-bleed, icon-only segments matching the
mockup's tertiary-container/error-container colors exactly. The idle
state keeps its prior compact layout deliberately -- HUD widgets only
appear once a ride exists, preserving the existing "a resting screen
must not look like a ride going nowhere" guarantee rather than
reinterpreting it.

State-machine logic (ticker, wakelock, speed subscription, error
handling) is untouched; every pre-existing record-screen test passed
unchanged against the rebuilt screen. Added tests that actually tap
Start/Pause/Stop and verify engine state changes, confirm HUD widgets
render over the map rather than replacing it, and verify the
mounted-mode speed digit's real contrast ratio against GlassPanel's
translucent surface specifically (the ticket's own named risk).

Corrects a UI-04 mistake found while implementing this ticket: the HUD's
default four metrics were ordered Speed/Distance/Elapsed/Max Speed, a
guess made before reading the actual Map HUD mockup HTML closely. The
real fixed row is Speed/Avg Speed/Dist/Time -- reordered HudMetric to
match and updated every test that asserted the old order.

Adds PulsingLocationMarker (UI-03) to RideMap's live usage via a new
showLocationMarker flag, and explicit tertiaryContainer/errorContainer
tokens to ripprColors so the control bar matches the design system's
literal values rather than an auto-derived tonal palette.

Verified end-to-end on a real emulator: Start, Pause, Resume, and Stop
all correctly drive the trip state machine with the full live map behind
everything. A lengthy false alarm during this verification (taps
appearing to do nothing) turned out to be a screenshot-scale
mis-measurement on the verification side, not an app defect -- resolved
by sampling pixel colors directly from the raw screenshot to find the
control bar's true on-screen position.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
2026-08-24 08:22:51 -05:00
bcface3529 UI-04: customizable, persisted HUD telemetry widget layout
Adds the full drag/resize/visibility infrastructure for a rider-owned HUD
layout: HudMetric (the stable 8-metric set), HudWidgetLayout (fractional
x/y/width/height + visible, with clamping to a legibility floor/ceiling
and JSON round-trip that degrades to sane defaults rather than crashing),
Config.hudLayout persistence, and a HudLayoutController that stays
in-memory-authoritative during an edit session and only writes through on
persist() -- never per drag frame.

DraggableResizableHudWidget and HudEditOverlay assemble the interaction:
edit mode is entered by a long-press on empty HUD space (not a specific
widget) and exited via Done or a tap on empty space; only while editing
does a widget attach any drag/resize gesture at all, so a normal tap can
never move one mid-ride by construction, not by an internal flag.

Fixes a real gesture-arena bug found during testing: outside edit mode, a
long-press landing on a widget was free to bubble to the overlay's
background long-press handler and wrongly enter edit mode. An inner no-op
GestureDetector of the same gesture type now absorbs it.

Adds a "Live HUD stats" Settings section, one switch per metric, that
persists immediately (unlike drag frames). Placed at the end of the
Settings list rather than in the middle -- inserting mid-list pushed
every later section below several existing tests' viewport assumptions.

Verified end-to-end on a real emulator including a full process restart:
resized a widget, force-stopped the app, relaunched, and the resize held.
No consuming screen exists yet (UI-05's job) -- verified via a throwaway
preview entry point, deleted after use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
2026-08-23 20:45:02 -05:00
24f4e17e81 UI-03: shared floating-glass component kit
Adds GlassPanel (BackdropFilter + translucent fill + subtle border, values
lifted directly from the Stitch exports' own bg-surface/90 backdrop-blur-xl
border-outline-variant/30 treatment), FloatingPill (a GlassPanel shaped as
a capsule around an arbitrary number of labeled stat columns with
dividers), and PulsingLocationMarker (expanding-ring-plus-glow-dot,
matching the exports' pulse-ring keyframes exactly, with a static-dot
fallback when reduced motion is requested). Pure presentation -- every
UI-04/05/06/07 screen composes these rather than reimplementing blur/
border styling per screen.

Adds outlineVariant to ripprColors, needed for the panel border and pill
dividers and not previously wired up by UI-08.

Verified with a throwaway preview entry point (deleted after use, no
consuming screen exists yet) plus 8 new widget tests covering blur/
translucency/border structure, contrast in both themes, arbitrary stat
counts, and the pulsing marker's animation lifecycle and reduced-motion
fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
2026-08-23 20:22:19 -05:00
10040e6985 UI-02: animated skeleton map when tiles can't be fetched
Adds MapConnectivityState, a shared tracker of tile-fetch outcomes (cache
miss + network failure) that flips every map into an animated skeleton
after 3 consecutive failures and recovers on a single success -- either an
ordinary fetch succeeding, or (once TileLayer has been fully unmounted in
skeleton mode) a periodic single-tile probe every 15s. Detected at the
fetch level rather than via an OS connectivity API, since a captive portal
or degraded connection can report "online" while every real fetch times
out.

SkeletonMapLayer reuses the Stitch exports' 40px grid-overlay treatment
with a shimmer sweep, replacing TileLayer entirely (never fetching
underneath its own placeholder) while markers/polylines keep rendering
since they come from local data. RideMap and the route planner's
independent FlutterMap both wire this in via a plain skeletonMode bool.

Moved the tile-source constants into a new tiles/tile_config.dart so the
connectivity probe (in the app-layer composition root) doesn't need to
import from ui/ to build its request URL.

Verified end-to-end on a real emulator: cut network, cleared the tile
cache, confirmed the skeleton renders after real fetch failures, then
confirmed automatic recovery within one probe interval once network
returned -- not just via the widget/unit tests that also cover this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
2026-08-23 20:07:47 -05:00
d3974eb4e1 UI-09: switch to CARTO dark map tiles with attribution and cache versioning
Replaces OSM's tan/cream default raster style with CARTO's "Dark Matter"
basemap (both RideMap and the route planner's independent TileLayer), so
the map itself looks like the dark mockups rather than a light basemap
under a dark overlay. Shares one urlTemplate/subdomains/maxNativeZoom
across both call sites instead of two independently-drifting copies.

Re-points the V3-11 tile cache at a versioned directory (tiles/carto_dark_v1)
since TileKey(z, x, y) carries no provider identity and would otherwise
silently serve stale tan tiles cached under the old scheme. Added a test
proving cache isolation holds across provider directories.

Adds a shared TileAttribution widget crediting both OpenStreetMap (CARTO's
style is still built from OSM data) and CARTO, composed into every map
rather than duplicated per screen -- OSM-only credit stopped being
sufficient once a second tile host entered the mix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
2026-08-23 19:50:16 -05:00
13017b8ee8 UI-08: migrate theme to Modern Professional Dark
Replaces V3-16's safety-orange identity with the palette actually backing
every fetched Stitch mockup (confirmed by hex-matching against all three
design systems in the Stitch project, not by trusting its declared default).
Ground drops to #0a0a0c with a three-level surface/card stack, primary
becomes Professional Blue (#4090fe), and reference readings move to a new
tertiary orange since blue is no longer free for that role. Re-verified
theme_test.dart's AA contrast assertions against the new values (all pass
with wide margin) and left mounted mode's separate daylight palette alone,
per the ticket's own reasoning.

Also resolves the ticket's named risk: RideMap's speed-gradient hardcoded
hex literal, which would have washed to a single hue now that primary is
blue, now lerps from the theme's own secondary token instead.

Inter is not wired in -- no bundled font asset exists to back the family
name, and setting one without an asset would silently fall back to Roboto.
Documented as a deliberate follow-up rather than a silent gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
2026-08-23 19:37:25 -05:00
5ab4934a4d UI-01: persistent 4-tab shell with a shared background map
Replaces the push/pop stack rooted at Record with a StatefulShellRoute
(Map/Rides/Plan/Settings), each tab keeping its own navigator so Trip Detail
and the route planner push within their own branch. AppShell/ShellScaffold
host one shared RideMap instance behind every tab -- full opacity and
interactive on Map, dimmed and non-interactive elsewhere -- so the camera
position and live path survive a tab switch instead of being refetched.
Every tab's AppBar/header is removed per the redesign's no-chrome mandate.

Fixes a bug this surfaced: RideMap rendered a structurally different tree
for empty vs. non-empty points, which crashed once the map became a
long-lived shell background instead of a fresh per-screen widget. Unified
the background-map tree shape so it no longer remounts mid-recording.

Verified on Medium_Phone_API_35: all four tabs, live recording surviving
tab switches with the camera preserved, and the real HOME-key lifecycle
tile-drop from V3-04 still firing correctly in the shell context.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
2026-08-23 19:28:25 -05:00
48 changed files with 4442 additions and 801 deletions

View File

@@ -19,15 +19,15 @@ v3 held to.
| # | Ticket | Size | Depends on | Status | | # | Ticket | Size | Depends on | Status |
|---|---|---|---|---| |---|---|---|---|---|
| [UI-01](UI-01-tab-shell-background-map.md) | Persistent tab shell with an always-visible background map | L | — | Not started | | [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 | Not started | | [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 | Not started | | [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 | Not started | | [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 | Not started | | [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 | Not started | | [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 | Not started | | [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 | — | Not started | | [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 | — | Not started | | [UI-09](UI-09-monochrome-dark-map-tiles.md) | Monochrome dark map tiles | S | — | Done |
## Dependencies ## Dependencies

View File

@@ -1,6 +1,6 @@
# UI-01 — Persistent tab shell with an always-visible background map # UI-01 — Persistent tab shell with an always-visible background map
**Depends on** nothing · **Size** L · **Status** Not started **Depends on** nothing · **Size** L · **Status** Done
## Goal ## Goal
Replace the current push/pop stack rooted at Record with a persistent 4-tab shell (Map, Replace the current push/pop stack rooted at Record with a persistent 4-tab shell (Map,
@@ -98,3 +98,85 @@ from a persistent bar.
The skeleton/offline map state (UI-02). Any visual redesign of what's inside a tab 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, (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. 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

@@ -1,6 +1,6 @@
# UI-02 — Offline / no-connection skeleton map # UI-02 — Offline / no-connection skeleton map
**Depends on** UI-01 · **Size** S · **Status** Not started **Depends on** UI-01 · **Size** S · **Status** Done
## Goal ## Goal
When the map has no tiles to show — no cached tiles for the current view and no network When the map has no tiles to show — no cached tiles for the current view and no network
@@ -80,3 +80,69 @@ own success/failure is the only thing that actually matters here.
## Out of scope ## Out of scope
General offline-mode UX beyond the map itself (e.g. graying out map-dependent buttons). General offline-mode UX beyond the map itself (e.g. graying out map-dependent buttons).
Manual retry controls — automatic recovery is the whole point. 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

@@ -1,6 +1,6 @@
# UI-03 — Shared floating-glass component kit # UI-03 — Shared floating-glass component kit
**Depends on** UI-08 (theme tokens) · **Size** M · **Status** Not started **Depends on** UI-08 (theme tokens) · **Size** M · **Status** Done
## Goal ## Goal
Build once, use everywhere: the small set of visual primitives every Stitch screen Build once, use everywhere: the small set of visual primitives every Stitch screen
@@ -70,3 +70,68 @@ static dot when animations are disabled system-wide.
## Out of scope ## Out of scope
The customizable drag/resize telemetry widgets (UI-04) — those consume `GlassPanel` as The customizable drag/resize telemetry widgets (UI-04) — those consume `GlassPanel` as
their visual shell but the interaction logic is a separate, larger ticket. 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

@@ -1,6 +1,6 @@
# UI-04 — Customizable HUD telemetry widgets # UI-04 — Customizable HUD telemetry widgets
**Depends on** UI-03 · **Size** L · **Status** Not started **Depends on** UI-03 · **Size** L · **Status** Done
## Goal ## Goal
Let the rider drag each floating telemetry widget (Speed, Distance, etc.) to wherever Let the rider drag each floating telemetry widget (Speed, Distance, etc.) to wherever
@@ -112,3 +112,86 @@ same pattern as every other `Config` preference in this app — see `mountedMode
Z-ordering/overlap resolution between widgets (widgets simply clamp to stay on-screen; 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 overlapping each other is the rider's own choice to avoid). Per-metric colour
customization. Sharing/exporting a HUD layout between devices. 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

@@ -1,6 +1,6 @@
# UI-05 — Map HUD: Record screen redesign # UI-05 — Map HUD: Record screen redesign
**Depends on** UI-01, UI-03, UI-04 · **Size** M · **Status** Not started **Depends on** UI-01, UI-03, UI-04 · **Size** M · **Status** Done
## Goal ## Goal
Rebuild the Record screen as the fullscreen Map HUD from the Stitch export — this screen Rebuild the Record screen as the fullscreen Map HUD from the Stitch export — this screen
@@ -80,3 +80,90 @@ and everything else floats on top of it via `GlassPanel`/HUD widgets.
## Out of scope ## Out of scope
Following a planned route turn-by-turn (V3-09). Anything about Plan/Route Planning/Rides 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. 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

@@ -1,6 +1,6 @@
# UI-06 — Plan & Route Planning redesign # UI-06 — Plan & Route Planning redesign
**Depends on** UI-01, UI-03 · **Size** M · **Status** Not started **Depends on** UI-01, UI-03 · **Size** M · **Status** Done
## Goal ## Goal
Rebuild the Plan tab (empty state) and the route planner (pins dropped) as fullscreen Rebuild the Plan tab (empty state) and the route planner (pins dropped) as fullscreen
@@ -89,3 +89,82 @@ matching Map HUD's nav bar exactly (same widget, in fact — see UI-01).
## Out of scope ## Out of scope
Road-snapped routing (V3-08/V3-09, deferred). Any change to the underlying route-planning Road-snapped routing (V3-08/V3-09, deferred). Any change to the underlying route-planning
data model or repository — this is presentation only. 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

@@ -1,6 +1,6 @@
# UI-07 — Rides History redesign # UI-07 — Rides History redesign
**Depends on** UI-01, UI-03 · **Size** M · **Status** Not started **Depends on** UI-01, UI-03 · **Size** M · **Status** Done
## Goal ## Goal
Rebuild the Trips list as "Rides History": a dimmed live map background (per UI-01, not Rebuild the Trips list as "Rides History": a dimmed live map background (per UI-01, not
@@ -55,13 +55,13 @@ other tab has (dimmed), not the mockup's static blurred screenshot — see UI-01
match whatever UI-01's nav bar ships with. match whatever UI-01's nav bar ships with.
## Acceptance criteria ## Acceptance criteria
- [ ] Background is the shared live map (dimmed), not a static image - [x] Background is the shared live map (dimmed), not a static image
- [ ] Search narrows the list by name/date - [x] Search narrows the list by name/date
- [ ] Filter narrows the list by activity type - [x] Filter narrows the list by activity type
- [ ] Summary row shows four distinct, real statistics, not one number repeated - [x] Summary row shows four distinct, real statistics, not one number repeated
- [ ] Each ride card shows an actual thumbnail of that ride's own path, not a stock photo - [x] Each ride card shows an actual thumbnail of that ride's own path, not a stock photo
- [ ] Merge/delete/rename actions from today's `TripsScreen` still work - [x] Merge/delete/rename actions from today's `TripsScreen` still work
- [ ] Colors and icon fill-states match Map HUD, not the Stitch export's card styling - [x] Colors and icon fill-states match Map HUD, not the Stitch export's card styling
## Tests ## Tests
- Widget: search filters the visible list correctly - Widget: search filters the visible list correctly
@@ -82,3 +82,67 @@ other tab has (dimmed), not the mockup's static blurred screenshot — see UI-01
## Out of scope ## Out of scope
Any change to trip data, merge, or split logic (V3-10) — presentation and search/filter Any change to trip data, merge, or split logic (V3-10) — presentation and search/filter
only. 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

@@ -1,6 +1,6 @@
# UI-08 — Theme migration to Modern Professional Dark # UI-08 — Theme migration to Modern Professional Dark
**Depends on** nothing · **Size** S · **Status** Not started **Depends on** nothing · **Size** S · **Status** Done
## Goal ## Goal
Replace `theme.dart`'s current safety-orange palette with "Modern Professional Dark" — Replace `theme.dart`'s current safety-orange palette with "Modern Professional Dark" —
@@ -94,3 +94,67 @@ Full spec: `docs/design/stitch-export/design-system-modern-professional-dark.md`
## Out of scope ## Out of scope
Any screen-specific redesign (UI-05/06/07) — this ticket only changes the token layer Any screen-specific redesign (UI-05/06/07) — this ticket only changes the token layer
those screens then build against. 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

@@ -1,6 +1,6 @@
# UI-09 — Monochrome dark map tiles # UI-09 — Monochrome dark map tiles
**Depends on** nothing · **Size** S · **Status** Not started **Depends on** nothing · **Size** S · **Status** Done
## Goal ## Goal
Replace the tan/cream default OpenStreetMap raster style with a monochrome dark map, so Replace the tan/cream default OpenStreetMap raster style with a monochrome dark map, so
@@ -96,3 +96,59 @@ ticket, not a separate one — see Implementation.
## Out of scope ## Out of scope
A fully custom/self-hosted vector tile style — much larger scope, and not needed to hit A fully custom/self-hosted vector tile style — much larger scope, and not needed to hit
"looks like the mockups" today. "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

@@ -11,6 +11,7 @@ import 'dart:io';
import 'package:drift/drift.dart' show driftRuntimeOptions; import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift_flutter/drift_flutter.dart'; import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../config/config.dart'; import '../config/config.dart';
@@ -18,6 +19,9 @@ import '../crash/crash_reporter.dart';
import '../data/database.dart'; import '../data/database.dart';
import '../data/route_plan_repository.dart'; import '../data/route_plan_repository.dart';
import '../data/trip_repository.dart'; import '../data/trip_repository.dart';
import '../hud/hud_layout_controller.dart';
import '../hud/hud_metric.dart';
import '../hud/hud_widget_layout.dart';
import '../domain/models.dart'; import '../domain/models.dart';
import '../notification/ride_notification_controller.dart'; import '../notification/ride_notification_controller.dart';
import '../notification/ride_notification_coordinator.dart'; import '../notification/ride_notification_coordinator.dart';
@@ -28,7 +32,9 @@ import '../recording/wakelock_controller.dart';
import '../telemetry/live_telemetry.dart'; import '../telemetry/live_telemetry.dart';
import '../telemetry/telemetry_uploader.dart'; import '../telemetry/telemetry_uploader.dart';
import '../tiles/cached_tile_provider.dart'; import '../tiles/cached_tile_provider.dart';
import '../tiles/map_connectivity.dart';
import '../tiles/tile_cache.dart'; import '../tiles/tile_cache.dart';
import '../tiles/tile_config.dart';
/// The Drift database, opened against app-private storage. /// The Drift database, opened against app-private storage.
/// ///
@@ -128,6 +134,14 @@ final mountedModeProvider = StateProvider<bool>(
(ref) => ref.watch(configProvider)?.mountedMode ?? false, (ref) => ref.watch(configProvider)?.mountedMode ?? false,
); );
/// UI-04: the customizable HUD's live layout. A `StateNotifierProvider`, not a plain
/// `StateProvider` like the flags above -- editing needs methods (`updatePosition`/
/// `updateSize`/`setVisible`/`persist`), not just a settable value.
final hudLayoutControllerProvider =
StateNotifierProvider<HudLayoutController, Map<HudMetric, HudWidgetLayout>>(
(ref) => HudLayoutController(ref.watch(configProvider)),
);
/// V3-12: same shape again. Note that flipping this at runtime does not retroactively /// V3-12: same shape again. Note that flipping this at runtime does not retroactively
/// start or stop a Sentry client already initialised at app launch -- see /// start or stop a Sentry client already initialised at app launch -- see
/// `maybeInitCrashReporting`'s doc comment on why that gate is checked once, in /// `maybeInitCrashReporting`'s doc comment on why that gate is checked once, in
@@ -203,7 +217,13 @@ const int defaultTileCacheMaxBytes = 200 * 1024 * 1024;
final tileCacheProvider = FutureProvider<TileCache>((ref) async { final tileCacheProvider = FutureProvider<TileCache>((ref) async {
final support = await getApplicationSupportDirectory(); final support = await getApplicationSupportDirectory();
final cache = FileTileCache( final cache = FileTileCache(
directory: Directory('${support.path}/tiles'), // UI-09: the subdirectory name doubles as the cache version. `TileKey(z, x, y)`
// carries no provider-specific data, so a cache built against OSM's tan tiles and
// one built against CARTO's dark tiles are indistinguishable to `FileTileCache`
// despite being visually incompatible -- switching tile URLs without also changing
// this path would silently serve stale, wrong-looking cached tiles under the new
// dark UI. Bump this suffix again if the tile source ever changes again.
directory: Directory('${support.path}/tiles/carto_dark_v1'),
maxBytes: defaultTileCacheMaxBytes, maxBytes: defaultTileCacheMaxBytes,
); );
ref.onDispose(cache.dispose); ref.onDispose(cache.dispose);
@@ -216,7 +236,7 @@ final tileCacheProvider = FutureProvider<TileCache>((ref) async {
final cachedTileProviderProvider = Provider<CachedTileProvider?>((ref) { final cachedTileProviderProvider = Provider<CachedTileProvider?>((ref) {
final cache = ref.watch(tileCacheProvider).valueOrNull; final cache = ref.watch(tileCacheProvider).valueOrNull;
if (cache == null) return null; if (cache == null) return null;
return CachedTileProvider(cache: cache); return CachedTileProvider(cache: cache, connectivity: ref.watch(mapConnectivityProvider));
}); });
/// Settings' "Offline tiles" section reads this rather than [tileCacheProvider] /// Settings' "Offline tiles" section reads this rather than [tileCacheProvider]
@@ -226,3 +246,39 @@ final tileCacheSizeProvider = FutureProvider<int>((ref) async {
final cache = await ref.watch(tileCacheProvider.future); final cache = await ref.watch(tileCacheProvider.future);
return cache.sizeBytes(); return cache.sizeBytes();
}); });
// --- Map connectivity / skeleton mode (UI-02) --------------------------------
/// One [MapConnectivityState] shared by every map in the app -- see the class's own
/// doc comment for why a single instance, rather than one per map widget, is correct
/// here. A [ChangeNotifierProvider] rather than a plain value: widgets need to rebuild
/// when `skeletonMode` flips, not just read it once.
final mapConnectivityProvider = ChangeNotifierProvider<MapConnectivityState>((ref) {
final client = http.Client();
ref.onDispose(client.close);
// `ChangeNotifierProvider` already disposes the notifier it returns on teardown --
// no separate `ref.onDispose(state.dispose)` here, which would double-dispose it.
return MapConnectivityState(
// A single, deterministic tile (the whole-world zoom-0 overview, always valid for
// any XYZ tile scheme) rather than whatever tile happens to be in view -- the probe
// exists to answer "is the tile host reachable at all", not to speculatively refetch
// the current viewport.
probe: () async {
try {
final url = tileUrlTemplate
.replaceFirst('{s}', tileSubdomains.first)
.replaceFirst('{z}', '0')
.replaceFirst('{x}', '0')
.replaceFirst('{y}', '0')
.replaceFirst('{r}', '');
final response = await client
.get(Uri.parse(url), headers: {'User-Agent': tileUserAgent})
.timeout(const Duration(seconds: 5));
return response.statusCode == 200;
} catch (_) {
return false;
}
},
);
});

View File

@@ -4,12 +4,15 @@
/// recording must work with no server at all, and uploading is opt-in. /// recording must work with no server at all, and uploading is opt-in.
library; library;
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../domain/models.dart' show UnitSystem; import '../domain/models.dart' show UnitSystem;
import '../hud/hud_metric.dart';
import '../hud/hud_widget_layout.dart';
const _keyEndpoint = 'upload_endpoint'; const _keyEndpoint = 'upload_endpoint';
const _keyDeviceId = 'device_id'; const _keyDeviceId = 'device_id';
@@ -17,6 +20,7 @@ const _keyMapEnabled = 'map_enabled';
const _keyUnitSystem = 'unit_system'; const _keyUnitSystem = 'unit_system';
const _keyMountedMode = 'mounted_mode'; const _keyMountedMode = 'mounted_mode';
const _keyCrashReportingEnabled = 'crash_reporting_enabled'; const _keyCrashReportingEnabled = 'crash_reporting_enabled';
const _keyHudLayout = 'hud_layout';
/// Countries that did not adopt metric for everyday distances. Not exhaustive — a /// Countries that did not adopt metric for everyday distances. Not exhaustive — a
/// best-effort default, not a claim of authority. Anyone can override it in Settings. /// best-effort default, not a claim of authority. Anyone can override it in Settings.
@@ -88,6 +92,33 @@ class Config {
Future<void> setCrashReportingEnabled(bool enabled) => Future<void> setCrashReportingEnabled(bool enabled) =>
_prefs.setBool(_keyCrashReportingEnabled, enabled); _prefs.setBool(_keyCrashReportingEnabled, enabled);
/// UI-04: every metric's position, size, and visibility on the customizable HUD.
/// Missing entirely (fresh install) or missing a specific metric (an app update
/// that added one) both fall back to [HudWidgetLayout.defaultFor] -- never a crash,
/// same discipline [HudWidgetLayout.fromJson] itself follows for a malformed entry.
Map<HudMetric, HudWidgetLayout> get hudLayout {
final raw = _prefs.getString(_keyHudLayout);
Map<String, dynamic> stored = const {};
if (raw != null) {
try {
stored = jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
stored = const {};
}
}
return {
for (final metric in HudMetric.values)
metric: stored[metric.name] is Map<String, dynamic>
? HudWidgetLayout.fromJson(metric, stored[metric.name] as Map<String, dynamic>)
: HudWidgetLayout.defaultFor(metric),
};
}
Future<void> setHudLayout(Map<HudMetric, HudWidgetLayout> layout) => _prefs.setString(
_keyHudLayout,
jsonEncode({for (final entry in layout.entries) entry.key.name: entry.value.toJson()}),
);
/// Stable per-install id so a server can distinguish riders in a group. /// Stable per-install id so a server can distinguish riders in a group.
String get deviceId { String get deviceId {
final existing = _prefs.getString(_keyDeviceId); final existing = _prefs.getString(_keyDeviceId);

View File

@@ -0,0 +1,51 @@
/// UI-04: the in-memory, authoritative HUD layout during an editing session.
///
/// Seeded from `Config` once, and written back to it only on [persist] -- not on every
/// drag/resize frame, which would hammer `SharedPreferences` mid-drag (the ticket's own
/// named risk). A drag in progress only ever touches this in-memory state; [persist] is
/// what a `HudEditOverlay` calls when the rider exits edit mode.
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/config.dart';
import 'hud_metric.dart';
import 'hud_widget_layout.dart';
class HudLayoutController extends StateNotifier<Map<HudMetric, HudWidgetLayout>> {
// `Config?`, not `Config` -- at app boot `configProvider` is briefly null (loaded
// asynchronously post-first-frame, same as every other Config-seeded provider in
// this app). Defaults stand in until it resolves; [persist] silently no-ops rather
// than blocking, since editing the HUD before Config has loaded isn't a real path a
// rider can reach in practice.
HudLayoutController(this._config)
: super(
_config?.hudLayout ??
{for (final m in HudMetric.values) m: HudWidgetLayout.defaultFor(m)},
);
final Config? _config;
void updatePosition(HudMetric metric, double x, double y) {
final current = state[metric];
if (current == null) return;
state = {...state, metric: current.copyWith(x: x, y: y).clamped()};
}
void updateSize(HudMetric metric, double width, double height) {
final current = state[metric];
if (current == null) return;
state = {...state, metric: current.copyWith(width: width, height: height).clamped()};
}
/// A metric turned on for the first time (no meaningfully-placed prior layout) gets
/// [HudWidgetLayout.defaultFor] rather than whatever stale position it held from
/// before it was last turned off -- the ticket's own acceptance criterion. A metric
/// that already has a real saved position keeps it.
void setVisible(HudMetric metric, bool visible) {
final current = state[metric] ?? HudWidgetLayout.defaultFor(metric);
state = {...state, metric: current.copyWith(visible: visible)};
}
Future<void> persist() async => _config?.setHudLayout(state);
}

View File

@@ -0,0 +1,39 @@
/// UI-04: the stable set of metrics a rider can place on the customizable HUD.
///
/// The same set `RecordUiState`/`Trip` already expose (see `record_screen.dart`'s
/// stats card) -- not a new data source, just a stable id for each one so a layout can
/// be persisted and re-applied across app versions without breaking if display order
/// changes.
library;
enum HudMetric {
// The first four are, in order, the actual Map HUD mockup's fixed telemetry row --
// Speed / Avg Speed / Dist / Time, verified directly against
// docs/design/stitch-export/screens/map-hud-dark.html's `#telemetry-container`
// rather than assumed. `HudWidgetLayout.defaultFor` uses this ordering directly to
// decide both default grid position and which metrics start visible, so this order
// is load-bearing, not cosmetic. (UI-05 correction: an earlier guess here, made
// before that HTML had been read closely, wrongly ordered Distance/Elapsed/Max
// Speed as the fixed four.)
speed,
avgSpeed,
distance,
elapsedTime,
maxSpeed,
movingTime,
elevationGain,
pointsCaptured;
/// Settings' "Live HUD stats" list label. Enum name, not this, is what's persisted --
/// this can be reworded freely without touching a saved layout.
String get label => switch (this) {
HudMetric.speed => 'Speed',
HudMetric.distance => 'Distance',
HudMetric.elapsedTime => 'Elapsed time',
HudMetric.movingTime => 'Moving time',
HudMetric.maxSpeed => 'Max speed',
HudMetric.avgSpeed => 'Average speed',
HudMetric.elevationGain => 'Elevation gain',
HudMetric.pointsCaptured => 'Points captured',
};
}

View File

@@ -0,0 +1,121 @@
/// UI-04: one telemetry widget's position, size, and visibility on the customizable
/// HUD -- see the ticket's Design section for why these are fractions (0.0-1.0) of the
/// available HUD area rather than absolute pixels: a layout saved on one device or
/// orientation still makes sense on another.
library;
import 'hud_metric.dart';
/// Legibility floor and a sane ceiling -- a widget must never shrink to the point its
/// own number is unreadable, or grow to the point it swallows the whole HUD.
const double hudMinWidthFraction = 0.20;
const double hudMaxWidthFraction = 0.70;
const double hudMinHeightFraction = 0.08;
const double hudMaxHeightFraction = 0.40;
class HudWidgetLayout {
const HudWidgetLayout({
required this.metric,
required this.x,
required this.y,
required this.width,
required this.height,
required this.visible,
});
final HudMetric metric;
/// Top-left corner, as a fraction of the HUD area's width/height.
final double x;
final double y;
final double width;
final double height;
final bool visible;
HudWidgetLayout copyWith({
double? x,
double? y,
double? width,
double? height,
bool? visible,
}) => HudWidgetLayout(
metric: metric,
x: x ?? this.x,
y: y ?? this.y,
width: width ?? this.width,
height: height ?? this.height,
visible: visible ?? this.visible,
);
/// Corrects a drag/resize result that ended outside the allowed area back to the
/// nearest valid position/size -- clamped to the [hudMinWidthFraction]/
/// [hudMaxWidthFraction] etc. bounds first (size), then positioned so it can never
/// sit even partially outside the 0.0-1.0 HUD area (position), in that order: a
/// resize that would push a widget off-screen should shrink it back on-screen, not
/// silently reposition it out from under the rider's finger.
HudWidgetLayout clamped() {
final clampedWidth = width.clamp(hudMinWidthFraction, hudMaxWidthFraction);
final clampedHeight = height.clamp(hudMinHeightFraction, hudMaxHeightFraction);
final clampedX = x.clamp(0.0, 1.0 - clampedWidth);
final clampedY = y.clamp(0.0, 1.0 - clampedHeight);
return HudWidgetLayout(
metric: metric,
x: clampedX,
y: clampedY,
width: clampedWidth,
height: clampedHeight,
visible: visible,
);
}
Map<String, dynamic> toJson() => {
'x': x,
'y': y,
'width': width,
'height': height,
'visible': visible,
};
/// Falls back to [defaultFor] rather than throwing on a malformed/partial entry --
/// an old saved layout from a future app version with fields this version doesn't
/// recognise should degrade to a sane default, not crash Settings on launch.
static HudWidgetLayout fromJson(HudMetric metric, Map<String, dynamic> json) {
try {
return HudWidgetLayout(
metric: metric,
x: (json['x'] as num).toDouble(),
y: (json['y'] as num).toDouble(),
width: (json['width'] as num).toDouble(),
height: (json['height'] as num).toDouble(),
visible: json['visible'] as bool,
).clamped();
} catch (_) {
return HudWidgetLayout.defaultFor(metric);
}
}
/// A deterministic starting grid -- a fresh install has a working, if plain, HUD
/// before the rider customises anything, and a metric toggled on for the first time
/// (with no saved position) lands somewhere sane rather than stacked on another
/// widget. Two rows of four, matching the Stitch mockup's row of cards for however
/// many metrics fit in the first row, with the rest continuing below it.
factory HudWidgetLayout.defaultFor(HudMetric metric) {
const columns = 4;
const cellWidth = 0.22;
const cellHeight = 0.12;
const gap = 0.02;
final index = HudMetric.values.indexOf(metric);
final row = index ~/ columns;
final col = index % columns;
return HudWidgetLayout(
metric: metric,
x: 0.02 + col * (cellWidth + gap),
y: 0.06 + row * (cellHeight + gap),
width: cellWidth,
height: cellHeight,
// The Map HUD mockup's own fixed row is Speed/Avg Speed/Dist/Time -- the first
// four enum values are ordered to match, so only those start visible.
visible: index < columns,
);
}
}

View File

@@ -11,17 +11,24 @@ import 'package:flutter/painting.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'map_connectivity.dart';
import 'tile_cache.dart'; import 'tile_cache.dart';
import 'tile_math.dart'; import 'tile_math.dart';
class CachedTileProvider extends TileProvider { class CachedTileProvider extends TileProvider {
CachedTileProvider({required this.cache, http.Client? client}) CachedTileProvider({required this.cache, this.connectivity, http.Client? client})
: _client = client ?? http.Client(), : _client = client ?? http.Client(),
super(); super();
final TileCache cache; final TileCache cache;
final http.Client _client; final http.Client _client;
/// UI-02: told about every fetch outcome so it can decide whether the map should be
/// showing an animated skeleton instead of tiles. Null in tests/callers that don't
/// care -- skeleton mode is a UI concern layered on top of caching, not something
/// this provider requires to function.
final MapConnectivityState? connectivity;
@override @override
ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => ImageProvider getImage(TileCoordinates coordinates, TileLayer options) =>
_CacheBackedImage( _CacheBackedImage(
@@ -30,6 +37,7 @@ class CachedTileProvider extends TileProvider {
headers: headers, headers: headers,
cache: cache, cache: cache,
client: _client, client: _client,
connectivity: connectivity,
); );
} }
@@ -40,6 +48,7 @@ class _CacheBackedImage extends ImageProvider<_CacheBackedImage> {
required this.headers, required this.headers,
required this.cache, required this.cache,
required this.client, required this.client,
this.connectivity,
}); });
final TileKey key; final TileKey key;
@@ -47,6 +56,7 @@ class _CacheBackedImage extends ImageProvider<_CacheBackedImage> {
final Map<String, String> headers; final Map<String, String> headers;
final TileCache cache; final TileCache cache;
final http.Client client; final http.Client client;
final MapConnectivityState? connectivity;
@override @override
Future<_CacheBackedImage> obtainKey(ImageConfiguration configuration) => Future<_CacheBackedImage> obtainKey(ImageConfiguration configuration) =>
@@ -62,12 +72,15 @@ class _CacheBackedImage extends ImageProvider<_CacheBackedImage> {
Future<ui.Codec> _load(ImageDecoderCallback decode) async { Future<ui.Codec> _load(ImageDecoderCallback decode) async {
final cached = await cache.get(key); final cached = await cache.get(key);
// A cache hit says nothing about current connectivity either way -- it's not a
// network round trip, so it neither counts as a success nor resets a failure run.
final bytes = cached ?? await _fetchAndStore(); final bytes = cached ?? await _fetchAndStore();
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
return decode(buffer); return decode(buffer);
} }
Future<Uint8List> _fetchAndStore() async { Future<Uint8List> _fetchAndStore() async {
try {
final response = await client.get(Uri.parse(url), headers: headers); final response = await client.get(Uri.parse(url), headers: headers);
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception('Tile fetch failed: ${response.statusCode} for $url'); throw Exception('Tile fetch failed: ${response.statusCode} for $url');
@@ -76,7 +89,15 @@ class _CacheBackedImage extends ImageProvider<_CacheBackedImage> {
// Write-through: viewing a tile online caches it for later, exactly like // Write-through: viewing a tile online caches it for later, exactly like
// flutter_map's own default caching did -- just capped and evictable now. // flutter_map's own default caching did -- just capped and evictable now.
await cache.put(key, bytes); await cache.put(key, bytes);
connectivity?.reportSuccess();
return bytes; return bytes;
} catch (_) {
// UI-02: a cache miss whose network fetch also failed is exactly the "no
// connection" signal skeleton mode is watching for -- report it and rethrow so
// flutter_map's own error handling for this tile is unchanged.
connectivity?.reportFailure();
rethrow;
}
} }
@override @override

View File

@@ -0,0 +1,108 @@
/// UI-02: decides whether the map should show live tiles or an animated skeleton.
///
/// Driven by actual tile-fetch outcomes, not an OS connectivity API -- a connectivity
/// API can report "online" while the real tile fetch still times out (a captive portal,
/// a degraded connection), and the fetch's own success or failure is the only thing
/// that actually matters to what's on screen.
library;
import 'dart:async';
import 'package:flutter/foundation.dart';
/// Consecutive tile-fetch failures (cache miss *and* network fetch failed) before
/// switching to skeleton mode. High enough that one blip mid-ride doesn't flash a
/// skeleton over an otherwise-live map; three genuine failures in a row is a real
/// connectivity problem, not noise.
const int skeletonFailureThreshold = 3;
/// How often skeleton mode probes for recovery. A `TileLayer` is fully unmounted while
/// in skeleton mode -- see `RideMap` -- so nothing is generating ordinary fetch
/// outcomes to react to; something has to periodically try again on the map's behalf.
/// Long enough that this can never look like the retry-storm V3-11's own design exists
/// to avoid, short enough that recovery still feels close to automatic.
const Duration skeletonProbeInterval = Duration(seconds: 15);
/// Tracks tile-fetch health and flips between live and skeleton map modes.
///
/// One instance is shared across every map in the app (see `mapConnectivityProvider`) --
/// connectivity is a fact about the network, not about which particular map widget
/// happens to be on screen, and sharing it means a failure noticed on one map's fetch
/// immediately reflects on every other map too.
class MapConnectivityState extends ChangeNotifier {
MapConnectivityState({required Future<bool> Function() probe, Duration? probeInterval})
: _probe = probe,
_probeInterval = probeInterval ?? skeletonProbeInterval;
final Future<bool> Function() _probe;
final Duration _probeInterval;
int _consecutiveFailures = 0;
bool _skeletonMode = false;
Timer? _probeTimer;
bool _probing = false;
bool get skeletonMode => _skeletonMode;
/// A tile fetch actually reached the network and succeeded. Resets the failure
/// count and, if already in skeleton mode, recovers immediately -- a single success
/// is enough, per the ticket's own acceptance criteria; there's no reason to make a
/// rider wait out a timer once the map has proven it works again.
void reportSuccess() {
_consecutiveFailures = 0;
if (_skeletonMode) _setSkeletonMode(false);
}
/// A tile fetch missed the cache and the network fetch also failed.
void reportFailure() {
// Once in skeleton mode, the `TileLayer` generating these reports is unmounted --
// recovery is the probe loop's job instead, not further failure counting.
if (_skeletonMode) return;
_consecutiveFailures++;
if (_consecutiveFailures >= skeletonFailureThreshold) {
_setSkeletonMode(true);
}
}
void _setSkeletonMode(bool value) {
if (_skeletonMode == value) return;
_skeletonMode = value;
if (value) {
_startProbing();
} else {
_stopProbing();
_consecutiveFailures = 0;
}
notifyListeners();
}
void _startProbing() {
_probeTimer?.cancel();
_probeTimer = Timer.periodic(_probeInterval, (_) => _runProbe());
}
void _stopProbing() {
_probeTimer?.cancel();
_probeTimer = null;
}
Future<void> _runProbe() async {
// A probe already in flight when the timer fires again means the last one is
// taking longer than the interval -- exactly the slow/degraded-connection case a
// second overlapping probe would make worse, not better.
if (_probing) return;
_probing = true;
try {
final recovered = await _probe();
if (recovered) _setSkeletonMode(false);
} finally {
_probing = false;
}
}
@override
void dispose() {
_stopProbing();
super.dispose();
}
}

View File

@@ -0,0 +1,18 @@
/// UI-09: the one place the app's tile source is named, so `RideMap`, the route
/// planner's own `FlutterMap`, and (UI-02) the connectivity probe in `app/providers.dart`
/// all point at the same host with the same parameters rather than three copies that
/// could silently drift apart.
library;
/// CARTO's dark basemap. `{s}` is one of [tileSubdomains]; `{r}` is resolved by
/// `TileLayer`'s own `retinaMode` to `@2x` (or empty) based on device pixel ratio.
const String tileUrlTemplate = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
const List<String> tileSubdomains = ['a', 'b', 'c', 'd'];
/// CARTO's dark tiles are natively rendered up to this zoom -- passed to
/// `TileLayer.maxNativeZoom` so a future bump to the app's own zoom ceiling
/// (`RideMap.maxTileZoom`) doesn't also require re-deriving this number.
const int tileMaxNativeZoom = 20;
/// Identifies the app to the tile host's servers. Anonymous bulk requests get 403.
const String tileUserAgent = 'com.rippr.port';

161
lib/src/ui/app_shell.dart Normal file
View File

@@ -0,0 +1,161 @@
/// UI-01: the persistent 4-tab shell. Replaces the old push/pop stack rooted at Record.
///
/// One background map, always mounted, shared by every tab -- not four separate map
/// instances. Full opacity and interactive on the Map tab; dimmed and non-interactive
/// (taps pass through to the tab's real content) everywhere else. This is what lets the
/// map survive a tab switch with its camera position and live path intact, and what
/// makes "the map is active on every tab" true without refetching tiles four times over.
///
/// No `AppBar` anywhere in this shell or in any of the four tab roots -- the point of the
/// redesign is showing as much map as possible, and a persistent bottom nav plus zero
/// header chrome is what buys that space back.
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../app/providers.dart';
import '../domain/models.dart';
import 'components/ride_map.dart';
/// The thin go_router adapter. Kept separate from [ShellScaffold] so tests can drive the
/// actual shell logic (background map, dimming, nav bar) with a plain `currentIndex` and
/// `child`, without needing to construct a real [StatefulNavigationShell] -- which
/// go_router only ever builds internally.
class AppShell extends StatelessWidget {
const AppShell({super.key, required this.navigationShell});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) => ShellScaffold(
currentIndex: navigationShell.currentIndex,
onDestinationSelected: (index) => navigationShell.goBranch(
index,
// Tapping the already-active tab pops it back to its own root, matching the
// conventional "tap the active tab to go home" behaviour of a bottom-tab app.
initialLocation: index == navigationShell.currentIndex,
),
child: navigationShell,
);
}
class ShellScaffold extends ConsumerWidget {
const ShellScaffold({
super.key,
required this.currentIndex,
required this.onDestinationSelected,
required this.child,
});
final int currentIndex;
final ValueChanged<int> onDestinationSelected;
final Widget child;
static const mapTabIndex = 0;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isMapTab = currentIndex == mapTabIndex;
// The Settings toggle's whole point (see its own doc comment) is that no tile is
// ever fetched when off, not merely hidden -- that guarantee must survive this
// screen becoming a permanent background across every tab, not just apply to the
// Map tab the way it used to.
final mapEnabled = ref.watch(mapEnabledProvider);
final trip = ref.watch(activeTripProvider).valueOrNull;
final points = trip == null
? const <TrackPoint>[]
: ref.watch(livePointsProvider(trip.id)).valueOrNull ?? const <TrackPoint>[];
final segments = trip == null
? const <Segment>[]
: ref.watch(liveSegmentsProvider(trip.id)).valueOrNull ?? const <Segment>[];
return Scaffold(
// Falls back to the ordinary theme background when the map is disabled -- the
// per-tab screens are transparent now, relying on this shell to paint something.
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Stack(
children: [
// The one shared map instance. IgnorePointer rather than a per-tab rebuild:
// keeping this the same widget across tab switches is what preserves camera
// position and avoids refetching tiles. Not constructed at all when the map is
// disabled -- no widget means no tile request can fire, the same real
// short-circuit `mapEnabledProvider` always guaranteed.
if (mapEnabled)
Positioned.fill(
child: IgnorePointer(
ignoring: !isMapTab,
child: RideMap(
key: const Key('shell-background-map'),
points: points,
segments: segments,
follow: isMapTab,
fill: true,
showEmptyLabel: false,
tileProvider: ref.watch(cachedTileProviderProvider),
skeletonMode: ref.watch(mapConnectivityProvider).skeletonMode,
// UI-05: only on the Map tab -- a pulsing marker ticking away behind
// a scrim on every other tab would be wasted animation no one sees.
showLocationMarker: isMapTab,
),
),
),
// Dims the map behind every tab except Map itself -- decoration there, not a
// control surface; the scrim also absorbs taps so they can't reach the map.
if (mapEnabled && !isMapTab)
const Positioned.fill(
child: IgnorePointer(
child: ColoredBox(color: Color(0xB3000000)),
),
),
Positioned.fill(child: child),
],
),
bottomNavigationBar: _ShellNavBar(
currentIndex: currentIndex,
onDestinationSelected: onDestinationSelected,
),
);
}
}
class _ShellNavBar extends StatelessWidget {
const _ShellNavBar({required this.currentIndex, required this.onDestinationSelected});
final int currentIndex;
final ValueChanged<int> onDestinationSelected;
@override
Widget build(BuildContext context) => NavigationBar(
key: const Key('shell-nav-bar'),
selectedIndex: currentIndex,
onDestinationSelected: onDestinationSelected,
destinations: const [
NavigationDestination(
key: Key('nav-map'),
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Map',
),
NavigationDestination(
key: Key('nav-rides'),
icon: Icon(Icons.directions_bike_outlined),
selectedIcon: Icon(Icons.directions_bike),
label: 'Rides',
),
NavigationDestination(
key: Key('nav-plan'),
icon: Icon(Icons.route_outlined),
selectedIcon: Icon(Icons.route),
label: 'Plan',
),
NavigationDestination(
key: Key('nav-settings'),
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
],
);
}

View File

@@ -0,0 +1,134 @@
/// UI-04: one telemetry widget on the customizable HUD -- positioned/sized from a
/// [HudWidgetLayout]'s fractions against whatever pixel area it's given, draggable and
/// resizable only while [editing] is true.
///
/// Deliberately does not attach any drag gesture at all when [editing] is false --
/// "outside edit mode, a normal tap never moves a widget" is guaranteed structurally
/// by the absence of a `GestureDetector`, not by an internal flag a bug could ignore.
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../hud/hud_widget_layout.dart';
import 'glass_panel.dart';
class DraggableResizableHudWidget extends StatefulWidget {
const DraggableResizableHudWidget({
super.key,
required this.layout,
required this.areaSize,
required this.editing,
required this.child,
required this.onMoved,
required this.onResized,
});
final HudWidgetLayout layout;
/// The pixel size of the HUD area the fractions in [layout] are relative to --
/// supplied by the caller (a `HudEditOverlay` reading its own `LayoutBuilder`
/// constraints), which is what keeps this widget itself free of any assumption
/// about what "the HUD area" means on a given screen (e.g. excluding the bottom nav
/// bar is the caller's job, not this widget's).
final Size areaSize;
final bool editing;
final Widget child;
/// Fractional x/y, already relative to [areaSize] -- not yet clamped; the caller
/// (`HudLayoutController.updatePosition`) owns clamping so there is exactly one
/// place that logic lives.
final void Function(double x, double y) onMoved;
final void Function(double width, double height) onResized;
@override
State<DraggableResizableHudWidget> createState() =>
_DraggableResizableHudWidgetState();
}
class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidget> {
bool _grabbed = false;
@override
Widget build(BuildContext context) {
final layout = widget.layout;
final left = layout.x * widget.areaSize.width;
final top = layout.y * widget.areaSize.height;
final width = layout.width * widget.areaSize.width;
final height = layout.height * widget.areaSize.height;
Widget card = AnimatedScale(
scale: _grabbed ? 1.05 : 1.0,
duration: const Duration(milliseconds: 150),
child: GlassPanel(child: Center(child: widget.child)),
);
if (widget.editing) {
card = GestureDetector(
onLongPressStart: (_) {
HapticFeedback.mediumImpact();
setState(() => _grabbed = true);
},
onLongPressMoveUpdate: (details) {
widget.onMoved(
layout.x + details.offsetFromOrigin.dx / widget.areaSize.width,
layout.y + details.offsetFromOrigin.dy / widget.areaSize.height,
);
},
onLongPressEnd: (_) => setState(() => _grabbed = false),
onLongPressCancel: () => setState(() => _grabbed = false),
child: card,
);
} else {
// A no-op long-press recognizer, not merely the absence of one: without it, a
// long-press on this widget wins nothing here and is free to bubble up to a
// `HudEditOverlay`'s background gesture detector, wrongly entering edit mode
// from a press that landed *on* a widget rather than the empty HUD area the
// ticket's design specifically calls for ("a long-press anywhere on the HUD
// area that isn't a specific widget"). An inner `GestureDetector` registering
// the same gesture type wins the arena over the outer one, absorbing it here.
card = GestureDetector(onLongPress: () {}, child: card);
}
return Positioned(
left: left,
top: top,
width: width,
height: height,
child: Stack(
clipBehavior: Clip.none,
children: [
card,
// The resize handle: visible, and interactive, only in edit mode -- a
// permanent on-screen handle during a live ride would be visual noise the
// ticket explicitly calls out as unwanted.
if (widget.editing)
Positioned(
right: -8,
bottom: -8,
child: GestureDetector(
onPanUpdate: (details) {
widget.onResized(
layout.width + details.delta.dx / widget.areaSize.width,
layout.height + details.delta.dy / widget.areaSize.height,
);
},
child: Container(
key: const Key('hud-resize-handle'),
width: 24,
height: 24,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: const Icon(Icons.open_in_full, size: 14, color: Colors.black),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,99 @@
/// UI-03: the horizontally-centred, fully-rounded stat summary from the Route Planning
/// export's Distance/Est. Time/Pins header -- a `GlassPanel` shaped as a capsule, laid
/// out with vertical dividers between however many stats it's given (not hardcoded to
/// three, so a screen ticket can use it for two stats or five without a second widget).
library;
import 'package:flutter/material.dart';
import 'glass_panel.dart';
class PillStat {
const PillStat({required this.label, required this.value, this.unit});
final String label;
final String value;
/// Rendered smaller and after [value], matching the export's `12.4<span
/// class="text-sm">km</span>` treatment -- a unit is not part of the number itself.
final String? unit;
}
class FloatingPill extends StatelessWidget {
const FloatingPill({super.key, required this.stats});
final List<PillStat> stats;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return GlassPanel(
borderRadius: const BorderRadius.all(Radius.circular(999)),
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < stats.length; i++) ...[
if (i > 0)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
width: 1,
height: 32,
color: colors.outlineVariant.withValues(alpha: 0.3),
),
),
_StatColumn(stat: stats[i]),
],
],
),
);
}
}
class _StatColumn extends StatelessWidget {
const _StatColumn({required this.stat});
final PillStat stat;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
stat.label.toUpperCase(),
style: TextStyle(
fontSize: 10,
letterSpacing: 1.5,
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
stat.value,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: colors.primary,
),
),
if (stat.unit != null) ...[
const SizedBox(width: 2),
Text(
stat.unit!,
style: TextStyle(fontSize: 13, color: colors.primary),
),
],
],
),
],
);
}
}

View File

@@ -0,0 +1,62 @@
/// UI-03: the one blurred-glass surface every floating card, tooltip, and control in
/// the redesign is built on, so `backdrop-blur`/translucency/border styling is written
/// once here rather than reinvented per screen. Pure presentation -- no data-fetching,
/// no business logic.
///
/// Values are lifted directly from the Stitch exports' own glass treatment
/// (`bg-surface/90 backdrop-blur-xl border border-outline-variant/30`), not guessed:
/// see `docs/design/stitch-export/screens/route-planning-dark.html`.
library;
import 'dart:ui';
import 'package:flutter/material.dart';
import '../theme.dart' show ripprRadiusLarge;
class GlassPanel extends StatelessWidget {
const GlassPanel({
super.key,
required this.child,
this.borderRadius = const BorderRadius.all(Radius.circular(ripprRadiusLarge)),
this.padding,
});
final Widget child;
final BorderRadiusGeometry borderRadius;
final EdgeInsetsGeometry? padding;
/// Tailwind's `backdrop-blur-xl`. Strong enough that whatever is behind the panel
/// (the live map, in every real usage) reads as texture, not detail -- the panel's
/// own content is what the eye is meant to land on.
static const double blurSigma = 24.0;
/// `bg-surface/90` -- almost opaque. Enough blur shows through to feel like glass
/// over the map rather than a solid card, without ever risking the panel's own text
/// losing contrast against whatever happens to be moving underneath it.
static const double fillOpacity = 0.9;
/// `border-outline-variant/30` -- a barely-there seam, not a hard edge.
static const double borderOpacity = 0.3;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return ClipRRect(
borderRadius: borderRadius,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
child: DecoratedBox(
decoration: BoxDecoration(
color: colors.surface.withValues(alpha: fillOpacity),
borderRadius: borderRadius,
border: Border.all(
color: colors.outlineVariant.withValues(alpha: borderOpacity),
),
),
child: padding == null ? child : Padding(padding: padding!, child: child),
),
),
);
}
}

View File

@@ -0,0 +1,87 @@
/// UI-04: hosts every visible HUD telemetry widget over a given area, and owns
/// entering/exiting edit mode -- the chrome (resize handles, the "Done" affordance)
/// only exists while editing, per the ticket's own reasoning against permanent-on-
/// screen edit chrome during a live ride.
///
/// Deliberately has no idea what a metric's live *value* is -- that's `RecordUiState`
/// (or equivalent), owned by whichever screen actually assembles a real HUD (UI-05).
/// [metricBuilder] is how that content gets in without this widget depending on it.
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/providers.dart';
import '../../hud/hud_metric.dart';
import 'draggable_resizable_hud_widget.dart';
class HudEditOverlay extends ConsumerStatefulWidget {
const HudEditOverlay({super.key, required this.metricBuilder});
final Widget Function(BuildContext context, HudMetric metric) metricBuilder;
@override
ConsumerState<HudEditOverlay> createState() => _HudEditOverlayState();
}
class _HudEditOverlayState extends ConsumerState<HudEditOverlay> {
bool _editing = false;
void _exitAndPersist() {
setState(() => _editing = false);
ref.read(hudLayoutControllerProvider.notifier).persist();
}
@override
Widget build(BuildContext context) {
final layout = ref.watch(hudLayoutControllerProvider);
final notifier = ref.read(hudLayoutControllerProvider.notifier);
return LayoutBuilder(
builder: (context, constraints) {
final areaSize = Size(constraints.maxWidth, constraints.maxHeight);
return GestureDetector(
key: const Key('hud-edit-background'),
behavior: HitTestBehavior.translucent,
// Long-press empty HUD space to enter edit mode; while already editing, a
// plain tap on empty space exits it (and persists) -- both per the ticket's
// Design section.
onLongPress: _editing
? null
: () {
HapticFeedback.selectionClick();
setState(() => _editing = true);
},
onTap: _editing ? _exitAndPersist : null,
child: Stack(
clipBehavior: Clip.none,
children: [
for (final entry in layout.entries)
if (entry.value.visible)
DraggableResizableHudWidget(
key: ValueKey(entry.key),
layout: entry.value,
areaSize: areaSize,
editing: _editing,
onMoved: (x, y) => notifier.updatePosition(entry.key, x, y),
onResized: (w, h) => notifier.updateSize(entry.key, w, h),
child: widget.metricBuilder(context, entry.key),
),
if (_editing)
Positioned(
top: 8,
right: 8,
child: FilledButton(
key: const Key('hud-edit-done'),
onPressed: _exitAndPersist,
child: const Text('Done'),
),
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,109 @@
/// UI-03: the expanding-ring-plus-glow-dot user-location marker from the Plan/Route
/// Planning exports. Values (sizes, colours, the scale/opacity curve) are lifted from
/// the exports' own `pulse-ring` keyframes, not guessed -- see
/// `docs/design/stitch-export/screens/route-planning-dark.html`.
library;
import 'package:flutter/material.dart';
class PulsingLocationMarker extends StatefulWidget {
const PulsingLocationMarker({super.key});
@override
State<PulsingLocationMarker> createState() => _PulsingLocationMarkerState();
}
class _PulsingLocationMarkerState extends State<PulsingLocationMarker>
with SingleTickerProviderStateMixin {
late final _controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
);
@override
void didChangeDependencies() {
super.didChangeDependencies();
_syncWithReducedMotion();
}
/// A static dot -- the ring never expands -- when the platform has reduced motion
/// enabled, the same accessibility setting `prefers-reduced-motion` maps to on the
/// web this component's design came from.
void _syncWithReducedMotion() {
final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
if (reduceMotion) {
_controller.stop();
} else if (!_controller.isAnimating) {
_controller.repeat();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return SizedBox(
width: 40,
height: 40,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
// The keyframes' own 0%/70%/100% easing: scale 0.8 -> 1.0 and opacity
// 0.8 -> 0, both eased out rather than linear, so the ring visibly slows as
// it fades rather than looking like it's cut off mid-motion.
final t = Curves.easeOut.transform(_controller.value);
final scale = 0.8 + (0.2 * t);
final opacity = 0.8 * (1 - t);
return Stack(
alignment: Alignment.center,
children: [
// The faint, static outer ring -- always present, pulse or no pulse.
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: colors.primary.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
),
Transform.scale(
scale: scale,
child: Opacity(
opacity: opacity,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: colors.primary,
shape: BoxShape.circle,
),
),
),
),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: colors.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: colors.primary.withValues(alpha: 0.5),
blurRadius: 10,
spreadRadius: 1,
),
],
),
),
],
);
},
),
);
}
}

View File

@@ -1,17 +1,22 @@
/// Ported from `com.rippr.ui.components.RideMap` (osmdroid → flutter_map). /// Ported from `com.rippr.ui.components.RideMap` (osmdroid → flutter_map).
/// ///
/// Same tile source, same reasoning that chose osmdroid: OpenStreetMap raster tiles, no /// UI-09: tiles come from CARTO's "Dark Matter" basemap, not OSM's own tan/cream
/// API key, no billing. /// default -- a purpose-built dark, desaturated style that actually matches the HUD
/// sitting on top of it, rather than a light basemap punched through a dark overlay.
/// Still no API key, no billing; still a third-party tile host to respect the same way
/// OSM's own usage policy was respected before (real user agent, no bulk prefetch,
/// rate-limit-conscious), just a different one, with its own attribution obligation
/// alongside OSM's (CARTO's stylesheet is still built from OSM's underlying data).
/// ///
/// ## Four things here are load-bearing /// ## Four things here are load-bearing
/// ///
/// 1. **One polyline per segment**, so a pause leaves a visible gap instead of a /// 1. **One polyline per segment**, so a pause leaves a visible gap instead of a
/// straight line drawn across town. /// straight line drawn across town.
/// 2. **Decimation is render-only.** `simplify` never touches storage or export. /// 2. **Decimation is render-only.** `simplify` never touches storage or export.
/// 3. **The zoom clamp.** Fitting a 50 m ride zooms past OpenStreetMap's maximum tile /// 3. **The zoom clamp.** Fitting a 50 m ride zooms past this app's zoom ceiling and
/// zoom of 19 and renders an empty grid — that shipped in v2.0 and is invisible to /// renders an empty grid — that shipped in v2.0 (against OSM's own tiles, capped at
/// any fixture of the wrong size. /// the same 19) and is invisible to any fixture of the wrong size.
/// 4. **A real user agent**, or OSM's tile servers return 403. /// 4. **A real user agent**, or a tile host's servers return 403.
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -20,6 +25,12 @@ import 'package:latlong2/latlong.dart' as ll;
import '../../domain/models.dart'; import '../../domain/models.dart';
import '../../geo/geo.dart' as geo; import '../../geo/geo.dart' as geo;
import '../../tiles/tile_config.dart';
import '../theme.dart' show ripprRadiusLarge;
import 'pulsing_location_marker.dart';
import 'skeleton_map_layer.dart';
export '../../tiles/tile_config.dart';
/// Metres. Render-only: a 3-hour ride is ~21,600 points and would jank undecimated. /// Metres. Render-only: a 3-hour ride is ~21,600 points and would jank undecimated.
const double simplifyEpsilonM = 5.0; const double simplifyEpsilonM = 5.0;
@@ -28,15 +39,14 @@ const double simplifyEpsilonM = 5.0;
/// fiddly, and flutter_map has no equivalent either. Buckets also read better at a glance. /// fiddly, and flutter_map has no equivalent either. Buckets also read better at a glance.
const int speedBucketKmh = 10; const int speedBucketKmh = 10;
/// OpenStreetMap serves no raster tiles above this. Exceeding it renders a blank grid. /// This app's own zoom ceiling -- not raised to CARTO's native 20 ([tileMaxNativeZoom])
/// without deliberately re-verifying the fit/follow-zoom logic tuned against 19 by
/// earlier tickets. Exceeding it renders an empty grid.
const double maxTileZoom = 19.0; const double maxTileZoom = 19.0;
/// What a very short ride falls back to, so streets stay visible. /// What a very short ride falls back to, so streets stay visible.
const double shortRideZoom = 17.0; const double shortRideZoom = 17.0;
/// Identifies the app to OSM's servers. Anonymous bulk requests get 403.
const String tileUserAgent = 'com.rippr.port';
class RideMap extends StatefulWidget { class RideMap extends StatefulWidget {
const RideMap({ const RideMap({
super.key, super.key,
@@ -45,12 +55,35 @@ class RideMap extends StatefulWidget {
this.height = 320, this.height = 320,
this.follow = false, this.follow = false,
this.tileProvider, this.tileProvider,
this.fill = false,
this.showEmptyLabel = true,
this.skeletonMode = false,
this.showLocationMarker = false,
this.showAttribution = true,
}); });
final List<TrackPoint> points; final List<TrackPoint> points;
final List<Segment> segments; final List<Segment> segments;
final double height; final double height;
/// UI-02: true when `MapConnectivityState` has decided neither the offline cache nor
/// the network can currently produce tiles. Swaps `TileLayer` for `SkeletonMapLayer`
/// -- markers/polylines are unaffected, since those come from local data, not tiles.
/// A plain `bool` rather than watching the connectivity state directly, matching how
/// [tileProvider] is already handed down rather than looked up -- `RideMap` stays a
/// plain `StatefulWidget`, not a `ConsumerWidget`.
final bool skeletonMode;
/// UI-01: fills whatever space the parent gives it (a `Positioned.fill`/`Expanded`
/// ancestor) instead of the fixed [height] -- for the persistent full-screen
/// background map behind every tab, where there is no card to size it.
final bool fill;
/// False for the persistent background: an idle app with no ride yet is the normal
/// state there, not an error to explain with text -- it should just look like a map,
/// centred on a neutral default, with nothing overlaid.
final bool showEmptyLabel;
/// V3-11: when supplied, tiles are read from (and written through to) the offline /// V3-11: when supplied, tiles are read from (and written through to) the offline
/// tile cache instead of flutter_map's own uncapped default. Null falls back to /// tile cache instead of flutter_map's own uncapped default. Null falls back to
/// ordinary networked tiles -- used whenever the cache isn't ready yet, or in tests. /// ordinary networked tiles -- used whenever the cache isn't ready yet, or in tests.
@@ -62,6 +95,17 @@ class RideMap extends StatefulWidget {
/// following at all. /// following at all.
final bool follow; final bool follow;
/// UI-05: draws a `PulsingLocationMarker` at the latest point -- only meaningful for
/// the live shell background (paired with [follow]), never for a finished ride's
/// static playback, where there is no "current position" to mark.
final bool showLocationMarker;
/// UI-07: false for the tiny (88x88) ride-history thumbnails, where the shared
/// attribution control's own icon/text would be unreadable clutter repeated across
/// dozens of list rows. The credit obligation is already discharged by every full-size
/// map showing the same underlying tiles (Map tab, Route Planner, Trip Detail).
final bool showAttribution;
@override @override
State<RideMap> createState() => _RideMapState(); State<RideMap> createState() => _RideMapState();
} }
@@ -118,13 +162,23 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
return grouped.values.toList(); return grouped.values.toList();
} }
// `SizedBox.expand`, not `Positioned.fill` -- this widget is used both as a direct
// `Stack` child (fine either way) and wrapped in an `IgnorePointer` first (the shared
// shell background), where a `Positioned` is invalid because it isn't a direct child
// of the `Stack`. `SizedBox.expand` fills the available space either way.
Widget _sized({required Widget child}) =>
widget.fill ? SizedBox.expand(child: child) : SizedBox(height: widget.height, child: child);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
final hasPoints = widget.points.isNotEmpty;
if (widget.points.isEmpty) { // The `showEmptyLabel && !hasPoints` case (a finished ride card with no points, e.g.
return SizedBox( // a corrupt/empty trip) renders text only, no map underneath -- that usage never
height: widget.height, // transitions live, so there's no risk to the element tree here.
if (!hasPoints && widget.showEmptyLabel) {
return _sized(
child: Center( child: Center(
child: Text( child: Text(
'No path recorded', 'No path recorded',
@@ -134,24 +188,35 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
); );
} }
final all = [ // UI-01 bug, found by a widget test: the empty and non-empty states used to return
// structurally different widget trees (a bare `FlutterMap` versus
// `ClipRRect > FlutterMap`) for the `showEmptyLabel: false` background-map usage.
// The moment a live ride's first point arrived, Flutter treated that as a full
// element swap rather than a rebuild of the same element -- unmounting the old
// `FlutterMap` out from under a `MapController` that a `didUpdateWidget`-scheduled
// post-frame callback was about to use, which threw "Looking up a deactivated
// widget's ancestor is unsafe." Building exactly one shape always for this usage,
// varying only the `MapOptions`/children by `hasPoints`, removes the swap entirely.
geo.Bounds? bounds;
if (hasPoints) {
bounds = geo.bounds([
for (final p in widget.points) geo.LatLon(p.latitude, p.longitude), for (final p in widget.points) geo.LatLon(p.latitude, p.longitude),
]; ]);
final bounds = geo.bounds(all)!; }
final polylines = _buildPolylines(colors); final polylines = hasPoints ? _buildPolylines(colors) : const <Polyline>[];
return SizedBox( final map = ClipRRect(
height: widget.height,
// The map draws to the edge of its box; clipping keeps it from painting over // The map draws to the edge of its box; clipping keeps it from painting over
// adjacent controls, which osmdroid did until it was explicitly bounded. // adjacent controls, which osmdroid did until it was explicitly bounded.
child: ClipRRect( borderRadius:
borderRadius: BorderRadius.circular(12), widget.fill ? BorderRadius.zero : BorderRadius.circular(ripprRadiusLarge),
child: FlutterMap( child: FlutterMap(
mapController: _controller, mapController: _controller,
options: MapOptions( options: MapOptions(
initialCameraFit: bounds.isDegenerate initialCameraFit: (bounds == null || bounds.isDegenerate)
// Every point at one spot — a parked "ride". Fitting this would zoom to // No points yet, or every point at one spot (a parked "ride") --
// infinity, so centre and use a sane street-level zoom instead. // fitting a degenerate box would zoom to infinity, so centre on a
// neutral or last-known point at a sane street-level zoom instead.
? null ? null
: CameraFit.bounds( : CameraFit.bounds(
bounds: LatLngBounds( bounds: LatLngBounds(
@@ -163,12 +228,18 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
// and renders an empty grid. // and renders an empty grid.
maxZoom: maxTileZoom, maxZoom: maxTileZoom,
), ),
initialCenter: ll.LatLng(bounds.centerLat, bounds.centerLon), initialCenter: bounds == null
initialZoom: bounds.isDegenerate ? shortRideZoom : maxTileZoom, ? const ll.LatLng(0, 0)
: ll.LatLng(bounds.centerLat, bounds.centerLon),
initialZoom: bounds == null
? 2
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
maxZoom: maxTileZoom, maxZoom: maxTileZoom,
interactionOptions: const InteractionOptions( interactionOptions: hasPoints
? const InteractionOptions(
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag, flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
), )
: const InteractionOptions(flags: InteractiveFlag.none),
onPositionChanged: !widget.follow onPositionChanged: !widget.follow
? null ? null
: (position, hasGesture) { : (position, hasGesture) {
@@ -181,22 +252,47 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
}, },
), ),
children: [ children: [
// UI-02: the skeleton replaces the TileLayer entirely rather than sitting on
// top of it -- a widget that keeps trying and failing to fetch underneath its
// own placeholder would be exactly the retry loop the ticket warns against.
if (widget.skeletonMode)
const SkeletonMapLayer()
// Omitted entirely while backgrounded -- not just visually hidden -- so no // Omitted entirely while backgrounded -- not just visually hidden -- so no
// tile request can fire off-screen. See the lifecycle observer above. // tile request can fire off-screen. See the lifecycle observer above.
if (!_backgrounded) else if (!_backgrounded)
TileLayer( TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', urlTemplate: tileUrlTemplate,
subdomains: tileSubdomains,
retinaMode: true,
userAgentPackageName: tileUserAgent, userAgentPackageName: tileUserAgent,
maxNativeZoom: maxTileZoom.toInt(), maxNativeZoom: tileMaxNativeZoom,
// Respect OSM's usage policy: render what is looked at, never bulk prefetch. // Respect the tile host's usage policy: render what is looked at, never
// bulk prefetch.
panBuffer: 0, panBuffer: 0,
tileProvider: widget.tileProvider, tileProvider: widget.tileProvider,
), ),
PolylineLayer(polylines: polylines), PolylineLayer(polylines: polylines),
if (widget.showLocationMarker && hasPoints)
MarkerLayer(
markers: [
Marker(
key: const Key('location-marker'),
point: ll.LatLng(
widget.points.last.latitude,
widget.points.last.longitude,
),
width: 40,
height: 40,
child: const PulsingLocationMarker(),
),
], ],
), ),
if (widget.showAttribution) const TileAttribution(),
],
), ),
); );
return _sized(child: map);
} }
/// One polyline per speed run within each segment. /// One polyline per speed run within each segment.
@@ -266,9 +362,37 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
/// Cool for slow, accent for fast. A single hue ramp reads better through a visor than /// Cool for slow, accent for fast. A single hue ramp reads better through a visor than
/// a rainbow. /// a rainbow.
///
/// UI-08: was a hardcoded instrument-blue lerped to `colors.primary`. Under the old
/// safety-orange theme that gave a clean blue-to-orange ramp; under Modern
/// Professional Dark, `colors.primary` is itself blue, so lerping from another
/// hardcoded blue would wash the whole gradient into one indistinct hue. Lerping
/// from `colors.secondary` (the theme's own cool slate-blue) to `colors.primary`
/// (Professional Blue) keeps a real, theme-driven cool-to-accent ramp instead.
Color _colorFor(int bucket, double maxSpeed, ColorScheme colors) { Color _colorFor(int bucket, double maxSpeed, ColorScheme colors) {
if (bucket < 0 || maxSpeed <= 0) return colors.primary; if (bucket < 0 || maxSpeed <= 0) return colors.primary;
final t = ((bucket * speedBucketKmh) / maxSpeed).clamp(0.0, 1.0); final t = ((bucket * speedBucketKmh) / maxSpeed).clamp(0.0, 1.0);
return Color.lerp(const Color(0xFF4FC3F7), colors.primary, t)!; return Color.lerp(colors.secondary, colors.primary, t)!;
} }
} }
/// UI-09: CARTO's dark tiles are built from OpenStreetMap's underlying data, so both
/// need crediting, not just CARTO's own styling on top -- OSM-only attribution (what
/// this app carried while it used OSM's raw tiles directly) stopped being sufficient the
/// moment CARTO entered the mix. Shared by every `FlutterMap` in the app rather than
/// duplicated per screen, so there's exactly one place this obligation is discharged.
class TileAttribution extends StatelessWidget {
const TileAttribution({super.key});
@override
Widget build(BuildContext context) => const RichAttributionWidget(
alignment: AttributionAlignment.bottomLeft,
// No `onTap` -- opening the license pages would need `url_launcher`, a new
// dependency this ticket has no other reason to add. The obligation is to credit
// both sources visibly, not to make the credit tappable.
attributions: [
TextSourceAttribution('OpenStreetMap contributors'),
TextSourceAttribution('CARTO'),
],
);
}

View File

@@ -0,0 +1,96 @@
/// UI-02: what the map shows in place of tiles when neither the offline cache nor the
/// network can produce them -- a placeholder that clearly reads as "still loading", not
/// blank space, not a grid of broken-image icons, and not an error.
///
/// The 40px faint grid is the same "technical grid overlay" treatment already present
/// in the Stitch exports (`.map-grid-overlay`, `rgba(255,255,255,0.03-0.05)` lines every
/// 40px) -- reused rather than invented, so the skeleton still looks like it belongs to
/// this app's map even with no tiles under it.
library;
import 'package:flutter/material.dart';
class SkeletonMapLayer extends StatefulWidget {
const SkeletonMapLayer({super.key});
@override
State<SkeletonMapLayer> createState() => _SkeletonMapLayerState();
}
class _SkeletonMapLayerState extends State<SkeletonMapLayer>
with SingleTickerProviderStateMixin {
late final _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1800),
)..repeat();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final ground = Theme.of(context).scaffoldBackgroundColor;
return ColoredBox(
color: ground,
child: SizedBox.expand(
child: CustomPaint(
painter: _GridPainter(),
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) => ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (bounds) {
// The sweep runs from just off the left edge to just off the right,
// parameterised on the animation value -- a `LinearGradient` whose
// stops slide across the box rather than a physically translated
// widget, so it never has to know the box's actual pixel size itself.
final t = _controller.value;
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: const [
Colors.transparent,
Colors.white24,
Colors.transparent,
],
stops: [
(t - 0.3).clamp(0.0, 1.0),
t.clamp(0.0, 1.0),
(t + 0.3).clamp(0.0, 1.0),
],
).createShader(bounds);
},
child: const SizedBox.expand(),
),
),
),
),
);
}
}
class _GridPainter extends CustomPainter {
const _GridPainter();
static const double _spacing = 40;
static const Color _lineColor = Color(0x0DFFFFFF); // white @ ~5% opacity
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = _lineColor
..strokeWidth = 1;
for (var x = 0.0; x <= size.width; x += _spacing) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
}
for (var y = 0.0; y <= size.height; y += _spacing) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
}
@override
bool shouldRepaint(_GridPainter oldDelegate) => false;
}

View File

@@ -77,9 +77,10 @@ class StatRow extends StatelessWidget {
final String value; final String value;
/// V3-16: true for a figure you compare the live reading against (a max, an average) /// V3-16: true for a figure you compare the live reading against (a max, an average)
/// rather than the live reading itself. Colours the value with the instrument-blue /// rather than the live reading itself. Colours the value with the theme's tertiary
/// tertiary instead of the default ink, so the same colour means the same kind of /// (UI-08: a warm orange, chosen specifically to read as distinct from the new
/// number everywhere it appears. See the ticket's Direction section. /// Professional Blue primary) instead of the default ink, so the same colour means
/// the same kind of number everywhere it appears. See the ticket's Direction section.
final bool reference; final bool reference;
@override @override

View File

@@ -331,6 +331,7 @@ class _Body extends StatelessWidget {
points: detail.points, points: detail.points,
segments: detail.segments, segments: detail.segments,
tileProvider: ref.watch(cachedTileProviderProvider), tileProvider: ref.watch(cachedTileProviderProvider),
skeletonMode: ref.watch(mapConnectivityProvider).skeletonMode,
), ),
), ),
if (detail.mapEnabled) const SizedBox(height: 24), if (detail.mapEnabled) const SizedBox(height: 24),

View File

@@ -1,4 +1,9 @@
/// Ported from `com.rippr.ui.record.RecordScreen` and `RecordViewModel`. /// Ported from `com.rippr.ui.record.RecordScreen` and `RecordViewModel`.
///
/// UI-05: this is the Map HUD -- the design north star of the whole redesign. No
/// embedded map card, no stats `Card`, no header: the map is the shared full-opacity
/// background this tab already provides (UI-01), and every other visual floats on top
/// of it as a `GlassPanel`/HUD widget.
library; library;
import 'dart:async'; import 'dart:async';
@@ -8,10 +13,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/providers.dart'; import '../../app/providers.dart';
import '../../domain/models.dart'; import '../../domain/models.dart';
import '../../hud/hud_metric.dart';
import '../../recording/location_source.dart'; import '../../recording/location_source.dart';
import '../../recording/wakelock_controller.dart'; import '../../recording/wakelock_controller.dart';
import '../../telemetry/telemetry.dart'; import '../../telemetry/telemetry.dart';
import '../components/ride_map.dart'; import '../components/glass_panel.dart';
import '../components/hud_edit_overlay.dart';
import '../components/stats.dart'; import '../components/stats.dart';
import '../format.dart'; import '../format.dart';
import '../theme.dart'; import '../theme.dart';
@@ -35,19 +42,18 @@ class RecordUiState {
bool get isIdle => trip == null; bool get isIdle => trip == null;
bool get isRecording => trip?.state == TripState.recording; bool get isRecording => trip?.state == TripState.recording;
bool get isPaused => trip?.state == TripState.paused; bool get isPaused => trip?.state == TripState.paused;
/// Distance-over-moving-time -- there is no stored average, only the pieces to
/// derive one. Zero rather than a divide-by-zero NaN before the ride has moved at all.
double get avgSpeedKmh {
final t = trip;
if (t == null || t.movingMillis <= 0) return 0;
return (t.distanceM / 1000) / (t.movingMillis / 3600000);
}
} }
class RecordScreen extends ConsumerStatefulWidget { class RecordScreen extends ConsumerStatefulWidget {
const RecordScreen({ const RecordScreen({super.key});
super.key,
this.onOpenTrips,
this.onOpenSettings,
this.onOpenRoutes,
});
final VoidCallback? onOpenTrips;
final VoidCallback? onOpenSettings;
final VoidCallback? onOpenRoutes;
@override @override
ConsumerState<RecordScreen> createState() => _RecordScreenState(); ConsumerState<RecordScreen> createState() => _RecordScreenState();
@@ -150,6 +156,12 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
} }
} }
/// 72dp normally, 96dp mounted -- one constant height regardless of how many
/// segments the control bar has (one/two/three), so the HUD area above it is always
/// the same size and a widget's saved fractional position never jumps between ride
/// states.
double _controlBarHeight(bool mountedMode) => mountedMode ? 96 : 72;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mountedMode = ref.watch(mountedModeProvider); final mountedMode = ref.watch(mountedModeProvider);
@@ -158,7 +170,6 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
// colour scheme straight off a locally-built theme rather than `Theme.of(context)`, // colour scheme straight off a locally-built theme rather than `Theme.of(context)`,
// which would still report the app-wide (dark) theme here. // which would still report the app-wide (dark) theme here.
final theme = mountedMode ? ripprMountedTheme() : Theme.of(context); final theme = mountedMode ? ripprMountedTheme() : Theme.of(context);
final colors = theme.colorScheme;
final engine = ref.watch(recordingEngineProvider); final engine = ref.watch(recordingEngineProvider);
final trip = ref.watch(activeTripProvider).valueOrNull; final trip = ref.watch(activeTripProvider).valueOrNull;
final units = ref.watch(unitSystemProvider); final units = ref.watch(unitSystemProvider);
@@ -173,76 +184,27 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
: (_now - trip.startedAt).clamp(0, 1 << 62), : (_now - trip.startedAt).clamp(0, 1 << 62),
); );
return Theme(data: theme, child: Scaffold( // UI-01: no header, no embedded map card -- the map is the shared background behind
// Scrollable, but still centred when there is room. // this whole tab (`AppShell`), full opacity here. A quick mounted-mode toggle used to
// // live in a header row; that row is gone, and the toggle's only home for now is
// With a ride active the card grows to six stat rows, which overflows a short // Settings' existing switch.
// screen. Compose clipped this silently; Flutter reports it, and a widget test return Theme(
// caught it immediately. Glove-sized 72dp controls make the content tall enough data: theme,
// that this is a real risk on small phones, not a theoretical one. child: Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea( body: SafeArea(
child: LayoutBuilder( child: Stack(
builder: (context, constraints) => SingleChildScrollView( children: [
child: ConstrainedBox( if (ui.isIdle)
constraints: BoxConstraints(minHeight: constraints.maxHeight), Center(
child: Padding( child: GlassPanel(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( // Current speed, not max: a max figure only moves when you beat
mainAxisAlignment: MainAxisAlignment.spaceBetween, // it, which reads as a frozen screen while riding steadily.
children: [ // This is the 2.0.1 fix and it must not regress.
Text(
'RIPPR',
style: TextStyle(
fontSize: 34,
fontWeight: FontWeight.w900,
letterSpacing: 6,
color: colors.primary,
),
),
Row(
children: [
TextButton(
key: const Key('open-routes'),
onPressed: widget.onOpenRoutes,
child: const Text('Routes'),
),
TextButton(
onPressed: widget.onOpenTrips,
child: const Text('Rides'),
),
IconButton(
key: const Key('mounted-mode-toggle'),
icon: Icon(mountedMode
? Icons.motorcycle
: Icons.motorcycle_outlined),
tooltip: mountedMode
? 'Mounted mode on'
: 'Mounted mode off',
color: mountedMode ? colors.primary : null,
onPressed: () async {
final next = !mountedMode;
await ref.read(configProvider)?.setMountedMode(next);
ref.read(mountedModeProvider.notifier).state = next;
},
),
IconButton(
key: const Key('open-settings'),
icon: const Icon(Icons.settings_outlined),
tooltip: 'Settings',
onPressed: widget.onOpenSettings,
),
],
),
],
),
const SizedBox(height: 20),
// Current speed, not max: a max figure only moves when you beat it, which
// reads as a frozen screen while riding steadily. This is the 2.0.1 fix and
// it must not regress.
BigStat( BigStat(
key: const Key('speed'), key: const Key('speed'),
label: 'SPEED', label: 'SPEED',
@@ -251,80 +213,52 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
scale: mountedMode ? mountedTextScale : 1.0, scale: mountedMode ? mountedTextScale : 1.0,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// A resting state, not a zeroed ride -- otherwise the screen
// V3-04: RecordingEngine has no idea this exists -- it is fed // looks like a recording that is going nowhere.
// entirely from a Drift stream, the same seam the trip-detail map
// already uses. Off by default (mapEnabledProvider) until battery
// impact is measured (V3-13), and only while a ride is actually
// active -- an idle screen has nothing to draw and no reason to
// hold a tile layer alive.
if (ui.trip != null && ref.watch(mapEnabledProvider)) ...[
_LiveMap(tripId: ui.trip!.id),
const SizedBox(height: 16),
],
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
if (ui.isIdle) ...[
// A resting state, not a zeroed ride — otherwise the screen looks
// like a recording that is going nowhere.
const StatRow(label: 'Status', value: 'Ready'), const StatRow(label: 'Status', value: 'Ready'),
const StatRow( const StatRow(label: 'Last ride', value: 'See Rides'),
label: 'Last ride', ],
value: 'See Rides',
),
] else ...[
StatRow(
label: 'Distance',
value: formatDistance(
ui.trip!.distanceM,
unit: units,
), ),
), ),
StatRow( )
label: 'Elapsed', else
value: formatDuration(ui.elapsedMillis), Positioned.fill(
), bottom: _controlBarHeight(mountedMode),
StatRow( child: HudEditOverlay(
label: 'Moving time', metricBuilder: (context, metric) =>
value: formatDuration(ui.trip!.movingMillis), _HudMetricValue(metric: metric, ui: ui, units: units),
),
StatRow(
label: 'Max speed',
value: formatSpeed(
ui.trip!.maxSpeedKmh,
unit: units,
),
reference: true,
),
StatRow(
label: 'Points captured',
value: '${ui.trip!.pointCount}',
),
StatRow(
label: 'Elevation gain',
value: formatElevation(
ui.trip!.elevationGainM,
unit: units,
), ),
), ),
if (ui.isPaused) if (ui.isPaused)
const StatRow(label: 'Status', value: 'Paused'), Positioned(
], top: 8,
left: 0,
right: 0,
child: Center(
child: GlassPanel(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
borderRadius: const BorderRadius.all(Radius.circular(999)),
child: const Text('Paused', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
),
if (ui.uploadError != null) if (ui.uploadError != null)
StatRow(label: 'Problem', value: ui.uploadError!), Positioned(
], top: 8,
left: 16,
right: 16,
child: GlassPanel(
padding: const EdgeInsets.all(12),
child: Text(ui.uploadError!),
), ),
), ),
), Positioned(
left: 0,
const SizedBox(height: 28), right: 0,
_Controls( bottom: 0,
height: _controlBarHeight(mountedMode),
child: _ControlBar(
ui: ui, ui: ui,
mounted: mountedMode,
onStart: () => _guard(engine.start), onStart: () => _guard(engine.start),
onPause: () => _guard(engine.pause), onPause: () => _guard(engine.pause),
onStop: () => _guard(() async => engine.stop()), onStop: () => _guard(() async => engine.stop()),
@@ -341,48 +275,89 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
if (confirmed) await _guard(engine.discard); if (confirmed) await _guard(engine.discard);
}, },
), ),
),
], ],
), ),
), ),
), ),
),
),
),
));
}
}
/// Thin adapter from the two live Drift streams to [RideMap]. Kept out of
/// [_RecordScreenState] so a provider read/write cycle here can't be confused with the
/// engine's own state -- this widget only ever reads.
class _LiveMap extends ConsumerWidget {
const _LiveMap({required this.tripId});
final int tripId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final points = ref.watch(livePointsProvider(tripId)).valueOrNull ?? const [];
final segments = ref.watch(liveSegmentsProvider(tripId)).valueOrNull ?? const [];
return RideMap(
key: const Key('live-map'),
points: points,
segments: segments,
height: 220,
follow: true,
tileProvider: ref.watch(cachedTileProviderProvider),
); );
} }
} }
class _Controls extends StatelessWidget { /// The content of one HUD telemetry widget -- purely presentational, sourced from the
const _Controls({ /// same `RecordUiState`/units that powered the old fixed stats card. No new data
/// plumbing, only new presentation, per the ticket's own Implementation section.
class _HudMetricValue extends StatelessWidget {
const _HudMetricValue({required this.metric, required this.ui, required this.units});
final HudMetric metric;
final RecordUiState ui;
final UnitSystem units;
/// Matches the Map HUD mockup's own colour choices for its default four metrics
/// exactly (Speed: primary, Avg Speed/Time: plain ink, Dist: secondary) rather than
/// this app's usual reference-vs-live convention -- this HUD is a new visual context
/// the mockup already specifies directly, colour by colour.
Color _valueColor(ColorScheme colors) => switch (metric) {
HudMetric.speed => colors.primary,
HudMetric.distance => colors.secondary,
_ => colors.onSurface,
};
String get _value {
final trip = ui.trip;
return switch (metric) {
HudMetric.speed => formatSpeedParts(ui.currentSpeedKmh, unit: units).$1,
HudMetric.avgSpeed => formatSpeedParts(ui.avgSpeedKmh, unit: units).$1,
HudMetric.distance => formatDistance(trip?.distanceM ?? 0, unit: units),
HudMetric.elapsedTime => formatDuration(ui.elapsedMillis),
HudMetric.maxSpeed => formatSpeed(trip?.maxSpeedKmh ?? 0, unit: units),
HudMetric.movingTime => formatDuration(trip?.movingMillis ?? 0),
HudMetric.elevationGain => formatElevation(trip?.elevationGainM ?? 0, unit: units),
HudMetric.pointsCaptured => '${trip?.pointCount ?? 0}',
};
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
metric.label.toUpperCase(),
style: TextStyle(fontSize: 10, letterSpacing: 1, color: colors.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
_value,
style: monoDigits.copyWith(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _valueColor(colors),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
}
/// The full-bleed, icon-only control bar directly above the bottom nav bar -- matches
/// the Map HUD mockup's Pause/Stop segment exactly for the recording state; the idle
/// and paused states aren't shown in that mockup, so they extend the same visual
/// language (full-bleed, icon-only, colour communicates the action) rather than
/// reverting to the old text-labelled buttons.
class _ControlBar extends StatelessWidget {
const _ControlBar({
required this.ui, required this.ui,
required this.onStart, required this.onStart,
required this.onPause, required this.onPause,
required this.onStop, required this.onStop,
required this.onDiscard, required this.onDiscard,
this.mounted = false,
}); });
final RecordUiState ui; final RecordUiState ui;
@@ -391,133 +366,143 @@ class _Controls extends StatelessWidget {
final VoidCallback onStop; final VoidCallback onStop;
final VoidCallback onDiscard; final VoidCallback onDiscard;
/// V3-05: 72dp works stopped; at speed, with gloves, it does not.
final bool mounted;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (ui.isIdle) { if (ui.isIdle) {
return _PrimaryButton( return _Segment(
label: 'START RECORDING',
onPressed: onStart,
keyName: 'start', keyName: 'start',
mounted: mounted, icon: Icons.play_arrow,
label: 'START RECORDING',
background: Theme.of(context).colorScheme.tertiaryContainer,
foreground: Theme.of(context).colorScheme.onTertiaryContainer,
onTap: onStart,
); );
} }
if (ui.isRecording) { if (ui.isRecording) {
return Column( final colors = Theme.of(context).colorScheme;
return Row(
children: [ children: [
_PrimaryButton( Expanded(
label: 'PAUSE', onPressed: onPause, keyName: 'pause', mounted: mounted), child: _Segment(
const SizedBox(height: 12), keyName: 'pause',
_SecondaryButton( icon: Icons.pause,
label: 'STOP', onPressed: onStop, keyName: 'stop', mounted: mounted), background: colors.tertiaryContainer,
foreground: colors.onTertiaryContainer,
onTap: onPause,
trailingBorder: true,
),
),
Expanded(
child: _Segment(
keyName: 'stop',
icon: Icons.stop,
background: colors.errorContainer,
foreground: colors.onErrorContainer,
onTap: onStop,
),
),
], ],
); );
} }
// Discard is offered only while paused. Putting a destructive action next to Pause // Discard is offered only while paused. Putting a destructive action next to Pause
// during a live ride invites a gloved mis-tap at speed. // during a live ride invites a gloved mis-tap at speed.
return Column(
children: [
_PrimaryButton(
label: 'RESUME', onPressed: onStart, keyName: 'resume', mounted: mounted),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _SecondaryButton(
label: 'STOP',
onPressed: onStop,
keyName: 'stop',
mounted: mounted,
),
),
const SizedBox(width: 12),
Expanded(
child: _SecondaryButton(
label: 'DISCARD',
onPressed: onDiscard,
keyName: 'discard',
destructive: true,
mounted: mounted,
),
),
],
),
],
);
}
}
/// 72dp tall throughout — these get pressed with gloves on. Mounted mode goes bigger
/// still (V3-05): stopped, 72dp is enough; at speed, with gloves, it is not.
class _PrimaryButton extends StatelessWidget {
const _PrimaryButton({
required this.label,
required this.onPressed,
required this.keyName,
this.mounted = false,
});
final String label;
final VoidCallback onPressed;
final String keyName;
final bool mounted;
@override
Widget build(BuildContext context) => SizedBox(
width: double.infinity,
height: mounted ? 96 : 72,
child: FilledButton(
key: Key(keyName),
onPressed: onPressed,
child: Text(
label,
style: TextStyle(
fontSize: mounted ? 26 : 20,
fontWeight: FontWeight.bold,
),
),
),
);
}
class _SecondaryButton extends StatelessWidget {
const _SecondaryButton({
required this.label,
required this.onPressed,
required this.keyName,
this.destructive = false,
this.mounted = false,
});
final String label;
final VoidCallback onPressed;
final String keyName;
final bool destructive;
final bool mounted;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
return SizedBox( return Row(
height: mounted ? 76 : 56, children: [
child: OutlinedButton( Expanded(
key: Key(keyName), flex: 2,
onPressed: onPressed, child: _Segment(
style: OutlinedButton.styleFrom( keyName: 'resume',
foregroundColor: destructive ? colors.error : colors.onSurface, icon: Icons.play_arrow,
background: colors.tertiaryContainer,
foreground: colors.onTertiaryContainer,
onTap: onStart,
trailingBorder: true,
), ),
child: Text( ),
label, Expanded(
child: _Segment(
keyName: 'stop',
icon: Icons.stop,
background: colors.errorContainer,
foreground: colors.onErrorContainer,
onTap: onStop,
trailingBorder: true,
),
),
Expanded(
child: _Segment(
keyName: 'discard',
icon: Icons.delete_outline,
background: colors.surfaceContainerHighest,
foreground: colors.error,
onTap: onDiscard,
),
),
],
);
}
}
class _Segment extends StatelessWidget {
const _Segment({
required this.keyName,
required this.icon,
required this.background,
required this.foreground,
required this.onTap,
this.label,
this.trailingBorder = false,
});
final String keyName;
final IconData icon;
final Color background;
final Color foreground;
final VoidCallback onTap;
final String? label;
final bool trailingBorder;
@override
Widget build(BuildContext context) => Material(
color: background,
// The canonical `Material` + `Ink` pairing: `Ink` paints the border directly onto
// the `Material`'s own layer, which is what lets the `InkWell`'s splash paint
// above it -- a separate `Container` ancestor wrapping `Material` would put the
// border outside the ink layer entirely.
child: Ink(
decoration: BoxDecoration(
border: trailingBorder
? Border(right: BorderSide(color: Colors.white.withValues(alpha: 0.1)))
: null,
),
child: InkWell(
key: Key(keyName),
onTap: onTap,
child: SizedBox.expand(
child: Center(
child: label == null
? Icon(icon, color: foreground, size: 32)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: foreground),
const SizedBox(width: 8),
Text(
label!,
style: TextStyle( style: TextStyle(
fontSize: mounted ? 20 : 16, color: foreground,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
),
),
), ),
), ),
), ),
); );
}
} }

View File

@@ -1,13 +1,13 @@
/// Ported from `com.rippr.ui.RipprNavHost`. /// UI-01: a persistent 4-tab shell (`StatefulShellRoute`), replacing the push/pop stack
/// /// rooted at Record that this app used through v3. Each branch keeps its own navigator,
/// Three destinations, same shape as the Compose original. `go_router` replaces /// so pushing Trip Detail from Rides -- or the route planner from Plan -- doesn't
/// `navigation-compose`; the path parameter is parsed explicitly, mirroring the Kotlin /// disturb the other tabs' state or the active tab index.
/// comment about ids arriving as the wrong type when the argument type is left implicit.
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'app_shell.dart';
import 'detail/trip_detail_screen.dart'; import 'detail/trip_detail_screen.dart';
import 'record/record_screen.dart'; import 'record/record_screen.dart';
import 'routes/route_planner_screen.dart'; import 'routes/route_planner_screen.dart';
@@ -17,65 +17,81 @@ import 'trips/trips_screen.dart';
abstract final class Routes { abstract final class Routes {
static const record = '/'; static const record = '/';
static const trips = '/trips'; static const trips = '/rides';
static const tripDetail = '/trip/:tripId'; static const tripDetail = '/rides/:tripId';
static const settings = '/settings'; static const settings = '/settings';
static const routes = '/routes'; static const routes = '/plan';
static const routePlanner = '/routes/:routeId'; static const routePlanner = '/plan/:routeId';
static String detailFor(int tripId) => '/trip/$tripId'; static String detailFor(int tripId) => '/rides/$tripId';
static String plannerFor(int routeId) => '/routes/$routeId'; static String plannerFor(int routeId) => '/plan/$routeId';
} }
GoRouter buildRouter() => GoRouter( GoRouter buildRouter() => GoRouter(
initialLocation: Routes.record, initialLocation: Routes.record,
routes: [
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) =>
AppShell(navigationShell: navigationShell),
branches: [
// Branch 0: Map. The Record screen, headerless -- see AppShell's doc comment.
StatefulShellBranch(
routes: [
GoRoute(path: Routes.record, builder: (context, state) => const RecordScreen()),
],
),
// Branch 1: Rides. Trip Detail pushes within this branch's own navigator.
StatefulShellBranch(
routes: [ routes: [
GoRoute( GoRoute(
path: Routes.record, path: Routes.trips,
builder: (context, state) => RecordScreen( builder: (context, state) => TripsScreen(
onOpenTrips: () => context.push(Routes.trips), onOpenTrip: (id) => context.push(Routes.detailFor(id)),
onOpenSettings: () => context.push(Routes.settings),
onOpenRoutes: () => context.push(Routes.routes),
), ),
routes: [
GoRoute(
path: ':tripId',
builder: (context, state) {
final id = int.tryParse(state.pathParameters['tripId'] ?? '');
if (id == null) return const _NotFound();
return TripDetailScreen(tripId: id, onBack: () => context.pop());
},
), ),
],
),
],
),
// Branch 2: Plan. The route planner pushes within this branch's own navigator.
StatefulShellBranch(
routes: [
GoRoute( GoRoute(
path: Routes.routes, path: Routes.routes,
builder: (context, state) => RoutesListScreen( builder: (context, state) => RoutesListScreen(
onOpenRoute: (id) => context.push(Routes.plannerFor(id)), onOpenRoute: (id) => context.push(Routes.plannerFor(id)),
onBack: () => context.pop(),
),
), ),
routes: [
GoRoute( GoRoute(
path: Routes.routePlanner, path: ':routeId',
builder: (context, state) { builder: (context, state) {
final id = int.tryParse(state.pathParameters['routeId'] ?? ''); final id = int.tryParse(state.pathParameters['routeId'] ?? '');
if (id == null) return const _NotFound(); if (id == null) return const _NotFound();
return RoutePlannerScreen(routeId: id, onBack: () => context.pop()); return RoutePlannerScreen(routeId: id, onBack: () => context.pop());
}, },
), ),
],
),
],
),
// Branch 3: Settings.
StatefulShellBranch(
routes: [
GoRoute( GoRoute(
path: Routes.settings, path: Routes.settings,
builder: (context, state) => builder: (context, state) => const SettingsScreen(),
SettingsScreen(onBack: () => context.pop()),
), ),
GoRoute( ],
path: Routes.trips,
builder: (context, state) => TripsScreen(
onOpenTrip: (id) => context.push(Routes.detailFor(id)),
onBack: () => context.pop(),
), ),
), ],
GoRoute(
path: Routes.tripDetail,
builder: (context, state) {
// Parsed explicitly. A malformed or missing id must land on a real screen
// saying so, never on a blank one or a crash.
final id = int.tryParse(state.pathParameters['tripId'] ?? '');
if (id == null) {
return const _NotFound();
}
return TripDetailScreen(tripId: id, onBack: () => context.pop());
},
), ),
], ],
errorBuilder: (context, state) => const _NotFound(), errorBuilder: (context, state) => const _NotFound(),
@@ -86,13 +102,11 @@ class _NotFound extends StatelessWidget {
@override @override
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) => Scaffold(
appBar: AppBar( body: Center(
leading: IconButton( child: TextButton(
icon: const Icon(Icons.arrow_back), onPressed: () => context.canPop() ? context.pop() : context.go(Routes.record),
onPressed: () => child: const Text('That ride could not be found. Back'),
context.canPop() ? context.pop() : context.go(Routes.record),
), ),
), ),
body: const Center(child: Text('That ride could not be found.')),
); );
} }

View File

@@ -2,6 +2,10 @@
/// ///
/// Straight lines only, deliberately — see the ticket. `geometry` stays null; the /// Straight lines only, deliberately — see the ticket. `geometry` stays null; the
/// polyline drawn here is derived from waypoints on every build, never persisted. /// polyline drawn here is derived from waypoints on every build, never persisted.
///
/// UI-06: rebuilt as a fullscreen map canvas with floating controls, restyled toward
/// Map HUD (UI-05) rather than copied from this screen's own Stitch export -- see
/// `docs/ui-redesign/README.md`'s note on why the Route Planning mockup drifted.
library; library;
import 'dart:async'; import 'dart:async';
@@ -9,6 +13,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart' as ll; import 'package:latlong2/latlong.dart' as ll;
@@ -16,12 +21,24 @@ import '../../app/providers.dart';
import '../../data/route_plan_repository.dart'; import '../../data/route_plan_repository.dart';
import '../../domain/models.dart'; import '../../domain/models.dart';
import '../../geo/geo.dart' as geo; import '../../geo/geo.dart' as geo;
import '../../telemetry/telemetry.dart' show formatDuration;
import '../../tiles/tile_cache.dart'; import '../../tiles/tile_cache.dart';
import '../../tiles/tile_downloader.dart'; import '../../tiles/tile_downloader.dart';
import '../../tiles/tile_math.dart'; import '../../tiles/tile_math.dart';
import '../components/ride_map.dart' show maxTileZoom, tileUserAgent; import '../components/floating_pill.dart';
import '../components/glass_panel.dart';
import '../components/ride_map.dart'
show
TileAttribution,
maxTileZoom,
tileMaxNativeZoom,
tileSubdomains,
tileUrlTemplate,
tileUserAgent;
import '../components/skeleton_map_layer.dart';
import '../components/stats.dart' show confirmDialog; import '../components/stats.dart' show confirmDialog;
import '../format.dart'; import '../format.dart';
import '../router.dart';
class RoutePlannerScreen extends ConsumerStatefulWidget { class RoutePlannerScreen extends ConsumerStatefulWidget {
const RoutePlannerScreen({super.key, required this.routeId, this.onBack}); const RoutePlannerScreen({super.key, required this.routeId, this.onBack});
@@ -33,15 +50,45 @@ class RoutePlannerScreen extends ConsumerStatefulWidget {
ConsumerState<RoutePlannerScreen> createState() => _RoutePlannerScreenState(); ConsumerState<RoutePlannerScreen> createState() => _RoutePlannerScreenState();
} }
class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> { class _Ripple {
_Ripple(this.offset, this.controller);
final Offset offset;
final AnimationController controller;
}
class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen>
with TickerProviderStateMixin {
final _mapController = MapController(); final _mapController = MapController();
final _ripples = <_Ripple>[];
@override @override
void dispose() { void dispose() {
_mapController.dispose(); _mapController.dispose();
for (final ripple in _ripples) {
ripple.controller.dispose();
}
super.dispose(); super.dispose();
} }
/// UI-06: a short-lived expanding-and-fading ring at the tap point, matching the
/// mockup's pin-drop micro-interaction. Placed once, at the moment of the tap, using
/// screen-local coordinates -- it lives for 500ms, so not tracking map pan/zoom
/// during that brief window is an acceptable simplification, not a real defect.
void _addRipple(Offset localPosition) {
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
late final _Ripple ripple;
ripple = _Ripple(localPosition, controller);
setState(() => _ripples.add(ripple));
controller.forward().whenComplete(() {
if (!mounted) return;
setState(() => _ripples.remove(ripple));
controller.dispose();
});
}
/// Fits every pin in view, the same way `RideMap` fits a recorded path -- otherwise a /// Fits every pin in view, the same way `RideMap` fits a recorded path -- otherwise a
/// fixed zoom guess either strands distant pins off-screen or, for two close-together /// fixed zoom guess either strands distant pins off-screen or, for two close-together
/// pins, can cull one before it is ever seen. Null below two points: a single pin (or /// pins, can cull one before it is ever seen. Null below two points: a single pin (or
@@ -73,6 +120,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
if (route == null) { if (route == null) {
return Scaffold( return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea( body: SafeArea(
child: Center( child: Center(
child: Text( child: Text(
@@ -85,51 +133,21 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
} }
return Scaffold( return Scaffold(
appBar: AppBar( backgroundColor: Colors.transparent,
leading: IconButton( body: SafeArea(
key: const Key('back'),
icon: const Icon(Icons.arrow_back),
onPressed: widget.onBack,
),
title: Text(route.name),
actions: [
IconButton(
key: const Key('download-tiles'),
icon: const Icon(Icons.download_for_offline_outlined),
tooltip: waypoints.isEmpty
? 'Add pins first'
: 'Download offline tiles along this route',
onPressed: waypoints.isEmpty
? null
: () => _downloadOfflineTiles(context, waypoints),
),
IconButton(
key: const Key('rename-route'),
icon: const Icon(Icons.edit_outlined),
onPressed: () => _rename(context, repo, route),
),
IconButton(
key: const Key('delete-route'),
icon: const Icon(Icons.delete_outline),
onPressed: () async {
await repo.deleteRoutePlan(widget.routeId);
widget.onBack?.call();
},
),
],
),
body: Column(
children: [
Expanded(
// `MapOptions.initialCenter`/`initialZoom` are read exactly once, at
// FlutterMap's construction -- not on every rebuild. Building the map before
// the waypoints stream has delivered its first value would freeze the camera
// on null-island forever, even once real waypoints arrive. Wait for the first
// emission (typically one frame) so the initial camera is right from the
// start.
child: !waypointsAsync.hasValue child: !waypointsAsync.hasValue
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: FlutterMap( : Stack(
children: [
Positioned.fill(
// `MapOptions.initialCenter`/`initialZoom` are read exactly once, at
// FlutterMap's construction -- not on every rebuild. Building the map
// before the waypoints stream has delivered its first value would
// freeze the camera on null-island forever, even once real waypoints
// arrive; the `!waypointsAsync.hasValue` branch above waits for that
// first emission (typically one frame) so the initial camera is
// right from the start.
child: FlutterMap(
mapController: _mapController, mapController: _mapController,
options: MapOptions( options: MapOptions(
initialCameraFit: _initialFit(waypoints), initialCameraFit: _initialFit(waypoints),
@@ -138,14 +156,27 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
: ll.LatLng(waypoints.first.latitude, waypoints.first.longitude), : ll.LatLng(waypoints.first.latitude, waypoints.first.longitude),
initialZoom: waypoints.length <= 1 ? 14 : maxTileZoom - 3, initialZoom: waypoints.length <= 1 ? 14 : maxTileZoom - 3,
maxZoom: maxTileZoom, maxZoom: maxTileZoom,
onTap: (tapPosition, point) => onTap: (tapPosition, point) {
repo.addWaypoint(widget.routeId, point.latitude, point.longitude), repo.addWaypoint(widget.routeId, point.latitude, point.longitude);
if (tapPosition.relative != null) {
_addRipple(tapPosition.relative!);
}
},
), ),
children: [ children: [
// UI-02: same skeleton-or-tiles swap as RideMap's background
// usage -- this screen manages its own FlutterMap directly
// rather than through RideMap, so it has to watch connectivity
// and swap the layer itself.
if (ref.watch(mapConnectivityProvider).skeletonMode)
const SkeletonMapLayer()
else
TileLayer( TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', urlTemplate: tileUrlTemplate,
subdomains: tileSubdomains,
retinaMode: true,
userAgentPackageName: tileUserAgent, userAgentPackageName: tileUserAgent,
maxNativeZoom: maxTileZoom.toInt(), maxNativeZoom: tileMaxNativeZoom,
panBuffer: 0, panBuffer: 0,
tileProvider: ref.watch(cachedTileProviderProvider), tileProvider: ref.watch(cachedTileProviderProvider),
), ),
@@ -157,9 +188,15 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
for (final w in waypoints) ll.LatLng(w.latitude, w.longitude), for (final w in waypoints) ll.LatLng(w.latitude, w.longitude),
], ],
strokeWidth: 4, strokeWidth: 4,
// Visibly distinct from a recorded path (see the acceptance // Visibly distinct from a recorded path (see the
// criteria) — dashed, and the planning accent rather than the // acceptance criteria) — dashed, and the planning
// speed-bucketed colours RideMap uses. // accent rather than the speed-bucketed colours
// RideMap uses. (UI-06: the mockup's own animated
// "marching ants" crawl was left as static dashes --
// it isn't in this ticket's acceptance criteria, and
// reprojecting a hand-animated dash phase under
// map pan/zoom is real added complexity for a purely
// decorative effect.)
pattern: StrokePattern.dashed(segments: const [8, 6]), pattern: StrokePattern.dashed(segments: const [8, 6]),
color: colors.primary, color: colors.primary,
), ),
@@ -197,31 +234,118 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
), ),
], ],
), ),
const TileAttribution(),
], ],
), ),
), ),
Padding( // Pin-drop ripples paint in the same local coordinate space as the
padding: const EdgeInsets.all(16), // map's `onTap` gave us, directly over the map -- not inside
child: Row( // FlutterMap's own `children` (a plain widget there doesn't get
mainAxisAlignment: MainAxisAlignment.center, // reprojected the way layer widgets do, which is fine for something
children: [ // this short-lived, but it does need to sit in a `Stack` positioned
Icon(Icons.straighten, size: 18, color: colors.outline), // identically to the map itself).
const SizedBox(width: 8), for (final ripple in _ripples)
Text( Positioned(
key: const Key('route-distance'), left: ripple.offset.dx - 30,
formatDistance(route.distanceM, unit: units), top: ripple.offset.dy - 30,
style: TextStyle(fontWeight: FontWeight.bold, color: colors.onSurface), child: IgnorePointer(
child: AnimatedBuilder(
animation: ripple.controller,
builder: (context, child) {
final t = Curves.easeOut.transform(ripple.controller.value);
return Opacity(
opacity: 1 - t,
child: Container(
width: 60 * t,
height: 60 * t,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colors.primary.withValues(alpha: 0.2),
border: Border.all(color: colors.primary, width: 2),
), ),
Text(
' · ${waypoints.length} pin${waypoints.length == 1 ? '' : 's'}',
style: TextStyle(color: colors.outline),
),
],
),
),
],
), ),
); );
},
),
),
),
if (waypoints.isEmpty)
const Positioned(top: 16, left: 0, right: 0, child: _DropPinTooltip()),
if (waypoints.isNotEmpty)
Positioned(
top: 16,
left: 0,
right: 0,
child: Center(
child: FloatingPill(
stats: [
PillStat(
label: 'Distance',
value: formatDistance(route.distanceM, unit: units),
),
PillStat(
label: 'Est. Time',
value: route.estimatedMillis == null
? '--'
: formatDuration(route.estimatedMillis!),
),
PillStat(label: 'Pins', value: '${waypoints.length}'),
],
),
),
),
Positioned(
top: 8,
right: 8,
child: _OverflowMenu(
hasWaypoints: waypoints.isNotEmpty,
onRename: () => _rename(context, repo, route),
onDownload: waypoints.isEmpty
? null
: () => _downloadOfflineTiles(context, waypoints),
onDelete: () async {
await repo.deleteRoutePlan(widget.routeId);
widget.onBack?.call();
},
),
),
if (waypoints.isNotEmpty)
Positioned(
bottom: 16,
left: 0,
right: 0,
child: Center(
child: FilledButton(
key: const Key('start-route'),
style: FilledButton.styleFrom(
backgroundColor: colors.primaryContainer,
foregroundColor: colors.onPrimaryContainer,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: const StadiumBorder(),
),
onPressed: () => _startRoute(context),
child: const Text(
'START ROUTE',
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1),
),
),
),
),
],
),
),
);
}
/// UI-06: turn-by-turn following of a planned route doesn't exist yet (V3-09,
/// explicitly deferred by this ticket) -- there is no "route currently being
/// followed" concept anywhere in the app to wire this button to. Rather than ship a
/// dead button or invent that data model here, this starts an ordinary recording and
/// switches to the Map tab, which is a real, working action a rider can use today
/// ("go start riding this route now"), not a placeholder.
Future<void> _startRoute(BuildContext context) async {
await ref.read(recordingEngineProvider).start();
if (context.mounted) context.go(Routes.record);
} }
Future<void> _rename( Future<void> _rename(
@@ -254,7 +378,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
/// V3-11: pre-downloads a corridor along the route, not a rectangle around it -- "far /// V3-11: pre-downloads a corridor along the route, not a rectangle around it -- "far
/// fewer tiles for the same usefulness," as the ticket puts it. Zoom range is fixed /// fewer tiles for the same usefulness," as the ticket puts it. Zoom range is fixed
/// (city-street level through the map's own max) rather than picked by the rider -- /// (city-street level through the map's own max) rather than picked by the rider --
/// keeping the choice small is part of what keeps this within OSM's usage policy. /// keeping the choice small is part of what keeps this within the tile host's usage
/// policy.
static const _downloadMinZoom = 13; static const _downloadMinZoom = 13;
Future<void> _downloadOfflineTiles( Future<void> _downloadOfflineTiles(
@@ -310,6 +435,108 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
} }
} }
/// UI-06: "Tap to drop a pin" -- shown only while the route has no pins yet, matching
/// the Plan Screen mockup's own gentle vertical float, a hint that disappears once the
/// rider has actually acted rather than a permanent fixture cluttering real work.
class _DropPinTooltip extends StatefulWidget {
const _DropPinTooltip();
@override
State<_DropPinTooltip> createState() => _DropPinTooltipState();
}
class _DropPinTooltipState extends State<_DropPinTooltip>
with SingleTickerProviderStateMixin {
late final _controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
)..repeat(reverse: true);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Center(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) => Transform.translate(
offset: Offset(0, -6 * Curves.easeInOut.transform(_controller.value)),
child: child,
),
child: GlassPanel(
borderRadius: const BorderRadius.all(Radius.circular(999)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.location_on, color: colors.primary, size: 20),
const SizedBox(width: 8),
const Text(
'TAP TO DROP A PIN',
style: TextStyle(fontSize: 12, letterSpacing: 1, fontWeight: FontWeight.w600),
),
],
),
),
),
);
}
}
/// UI-06: rename/download/delete lost their `AppBar` actions row -- this is their new
/// home, styled per Map HUD's `GlassPanel` language rather than invented fresh.
class _OverflowMenu extends StatelessWidget {
const _OverflowMenu({
required this.hasWaypoints,
required this.onRename,
required this.onDownload,
required this.onDelete,
});
final bool hasWaypoints;
final VoidCallback onRename;
final VoidCallback? onDownload;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) => GlassPanel(
borderRadius: const BorderRadius.all(Radius.circular(999)),
child: PopupMenuButton<String>(
key: const Key('route-overflow-menu'),
icon: const Icon(Icons.more_vert),
onSelected: (value) => switch (value) {
'rename' => onRename(),
'download' => onDownload?.call(),
'delete' => onDelete(),
_ => null,
},
itemBuilder: (context) => [
const PopupMenuItem(
key: Key('rename-route'),
value: 'rename',
child: Text('Rename'),
),
PopupMenuItem(
key: const Key('download-tiles'),
value: 'download',
enabled: hasWaypoints,
child: const Text('Download offline tiles'),
),
const PopupMenuItem(
key: Key('delete-route'),
value: 'delete',
child: Text('Delete route'),
),
],
),
);
}
/// Owns the download's stream subscription for exactly the lifetime of the dialog -- /// Owns the download's stream subscription for exactly the lifetime of the dialog --
/// pulled out of a plain `showDialog` builder because that builder re-runs on every /// pulled out of a plain `showDialog` builder because that builder re-runs on every
/// `setState`, which would otherwise start a brand new overlapping download on every /// `setState`, which would otherwise start a brand new overlapping download on every
@@ -338,7 +565,18 @@ class _DownloadDialogState extends State<_DownloadDialog> {
cache: widget.cache, cache: widget.cache,
cancelToken: _token, cancelToken: _token,
fetchTile: (key) async { fetchTile: (key) async {
final url = 'https://tile.openstreetmap.org/${key.z}/${key.x}/${key.y}.png'; // UI-09 correction: this still pointed at OSM's raw tile host directly, from
// before the switch to CARTO's dark tiles -- downloading tan OSM tiles for
// offline use while the live map renders CARTO's dark style would have quietly
// cached tiles that never actually get served (the cache is keyed by the same
// (z, x, y) regardless of provider, and the CARTO-tagged cache directory --
// see UI-09 -- would just never contain what this was fetching).
final url = tileUrlTemplate
.replaceFirst('{s}', tileSubdomains.first)
.replaceFirst('{z}', '${key.z}')
.replaceFirst('{x}', '${key.x}')
.replaceFirst('{y}', '${key.y}')
.replaceFirst('{r}', '');
final response = await _client.get( final response = await _client.get(
Uri.parse(url), Uri.parse(url),
headers: {'User-Agent': tileUserAgent}, headers: {'User-Agent': tileUserAgent},

View File

@@ -6,14 +6,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/providers.dart'; import '../../app/providers.dart';
import '../../domain/models.dart'; import '../../domain/models.dart';
import '../components/glass_panel.dart';
import '../components/stats.dart'; import '../components/stats.dart';
import '../format.dart'; import '../format.dart';
class RoutesListScreen extends ConsumerWidget { class RoutesListScreen extends ConsumerWidget {
const RoutesListScreen({super.key, this.onOpenRoute, this.onBack}); const RoutesListScreen({super.key, this.onOpenRoute});
final void Function(int routeId)? onOpenRoute; final void Function(int routeId)? onOpenRoute;
final VoidCallback? onBack;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -21,7 +21,10 @@ class RoutesListScreen extends ConsumerWidget {
final routes = ref.watch(routePlansProvider).valueOrNull ?? const <RoutePlan>[]; final routes = ref.watch(routePlansProvider).valueOrNull ?? const <RoutePlan>[];
final units = ref.watch(unitSystemProvider); final units = ref.watch(unitSystemProvider);
// UI-01: no back/title header -- Plan is a tab now. "New route" is functional, not
// chrome, so it stays, right-aligned on its own.
return Scaffold( return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea( body: SafeArea(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -30,15 +33,6 @@ class RoutesListScreen extends ConsumerWidget {
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row( child: Row(
children: [ children: [
TextButton(
key: const Key('back'),
onPressed: onBack,
child: const Text('‹ Record'),
),
const Padding(
padding: EdgeInsets.only(left: 8),
child: Text('ROUTES', style: TextStyle(letterSpacing: 2)),
),
const Spacer(), const Spacer(),
IconButton( IconButton(
key: const Key('new-route'), key: const Key('new-route'),
@@ -65,8 +59,21 @@ class RoutesListScreen extends ConsumerWidget {
itemCount: routes.length, itemCount: routes.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final route = routes[i]; final route = routes[i];
return Card( // UI-06: a GlassPanel-wrapped row rather than a plain Card --
// matches Map HUD's floating-glass language, which every other
// restyled screen now uses for list-like surfaces too.
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: GlassPanel(
key: Key('route-${route.id}'), key: Key('route-${route.id}'),
padding: EdgeInsets.zero,
// A `ListTile` paints its ink splash/tap feedback on the
// nearest `Material` ancestor -- `GlassPanel`'s own
// `DecoratedBox` sits between it and the `Scaffold`'s
// Material otherwise, which Flutter flags as a real bug
// (splashes would paint invisibly behind the decoration).
child: Material(
type: MaterialType.transparency,
child: ListTile( child: ListTile(
title: Text(route.name), title: Text(route.name),
subtitle: Text(formatDistance(route.distanceM, unit: units)), subtitle: Text(formatDistance(route.distanceM, unit: units)),
@@ -79,6 +86,8 @@ class RoutesListScreen extends ConsumerWidget {
), ),
onTap: () => onOpenRoute?.call(route.id), onTap: () => onOpenRoute?.call(route.id),
), ),
),
),
); );
}, },
), ),

View File

@@ -11,25 +11,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/providers.dart'; import '../../app/providers.dart';
import '../../config/config.dart'; import '../../config/config.dart';
import '../../domain/models.dart'; import '../../domain/models.dart';
import '../../hud/hud_metric.dart';
class SettingsScreen extends ConsumerWidget { class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key, this.onBack}); const SettingsScreen({super.key});
final VoidCallback? onBack;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final config = ref.watch(configProvider); final config = ref.watch(configProvider);
// UI-01: no AppBar -- Settings is a tab now, reached via the persistent bottom nav,
// not a pushed screen with somewhere to back out to.
return Scaffold( return Scaffold(
appBar: AppBar( backgroundColor: Colors.transparent,
leading: IconButton(
key: const Key('back'),
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
),
title: const Text('Settings'),
),
// Config loads once at startup (see main.dart) and is normally already present by // Config loads once at startup (see main.dart) and is normally already present by
// the time anyone reaches this screen, but the seam is real: show a spinner rather // the time anyone reaches this screen, but the seam is real: show a spinner rather
// than crash on a null read in the small window before it resolves. // than crash on a null read in the small window before it resolves.
@@ -248,6 +242,24 @@ class _SettingsBodyState extends ConsumerState<_SettingsBody> {
applicationVersion: '1.0.0', applicationVersion: '1.0.0',
), ),
), ),
const Divider(),
const _SectionHeader('Live HUD stats'),
for (final metric in HudMetric.values)
SwitchListTile(
key: Key('hud-visible-${metric.name}'),
title: Text(metric.label),
// Order is the stable enum order, not current HUD position -- easy to
// scan, per the ticket's own Settings integration note.
value: ref.watch(hudLayoutControllerProvider)[metric]?.visible ?? false,
onChanged: (value) {
final notifier = ref.read(hudLayoutControllerProvider.notifier);
notifier.setVisible(metric, value);
// A Settings toggle is a discrete, deliberate action, unlike a drag
// frame -- it persists immediately, the same as every other switch on
// this screen, rather than waiting for a HUD edit-mode session to end.
notifier.persist();
},
),
], ],
); );
} }

View File

@@ -8,40 +8,81 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
// Matches the launcher icon: near-black tarmac with a safety-orange accent. V3-16 // UI-08: "Modern Professional Dark", the design system actually backing every fetched
// warmed the ground fractionally (asphalt, not a generic dark-mode blue-black) and added // Stitch mockup (confirmed by hex-matching, not by trusting the project's declared
// an instrument-blue tertiary reserved for reference readings -- see the ticket's // default theme — see docs/design/stitch-export/README.md). This supersedes V3-16's
// Direction section for why. // safety-orange identity outright, not an extension of it. Full spec:
const _accent = Color(0xFFFF5722); // docs/design/stitch-export/design-system-modern-professional-dark.md.
const _ground = Color(0xFF120F0D); //
const _surface = Color(0xFF1C1917); // `surface-main` (#0a0a0c), not the spec's plain `background`/`surface` (#131313), is
const _ink = Color(0xFFF2F5F8); // used as the app ground: the spec's own prose calls out #0a0a0c as *the* background,
const _outline = Color(0xFF6E7681); // with #131313 one step up for a mid-level surface and #16161a another step up for cards
const _danger = Color(0xFFE53935); // — a three-step stack, not two.
const _ground = Color(0xFF0A0A0C);
const _surfaceMid = Color(0xFF131313);
const _ink = Color(0xFFE5E2E1);
const _outline = Color(0xFF8B919F);
const _danger = Color(0xFFFFB4AB);
/// Reference readings only (a max, an average) -- never the live figure, which stays /// Professional Blue. The soul of the interface per the design system's own doc —
/// [_accent]. The same colour [RideMap] already uses for its slowest speed bucket, /// primary buttons, the active nav indicator, the user's own location dot, and (via
/// reused rather than invented, so "instrument blue" means one thing everywhere. /// `RideMap`'s speed gradient) the accent end of the live-path colouring. Replaces
const _instrumentBlue = Color(0xFF4FC3F7); /// safety-orange as *the* accent everywhere; the app's existing convention of leaving
/// headline figures (`BigStat`) in plain ink and reserving colour for `StatRow`'s
/// `reference` values is unchanged by this ticket.
const _primary = Color(0xFF4090FE);
/// UI-08 decision (the design system doesn't prescribe this split itself): reference
/// readings (a max, an average) get the warm tertiary orange, never the live figure,
/// which stays [_primary]. Inverted from V3-16 (blue was the reference colour there)
/// because blue is now the primary/live colour — a same-hue reference tone would have
/// been visually indistinguishable from the live one it exists to be told apart from.
const _referenceOrange = Color(0xFFFFB68C);
const ripprColors = ColorScheme.dark( const ripprColors = ColorScheme.dark(
primary: _accent, primary: _primary,
onPrimary: Color(0xFF100A06), onPrimary: Color(0xFF002F64),
secondary: _outline, secondary: Color(0xFFAEC7F6),
tertiary: _instrumentBlue, tertiary: _referenceOrange,
onTertiary: Color(0xFF042433), onTertiary: Color(0xFF532200),
surface: _surface, // UI-05: explicit rather than left to `ColorScheme.dark`'s own M3 tonal-palette
// derivation -- the Map HUD's Pause/Stop control bar needs to match the design
// system's literal `tertiary-container`/`error-container` tokens exactly, not
// whatever a generated tonal palette happens to compute from `tertiary`/`error` alone.
tertiaryContainer: Color(0xFFE3711F),
onTertiaryContainer: Color(0xFF481D00),
surface: _surfaceMid,
onSurface: _ink, onSurface: _ink,
surfaceContainerHighest: Color(0xFF262220), surfaceContainerHighest: Color(0xFF353534),
onSurfaceVariant: _ink, onSurfaceVariant: Color(0xFFC1C6D5),
outline: _outline, outline: _outline,
// UI-03: the design system's own `outline-variant` token -- a subtler line than
// `outline` itself, for a `GlassPanel`'s 1px border and a `FloatingPill`'s dividers,
// where a full-strength outline colour would read as a hard edge rather than a
// barely-there seam between tonal layers.
outlineVariant: Color(0xFF414753),
error: _danger, error: _danger,
onError: _ink, onError: Color(0xFF690005),
errorContainer: Color(0xFF93000A),
onErrorContainer: Color(0xFFFFDAD6),
); );
/// The near-black ground the whole app sits on. /// The near-black ground the whole app sits on.
const ripprBackground = _ground; const ripprBackground = _ground;
/// Cards and modals — one step up from [ripprColors]'s `surface`, per the design
/// system's three-level stack ("Surface Strategy": base darkest, cards a step lighter).
/// Exposed separately because `ColorScheme` has no fourth surface slot for it that isn't
/// already claimed by `surfaceContainerHighest` (reserved here for the selected/
/// highlighted state, not the resting card colour).
const ripprCardColor = Color(0xFF16161A);
/// Shape spec: "Rounded (Level 2)" — small components (buttons, inputs) get 8px, large
/// containers (cards, modals) get 16px. Two constants, not a single "roundness" figure,
/// because the spec explicitly gives them different values.
const double ripprRadiusSmall = 8.0;
const double ripprRadiusLarge = 16.0;
ThemeData ripprTheme() { ThemeData ripprTheme() {
final base = ThemeData( final base = ThemeData(
colorScheme: ripprColors, colorScheme: ripprColors,
@@ -57,13 +98,25 @@ ThemeData ripprTheme() {
// Flutter's equivalent exposure is the default text colour. Setting it explicitly // Flutter's equivalent exposure is the default text colour. Setting it explicitly
// here, rather than relying on a wrapping widget, means the failure cannot recur by // here, rather than relying on a wrapping widget, means the failure cannot recur by
// someone removing a container. // someone removing a container.
// UI-08: the design system specifies Inter exclusively. Not switched to here --
// naming a fontFamily with no matching asset bundled would silently fall back to
// the platform default (Roboto), the exact class of invisible failure the bodyColor
// fix below exists to prevent. Bundling the actual font files (or a vetted offline
// build of the google_fonts package) is left as a deliberate follow-up rather than
// shipping a fontFamily string that doesn't resolve to anything.
textTheme: base.textTheme.apply(bodyColor: _ink, displayColor: _ink), textTheme: base.textTheme.apply(bodyColor: _ink, displayColor: _ink),
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
backgroundColor: _ground, backgroundColor: _ground,
foregroundColor: _ink, foregroundColor: _ink,
elevation: 0, elevation: 0,
), ),
dividerTheme: const DividerThemeData(color: Color(0xFF322C28), space: 1), cardTheme: const CardThemeData(
color: ripprCardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(ripprRadiusLarge)),
),
),
dividerTheme: const DividerThemeData(color: Color(0xFF2D3037), space: 1),
); );
} }

View File

@@ -1,7 +1,9 @@
/// Ported from `com.rippr.ui.trips.TripsScreen` and `TripsViewModel`. /// Ported from `com.rippr.ui.trips.TripsScreen` and `TripsViewModel`.
/// ///
/// Every value shown in this list is already persisted on the Trip row, so the screen /// UI-07: "Rides History" -- restyled to Map HUD's (UI-05) established colours/icons,
/// never touches the point table. That keeps it fast with hundreds of rides. /// not the Stitch export's own card styling (see the ticket's Design section for the
/// generation artifacts deliberately not copied: four identical summary cards, and a
/// grayscale "older ride" treatment on the third card with no defined rule behind it).
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -11,8 +13,11 @@ import '../../app/providers.dart';
import '../../domain/models.dart'; import '../../domain/models.dart';
import '../../telemetry/telemetry.dart'; import '../../telemetry/telemetry.dart';
import '../activity_display.dart'; import '../activity_display.dart';
import '../components/glass_panel.dart';
import '../components/ride_map.dart';
import '../components/stats.dart'; import '../components/stats.dart';
import '../format.dart'; import '../format.dart';
import '../theme.dart';
/// Selection is screen-local: it must not survive navigating away, and nothing else /// Selection is screen-local: it must not survive navigating away, and nothing else
/// needs to observe it. /// needs to observe it.
@@ -20,23 +25,81 @@ final _selectionProvider = StateProvider.autoDispose<Set<int>>(
(ref) => <int>{}, (ref) => <int>{},
); );
/// UI-07: also screen-local -- a search/filter session has no reason to survive
/// leaving the tab, same reasoning as the selection above.
final _searchQueryProvider = StateProvider.autoDispose<String>((ref) => '');
final _activityFilterProvider = StateProvider.autoDispose<Activity?>((ref) => null);
/// Distinct, real statistics across every completed ride -- the ticket's own named fix
/// for the mockup's "four identical cards" generation artifact. A pure function, not a
/// provider: folding over an already-fetched `List<Trip>` is cheap regardless of ride
/// count (simple arithmetic, no I/O), so there is no cost this would actually be
/// optimising away -- and being a pure function is what makes it directly unit-testable
/// against a hand-computed total, per the ticket's own Tests section.
class RideHistorySummary {
const RideHistorySummary({
required this.totalDistanceM,
required this.totalMovingMillis,
required this.rideCount,
required this.avgSpeedKmh,
});
final double totalDistanceM;
final int totalMovingMillis;
final int rideCount;
final double avgSpeedKmh;
factory RideHistorySummary.compute(List<Trip> trips) {
final totalDistanceM = trips.fold<double>(0, (a, t) => a + t.distanceM);
final totalMovingMillis = trips.fold<int>(0, (a, t) => a + t.movingMillis);
return RideHistorySummary(
totalDistanceM: totalDistanceM,
totalMovingMillis: totalMovingMillis,
rideCount: trips.length,
// Average speed across the whole history, not an average of each ride's own
// average -- distance-weighted, so a handful of long rides isn't drowned out by
// many short ones the way a naive per-ride average would be.
avgSpeedKmh: totalMovingMillis <= 0
? 0
: (totalDistanceM / 1000) / (totalMovingMillis / 3600000),
);
}
}
class TripsScreen extends ConsumerWidget { class TripsScreen extends ConsumerWidget {
const TripsScreen({super.key, this.onOpenTrip, this.onBack}); const TripsScreen({super.key, this.onOpenTrip});
final void Function(int tripId)? onOpenTrip; final void Function(int tripId)? onOpenTrip;
final VoidCallback? onBack;
bool _matchesQuery(Trip trip, String query) {
if (query.isEmpty) return true;
final q = query.toLowerCase();
return tripLabel(trip).toLowerCase().contains(q) ||
formatDateTime(trip.startedAt).toLowerCase().contains(q);
}
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
final tripsAsync = ref.watch(completedTripsProvider); final tripsAsync = ref.watch(completedTripsProvider);
final trips = tripsAsync.valueOrNull ?? const <Trip>[]; final allTrips = tripsAsync.valueOrNull ?? const <Trip>[];
final units = ref.watch(unitSystemProvider); final units = ref.watch(unitSystemProvider);
final query = ref.watch(_searchQueryProvider);
final activityFilter = ref.watch(_activityFilterProvider);
final trips = allTrips
.where((t) => _matchesQuery(t, query))
.where((t) => activityFilter == null || t.activity == activityFilter)
.toList();
// The summary row reflects the whole history, not the filtered/searched subset --
// it's an overview stat, not a count of what's currently visible below it.
final summary = RideHistorySummary.compute(allTrips);
// Drop ids that no longer exist, so a deleted trip cannot linger in the selection. // Drop ids that no longer exist, so a deleted trip cannot linger in the selection.
final live = ref final live = ref
.watch(_selectionProvider) .watch(_selectionProvider)
.intersection(trips.map((t) => t.id).toSet()); .intersection(allTrips.map((t) => t.id).toSet());
final selecting = live.isNotEmpty; final selecting = live.isNotEmpty;
// Merge combines exactly two rides; anything else is ambiguous. // Merge combines exactly two rides; anything else is ambiguous.
final canMerge = live.length == 2; final canMerge = live.length == 2;
@@ -44,18 +107,23 @@ class TripsScreen extends ConsumerWidget {
void clearSelection() => void clearSelection() =>
ref.read(_selectionProvider.notifier).state = <int>{}; ref.read(_selectionProvider.notifier).state = <int>{};
// UI-01: no back/title header -- Rides is a tab now. The selection toolbar (Cancel/
// count/Merge/Delete) is functional, not chrome, so it stays -- it only appears
// while `selecting` is true.
return Scaffold( return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea( body: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const SizedBox(height: 8),
if (selecting)
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
child: Row( child: Row(
children: [ children: [
if (selecting) ...[
TextButton( TextButton(
onPressed: clearSelection, onPressed: clearSelection,
child: const Text('Cancel'), child: const Text('Cancel'),
@@ -67,31 +135,11 @@ class TripsScreen extends ConsumerWidget {
style: TextStyle(fontSize: 14, color: colors.outline), style: TextStyle(fontSize: 14, color: colors.outline),
), ),
), ),
] else ...[
TextButton(
key: const Key('back'),
onPressed: onBack,
child: const Text('‹ Record'),
),
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
'RIDES',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 3,
color: colors.primary,
),
),
),
],
const Spacer(), const Spacer(),
if (selecting) ...[
TextButton( TextButton(
key: const Key('merge'), key: const Key('merge'),
onPressed: canMerge onPressed: canMerge
? () => _confirmMerge(context, ref, trips, live) ? () => _confirmMerge(context, ref, allTrips, live)
: null, : null,
child: const Text('Merge'), child: const Text('Merge'),
), ),
@@ -104,9 +152,23 @@ class TripsScreen extends ConsumerWidget {
), ),
), ),
], ],
),
)
else ...[
_SearchAndFilter(
query: query,
activityFilter: activityFilter,
onQueryChanged: (q) =>
ref.read(_searchQueryProvider.notifier).state = q,
onFilterChanged: (a) =>
ref.read(_activityFilterProvider.notifier).state = a,
),
const SizedBox(height: 12),
if (allTrips.isNotEmpty) ...[
_SummaryRow(summary: summary, units: units),
const SizedBox(height: 12),
],
], ],
),
),
Expanded( Expanded(
child: switch (tripsAsync) { child: switch (tripsAsync) {
AsyncValue(hasValue: true, value: final list) AsyncValue(hasValue: true, value: final list)
@@ -115,11 +177,18 @@ class TripsScreen extends ConsumerWidget {
message: 'No rides yet.\nPress Start on the record screen to capture one.', message: 'No rides yet.\nPress Start on the record screen to capture one.',
icon: Icons.two_wheeler, icon: Icons.two_wheeler,
), ),
AsyncValue(hasValue: true) when trips.isEmpty =>
const EmptyState(
message: 'No rides match your search.',
icon: Icons.search_off,
),
AsyncValue(hasValue: true) => ListView.builder( AsyncValue(hasValue: true) => ListView.builder(
itemCount: trips.length, itemCount: trips.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final trip = trips[i]; final trip = trips[i];
return _TripTile( return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _TripCard(
trip: trip, trip: trip,
selected: live.contains(trip.id), selected: live.contains(trip.id),
selecting: selecting, selecting: selecting,
@@ -132,6 +201,7 @@ class TripsScreen extends ConsumerWidget {
} }
}, },
onLongPress: () => _toggle(ref, trip.id), onLongPress: () => _toggle(ref, trip.id),
),
); );
}, },
), ),
@@ -200,8 +270,145 @@ class TripsScreen extends ConsumerWidget {
} }
} }
class _TripTile extends StatelessWidget { class _SearchAndFilter extends StatelessWidget {
const _TripTile({ const _SearchAndFilter({
required this.query,
required this.activityFilter,
required this.onQueryChanged,
required this.onFilterChanged,
});
final String query;
final Activity? activityFilter;
final ValueChanged<String> onQueryChanged;
final ValueChanged<Activity?> onFilterChanged;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return GlassPanel(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
children: [
Icon(Icons.search, color: colors.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: TextField(
key: const Key('ride-search'),
onChanged: onQueryChanged,
controller: TextEditingController.fromValue(
TextEditingValue(
text: query,
selection: TextSelection.collapsed(offset: query.length),
),
),
decoration: const InputDecoration(
hintText: 'Search rides...',
border: InputBorder.none,
isDense: true,
),
),
),
PopupMenuButton<Activity?>(
key: const Key('activity-filter'),
icon: Icon(
Icons.tune,
color: activityFilter == null ? colors.onSurfaceVariant : colors.primary,
),
tooltip: 'Filter by activity',
initialValue: activityFilter,
onSelected: onFilterChanged,
itemBuilder: (context) => [
const PopupMenuItem(
key: Key('activity-filter-all'),
value: null,
child: Text('All activities'),
),
for (final activity in Activity.values)
PopupMenuItem(
key: Key('activity-filter-${activity.name}'),
value: activity,
child: Row(
children: [
Icon(activityIcon(activity), size: 20),
const SizedBox(width: 12),
Text(activityLabel(activity)),
],
),
),
],
),
],
),
);
}
}
class _SummaryRow extends StatelessWidget {
const _SummaryRow({required this.summary, required this.units});
final RideHistorySummary summary;
final UnitSystem units;
@override
Widget build(BuildContext context) {
final stats = [
('TOTAL DIST', formatDistance(summary.totalDistanceM, unit: units)),
('TOTAL TIME', formatDuration(summary.totalMovingMillis)),
('RIDES', '${summary.rideCount}'),
('AVG SPEED', formatSpeed(summary.avgSpeedKmh, unit: units)),
];
return Row(
children: [
for (var i = 0; i < stats.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
Expanded(child: _SummaryStatCard(label: stats[i].$1, value: stats[i].$2)),
],
],
);
}
}
class _SummaryStatCard extends StatelessWidget {
const _SummaryStatCard({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return GlassPanel(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(fontSize: 9, letterSpacing: 1, color: colors.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: colors.primary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
class _TripCard extends ConsumerWidget {
const _TripCard({
required this.trip, required this.trip,
required this.selected, required this.selected,
required this.selecting, required this.selecting,
@@ -218,31 +425,156 @@ class _TripTile extends StatelessWidget {
final VoidCallback onLongPress; final VoidCallback onLongPress;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
return Card( final points = ref.watch(livePointsProvider(trip.id)).valueOrNull ?? const [];
final segments = ref.watch(liveSegmentsProvider(trip.id)).valueOrNull ?? const [];
final avgSpeedKmh = trip.movingMillis <= 0
? 0.0
: (trip.distanceM / 1000) / (trip.movingMillis / 3600000);
return GlassPanel(
key: Key('trip-${trip.id}'), key: Key('trip-${trip.id}'),
padding: EdgeInsets.zero,
// A `ListTile`/`InkWell` paints its ink splash on the nearest `Material`
// ancestor -- `GlassPanel`'s own `DecoratedBox` sits between it and the
// `Scaffold`'s Material otherwise (the same fix UI-06 needed for its own
// GlassPanel-wrapped list rows).
child: Material(
type: MaterialType.transparency,
color: selected ? colors.surfaceContainerHighest : null, color: selected ? colors.surfaceContainerHighest : null,
child: ListTile( child: InkWell(
onTap: onTap, onTap: onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
leading: selecting borderRadius: const BorderRadius.all(Radius.circular(ripprRadiusLarge)),
? Icon( child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (selecting)
Padding(
padding: const EdgeInsets.only(right: 8, top: 28),
child: Icon(
selected ? Icons.check_circle : Icons.circle_outlined, selected ? Icons.check_circle : Icons.circle_outlined,
color: selected ? colors.primary : colors.outline, color: selected ? colors.primary : colors.outline,
),
) )
: Icon(activityIcon(trip.activity), color: colors.outline), else
title: Text( // UI-07: a real thumbnail of this ride's own path -- the ticket's
// own fix for the mockup's stock placeholder photos -- rather than
// a second, separate map-rendering path, this reuses `RideMap`
// directly, non-interactive (wrapped in `IgnorePointer`) at a small
// fixed size. If a long ride history ever makes many simultaneous
// small `RideMap`s a real scroll-performance cost, the ticket's own
// documented fallback is a lightweight static polyline-on-canvas
// painter instead -- not attempted here without a realistic ride
// history to actually measure against.
Padding(
padding: const EdgeInsets.only(right: 12),
child: ClipRRect(
borderRadius: BorderRadius.circular(ripprRadiusSmall),
child: SizedBox(
width: 88,
height: 88,
child: IgnorePointer(
child: RideMap(
points: points,
segments: segments,
fill: true,
tileProvider: ref.watch(cachedTileProviderProvider),
skeletonMode: ref.watch(mapConnectivityProvider).skeletonMode,
showAttribution: false,
),
),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(activityIcon(trip.activity), size: 16, color: colors.outline),
const SizedBox(width: 6),
Expanded(
child: Text(
tripLabel(trip), tripLabel(trip),
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
if (trip.name != null) ...[
const SizedBox(height: 2),
Text(
formatDateTime(trip.startedAt),
style: TextStyle(fontSize: 12, color: colors.outline),
),
],
const SizedBox(height: 8),
Row(
children: [
_StatColumn(
label: 'Distance',
value: formatDistance(trip.distanceM, unit: units),
),
const SizedBox(width: 16),
_StatColumn(
label: 'Time',
value: formatDuration(trip.movingMillis),
),
const SizedBox(width: 16),
_StatColumn(
label: 'Avg spd',
value: formatSpeed(avgSpeedKmh, unit: units),
),
],
),
],
),
),
),
],
),
), ),
subtitle: Text(
'${formatDistance(trip.distanceM, unit: units)} · '
'${formatDuration(trip.movingMillis)} · '
'max ${formatSpeed(trip.maxSpeedKmh, unit: units)}',
style: TextStyle(color: colors.outline),
), ),
), ),
); );
} }
} }
class _StatColumn extends StatelessWidget {
const _StatColumn({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label.toUpperCase(),
style: TextStyle(fontSize: 9, letterSpacing: 1, color: colors.onSurfaceVariant),
),
Text(
value,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colors.primary,
),
),
],
);
}
}

View File

@@ -1,6 +1,8 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/config/config.dart'; import 'package:rippr/src/config/config.dart';
import 'package:rippr/src/domain/models.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'; import 'package:shared_preferences/shared_preferences.dart';
/// `Config` had no dedicated tests before V3-03 — every existing widget test leaves /// `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', () { group('deviceId', () {
test('is generated once and then stable across reads', () async { test('is generated once and then stable across reads', () async {
final config = await freshConfig(); 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:flutter_test/flutter_test.dart';
import 'package:rippr/src/domain/models.dart'; import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/ui/components/ride_map.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'; import 'package:rippr/src/ui/theme.dart';
/// Tiles are never fetched here — a widget test cannot serve them — but the polyline /// 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, expect(map.options.maxZoom, maxTileZoom,
reason: 'exceeding OSM max tile zoom renders an empty grid'); 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/app/providers.dart';
import 'package:rippr/src/data/database.dart'; import 'package:rippr/src/data/database.dart';
import 'package:rippr/src/data/route_plan_repository.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/route_planner_screen.dart';
import 'package:rippr/src/ui/routes/routes_list_screen.dart'; import 'package:rippr/src/ui/routes/routes_list_screen.dart';
import 'package:rippr/src/ui/theme.dart'; import 'package:rippr/src/ui/theme.dart';
@@ -107,8 +110,10 @@ void main() {
final id = await repo.createRoutePlan(1000); final id = await repo.createRoutePlan(1000);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id))); await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
expect(find.text('0 m'), findsOneWidget); // UI-06: no pins yet -- the "tap to drop a pin" tooltip shows, not a stat pill
expect(find.textContaining('0 pins'), findsOneWidget); // (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.tapAt(tester.getCenter(find.byType(FlutterMap)));
await tester.pump(const Duration(milliseconds: 50)); await tester.pump(const Duration(milliseconds: 50));
@@ -119,35 +124,54 @@ void main() {
final waypoints = await repo.waypointsFor(id); final waypoints = await repo.waypointsFor(id);
expect(waypoints, hasLength(2)); expect(waypoints, hasLength(2));
expect(find.textContaining('2 pins'), findsOneWidget);
final label = tester.widget<Text>(find.byKey(const Key('route-distance'))); // The tooltip is gone and the stat pill has taken its place, showing the real
expect(label.data, isNot('0 m')); // 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 { 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); final id = await repo.createRoutePlan(1000);
await repo.addWaypoint(id, 51.0, -114.0); await repo.addWaypoint(id, 51.0, -114.0);
await repo.addWaypoint(id, 51.01, -114.0);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id))); await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
// flutter_map's `Marker` is a plain data class, not a Widget -- it never appears // 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. // 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(); 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'); final id = await repo.createRoutePlan(1000, name: 'Old name');
await pumpMap(tester, host(RoutePlannerScreen(routeId: id))); 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();
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( await tester.enterText(
find.byKey(const Key('route-name-field')), find.byKey(const Key('route-name-field')),
'Sunday coast run', 'Sunday coast run',
@@ -155,7 +179,8 @@ void main() {
await tester.tap(find.text('Save')); await tester.tap(find.text('Save'));
await tester.pump(); 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 { 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); expect(find.textContaining('no longer exists'), findsOneWidget);
}); });
screenTest('the offline-tiles download button is disabled with no pins ' screenTest('the offline-tiles download menu item is disabled with no pins '
'(V3-11)', (tester) async { '(V3-11, UI-06: now a PopupMenuItem behind the overflow menu)',
(tester) async {
final id = await repo.createRoutePlan(1000); final id = await repo.createRoutePlan(1000);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id))); 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')), find.byKey(const Key('download-tiles')),
); );
expect(button.onPressed, isNull); expect(item.enabled, isFalse);
expect(button.tooltip, contains('Add pins'));
}); });
screenTest('downloading shows a tile count and size estimate before any ' 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 repo.addWaypoint(id, 51.01, -114.0);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id))); 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')), find.byKey(const Key('download-tiles')),
); );
expect(button.onPressed, isNotNull); expect(item.enabled, isTrue);
await tester.tap(find.byKey(const Key('download-tiles'))); await tester.tap(find.byKey(const Key('download-tiles')));
// Bounded, not pumpAndSettle: the confirmation dialog is safe to settle (no // 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('tiles?'), findsOneWidget);
expect(find.textContaining('MB'), 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/app/providers.dart';
import 'package:rippr/src/config/config.dart'; import 'package:rippr/src/config/config.dart';
import 'package:rippr/src/domain/models.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/settings/settings_screen.dart';
import 'package:rippr/src/ui/theme.dart'; import 'package:rippr/src/ui/theme.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -196,6 +197,46 @@ void main() {
expect(tester.takeException(), isNull); 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', testWidgets('shows a spinner rather than crashing while Config is still loading',
(tester) async { (tester) async {
await tester.pumpWidget( await tester.pumpWidget(

View File

@@ -89,4 +89,26 @@ void main() {
expect(await reopened.get(const TileKey(5, 10, 10)), isNotNull); expect(await reopened.get(const TileKey(5, 10, 10)), isNotNull);
expect(await reopened.sizeBytes(), 64); 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/ui/activity_display.dart';
import 'package:rippr/src/recording/location_source.dart'; import 'package:rippr/src/recording/location_source.dart';
import 'package:rippr/src/recording/wakelock_controller.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/detail/trip_detail_screen.dart';
import 'package:rippr/src/ui/format.dart'; import 'package:rippr/src/ui/format.dart';
import 'package:rippr/src/ui/record/record_screen.dart'; import 'package:rippr/src/ui/record/record_screen.dart';
@@ -184,6 +187,51 @@ void main() {
reason: 'cancelling must not delete the ride'); 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 { screenTest('text is legible against the dark ground', (tester) async {
// The regression this exists for: removing Compose's Surface left LocalContentColor // 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 // 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)); expect(colour, isNot(Colors.black));
}); });
screenTest('a settings entry point exists and is wired (V3-02)', (tester) async { // UI-01: Settings/Routes/Rides entry points are no longer per-screen callback
var opened = false; // buttons on RecordScreen -- they're destinations on the shell's persistent bottom
await tester.pumpWidget( // nav bar. See the `AppShell`/`ShellScaffold` group below for their coverage.
host(RecordScreen(onOpenSettings: () => opened = true)),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('open-settings')), findsOneWidget); screenTest('the background map appears only while the map toggle is on '
await tester.tap(find.byKey(const Key('open-settings'))); '(V3-04, moved to the shell by UI-01)', (tester) async {
await tester.pumpAndSettle(); // Toggle off: the shell must not construct the map at all -- not just hidden.
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.
await repo.startTrip(1000); await repo.startTrip(1000);
await tester.pumpWidget(const SizedBox.shrink()); await pumpLive(
await pumpLive(tester, host(const RecordScreen(), map: false)); tester,
expect(find.byKey(const Key('live-map')), findsNothing); host(
ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
map: false,
),
);
expect(find.byKey(const Key('shell-background-map')), findsNothing);
expect(find.byType(FlutterMap), 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 tester.pumpWidget(const SizedBox.shrink());
await pumpLive(tester, host(const RecordScreen())); await pumpLive(
expect(find.byKey(const Key('live-map')), findsOneWidget); 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)', screenTest('backgrounding the app drops the tile layer (V3-04, moved to the '
(tester) async { 'shell by UI-01)', (tester) async {
final h = await repo.startTrip(1000); final h = await repo.startTrip(1000);
await repo.appendPoints([ await repo.appendPoints([
TrackPoint( TrackPoint(
@@ -267,7 +294,12 @@ void main() {
altitudeM: 1000.0, 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, expect(find.byType(TileLayer), findsOneWidget,
reason: 'foregrounded: tiles render normally'); reason: 'foregrounded: tiles render normally');
@@ -278,6 +310,7 @@ void main() {
await tester.binding.defaultBinaryMessenger await tester.binding.defaultBinaryMessenger
.handlePlatformMessage('flutter/lifecycle', message, (_) {}); .handlePlatformMessage('flutter/lifecycle', message, (_) {});
await tester.pump(); await tester.pump();
await tester.pump();
expect(find.byType(TileLayer), findsNothing, expect(find.byType(TileLayer), findsNothing,
reason: 'backgrounded: no tile layer means no tile request can fire'); reason: 'backgrounded: no tile layer means no tile request can fire');
@@ -288,6 +321,7 @@ void main() {
await tester.binding.defaultBinaryMessenger await tester.binding.defaultBinaryMessenger
.handlePlatformMessage('flutter/lifecycle', resumed, (_) {}); .handlePlatformMessage('flutter/lifecycle', resumed, (_) {});
await tester.pump(); await tester.pump();
await tester.pump();
expect(find.byType(TileLayer), findsOneWidget, expect(find.byType(TileLayer), findsOneWidget,
reason: 'foregrounding again must resume tiles'); reason: 'foregrounding again must resume tiles');
}); });
@@ -362,6 +396,69 @@ void main() {
expect(start.height, 96, expect(start.height, 96,
reason: 'V3-05: 72dp is not enough at speed, with gloves'); 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', () { group('trips list', () {
@@ -397,8 +494,86 @@ void main() {
expect(find.byKey(const Key('trip-1')), findsOneWidget); expect(find.byKey(const Key('trip-1')), findsOneWidget);
expect(find.text('Finished'), findsOneWidget); expect(find.text('Finished'), findsOneWidget);
// Only the completed one. // Only the completed one. UI-07: trip cards are GlassPanel-based, not Card --
expect(find.byType(Card), findsOneWidget); // 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 { screenTest('Merge enables at exactly two selections', (tester) async {