Compare commits
10 Commits
3e7878b6e1
...
04127d7237
| Author | SHA1 | Date | |
|---|---|---|---|
| 04127d7237 | |||
| 7c1d6570a2 | |||
| dccccb68b6 | |||
| fa6b1b8ef2 | |||
| bcface3529 | |||
| 24f4e17e81 | |||
| 10040e6985 | |||
| d3974eb4e1 | |||
| 13017b8ee8 | |||
| 5ab4934a4d |
Binary file not shown.
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
51
lib/src/hud/hud_layout_controller.dart
Normal file
51
lib/src/hud/hud_layout_controller.dart
Normal 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);
|
||||||
|
}
|
||||||
39
lib/src/hud/hud_metric.dart
Normal file
39
lib/src/hud/hud_metric.dart
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
121
lib/src/hud/hud_widget_layout.dart
Normal file
121
lib/src/hud/hud_widget_layout.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
108
lib/src/tiles/map_connectivity.dart
Normal file
108
lib/src/tiles/map_connectivity.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
lib/src/tiles/tile_config.dart
Normal file
18
lib/src/tiles/tile_config.dart
Normal 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
161
lib/src/ui/app_shell.dart
Normal 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',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
134
lib/src/ui/components/draggable_resizable_hud_widget.dart
Normal file
134
lib/src/ui/components/draggable_resizable_hud_widget.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
lib/src/ui/components/floating_pill.dart
Normal file
99
lib/src/ui/components/floating_pill.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
lib/src/ui/components/glass_panel.dart
Normal file
62
lib/src/ui/components/glass_panel.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
lib/src/ui/components/hud_edit_overlay.dart
Normal file
87
lib/src/ui/components/hud_edit_overlay.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
lib/src/ui/components/pulsing_location_marker.dart
Normal file
109
lib/src/ui/components/pulsing_location_marker.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
96
lib/src/ui/components/skeleton_map_layer.dart
Normal file
96
lib/src/ui/components/skeleton_map_layer.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.')),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
152
test/glass_component_kit_test.dart
Normal file
152
test/glass_component_kit_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
113
test/hud_edit_overlay_test.dart
Normal file
113
test/hud_edit_overlay_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
71
test/hud_layout_controller_test.dart
Normal file
71
test/hud_layout_controller_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
128
test/hud_widget_layout_test.dart
Normal file
128
test/hud_widget_layout_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
116
test/map_connectivity_test.dart
Normal file
116
test/map_connectivity_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
43
test/ride_history_summary_test.dart
Normal file
43
test/ride_history_summary_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user