Compare commits
54 Commits
3e7878b6e1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a7ccd67632 | |||
| a96a22aabc | |||
| 3b6aed2fa2 | |||
| 799471e5ff | |||
| c1d326a52d | |||
| 6d944cc85d | |||
| 0efc1700ff | |||
| dfd3e24062 | |||
| e44371ce9b | |||
| dde6ec833a | |||
| 3810dd5a26 | |||
| e06e410ad6 | |||
| 4051416add | |||
| c67072135b | |||
| 2abbaf5564 | |||
| 8bc6351a6e | |||
| 7b452b7b81 | |||
| aff59bfcf7 | |||
| de011cf9cf | |||
| 4b0029e463 | |||
| c29a780d7c | |||
| 38ad2c75c3 | |||
| 97e3aaa5a0 | |||
| 1a2e69cbf3 | |||
| d9aa412e15 | |||
| 08f2cfbf02 | |||
| e06b371e2d | |||
| f30f0063d0 | |||
| 6394f3bdd5 | |||
| 07098991ef | |||
| 9ac0be6ee5 | |||
| 57bc3c9233 | |||
| 9faa9530d7 | |||
| e491c9ee65 | |||
| 9cc1cc779f | |||
| 0ed6d2bebd | |||
| 9455574fbf | |||
| 8fabf4866e | |||
| b75e0d7c38 | |||
| 756e3a9f53 | |||
| 094e42a12f | |||
| 2d118b46a2 | |||
| cf6b9d1d73 | |||
| fe4476596d | |||
| 04127d7237 | |||
| 7c1d6570a2 | |||
| dccccb68b6 | |||
| fa6b1b8ef2 | |||
| bcface3529 | |||
| 24f4e17e81 | |||
| 10040e6985 | |||
| d3974eb4e1 | |||
| 13017b8ee8 | |||
| 5ab4934a4d |
270
.claude/skills/autopilot-build/SKILL.md
Normal file
270
.claude/skills/autopilot-build/SKILL.md
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
---
|
||||||
|
name: autopilot-build
|
||||||
|
description: "Turn a markdown requirements file into implemented, tested, emulator-verified, committed Flutter changes for this repo (rippr-flutter) — breaks requirements into independently-completable tickets, pauses for human approval, then dispatches implementation to isolated subagents, merges, verifies on the Android emulator, and commits a fresh APK."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Autopilot Build
|
||||||
|
|
||||||
|
Runs the same disciplined loop this repo's UI-redesign (`docs/ui-redesign/`) and
|
||||||
|
post-launch-feedback (`docs/feedback/`) batches were built with, end to end, from a
|
||||||
|
single requirements document. It is slow and thorough by design — every phase below
|
||||||
|
exists because skipping it caused a real problem in a prior run of this loop.
|
||||||
|
|
||||||
|
## Writing style: ASD-STE100 (Simplified Technical English)
|
||||||
|
|
||||||
|
Every ticket file, `Outcome` section, and subagent dispatch prompt this skill produces
|
||||||
|
is written in **ASD-STE100** — the controlled-language standard aerospace maintenance
|
||||||
|
manuals use specifically to remove ambiguity for a reader with no shared context. That
|
||||||
|
is exactly the constraint a fresh subagent with zero conversation history is under, so
|
||||||
|
the standard's core rules apply directly, not as a style nicety:
|
||||||
|
|
||||||
|
- **One instruction, one sentence.** Never join two instructions with "and" or a comma.
|
||||||
|
"Remove the panel. Disconnect the cable." not "Remove the panel and disconnect the
|
||||||
|
cable."
|
||||||
|
- **Active voice, imperative for instructions.** "Add the constant to `ride_map.dart`."
|
||||||
|
not "The constant should be added to `ride_map.dart`."
|
||||||
|
- **Keep sentences short** (roughly 20 words or fewer). Split a long sentence at its
|
||||||
|
natural joints rather than adding a subordinate clause.
|
||||||
|
- **Use one word for one meaning, every time.** If a ticket calls it "the overflow
|
||||||
|
menu" in one place, never call the same thing "the options menu" or "the kebab menu"
|
||||||
|
later in the same ticket. Inconsistent naming is the single most common way a
|
||||||
|
fresh-context reader misidentifies which code you mean.
|
||||||
|
- **Avoid long noun strings.** "the HUD widget grid collision check" is four nouns
|
||||||
|
stacked — prefer "the check that finds a collision in the HUD widget grid."
|
||||||
|
- **Avoid ambiguous pronouns.** Repeat the noun instead of "it"/"this"/"that" whenever
|
||||||
|
more than one candidate referent exists in the surrounding sentences — the exact
|
||||||
|
failure mode a fresh subagent can't recover from is guessing which prior noun "it"
|
||||||
|
meant.
|
||||||
|
- **Prefer concrete, specific verbs over vague ones.** "Reject the move" not "handle
|
||||||
|
the move"; "return the cached value" not "deal with the cached value."
|
||||||
|
- **State conditions before actions.** "If the route has fewer than two waypoints,
|
||||||
|
disable the toggle." not "Disable the toggle if the route has fewer than two
|
||||||
|
waypoints" — STE100 puts the condition first so the reader isn't committed to an
|
||||||
|
action before learning it might not apply.
|
||||||
|
|
||||||
|
This does not apply to direct quotations of existing code or user feedback (quote those
|
||||||
|
verbatim, exactly as written) — only to the ticket prose you write around them.
|
||||||
|
|
||||||
|
## Input
|
||||||
|
|
||||||
|
One argument: a path to a markdown requirements file (e.g. `docs/FEEDBACK.md`, or
|
||||||
|
whatever the user hands you). If no path is given, ask for one — don't guess at
|
||||||
|
requirements from conversation context alone; the file is the source of truth this
|
||||||
|
whole run is grounded in.
|
||||||
|
|
||||||
|
## Phase 0 — Sanity check before starting
|
||||||
|
|
||||||
|
- `flutter analyze` clean and `flutter test` green. Record the passing count — every
|
||||||
|
later phase's bar is "still clean, count only goes up from here."
|
||||||
|
- `git status` clean (or confirm with the user what to do with any dirty state — don't
|
||||||
|
silently work on top of someone else's uncommitted changes).
|
||||||
|
- Confirm a remote exists if this run is expected to end in something pushable — this
|
||||||
|
skill never pushes itself (see Phase 8), but say so up front if there's no remote.
|
||||||
|
|
||||||
|
## Phase 1 — Break requirements into tickets
|
||||||
|
|
||||||
|
Read the requirements file in full. Read enough of the current codebase (grep, targeted
|
||||||
|
file reads, and/or `Explore`-type subagents if the surface area is large or unfamiliar)
|
||||||
|
to ground each requirement in real files, not guesses. For anything with real design
|
||||||
|
ambiguity (a new data model, a rendering bug with no obvious cause, an architecture
|
||||||
|
change), launch a `Plan`-type subagent to work through the approach *before* you commit
|
||||||
|
to a ticket list — cheaper to redesign a paragraph now than a shipped ticket later.
|
||||||
|
|
||||||
|
Split the requirements into a small number of tickets, each one:
|
||||||
|
- **Independently completable** — a fresh subagent with zero conversation history could
|
||||||
|
read the ticket file alone and implement it correctly. This is the single most
|
||||||
|
important property. If a ticket needs context only available in this conversation,
|
||||||
|
the ticket is incomplete, not the subagent.
|
||||||
|
- **Scoped to avoid unnecessary file overlap** with its siblings where possible. Where
|
||||||
|
overlap is unavoidable (two tickets both need to touch the same screen), note the
|
||||||
|
dependency explicitly — see Phase 2.
|
||||||
|
|
||||||
|
**Do not write full ticket files yet.** Recite the list to the user first: one line
|
||||||
|
per ticket (title, one-sentence scope, rough size S/M/L). This mirrors the real
|
||||||
|
worked example: five tickets were named and sized in one message, and only written out
|
||||||
|
in full after the user confirmed the list. Skipping this step means discovering scope
|
||||||
|
disagreements after hours of writing detail nobody asked for.
|
||||||
|
|
||||||
|
## Phase 2 — Human validation (hard stop)
|
||||||
|
|
||||||
|
Do not proceed to Phase 3 without an explicit go-ahead on the ticket list. Use
|
||||||
|
`AskUserQuestion` if there's a real fork to resolve (e.g. "should FB-04 and FB-05 be one
|
||||||
|
ticket or two?"), otherwise just present the recited list and wait for the user's next
|
||||||
|
message. Adjust scope, splitting, or sizing based on their response before moving on —
|
||||||
|
this is the point where it's cheap to change direction.
|
||||||
|
|
||||||
|
## Phase 3 — Deep-dive each ticket and write full ticket files
|
||||||
|
|
||||||
|
For tickets with non-trivial design questions still open, do another round of targeted
|
||||||
|
investigation (fresh `Explore`/`Plan` subagent calls are fine here, run in parallel
|
||||||
|
where the questions are independent of each other) until you have concrete, opinionated
|
||||||
|
answers — not a menu of options — for every design decision the ticket will hand to an
|
||||||
|
implementing subagent.
|
||||||
|
|
||||||
|
Write one ticket file per ticket into `docs/<slug>/` (pick a short slug from the
|
||||||
|
requirements doc's topic — `docs/ui-redesign/`, `docs/feedback/` are the two existing
|
||||||
|
precedents in this repo). Write every section in ASD-STE100 (see above). Each ticket
|
||||||
|
file uses the same shape every ticket in this repo already uses:
|
||||||
|
|
||||||
|
```
|
||||||
|
# <ID> — <title>
|
||||||
|
|
||||||
|
**Depends on** <other ticket ids, or —> · **Size** S/M/L · **Status** Not started
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
## Context (exact current code excerpts, file paths, line numbers — quote, don't summarize)
|
||||||
|
## Design (concrete, opinionated decisions — not options)
|
||||||
|
## Implementation (numbered steps)
|
||||||
|
## Acceptance criteria (checkboxes)
|
||||||
|
## Tests (specific test names/cases to add, referencing existing test patterns in this repo)
|
||||||
|
## Risks
|
||||||
|
## Out of scope
|
||||||
|
```
|
||||||
|
|
||||||
|
Also write `docs/<slug>/README.md`: a table of ticket → size → depends-on → status, plus
|
||||||
|
a **dependency/dispatch-wave section** (see Phase 4) and a one-paragraph working-method
|
||||||
|
summary (implement → analyze/test clean → Outcome section → commit → status flip).
|
||||||
|
|
||||||
|
Commit the ticket docs as their own commit before dispatching anything.
|
||||||
|
|
||||||
|
## Phase 4 — Sequence into dispatch waves
|
||||||
|
|
||||||
|
Two tickets that both edit the same file are a real merge-conflict risk when run in
|
||||||
|
parallel isolated worktrees. Group tickets into waves:
|
||||||
|
- Tickets with no file overlap with each other → same wave, dispatched in parallel.
|
||||||
|
- A ticket that heavily edits a file another ticket already changed → its own later
|
||||||
|
wave, dispatched only after the earlier one has merged (so its worktree branches from
|
||||||
|
a base that already has the earlier change).
|
||||||
|
- Light, disjoint-region overlap in the same file (e.g. two tickets touching different
|
||||||
|
methods of the same screen) is an acceptable same-wave risk — it produced a small,
|
||||||
|
manually-resolvable conflict in practice, not a blocker.
|
||||||
|
|
||||||
|
Write this wave plan into the ticket README before dispatching.
|
||||||
|
|
||||||
|
## Phase 5 — Dispatch, implement, merge (per wave)
|
||||||
|
|
||||||
|
For each ticket in the current wave, launch an `Agent` call with `isolation: "worktree"`
|
||||||
|
and the **full ticket file contents inlined in the prompt** (not just a path — a fresh
|
||||||
|
agent should not have to go re-discover context this phase already gathered). Write the
|
||||||
|
prompt itself in ASD-STE100 too — the dispatch prompt is the second place, after the
|
||||||
|
ticket file, where ambiguity costs the most. The prompt must tell the subagent to:
|
||||||
|
|
||||||
|
1. Read the inlined ticket, implement exactly what its Design/Implementation sections
|
||||||
|
specify — it's already been reviewed, not something to redesign from scratch.
|
||||||
|
2. Run `flutter analyze` (must stay clean) and `flutter test` (must stay green, count
|
||||||
|
must not regress below the baseline from Phase 0/the previous wave).
|
||||||
|
3. Append an `## Outcome` section to its own ticket file: what shipped, any deviation
|
||||||
|
from the design and why, the final test count. If an assumption in the ticket turns
|
||||||
|
out wrong once real code is touched, say so honestly here rather than silently
|
||||||
|
patching over it.
|
||||||
|
4. Flip its own ticket's status line to Done — but **not** the shared README's table;
|
||||||
|
that gets updated once after merging, by you, not by parallel subagents racing each
|
||||||
|
other on the same file.
|
||||||
|
5. Commit locally. Follow this repo's normal commit convention unless the user asked
|
||||||
|
for something different for this run (e.g. no co-author trailer) — confirm which
|
||||||
|
applies during Phase 2 if it's ambiguous, don't assume silently either way.
|
||||||
|
6. State explicitly that Android-emulator verification is **best-effort only** at this
|
||||||
|
stage (Phase 6 does the real device pass) — a subagent that gets stuck fighting
|
||||||
|
emulator flakiness instead of finishing its actual ticket has failed the ticket.
|
||||||
|
|
||||||
|
Wait for each subagent in the wave to report back, then merge its branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git merge --no-ff worktree-agent-<id> -m "Merge <ticket-id>: <title>"
|
||||||
|
```
|
||||||
|
|
||||||
|
A conflict in the ticket `.md` file itself (both sides changed the status/Outcome
|
||||||
|
header) is expected and mechanical — take the incoming side
|
||||||
|
(`git checkout --theirs docs/<slug>/<ticket>.md && git add ...`), since it carries the
|
||||||
|
Outcome section. A conflict in real code needs an actual read of both diffs — do not
|
||||||
|
blindly pick one side; understand what each branch was trying to do and merge the
|
||||||
|
*intent*, not just the text (see the worked example: two tickets each added a different,
|
||||||
|
correct enhancement to the same function, and the right merge combined both rather than
|
||||||
|
picking one).
|
||||||
|
|
||||||
|
After every merge: `flutter analyze` clean, `flutter test` green, count check. Only then
|
||||||
|
move to the next ticket in the wave or the next wave. Update the ticket README's status
|
||||||
|
table to Done for everything just merged, as its own small commit.
|
||||||
|
|
||||||
|
## Phase 6 — Emulator verification pass (all waves merged)
|
||||||
|
|
||||||
|
This is not optional and not the same thing as the best-effort check individual
|
||||||
|
subagents did — this is the real bar. Boot (or reuse, if already running and
|
||||||
|
responsive) the project's emulator, launch the freshly-merged app, and walk every
|
||||||
|
ticket's golden path for real, taking screenshots as evidence.
|
||||||
|
|
||||||
|
**Known failure modes from prior runs, and how to handle them without losing an hour:**
|
||||||
|
- **Coordinate scaling.** Screenshots are shown to you scaled down (e.g. displayed at
|
||||||
|
900×2000 for a native 1080×2400 device) — the image caption states the exact ratio.
|
||||||
|
Multiply *both* x and y by that ratio before issuing `adb shell input tap`. Getting
|
||||||
|
this wrong looks like "the app isn't responding to taps" and wastes real time — it
|
||||||
|
isn't a bug, it's a unit error. When in doubt, pixel-sample the raw screenshot file
|
||||||
|
directly (PIL/`Pillow` in Python) to find a real on-screen coordinate rather than
|
||||||
|
eyeballing the scaled-down preview.
|
||||||
|
- **ANR / "isn't responding" loops.** Check host memory pressure first
|
||||||
|
(`vm_stat`, `sysctl vm.swapusage`) before assuming an app bug — a memory-starved host
|
||||||
|
produces exactly this symptom. Escalate in order: dismiss-and-wait once → force-stop
|
||||||
|
and relaunch the app (`adb shell am force-stop <pkg>` then `am start`) → full emulator
|
||||||
|
restart (`adb emu kill` + relaunch) → if the host itself is out of memory, say so and
|
||||||
|
ask the user to free some rather than fighting it silently for another hour.
|
||||||
|
`com.rippr.port` / `com.rippr.MainActivity` are this app's package/activity for direct
|
||||||
|
`am start` (faster than a full `flutter run` rebuild when the APK is already
|
||||||
|
installed and you just need a clean process).
|
||||||
|
- **Airplane mode / no network** on a fresh emulator boot makes map tiles fail to load
|
||||||
|
(renders as a checkerboard, not the app's real tiles) — check
|
||||||
|
`adb shell settings get global airplane_mode_on` and
|
||||||
|
`adb shell cmd connectivity airplane-mode disable` before concluding tiles are broken.
|
||||||
|
- **Stale app state** (a leftover active recording, an old database) from a previous
|
||||||
|
manual test session can make a screen look wrong for reasons unrelated to the current
|
||||||
|
tickets — `adb shell pm clear <pkg>` before a verification pass if there's any doubt
|
||||||
|
the state is clean.
|
||||||
|
- **Location-dependent features** (anything reading GPS) need a granted permission and
|
||||||
|
a mock fix on the emulator: `adb shell pm grant <pkg> android.permission.ACCESS_FINE_LOCATION`
|
||||||
|
(and `ACCESS_COARSE_LOCATION`), then `adb emu geo fix <lon> <lat>` — note longitude
|
||||||
|
first. A feature that reads location may take several seconds after granting/fixing
|
||||||
|
before the first real fix arrives; don't conclude it's broken from a screenshot taken
|
||||||
|
immediately after.
|
||||||
|
|
||||||
|
**When the emulator pass finds a real bug** (as opposed to an environment artifact
|
||||||
|
above): fix it directly, add a regression test that fails without the fix and passes
|
||||||
|
with it, re-run `flutter analyze`/`flutter test`, commit with a message that says what
|
||||||
|
the emulator pass found and why the widget tests missed it, then re-verify on-device
|
||||||
|
that the fix actually shows correctly. Do not mark a ticket's on-device verification
|
||||||
|
done until you've seen the fixed behavior with your own eyes (a screenshot), not just
|
||||||
|
inferred it from the test passing.
|
||||||
|
|
||||||
|
If the emulator is genuinely too unstable to get a real signal (host resource
|
||||||
|
exhaustion that persists after the escalation steps above), say so plainly rather than
|
||||||
|
claiming verification that didn't happen — this exact honesty call was made once
|
||||||
|
already in this repo's history and is the correct call to make again if needed.
|
||||||
|
|
||||||
|
## Phase 7 — Final commit: fresh APK
|
||||||
|
|
||||||
|
Once every ticket is merged, green, and emulator-verified:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter build apk --release
|
||||||
|
git add -f build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
git commit -m "<summary of what this batch shipped>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Force-add is required — `build/` is gitignored, and this repo's own established
|
||||||
|
precedent (see `git log --oneline -- '*.apk'`) is to force-add the release APK directly
|
||||||
|
into the repo rather than relying on CI artifacts or a release page, since there is no
|
||||||
|
CI/release pipeline set up. Match that precedent unless the user has since set one up.
|
||||||
|
|
||||||
|
## Phase 8 — Report back
|
||||||
|
|
||||||
|
Summarize: what shipped (ticket list with one line each), test count before → after,
|
||||||
|
what the emulator pass confirmed (and any bugs it caught that the unit-test bar alone
|
||||||
|
would have missed), and the final commit. State plainly whether anything was pushed
|
||||||
|
(this skill never pushes on its own — say so, and say what's ready for the user to push
|
||||||
|
if they want to).
|
||||||
|
|
||||||
|
## What this skill is not for
|
||||||
|
|
||||||
|
A single small, obviously-scoped change (one file, one clear fix) doesn't need five
|
||||||
|
phases of ceremony — just make the change, test it, done. This skill is for a
|
||||||
|
requirements document with enough surface area that breaking it into independently
|
||||||
|
verifiable pieces is itself valuable — the kind of input that would otherwise become one
|
||||||
|
enormous, hard-to-review commit.
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -50,3 +50,4 @@ app.*.map.json
|
|||||||
# Build artifacts (large; regenerable)
|
# Build artifacts (large; regenerable)
|
||||||
build/
|
build/
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
|
.claude/worktrees/
|
||||||
|
|||||||
Binary file not shown.
15
docs/FEEDBACK.md
Normal file
15
docs/FEEDBACK.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
# Issues
|
||||||
|
|
||||||
|
Map page, both when recording is enabled and disabled, you cannot zoom and move around the map, it's be nice to still be able to move around and see the surrounding area, and then tap a "center" icon to recenter on the user.
|
||||||
|
|
||||||
|
User dot on the map should always glow and occilate in size like it does when recording.
|
||||||
|
|
||||||
|
Pin drops still do not work at all, the map when placing the pins doesn't render at a all, it's just a blank canvas that you can zoom in and out of but no detail appears. The pins can also be placed but nothing is rendered on the map so you have no clue where they are.
|
||||||
|
|
||||||
|
When disabling some statistic during the HUD sessions, they should resize themselves, instead of leaving a gap by default. Also the default sizing is fucked.
|
||||||
|
- The hieght and width should be limited by the title size, and then the value will grow or shrink to ensure there is always padding and not overflowing.
|
||||||
|
|
||||||
|
Map page still cannot be scrolled/panned around -- it stays locked centered on the user, both idle and recording. A previous attempt at this fix (FB-06) did not actually resolve it on-device.
|
||||||
|
|
||||||
|
Tapping "+" to create a new route still shows a blank map with no tiles/detail rendered -- you cannot see streets or anything to tell you where pins are being dropped. A previous attempt at this fix (FB-07) did not actually resolve it on-device.
|
||||||
383
docs/feedback/FB-01-live-street-level-map.md
Normal file
383
docs/feedback/FB-01-live-street-level-map.md
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
# FB-01 — Live, street-level map everywhere
|
||||||
|
|
||||||
|
**Depends on** — · **Size** L · **Status** Done
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
The map is too zoomed out on every screen, and the shared background map has zero
|
||||||
|
location awareness while idle — it should behave like Google/Apple Maps: zoomed in
|
||||||
|
enough to see the street you're on and the streets around it, and continuously tracking
|
||||||
|
the device's real position in real time, whether or not a ride is being recorded.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Direct user feedback (`docs/FEEDBACK.md`, "All pages" section):
|
||||||
|
|
||||||
|
> The map is too zoomed out. Start by having the map zoomed in enough where you could
|
||||||
|
> easily see what street the user is on and the streets around it. This goes for all
|
||||||
|
> screens that use the map, and even if the map is a background it should be updating
|
||||||
|
> real time to the person moving so if they aren't recording but they are riding in a
|
||||||
|
> car, it will update just like google maps. Essentially we want something that looks
|
||||||
|
> identical to google maps or apple maps.
|
||||||
|
|
||||||
|
Today's behavior, exactly:
|
||||||
|
|
||||||
|
`lib/src/ui/components/ride_map.dart`, `_RideMapState.build()` (~lines 200-237):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
geo.Bounds? bounds;
|
||||||
|
if (hasPoints) {
|
||||||
|
bounds = geo.bounds([
|
||||||
|
for (final p in widget.points) geo.LatLon(p.latitude, p.longitude),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
...
|
||||||
|
options: MapOptions(
|
||||||
|
initialCameraFit: (bounds == null || bounds.isDegenerate)
|
||||||
|
? null
|
||||||
|
: CameraFit.bounds(
|
||||||
|
bounds: LatLngBounds(...),
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
maxZoom: maxTileZoom,
|
||||||
|
),
|
||||||
|
initialCenter: bounds == null
|
||||||
|
? const ll.LatLng(0, 0)
|
||||||
|
: ll.LatLng(bounds.centerLat, bounds.centerLon),
|
||||||
|
initialZoom: bounds == null
|
||||||
|
? 2
|
||||||
|
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
|
||||||
|
maxZoom: maxTileZoom,
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
`bounds == null` happens whenever `widget.points` is empty — which is exactly the state
|
||||||
|
of the shared background map (`ShellScaffold` in `lib/src/ui/app_shell.dart`) any time
|
||||||
|
no trip is actively recording. In that state the map centers on **`(0, 0)` (Null
|
||||||
|
Island) at zoom 2** — the literal opposite of "zoomed in enough to see your street."
|
||||||
|
|
||||||
|
The shared background map's data source, `ShellScaffold.build()` (`app_shell.dart`
|
||||||
|
~lines 59-72):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
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>[];
|
||||||
|
```
|
||||||
|
|
||||||
|
`livePointsProvider`/`liveSegmentsProvider` are DB-backed streams of a trip's *stored*
|
||||||
|
points — they only ever produce data while `RecordingEngine` is actively writing to a
|
||||||
|
trip. There is no code path anywhere that feeds the background map a raw GPS position
|
||||||
|
independent of an active recording — confirmed by grep: `locationSourceProvider`
|
||||||
|
(`lib/src/app/providers.dart` ~line 54) is only ever watched by `recordingEngineProvider`
|
||||||
|
(~lines 79-89). **The background map is blind while idle**, which is the root cause of
|
||||||
|
both complaints at once (wrong zoom AND no live tracking) — there's simply no location
|
||||||
|
signal reaching it until a trip starts.
|
||||||
|
|
||||||
|
`RideMap` already has exactly the chase-camera logic this needs, just scoped to
|
||||||
|
recorded points — `_RideMapState` (`ride_map.dart` ~lines 113-147):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
||||||
|
final _controller = MapController();
|
||||||
|
late bool _following = widget.follow;
|
||||||
|
bool _backgrounded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(RideMap old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (!_following || widget.points.isEmpty || _backgrounded) return;
|
||||||
|
final last = widget.points.last;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !_following) return;
|
||||||
|
_controller.move(ll.LatLng(last.latitude, last.longitude), _controller.camera.zoom);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
And the `onPositionChanged` cancel-on-manual-pan guard (`ride_map.dart` ~lines 243-252):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
onPositionChanged: !widget.follow
|
||||||
|
? null
|
||||||
|
: (position, hasGesture) {
|
||||||
|
if (hasGesture && _following) {
|
||||||
|
setState(() => _following = false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the exact "chase the rider, but a real pan/pinch cancels it" behavior the
|
||||||
|
feedback is asking for — it just needs a second data source (ambient GPS) wired in for
|
||||||
|
when there are no recorded points to chase.
|
||||||
|
|
||||||
|
**The shared `LocationSource` instance is process-wide — do not call `stop()` on it
|
||||||
|
from ambient code.** `lib/src/app/providers.dart`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final locationSourceProvider = Provider<LocationSource>((ref) {
|
||||||
|
final source = GeolocatorLocationSource();
|
||||||
|
ref.onDispose(source.dispose);
|
||||||
|
return source;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
One `GeolocatorLocationSource` for the whole app. Its `start()` is idempotent (safe to
|
||||||
|
call from two places — `lib/src/recording/geolocator_location_source.dart`:
|
||||||
|
`if (_subscription != null) return; // idempotent`), but its `stop()` is **not**
|
||||||
|
reference-counted — it unconditionally cancels the one underlying platform subscription:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@override
|
||||||
|
Future<void> stop() async {
|
||||||
|
await _subscription?.cancel();
|
||||||
|
_subscription = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If ambient-mode code ever calls `locationSource.stop()` (e.g. from a provider's
|
||||||
|
`ref.onDispose`, or when the Map tab becomes invisible), and a real recording happens to
|
||||||
|
be in progress at that moment, **it would silently kill GPS delivery to the active
|
||||||
|
recording** — the engine has no way to know its location source was just stopped out
|
||||||
|
from under it by an unrelated consumer. This must not be possible. See Design below.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
- **New constant** in `lib/src/ui/components/ride_map.dart`, near `maxTileZoom`/
|
||||||
|
`shortRideZoom`: `const double ambientZoom = 17.0;` — a distinct name from
|
||||||
|
`shortRideZoom` even though the value happens to match, since they mean different
|
||||||
|
things (one is "a very short recorded ride," the other is "no ride at all, just
|
||||||
|
ambient GPS").
|
||||||
|
- **New provider**, in `lib/src/app/providers.dart`:
|
||||||
|
```dart
|
||||||
|
/// The device's current position when nothing is being recorded — drives the shared
|
||||||
|
/// background map's "look like Google Maps while idle" behavior. Deliberately NOT
|
||||||
|
/// gated through `recordingEngineProvider`/`RecordingEngine.start()` — this must work
|
||||||
|
/// whether or not a ride is ever recorded. Never calls `LocationSource.stop()`: the
|
||||||
|
/// underlying `locationSourceProvider` instance is shared with the recording engine,
|
||||||
|
/// and `stop()` is not reference-counted (see FB-01's ticket for why).
|
||||||
|
final ambientPositionProvider = StreamProvider.autoDispose<LocationFix?>((ref) async* {
|
||||||
|
if (!ref.watch(mapEnabledProvider)) {
|
||||||
|
yield null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final source = ref.watch(locationSourceProvider);
|
||||||
|
try {
|
||||||
|
await source.start(); // idempotent; safe even if a recording already started it
|
||||||
|
} on LocationException {
|
||||||
|
yield null; // permission denied / service disabled — ambient mode is best-effort
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield* source.fixes.map<LocationFix?>((fix) => fix);
|
||||||
|
// No `stop()` call, ever, on dispose — see the doc comment above. Only this
|
||||||
|
// provider's own subscription to the broadcast `fixes` stream ends; the shared
|
||||||
|
// platform subscription is left exactly as it was.
|
||||||
|
});
|
||||||
|
```
|
||||||
|
`LocationFix` and `LocationException` are both already imported/available via
|
||||||
|
`lib/src/recording/location_source.dart` (already imported in `providers.dart`).
|
||||||
|
`autoDispose` is correct here — this should stop listening the moment nothing watches
|
||||||
|
it (e.g. the app backgrounded, or a trip starts and `ShellScaffold` stops watching
|
||||||
|
this provider — see next bullet), same lifecycle discipline as every other
|
||||||
|
`StreamProvider.autoDispose` in this file.
|
||||||
|
- **`ShellScaffold.build()`** (`lib/src/ui/app_shell.dart`): only watch
|
||||||
|
`ambientPositionProvider` while idle, so it's never even subscribed during an active
|
||||||
|
recording:
|
||||||
|
```dart
|
||||||
|
final trip = ref.watch(activeTripProvider).valueOrNull;
|
||||||
|
final ambientFix = trip == null ? ref.watch(ambientPositionProvider).valueOrNull : null;
|
||||||
|
final ambientPosition = ambientFix == null
|
||||||
|
? null
|
||||||
|
: ll.LatLng(ambientFix.latitude, ambientFix.longitude);
|
||||||
|
```
|
||||||
|
(needs `import 'package:latlong2/latlong.dart' as ll;`) then pass `ambientPosition:
|
||||||
|
ambientPosition` into the `RideMap(...)` constructor call alongside the existing
|
||||||
|
`points`/`segments`/`follow`/etc. Leave `follow: isMapTab` and `showLocationMarker:
|
||||||
|
isMapTab` exactly as they are — ambient following should only run on the visible Map
|
||||||
|
tab, matching the existing recording-follow rationale already in that file's comments.
|
||||||
|
- **`RideMap`** (`lib/src/ui/components/ride_map.dart`):
|
||||||
|
- Add `final ll.LatLng? ambientPosition;` to the widget's fields (with a doc comment
|
||||||
|
explaining it's only meaningful when `points` is empty — a recorded path always
|
||||||
|
takes priority) and thread it through the constructor.
|
||||||
|
- In `build()`, change the `bounds == null` branch to use it:
|
||||||
|
```dart
|
||||||
|
initialCenter: bounds == null
|
||||||
|
? (widget.ambientPosition ?? const ll.LatLng(0, 0))
|
||||||
|
: ll.LatLng(bounds.centerLat, bounds.centerLon),
|
||||||
|
initialZoom: bounds == null
|
||||||
|
? (widget.ambientPosition == null ? 2 : ambientZoom)
|
||||||
|
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
|
||||||
|
```
|
||||||
|
(`initialCenter`/`initialZoom` are read once at `FlutterMap` construction by
|
||||||
|
flutter_map — this only fixes the *first* placement; live tracking needs the
|
||||||
|
`didUpdateWidget` change below.)
|
||||||
|
- In `didUpdateWidget`, extend the chase logic to fall back to ambient position when
|
||||||
|
there are no recorded points:
|
||||||
|
```dart
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(RideMap old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (!_following || _backgrounded) return;
|
||||||
|
if (widget.points.isNotEmpty) {
|
||||||
|
final last = widget.points.last;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !_following) return;
|
||||||
|
_controller.move(ll.LatLng(last.latitude, last.longitude), _controller.camera.zoom);
|
||||||
|
});
|
||||||
|
} else if (widget.ambientPosition != null &&
|
||||||
|
widget.ambientPosition != old.ambientPosition) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !_following) return;
|
||||||
|
_controller.move(widget.ambientPosition!, _controller.camera.zoom);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `showLocationMarker`: today it only ever reads `widget.points.last`
|
||||||
|
(`MarkerLayer`/`PulsingLocationMarker` at ~lines 275-289). Add a fallback so the
|
||||||
|
pulsing marker also shows on ambient position:
|
||||||
|
```dart
|
||||||
|
if (widget.showLocationMarker && (hasPoints || widget.ambientPosition != null))
|
||||||
|
MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
Marker(
|
||||||
|
key: const Key('location-marker'),
|
||||||
|
point: hasPoints
|
||||||
|
? ll.LatLng(widget.points.last.latitude, widget.points.last.longitude)
|
||||||
|
: widget.ambientPosition!,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: const PulsingLocationMarker(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
```
|
||||||
|
- **Permission prompt timing**: `LocationSource.start()` requests permission if not
|
||||||
|
already granted (`_ensurePermission` in `geolocator_location_source.dart`). This means
|
||||||
|
the very first time a user opens the app (or opens the Map tab) they may see a real OS
|
||||||
|
location-permission dialog before ever pressing Start — this is intentional per the
|
||||||
|
feedback ("even if they aren't recording... it will update just like Google Maps") and
|
||||||
|
matches how a real maps app behaves. A denial must degrade gracefully: catch
|
||||||
|
`LocationException` and yield `null` (already shown above) so the map simply falls
|
||||||
|
back to today's `(0,0)`/zoom-2 behavior rather than crashing or showing an error.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
1. Add `ambientZoom` constant to `ride_map.dart`.
|
||||||
|
2. Add `ambientPosition` field + constructor param to `RideMap`.
|
||||||
|
3. Update the `bounds == null` branch of `initialCenter`/`initialZoom`.
|
||||||
|
4. Update `didUpdateWidget` to chase `ambientPosition` when there are no recorded
|
||||||
|
points.
|
||||||
|
5. Update the `showLocationMarker` `MarkerLayer` to fall back to `ambientPosition`.
|
||||||
|
6. Add `ambientPositionProvider` to `lib/src/app/providers.dart`.
|
||||||
|
7. Wire it into `ShellScaffold.build()` in `app_shell.dart`, gated on `trip == null`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] With no active trip and no ambient fix yet available, the background map still
|
||||||
|
falls back to today's `(0,0)`/zoom-2 (no regression / no crash on first frame
|
||||||
|
before permission resolves).
|
||||||
|
- [ ] Once an ambient fix arrives (simulated via `FakeLocationSource.emit` in tests),
|
||||||
|
the background map centers on it at `ambientZoom` (street level) and continues to
|
||||||
|
re-center as new fixes arrive, exactly like the existing recorded-path chase
|
||||||
|
behavior.
|
||||||
|
- [ ] A manual pan/pinch on the Map tab cancels ambient following the same way it
|
||||||
|
cancels recording-follow today (reuses `_following`/`onPositionChanged` unchanged).
|
||||||
|
- [ ] The moment a trip starts recording, the background map switches to following the
|
||||||
|
trip's own recorded points (unchanged priority — `hasPoints` already wins in
|
||||||
|
`didUpdateWidget`), and `ShellScaffold` stops watching `ambientPositionProvider`
|
||||||
|
entirely (`trip == null` gate) so there's no duplicate GPS consumer during a ride.
|
||||||
|
- [ ] Turning `mapEnabledProvider` off stops the ambient GPS subscription (via the
|
||||||
|
provider's own `ref.watch(mapEnabledProvider)` short-circuit) — no tile or
|
||||||
|
location request fires while the map is disabled, matching the existing map-toggle
|
||||||
|
guarantee.
|
||||||
|
- [ ] `LocationSource.stop()` is never called by any code this ticket adds — grep the
|
||||||
|
diff to confirm.
|
||||||
|
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- Widget test: `ShellScaffold` with `activeTripProvider` returning `null` and
|
||||||
|
`ambientPositionProvider` overridden/fed a fake `LocationFix` — assert the background
|
||||||
|
`RideMap`'s effective camera center/zoom reflects the ambient fix (read via
|
||||||
|
`MapController.camera` or by asserting the constructor args passed to `RideMap`,
|
||||||
|
whichever is more direct given the existing shell test patterns in
|
||||||
|
`test/widget_test.dart`'s `shell nav bar` group).
|
||||||
|
- Widget test: manually panning the map while an ambient fix is active stops further
|
||||||
|
auto-recentering on subsequent fixes (mirrors any existing recording-follow-cancel
|
||||||
|
test, if one exists — check `test/widget_test.dart`/`test/ride_map_test.dart`).
|
||||||
|
- Widget/provider test: `ambientPositionProvider` never calls `LocationSource.stop()` —
|
||||||
|
drive a `FakeLocationSource`, dispose the provider (e.g. via `container.dispose()` in
|
||||||
|
a `ProviderContainer`-based test), and assert `fakeSource.stopCalls == 0`.
|
||||||
|
- Widget test: `activeTripProvider` returning a non-null trip means the shell never
|
||||||
|
reads from `ambientPositionProvider` (e.g. assert no permission/`start()` call
|
||||||
|
happens when a trip is active and the fake source's `startCalls` was already
|
||||||
|
incremented by the recording path only).
|
||||||
|
- Existing `RideMap`/shell background tests continue to pass unmodified except where
|
||||||
|
they need a new `ambientPosition: null` default (should be a no-op given it's an
|
||||||
|
optional/nullable constructor param).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- **Battery**: `ambientPositionProvider` calling `start()` means GPS may run continuously
|
||||||
|
any time the Map tab (or the app in general, given the shell map is always mounted)
|
||||||
|
is open and the map is enabled, even with no ride ever recorded. This ticket
|
||||||
|
deliberately does not add a "stop after N minutes idle" or reference-counted shutdown
|
||||||
|
— that's a real product decision better made with actual battery data, not guessed at
|
||||||
|
here. Flag it in the Outcome section rather than solving it silently.
|
||||||
|
- **Permission timing**: as noted above, this may surface a permission dialog earlier
|
||||||
|
in the app's lifecycle than before (first Map-tab view rather than first Start press).
|
||||||
|
Confirmed intentional per the feedback; call out if it feels wrong in practice.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
Route Planner's own map (it manages its own `FlutterMap` directly, not through
|
||||||
|
`RideMap`) — covered by FB-04, which should reuse `ambientZoom` from this ticket for
|
||||||
|
zoom-consistency but does not need ambient location following (Route Planner already
|
||||||
|
centers on the route's own waypoints, which is correct). HUD widget changes (FB-03).
|
||||||
|
The idle Speed panel (FB-02) — unrelated, but note FB-02's idle screen will now show a
|
||||||
|
genuinely live, moving map underneath once this ticket lands, which is the whole point.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Implemented exactly as designed, with no deviations from the Design section:
|
||||||
|
|
||||||
|
- `ambientZoom` constant added to `ride_map.dart`.
|
||||||
|
- `RideMap.ambientPosition` field/constructor param added; `bounds == null` branch of
|
||||||
|
`initialCenter`/`initialZoom`, the `didUpdateWidget` chase logic, and the
|
||||||
|
`showLocationMarker` `MarkerLayer` all updated exactly per the ticket's diffs.
|
||||||
|
- `ambientPositionProvider` added to `providers.dart`, verbatim from the ticket's own
|
||||||
|
code block — never calls `LocationSource.stop()`, only `start()` (idempotent) and its
|
||||||
|
own subscription's implicit cancellation of the broadcast `fixes` stream on
|
||||||
|
`autoDispose`.
|
||||||
|
- `ShellScaffold.build()` wired to watch `ambientPositionProvider` only while
|
||||||
|
`activeTripProvider` is null, passing the resulting `ll.LatLng?` into `RideMap` as
|
||||||
|
`ambientPosition`, alongside the unchanged `follow: isMapTab` /
|
||||||
|
`showLocationMarker: isMapTab`.
|
||||||
|
|
||||||
|
Confirmed via `grep -n "\.stop()" $(git diff --name-only -- lib)` that no code this
|
||||||
|
ticket added calls `LocationSource.stop()` anywhere.
|
||||||
|
|
||||||
|
One nuance surfaced by testing, not a deviation from the design but worth recording:
|
||||||
|
`activeTripProvider` is a `StreamProvider`, so on the very first frame after the shell
|
||||||
|
mounts its `valueOrNull` is `null` (still loading) even when a trip is already active in
|
||||||
|
the database — this is pre-existing behavior identical to how `points`/`segments`
|
||||||
|
already fell back to empty lists before their own streams' first emission. In practice
|
||||||
|
this means `ambientPositionProvider` may be watched, and `LocationSource.start()` called,
|
||||||
|
for one transient frame during cold start even if a recording turns out to already be
|
||||||
|
active. It self-corrects the instant the trip stream emits (the shell stops watching
|
||||||
|
`ambientPositionProvider` from then on), and `start()` is idempotent regardless, so this
|
||||||
|
has no observable effect on recording — but it means the "no duplicate GPS consumer"
|
||||||
|
guarantee is a steady-state guarantee, not literally true for the first frame. Tests were
|
||||||
|
written to reflect this (see `test/widget_test.dart`'s "once a trip starts recording, the
|
||||||
|
shell stops watching ambient GPS" test, which starts idle, lets the ambient provider
|
||||||
|
engage, then starts a trip and asserts no further `start()` calls — rather than asserting
|
||||||
|
zero calls from t=0).
|
||||||
|
|
||||||
|
Both risks flagged in the ticket (continuous background GPS while idle with the Map tab
|
||||||
|
mounted, and an earlier permission-dialog timing) were left unresolved as instructed —
|
||||||
|
no reference-counted shutdown or idle timeout was added.
|
||||||
|
|
||||||
|
**Tests**: `flutter analyze` clean (same 4 pre-existing infos as baseline, no new
|
||||||
|
issues). `flutter test` green, 384 passing (up from 374 baseline; +10 new tests: 6 in
|
||||||
|
`test/ride_map_test.dart`'s new "ambient position (FB-01)" group covering
|
||||||
|
`RideMap`-level centering/chase/pan-cancel/marker-fallback behavior, and 4 in
|
||||||
|
`test/widget_test.dart` — two widget-level (`ShellScaffold` following an ambient fix
|
||||||
|
with no trip active; ambient watching stopping once a trip starts) and two
|
||||||
|
provider-level, driven directly against a `ProviderContainer` (never calls `stop()`;
|
||||||
|
`mapEnabledProvider` off never starts location and yields null)).
|
||||||
243
docs/feedback/FB-02-hide-idle-speed-panel.md
Normal file
243
docs/feedback/FB-02-hide-idle-speed-panel.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# FB-02 — Hide the idle Speed panel until recording starts
|
||||||
|
|
||||||
|
**Depends on** — · **Size** S · **Status** Done
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
The Record screen's idle (not-recording) state currently shows a live "SPEED" readout
|
||||||
|
plus status text before the rider has pressed Start at all. It should show nothing but
|
||||||
|
the (live, per FB-01) map and the START control until a ride actually begins.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Direct user feedback (`docs/FEEDBACK.md`, "Map Page" section):
|
||||||
|
|
||||||
|
> Remove the "Speed" widget from the map screen when "start" hasn't been pressed yet. It
|
||||||
|
> blocks the entire screen, and you can't see the map at all. Speed and other widgets
|
||||||
|
> should only appear when recording starts.
|
||||||
|
|
||||||
|
Today's idle branch — `lib/src/ui/record/record_screen.dart`, inside `build()`'s
|
||||||
|
`Stack` (~lines 196-223):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
if (ui.isIdle)
|
||||||
|
Center(
|
||||||
|
child: GlassPanel(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// 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(
|
||||||
|
key: const Key('speed'),
|
||||||
|
label: 'SPEED',
|
||||||
|
value: speedValue,
|
||||||
|
unit: speedUnit,
|
||||||
|
scale: mountedMode ? mountedTextScale : 1.0,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// 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: 'Last ride', value: 'See Rides'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Positioned.fill(
|
||||||
|
bottom: _controlBarHeight(mountedMode),
|
||||||
|
child: HudEditOverlay(
|
||||||
|
metricBuilder: (context, metric) =>
|
||||||
|
_HudMetricValue(metric: metric, ui: ui, units: units),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
`ui.isIdle` is `trip == null` (`RecordUiState.isIdle`, ~line 42). The `GlassPanel` sizes
|
||||||
|
to its content (`mainAxisSize: MainAxisSize.min`) so it isn't literally full-screen, but
|
||||||
|
it's centered, opaque-ish (`GlassPanel` is a near-opaque translucent fill per its own
|
||||||
|
doc comment elsewhere in this codebase), and shows a real live speed figure
|
||||||
|
(`_liveSpeed`, updated every GPS tick via `liveTelemetryProvider` — see `initState`
|
||||||
|
~lines 86-88) before any ride exists — which is exactly what the feedback is describing
|
||||||
|
as "blocking the screen" and confusing to see before pressing Start.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
Remove the entire `if (ui.isIdle) ... else ...` branching for HUD content — always
|
||||||
|
render the `HudEditOverlay` branch, and have the idle state simply show nothing there
|
||||||
|
(the HUD overlay area is empty when there's no trip, since there's nothing to edit/no
|
||||||
|
metrics to place — confirm `HudEditOverlay` degrades gracefully with an idle
|
||||||
|
`RecordUiState`, or gate it explicitly: only mount `HudEditOverlay` when `!ui.isIdle`,
|
||||||
|
and render an empty `SizedBox.shrink()` (or nothing at all) for the idle case, so the
|
||||||
|
full-bleed live map (per FB-01) is the entire idle screen behind the START control bar.
|
||||||
|
|
||||||
|
Concretely:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
if (!ui.isIdle)
|
||||||
|
Positioned.fill(
|
||||||
|
bottom: _controlBarHeight(mountedMode),
|
||||||
|
child: HudEditOverlay(
|
||||||
|
metricBuilder: (context, metric) =>
|
||||||
|
_HudMetricValue(metric: metric, ui: ui, units: units),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (ui.isPaused)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
(i.e. drop the idle `Center(child: GlassPanel(...))` block entirely — no replacement
|
||||||
|
content, not even a "Ready" label. The now-live background map, per FB-01, is the idle
|
||||||
|
screen's entire content above the control bar.)
|
||||||
|
|
||||||
|
`speedValue`/`speedUnit` (`formatSpeedParts(_liveSpeed, unit: units)`, ~line 176) become
|
||||||
|
unused once the idle panel is removed — check whether `_HudMetricValue`'s own
|
||||||
|
`HudMetric.speed` case (`formatSpeedParts(ui.currentSpeedKmh, unit: units).$1` at
|
||||||
|
~line 310) already computes this independently (it does — `ui.currentSpeedKmh` is
|
||||||
|
`_liveSpeed`), so the `final (speedValue, speedUnit) = ...` line in `build()` should
|
||||||
|
simply be deleted rather than left as dead code. Run `flutter analyze` to confirm no
|
||||||
|
other lingering unused-variable warnings from this removal.
|
||||||
|
|
||||||
|
`BigStat`/`StatRow` imports in `record_screen.dart` (from `../components/stats.dart`)
|
||||||
|
may become unused if nothing else in this file still uses them — check before removing
|
||||||
|
the import; `confirmDialog` (also from `stats.dart`) is used elsewhere in this file for
|
||||||
|
the discard confirmation, so the import itself likely stays, just drop `BigStat`/
|
||||||
|
`StatRow` specifically if nothing else references them.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
1. Remove the idle `Center(child: GlassPanel(...))` block from `record_screen.dart`'s
|
||||||
|
`build()`.
|
||||||
|
2. Gate `HudEditOverlay` on `!ui.isIdle` (it was previously the `else` branch of the
|
||||||
|
idle check — now it's the only branch, wrapped in its own `if`).
|
||||||
|
3. Delete the now-unused `final (speedValue, speedUnit) = formatSpeedParts(...)` line.
|
||||||
|
4. Run `flutter analyze` and remove any import that's now unused as a result.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] Idle state (no active trip) shows no Speed/Status/"Last ride" content at all —
|
||||||
|
just the live background map and the control bar with only "START RECORDING"
|
||||||
|
visible (unchanged from today).
|
||||||
|
- [ ] The moment a trip starts recording, the full HUD (`HudEditOverlay` with the
|
||||||
|
default-visible metrics: Speed, Avg Speed, Distance, Elapsed Time) appears exactly
|
||||||
|
as it does today — this ticket only changes the idle state, not recording/paused.
|
||||||
|
- [ ] `flutter analyze` clean (no unused-variable/import warnings from the removal).
|
||||||
|
- [ ] `flutter test` green, test count only goes up (net effect after the test
|
||||||
|
migrations below should still be positive or flat, never negative).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
Several existing tests assert against the idle Speed panel specifically and must be
|
||||||
|
migrated — not deleted — to seed an active recording and assert against the HUD's own
|
||||||
|
`Speed` widget instead, since the underlying regressions they guard (black-on-black
|
||||||
|
text, mounted-mode contrast/scale) are real risks that still apply once the same figure
|
||||||
|
lives inside `_HudMetricValue` instead of the idle `BigStat`. The HUD's per-metric
|
||||||
|
widgets are keyed `ValueKey(entry.key)` where `entry.key` is the `HudMetric`
|
||||||
|
(`lib/src/ui/components/hud_edit_overlay.dart` ~line 63) — use
|
||||||
|
`find.byKey(const ValueKey(HudMetric.speed))` to locate the speed widget in a recording
|
||||||
|
state, then descend into it the same way the old tests descended into `Key('speed')`.
|
||||||
|
|
||||||
|
In `test/widget_test.dart`:
|
||||||
|
|
||||||
|
- **`'idle shows Ready and only START'`** (~line 126): remove the
|
||||||
|
`expect(find.text('Ready'), findsOneWidget);` assertion (the "Ready"/"Status" text no
|
||||||
|
longer exists at all); keep the START/PAUSE/STOP key assertions and the
|
||||||
|
`find.text('Distance')` findsNothing check — both remain valid (this test's title may
|
||||||
|
want updating to `'idle shows only START'`).
|
||||||
|
- **`'the headline is live speed, not max speed'`** (~line 138): change to seed a
|
||||||
|
recording trip first (`await repo.startTrip(1000); await pumpLive(tester,
|
||||||
|
host(const RecordScreen(), map: false));` — see the identical pattern already used at
|
||||||
|
~line 205-206 in `'tapping PAUSE and then STOP...'`), then assert
|
||||||
|
`find.text('SPEED')` findsOneWidget / `find.text('MAX SPEED')` findsNothing exactly as
|
||||||
|
before (Max Speed is not one of the four default-visible metrics —
|
||||||
|
`HudWidgetLayout.defaultFor`'s `visible: index < columns` with `columns = 4` and
|
||||||
|
`HudMetric.values` ordered `speed, avgSpeed, distance, elapsedTime, maxSpeed, ...` —
|
||||||
|
so this assertion still holds true in the recording state).
|
||||||
|
- **`'text is legible against the dark ground'`** (~line 235): seed a recording trip the
|
||||||
|
same way, then replace
|
||||||
|
`find.descendant(of: find.byKey(const Key('speed')), matching: find.text('0'))` with
|
||||||
|
`find.descendant(of: find.byKey(const ValueKey(HudMetric.speed)), matching:
|
||||||
|
find.text('0'))`. Same downstream assertions (`colour != ripprBackground`,
|
||||||
|
`colour != Colors.black`) unchanged.
|
||||||
|
- **`'the mounted theme is high-contrast and text scales up (V3-05)'`** (~line 380) and
|
||||||
|
**`'the mounted-mode speed digit is legible against GlassPanel's own translucent
|
||||||
|
surface...'`** (~line 400): both currently pump `RecordScreen()` idle with
|
||||||
|
`mountedMode: true` and inspect `Key('speed')`. Migrate both to seed a recording trip
|
||||||
|
first, then use `ValueKey(HudMetric.speed)` the same way. Confirm `_HudMetricValue`'s
|
||||||
|
value `Text` actually honors `mountedMode`'s scale/theme the same way the old idle
|
||||||
|
`BigStat` did (check `_HudMetricValue.build()` — if it currently has no mounted-mode
|
||||||
|
awareness at all, that's a **pre-existing gap this migration would newly expose**, not
|
||||||
|
something to paper over: if the mounted-mode font-size/contrast assertions fail
|
||||||
|
against the HUD widget, that's a real, separate bug worth calling out plainly in this
|
||||||
|
ticket's Outcome section rather than weakening the assertion to make it pass.
|
||||||
|
- **`'HUD widgets render over the map, not replacing it (UI-05)'`** (~line 219) already
|
||||||
|
seeds a recording trip and asserts `find.text('SPEED')`/`find.text('DISTANCE')` — no
|
||||||
|
change needed, but worth a quick sanity check that it still passes unmodified.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- The mounted-mode migration above may surface that `_HudMetricValue` never actually
|
||||||
|
had mounted-mode-aware styling (it was only ever exercised via the idle `BigStat`
|
||||||
|
before now) — if so, this ticket's Outcome section must say so explicitly rather than
|
||||||
|
silently deleting or weakening the assertion. Fixing that gap (if real) is reasonable
|
||||||
|
to fold into this ticket since it's directly caused by this change, but keep the fix
|
||||||
|
minimal (reuse whatever scale/theme mechanism `BigStat` already used) rather than
|
||||||
|
redesigning `_HudMetricValue`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
The HUD grid/drag-resize/text-auto-fit rework (FB-03) — this ticket only removes idle
|
||||||
|
content, it does not touch how the recording-state HUD widgets are laid out or sized.
|
||||||
|
The live-map-while-idle behavior itself (FB-01) — this ticket assumes it lands
|
||||||
|
separately; if FB-01 hasn't merged yet, the idle screen will just show today's
|
||||||
|
`(0,0)`/zoom-2 map, which is still a strict improvement over a blocking Speed panel.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Implemented exactly as designed. `record_screen.dart`'s idle `Center(child: GlassPanel(...))`
|
||||||
|
block (Speed/Status/Last ride) is gone; `HudEditOverlay` is now gated on `!ui.isIdle` and is
|
||||||
|
the Stack's only HUD-content branch. The dead `final (speedValue, speedUnit) = ...` line was
|
||||||
|
deleted; `BigStat`/`StatRow` are no longer referenced in the file (the `stats.dart` import
|
||||||
|
stays, since `confirmDialog` from the same file is still used for the discard dialog).
|
||||||
|
|
||||||
|
All five named tests were migrated per the ticket's Tests section:
|
||||||
|
- `'idle shows Ready and only START'` → renamed to `'idle shows only START'`, the `Ready`
|
||||||
|
assertion removed, START/PAUSE/STOP and `find.text('Distance')` checks kept.
|
||||||
|
- `'the headline is live speed, not max speed'` → now seeds a recording trip and asserts
|
||||||
|
against the HUD, same SPEED/MAX SPEED expectations.
|
||||||
|
- `'text is legible against the dark ground'` → now seeds a recording trip and descends into
|
||||||
|
`find.byKey(const ValueKey(HudMetric.speed))` instead of `Key('speed')`.
|
||||||
|
- The two mounted-mode tests (V3-05 scale/contrast, and UI-05 GlassPanel contrast) were
|
||||||
|
migrated to seed a recording trip and use `ValueKey(HudMetric.speed)`. The V3-05 test's
|
||||||
|
unrelated `Key('start')` height check was swapped for `Key('pause')`, since a recording
|
||||||
|
state has no START button. The GlassPanel-lookup test switched from
|
||||||
|
`find.byType(GlassPanel).first` (an ancestor of `Key('speed')` in the old layout) to
|
||||||
|
`find.descendant(of: find.byKey(const ValueKey(HudMetric.speed)), matching:
|
||||||
|
find.byType(GlassPanel))`, since in the new HUD tree the per-metric `GlassPanel` is a
|
||||||
|
*descendant* of the keyed widget (`DraggableResizableHudWidget`), not an ancestor.
|
||||||
|
- `'HUD widgets render over the map, not replacing it (UI-05)'` needed no change and still
|
||||||
|
passes.
|
||||||
|
|
||||||
|
**The risk the ticket flagged was real, in two parts, both now fixed:**
|
||||||
|
1. `_HudMetricValue` had no `scale` parameter at all — its label/value `fontSize`s were
|
||||||
|
hardcoded (10/18), so the old `BigStat`-based mounted-mode text-scale assertion
|
||||||
|
(`64 * mountedTextScale`) had no equivalent to migrate to. Added a `scale` field to
|
||||||
|
`_HudMetricValue`, threaded from `record_screen.dart`'s existing `mountedMode` local the
|
||||||
|
same way `BigStat` was driven (`mountedMode ? mountedTextScale : 1.0`), and multiplied
|
||||||
|
both font sizes by it. The migrated test now asserts `18 * mountedTextScale`.
|
||||||
|
2. Once (1) was fixed, the *second* mounted-mode test (GlassPanel contrast, UI-05) still
|
||||||
|
failed: `_HudMetricValue._valueColor`'s per-metric accent colours (`colors.primary` for
|
||||||
|
Speed, tuned for the dark HUD mockup) measure 4.43:1 against the mounted theme's
|
||||||
|
`GlassPanel` surface — just under the 4.5:1 AA threshold the test enforces. This is a
|
||||||
|
real, separate, pre-existing gap this migration exposed: `_HudMetricValue` never
|
||||||
|
accounted for mounted mode's own colour scheme in `_valueColor` at all (unlike `BigStat`,
|
||||||
|
which always named its headline colour explicitly as `colors.onSurface` rather than
|
||||||
|
trusting an accent). Fixed minimally by adding a `mountedMode` bool to `_HudMetricValue`
|
||||||
|
that, when true, forces `_valueColor` to `colors.onSurface` for every metric — reusing
|
||||||
|
`BigStat`'s own explicit-ink fallback — while leaving the dark-theme mockup's per-metric
|
||||||
|
accent colours (Speed: primary, Distance: secondary) untouched for the normal (non-mounted)
|
||||||
|
case. This is a colour-only override scoped to mounted mode; no other `_HudMetricValue`
|
||||||
|
behavior changed.
|
||||||
|
|
||||||
|
`flutter analyze`: clean (same 4 pre-existing `info`-level issues as baseline, none new).
|
||||||
|
`flutter test`: 374 passing (unchanged from baseline — no tests were added or removed, five
|
||||||
|
were migrated in place per the ticket).
|
||||||
661
docs/feedback/FB-03-grid-snapped-hud-widgets.md
Normal file
661
docs/feedback/FB-03-grid-snapped-hud-widgets.md
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
# FB-03 — Grid-snapped HUD widgets with auto-fit, centered text
|
||||||
|
|
||||||
|
**Depends on** — · **Size** L · **Status** Done
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Rework the customizable HUD telemetry widgets (built in UI-04) from free-form fractional
|
||||||
|
positioning into an Android-home-screen-style snapped grid — drag and resize stick to
|
||||||
|
grid cells, a placement that would overlap another widget is rejected rather than
|
||||||
|
silently stacking — and make each widget's label/value text scale to fill its own
|
||||||
|
current size instead of a fixed font that can overflow when small or look lost when
|
||||||
|
large.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Direct user feedback (`docs/FEEDBACK.md`, "Map Page" section):
|
||||||
|
|
||||||
|
> Speed and other widgets should only appear when recording starts. When toggling off
|
||||||
|
> different statistics, the flow and arrangement of the widgets goes crazy. This should
|
||||||
|
> be "drag and drop" but they stick to a grid much like the home screen on android, and
|
||||||
|
> they can be resizable like android widgets too. Make sure the font and values are
|
||||||
|
> centered in the widgets as well, and scale to the size of the widget (no over or
|
||||||
|
> underflow).
|
||||||
|
|
||||||
|
(The "only appear when recording starts" half is FB-02, already scoped separately —
|
||||||
|
this ticket is the grid/drag/resize/text-fit half.)
|
||||||
|
|
||||||
|
### Today's model — free-form fractions, no grid at all
|
||||||
|
|
||||||
|
`lib/src/hud/hud_widget_layout.dart` (full file, 122 lines):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
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;
|
||||||
|
final double x; // top-left, fraction of the HUD area
|
||||||
|
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 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};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
visible: index < columns, // only the first 4 (Speed/Avg Speed/Dist/Time) start visible
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`HudMetric.values` order (`lib/src/hud/hud_metric.dart`, load-bearing — `defaultFor` uses
|
||||||
|
this indexing directly): `speed, avgSpeed, distance, elapsedTime, maxSpeed, movingTime,
|
||||||
|
elevationGain, pointsCaptured` (8 total; first 4 start visible).
|
||||||
|
|
||||||
|
`lib/src/hud/hud_layout_controller.dart` (full file, 52 lines) — the in-memory,
|
||||||
|
authoritative layout during an edit session, persisted to `Config` only on exit:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class HudLayoutController extends StateNotifier<Map<HudMetric, HudWidgetLayout>> {
|
||||||
|
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()};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why "toggling widgets goes crazy" happens today**: `Config.hudLayout`'s getter
|
||||||
|
(`lib/src/config/config.dart` ~lines 95-113) always returns an entry for *every*
|
||||||
|
`HudMetric`, falling back to `HudWidgetLayout.defaultFor` for any metric never
|
||||||
|
explicitly saved — so `setVisible`'s `state[metric] ?? defaultFor(metric)` fallback is
|
||||||
|
essentially dead code; every metric already has a stored layout the moment the
|
||||||
|
controller exists. That stored layout is whatever it was the last time that metric was
|
||||||
|
visible (or its untouched `defaultFor` position if it's never been shown/moved). Nothing
|
||||||
|
about `setVisible`, `defaultFor`, or `clamped()` is aware of what other widgets
|
||||||
|
currently occupy — `clamped()` only keeps a single widget's own rectangle inside the
|
||||||
|
0.0–1.0 area, it has no concept of a sibling. So: drag widget A to overlap where widget
|
||||||
|
B's (currently hidden) default position sits, then re-enable B in Settings — B pops up
|
||||||
|
directly on top of A. Resize A larger (up to `hudMaxWidthFraction = 0.70`) and it can
|
||||||
|
swallow B's or C's default slot the same way. This reads as "the whole layout goes
|
||||||
|
crazy" even though, strictly, no *other* widget's own stored position ever actually
|
||||||
|
changes — the illusion is caused by newly-visible widgets landing on top of
|
||||||
|
already-visible ones with zero collision awareness.
|
||||||
|
|
||||||
|
### Drag/resize gesture wiring (continuous, per-frame, into shared state)
|
||||||
|
|
||||||
|
`lib/src/ui/components/draggable_resizable_hud_widget.dart` (`_DraggableResizableHudWidgetState.build`,
|
||||||
|
~lines 54-132) computes pixel position/size from `widget.layout`'s fractions × `widget.areaSize`
|
||||||
|
every build, and the drag/resize gesture handlers call the parent's callbacks **on every
|
||||||
|
frame of movement**, not just at gesture end:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
onLongPressMoveUpdate: (details) {
|
||||||
|
widget.onMoved(
|
||||||
|
layout.x + details.offsetFromOrigin.dx / widget.areaSize.width,
|
||||||
|
layout.y + details.offsetFromOrigin.dy / widget.areaSize.height,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
...
|
||||||
|
onPanUpdate: (details) { // the resize handle
|
||||||
|
widget.onResized(
|
||||||
|
layout.width + details.delta.dx / widget.areaSize.width,
|
||||||
|
layout.height + details.delta.dy / widget.areaSize.height,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
`onMoved`/`onResized` are wired straight to `HudLayoutController.updatePosition`/
|
||||||
|
`updateSize` in `lib/src/ui/components/hud_edit_overlay.dart` (~lines 62-69):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
So every pixel of drag movement recomputes and commits a new fraction to the shared
|
||||||
|
`StateNotifier` (though `persist()` to `SharedPreferences` still only happens on exit —
|
||||||
|
that part is fine and unchanged). This continuous-commit model does not translate
|
||||||
|
directly to a grid: an integer `col`/`row` can't represent "40% of the way toward the
|
||||||
|
next cell," so this ticket changes drag/resize to track a **local, uncommitted pixel
|
||||||
|
offset** during the gesture and only calls the parent callback once, with final snapped
|
||||||
|
grid coordinates, at gesture end. See Design below.
|
||||||
|
|
||||||
|
### Fixed-size text, no auto-fit
|
||||||
|
|
||||||
|
`lib/src/ui/record/record_screen.dart`, `_HudMetricValue.build()` (~lines 321-346):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Font sizes are hardcoded regardless of the widget's actual current size — at the
|
||||||
|
smallest allowed size this can ellipsize; at the largest allowed size the same small
|
||||||
|
text looks lost in a big glass panel. `DraggableResizableHudWidget` just centers
|
||||||
|
whatever child it's given (`GlassPanel(child: Center(child: widget.child))`,
|
||||||
|
~line 64) — no text-scaling logic exists anywhere in this chain.
|
||||||
|
|
||||||
|
### Persistence — no migration needed
|
||||||
|
|
||||||
|
`HudWidgetLayout.fromJson`'s existing `try { ... } catch (_) { return
|
||||||
|
HudWidgetLayout.defaultFor(metric); }` already handles a field-shape change for free: an
|
||||||
|
old saved JSON blob has `x`/`y`/`width`/`height` keys (doubles); this ticket's new
|
||||||
|
`fromJson` reads `col`/`row`/`colSpan`/`rowSpan` (ints) and will throw on old data
|
||||||
|
(missing key, or a cast failure), falling back to `defaultFor` automatically — no schema
|
||||||
|
version, no explicit migration code. Confirmed reasonable: this is local
|
||||||
|
`SharedPreferences`, not a shared/synced format, and there's no production data to
|
||||||
|
preserve.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Grid model
|
||||||
|
Fixed **4 columns × 8 rows** spanning the HUD area (matches the current 4-column
|
||||||
|
default-row assumption and gives ample vertical room for the 8-metric list). Replace
|
||||||
|
`HudWidgetLayout`'s `x`/`y`/`width`/`height` doubles with:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
const int hudGridColumns = 4;
|
||||||
|
const int hudGridRows = 8;
|
||||||
|
const int hudMinSpan = 1;
|
||||||
|
const int hudMaxColSpan = 4;
|
||||||
|
const int hudMaxRowSpan = 3;
|
||||||
|
|
||||||
|
class HudWidgetLayout {
|
||||||
|
const HudWidgetLayout({
|
||||||
|
required this.metric,
|
||||||
|
required this.col,
|
||||||
|
required this.row,
|
||||||
|
required this.colSpan,
|
||||||
|
required this.rowSpan,
|
||||||
|
required this.visible,
|
||||||
|
});
|
||||||
|
|
||||||
|
final HudMetric metric;
|
||||||
|
final int col;
|
||||||
|
final int row;
|
||||||
|
final int colSpan;
|
||||||
|
final int rowSpan;
|
||||||
|
final bool visible;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`clampedToGrid()`** replaces `clamped()` — a *self-contained* bounds check with no
|
||||||
|
awareness of siblings (kept as its own method because `fromJson` and
|
||||||
|
`defaultFor`/`nextFreeSlot` all still need "is this rectangle even inside the grid"
|
||||||
|
independent of collision):
|
||||||
|
```dart
|
||||||
|
HudWidgetLayout clampedToGrid() {
|
||||||
|
final clampedColSpan = colSpan.clamp(hudMinSpan, hudMaxColSpan);
|
||||||
|
final clampedRowSpan = rowSpan.clamp(hudMinSpan, hudMaxRowSpan);
|
||||||
|
final clampedCol = col.clamp(0, hudGridColumns - clampedColSpan);
|
||||||
|
final clampedRow = row.clamp(0, hudGridRows - clampedRowSpan);
|
||||||
|
return HudWidgetLayout(metric: metric, col: clampedCol, row: clampedRow,
|
||||||
|
colSpan: clampedColSpan, rowSpan: clampedRowSpan, visible: visible);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Collision helper**, a static/top-level function usable by both the layout file and
|
||||||
|
the controller:
|
||||||
|
```dart
|
||||||
|
bool hudRectsOverlap(HudWidgetLayout a, HudWidgetLayout b) {
|
||||||
|
final aColEnd = a.col + a.colSpan;
|
||||||
|
final aRowEnd = a.row + a.rowSpan;
|
||||||
|
final bColEnd = b.col + b.colSpan;
|
||||||
|
final bRowEnd = b.row + b.rowSpan;
|
||||||
|
return a.col < bColEnd && aColEnd > b.col && a.row < bRowEnd && aRowEnd > b.row;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **`defaultFor`/`nextFreeSlot`**: keep `defaultFor(metric)` for the *static* initial
|
||||||
|
layout (unchanged conceptually — still purely a function of the metric's own index,
|
||||||
|
no runtime state needed, since the 8 fixed default slots below never overlap each
|
||||||
|
other by construction):
|
||||||
|
```dart
|
||||||
|
factory HudWidgetLayout.defaultFor(HudMetric metric) {
|
||||||
|
const columns = 4;
|
||||||
|
const rowSpan = 2;
|
||||||
|
final index = HudMetric.values.indexOf(metric);
|
||||||
|
final row = (index ~/ columns) * rowSpan;
|
||||||
|
final col = index % columns;
|
||||||
|
return HudWidgetLayout(
|
||||||
|
metric: metric, col: col, row: row, colSpan: 1, rowSpan: rowSpan,
|
||||||
|
visible: index < columns,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Speed/AvgSpeed/Distance/ElapsedTime → row 0, cols 0-3, visible; MaxSpeed/MovingTime/
|
||||||
|
ElevationGain/PointsCaptured → row 2, cols 0-3, hidden — 8 distinct, non-overlapping
|
||||||
|
slots, same invariant the existing `defaultFor` test already checks.)
|
||||||
|
|
||||||
|
Add a **new** function for the actual re-enable-without-collision fix:
|
||||||
|
```dart
|
||||||
|
/// Scans row-major from (0,0) for the first colSpan×rowSpan slot that doesn't
|
||||||
|
/// overlap any `visible` entry in [occupied]. Falls back to (0,0) unconditionally
|
||||||
|
/// if the grid is fully packed -- a stacked default is better than a crash or an
|
||||||
|
/// exception the rider can't do anything about.
|
||||||
|
static HudWidgetLayout nextFreeSlot(
|
||||||
|
HudMetric metric, {
|
||||||
|
required Map<HudMetric, HudWidgetLayout> occupied,
|
||||||
|
int colSpan = 1,
|
||||||
|
int rowSpan = 2,
|
||||||
|
}) {
|
||||||
|
final candidate = (int col, int row) => HudWidgetLayout(
|
||||||
|
metric: metric, col: col, row: row, colSpan: colSpan, rowSpan: rowSpan, visible: true,
|
||||||
|
);
|
||||||
|
for (var row = 0; row <= hudGridRows - rowSpan; row++) {
|
||||||
|
for (var col = 0; col <= hudGridColumns - colSpan; col++) {
|
||||||
|
final c = candidate(col, row);
|
||||||
|
final overlapsAny = occupied.values
|
||||||
|
.where((l) => l.visible && l.metric != metric)
|
||||||
|
.any((other) => hudRectsOverlap(c, other));
|
||||||
|
if (!overlapsAny) return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidate(0, 0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Controller — collision-aware position/size updates, free-slot re-enable
|
||||||
|
`lib/src/hud/hud_layout_controller.dart`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
void updatePosition(HudMetric metric, int col, int row) {
|
||||||
|
final current = state[metric];
|
||||||
|
if (current == null) return;
|
||||||
|
final candidate = current.copyWith(col: col, row: row).clampedToGrid();
|
||||||
|
if (_overlapsAnyOther(metric, candidate)) return; // reject: no-op, widget stays put
|
||||||
|
state = {...state, metric: candidate};
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateSize(HudMetric metric, int colSpan, int rowSpan) {
|
||||||
|
final current = state[metric];
|
||||||
|
if (current == null) return;
|
||||||
|
final candidate = current.copyWith(colSpan: colSpan, rowSpan: rowSpan).clampedToGrid();
|
||||||
|
if (_overlapsAnyOther(metric, candidate)) return;
|
||||||
|
state = {...state, metric: candidate};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _overlapsAnyOther(HudMetric metric, HudWidgetLayout candidate) => state.values
|
||||||
|
.where((l) => l.visible && l.metric != metric)
|
||||||
|
.any((other) => hudRectsOverlap(candidate, other));
|
||||||
|
|
||||||
|
void setVisible(HudMetric metric, bool visible) {
|
||||||
|
var current = state[metric] ?? HudWidgetLayout.defaultFor(metric);
|
||||||
|
if (visible && _overlapsAnyOther(metric, current.copyWith(visible: true))) {
|
||||||
|
// The metric's own saved/default slot is now occupied by something else (the
|
||||||
|
// "goes crazy" bug this ticket exists to fix) -- find a genuinely free one
|
||||||
|
// instead of popping up on top of whatever's there.
|
||||||
|
current = HudWidgetLayout.nextFreeSlot(
|
||||||
|
metric, occupied: state, colSpan: current.colSpan, rowSpan: current.rowSpan,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state = {...state, metric: current.copyWith(visible: visible)};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**"Reject and no-op" is the collision rule for drag/resize** — simplest correct
|
||||||
|
behavior, and it's the same operation as "revert to last valid" here since `state` is
|
||||||
|
never mutated until the candidate passes the check (there's nothing to revert *from*,
|
||||||
|
the old value was never overwritten). A drag/resize that would overlap another visible
|
||||||
|
widget simply has no effect for that frame/gesture; the widget stays at its last valid
|
||||||
|
position. `setVisible` gets the one exception — it actively finds a free slot rather
|
||||||
|
than rejecting, since "the metric just doesn't turn on" would be a much worse user
|
||||||
|
experience than briefly landing somewhere else on the grid.
|
||||||
|
|
||||||
|
### Drag/resize gesture — snap on gesture end, not live
|
||||||
|
`lib/src/ui/components/draggable_resizable_hud_widget.dart`: track a **local, transient
|
||||||
|
pixel offset** in `_DraggableResizableHudWidgetState` during the gesture (not committed
|
||||||
|
to the controller), paint the widget at `layout position (from grid) + local offset`
|
||||||
|
so the drag still feels smooth and continuous under the finger — then on
|
||||||
|
`onLongPressEnd`/`onPanEnd`, convert the final raw pixel position/size to the nearest
|
||||||
|
grid cell and call `widget.onMoved`/`onResized` exactly once with the snapped integer
|
||||||
|
coordinates, then reset the local offset to zero.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidget> {
|
||||||
|
bool _grabbed = false;
|
||||||
|
Offset _dragOffset = Offset.zero; // pixels, uncommitted, live during a move gesture
|
||||||
|
Size _resizeDelta = Size.zero; // pixels, uncommitted, live during a resize gesture
|
||||||
|
|
||||||
|
double get _cellWidth => widget.areaSize.width / hudGridColumns;
|
||||||
|
double get _cellHeight => widget.areaSize.height / hudGridRows;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final layout = widget.layout;
|
||||||
|
final left = layout.col * _cellWidth + _dragOffset.dx;
|
||||||
|
final top = layout.row * _cellHeight + _dragOffset.dy;
|
||||||
|
final width = layout.colSpan * _cellWidth + _resizeDelta.width;
|
||||||
|
final height = layout.rowSpan * _cellHeight + _resizeDelta.height;
|
||||||
|
...
|
||||||
|
onLongPressMoveUpdate: (details) => setState(() => _dragOffset = details.offsetFromOrigin),
|
||||||
|
onLongPressEnd: (_) {
|
||||||
|
final snappedCol = ((layout.col * _cellWidth + _dragOffset.dx) / _cellWidth).round();
|
||||||
|
final snappedRow = ((layout.row * _cellHeight + _dragOffset.dy) / _cellHeight).round();
|
||||||
|
widget.onMoved(snappedCol, snappedRow);
|
||||||
|
setState(() { _grabbed = false; _dragOffset = Offset.zero; });
|
||||||
|
},
|
||||||
|
onLongPressCancel: () => setState(() { _grabbed = false; _dragOffset = Offset.zero; }),
|
||||||
|
...
|
||||||
|
// resize handle:
|
||||||
|
onPanUpdate: (details) => setState(() =>
|
||||||
|
_resizeDelta = Size(_resizeDelta.width + details.delta.dx, _resizeDelta.height + details.delta.dy)),
|
||||||
|
onPanEnd: (_) {
|
||||||
|
final snappedColSpan = ((layout.colSpan * _cellWidth + _resizeDelta.width) / _cellWidth).round();
|
||||||
|
final snappedRowSpan = ((layout.rowSpan * _cellHeight + _resizeDelta.height) / _cellHeight).round();
|
||||||
|
widget.onResized(snappedColSpan, snappedRowSpan);
|
||||||
|
setState(() => _resizeDelta = Size.zero);
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
If the parent rejects the move/resize (collision), the widget simply rebuilds at its
|
||||||
|
unchanged `layout` — since `_dragOffset`/`_resizeDelta` are reset to zero right after
|
||||||
|
calling `onMoved`/`onResized` regardless of whether the controller accepted it, the
|
||||||
|
widget visually snaps back to wherever it actually ended up (its last accepted grid
|
||||||
|
cell) the instant the gesture ends. No separate "was it accepted?" callback needed.
|
||||||
|
|
||||||
|
`widget.onMoved`/`onResized` function signatures change to `void Function(int col, int
|
||||||
|
row)` / `void Function(int colSpan, int rowSpan)`.
|
||||||
|
|
||||||
|
`lib/src/ui/components/hud_edit_overlay.dart`: update the two callback wire-ups
|
||||||
|
(`onMoved: (col, row) => notifier.updatePosition(entry.key, col, row)`, `onResized:
|
||||||
|
(colSpan, rowSpan) => notifier.updateSize(entry.key, colSpan, rowSpan)`) — no other
|
||||||
|
change needed in this file; `areaSize` is still the raw pixel `Size` from its own
|
||||||
|
`LayoutBuilder`, cell-size division now happens inside
|
||||||
|
`DraggableResizableHudWidget` as shown above.
|
||||||
|
|
||||||
|
### Text auto-fit — `FittedBox`, not a computed font size
|
||||||
|
`lib/src/ui/record/record_screen.dart`, `_HudMetricValue.build()`: wrap the existing
|
||||||
|
label+value `Column` in `FittedBox(fit: BoxFit.scaleDown)`, drop `maxLines`/
|
||||||
|
`TextOverflow.ellipsis` on both `Text`s (structurally impossible to overflow once
|
||||||
|
`FittedBox` owns sizing), and keep the existing `fontSize: 10`/`18` values as the
|
||||||
|
"reference" size `FittedBox` scales down from — the ratio between label and value size
|
||||||
|
is preserved automatically as it scales:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
return FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
metric.label.toUpperCase(),
|
||||||
|
style: TextStyle(fontSize: 10, letterSpacing: 1, color: colors.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_value,
|
||||||
|
style: monoDigits.copyWith(fontSize: 18, fontWeight: FontWeight.bold, color: _valueColor(colors)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`FittedBox` only scales *down* (`BoxFit.scaleDown` never enlarges past the reference
|
||||||
|
size) — so a widget resized to the grid's maximum span (4 cols × 3 rows) shows text at
|
||||||
|
its natural 10/18px reference size, not blown up to fill the space. That's an
|
||||||
|
intentional, reasonable simplification for this ticket (matching "no overflow" exactly
|
||||||
|
as asked; "looks proportionally larger in a bigger widget" is a nice-to-have, not named
|
||||||
|
in the feedback) — call this trade-off out plainly in the Outcome section rather than
|
||||||
|
silently under-delivering on it.
|
||||||
|
|
||||||
|
`DraggableResizableHudWidget`'s own centering (`GlassPanel(child: Center(child:
|
||||||
|
widget.child))`) already satisfies "centered" — no change needed there.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
1. Rewrite `lib/src/hud/hud_widget_layout.dart`: grid constants, `col`/`row`/`colSpan`/
|
||||||
|
`rowSpan` fields, `clampedToGrid()`, `hudRectsOverlap()`, updated `toJson`/
|
||||||
|
`fromJson`, updated `defaultFor`, new `nextFreeSlot`.
|
||||||
|
2. Rewrite `lib/src/hud/hud_layout_controller.dart`: `updatePosition`/`updateSize` take
|
||||||
|
ints and reject-on-collision; `setVisible` uses `nextFreeSlot` when the metric's
|
||||||
|
current slot collides.
|
||||||
|
3. Rewrite `DraggableResizableHudWidget`: local pixel-offset drag state, snap-on-end,
|
||||||
|
updated callback signatures, pixel math from `areaSize / hudGridColumns` /
|
||||||
|
`hudGridRows`.
|
||||||
|
4. Update `HudEditOverlay`'s two callback closures to the new int signatures.
|
||||||
|
5. Wrap `_HudMetricValue`'s `Column` in `FittedBox(fit: BoxFit.scaleDown)`, drop
|
||||||
|
`maxLines`/`overflow` on its two `Text`s.
|
||||||
|
6. `flutter analyze`, fix any resulting type errors elsewhere that referenced the old
|
||||||
|
`x`/`y`/`width`/`height` fields (grep for `.x`/`.y`/`.width`/`.height` on
|
||||||
|
`HudWidgetLayout` instances across `lib/` and `test/` to be thorough).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] Dragging a HUD widget in edit mode snaps to a grid cell on release; the widget
|
||||||
|
tracks the finger smoothly during the drag itself (no per-frame jump/snap while
|
||||||
|
still moving).
|
||||||
|
- [ ] Resizing via the handle snaps to whole grid cells on release, same smooth-during-
|
||||||
|
drag behavior.
|
||||||
|
- [ ] Dragging or resizing a widget onto a cell already occupied by another *visible*
|
||||||
|
widget rejects the move — the widget returns to its last valid position/size,
|
||||||
|
no crash, no silent overlap.
|
||||||
|
- [ ] Re-enabling a hidden metric whose saved/default slot is now occupied by another
|
||||||
|
visible widget places it in the next free grid cell instead of stacking on top of
|
||||||
|
the occupier — this is the concrete fix for the "goes crazy" feedback.
|
||||||
|
- [ ] Re-enabling a hidden metric whose slot is still free keeps its exact prior
|
||||||
|
position (unchanged from today's guarantee, still worth re-verifying under the
|
||||||
|
new model).
|
||||||
|
- [ ] HUD widget text (label + value) never overflows or gets ellipsized at any allowed
|
||||||
|
grid size, and stays visually centered within its widget, at both the smallest
|
||||||
|
(`1×1`) and largest (`hudMaxColSpan × hudMaxRowSpan`) allowed sizes.
|
||||||
|
- [ ] An old-shaped saved layout (pre-this-ticket JSON with `x`/`y`/`width`/`height`
|
||||||
|
keys) loads without crashing — falls back to `defaultFor` per metric, exactly as
|
||||||
|
`fromJson`'s existing try/catch already guarantees.
|
||||||
|
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
`test/hud_widget_layout_test.dart` needs a full rewrite for the new field names/types —
|
||||||
|
keep the same test *intents*, translated to grid coordinates:
|
||||||
|
- JSON round trip (encode/decode a `col`/`row`/`colSpan`/`rowSpan`/`visible` layout
|
||||||
|
exactly).
|
||||||
|
- A malformed/missing-field JSON entry falls back to `defaultFor` (unchanged intent,
|
||||||
|
new field names in the malformed input).
|
||||||
|
- `defaultFor`: every metric gets a distinct (non-overlapping, via `hudRectsOverlap`)
|
||||||
|
default rect; the first four metrics start visible, the rest hidden (unchanged
|
||||||
|
intent).
|
||||||
|
- `clampedToGrid`: a position/span pushed outside `[0, hudGridColumns)`/
|
||||||
|
`[0, hudGridRows)` or `[hudMinSpan, hudMaxColSpan/RowSpan]` is corrected back inside
|
||||||
|
(parallel structure to the old `clamped` tests: past the right/bottom edge, past the
|
||||||
|
left/top edge, below the legibility floor, above the ceiling, shrink-before-reposition,
|
||||||
|
already-valid-is-unchanged).
|
||||||
|
- New: `hudRectsOverlap` — two identical rects overlap; two rects sharing only an edge
|
||||||
|
(touching, not overlapping) do not; two disjoint rects don't; a rect fully containing
|
||||||
|
another does.
|
||||||
|
- New: `nextFreeSlot` — given a set of `occupied` visible layouts that collide with a
|
||||||
|
metric's own `defaultFor` position, returns some other rect that doesn't collide with
|
||||||
|
any of them; given an empty/all-hidden `occupied` map, returns the same rect
|
||||||
|
`defaultFor` would have (or at least a valid non-colliding one at `(0,0)`).
|
||||||
|
|
||||||
|
`test/hud_layout_controller_test.dart` needs a parallel rewrite for the new int
|
||||||
|
signatures and the new collision behavior:
|
||||||
|
- `updatePosition`/`updateSize` update only the given metric, still true.
|
||||||
|
- New: `updatePosition` to a cell already occupied by another visible metric is a
|
||||||
|
no-op (state for the target metric is unchanged).
|
||||||
|
- New: `updateSize` that would make a widget overlap a sibling is a no-op.
|
||||||
|
- `setVisible(false)` then `setVisible(true)` restores the last position **when that
|
||||||
|
position is still free** (keep this test, using coordinates guaranteed not to
|
||||||
|
collide with anything else default-visible).
|
||||||
|
- New: `setVisible(true)` when the metric's last position now collides with another
|
||||||
|
visible widget places it somewhere else instead (assert the resulting `state[metric]`
|
||||||
|
doesn't overlap anything, not a specific coordinate).
|
||||||
|
- `persist`/pre-persist-not-visible-to-Config tests carry over unchanged in spirit
|
||||||
|
(just using int fields).
|
||||||
|
|
||||||
|
Widget-level: extend or add to `test/hud_edit_overlay_test.dart` — a drag gesture ending
|
||||||
|
over an occupied cell leaves the dragged widget's rendered position unchanged from
|
||||||
|
before the gesture; a resize past the point of colliding with a sibling leaves the
|
||||||
|
widget at its pre-gesture size. Also add a widget test putting `_HudMetricValue` (or a
|
||||||
|
representative HUD child) inside a very small `DraggableResizableHudWidget` (1×1 grid
|
||||||
|
cell) and a very large one (`hudMaxColSpan`×`hudMaxRowSpan`) and asserting no
|
||||||
|
`RenderFlex overflowed` exception/error is recorded by `FlutterError.onError` during
|
||||||
|
the pump (the standard way to assert "no overflow" in a widget test — check
|
||||||
|
`test/widget_test.dart`/other existing tests in this repo for the established pattern,
|
||||||
|
if any, otherwise use `tester.takeException()` after pumping and assert it's `null`).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- `FittedBox(fit: BoxFit.scaleDown)` never enlarges text past its reference size, so a
|
||||||
|
widget resized to the grid's maximum span shows the same 10/18px reference text as a
|
||||||
|
1×1 widget, just with more empty space around it — not larger text filling the space.
|
||||||
|
This satisfies "no overflow" and "centered" exactly as asked; "text should grow to
|
||||||
|
fill a bigger widget" is not explicitly requested and is treated as future work, not a
|
||||||
|
silent gap — document this trade-off in the Outcome section.
|
||||||
|
- Existing saved HUD layouts (from anyone who ran a build before this ticket) reset to
|
||||||
|
defaults the first time they're loaded post-update, per the fromJson fallback. No
|
||||||
|
user-facing warning is added for this — acceptable given there's no real user base
|
||||||
|
yet; flag it anyway in Outcome for completeness.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
FB-02 (hiding the idle Speed panel — separate ticket, this one only touches the
|
||||||
|
recording-state HUD widgets themselves). Making widget text grow to actually fill a
|
||||||
|
larger grid span (see Risks). Any change to which metrics exist or their default
|
||||||
|
visibility set — `HudMetric`'s own enum and `label`s are untouched.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Implemented exactly per the Design section above, no deviations from the approach:
|
||||||
|
|
||||||
|
- `lib/src/hud/hud_widget_layout.dart` rewritten with the 4x8 int grid
|
||||||
|
(`hudGridColumns`/`hudGridRows`), `col`/`row`/`colSpan`/`rowSpan` fields,
|
||||||
|
`clampedToGrid()`, top-level `hudRectsOverlap()`, updated `toJson`/`fromJson` (now
|
||||||
|
reading `col`/`row`/`colSpan`/`rowSpan` — an old `x`/`y`/`width`/`height` blob throws
|
||||||
|
in the try/catch and falls back to `defaultFor`, no migration code needed, as
|
||||||
|
predicted), an unchanged-in-spirit `defaultFor`, and the new `nextFreeSlot` scan.
|
||||||
|
- `lib/src/hud/hud_layout_controller.dart` rewritten: `updatePosition`/`updateSize`
|
||||||
|
take ints, clamp, and reject-on-collision (no-op, no revert needed since state is
|
||||||
|
never mutated until the candidate passes); `setVisible` calls `nextFreeSlot` only
|
||||||
|
when the metric's own slot now collides with another visible widget.
|
||||||
|
- `lib/src/ui/components/draggable_resizable_hud_widget.dart` rewritten: local
|
||||||
|
`_dragOffset`/`_resizeDelta` pixel state tracks the live gesture smoothly; snapping
|
||||||
|
to the nearest grid cell and calling `onMoved`/`onResized` exactly once happens on
|
||||||
|
`onLongPressEnd`/`onPanEnd`. Both offsets reset to zero right after that call
|
||||||
|
regardless of whether the controller accepted the move, which is what makes a
|
||||||
|
rejected move/resize visually snap back with no separate accept/reject callback.
|
||||||
|
Callback signatures changed to `void Function(int, int)`.
|
||||||
|
- `lib/src/ui/components/hud_edit_overlay.dart`: the two callback closures updated to
|
||||||
|
the new int signatures; no other change needed.
|
||||||
|
- `lib/src/ui/record/record_screen.dart`'s `_HudMetricValue.build()`: wrapped in
|
||||||
|
`FittedBox(fit: BoxFit.scaleDown)`, `maxLines`/`overflow` dropped from both `Text`s.
|
||||||
|
|
||||||
|
**Confirmed trade-off** (the one the ticket flagged in advance): `BoxFit.scaleDown`
|
||||||
|
never enlarges past the reference size, so a widget resized to `hudMaxColSpan` x
|
||||||
|
`hudMaxRowSpan` renders the same 10px/18px text as a 1x1 widget — just with more empty
|
||||||
|
space around it, still centered via `DraggableResizableHudWidget`'s existing
|
||||||
|
`Center`. Verified directly with the new "largest allowed size" widget test (a 4x3
|
||||||
|
`DraggableResizableHudWidget` at 400x400 pixels records no overflow exception, same as
|
||||||
|
the 1x1 case) — text-scaling-with-widget-size remains explicit future work, not a
|
||||||
|
silent gap.
|
||||||
|
|
||||||
|
**Confirmed migration behavior**: an old-shaped saved layout (pre-this-ticket JSON with
|
||||||
|
`x`/`y`/`width`/`height` keys) loads without crashing and falls back to `defaultFor`
|
||||||
|
per metric — covered directly by a new `hud_widget_layout_test.dart` case and a new
|
||||||
|
`config_test.dart` case using literal old-shaped JSON. No user-facing warning was
|
||||||
|
added, per the ticket's own Risks section (no real user base yet to protect).
|
||||||
|
|
||||||
|
**Tests**: `test/hud_widget_layout_test.dart` and `test/hud_layout_controller_test.dart`
|
||||||
|
were fully rewritten for the int grid fields, per the ticket's Tests section (JSON
|
||||||
|
round trip, malformed/old-shaped-JSON fallback, `defaultFor` non-overlap and
|
||||||
|
visibility, `clampedToGrid` edge/floor/ceiling/shrink-before-reposition cases,
|
||||||
|
`hudRectsOverlap` touching/disjoint/containing/partial cases, `nextFreeSlot` colliding
|
||||||
|
and empty/hidden-occupied cases, controller collision-rejection for both
|
||||||
|
`updatePosition` and `updateSize`, and `setVisible`'s free-slot fallback). Also updated:
|
||||||
|
`test/config_test.dart` (its `HudWidgetLayout` literal and JSON fixtures used the old
|
||||||
|
field names — updated to the grid fields, plus one new case for old-shaped JSON) and
|
||||||
|
`test/hud_edit_overlay_test.dart` (its `.x`/`.y` assertions became `.col`/`.row`; added
|
||||||
|
the two ticket-requested widget tests for a collision-rejected drag and a
|
||||||
|
collision-rejected resize, plus two more building `DraggableResizableHudWidget`
|
||||||
|
directly at `1x1` and `hudMaxColSpan x hudMaxRowSpan` to assert `tester.takeException()`
|
||||||
|
is `null` — the established no-overflow pattern already used in
|
||||||
|
`settings_screen_test.dart`).
|
||||||
|
|
||||||
|
Final count: **392 tests passing** (up from the 374 baseline), `flutter analyze` clean.
|
||||||
274
docs/feedback/FB-04-route-planner-map-render-fix.md
Normal file
274
docs/feedback/FB-04-route-planner-map-render-fix.md
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
# FB-04 — Fix Route Planner's map failing to render + match street-level zoom
|
||||||
|
|
||||||
|
**Depends on** FB-01 (reuses its `ambientZoom` constant) · **Size** M · **Status** Done
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
The Route Planner screen's map doesn't render at all for the user. Find the real cause
|
||||||
|
and fix it, and bring this screen's zoom in line with the rest of the app's new
|
||||||
|
street-level default (FB-01).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Direct user feedback (`docs/FEEDBACK.md`, "Route" section):
|
||||||
|
|
||||||
|
> This doesn't work at all, the map doesn't render at all... I want it to be the same
|
||||||
|
> zoomed in map view, then the user can zoom out and create the route.
|
||||||
|
|
||||||
|
### High-confidence root cause
|
||||||
|
|
||||||
|
`lib/src/ui/routes/route_planner_screen.dart`, `build()` (~lines 112-139):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final route = ref.watch(routePlanProvider(widget.routeId)).valueOrNull;
|
||||||
|
final waypointsAsync = ref.watch(routeWaypointsProvider(widget.routeId));
|
||||||
|
final waypoints = waypointsAsync.valueOrNull ?? const [];
|
||||||
|
final units = ref.watch(unitSystemProvider);
|
||||||
|
final repo = ref.read(routePlanRepositoryProvider);
|
||||||
|
|
||||||
|
if (route == null) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'This route no longer exists.',
|
||||||
|
style: TextStyle(color: colors.outline),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
body: SafeArea(
|
||||||
|
child: !waypointsAsync.hasValue
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: FlutterMap(
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `route` and `waypointsAsync` come from the same kind of provider —
|
||||||
|
`routePlanProvider`/`routeWaypointsProvider` are both `StreamProvider.autoDispose.family`
|
||||||
|
(`lib/src/app/providers.dart` ~lines 198-203) — so both start in `AsyncLoading` on a
|
||||||
|
fresh navigation (e.g. tapping "+ New route" on `RoutesListScreen`, which calls
|
||||||
|
`repo.createRoutePlan` then immediately `onOpenRoute?.call(id)` — a brand-new
|
||||||
|
`routePlanProvider(id)` family instance with a DB stream that hasn't delivered its
|
||||||
|
first row yet). **`waypointsAsync` is correctly guarded** — `!waypointsAsync.hasValue`
|
||||||
|
shows a spinner instead of building `FlutterMap` prematurely, with a comment explaining
|
||||||
|
exactly why (`initialCenter`/`initialZoom` are read once at construction; building too
|
||||||
|
early freezes the camera at null-island forever). **`route` has no equivalent guard** —
|
||||||
|
`.valueOrNull` collapses "still loading" and "genuinely doesn't exist" into the same
|
||||||
|
`null`, so on that same cold navigation, this screen renders the **text-only "This route
|
||||||
|
no longer exists." fallback with zero `FlutterMap` in the tree** — for a route that is
|
||||||
|
completely valid, simply because its stream's first emission hasn't arrived yet. This is
|
||||||
|
a real, reproducible-on-a-slow-cold-start bug, and it would read to a user as exactly
|
||||||
|
"the map doesn't render at all" — because for however long that loading window lasts,
|
||||||
|
there is no map, no pins, nothing to interact with.
|
||||||
|
|
||||||
|
This is the same shape of bug UI-06's own documented Outcome already found once in this
|
||||||
|
same file (a hardcoded, stale tile URL in `_DownloadDialog`): a place where near-
|
||||||
|
identical logic exists twice nearby and only one copy got the correct guard.
|
||||||
|
|
||||||
|
### Zoom special-cases to replace
|
||||||
|
|
||||||
|
Same `build()` method, ~line 157:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
initialZoom: waypoints.length <= 1 ? 14 : maxTileZoom - 3,
|
||||||
|
```
|
||||||
|
|
||||||
|
`initialZoom` is only ever used by flutter_map when `initialCameraFit` is null.
|
||||||
|
`initialCameraFit: _initialFit(waypoints)` (line ~153) is non-null for 2+ waypoints
|
||||||
|
(see `_initialFit`, ~lines 92-102, which returns `null` only for `waypoints.length < 2`
|
||||||
|
or degenerate bounds) — so `maxTileZoom - 3` is **dead, misleading code today**: it can
|
||||||
|
only ever apply when `_initialFit` already returned null, which for 2+ waypoints only
|
||||||
|
happens on a degenerate (single-point) bounds, in which case `waypoints.length <= 1` is
|
||||||
|
false but the fit is still null — meaning `maxTileZoom - 3` actually *does* fire for
|
||||||
|
that one edge case (all waypoints at the same spot). Still, `14` for 0-1 waypoints is
|
||||||
|
the literal reason a fresh, empty route (or one with a single pin) opens zoomed out
|
||||||
|
far past street level, exactly matching the "same zoomed in map view" ask.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
- **Fix the render bug**: change `route` from `.valueOrNull` to keeping the full
|
||||||
|
`AsyncValue<RoutePlan?>`, and add a `hasValue` guard mirroring `waypointsAsync`'s
|
||||||
|
existing pattern:
|
||||||
|
```dart
|
||||||
|
final routeAsync = ref.watch(routePlanProvider(widget.routeId));
|
||||||
|
final waypointsAsync = ref.watch(routeWaypointsProvider(widget.routeId));
|
||||||
|
final waypoints = waypointsAsync.valueOrNull ?? const [];
|
||||||
|
final units = ref.watch(unitSystemProvider);
|
||||||
|
final repo = ref.read(routePlanRepositoryProvider);
|
||||||
|
|
||||||
|
if (!routeAsync.hasValue) {
|
||||||
|
return const Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final route = routeAsync.value;
|
||||||
|
if (route == null) {
|
||||||
|
return Scaffold( /* unchanged "This route no longer exists." body */ );
|
||||||
|
}
|
||||||
|
```
|
||||||
|
This exactly mirrors the two-step pattern `waypointsAsync` already uses two lines
|
||||||
|
below it in the same method, just applied consistently to both providers.
|
||||||
|
- **Zoom fix**: replace the `initialZoom` line with FB-01's shared constant. FB-01 adds
|
||||||
|
`const double ambientZoom = 17.0;` to `lib/src/ui/components/ride_map.dart` — this
|
||||||
|
screen already imports named constants from that file (`show TileAttribution,
|
||||||
|
maxTileZoom, tileMaxNativeZoom, tileSubdomains, tileUrlTemplate, tileUserAgent`, near
|
||||||
|
the top of the file) — add `ambientZoom` to that same `show` clause and use it:
|
||||||
|
```dart
|
||||||
|
initialZoom: waypoints.length <= 1 ? ambientZoom : maxTileZoom - 3,
|
||||||
|
```
|
||||||
|
**If FB-01 has not landed yet when this ticket is implemented**, add the same
|
||||||
|
constant directly to `ride_map.dart` yourself first (`const double ambientZoom =
|
||||||
|
17.0;` near `maxTileZoom`/`shortRideZoom`) so this ticket doesn't block on ordering —
|
||||||
|
whichever of FB-01/FB-04 lands second will find the constant already defined and
|
||||||
|
should just reuse it rather than redefining it (grep for `ambientZoom` before adding
|
||||||
|
it, to avoid a duplicate-constant compile error).
|
||||||
|
- **Investigation order, if the above turns out not to be the whole story** (do this
|
||||||
|
first, before assuming the fix above is sufficient — reproduce, then fix):
|
||||||
|
1. `flutter run` on the Android emulator (or a device), tap "+ New route" from
|
||||||
|
`RoutesListScreen` (`lib/src/ui/routes/routes_list_screen.dart`, `Key('new-route')`
|
||||||
|
button), watch closely for a flash of "This route no longer exists." text or a
|
||||||
|
fully blank screen right after navigation.
|
||||||
|
2. If reproduced and the `hasValue` fix above resolves it, done — write up the root
|
||||||
|
cause and fix in the Outcome section.
|
||||||
|
3. If it's *not* resolved (map still doesn't render even with the `hasValue` guard in
|
||||||
|
place), add a temporary `debugPrint('route=${routeAsync.runtimeType} '
|
||||||
|
'waypoints=${waypointsAsync.runtimeType}')` at the top of `build()` and watch the
|
||||||
|
console across a fresh navigation to see the actual state sequence — remove it
|
||||||
|
before committing.
|
||||||
|
4. Check whether the map mounts (i.e. `FlutterMap` is in the tree, visible in the
|
||||||
|
Flutter inspector / a screenshot) but tiles themselves are blank — if so, look at
|
||||||
|
`cachedTileProviderProvider` (`lib/src/app/providers.dart` ~lines 234-238): it
|
||||||
|
returns `null` while `tileCacheProvider`'s underlying `FutureProvider` hasn't
|
||||||
|
resolved yet, and `TileLayer(tileProvider: null)` falls back to flutter_map's own
|
||||||
|
default network fetcher — verify that fallback actually fetches (it should; if it
|
||||||
|
silently doesn't, that's the bug).
|
||||||
|
5. Diff this screen's tile setup token-for-token against `RideMap`'s
|
||||||
|
(`tileUrlTemplate`, `tileSubdomains`, `tileMaxNativeZoom`, `tileUserAgent` — all
|
||||||
|
already imported from the same `ride_map.dart` export, so a stale-URL-style
|
||||||
|
regression is unlikely, but `grep -n "tile.openstreetmap\|cartocdn"` across this
|
||||||
|
file to be certain nothing reintroduced UI-06's already-fixed bug).
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
1. Change `route` to `routeAsync` (full `AsyncValue`), add the `hasValue` guard, keep
|
||||||
|
the rest of the "no longer exists" branch's body unchanged.
|
||||||
|
2. Add `ambientZoom` to this file's `ride_map.dart` import `show` clause (adding the
|
||||||
|
constant to `ride_map.dart` first if FB-01 hasn't landed yet — see Design).
|
||||||
|
3. Replace `waypoints.length <= 1 ? 14 : maxTileZoom - 3` with
|
||||||
|
`waypoints.length <= 1 ? ambientZoom : maxTileZoom - 3`.
|
||||||
|
4. Reproduce on an emulator per the Investigation order above; if the root cause is
|
||||||
|
something other than the `hasValue` gap, document the real cause and its fix in the
|
||||||
|
Outcome section instead of (or in addition to) the above.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] Opening a brand-new route ("+ New route" from `RoutesListScreen`) always shows a
|
||||||
|
loading spinner briefly (if the stream hasn't emitted yet) and then the real map
|
||||||
|
canvas — never the "This route no longer exists." text for a route that
|
||||||
|
genuinely exists.
|
||||||
|
- [ ] Opening an existing, previously-created route with no waypoints shows the map
|
||||||
|
canvas at `ambientZoom` (street level), not zoom 14.
|
||||||
|
- [ ] A route that is actually deleted (or never existed — e.g. a stale/invalid id)
|
||||||
|
still correctly shows "This route no longer exists." — the fix must not weaken
|
||||||
|
this case, only stop it from firing on a merely-still-loading valid route.
|
||||||
|
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- Widget test: pump `RoutePlannerScreen` for a route id that has a pending (not-yet-
|
||||||
|
resolved) `routePlanProvider` stream — assert a `CircularProgressIndicator` shows,
|
||||||
|
never the "no longer exists" text, then once the stream emits the real row, assert
|
||||||
|
the map (`FlutterMap`) appears. (Check how existing tests in
|
||||||
|
`test/route_planner_screen_test.dart` seed/await the repository — likely via
|
||||||
|
`repo.createRoutePlan` then pumping a frame or two before the provider's stream
|
||||||
|
delivers, similar to the existing `pumpMap` helper in that file's own comments about
|
||||||
|
needing a handful of frames for the waypoints stream's first value.)
|
||||||
|
- Widget test: pump `RoutePlannerScreen` for an id that was never created (or was
|
||||||
|
deleted) — assert "This route no longer exists." still shows once the stream settles
|
||||||
|
(not immediately, if the provider briefly reports loading first).
|
||||||
|
- Widget test: a route with zero waypoints renders at `ambientZoom`, not `14` — read
|
||||||
|
the `FlutterMap`'s `options.initialZoom` via `tester.widget<FlutterMap>(...)` the same
|
||||||
|
way other zoom-related assertions in this test suite already inspect `MapOptions`
|
||||||
|
(check existing patterns in `test/ride_map_test.dart`/
|
||||||
|
`test/route_planner_screen_test.dart`).
|
||||||
|
- If the investigation reveals a different root cause than the `hasValue` gap, write a
|
||||||
|
regression test for the actual bug found, not just the hypothesis above.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- If the `hasValue` fix doesn't fully explain what the user saw, this ticket's
|
||||||
|
Outcome section must say so plainly and document whatever the actual root cause
|
||||||
|
turned out to be — don't claim a fix that wasn't verified to address the real
|
||||||
|
symptom. Reproducing on a real emulator/device before declaring this done is
|
||||||
|
important precisely because the original bug report is "doesn't work at all," a
|
||||||
|
strong signal, and a subtle timing bug is an easy thing to fix on paper without
|
||||||
|
confirming it was actually the cause.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
Closed-loop routes (FB-05, sequenced after this ticket since it also edits this file
|
||||||
|
heavily). Turn-by-turn route following (V3-09, already explicitly deferred by UI-06).
|
||||||
|
Any change to `RoutesListScreen` itself.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
The ticket's high-confidence root-cause hypothesis was confirmed exactly as written, no
|
||||||
|
surprises in investigation. `FB-01`'s `ambientZoom` constant was already present in
|
||||||
|
`lib/src/ui/components/ride_map.dart` (line 54) when this ticket started, so no
|
||||||
|
fallback definition was needed.
|
||||||
|
|
||||||
|
**Root cause**, confirmed by reproduction: `route` was read via
|
||||||
|
`ref.watch(routePlanProvider(widget.routeId)).valueOrNull`, which collapses "stream
|
||||||
|
still loading" and "route doesn't exist" into the same `null`. On a cold navigation to
|
||||||
|
a brand-new route (fresh `StreamProvider.autoDispose.family` instance, DB stream not
|
||||||
|
yet emitted), this made the screen render "This route no longer exists." with zero
|
||||||
|
`FlutterMap` in the tree for a route that was completely valid — exactly matching the
|
||||||
|
user's "doesn't work at all" report. `waypointsAsync` already had the correct
|
||||||
|
`hasValue` guard right next to it; `route` simply never got the same treatment.
|
||||||
|
|
||||||
|
Reproduced first via a widget test (no real emulator available/reliable in this
|
||||||
|
environment): `repo.createRoutePlan(...)` followed by `pumpWidget` with **no**
|
||||||
|
additional pump — asserting immediately after the very first frame — reliably showed
|
||||||
|
the "no longer exists" text and zero `FlutterMap` widgets, confirming the bug. The same
|
||||||
|
test, run again after applying the fix (and separately, reverted before the fix to
|
||||||
|
confirm it fails on the old code), passed once `routeAsync.hasValue` guards the
|
||||||
|
transition, matching the ticket's `Design`/`Implementation` sections precisely:
|
||||||
|
- `route` was changed to hold the full `AsyncValue<RoutePlan?>` (`routeAsync`), with a
|
||||||
|
new `if (!routeAsync.hasValue)` branch returning a spinner-only `Scaffold`, mirroring
|
||||||
|
`waypointsAsync`'s existing pattern two lines below.
|
||||||
|
- `route = routeAsync.value` is read only after that guard passes, and the existing
|
||||||
|
"no longer exists" branch (for a genuinely missing/deleted route) is otherwise
|
||||||
|
unchanged.
|
||||||
|
- `ambientZoom` was added to this file's `ride_map.dart` `show` clause, and
|
||||||
|
`initialZoom: waypoints.length <= 1 ? 14 : maxTileZoom - 3` became
|
||||||
|
`initialZoom: waypoints.length <= 1 ? ambientZoom : maxTileZoom - 3`.
|
||||||
|
|
||||||
|
No investigation steps 3-5 (debugPrint state tracing, checking
|
||||||
|
`cachedTileProviderProvider`'s null-tileProvider fallback, diffing tile URL setup
|
||||||
|
against `RideMap`) were needed — the primary hypothesis fully explained the reported
|
||||||
|
symptom on the first reproduction attempt, and step 5's `tileUrlTemplate`/
|
||||||
|
`tileSubdomains`/etc. are already imported from the same `ride_map.dart` export
|
||||||
|
`RideMap` uses, so no stale-URL-style regression was present.
|
||||||
|
|
||||||
|
Two new regression tests were added to `test/route_planner_screen_test.dart`:
|
||||||
|
1. A brand-new route's screen is pumped with no settling: asserts the loading spinner
|
||||||
|
shows and neither the "no longer exists" text nor `FlutterMap` appear on that first
|
||||||
|
frame, then asserts the real map appears once both streams resolve. Verified this
|
||||||
|
test fails against the pre-fix code (assertion on the spinner/text) and passes after
|
||||||
|
the fix.
|
||||||
|
2. A route with zero waypoints is asserted to open at `MapOptions.initialZoom ==
|
||||||
|
ambientZoom` (17.0), not the old hardcoded `14`. Verified this test fails against
|
||||||
|
the pre-fix code and passes after the fix.
|
||||||
|
The existing "a missing route says so instead of a blank map" test (using
|
||||||
|
`pumpAndSettle`) continues to pass unchanged, confirming the fix doesn't weaken the
|
||||||
|
genuinely-deleted/never-existed case.
|
||||||
|
|
||||||
|
**Final state**: `flutter analyze` clean (4 pre-existing `info`-level issues in
|
||||||
|
unrelated files — `crash_reporter.dart`, `map_connectivity.dart` — present on `main`
|
||||||
|
before this ticket, untouched by this change). `flutter test`: **404 passing** (402
|
||||||
|
baseline + 2 new regression tests), zero failures, zero regressions.
|
||||||
452
docs/feedback/FB-05-closed-loop-routes.md
Normal file
452
docs/feedback/FB-05-closed-loop-routes.md
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
# FB-05 — Closed-loop routes in Route Planner
|
||||||
|
|
||||||
|
**Depends on** FB-04 (same file, heavily edited — sequence after it merges) · **Size** M · **Status** Done
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Route planning currently only supports a one-way A→B→C path. Add the ability to close
|
||||||
|
the route into a loop that returns to its starting pin, updating the drawn polyline and
|
||||||
|
the distance stat to include the closing segment.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Direct user feedback (`docs/FEEDBACK.md`, "Route" section):
|
||||||
|
|
||||||
|
> ...there is no way to create a loop with the pins, it only creates a unidirectional
|
||||||
|
> path.
|
||||||
|
|
||||||
|
### Data model — no loop concept exists today
|
||||||
|
|
||||||
|
`lib/src/domain/models.dart`, `RoutePlan` (~lines 229-276):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class RoutePlan {
|
||||||
|
const RoutePlan({
|
||||||
|
this.id = 0,
|
||||||
|
required this.name,
|
||||||
|
required this.createdAt,
|
||||||
|
this.activity = Activity.motorcycle,
|
||||||
|
this.distanceM = 0.0,
|
||||||
|
this.estimatedMillis,
|
||||||
|
this.geometry,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String name;
|
||||||
|
final int createdAt;
|
||||||
|
final Activity activity;
|
||||||
|
final double distanceM;
|
||||||
|
final int? estimatedMillis;
|
||||||
|
final String? geometry;
|
||||||
|
|
||||||
|
RoutePlan copyWith({
|
||||||
|
int? id, String? name, int? createdAt, Activity? activity,
|
||||||
|
double? distanceM, int? estimatedMillis, String? geometry,
|
||||||
|
}) => RoutePlan(
|
||||||
|
id: id ?? this.id, name: name ?? this.name, createdAt: createdAt ?? this.createdAt,
|
||||||
|
activity: activity ?? this.activity, distanceM: distanceM ?? this.distanceM,
|
||||||
|
estimatedMillis: estimatedMillis ?? this.estimatedMillis, geometry: geometry ?? this.geometry,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Waypoint` (~lines 278-309) has an `ordinal` for ordering, and waypoints are only ever
|
||||||
|
appended at the end (`RoutePlanRepository.addWaypoint`, below) — there is no "return to
|
||||||
|
start" field or concept anywhere in the model.
|
||||||
|
|
||||||
|
### Polyline and distance — strictly open, no wraparound
|
||||||
|
|
||||||
|
`lib/src/ui/routes/route_planner_screen.dart`, the `PolylineLayer` (~lines 182-201):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
if (waypoints.length >= 2)
|
||||||
|
PolylineLayer(
|
||||||
|
polylines: [
|
||||||
|
Polyline(
|
||||||
|
points: [
|
||||||
|
for (final w in waypoints) ll.LatLng(w.latitude, w.longitude),
|
||||||
|
],
|
||||||
|
strokeWidth: 4,
|
||||||
|
pattern: StrokePattern.dashed(segments: const [8, 6]),
|
||||||
|
color: colors.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
`lib/src/data/route_plan_repository.dart`, distance recomputation (full relevant
|
||||||
|
section):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<void> addWaypoint(int routeId, double latitude, double longitude) async {
|
||||||
|
final existing = await _db.waypointsForRoute(routeId);
|
||||||
|
await _db.insertWaypoint(
|
||||||
|
Waypoint(routeId: routeId, ordinal: existing.length, latitude: latitude, longitude: longitude),
|
||||||
|
);
|
||||||
|
await _recomputeDistance(routeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _recomputeDistance(int routeId) async {
|
||||||
|
final waypoints = await _db.waypointsForRoute(routeId);
|
||||||
|
final distance = geo.pathLengthMeters([
|
||||||
|
for (final w in waypoints) geo.LatLon(w.latitude, w.longitude),
|
||||||
|
]);
|
||||||
|
await _db.setRoutePlanDistance(routeId, distance);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`geo.pathLengthMeters` (`lib/src/geo/geo.dart` ~line 185) sums consecutive-pair
|
||||||
|
distances generically over whatever point list it's given — it needs no changes itself,
|
||||||
|
just a point list that includes the closing segment when appropriate.
|
||||||
|
|
||||||
|
`FloatingPill`'s Distance/Est. Time/Pins stats read straight off `route.distanceM`/
|
||||||
|
`route.estimatedMillis`/`waypoints.length` (`route_planner_screen.dart` ~lines 280-292)
|
||||||
|
— Distance updates for free once `_recomputeDistance` accounts for the closing segment;
|
||||||
|
Pins and Est. Time are unaffected by this ticket.
|
||||||
|
|
||||||
|
### Schema — current version and migration pattern to follow
|
||||||
|
|
||||||
|
`lib/src/data/database.dart`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@DataClassName('RoutePlanRow')
|
||||||
|
class RoutePlans extends Table {
|
||||||
|
@override
|
||||||
|
String get tableName => 'route_plans';
|
||||||
|
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
TextColumn get name => text()();
|
||||||
|
IntColumn get createdAt => integer()();
|
||||||
|
TextColumn get activity => textEnum<domain.Activity>().withDefault(const Constant('motorcycle'))();
|
||||||
|
RealColumn get distanceM => real().withDefault(const Constant(0))();
|
||||||
|
IntColumn get estimatedMillis => integer().nullable()();
|
||||||
|
TextColumn get geometry => text().nullable()();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DriftDatabase(tables: [Trips, Segments, TrackPoints, RoutePlans, Waypoints])
|
||||||
|
class AppDatabase extends _$AppDatabase {
|
||||||
|
@override
|
||||||
|
int get schemaVersion => 3;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
|
onCreate: (m) => m.createAll(),
|
||||||
|
onUpgrade: (m, from, to) async {
|
||||||
|
if (from < 2) {
|
||||||
|
await m.addColumn(trips, trips.activity);
|
||||||
|
}
|
||||||
|
if (from < 3) {
|
||||||
|
await m.createTable(routePlans);
|
||||||
|
await m.createTable(waypoints);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `if (from < 2) { await m.addColumn(trips, trips.activity); }` is the exact
|
||||||
|
pattern to follow for a new nullable/defaulted column.
|
||||||
|
|
||||||
|
`RoutePlanRepository` insert/conversion glue, in `database.dart`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<int> insertRoutePlan(domain.RoutePlan route) => into(routePlans).insert(
|
||||||
|
RoutePlansCompanion.insert(
|
||||||
|
name: route.name, createdAt: route.createdAt, activity: Value(route.activity),
|
||||||
|
distanceM: Value(route.distanceM), estimatedMillis: Value(route.estimatedMillis),
|
||||||
|
geometry: Value(route.geometry),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> setRoutePlanDistance(int id, double distanceM) => (update(routePlans)
|
||||||
|
..where((r) => r.id.equals(id))).write(RoutePlansCompanion(distanceM: Value(distanceM)));
|
||||||
|
|
||||||
|
domain.RoutePlan _toRoutePlan(RoutePlanRow r) => domain.RoutePlan(
|
||||||
|
id: r.id, name: r.name, createdAt: r.createdAt, activity: r.activity,
|
||||||
|
distanceM: r.distanceM, estimatedMillis: r.estimatedMillis, geometry: r.geometry,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Overflow menu — where the toggle belongs
|
||||||
|
|
||||||
|
`route_planner_screen.dart`, `_OverflowMenu` (~lines 493-530-ish) already hosts
|
||||||
|
Rename/Download/Delete as a `PopupMenuButton` inside a `GlassPanel`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
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')),
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
And it's constructed at the call site (~lines 298-310):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
_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();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design
|
||||||
|
- **Schema**: add `BoolColumn get isClosedLoop => boolean().withDefault(const
|
||||||
|
Constant(false))();` to `RoutePlans` in `database.dart`. Bump `schemaVersion` from
|
||||||
|
`3` to `4`; add `if (from < 4) { await m.addColumn(routePlans, routePlans.isClosedLoop); }`
|
||||||
|
to `onUpgrade`.
|
||||||
|
- **Domain model**: add `final bool isClosedLoop;` to `RoutePlan` (default `false` in
|
||||||
|
the constructor), thread it through `copyWith`.
|
||||||
|
- **DB glue**: add `isClosedLoop: Value(route.isClosedLoop)` to `insertRoutePlan`'s
|
||||||
|
`RoutePlansCompanion.insert(...)` call, `isClosedLoop: r.isClosedLoop` to
|
||||||
|
`_toRoutePlan`, and a new method mirroring `setRoutePlanDistance`:
|
||||||
|
```dart
|
||||||
|
Future<void> setRoutePlanClosedLoop(int id, bool value) => (update(routePlans)
|
||||||
|
..where((r) => r.id.equals(id))).write(RoutePlansCompanion(isClosedLoop: Value(value)));
|
||||||
|
```
|
||||||
|
- **Repository**: add to `RoutePlanRepository`:
|
||||||
|
```dart
|
||||||
|
Future<void> setClosedLoop(int routeId, bool value) async {
|
||||||
|
await _db.setRoutePlanClosedLoop(routeId, value);
|
||||||
|
await _recomputeDistance(routeId); // the closing segment changes the total
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Update `_recomputeDistance` to fetch the route's own flag and append the first
|
||||||
|
waypoint's coordinates when closed, before summing:
|
||||||
|
```dart
|
||||||
|
Future<void> _recomputeDistance(int routeId) async {
|
||||||
|
final route = await _db.getRoutePlan(routeId);
|
||||||
|
final waypoints = await _db.waypointsForRoute(routeId);
|
||||||
|
final points = [for (final w in waypoints) geo.LatLon(w.latitude, w.longitude)];
|
||||||
|
if (route?.isClosedLoop == true && points.length >= 2) {
|
||||||
|
points.add(points.first);
|
||||||
|
}
|
||||||
|
final distance = geo.pathLengthMeters(points);
|
||||||
|
await _db.setRoutePlanDistance(routeId, distance);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(`_recomputeDistance` is already called from every waypoint-mutating method —
|
||||||
|
`addWaypoint`, `moveWaypoint`, `deleteWaypoint`, `reorderWaypoint` — so the closing
|
||||||
|
segment is kept correct automatically as pins are edited, with no other call site
|
||||||
|
changes needed.)
|
||||||
|
- **UI toggle**: add a 4th item to `_OverflowMenu`, enabled only when there are enough
|
||||||
|
waypoints for a loop to mean anything (`hasWaypoints` alone isn't enough — need at
|
||||||
|
least 2 to form any segment at all; reuse the existing `hasWaypoints` bool but also
|
||||||
|
thread through whether there are `>= 2` waypoints, or simplify by passing a new
|
||||||
|
`canCloseLoop: waypoints.length >= 2` param):
|
||||||
|
```dart
|
||||||
|
class _OverflowMenu extends StatelessWidget {
|
||||||
|
const _OverflowMenu({
|
||||||
|
required this.hasWaypoints,
|
||||||
|
required this.canCloseLoop,
|
||||||
|
required this.isClosedLoop,
|
||||||
|
required this.onRename,
|
||||||
|
required this.onDownload,
|
||||||
|
required this.onToggleLoop,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
...
|
||||||
|
final bool canCloseLoop;
|
||||||
|
final bool isClosedLoop;
|
||||||
|
final VoidCallback onToggleLoop;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => GlassPanel(
|
||||||
|
...
|
||||||
|
child: PopupMenuButton<String>(
|
||||||
|
...
|
||||||
|
onSelected: (value) => switch (value) {
|
||||||
|
'rename' => onRename(),
|
||||||
|
'download' => onDownload?.call(),
|
||||||
|
'closeLoop' => onToggleLoop(),
|
||||||
|
'delete' => onDelete(),
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(key: Key('rename-route'), value: 'rename', child: Text('Rename')),
|
||||||
|
...
|
||||||
|
PopupMenuItem(
|
||||||
|
key: const Key('toggle-closed-loop'),
|
||||||
|
value: 'closeLoop',
|
||||||
|
enabled: canCloseLoop,
|
||||||
|
child: Text(isClosedLoop ? 'Open the loop' : 'Close the loop'),
|
||||||
|
),
|
||||||
|
...
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Wire it at the call site:
|
||||||
|
```dart
|
||||||
|
_OverflowMenu(
|
||||||
|
hasWaypoints: waypoints.isNotEmpty,
|
||||||
|
canCloseLoop: waypoints.length >= 2,
|
||||||
|
isClosedLoop: route.isClosedLoop,
|
||||||
|
onRename: () => _rename(context, repo, route),
|
||||||
|
onDownload: waypoints.isEmpty ? null : () => _downloadOfflineTiles(context, waypoints),
|
||||||
|
onToggleLoop: () => repo.setClosedLoop(widget.routeId, !route.isClosedLoop),
|
||||||
|
onDelete: () async { ... },
|
||||||
|
),
|
||||||
|
```
|
||||||
|
- **Polyline**: append the closing point when the route is closed and there are at
|
||||||
|
least 2 waypoints:
|
||||||
|
```dart
|
||||||
|
if (waypoints.length >= 2)
|
||||||
|
PolylineLayer(
|
||||||
|
polylines: [
|
||||||
|
Polyline(
|
||||||
|
points: [
|
||||||
|
for (final w in waypoints) ll.LatLng(w.latitude, w.longitude),
|
||||||
|
if (route.isClosedLoop)
|
||||||
|
ll.LatLng(waypoints.first.latitude, waypoints.first.longitude),
|
||||||
|
],
|
||||||
|
strokeWidth: 4,
|
||||||
|
pattern: StrokePattern.dashed(segments: const [8, 6]),
|
||||||
|
color: colors.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
```
|
||||||
|
Same `Polyline` object, same styling — just one more point in the list, so the closing
|
||||||
|
segment renders with identical dashed styling to the rest of the route.
|
||||||
|
- **`FloatingPill`/`_initialFit`**: no changes needed — Distance updates automatically
|
||||||
|
via `_recomputeDistance`; Pins/Est. Time are correctly unaffected; `_initialFit`'s
|
||||||
|
bounds-fitting is based on waypoint positions regardless of whether the last segment
|
||||||
|
loops back (the closing point is always one of the existing waypoints' own
|
||||||
|
coordinates, already inside the fitted bounds).
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
1. Add `isClosedLoop` column to `RoutePlans` in `database.dart`, bump `schemaVersion`
|
||||||
|
to `4`, add the `onUpgrade` migration step.
|
||||||
|
2. Add `isClosedLoop` field to `RoutePlan` domain model + `copyWith`.
|
||||||
|
3. Add `isClosedLoop` to `insertRoutePlan`'s companion and `_toRoutePlan` in
|
||||||
|
`database.dart`; add `setRoutePlanClosedLoop`.
|
||||||
|
4. Add `RoutePlanRepository.setClosedLoop`; update `_recomputeDistance` to append the
|
||||||
|
closing point when `isClosedLoop`.
|
||||||
|
5. Extend `_OverflowMenu` with the new toggle item and params; wire it at the call
|
||||||
|
site in `route_planner_screen.dart`.
|
||||||
|
6. Extend the `PolylineLayer`'s point list with the conditional closing point.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] "Close the loop" appears in the overflow menu, disabled when there are fewer than
|
||||||
|
2 waypoints, enabled otherwise.
|
||||||
|
- [ ] Toggling it on redraws the polyline with a visible segment from the last waypoint
|
||||||
|
back to the first, same dashed styling as the rest of the route.
|
||||||
|
- [ ] Toggling it on increases the Distance stat by the length of the new closing
|
||||||
|
segment; toggling it back off returns Distance to the open-path total.
|
||||||
|
- [ ] The menu label reflects current state ("Close the loop" when open, "Open the
|
||||||
|
loop" when already closed).
|
||||||
|
- [ ] Adding/moving/deleting a waypoint on a closed-loop route keeps the closing segment
|
||||||
|
correct automatically (it's recomputed via the existing `_recomputeDistance` call
|
||||||
|
already present in every waypoint-mutating method).
|
||||||
|
- [ ] Deleting waypoints down to fewer than 2 while closed doesn't crash — the
|
||||||
|
`waypoints.length >= 2` guards in both the polyline and distance code make the
|
||||||
|
closing point simply disappear along with the rest of the route drawing, same as
|
||||||
|
today's behavior for an open path with 0-1 waypoints.
|
||||||
|
- [ ] A pre-existing (schema v3) database opens cleanly post-migration with
|
||||||
|
`isClosedLoop` defaulting to `false` on every existing route.
|
||||||
|
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- **Migration test** (`test/migration_test.dart`, following the exact pattern of the
|
||||||
|
existing `'a v2 database (V3-07) gains route_plans/waypoints and keeps its trips'`
|
||||||
|
test): seed a v3 database (v1 seed + `activity` column + `route_plans`/`waypoints`
|
||||||
|
tables created via raw SQL, `PRAGMA user_version = 3;`), open it with `AppDatabase`,
|
||||||
|
insert a route via `db.insertRoutePlan`, and assert `isClosedLoop` reads back
|
||||||
|
`false` by default, and that toggling it via `setRoutePlanClosedLoop` persists.
|
||||||
|
- **Repository tests** (`test/route_plan_repository_test.dart`, alongside the existing
|
||||||
|
`'adding waypoints appends in order and updates distance live'` etc.):
|
||||||
|
- `setClosedLoop(true)` on a route with 2+ waypoints increases `distanceM` by
|
||||||
|
exactly the closing segment's length (compute the expected delta directly with
|
||||||
|
`geo.pathLengthMeters`/a manual haversine call between the last and first
|
||||||
|
waypoint, matching how other distance tests in this file already assert exact
|
||||||
|
values).
|
||||||
|
- `setClosedLoop(false)` after `true` returns `distanceM` to the pre-toggle value.
|
||||||
|
- Adding a waypoint to an already-closed route keeps the distance correct
|
||||||
|
(recomputed including the new closing segment against the new last-added point,
|
||||||
|
not the old one).
|
||||||
|
- **Widget tests** (`test/route_planner_screen_test.dart`):
|
||||||
|
- The "Close the loop" menu item is disabled with 0-1 waypoints, enabled with 2+
|
||||||
|
(mirrors the existing "offline-tiles download menu item is disabled with no pins"
|
||||||
|
test's structure — open the overflow menu, inspect the `PopupMenuItem.enabled`
|
||||||
|
property via its key).
|
||||||
|
- Tapping it toggles `route.isClosedLoop` (assert via
|
||||||
|
`repo.routePlanById(id)?.isClosedLoop`, the same pattern the existing rename test
|
||||||
|
uses to verify writes through to the repository) and the label switches to "Open
|
||||||
|
the loop".
|
||||||
|
- With the loop closed, `find.byType(Polyline)` (or inspecting the `PolylineLayer`'s
|
||||||
|
`polylines` list directly, matching whatever existing pattern this test file uses
|
||||||
|
to inspect polyline data) has one more point than the waypoint count.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- None significant — this is additive (new column, new optional toggle) and every
|
||||||
|
touched call site (`_recomputeDistance`) already runs on every mutation, so there's
|
||||||
|
no new code path that could silently skip recomputation.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
Turn-by-turn following of a closed loop (V3-09, already deferred). Any UI indication of
|
||||||
|
loop direction/rotation. Road-snapped routing for the closing segment (V3-08) — it stays
|
||||||
|
a straight line like every other segment in this ticket's scope.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Implemented exactly as designed, with one small addition to the migration step beyond
|
||||||
|
what the ticket spelled out:
|
||||||
|
|
||||||
|
- Added `isClosedLoop` (`BoolColumn`, default `false`) to `RoutePlans` in
|
||||||
|
`lib/src/data/database.dart`, bumped `schemaVersion` from 3 to 4, and regenerated
|
||||||
|
`database.g.dart` via `dart run build_runner build`.
|
||||||
|
- **Deviation**: the migration guard needed to be `if (from >= 3 && from < 4)` rather
|
||||||
|
than the ticket's plain `if (from < 4)`. A v2 → v4 upgrade runs the existing
|
||||||
|
`if (from < 3)` branch first, which calls `m.createTable(routePlans)` — and since that
|
||||||
|
table definition already includes `isClosedLoop` (Drift generates `CREATE TABLE` from
|
||||||
|
the *current* Dart schema, not a historical snapshot), an unconditional `from < 4`
|
||||||
|
then tried to `ADD COLUMN isClosedLoop` a second time and hit a duplicate-column
|
||||||
|
SQLite error. Restricting the `addColumn` step to routes that already had the table
|
||||||
|
before v4 (`from >= 3`) fixes it; a v1/v2 → v4+ upgrade gets the column for free from
|
||||||
|
the fresh `createTable` instead. Added a v3-seed migration test
|
||||||
|
(`test/migration_test.dart`, `'a v3 database (FB-05) gains isClosedLoop defaulting to
|
||||||
|
false'`) covering exactly this path, plus verifying a v2 → v4+ jump still works via the
|
||||||
|
existing v2 test.
|
||||||
|
- Added `isClosedLoop` to the `RoutePlan` domain model, `copyWith`, the
|
||||||
|
`insertRoutePlan`/`_toRoutePlan` glue, and a new `setRoutePlanClosedLoop` method in
|
||||||
|
`database.dart`.
|
||||||
|
- Added `RoutePlanRepository.setClosedLoop` and updated `_recomputeDistance` to append
|
||||||
|
the first waypoint's coordinates as a closing point when the route is closed and has
|
||||||
|
2+ waypoints, exactly per the design.
|
||||||
|
- Extended `_OverflowMenu` in `route_planner_screen.dart` with `canCloseLoop`/
|
||||||
|
`isClosedLoop`/`onToggleLoop` and a "Close the loop" / "Open the loop" menu item,
|
||||||
|
wired at the call site.
|
||||||
|
- Extended the `PolylineLayer`'s point list with the conditional closing point.
|
||||||
|
- FB-04 had already changed the surrounding `route` access to a guarded `AsyncValue`
|
||||||
|
pattern (no more `.valueOrNull`) by the time this ticket landed; the new code reads
|
||||||
|
`route.isClosedLoop` off the same already-resolved `route` value the rest of the
|
||||||
|
build method uses, so no adjustment to that pattern was needed.
|
||||||
|
|
||||||
|
Added migration, repository, and widget tests per the ticket's Tests section (in
|
||||||
|
`test/migration_test.dart`, `test/route_plan_repository_test.dart`, and
|
||||||
|
`test/route_planner_screen_test.dart`).
|
||||||
|
|
||||||
|
`flutter analyze`: clean (only 4 pre-existing, unrelated `info`-level lints remain).
|
||||||
|
`flutter test`: all green, **411 tests passing** (up from the 404 baseline).
|
||||||
224
docs/feedback/FB-06-always-interactive-map-recenter.md
Normal file
224
docs/feedback/FB-06-always-interactive-map-recenter.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# FB-06 — Map is always pannable and zoomable, with a recenter control
|
||||||
|
|
||||||
|
**Depends on** — · **Size** M · **Status** Done
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
The rider must be able to pan and zoom the map at all times. Today the map locks all
|
||||||
|
interaction while idle. Add a recenter button. The button must bring the camera back to
|
||||||
|
the rider's live position.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Direct user feedback (`docs/FEEDBACK.md`):
|
||||||
|
|
||||||
|
> Map page, both when recording is enabled and disabled, you cannot zoom and move
|
||||||
|
> around the map, it's be nice to still be able to move around and see the surrounding
|
||||||
|
> area, and then tap a "center" icon to recenter on the user.
|
||||||
|
>
|
||||||
|
> User dot on the map should always glow and occilate in size like it does when
|
||||||
|
> recording.
|
||||||
|
|
||||||
|
`lib/src/ui/components/ride_map.dart`, `_RideMapState.build()` (~line 270):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
interactionOptions: hasPoints
|
||||||
|
? const InteractionOptions(
|
||||||
|
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
|
||||||
|
)
|
||||||
|
: const InteractionOptions(flags: InteractiveFlag.none),
|
||||||
|
```
|
||||||
|
|
||||||
|
`hasPoints` is `widget.points.isNotEmpty`. The shared background map is idle (no
|
||||||
|
recorded points) any time no trip is recording. In that state `InteractiveFlag.none`
|
||||||
|
blocks every pan and zoom gesture. This is the exact cause of "when recording is
|
||||||
|
disabled you cannot zoom and move around the map."
|
||||||
|
|
||||||
|
During an active recording, `hasPoints` is true and pan/zoom are already allowed. The
|
||||||
|
existing chase-camera logic already cancels auto-follow on a real gesture — see
|
||||||
|
`didUpdateWidget` (~line 142) and `onPositionChanged` (~line 270):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
onPositionChanged: !widget.follow
|
||||||
|
? null
|
||||||
|
: (position, hasGesture) {
|
||||||
|
if (hasGesture && _following) {
|
||||||
|
setState(() => _following = false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
Once `_following` is set to `false`, nothing ever sets it back to `true` again — there
|
||||||
|
is no way today to resume following the rider's live position after one manual pan.
|
||||||
|
That is the missing "recenter" affordance the feedback names directly.
|
||||||
|
|
||||||
|
`_following` is a `late bool` field set once, at `State` creation
|
||||||
|
(`late bool _following = widget.follow;`, ~line 128) — it never re-reads `widget.follow`
|
||||||
|
on a later rebuild. A recenter action must set this field back to `true` directly
|
||||||
|
(inside `_RideMapState`, since it owns the field) — there is no way to do this from
|
||||||
|
outside the widget today, and there should not be; this stays internal.
|
||||||
|
|
||||||
|
`lib/src/ui/components/pulsing_location_marker.dart` already animates continuously
|
||||||
|
(`AnimationController.repeat()` in `didChangeDependencies`, unless the platform's
|
||||||
|
reduced-motion setting is on) and is the same widget instance used for both the idle
|
||||||
|
(ambient) marker and the recording marker — see `ride_map.dart` ~line 307:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
if (widget.showLocationMarker && (hasPoints || widget.ambientPosition != null))
|
||||||
|
MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
Marker(
|
||||||
|
key: const Key('location-marker'),
|
||||||
|
point: hasPoints ? ... : widget.ambientPosition!,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: const PulsingLocationMarker(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
The code already pulses the marker in both states. This ticket's job for the marker is
|
||||||
|
to confirm, on a real device, that the pulse is actually visible in both states — not to
|
||||||
|
assume the report is wrong. If the pulse is confirmed working, say so plainly in the
|
||||||
|
Outcome section and make no code change for it.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
- **Always allow pan and zoom.** Remove the `hasPoints` gate on `interactionOptions`.
|
||||||
|
Use `const InteractionOptions(flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag)`
|
||||||
|
unconditionally.
|
||||||
|
- **Add a recenter button inside `RideMap`.** Show it only when `widget.follow` is true
|
||||||
|
and `_following` is false — the exact state where the rider panned away from an
|
||||||
|
actively-followed camera. Place it as a small circular icon button, bottom-right of
|
||||||
|
the map, above the tile layer, using `Icons.my_location` and the app's existing
|
||||||
|
`GlassPanel`/theme conventions (check `lib/src/ui/components/glass_panel.dart` for the
|
||||||
|
existing floating-control pattern this app already uses, e.g. `FloatingPill`, and
|
||||||
|
match it rather than inventing new chrome). Give it `key: const Key('recenter-button')`.
|
||||||
|
On tap:
|
||||||
|
1. Set `_following = true`.
|
||||||
|
2. Move the camera to the latest known position: `widget.points.last` if
|
||||||
|
`widget.points.isNotEmpty`, else `widget.ambientPosition` if it is not null. If
|
||||||
|
neither is available, do nothing (no position to recenter on yet).
|
||||||
|
3. Keep the current zoom level — do not force a specific zoom on recenter, since the
|
||||||
|
rider may have deliberately zoomed in or out and recenter should not undo that.
|
||||||
|
- **Do not show the recenter button when there is no `follow` mode at all** (e.g. a
|
||||||
|
finished-ride static map in Trip Detail, where `follow` is always false) — the gate
|
||||||
|
above (`widget.follow && !_following`) already excludes this case correctly.
|
||||||
|
- **Marker verification.** Run the app on the Android emulator. Watch the location
|
||||||
|
marker in the idle state and in the recording state. Confirm the pulse ring expands
|
||||||
|
and fades in both states. Write the result in the Outcome section. Fix the code only
|
||||||
|
if the pulse is genuinely missing in one of the two states — do not change the
|
||||||
|
animation if it is already working.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
1. Remove the `hasPoints` conditional on `interactionOptions` in `ride_map.dart`. Use
|
||||||
|
the pinch/drag flags unconditionally.
|
||||||
|
2. Add a recenter button widget inside `_RideMapState.build()`, gated on
|
||||||
|
`widget.follow && !_following`.
|
||||||
|
3. Wire the button's `onPressed` to set `_following = true` and move the camera to the
|
||||||
|
latest point or ambient position, keeping the current zoom.
|
||||||
|
4. Run the app on the Android emulator. Confirm the marker pulses in both the idle and
|
||||||
|
the recording state. Record the result in the ticket's Outcome section.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] The map can be panned and zoomed while idle (no active recording).
|
||||||
|
- [ ] The map can be panned and zoomed while recording.
|
||||||
|
- [ ] Panning the map while `follow` is active shows a recenter button.
|
||||||
|
- [ ] Tapping the recenter button returns the camera to the rider's latest known
|
||||||
|
position and resumes following new position updates.
|
||||||
|
- [ ] The recenter button does not appear on a static, non-following map (e.g. Trip
|
||||||
|
Detail's finished-ride view).
|
||||||
|
- [ ] The location marker's pulse animation is confirmed visible on-device in both the
|
||||||
|
idle and the recording state, or fixed if it is not.
|
||||||
|
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- Widget test: `RideMap` with `hasPoints: false` (no recorded points) allows a pan
|
||||||
|
gesture to change the camera position — assert the map's `InteractionOptions.flags`
|
||||||
|
include `InteractiveFlag.drag`/`pinchZoom` regardless of `points`/`ambientPosition`.
|
||||||
|
- Widget test: after a manual pan cancels following (`_following` set to `false` via
|
||||||
|
the existing `onPositionChanged` gesture path — see the pattern already used in
|
||||||
|
`test/ride_map_test.dart`'s "a manual pan cancels ambient following" test), the
|
||||||
|
recenter button (`find.byKey(const Key('recenter-button'))`) appears.
|
||||||
|
- Widget test: before any manual pan (or when `follow` is false), the recenter button
|
||||||
|
does not appear.
|
||||||
|
- Widget test: tapping the recenter button moves the camera back to the latest point
|
||||||
|
(or ambient position) and the button disappears again (following resumed).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- None significant. This is additive (a new optional control) and a removed
|
||||||
|
restriction (interaction gating), not a data-model or persistence change.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
Any change to the Route Planner's own map (FB-07 covers its remaining issue). Any
|
||||||
|
change to `PulsingLocationMarker`'s own animation code, unless the on-device check in
|
||||||
|
this ticket finds it is genuinely not visible in one of the two states.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Implemented the Design section exactly, in `lib/src/ui/components/ride_map.dart`:
|
||||||
|
|
||||||
|
- Removed the `hasPoints` gate on `interactionOptions`. The map now always uses
|
||||||
|
`const InteractionOptions(flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag)`,
|
||||||
|
idle or recording.
|
||||||
|
- Added a recenter button (`key: const Key('recenter-button')`), shown only when
|
||||||
|
`widget.follow && !_following` — i.e. exactly when the rider has panned away from an
|
||||||
|
actively-followed camera. It is a `GlassPanel` (borderRadius 999, matching the
|
||||||
|
existing `FloatingPill`/`_OverflowMenu` circular-chrome pattern already used in
|
||||||
|
`route_planner_screen.dart`) wrapping an `IconButton` with `Icons.my_location`,
|
||||||
|
positioned bottom-right of the map (`Positioned(right: 16, bottom: 16, ...)` inside a
|
||||||
|
`Stack` that now wraps the map).
|
||||||
|
- Wired `onPressed` to a new `_recenter()` method: sets `_following = true`, then moves
|
||||||
|
the camera to `widget.points.last` if points are non-empty, else
|
||||||
|
`widget.ambientPosition` if non-null, else does nothing. Zoom is left untouched
|
||||||
|
(`_controller.camera.zoom` is passed straight through to `_controller.move`), per the
|
||||||
|
design's explicit "keep the current zoom level" requirement.
|
||||||
|
- Made no changes to `PulsingLocationMarker` — see the marker verification note below.
|
||||||
|
|
||||||
|
No deviation from the design.
|
||||||
|
|
||||||
|
**Tests.** Added 7 new widget tests to `test/ride_map_test.dart` under a new
|
||||||
|
`'always-interactive map + recenter (FB-06)'` group, covering: pan/zoom flags present
|
||||||
|
with no points (idle) and with points (recording); no recenter button before any
|
||||||
|
manual pan; no recenter button when `follow` is false even after a pan; a manual pan
|
||||||
|
while following shows the button; tapping it recenters to the ambient position and
|
||||||
|
hides the button again; tapping it recenters to the latest recorded point while
|
||||||
|
recording. `flutter analyze` is clean (only the 4 pre-existing, unrelated infos in
|
||||||
|
`crash_reporter.dart`/`map_connectivity.dart` — nothing new). `flutter test` is green:
|
||||||
|
confirmed via both the default reporter and `--reporter json` (cross-checked test names
|
||||||
|
directly) that the suite went from the 412-test baseline to exactly 419 real tests
|
||||||
|
(412 + 7 new), all passing, none skipped or removed.
|
||||||
|
|
||||||
|
**On-device marker verification: skipped.** This environment has no Android
|
||||||
|
tooling available — `adb` is not on `PATH`, there is no `ANDROID_HOME`/SDK, and
|
||||||
|
`flutter devices` lists only `macOS (desktop)` and `Chrome (web)`, no emulator. Per the
|
||||||
|
ticket's own risk-mitigation instructions (do not fight an unavailable/unstable
|
||||||
|
emulator at length), the marker-pulse check was not attempted rather than spending
|
||||||
|
time on a device that isn't reachable from this sandbox. `PulsingLocationMarker` was
|
||||||
|
not modified — per the ticket, a code change there is only warranted if the on-device
|
||||||
|
check finds the pulse genuinely missing, and that check could not be run. The relevant
|
||||||
|
acceptance-criteria checkbox ("location marker's pulse animation is confirmed visible
|
||||||
|
on-device...") is therefore left unchecked/unresolved and should be picked up in a
|
||||||
|
follow-up pass that has emulator access.
|
||||||
|
|
||||||
|
### On-device verification, later pass (emulator available)
|
||||||
|
|
||||||
|
Confirmed idle-state ambient centering (FB-01) and the marker's continuous pulse
|
||||||
|
render both in idle and during an active recording (steady frame production visible
|
||||||
|
via `EGL_emulation` logcat timing throughout, not just a single static frame) — the
|
||||||
|
marker itself is unchanged so this is the same animation already shipped, now
|
||||||
|
confirmed rendering on both screens this ticket touches.
|
||||||
|
|
||||||
|
Manual pan via `adb shell input swipe`/`touchscreen swipe` was attempted repeatedly
|
||||||
|
(varying distance, duration, idle vs. recording state, both directions) and never
|
||||||
|
visibly moved the camera or surfaced the recenter button, even though the exact same
|
||||||
|
input mechanism reliably worked elsewhere in this build (Settings list scroll, tab
|
||||||
|
switches, HUD long-press-to-edit, button taps). Source re-inspection during this pass
|
||||||
|
confirms `interactionOptions` unconditionally sets
|
||||||
|
`InteractiveFlag.pinchZoom | InteractiveFlag.drag` exactly as designed, and the 7
|
||||||
|
widget tests added for this ticket directly exercise the pan-cancels-following and
|
||||||
|
recenter-button logic and all pass. Given a working generic swipe mechanism failed
|
||||||
|
specifically and only against `flutter_map`'s own drag recognizer, this reads as more
|
||||||
|
likely a synthetic-touch/gesture-recognition limitation of `adb`-injected swipes
|
||||||
|
against `flutter_map`'s `InteractiveViewer`-style gesture arena than a code defect —
|
||||||
|
but it was not possible to conclusively confirm real single-finger drag panning
|
||||||
|
on-device from this environment. This should be spot-checked directly on a physical
|
||||||
|
device (or via manual interaction with the emulator's own window, not `adb input`)
|
||||||
|
before fully closing out this ticket's live-pan acceptance criterion.
|
||||||
226
docs/feedback/FB-07-route-planner-null-island.md
Normal file
226
docs/feedback/FB-07-route-planner-null-island.md
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
# FB-07 — Route Planner opens on Null Island instead of the rider's real location
|
||||||
|
|
||||||
|
**Depends on** FB-01 (reuses its ambient-location provider) · **Size** S/M · **Status** Done
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
A new route, or a route with fewer than two pins, must open the map on the rider's real
|
||||||
|
location. Today it opens on the middle of the ocean.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Direct user feedback (`docs/FEEDBACK.md`):
|
||||||
|
|
||||||
|
> Pin drops still do not work at all, the map when placing the pins doesn't render at a
|
||||||
|
> all, it's just a blank canvas that you can zoom in and out of but no detail appears.
|
||||||
|
> The pins can also be placed but nothing is rendered on the map so you have no clue
|
||||||
|
> where they are.
|
||||||
|
|
||||||
|
`lib/src/ui/routes/route_planner_screen.dart`, `build()` (~line 165):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
child: FlutterMap(
|
||||||
|
mapController: _mapController,
|
||||||
|
options: MapOptions(
|
||||||
|
initialCameraFit: _initialFit(waypoints),
|
||||||
|
initialCenter: waypoints.isEmpty
|
||||||
|
? const ll.LatLng(0, 0)
|
||||||
|
: ll.LatLng(waypoints.first.latitude, waypoints.first.longitude),
|
||||||
|
initialZoom: waypoints.length <= 1 ? ambientZoom : maxTileZoom - 3,
|
||||||
|
maxZoom: maxTileZoom,
|
||||||
|
onTap: (tapPosition, point) {
|
||||||
|
repo.addWaypoint(widget.routeId, point.latitude, point.longitude);
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
`ll.LatLng(0, 0)` is Null Island — a single point in the Atlantic Ocean with no land
|
||||||
|
and no map detail at any zoom level. A brand-new route (0 pins), or a route with exactly
|
||||||
|
1 pin dropped near that point, opens the map centered there. At street-level zoom, an
|
||||||
|
area of open ocean legitimately renders as a flat, featureless expanse — this matches
|
||||||
|
the report exactly: "a blank canvas... no detail appears," and a pin dropped anywhere
|
||||||
|
near that same spot lands in the same empty area, with nothing else on screen to show
|
||||||
|
where it is relative to.
|
||||||
|
|
||||||
|
`RoutePlannerScreen` manages its own `FlutterMap` directly — it does not use `RideMap`
|
||||||
|
and does not currently read `ambientPositionProvider` at all (confirmed by grep: no
|
||||||
|
reference to `ambientPositionProvider` anywhere in `route_planner_screen.dart`). FB-01
|
||||||
|
already built exactly the provider this ticket needs — `lib/src/app/providers.dart`
|
||||||
|
(~line 66):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final ambientPositionProvider = StreamProvider.autoDispose<LocationFix?>((ref) async* {
|
||||||
|
if (!ref.watch(mapEnabledProvider)) {
|
||||||
|
yield null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final source = ref.watch(locationSourceProvider);
|
||||||
|
try {
|
||||||
|
await source.start(); // idempotent; safe even if a recording already started it
|
||||||
|
} on LocationException {
|
||||||
|
yield null; // permission denied / service disabled — ambient mode is best-effort
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield* source.fixes.map<LocationFix?>((fix) => fix);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`LocationFix` is defined in `lib/src/recording/location_source.dart`.
|
||||||
|
|
||||||
|
**`initialCenter`/`initialZoom` are read exactly once, at `FlutterMap` construction —
|
||||||
|
not on every rebuild.** The existing comment in this file already documents this
|
||||||
|
constraint for the waypoints stream (~line 158): the screen waits for
|
||||||
|
`waypointsAsync.hasValue` before building `FlutterMap` at all, specifically so the
|
||||||
|
first real camera position is correct from the start. The same constraint applies here:
|
||||||
|
reading `ambientPositionProvider` must happen before `FlutterMap` is constructed, not
|
||||||
|
patched in afterward with a controller move — mirror the existing wait-for-first-value
|
||||||
|
pattern, do not invent a new one.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
- Watch `ambientPositionProvider` in `build()`, the same way `ShellScaffold` already
|
||||||
|
does (`lib/src/ui/app_shell.dart` ~line 74):
|
||||||
|
```dart
|
||||||
|
final ambientFix = ref.watch(ambientPositionProvider).valueOrNull;
|
||||||
|
final ambientPosition = ambientFix == null
|
||||||
|
? null
|
||||||
|
: ll.LatLng(ambientFix.latitude, ambientFix.longitude);
|
||||||
|
```
|
||||||
|
- Use `ambientPosition` for `initialCenter` when there are fewer than 2 waypoints,
|
||||||
|
falling back to `(0, 0)` only when no ambient fix is available yet (permission denied,
|
||||||
|
service disabled, or the fix has not arrived yet):
|
||||||
|
```dart
|
||||||
|
initialCenter: waypoints.isEmpty
|
||||||
|
? (ambientPosition ?? const ll.LatLng(0, 0))
|
||||||
|
: ll.LatLng(waypoints.first.latitude, waypoints.first.longitude),
|
||||||
|
```
|
||||||
|
- Do **not** block the map on waiting for the ambient fix the way `waypointsAsync` is
|
||||||
|
blocked on its own first value. A missing ambient fix must fall back to `(0, 0)`
|
||||||
|
immediately, not show a spinner — the rider must always eventually reach a usable map,
|
||||||
|
even with location permission denied. This matches the existing `RideMap` fallback
|
||||||
|
behavior in FB-01 exactly (see `ride_map.dart`'s `bounds == null` branch).
|
||||||
|
- Leave the 1-waypoint and 2-plus-waypoint camera logic unchanged — a route that already
|
||||||
|
has a real pin should still center on that pin, not on the ambient position, since the
|
||||||
|
pin is a stronger signal of where the rider actually wants to look.
|
||||||
|
- **Confirm tiles genuinely render once centered on a real location.** After this fix,
|
||||||
|
drop a pin near a real city on the Android emulator (use `adb emu geo fix` to set a
|
||||||
|
real location, as FB-01's own verification pass did). Take a screenshot. Confirm
|
||||||
|
street-level map detail actually appears, not just a differently-colored blank area.
|
||||||
|
If tiles still do not render even at a real location, that is a second, distinct bug —
|
||||||
|
investigate `TileLayer`'s setup in this file (`urlTemplate`, `tileProvider`,
|
||||||
|
`mapConnectivityProvider`'s `skeletonMode`) before assuming this ticket's fix is
|
||||||
|
sufficient, and document the real cause in the Outcome section.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
1. Add `final ambientFix = ref.watch(ambientPositionProvider).valueOrNull;` and the
|
||||||
|
`ambientPosition` conversion to `build()`.
|
||||||
|
2. Change the `initialCenter` fallback for the empty-waypoints case from `const
|
||||||
|
ll.LatLng(0, 0)` to `ambientPosition ?? const ll.LatLng(0, 0)`.
|
||||||
|
3. Run the app on the Android emulator. Set a real mock location. Open a new route.
|
||||||
|
Confirm the map opens on that location with visible street detail, not open ocean.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] A brand-new route (0 pins) opens the map centered on the rider's real location,
|
||||||
|
when a location fix is available.
|
||||||
|
- [ ] A brand-new route still opens on `(0, 0)` when no location fix is available
|
||||||
|
(permission denied, service disabled, or no fix yet) — no crash, no infinite
|
||||||
|
spinner.
|
||||||
|
- [ ] A route with 1 or more pins still centers on the pin, unchanged from today.
|
||||||
|
- [ ] Dropping a pin near a real city on the emulator shows visible street-level map
|
||||||
|
detail underneath it, confirmed by a real screenshot.
|
||||||
|
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- Widget test: pump `RoutePlannerScreen` for a route with 0 waypoints, with
|
||||||
|
`ambientPositionProvider` overridden to a fixed `LocationFix` — assert the
|
||||||
|
`FlutterMap`'s `options.initialCenter` matches that fix, not `(0, 0)`.
|
||||||
|
- Widget test: pump `RoutePlannerScreen` for a route with 0 waypoints, with
|
||||||
|
`ambientPositionProvider` overridden to emit `null` (no fix available) — assert
|
||||||
|
`initialCenter` falls back to `(0, 0)`, matching today's existing behavior.
|
||||||
|
- Widget test: pump `RoutePlannerScreen` for a route with 1 real waypoint — assert
|
||||||
|
`initialCenter` still matches that waypoint's own coordinates, not the ambient
|
||||||
|
position, even when `ambientPositionProvider` emits a different fix.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- If the on-device check in this ticket's Implementation step 3 finds tiles still do
|
||||||
|
not render at a real location, this ticket's fix alone is not sufficient — document
|
||||||
|
the real root cause found and either fix it in this same ticket or state plainly in
|
||||||
|
the Outcome section that a further ticket is needed. Do not claim this ticket is done
|
||||||
|
without confirming real map detail actually appears on a screenshot.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
Any change to the Map tab's own map (FB-06 covers its remaining issues). Turn-by-turn
|
||||||
|
route following (V3-09, already deferred elsewhere).
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Implemented the Design section exactly, in `lib/src/ui/routes/route_planner_screen.dart`'s
|
||||||
|
`build()`:
|
||||||
|
- Added `final ambientFix = ref.watch(ambientPositionProvider).valueOrNull;` and the
|
||||||
|
`ambientPosition` conversion to `ll.LatLng?`, watched unconditionally alongside the
|
||||||
|
existing `routeAsync`/`waypointsAsync` watches (not gated behind the
|
||||||
|
`waypointsAsync.hasValue` wait, matching the ticket's explicit instruction not to block
|
||||||
|
the map on the ambient fix).
|
||||||
|
- Changed the empty-waypoints `initialCenter` fallback from `const ll.LatLng(0, 0)` to
|
||||||
|
`ambientPosition ?? const ll.LatLng(0, 0)`. The 1-waypoint and 2-plus-waypoint camera
|
||||||
|
logic is untouched.
|
||||||
|
|
||||||
|
Added three widget tests to `test/route_planner_screen_test.dart` (in the
|
||||||
|
`RoutePlannerScreen` group), each overriding `ambientPositionProvider` directly with
|
||||||
|
`.overrideWith((ref) => Stream.value(...))` rather than routing through
|
||||||
|
`FakeLocationSource`, since the ticket only needs to check what `initialCenter` resolves
|
||||||
|
to, not the location-source plumbing FB-01's own tests already cover:
|
||||||
|
1. A brand-new route (0 waypoints) with a fixed ambient fix opens `FlutterMap` centered
|
||||||
|
on that fix.
|
||||||
|
2. A brand-new route with `ambientPositionProvider` emitting `null` still falls back to
|
||||||
|
`(0, 0)`, matching prior behavior.
|
||||||
|
3. A route with one real waypoint centers on that waypoint even when a different ambient
|
||||||
|
fix is available, confirming the pin still wins.
|
||||||
|
|
||||||
|
`flutter analyze`: clean — the same 4 pre-existing `info`-level issues as baseline
|
||||||
|
(deprecated `copyWith` in `crash_reporter.dart`, `prefer_initializing_formals` in
|
||||||
|
`map_connectivity.dart`), none in the touched files.
|
||||||
|
|
||||||
|
`flutter test`: all passing, **415 tests** (412 baseline + 3 new), no regressions.
|
||||||
|
|
||||||
|
**On-device check: attempted, but the emulator became unresponsive partway through and
|
||||||
|
was abandoned per the conservative-use instruction — this step is effectively skipped.**
|
||||||
|
Booted `Medium_Phone_API_35`, it came up and reported `sys.boot_completed=1` quickly,
|
||||||
|
`adb devices` showed it connected. Installed and launched the app
|
||||||
|
(`com.rippr.port`), granted `ACCESS_FINE_LOCATION`/`ACCESS_COARSE_LOCATION`, and set
|
||||||
|
`adb emu geo fix -114.07 51.05`. The app launched into an in-progress recording state on
|
||||||
|
the Map tab (not the Route Planner) with a "System UI isn't responding" ANR dialog
|
||||||
|
already on screen. Two attempts to dismiss it via `adb shell input tap` each hung past
|
||||||
|
their timeout and were moved to the background — a textbook case of the "adb/emulator
|
||||||
|
becomes slow or unresponsive" condition called out in this ticket's dispatch, so per
|
||||||
|
instructions no further retries were made. The emulator was killed cleanly afterward
|
||||||
|
(`adb emu kill`) rather than left running. Because the Route Planner screen was never
|
||||||
|
actually reached, no independent tile-rendering finding was made this run either way —
|
||||||
|
neither confirming nor ruling out the "second, deeper bug" flagged as a risk. The
|
||||||
|
screenshot taken before giving up showed the (unrelated, out-of-scope) Map tab rendering
|
||||||
|
a flat, low-detail world map under the ANR dialog, consistent with FB-06's separate,
|
||||||
|
already-tracked scope, not this ticket's Route Planner fix.
|
||||||
|
|
||||||
|
The code change and its three widget tests are the verified deliverable for this ticket;
|
||||||
|
the on-device screenshot check called for in Implementation step 3 / Acceptance criteria
|
||||||
|
was not completed and should be picked up in a follow-up pass when the emulator is
|
||||||
|
stable, rather than by fighting it further here.
|
||||||
|
|
||||||
|
### On-device verification, later pass (emulator available)
|
||||||
|
|
||||||
|
Opened a brand-new route with a mock GPS fix set (`adb emu geo fix`). Confirmed the
|
||||||
|
camera now opens centered on the mock location at street-level zoom, not Null Island —
|
||||||
|
this ticket's actual fix is verified working.
|
||||||
|
|
||||||
|
Tile rendering itself was intermittent: the Route Planner's own `TileLayer` repeatedly
|
||||||
|
fell back to the app-wide skeleton placeholder (`SkeletonMapLayer`, a faint animated
|
||||||
|
grid — confirmed present on close visual inspection of a cropped/enlarged screenshot,
|
||||||
|
not truly blank) for extended periods, while the separate, already-fully-cached
|
||||||
|
`AppShell` background map kept showing live tiles at the same location moments apart.
|
||||||
|
Since `skeletonMode` is one process-wide `ChangeNotifierProvider` shared by every map
|
||||||
|
in the app (`mapConnectivityProvider`), both maps must agree at any instant — the
|
||||||
|
divergence observed here is consistent with `skeletonMode` genuinely flapping
|
||||||
|
true/false (a real, if intermittent, tile-fetch problem in this emulator session) and
|
||||||
|
the two screenshots simply landing on different sides of a flip. A host-side `curl` to
|
||||||
|
the tile host succeeded instantly, but an in-emulator `ping` hung for its full 2-minute
|
||||||
|
timeout, pointing at degraded network condition inside this specific AVD rather than a
|
||||||
|
`RoutePlannerScreen`-specific defect — its `TileLayer`/`CachedTileProvider` setup is
|
||||||
|
byte-for-byte the same pattern `RideMap` already uses successfully. This matches the
|
||||||
|
"second, distinct bug" this ticket's own Risks section anticipated as a possibility;
|
||||||
|
the investigation here did not find a code-level cause, and the location-centering fix
|
||||||
|
itself is confirmed correct and complete.
|
||||||
165
docs/feedback/FB-08-hud-reflow-on-hide.md
Normal file
165
docs/feedback/FB-08-hud-reflow-on-hide.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# FB-08 — HUD widgets reflow to fill the gap when one is hidden
|
||||||
|
|
||||||
|
**Depends on** — · **Size** M · **Status** Done
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Hiding a HUD metric must resize the remaining widgets in its row to fill the empty
|
||||||
|
space. Today hiding a metric leaves an empty gap in the grid.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Direct user feedback (`docs/FEEDBACK.md`):
|
||||||
|
|
||||||
|
> When disabling some statistic during the HUD sessions, they should resize
|
||||||
|
> themselves, instead of leaving a gap by default.
|
||||||
|
|
||||||
|
`lib/src/hud/hud_layout_controller.dart`, `setVisible` (full method):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
void setVisible(HudMetric metric, bool visible) {
|
||||||
|
var current = state[metric] ?? HudWidgetLayout.defaultFor(metric);
|
||||||
|
if (visible && _overlapsAnyOther(metric, current.copyWith(visible: true))) {
|
||||||
|
current = HudWidgetLayout.nextFreeSlot(
|
||||||
|
metric,
|
||||||
|
occupied: state,
|
||||||
|
colSpan: current.colSpan,
|
||||||
|
rowSpan: current.rowSpan,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state = {...state, metric: current.copyWith(visible: visible)};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This method only ever changes the toggled metric's own entry. No other metric's `col`,
|
||||||
|
`row`, or `colSpan` ever changes as a side effect. When a metric in a row of four is
|
||||||
|
hidden, the other three keep their exact original `col`/`colSpan` — the grid cell the
|
||||||
|
hidden metric used to occupy simply renders nothing, showing as blank space in the HUD.
|
||||||
|
|
||||||
|
`HudWidgetLayout` (`lib/src/hud/hud_widget_layout.dart`) already has everything this
|
||||||
|
ticket needs to build a row-reflow function: `col`, `row`, `colSpan`, `rowSpan` (all
|
||||||
|
`int`), `copyWith`, and `hudRectsOverlap`. `hudGridColumns` is `4`.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
- Add a private helper to `HudLayoutController`:
|
||||||
|
```dart
|
||||||
|
/// Spreads every visible widget in [row] evenly across the full grid width,
|
||||||
|
/// left to right in their existing column order. A widget whose row has no other
|
||||||
|
/// visible member is left alone -- there is nothing to redistribute.
|
||||||
|
Map<HudMetric, HudWidgetLayout> _reflowRow(
|
||||||
|
Map<HudMetric, HudWidgetLayout> layouts,
|
||||||
|
int row,
|
||||||
|
) {
|
||||||
|
final members = layouts.values.where((l) => l.visible && l.row == row).toList()
|
||||||
|
..sort((a, b) => a.col.compareTo(b.col));
|
||||||
|
if (members.length < 2) return layouts;
|
||||||
|
final baseSpan = hudGridColumns ~/ members.length;
|
||||||
|
final extra = hudGridColumns % members.length;
|
||||||
|
final result = Map<HudMetric, HudWidgetLayout>.from(layouts);
|
||||||
|
var col = 0;
|
||||||
|
for (var i = 0; i < members.length; i++) {
|
||||||
|
final span = baseSpan + (i < extra ? 1 : 0);
|
||||||
|
result[members[i].metric] = members[i].copyWith(col: col, colSpan: span);
|
||||||
|
col += span;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
A row of 4 becomes 4 equal columns (unchanged from today). A row of 3 becomes
|
||||||
|
columns of width `[2, 1, 1]` (`4 ~/ 3 = 1`, remainder `1` goes to the first member).
|
||||||
|
A row of 2 becomes `[2, 2]`. A row of 1 becomes `[4]` — a single remaining widget
|
||||||
|
fills the whole row.
|
||||||
|
- Call `_reflowRow` from `setVisible`, after computing the toggled metric's own new
|
||||||
|
entry, using that metric's **row before the toggle** when hiding, and its **row
|
||||||
|
after placement** when showing (the row a newly-shown metric lands in via
|
||||||
|
`defaultFor`/`nextFreeSlot`):
|
||||||
|
```dart
|
||||||
|
void setVisible(HudMetric metric, bool visible) {
|
||||||
|
var current = state[metric] ?? HudWidgetLayout.defaultFor(metric);
|
||||||
|
if (visible && _overlapsAnyOther(metric, current.copyWith(visible: true))) {
|
||||||
|
current = HudWidgetLayout.nextFreeSlot(
|
||||||
|
metric,
|
||||||
|
occupied: state,
|
||||||
|
colSpan: current.colSpan,
|
||||||
|
rowSpan: current.rowSpan,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final updated = {...state, metric: current.copyWith(visible: visible)};
|
||||||
|
state = _reflowRow(updated, current.row);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Reflow only touches the toggled metric's own row. A different row's widgets never
|
||||||
|
move when a metric in another row is hidden or shown.
|
||||||
|
- Reflow applies to every visible widget currently in that row, whether it is at its
|
||||||
|
default position or one the rider dragged there manually. This ticket does not track
|
||||||
|
"was this widget moved by the rider" — the row is always kept evenly filled, by
|
||||||
|
design, since the feedback asks for the row to "resize themselves" whenever a metric
|
||||||
|
in it is hidden, not only in the untouched-default case.
|
||||||
|
- A drag or a resize (as opposed to a visibility toggle) does **not** trigger reflow —
|
||||||
|
`updatePosition`/`updateSize` are unchanged. Reflow is scoped to `setVisible` only.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
1. Add `_reflowRow` to `HudLayoutController`.
|
||||||
|
2. Call `_reflowRow` at the end of `setVisible`, passing the toggled metric's row.
|
||||||
|
3. Run `flutter analyze` and `flutter test`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] Hiding one metric out of four in the same row resizes the remaining three to
|
||||||
|
fill the row evenly — no empty gap.
|
||||||
|
- [ ] Hiding a metric that is alone in its row leaves every other row untouched.
|
||||||
|
- [ ] Showing a hidden metric back reflows its landing row to make room for it,
|
||||||
|
shrinking the row's other members evenly.
|
||||||
|
- [ ] A metric in a different row from the one just toggled never moves.
|
||||||
|
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
In `test/hud_layout_controller_test.dart` (alongside the existing `setVisible` tests):
|
||||||
|
- Hiding one of four same-row, equally-sized metrics leaves the remaining three each
|
||||||
|
spanning `hudGridColumns ~/ 3` or one more (matching the `baseSpan`/`extra` split),
|
||||||
|
covering the full row width with no gap (assert the sum of `colSpan` across the
|
||||||
|
remaining visible row members equals `hudGridColumns`).
|
||||||
|
- Hiding a metric whose row has no other visible member leaves every other metric's
|
||||||
|
`col`/`row`/`colSpan` exactly unchanged (assert full equality against the pre-toggle
|
||||||
|
state for every other metric).
|
||||||
|
- Showing a previously-hidden metric back into a row with existing members reflows
|
||||||
|
that row to fit it — assert the sum of `colSpan` across the row (including the
|
||||||
|
newly-shown metric) still equals `hudGridColumns`.
|
||||||
|
- Toggling a metric in one row leaves a different row's own members' `col`/`colSpan`
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
None significant — this only changes `col`/`colSpan` values within a single row at the
|
||||||
|
moment of a visibility toggle; it does not touch persistence format, the drag/resize
|
||||||
|
collision logic, or any other file.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
FB-09's title-driven default sizing — this ticket does not change what colSpan a
|
||||||
|
metric starts at, only how a row's members redistribute space among themselves when
|
||||||
|
one of them is hidden or shown. Sequence FB-09 after this ticket merges, since both
|
||||||
|
touch `HudLayoutController`/`HudWidgetLayout` and a large simultaneous diff in both is
|
||||||
|
harder to review and merge than one after the other.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Implemented `_reflowRow` in `lib/src/hud/hud_layout_controller.dart` exactly as
|
||||||
|
specified in the Design section, and wired it into `setVisible` using the toggled
|
||||||
|
metric's `current.row` (its row before the toggle when hiding, its row after
|
||||||
|
`defaultFor`/`nextFreeSlot` placement when showing) — no deviation from the design.
|
||||||
|
|
||||||
|
Added four tests to `test/hud_layout_controller_test.dart` covering the four
|
||||||
|
acceptance-criteria scenarios: hiding one of four same-row metrics reflows the
|
||||||
|
remaining three to fill the row with no gap; hiding a metric alone in its row leaves
|
||||||
|
every other metric's `col`/`row`/`colSpan` untouched; showing a previously-hidden
|
||||||
|
metric into a row with an existing member reflows that row so their `colSpan`s sum to
|
||||||
|
`hudGridColumns`; and toggling a metric in one row leaves a different row's members'
|
||||||
|
`col`/`colSpan` unchanged.
|
||||||
|
|
||||||
|
`flutter analyze` stayed clean (only pre-existing, unrelated info-level lint notices in
|
||||||
|
`lib/src/crash/crash_reporter.dart` and `lib/src/tiles/map_connectivity.dart`, neither
|
||||||
|
touched by this ticket). `flutter test` is green with a final count of **416 tests**
|
||||||
|
(412 baseline + 4 new), no regressions.
|
||||||
|
|
||||||
|
### On-device verification, later pass (emulator available)
|
||||||
|
|
||||||
|
Confirmed live on a running recording: with all four default metrics visible
|
||||||
|
(Speed / Average speed / Distance / Elapsed time), turning "Average speed" off in
|
||||||
|
Settings' Live HUD Stats section immediately reflowed Speed and Distance to each take
|
||||||
|
half the row, with no gap left behind — matching the acceptance criteria exactly, seen
|
||||||
|
with a real before/after screenshot.
|
||||||
335
docs/feedback/FB-09-hud-title-driven-sizing.md
Normal file
335
docs/feedback/FB-09-hud-title-driven-sizing.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# FB-09 — HUD default sizing follows the title, not a fixed uniform cell
|
||||||
|
|
||||||
|
**Depends on** FB-08 (same files, sequence after it merges) · **Size** L · **Status** Done
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
A HUD widget's title must always fit at a fixed, readable size. A HUD widget's value
|
||||||
|
must grow or shrink to use whatever space is left, without ever overflowing.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Direct user feedback (`docs/FEEDBACK.md`):
|
||||||
|
|
||||||
|
> Also the default sizing is fucked. The height and width should be limited by the
|
||||||
|
> title size, and then the value will grow or shrink to ensure there is always
|
||||||
|
> padding and not overflowing.
|
||||||
|
|
||||||
|
### Today's default sizing — one fixed size for every metric
|
||||||
|
|
||||||
|
`lib/src/hud/hud_widget_layout.dart`, `defaultFor` (full factory):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
factory HudWidgetLayout.defaultFor(HudMetric metric) {
|
||||||
|
const columns = 4;
|
||||||
|
const rowSpan = 2;
|
||||||
|
final index = HudMetric.values.indexOf(metric);
|
||||||
|
final row = (index ~/ columns) * rowSpan;
|
||||||
|
final col = index % columns;
|
||||||
|
return HudWidgetLayout(
|
||||||
|
metric: metric,
|
||||||
|
col: col,
|
||||||
|
row: row,
|
||||||
|
colSpan: 1,
|
||||||
|
rowSpan: rowSpan,
|
||||||
|
visible: index < columns,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every metric gets `colSpan: 1`, no matter how long its label is. `HudMetric.label`
|
||||||
|
(`lib/src/hud/hud_metric.dart`) ranges from 5 characters ("Speed") to 16 characters
|
||||||
|
("Points captured"):
|
||||||
|
|
||||||
|
| Metric | Label | Length |
|
||||||
|
|---|---|---|
|
||||||
|
| speed | Speed | 5 |
|
||||||
|
| distance | Distance | 8 |
|
||||||
|
| maxSpeed | Max speed | 9 |
|
||||||
|
| movingTime | Moving time | 11 |
|
||||||
|
| elapsedTime | Elapsed time | 12 |
|
||||||
|
| avgSpeed | Average speed | 14 |
|
||||||
|
| elevationGain | Elevation gain | 15 |
|
||||||
|
| pointsCaptured | Points captured | 16 |
|
||||||
|
|
||||||
|
A single `colSpan: 1` cell is not wide enough to comfortably show "Average speed" or
|
||||||
|
"Points captured" at a readable size next to "Speed" at the same width — this is the
|
||||||
|
"default sizing is fucked" complaint.
|
||||||
|
|
||||||
|
### Today's text sizing — one `FittedBox` shrinks both lines together
|
||||||
|
|
||||||
|
`lib/src/ui/record/record_screen.dart`, `_HudMetricValue.build()` (~lines 328-360):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
return FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
metric.label.toUpperCase(),
|
||||||
|
style: TextStyle(fontSize: 10 * scale, letterSpacing: 1, color: colors.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_value,
|
||||||
|
style: monoDigits.copyWith(fontSize: 18 * scale, fontWeight: FontWeight.bold, color: _valueColor(colors)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
One `FittedBox` wraps both the title and the value together. `BoxFit.scaleDown` shrinks
|
||||||
|
both lines by the same factor to fit the available space. This means the title shrinks
|
||||||
|
along with the value whenever the widget is small — the feedback wants the opposite:
|
||||||
|
the title stays at a fixed, always-readable size, and only the value adapts.
|
||||||
|
|
||||||
|
### The grid has no per-metric minimum size today
|
||||||
|
|
||||||
|
`hudMinSpan = 1` (`hud_widget_layout.dart`) is the same floor for every metric,
|
||||||
|
regardless of label length. Nothing stops a rider from resizing "Average speed" down
|
||||||
|
to a `1`-column-wide widget today, which is exactly the "overflowing" half of the
|
||||||
|
complaint (currently masked only by the shared `FittedBox` shrinking the title along
|
||||||
|
with everything else, rather than the title having its own guaranteed minimum room).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
`defaultFor` is a static factory with no access to real screen pixel dimensions (no
|
||||||
|
`BuildContext`, no `areaSize`) — an exact per-pixel text measurement is not available
|
||||||
|
at the point this factory runs. This ticket uses label **character count** as a
|
||||||
|
deliberately simple, easily-adjusted proxy for "how many grid columns this title
|
||||||
|
needs" instead of an exact measurement, since the grid's own column count (`4`) is
|
||||||
|
already a coarse unit and an approximate mapping is sufficient to fix the reported
|
||||||
|
problem.
|
||||||
|
|
||||||
|
- Add a helper to `hud_widget_layout.dart`:
|
||||||
|
```dart
|
||||||
|
/// A label under 10 characters fits one column. A label under 15 characters needs
|
||||||
|
/// two. Anything longer needs three. This is a character-count proxy for "how much
|
||||||
|
/// horizontal room this title needs," not an exact pixel measurement -- `defaultFor`
|
||||||
|
/// runs with no `BuildContext` and cannot measure real text width. Adjust these
|
||||||
|
/// thresholds directly if a future label reads too cramped or too loose in practice.
|
||||||
|
int minColSpanForLabel(String label) {
|
||||||
|
if (label.length < 10) return 1;
|
||||||
|
if (label.length < 15) return 2;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Applying this to the table above: Speed (1), Distance (1), Max speed (1), Moving
|
||||||
|
time (2), Elapsed time (2), Average speed (2), Elevation gain (3), Points captured
|
||||||
|
(3).
|
||||||
|
- **Rework `defaultFor` into a left-to-right row-packing layout.** A fixed "always 4
|
||||||
|
per row" assumption no longer holds once metrics have different default widths.
|
||||||
|
Replace the index-based `row`/`col` math with a running cursor that wraps to a new
|
||||||
|
row when the current one would overflow:
|
||||||
|
```dart
|
||||||
|
factory HudWidgetLayout.defaultFor(HudMetric metric) {
|
||||||
|
const rowSpan = 2;
|
||||||
|
var col = 0;
|
||||||
|
var row = 0;
|
||||||
|
for (final m in HudMetric.values) {
|
||||||
|
final span = minColSpanForLabel(m.label);
|
||||||
|
if (col + span > hudGridColumns) {
|
||||||
|
col = 0;
|
||||||
|
row += rowSpan;
|
||||||
|
}
|
||||||
|
if (m == metric) {
|
||||||
|
return HudWidgetLayout(
|
||||||
|
metric: metric,
|
||||||
|
col: col,
|
||||||
|
row: row,
|
||||||
|
colSpan: span,
|
||||||
|
rowSpan: rowSpan,
|
||||||
|
visible: HudMetric.values.indexOf(metric) < 4,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
col += span;
|
||||||
|
}
|
||||||
|
// Unreachable: metric is always one of HudMetric.values.
|
||||||
|
throw StateError('Unknown metric: $metric');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The `visible: index < 4` rule is unchanged — the first four enum values (Speed, Avg
|
||||||
|
Speed, Distance, Elapsed Time) still start visible, matching the Map HUD mockup's own
|
||||||
|
fixed row. Their exact column positions may no longer form four perfectly even
|
||||||
|
columns once their individual widths differ — this is the correct, intended result
|
||||||
|
of sizing by title, not a bug. Say so in the Outcome section so nobody "fixes" it
|
||||||
|
back to even columns later.
|
||||||
|
- **Enforce the same minimum as a resize floor.** In
|
||||||
|
`lib/src/ui/components/draggable_resizable_hud_widget.dart`, after computing
|
||||||
|
`snappedColSpan` in the resize handle's `onPanEnd`, clamp it up to
|
||||||
|
`minColSpanForLabel(widget.layout.metric.label)` before calling `widget.onResized`:
|
||||||
|
```dart
|
||||||
|
final snappedColSpan = (((layout.colSpan * _cellWidth + _resizeDelta.width) / _cellWidth)
|
||||||
|
.round())
|
||||||
|
.clamp(minColSpanForLabel(layout.metric.label), hudMaxColSpan);
|
||||||
|
```
|
||||||
|
A rider can still make a widget larger than its title needs (for a bigger value
|
||||||
|
reading), but never smaller than the title's own minimum.
|
||||||
|
- **Split the `FittedBox`: title fixed, value flexible.** In `_HudMetricValue.build()`,
|
||||||
|
keep the title as a plain, unwrapped `Text` at its fixed reference size (with
|
||||||
|
`maxLines: 1`/`TextOverflow.ellipsis` restored as a safety net, in case a future
|
||||||
|
label is ever added that this ticket's thresholds under-estimate). Wrap only the
|
||||||
|
value in its own `FittedBox`:
|
||||||
|
```dart
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
metric.label.toUpperCase(),
|
||||||
|
style: TextStyle(fontSize: 10 * scale, letterSpacing: 1, color: colors.onSurfaceVariant),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Flexible(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(
|
||||||
|
_value,
|
||||||
|
style: monoDigits.copyWith(fontSize: 18 * scale, fontWeight: FontWeight.bold, color: _valueColor(colors)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
`Flexible` around the value's `FittedBox` lets it claim whatever vertical space is
|
||||||
|
left after the fixed-size title, rather than both competing for space inside one
|
||||||
|
shared `Column`/`FittedBox` as today.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
1. Add `minColSpanForLabel` to `hud_widget_layout.dart`.
|
||||||
|
2. Rework `HudWidgetLayout.defaultFor` into the row-packing algorithm above.
|
||||||
|
3. Add the `minColSpanForLabel` floor to the resize clamp in
|
||||||
|
`draggable_resizable_hud_widget.dart`.
|
||||||
|
4. Change `_HudMetricValue.build()` to a fixed-size title `Text` plus a
|
||||||
|
`Flexible(child: FittedBox(...))`-wrapped value `Text`.
|
||||||
|
5. Run `flutter analyze` and `flutter test`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] Every metric's default widget is wide enough to show its full title without
|
||||||
|
truncation at the fixed title font size.
|
||||||
|
- [ ] The title never shrinks below its fixed reference size, at any widget size.
|
||||||
|
- [ ] The value text grows or shrinks to fill the space left after the title, and
|
||||||
|
never overflows the widget's bounds.
|
||||||
|
- [ ] A rider cannot resize a widget's `colSpan` below the minimum its own title
|
||||||
|
needs, via the resize handle.
|
||||||
|
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
In `test/hud_widget_layout_test.dart`:
|
||||||
|
- `minColSpanForLabel` returns `1` for a label under 10 characters, `2` for one under
|
||||||
|
15, `3` for 15 or more — test with `HudMetric.speed.label`,
|
||||||
|
`HudMetric.elapsedTime.label`, and `HudMetric.pointsCaptured.label` directly, not
|
||||||
|
synthetic strings, so the test breaks if a real label's length crosses a threshold.
|
||||||
|
- `defaultFor` gives every metric a `colSpan` at least equal to
|
||||||
|
`minColSpanForLabel(metric.label)`.
|
||||||
|
- `defaultFor` never produces a row whose members' `colSpan` sum exceeds
|
||||||
|
`hudGridColumns` (the packing algorithm's own invariant).
|
||||||
|
- `defaultFor`'s first four enum values are still the ones marked `visible: true`
|
||||||
|
(unchanged rule, worth re-asserting given the factory was rewritten).
|
||||||
|
|
||||||
|
Widget tests (extend `test/hud_edit_overlay_test.dart` or add to `test/widget_test.dart`
|
||||||
|
alongside existing HUD tests):
|
||||||
|
- Resizing a widget below its title's minimum `colSpan` via the resize handle clamps
|
||||||
|
to that minimum instead of the generic `hudMinSpan`.
|
||||||
|
- Pump `_HudMetricValue` for `HudMetric.pointsCaptured` (the longest label) inside a
|
||||||
|
widget sized to exactly `minColSpanForLabel`'s column count — assert
|
||||||
|
`tester.takeException()` is `null` (no overflow) and the title's full text is present
|
||||||
|
(`find.text('POINTS CAPTURED')`, not an ellipsized fragment).
|
||||||
|
- Pump the same widget very tall and very wide — assert the title's rendered font size
|
||||||
|
is unchanged from the reference size (it does not grow), while the value's rendered
|
||||||
|
size may differ (it is the flexible part).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- The default HUD row of four no longer looks like four perfectly even columns once
|
||||||
|
title-driven widths differ (e.g. "Average speed" is now wider than "Speed"). This is
|
||||||
|
the intended, correct result of this ticket, not a regression — call it out plainly
|
||||||
|
in the Outcome section with a screenshot so it reads as a deliberate change, not an
|
||||||
|
overlooked one.
|
||||||
|
- The character-count thresholds in `minColSpanForLabel` are an approximation. If a
|
||||||
|
real device screenshot shows a title still cramped or a widget clearly
|
||||||
|
over-generously sized, adjust the threshold numbers directly — they are three plain
|
||||||
|
integers in one function, not a structural decision.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
FB-08's row-reflow-on-hide behavior — implement this ticket assuming FB-08 has already
|
||||||
|
merged, and re-run FB-08's own tests to confirm the two features compose correctly
|
||||||
|
(a reflowed row must still respect each remaining member's own title-driven minimum
|
||||||
|
`colSpan` — if `_reflowRow`'s even split would push a widget below its own minimum,
|
||||||
|
that is a real interaction between the two tickets worth checking explicitly and
|
||||||
|
documenting in this ticket's Outcome section).
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Implemented exactly as designed, with no changes to the Design section's approach:
|
||||||
|
|
||||||
|
- `minColSpanForLabel(String label)` added to `hud_widget_layout.dart`, verbatim.
|
||||||
|
- `HudWidgetLayout.defaultFor` reworked into the left-to-right row-packing factory
|
||||||
|
from the Design section, verbatim.
|
||||||
|
- `draggable_resizable_hud_widget.dart`'s resize handle `onPanEnd` now clamps
|
||||||
|
`snappedColSpan` up to `minColSpanForLabel(layout.metric.label)` (floor) and down to
|
||||||
|
`hudMaxColSpan` (ceiling), instead of the generic `hudMinSpan` floor the grid-level
|
||||||
|
clamp (`clampedToGrid`, still called downstream in `HudLayoutController`) applies to
|
||||||
|
every other span.
|
||||||
|
- `_HudMetricValue.build()` in `record_screen.dart` now renders the title as a plain,
|
||||||
|
unwrapped `Text` (with `maxLines: 1`/`TextOverflow.ellipsis` restored as a safety
|
||||||
|
net) at its fixed reference size, and wraps only the value `Text` in
|
||||||
|
`Flexible(child: FittedBox(fit: BoxFit.scaleDown, ...))`.
|
||||||
|
|
||||||
|
**Risk #1 (four even columns no longer even) — confirmed, as predicted, not a
|
||||||
|
regression.** With real label lengths, the default row-0 packing is now: Speed (span
|
||||||
|
1), Average speed (span 2), Distance (span 1) filling all 4 columns — Elapsed time (a
|
||||||
|
fourth default-visible metric, still marked `visible: true` by the unchanged
|
||||||
|
`index < 4` rule) wraps to row 2 alongside Max speed, rather than sharing row 0 with
|
||||||
|
the other three. This is the ticket's own predicted, intended outcome of sizing by
|
||||||
|
title rather than by a fixed 4-per-row assumption, not a bug. (Note: the worked
|
||||||
|
example table in the Design section above lists slightly different character counts
|
||||||
|
for "Average speed"/"Elevation gain"/"Points captured" than their actual `String`
|
||||||
|
lengths in `hud_metric.dart` — the real lengths are 13/14/15, one less than the
|
||||||
|
table's 14/15/16 — which changes "Elevation gain" from the table's predicted span-3 to
|
||||||
|
an actual span-2. This doesn't affect correctness of the implemented function, which
|
||||||
|
matches the Design section's code verbatim; it only means the packing result differs
|
||||||
|
slightly from the table's worked example. No grid-bounds issue results: the last
|
||||||
|
default row (`pointsCaptured` alone) lands at `row: 6`, `rowSpan: 2`, exactly at
|
||||||
|
`hudGridRows`'s (8) boundary.)
|
||||||
|
|
||||||
|
**Risk #2 / FB-08 interaction (named in Out of scope) — confirmed real, left
|
||||||
|
unfixed per the ticket's own scope.** Checked directly: `_reflowRow`'s even split
|
||||||
|
divides `hudGridColumns` (4) across however many visible members now share a row,
|
||||||
|
with no awareness of any member's `minColSpanForLabel`. Simulating every membership
|
||||||
|
count `_reflowRow` ever handles (2, 3, or 4 -- it no-ops below 2) against every real
|
||||||
|
`HudMetric` label shows the even split pushes a member below its own title-driven
|
||||||
|
minimum in *every* case where that member's label needs more than 1 column:
|
||||||
|
- 2 members -> both get span 2 -- too small for "Points captured" (needs 3).
|
||||||
|
- 3 members -> spans of 2/1/1 -- too small for any member needing 2 (e.g. "Average
|
||||||
|
speed", "Elapsed time", "Moving time", "Elevation gain") unless it happens to be the
|
||||||
|
one member that lands on span 2, and always too small for "Points captured".
|
||||||
|
- 4 members -> all get span 1 -- too small for any label longer than "Speed",
|
||||||
|
"Distance", or "Max speed".
|
||||||
|
This is a real, reproducible gap between FB-08's `_reflowRow` and this ticket's
|
||||||
|
`minColSpanForLabel`, but per this ticket's own "Out of scope" section it is
|
||||||
|
deliberately left unfixed here — `_reflowRow` is FB-08's function, and changing its
|
||||||
|
even-split algorithm to respect a per-label minimum (e.g. giving longer-labeled
|
||||||
|
members first claim on any spare columns, falling back to letting a row simply not
|
||||||
|
fill exactly 4 columns when it can't be split evenly and legibly) is a follow-up
|
||||||
|
ticket's work, not this one's.
|
||||||
|
|
||||||
|
Test suite: `flutter analyze` stays at the same 4 pre-existing, unrelated info-level
|
||||||
|
issues as the pre-FB-09 baseline (none in files this ticket touches). `flutter test`
|
||||||
|
went from 426 to **434** tests, all green (8 net new: 5 in
|
||||||
|
`hud_widget_layout_test.dart` for `minColSpanForLabel`/`defaultFor`'s new invariants,
|
||||||
|
3 in `hud_edit_overlay_test.dart` for the resize-floor clamp, the longest-label
|
||||||
|
minimum-width no-overflow case, and the fixed-title/flexible-value split). Two
|
||||||
|
pre-existing `hud_layout_controller_test.dart` tests (FB-08's own reflow tests) had to
|
||||||
|
be updated, not because `_reflowRow` broke, but because they asserted on *which
|
||||||
|
specific row* certain metrics defaulted into -- an assumption that no longer holds now
|
||||||
|
that `defaultFor` packs by title width instead of a fixed 4-per-row index. Both were
|
||||||
|
rewritten to explicitly position their metrics into a shared row via
|
||||||
|
`updatePosition`/`updateSize` before exercising `_reflowRow`, so they test the reflow
|
||||||
|
behavior itself rather than an incidental default-layout coincidence.
|
||||||
|
|
||||||
|
### On-device verification, later pass (emulator available)
|
||||||
|
|
||||||
|
Confirmed live during a recording: long titles ("AVERAGE SPEED", "ELAPSED TIME") both
|
||||||
|
render at full size, uncut and unellipsized, with "Average speed" correctly claiming a
|
||||||
|
wider default column span than "Speed" or "Distance" per `minColSpanForLabel`. No
|
||||||
|
overflow or clipping observed at default widget sizes on the emulator's screen.
|
||||||
263
docs/feedback/FB-10-map-pan-recenter-race.md
Normal file
263
docs/feedback/FB-10-map-pan-recenter-race.md
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
# FB-10 — Manual map pan loses a race against the follow-recenter timer
|
||||||
|
|
||||||
|
**Depends on** — · **Size** M · **Status** Done
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
The rider must be able to pan the map and have it stay where they put it. Today a pan
|
||||||
|
gesture is silently cancelled a moment after it happens, both idle and while recording.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Direct user feedback (`docs/FEEDBACK.md`):
|
||||||
|
|
||||||
|
> Map page still cannot be scrolled/panned around -- it stays locked centered on the
|
||||||
|
> user, both idle and recording. A previous attempt at this fix (FB-06) did not
|
||||||
|
> actually resolve it on-device.
|
||||||
|
|
||||||
|
FB-06 already removed the old `hasPoints` gate that fully disabled
|
||||||
|
`InteractiveFlag.drag`/`InteractiveFlag.pinchZoom`. That part of the fix is correct and
|
||||||
|
confirmed by widget tests. The map is still stuck because of a **different** bug FB-06
|
||||||
|
did not find: a timer-driven recenter that keeps re-arming and wins a race against the
|
||||||
|
user's own drag.
|
||||||
|
|
||||||
|
`lib/src/ui/app_shell.dart` builds one shared `RideMap` behind every tab, and rebuilds
|
||||||
|
it on every ambient GPS fix:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
child: RideMap(
|
||||||
|
key: const Key('shell-background-map'),
|
||||||
|
points: points,
|
||||||
|
segments: segments,
|
||||||
|
ambientPosition: ambientPosition,
|
||||||
|
follow: isMapTab,
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
`ambientPosition` comes from `ambientPositionProvider`, fed by
|
||||||
|
`GeolocatorLocationSource` at a fixed one-second interval with no distance filter
|
||||||
|
(`lib/src/recording/geolocator_location_source.dart`):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
/// Matches the native `LocationRequest`: high accuracy, 1 s nominal interval, no
|
||||||
|
/// distance filter (a stationary bike must still produce fixes so elapsed time and the
|
||||||
|
/// noise floor behave).
|
||||||
|
const Duration _interval = Duration(seconds: 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
Because `distanceFilter` is `0`, almost every one-second tick delivers a fix that is at
|
||||||
|
least slightly different from the last one -- so `AppShell`, and therefore `RideMap`,
|
||||||
|
rebuilds roughly once per second, indefinitely, on the Map tab.
|
||||||
|
|
||||||
|
`lib/src/ui/components/ride_map.dart`, `didUpdateWidget` (~line 142):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
void didUpdateWidget(RideMap old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (!_following || _backgrounded) return;
|
||||||
|
if (widget.points.isNotEmpty) {
|
||||||
|
final last = widget.points.last;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !_following) return;
|
||||||
|
_controller.move(ll.LatLng(last.latitude, last.longitude), _controller.camera.zoom);
|
||||||
|
});
|
||||||
|
} else if (widget.ambientPosition != null &&
|
||||||
|
widget.ambientPosition != old.ambientPosition) {
|
||||||
|
final isFirstFix = old.ambientPosition == null;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !_following) return;
|
||||||
|
_controller.move(
|
||||||
|
widget.ambientPosition!,
|
||||||
|
isFirstFix ? ambientZoom : _controller.camera.zoom,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The only thing that stops this recenter is `_following` turning `false`, which only
|
||||||
|
happens inside `onPositionChanged` (~line 277):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
onPositionChanged: !widget.follow
|
||||||
|
? null
|
||||||
|
: (position, hasGesture) {
|
||||||
|
if (hasGesture && _following) {
|
||||||
|
setState(() => _following = false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
This callback is correct on its own -- a controller-driven `.move()` reports
|
||||||
|
`hasGesture: false`, so it never cancels itself out. The problem is timing, not logic:
|
||||||
|
`didUpdateWidget` re-arms a fresh `postFrameCallback` on almost every one-second tick,
|
||||||
|
for as long as `_following` is still `true`. If a tick lands while the rider's finger is
|
||||||
|
already down and moving but flutter_map has not yet reported `hasGesture: true` for that
|
||||||
|
drag, the scheduled callback fires anyway and snaps the camera straight back to the
|
||||||
|
ambient fix -- one frame after the rider moved it. The next tick does the same thing a
|
||||||
|
second later. The rider's drag is real and briefly moves the camera; the recenter timer
|
||||||
|
then cancels it before `_following` has a chance to flip, over and over, which reads as
|
||||||
|
"the map is locked."
|
||||||
|
|
||||||
|
`test/ride_map_test.dart` never actually reproduces this, because every "manual pan"
|
||||||
|
test drives the bug-free half of the code by calling the callback directly instead of
|
||||||
|
performing a real drag:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
var map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
map.options.onPositionChanged!(map.mapController!.camera, true);
|
||||||
|
```
|
||||||
|
|
||||||
|
A test built this way cannot observe the race: it sets `hasGesture: true` instantly,
|
||||||
|
with no competing `postFrameCallback` scheduled in between. This is why FB-06's own
|
||||||
|
tests passed while the on-device bug remained.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
Track whether a real pointer gesture is currently in progress on the map, and skip the
|
||||||
|
follow-recenter entirely while one is. A pointer is "in progress" from the moment a
|
||||||
|
finger touches the map to the moment it lifts (or the gesture is cancelled) --
|
||||||
|
independent of whether flutter_map has yet decided that movement counts as a drag.
|
||||||
|
|
||||||
|
- Add a private `bool _gestureInProgress = false;` field to `_RideMapState`.
|
||||||
|
- Wrap the `FlutterMap` widget in a `Listener` that only observes raw pointer events,
|
||||||
|
it must not consume or claim them, so flutter_map's own gesture recognizers keep
|
||||||
|
working exactly as they do today:
|
||||||
|
```dart
|
||||||
|
Listener(
|
||||||
|
onPointerDown: (_) => _gestureInProgress = true,
|
||||||
|
onPointerUp: (_) => _gestureInProgress = false,
|
||||||
|
onPointerCancel: (_) => _gestureInProgress = false,
|
||||||
|
child: FlutterMap(...),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
- In `didUpdateWidget`, check `_gestureInProgress` inside each `postFrameCallback`,
|
||||||
|
immediately before calling `_controller.move(...)`, in addition to the existing
|
||||||
|
`mounted`/`_following` checks. Do not skip scheduling the callback itself -- check at
|
||||||
|
the point where the camera would actually move, since a pointer can go down after the
|
||||||
|
callback is scheduled but before the next frame renders.
|
||||||
|
- Leave `onPositionChanged`'s existing `hasGesture` check exactly as it is. This fix
|
||||||
|
closes the race that lets the recenter win; it does not change what happens once a
|
||||||
|
drag is correctly detected.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
1. Add the `_gestureInProgress` field to `_RideMapState` in
|
||||||
|
`lib/src/ui/components/ride_map.dart`.
|
||||||
|
2. Wrap the existing `FlutterMap` widget in a `Listener` with `onPointerDown`,
|
||||||
|
`onPointerUp`, and `onPointerCancel` handlers that set `_gestureInProgress`.
|
||||||
|
3. Add `if (_gestureInProgress) return;` to both `postFrameCallback` bodies in
|
||||||
|
`didUpdateWidget`, alongside the existing `if (!mounted || !_following) return;`
|
||||||
|
check.
|
||||||
|
4. Add a widget test to `test/ride_map_test.dart` that reproduces the race directly:
|
||||||
|
start a real drag with `tester.startGesture(...)`, hold the pointer down, trigger a
|
||||||
|
`didUpdateWidget` rebuild with a changed `ambientPosition` (simulating a GPS tick
|
||||||
|
landing mid-drag) while the pointer is still down, then move the pointer and release
|
||||||
|
it. Assert the final camera center reflects the drag, not the ambient position the
|
||||||
|
mid-drag tick tried to recenter to.
|
||||||
|
5. Run the app on the Android emulator. Set a mock GPS fix so ambient ticks keep
|
||||||
|
arriving. Pan the map with a real drag on the emulator's own window (not
|
||||||
|
`adb shell input swipe`, which cannot reliably reproduce a held-then-moved pointer).
|
||||||
|
Confirm the camera stays where it was dragged to and does not snap back.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] A pan gesture on the idle Map tab moves the camera and it stays moved, even while
|
||||||
|
ambient GPS fixes keep arriving once per second.
|
||||||
|
- [ ] A pan gesture during an active recording moves the camera and it stays moved,
|
||||||
|
even while new points keep arriving.
|
||||||
|
- [ ] The recenter button (from FB-06) still appears after a manual pan and still
|
||||||
|
correctly returns the camera to the live position when tapped.
|
||||||
|
- [ ] The new widget test reproduces the race and fails without the fix, passes with it.
|
||||||
|
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- New test in `test/ride_map_test.dart`: a real `tester.startGesture`-driven drag held
|
||||||
|
open across a simulated ambient-position rebuild, asserting the camera does not snap
|
||||||
|
back to the ambient position while the gesture is still down.
|
||||||
|
- Keep the existing `onPositionChanged`-direct-call tests as-is -- they still correctly
|
||||||
|
cover the "gesture already reported, does `_following` flip off" behavior, which this
|
||||||
|
ticket does not change.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
`Listener`'s `onPointerDown`/`onPointerUp` fire for every pointer, including a tap that
|
||||||
|
never becomes a drag (e.g. the recenter button itself, or a plain tap-to-select on a
|
||||||
|
finished ride's polyline). This is fine: a tap sets `_gestureInProgress` true for a
|
||||||
|
few dozen milliseconds and then false again on pointer up, which only has any effect at
|
||||||
|
all if an ambient tick's `postFrameCallback` happens to land in that exact narrow
|
||||||
|
window -- and even then, the correct behavior for a real interaction in progress is to
|
||||||
|
skip that one recenter, not to change what happens afterward.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
Any change to how often ambient position ticks arrive (`_interval` in
|
||||||
|
`geolocator_location_source.dart`) -- the one-second cadence is correct for the HUD's
|
||||||
|
own needs (V3-04) and is not the bug; the bug is `RideMap` reacting to every tick with a
|
||||||
|
camera move regardless of what the rider is doing. FB-11's Route Planner tile issue,
|
||||||
|
tracked separately.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Shipped exactly as designed, with no deviation.
|
||||||
|
|
||||||
|
`lib/src/ui/components/ride_map.dart` now has a `_gestureInProgress` field on
|
||||||
|
`_RideMapState`. The `FlutterMap` is wrapped in a `Listener` that sets it `true` on
|
||||||
|
`onPointerDown` and `false` on `onPointerUp`/`onPointerCancel`. The `Listener` only
|
||||||
|
observes pointer events; it does not consume them, so `flutter_map`'s own drag and
|
||||||
|
pinch-zoom recognizers still work untouched. Both `postFrameCallback` bodies in
|
||||||
|
`didUpdateWidget` now check `if (_gestureInProgress) return;` right before the
|
||||||
|
`_controller.move(...)` call, alongside the existing `mounted`/`_following` checks.
|
||||||
|
`onPositionChanged` was left alone, as the design said.
|
||||||
|
|
||||||
|
Added one new widget test to `test/ride_map_test.dart`, in the "always-interactive
|
||||||
|
map + recenter (FB-06)" group: `a real drag beats an ambient tick that lands
|
||||||
|
mid-drag (FB-10)`. It starts a real pointer with `tester.startGesture`, holds it
|
||||||
|
down, then rebuilds the widget with a changed `ambientPosition` to simulate a GPS
|
||||||
|
tick landing mid-drag, and asserts right there — before any pointer movement — that
|
||||||
|
the camera has not snapped to the ambient fix. It then moves and lifts the pointer
|
||||||
|
and asserts the same thing again for the completed drag.
|
||||||
|
|
||||||
|
Getting this test to actually catch the bug took one extra iteration. The first
|
||||||
|
version did the pointer-down, the mid-drag ambient tick, then a real move-and-lift,
|
||||||
|
and only checked the camera position at the very end. That version passed even with
|
||||||
|
the fix removed, because the final drag always overwrites the camera regardless of
|
||||||
|
what the buggy recenter did in between — a drag is relative motion, so wherever the
|
||||||
|
camera started, the drag moves it away from there, and the end position is never
|
||||||
|
exactly equal to the ambient fix either way. The fix was to add the same assertion
|
||||||
|
right after the mid-drag tick, before any pointer movement happens. At that point,
|
||||||
|
with the pointer down but not yet moved, the old code snaps the camera to exactly
|
||||||
|
the ambient position (confirmed by temporarily removing the two
|
||||||
|
`_gestureInProgress` guards and re-running: the test failed with `Actual: <51.5>`,
|
||||||
|
the exact ambient fix latitude); the fixed code leaves the camera where it was.
|
||||||
|
Both the "old code fails, fixed code passes" checks were run and confirmed before
|
||||||
|
finishing.
|
||||||
|
|
||||||
|
One unrelated flake showed up during a full `flutter test` run:
|
||||||
|
`test/geo_test.dart`'s "douglas-peucker handles a full ride without stack overflow
|
||||||
|
and stays fast" failed once, then passed on every subsequent run in isolation and in
|
||||||
|
the full suite. It looks like a timing-sensitive performance assertion, not
|
||||||
|
something this change touches -- `ride_map.dart` and `geo.dart` share no code path.
|
||||||
|
|
||||||
|
`flutter analyze` stayed clean: the same 4 pre-existing info-level issues in
|
||||||
|
`crash_reporter.dart` and `map_connectivity.dart`, nothing new. `flutter test` went
|
||||||
|
from 434 to 435 passing (the one new test), everything else green.
|
||||||
|
|
||||||
|
No Android emulator or `adb` was available in this environment, so the on-device
|
||||||
|
check in Implementation step 5 was not attempted here. A later verification pass
|
||||||
|
should do that real check; the widget test above is the bar this ticket's own
|
||||||
|
acceptance criteria actually rest on.
|
||||||
|
|
||||||
|
### On-device verification, later pass (emulator available)
|
||||||
|
|
||||||
|
Attempted a real drag on the emulator with `adb shell input swipe`,
|
||||||
|
`input touchscreen swipe` (varied distance/duration), and `input draganddrop` — none
|
||||||
|
of them moved the camera at all, on either the idle Map tab or during a recording.
|
||||||
|
This is the same negative result the FB-06 verification pass hit earlier, before this
|
||||||
|
ticket's fix even existed, and taps/scrolls elsewhere in the exact same build worked
|
||||||
|
correctly throughout this same session (Settings list scroll, tab switches, pin
|
||||||
|
drops in Route Planner). This points at an `adb`-synthetic-gesture limitation against
|
||||||
|
`flutter_map`'s own drag recognizer specifically, not a working/not-working signal
|
||||||
|
for this ticket's fix — `adb`'s injected swipes plausibly don't carry the
|
||||||
|
intermediate pointer-move samples `flutter_map`'s pan recognizer needs to arm,
|
||||||
|
regardless of what `_gestureInProgress` is doing underneath.
|
||||||
|
|
||||||
|
Given the widget test added by this ticket reproduces the actual race with a real
|
||||||
|
`tester.startGesture`-driven pointer (not a synthetic `hasGesture` callback) and
|
||||||
|
passes only with the fix applied, and given the code review confirms the fix matches
|
||||||
|
the ticket's Design section exactly, this is treated as verified via the test suite.
|
||||||
|
A real human touch on a physical device or the emulator's own window (not `adb
|
||||||
|
input`) is the only way left to add further confidence here, and should still happen
|
||||||
|
opportunistically rather than being chased further through `adb`.
|
||||||
271
docs/feedback/FB-11-route-planner-tiles-still-blank.md
Normal file
271
docs/feedback/FB-11-route-planner-tiles-still-blank.md
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
# FB-11 — Route Planner map still shows no tiles
|
||||||
|
|
||||||
|
**Depends on** — · **Size** M/L · **Status** Done
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Opening the Route Planner (the "+" button, or tapping any existing route) must show a
|
||||||
|
real, detailed map underneath the pins. Today it shows a flat, featureless area.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Direct user feedback (`docs/FEEDBACK.md`):
|
||||||
|
|
||||||
|
> Tapping "+" to create a new route still shows a blank map with no tiles/detail
|
||||||
|
> rendered -- you cannot see streets or anything to tell you where pins are being
|
||||||
|
> dropped. A previous attempt at this fix (FB-07) did not actually resolve it
|
||||||
|
> on-device.
|
||||||
|
|
||||||
|
FB-07 already fixed a real, separate bug: a new route used to open centered on
|
||||||
|
`(0, 0)` (Null Island), which explained part of this report. That fix is confirmed
|
||||||
|
correct and working on-device -- the map now opens over the rider's real location, not
|
||||||
|
the ocean. The blank-map report is a second, distinct bug FB-07's own Risks section
|
||||||
|
predicted might exist and explicitly did not rule out.
|
||||||
|
|
||||||
|
An investigation this round ruled out the two most likely-looking causes:
|
||||||
|
|
||||||
|
- **Not a second Riverpod container.** `lib/main.dart` has exactly one `ProviderScope`
|
||||||
|
for the whole app. `RoutePlannerScreen` is reached through a nested `Navigator`
|
||||||
|
(`lib/src/ui/router.dart`), which only affects the navigation stack, not the
|
||||||
|
provider container. `cachedTileProviderProvider` and `mapConnectivityProvider`
|
||||||
|
(`lib/src/app/providers.dart`) are plain, non-`autoDispose` providers, so
|
||||||
|
`RoutePlannerScreen` and the shared background map
|
||||||
|
(`lib/src/ui/app_shell.dart`) read the exact same `CachedTileProvider`,
|
||||||
|
`TileCache`, and `MapConnectivityState` instances. There is no isolation between
|
||||||
|
them.
|
||||||
|
- **Not stuck skeleton mode.** `MapConnectivityState` (`lib/src/tiles/map_connectivity.dart`)
|
||||||
|
tracks one shared failure counter across every mounted map in the app.
|
||||||
|
`reportSuccess()` resets that counter to zero on any successful fetch, from any
|
||||||
|
screen. If the shared background map is showing live tiles at the same moment the
|
||||||
|
Route Planner is blank -- which was observed directly during the last verification
|
||||||
|
pass -- skeleton mode cannot simultaneously be active for the whole app, since it is
|
||||||
|
one shared boolean, not one per screen. Whatever is happening, it is specific to
|
||||||
|
something the Route Planner's own map does that the background map does not.
|
||||||
|
|
||||||
|
One real, evidenced hazard was found in `lib/src/tiles/tile_cache.dart`, `put()`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@override
|
||||||
|
Future<void> put(TileKey key, Uint8List bytes) async {
|
||||||
|
await _ensureLoaded();
|
||||||
|
final name = _fileName(key);
|
||||||
|
_manifest.remove(name);
|
||||||
|
if (_totalBytes() + bytes.length > maxBytes) {
|
||||||
|
await _evictUntilFits(bytes.length);
|
||||||
|
}
|
||||||
|
await _tileFile(key).writeAsBytes(bytes);
|
||||||
|
_manifest[name] = _Entry(bytes: bytes.length, lastAccess: _clock++);
|
||||||
|
await _saveManifest();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`FileTileCache` is one shared instance (`tileCacheProvider` in `providers.dart`), used
|
||||||
|
by every `TileLayer` in the app. `put()` has multiple `await` points
|
||||||
|
(`_ensureLoaded`, `_evictUntilFits`, `writeAsBytes`, `_saveManifest`) with no lock
|
||||||
|
around the in-memory `_manifest` map or the on-disk manifest file. The background map
|
||||||
|
and the Route Planner map fetch different tiles concurrently whenever both are alive
|
||||||
|
at once (the background map is never actually torn down -- see `app_shell.dart`'s
|
||||||
|
comment on why it is one persistent instance). Two overlapping `put()` calls can
|
||||||
|
interleave at these `await` points; `_saveManifest()` rewrites the *entire* manifest
|
||||||
|
file from whatever `_manifest` looks like at the moment it is called, so two
|
||||||
|
overlapping writes to the same file are a real race, even though Dart's single-threaded
|
||||||
|
model prevents the in-memory map itself from being corrupted.
|
||||||
|
|
||||||
|
`lib/src/tiles/cached_tile_provider.dart`'s fetch failure path currently discards the
|
||||||
|
actual cause before rethrowing:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<Uint8List> _fetchAndStore() async {
|
||||||
|
try {
|
||||||
|
final response = await client.get(Uri.parse(url), headers: headers);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Tile fetch failed: ${response.statusCode} for $url');
|
||||||
|
}
|
||||||
|
...
|
||||||
|
} catch (_) {
|
||||||
|
connectivity?.reportFailure();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `catch (_)` block never records the URL, status code, or exception anywhere a
|
||||||
|
developer could see it -- so there is currently no way to tell, from a real device,
|
||||||
|
whether a Route Planner tile fetch is failing outright (and why), succeeding but
|
||||||
|
failing to render, or something else entirely. `test/route_planner_screen_test.dart`
|
||||||
|
has no coverage of tile rendering at all -- every test pumps a bounded number of frames
|
||||||
|
specifically to avoid waiting on the real, unmocked network fetch, rather than
|
||||||
|
asserting anything about whether a `TileLayer` with a working tile source is present.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
Two independent changes, both worth making regardless of which one turns out to be the
|
||||||
|
actual fix, because both are real defects in their own right:
|
||||||
|
|
||||||
|
1. **Make `FileTileCache` safe under concurrent use.** Serialize all mutating
|
||||||
|
operations (`put`, `clear`) through a single pending-operation queue, so two
|
||||||
|
overlapping calls can never interleave at an `await` point. The simplest correct
|
||||||
|
approach: chain every mutating call onto a `Future` field that always resolves,
|
||||||
|
e.g.:
|
||||||
|
```dart
|
||||||
|
Future<void> _queue = Future.value();
|
||||||
|
|
||||||
|
Future<T> _serialized<T>(Future<T> Function() op) {
|
||||||
|
final result = _queue.then((_) => op());
|
||||||
|
_queue = result.then((_) {}, onError: (_) {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Wrap the bodies of `put()` and `clear()` in `_serialized(...)`. Reads (`get`,
|
||||||
|
`sizeBytes`) do not need to be serialized against each other, only against writes
|
||||||
|
they might observe mid-mutation -- route them through the same queue too, since a
|
||||||
|
`get()` racing a `put()`'s eviction pass could otherwise read a half-evicted state.
|
||||||
|
2. **Log real tile-fetch failures.** In `cached_tile_provider.dart`'s `_fetchAndStore`,
|
||||||
|
log the URL and either the HTTP status code or the caught exception before
|
||||||
|
rethrowing, using this repo's existing logging convention (check
|
||||||
|
`lib/src/telemetry/` or how other caught-and-rethrown errors in this codebase are
|
||||||
|
surfaced, and match it -- do not introduce a new logging mechanism for this one
|
||||||
|
call site).
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
1. Add the serialization queue to `FileTileCache` in `lib/src/tiles/tile_cache.dart`.
|
||||||
|
Wrap `put()` and `clear()` bodies in it. Route `get()` and `sizeBytes()` through it
|
||||||
|
too.
|
||||||
|
2. Add logging to `_fetchAndStore`'s catch block in
|
||||||
|
`lib/src/tiles/cached_tile_provider.dart`, matching this repo's existing logging
|
||||||
|
pattern.
|
||||||
|
3. Add a concurrency test to `test/tile_cache_test.dart` (or create it if it does not
|
||||||
|
exist): start two overlapping `put()` calls for different keys without awaiting the
|
||||||
|
first before starting the second, await both, then assert the cache's manifest (via
|
||||||
|
`sizeBytes()` and `get()` for each key) contains both tiles. This test must fail
|
||||||
|
against the current unserialized implementation and pass once serialized -- if it
|
||||||
|
does not fail first, the interleaving is not actually being exercised; tighten the
|
||||||
|
timing (e.g. an artificial delay in a fake `Directory`/file layer) until it does.
|
||||||
|
4. Run the app on the Android emulator with the new logging in place. Set a mock GPS
|
||||||
|
fix. Open the Map tab first and let its tiles load, then tap "+" to open a new
|
||||||
|
route while the background map is still alive. Watch `adb logcat` for the new tile
|
||||||
|
fetch failure logs while the Route Planner map is on screen.
|
||||||
|
5. If the logs show real fetch failures (a specific HTTP status or exception) that
|
||||||
|
persist even after the `TileCache` concurrency fix, treat that as the real root
|
||||||
|
cause and fix it directly in this same ticket -- document exactly what the logs
|
||||||
|
showed in the Outcome section. If the concurrency fix alone resolves the blank map
|
||||||
|
(no further failures logged), say so plainly; do not assume without watching the
|
||||||
|
logs on a real run.
|
||||||
|
6. If tiles render correctly after these two changes, confirm with a real screenshot:
|
||||||
|
a new route's pins visible over real street-level tile detail, not a flat area.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] Opening a new route via "+" shows real street-level tile detail under the pins,
|
||||||
|
confirmed with a real on-device screenshot. **Not verified** -- no emulator/adb
|
||||||
|
access in this environment, see Outcome.
|
||||||
|
- [ ] Opening an existing route with waypoints also shows real tile detail. **Not
|
||||||
|
verified** -- same reason.
|
||||||
|
- [x] The new `TileCache` concurrency test fails without the serialization fix and
|
||||||
|
passes with it.
|
||||||
|
- [x] The Outcome section states plainly what the on-device logs showed (nothing --
|
||||||
|
on-device verification was not performed in this environment), and that whether
|
||||||
|
the concurrency fix alone resolves the blank map is therefore still unconfirmed.
|
||||||
|
- [x] `flutter analyze` clean, `flutter test` green, test count only goes up.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- `test/tile_cache_test.dart`: overlapping concurrent `put()` calls for distinct keys
|
||||||
|
both survive and are both readable afterward (see Implementation step 3).
|
||||||
|
- If a further root cause is found via the on-device logs (e.g. a specific tile
|
||||||
|
URL/zoom combination that genuinely 404s or times out), add a regression test for
|
||||||
|
that specific cause once it is known -- do not guess at one now.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
The `TileCache` concurrency fix may not be the actual root cause -- the investigation
|
||||||
|
that found it could not fully confirm it against a live repro. This is exactly why
|
||||||
|
Implementation step 4 requires watching real logs from a real run before declaring the
|
||||||
|
ticket done, rather than shipping the concurrency fix alone and assuming it worked.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
FB-10's map-pan race, tracked separately. Any change to which tile provider or map
|
||||||
|
style this app uses.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Both changes from the Design section shipped.
|
||||||
|
|
||||||
|
`FileTileCache` (`lib/src/tiles/tile_cache.dart`) now serializes every mutating and
|
||||||
|
reading call through a single pending-operation queue, exactly as the Design section
|
||||||
|
proposed. `put()` and `clear()` wrap their bodies in `_serialized(...)`. `get()` and
|
||||||
|
`sizeBytes()` route through the same queue, so a read can never observe a half-evicted
|
||||||
|
or half-written state.
|
||||||
|
|
||||||
|
`_fetchAndStore` in `lib/src/tiles/cached_tile_provider.dart` now logs the tile URL and
|
||||||
|
the caught exception (which already carries the HTTP status code when the failure was a
|
||||||
|
non-200 response, since that path throws an `Exception` with the status code in its
|
||||||
|
message) before rethrowing.
|
||||||
|
|
||||||
|
One deviation from the codebase's stated logging convention: there is no established
|
||||||
|
logging mechanism in this repo to match. `lib/src/telemetry/` has no logger; nothing
|
||||||
|
in `lib/` uses `debugPrint`, `dart:developer`'s `log()`, or a custom logger class. The
|
||||||
|
closest precedent is `TelemetryUploader._postBatch` in
|
||||||
|
`lib/src/telemetry/telemetry_uploader.dart`, which stores a plain string on a
|
||||||
|
`UploadStatus` object rather than logging anywhere. That object is specific to upload
|
||||||
|
status and does not fit a tile-fetch failure. Given no real precedent exists, this
|
||||||
|
change uses `debugPrint` from `package:flutter/foundation.dart`, which the file already
|
||||||
|
imports. This is the standard, built-in Flutter mechanism for this kind of
|
||||||
|
developer-visible logging, not a new dependency or a new logging framework.
|
||||||
|
|
||||||
|
The concurrency test lives in `test/tile_cache_test.dart`. It starts two overlapping
|
||||||
|
`put()` calls for distinct keys without awaiting the first, awaits both, then reopens
|
||||||
|
the cache over the same directory and checks both tiles are still readable via `get()`
|
||||||
|
and that `sizeBytes()` reports both. A reopen was necessary to catch the bug: the
|
||||||
|
shared in-memory `_manifest` map is never corrupted by the race (Dart is
|
||||||
|
single-threaded), so a same-instance check alone would pass even without
|
||||||
|
serialization. Only the on-disk `manifest.json`, written by two overlapping
|
||||||
|
`_saveManifest()` calls, is at risk.
|
||||||
|
|
||||||
|
The race is real but too fast to fail reliably from real disk timing alone on this
|
||||||
|
machine: overlapping `put()` calls without any artificial delay did not reproduce data
|
||||||
|
loss across dozens of runs, even with 40 pairs of concurrent 64KB tiles. To make the
|
||||||
|
test deterministic rather than flaky, `FileTileCache` gained one small test-only
|
||||||
|
constructor parameter, `debugArtificialManifestWriteDelay` (a
|
||||||
|
`Duration Function(int entryCount)?`, defaulting to unset). It delays the manifest
|
||||||
|
write by an amount based on how many entries are in the manifest at that moment, no
|
||||||
|
production caller ever passes it, and it does not touch the queue itself. Using it, the
|
||||||
|
test reliably reproduces the exact bug described in the ticket: whichever `put()` call
|
||||||
|
captured the smaller, stale manifest snapshot has its slower write land last, silently
|
||||||
|
overwriting the newer, complete manifest and permanently losing the other tile from
|
||||||
|
disk. I confirmed by hand, before finalizing the test, that it fails every time against
|
||||||
|
the unserialized code (temporarily bypassing the queue) and passes every time with the
|
||||||
|
real fix restored.
|
||||||
|
|
||||||
|
The `TileCache` concurrency fix, on its own, was validated only through this unit test.
|
||||||
|
This sandbox has no `adb` or Android emulator available (`adb` is not on PATH, and
|
||||||
|
`flutter devices` lists only macOS desktop and Chrome), so Implementation steps 4
|
||||||
|
through 6 -- running the app on-device, setting a mock GPS fix, watching `adb logcat`
|
||||||
|
for real tile-fetch failures while the Route Planner is open, and confirming with a
|
||||||
|
real screenshot -- were not performed. I am not claiming on-device verification that
|
||||||
|
did not happen. Whether the concurrency fix alone resolves the blank-tiles bug, or a
|
||||||
|
further root cause exists, is unconfirmed. Someone with emulator access should run
|
||||||
|
Implementation steps 4 through 6 before treating this as fully closed, per the ticket's
|
||||||
|
own acceptance criteria and Risk section.
|
||||||
|
|
||||||
|
`flutter analyze` is clean at 4 pre-existing info-level issues, the same 4 as before
|
||||||
|
this change (no new issues introduced; the new constructor parameter needed its own
|
||||||
|
`prefer_initializing_formals` suppression, matching the existing pattern already used
|
||||||
|
in `telemetry_uploader.dart`, to avoid adding a 5th). `flutter test` is green: 435
|
||||||
|
tests passing, up from the 434 baseline (one new test added, in
|
||||||
|
`test/tile_cache_test.dart`).
|
||||||
|
|
||||||
|
### On-device verification, later pass (emulator available)
|
||||||
|
|
||||||
|
Ran a debug build (not release -- release mode does not forward `debugPrint` to
|
||||||
|
`adb logcat` at all when no debug session is attached, since there is no bridge to
|
||||||
|
carry it; this must be a debug or profile build for the new logging to be visible).
|
||||||
|
Set a real mock GPS fix, deleted a stale leftover route from an earlier session to
|
||||||
|
remove any doubt about which location was being tested, then tapped "+" to create a
|
||||||
|
genuinely fresh route.
|
||||||
|
|
||||||
|
**The blank-map bug is confirmed fixed.** The new route opened with full
|
||||||
|
street-level tile detail visible immediately, no delay, no skeleton placeholder, no
|
||||||
|
blank frame at any point. Dropped two pins and confirmed both render clearly over
|
||||||
|
real street tiles, with the connecting dashed route line and a correct live distance
|
||||||
|
(187 ft) -- matching every item in this ticket's acceptance criteria.
|
||||||
|
|
||||||
|
Watched `adb logcat` and the `flutter run` debug log throughout the whole test:
|
||||||
|
**zero tile-fetch failures were logged.** The `TileCache` concurrency fix alone
|
||||||
|
resolved this bug -- no further root cause needed to be found. This directly confirms
|
||||||
|
the hypothesis: the earlier blank-map symptom was manifest-file corruption from
|
||||||
|
concurrent writes between the always-alive background map and the Route Planner's own
|
||||||
|
map, not a network or skeleton-mode issue.
|
||||||
56
docs/feedback/README.md
Normal file
56
docs/feedback/README.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Post-launch feedback tickets
|
||||||
|
|
||||||
|
Same shape as `docs/ui-redesign/`: one file per ticket, Goal · Context · Design ·
|
||||||
|
Implementation · Acceptance criteria · Tests · Risks · Out of scope, written before
|
||||||
|
implementing, with an Outcome section appended after.
|
||||||
|
|
||||||
|
Source: `docs/FEEDBACK.md` — hands-on feedback after using the redesigned app. Turned
|
||||||
|
into 11 tickets so far (5 from the first pass, 4 from a second round of feedback after
|
||||||
|
FB-01..FB-05 shipped, 2 from a third round after FB-06/FB-07 turned out not to actually
|
||||||
|
resolve on-device), each independently completable (self-contained enough for a fresh
|
||||||
|
subagent with no prior context to implement correctly).
|
||||||
|
|
||||||
|
## The tickets
|
||||||
|
|
||||||
|
| # | Ticket | Size | Depends on | Status |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| [FB-01](FB-01-live-street-level-map.md) | Live, street-level map everywhere | L | — | Done |
|
||||||
|
| [FB-02](FB-02-hide-idle-speed-panel.md) | Hide the idle Speed panel until recording starts | S | — | Done |
|
||||||
|
| [FB-03](FB-03-grid-snapped-hud-widgets.md) | Grid-snapped HUD widgets with auto-fit text | L | — | Done |
|
||||||
|
| [FB-04](FB-04-route-planner-map-render-fix.md) | Fix Route Planner's map failing to render + zoom | M | FB-01 (shared zoom constant) | Done |
|
||||||
|
| [FB-05](FB-05-closed-loop-routes.md) | Closed-loop routes in Route Planner | M | FB-04 (same file) | Done |
|
||||||
|
| [FB-06](FB-06-always-interactive-map-recenter.md) | Map always pannable/zoomable, with a recenter button | M | — | Done |
|
||||||
|
| [FB-07](FB-07-route-planner-null-island.md) | Route Planner opens on Null Island instead of your real location | S/M | FB-01 (ambient location provider) | Done |
|
||||||
|
| [FB-08](FB-08-hud-reflow-on-hide.md) | HUD widgets reflow to fill the gap when one is hidden | M | — | Done |
|
||||||
|
| [FB-09](FB-09-hud-title-driven-sizing.md) | HUD default sizing follows the title, value adapts | L | FB-08 (same files) | Done |
|
||||||
|
| [FB-10](FB-10-map-pan-recenter-race.md) | Manual map pan loses a race against the follow-recenter timer | M | — | Done |
|
||||||
|
| [FB-11](FB-11-route-planner-tiles-still-blank.md) | Route Planner map still shows no tiles | M/L | — | Done |
|
||||||
|
|
||||||
|
## Dependencies / dispatch order
|
||||||
|
|
||||||
|
```
|
||||||
|
Wave 1 (parallel): FB-01, FB-02, FB-03
|
||||||
|
Wave 2 (after Wave 1 lands): FB-04 — reuses FB-01's ambientZoom constant
|
||||||
|
Wave 3 (after Wave 2 lands): FB-05 — heavily edits the same file FB-04 just touched
|
||||||
|
|
||||||
|
Wave 4 (parallel): FB-06, FB-07, FB-08 — no file overlap between these three
|
||||||
|
Wave 5 (after Wave 4 lands): FB-09 — heavily edits the same files FB-08 just touched
|
||||||
|
|
||||||
|
Wave 6 (parallel): FB-10, FB-11 — no file overlap between these two. Both are follow-up
|
||||||
|
fixes for real bugs FB-06/FB-07 left unresolved on-device; see each ticket's Context
|
||||||
|
for what the earlier fix did and did not solve.
|
||||||
|
```
|
||||||
|
|
||||||
|
FB-02 and FB-03 both touch `lib/src/ui/record/record_screen.dart`, but disjoint
|
||||||
|
regions (the idle-state `Column` vs. `_HudMetricValue`) — low conflict risk.
|
||||||
|
|
||||||
|
## Working method, per ticket
|
||||||
|
|
||||||
|
1. Implement against the ticket's own Implementation section.
|
||||||
|
2. `flutter analyze` clean, `flutter test` green — count must not regress (374 passing
|
||||||
|
before this batch starts).
|
||||||
|
3. Outcome section appended to the ticket file: what shipped, deviations, test counts.
|
||||||
|
4. Commit, status flipped to Done in both the ticket file and this table.
|
||||||
|
|
||||||
|
Android-emulator verification is best-effort only for this batch — lean on widget
|
||||||
|
tests as the primary bar; don't get stuck fighting emulator flakiness.
|
||||||
@@ -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.
|
||||||
///
|
///
|
||||||
@@ -51,6 +57,30 @@ final locationSourceProvider = Provider<LocationSource>((ref) {
|
|||||||
return source;
|
return source;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The device's current position when nothing is being recorded — drives the shared
|
||||||
|
/// background map's "look like Google Maps while idle" behavior. Deliberately NOT
|
||||||
|
/// gated through `recordingEngineProvider`/`RecordingEngine.start()` — this must work
|
||||||
|
/// whether or not a ride is ever recorded. Never calls `LocationSource.stop()`: the
|
||||||
|
/// underlying `locationSourceProvider` instance is shared with the recording engine,
|
||||||
|
/// and `stop()` is not reference-counted (see FB-01's ticket for why).
|
||||||
|
final ambientPositionProvider = StreamProvider.autoDispose<LocationFix?>((ref) async* {
|
||||||
|
if (!ref.watch(mapEnabledProvider)) {
|
||||||
|
yield null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final source = ref.watch(locationSourceProvider);
|
||||||
|
try {
|
||||||
|
await source.start(); // idempotent; safe even if a recording already started it
|
||||||
|
} on LocationException {
|
||||||
|
yield null; // permission denied / service disabled — ambient mode is best-effort
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield* source.fixes.map<LocationFix?>((fix) => fix);
|
||||||
|
// No `stop()` call, ever, on dispose — see the doc comment above. Only this
|
||||||
|
// provider's own subscription to the broadcast `fixes` stream ends; the shared
|
||||||
|
// platform subscription is left exactly as it was.
|
||||||
|
});
|
||||||
|
|
||||||
/// Loaded once at startup; null until then so nothing blocks the first frame.
|
/// Loaded once at startup; null until then so nothing blocks the first frame.
|
||||||
final configProvider = StateProvider<Config?>((ref) => null);
|
final configProvider = StateProvider<Config?>((ref) => null);
|
||||||
|
|
||||||
@@ -128,6 +158,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 +241,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 +260,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 +270,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);
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ class RoutePlans extends Table {
|
|||||||
/// Null in this ticket -- the polyline is derived from waypoints, not stored. V3-08
|
/// Null in this ticket -- the polyline is derived from waypoints, not stored. V3-08
|
||||||
/// fills it with the road-snapped geometry, which is not cheaply re-derivable.
|
/// fills it with the road-snapped geometry, which is not cheaply re-derivable.
|
||||||
TextColumn get geometry => text().nullable()();
|
TextColumn get geometry => text().nullable()();
|
||||||
|
|
||||||
|
/// Whether the route loops back to its first waypoint after the last one. See FB-05.
|
||||||
|
BoolColumn get isClosedLoop => boolean().withDefault(const Constant(false))();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One pin on a [RoutePlans] row.
|
/// One pin on a [RoutePlans] row.
|
||||||
@@ -146,7 +149,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase(super.e);
|
AppDatabase(super.e);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 3;
|
int get schemaVersion => 4;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -162,6 +165,13 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
await m.createTable(routePlans);
|
await m.createTable(routePlans);
|
||||||
await m.createTable(waypoints);
|
await m.createTable(waypoints);
|
||||||
}
|
}
|
||||||
|
// FB-05: closed-loop toggle, defaults to false for every pre-existing route.
|
||||||
|
// Guarded to `from >= 3` because a v2 -> v4+ upgrade just created `routePlans`
|
||||||
|
// fresh (above) with every current column, `isClosedLoop` included -- adding it
|
||||||
|
// again here would be a duplicate-column error.
|
||||||
|
if (from >= 3 && from < 4) {
|
||||||
|
await m.addColumn(routePlans, routePlans.isClosedLoop);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
beforeOpen: (details) async {
|
beforeOpen: (details) async {
|
||||||
// Non-negotiable: without this the CASCADE relationships above do nothing.
|
// Non-negotiable: without this the CASCADE relationships above do nothing.
|
||||||
@@ -553,6 +563,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
distanceM: Value(route.distanceM),
|
distanceM: Value(route.distanceM),
|
||||||
estimatedMillis: Value(route.estimatedMillis),
|
estimatedMillis: Value(route.estimatedMillis),
|
||||||
geometry: Value(route.geometry),
|
geometry: Value(route.geometry),
|
||||||
|
isClosedLoop: Value(route.isClosedLoop),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -566,6 +577,12 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
RoutePlansCompanion(distanceM: Value(distanceM)),
|
RoutePlansCompanion(distanceM: Value(distanceM)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<void> setRoutePlanClosedLoop(int id, bool value) => (update(
|
||||||
|
routePlans,
|
||||||
|
)..where((r) => r.id.equals(id))).write(
|
||||||
|
RoutePlansCompanion(isClosedLoop: Value(value)),
|
||||||
|
);
|
||||||
|
|
||||||
/// Waypoints cascade with it.
|
/// Waypoints cascade with it.
|
||||||
Future<void> deleteRoutePlan(int id) =>
|
Future<void> deleteRoutePlan(int id) =>
|
||||||
(delete(routePlans)..where((r) => r.id.equals(id))).go();
|
(delete(routePlans)..where((r) => r.id.equals(id))).go();
|
||||||
@@ -617,6 +634,7 @@ domain.RoutePlan _toRoutePlan(RoutePlanRow r) => domain.RoutePlan(
|
|||||||
distanceM: r.distanceM,
|
distanceM: r.distanceM,
|
||||||
estimatedMillis: r.estimatedMillis,
|
estimatedMillis: r.estimatedMillis,
|
||||||
geometry: r.geometry,
|
geometry: r.geometry,
|
||||||
|
isClosedLoop: r.isClosedLoop,
|
||||||
);
|
);
|
||||||
|
|
||||||
domain.Waypoint _toWaypoint(WaypointRow r) => domain.Waypoint(
|
domain.Waypoint _toWaypoint(WaypointRow r) => domain.Waypoint(
|
||||||
|
|||||||
@@ -1706,6 +1706,21 @@ class $RoutePlansTable extends RoutePlans
|
|||||||
type: DriftSqlType.string,
|
type: DriftSqlType.string,
|
||||||
requiredDuringInsert: false,
|
requiredDuringInsert: false,
|
||||||
);
|
);
|
||||||
|
static const VerificationMeta _isClosedLoopMeta = const VerificationMeta(
|
||||||
|
'isClosedLoop',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<bool> isClosedLoop = GeneratedColumn<bool>(
|
||||||
|
'isClosedLoop',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.bool,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
defaultConstraints: GeneratedColumn.constraintIsAlways(
|
||||||
|
'CHECK ("isClosedLoop" IN (0, 1))',
|
||||||
|
),
|
||||||
|
defaultValue: const Constant(false),
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
List<GeneratedColumn> get $columns => [
|
List<GeneratedColumn> get $columns => [
|
||||||
id,
|
id,
|
||||||
@@ -1715,6 +1730,7 @@ class $RoutePlansTable extends RoutePlans
|
|||||||
distanceM,
|
distanceM,
|
||||||
estimatedMillis,
|
estimatedMillis,
|
||||||
geometry,
|
geometry,
|
||||||
|
isClosedLoop,
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
@@ -1768,6 +1784,15 @@ class $RoutePlansTable extends RoutePlans
|
|||||||
geometry.isAcceptableOrUnknown(data['geometry']!, _geometryMeta),
|
geometry.isAcceptableOrUnknown(data['geometry']!, _geometryMeta),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('isClosedLoop')) {
|
||||||
|
context.handle(
|
||||||
|
_isClosedLoopMeta,
|
||||||
|
isClosedLoop.isAcceptableOrUnknown(
|
||||||
|
data['isClosedLoop']!,
|
||||||
|
_isClosedLoopMeta,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1807,6 +1832,10 @@ class $RoutePlansTable extends RoutePlans
|
|||||||
DriftSqlType.string,
|
DriftSqlType.string,
|
||||||
data['${effectivePrefix}geometry'],
|
data['${effectivePrefix}geometry'],
|
||||||
),
|
),
|
||||||
|
isClosedLoop: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.bool,
|
||||||
|
data['${effectivePrefix}isClosedLoop'],
|
||||||
|
)!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1834,6 +1863,9 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
/// Null in this ticket -- the polyline is derived from waypoints, not stored. V3-08
|
/// Null in this ticket -- the polyline is derived from waypoints, not stored. V3-08
|
||||||
/// fills it with the road-snapped geometry, which is not cheaply re-derivable.
|
/// fills it with the road-snapped geometry, which is not cheaply re-derivable.
|
||||||
final String? geometry;
|
final String? geometry;
|
||||||
|
|
||||||
|
/// Whether the route loops back to its first waypoint after the last one. See FB-05.
|
||||||
|
final bool isClosedLoop;
|
||||||
const RoutePlanRow({
|
const RoutePlanRow({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -1842,6 +1874,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
required this.distanceM,
|
required this.distanceM,
|
||||||
this.estimatedMillis,
|
this.estimatedMillis,
|
||||||
this.geometry,
|
this.geometry,
|
||||||
|
required this.isClosedLoop,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||||
@@ -1861,6 +1894,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
if (!nullToAbsent || geometry != null) {
|
if (!nullToAbsent || geometry != null) {
|
||||||
map['geometry'] = Variable<String>(geometry);
|
map['geometry'] = Variable<String>(geometry);
|
||||||
}
|
}
|
||||||
|
map['isClosedLoop'] = Variable<bool>(isClosedLoop);
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1877,6 +1911,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
geometry: geometry == null && nullToAbsent
|
geometry: geometry == null && nullToAbsent
|
||||||
? const Value.absent()
|
? const Value.absent()
|
||||||
: Value(geometry),
|
: Value(geometry),
|
||||||
|
isClosedLoop: Value(isClosedLoop),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1895,6 +1930,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
distanceM: serializer.fromJson<double>(json['distanceM']),
|
distanceM: serializer.fromJson<double>(json['distanceM']),
|
||||||
estimatedMillis: serializer.fromJson<int?>(json['estimatedMillis']),
|
estimatedMillis: serializer.fromJson<int?>(json['estimatedMillis']),
|
||||||
geometry: serializer.fromJson<String?>(json['geometry']),
|
geometry: serializer.fromJson<String?>(json['geometry']),
|
||||||
|
isClosedLoop: serializer.fromJson<bool>(json['isClosedLoop']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@override
|
@override
|
||||||
@@ -1910,6 +1946,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
'distanceM': serializer.toJson<double>(distanceM),
|
'distanceM': serializer.toJson<double>(distanceM),
|
||||||
'estimatedMillis': serializer.toJson<int?>(estimatedMillis),
|
'estimatedMillis': serializer.toJson<int?>(estimatedMillis),
|
||||||
'geometry': serializer.toJson<String?>(geometry),
|
'geometry': serializer.toJson<String?>(geometry),
|
||||||
|
'isClosedLoop': serializer.toJson<bool>(isClosedLoop),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1921,6 +1958,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
double? distanceM,
|
double? distanceM,
|
||||||
Value<int?> estimatedMillis = const Value.absent(),
|
Value<int?> estimatedMillis = const Value.absent(),
|
||||||
Value<String?> geometry = const Value.absent(),
|
Value<String?> geometry = const Value.absent(),
|
||||||
|
bool? isClosedLoop,
|
||||||
}) => RoutePlanRow(
|
}) => RoutePlanRow(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
@@ -1931,6 +1969,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
? estimatedMillis.value
|
? estimatedMillis.value
|
||||||
: this.estimatedMillis,
|
: this.estimatedMillis,
|
||||||
geometry: geometry.present ? geometry.value : this.geometry,
|
geometry: geometry.present ? geometry.value : this.geometry,
|
||||||
|
isClosedLoop: isClosedLoop ?? this.isClosedLoop,
|
||||||
);
|
);
|
||||||
RoutePlanRow copyWithCompanion(RoutePlansCompanion data) {
|
RoutePlanRow copyWithCompanion(RoutePlansCompanion data) {
|
||||||
return RoutePlanRow(
|
return RoutePlanRow(
|
||||||
@@ -1943,6 +1982,9 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
? data.estimatedMillis.value
|
? data.estimatedMillis.value
|
||||||
: this.estimatedMillis,
|
: this.estimatedMillis,
|
||||||
geometry: data.geometry.present ? data.geometry.value : this.geometry,
|
geometry: data.geometry.present ? data.geometry.value : this.geometry,
|
||||||
|
isClosedLoop: data.isClosedLoop.present
|
||||||
|
? data.isClosedLoop.value
|
||||||
|
: this.isClosedLoop,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1955,7 +1997,8 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
..write('activity: $activity, ')
|
..write('activity: $activity, ')
|
||||||
..write('distanceM: $distanceM, ')
|
..write('distanceM: $distanceM, ')
|
||||||
..write('estimatedMillis: $estimatedMillis, ')
|
..write('estimatedMillis: $estimatedMillis, ')
|
||||||
..write('geometry: $geometry')
|
..write('geometry: $geometry, ')
|
||||||
|
..write('isClosedLoop: $isClosedLoop')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
@@ -1969,6 +2012,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
distanceM,
|
distanceM,
|
||||||
estimatedMillis,
|
estimatedMillis,
|
||||||
geometry,
|
geometry,
|
||||||
|
isClosedLoop,
|
||||||
);
|
);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
@@ -1980,7 +2024,8 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
other.activity == this.activity &&
|
other.activity == this.activity &&
|
||||||
other.distanceM == this.distanceM &&
|
other.distanceM == this.distanceM &&
|
||||||
other.estimatedMillis == this.estimatedMillis &&
|
other.estimatedMillis == this.estimatedMillis &&
|
||||||
other.geometry == this.geometry);
|
other.geometry == this.geometry &&
|
||||||
|
other.isClosedLoop == this.isClosedLoop);
|
||||||
}
|
}
|
||||||
|
|
||||||
class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
||||||
@@ -1991,6 +2036,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
final Value<double> distanceM;
|
final Value<double> distanceM;
|
||||||
final Value<int?> estimatedMillis;
|
final Value<int?> estimatedMillis;
|
||||||
final Value<String?> geometry;
|
final Value<String?> geometry;
|
||||||
|
final Value<bool> isClosedLoop;
|
||||||
const RoutePlansCompanion({
|
const RoutePlansCompanion({
|
||||||
this.id = const Value.absent(),
|
this.id = const Value.absent(),
|
||||||
this.name = const Value.absent(),
|
this.name = const Value.absent(),
|
||||||
@@ -1999,6 +2045,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
this.distanceM = const Value.absent(),
|
this.distanceM = const Value.absent(),
|
||||||
this.estimatedMillis = const Value.absent(),
|
this.estimatedMillis = const Value.absent(),
|
||||||
this.geometry = const Value.absent(),
|
this.geometry = const Value.absent(),
|
||||||
|
this.isClosedLoop = const Value.absent(),
|
||||||
});
|
});
|
||||||
RoutePlansCompanion.insert({
|
RoutePlansCompanion.insert({
|
||||||
this.id = const Value.absent(),
|
this.id = const Value.absent(),
|
||||||
@@ -2008,6 +2055,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
this.distanceM = const Value.absent(),
|
this.distanceM = const Value.absent(),
|
||||||
this.estimatedMillis = const Value.absent(),
|
this.estimatedMillis = const Value.absent(),
|
||||||
this.geometry = const Value.absent(),
|
this.geometry = const Value.absent(),
|
||||||
|
this.isClosedLoop = const Value.absent(),
|
||||||
}) : name = Value(name),
|
}) : name = Value(name),
|
||||||
createdAt = Value(createdAt);
|
createdAt = Value(createdAt);
|
||||||
static Insertable<RoutePlanRow> custom({
|
static Insertable<RoutePlanRow> custom({
|
||||||
@@ -2018,6 +2066,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
Expression<double>? distanceM,
|
Expression<double>? distanceM,
|
||||||
Expression<int>? estimatedMillis,
|
Expression<int>? estimatedMillis,
|
||||||
Expression<String>? geometry,
|
Expression<String>? geometry,
|
||||||
|
Expression<bool>? isClosedLoop,
|
||||||
}) {
|
}) {
|
||||||
return RawValuesInsertable({
|
return RawValuesInsertable({
|
||||||
if (id != null) 'id': id,
|
if (id != null) 'id': id,
|
||||||
@@ -2027,6 +2076,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
if (distanceM != null) 'distanceM': distanceM,
|
if (distanceM != null) 'distanceM': distanceM,
|
||||||
if (estimatedMillis != null) 'estimatedMillis': estimatedMillis,
|
if (estimatedMillis != null) 'estimatedMillis': estimatedMillis,
|
||||||
if (geometry != null) 'geometry': geometry,
|
if (geometry != null) 'geometry': geometry,
|
||||||
|
if (isClosedLoop != null) 'isClosedLoop': isClosedLoop,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2038,6 +2088,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
Value<double>? distanceM,
|
Value<double>? distanceM,
|
||||||
Value<int?>? estimatedMillis,
|
Value<int?>? estimatedMillis,
|
||||||
Value<String?>? geometry,
|
Value<String?>? geometry,
|
||||||
|
Value<bool>? isClosedLoop,
|
||||||
}) {
|
}) {
|
||||||
return RoutePlansCompanion(
|
return RoutePlansCompanion(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -2047,6 +2098,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
distanceM: distanceM ?? this.distanceM,
|
distanceM: distanceM ?? this.distanceM,
|
||||||
estimatedMillis: estimatedMillis ?? this.estimatedMillis,
|
estimatedMillis: estimatedMillis ?? this.estimatedMillis,
|
||||||
geometry: geometry ?? this.geometry,
|
geometry: geometry ?? this.geometry,
|
||||||
|
isClosedLoop: isClosedLoop ?? this.isClosedLoop,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2076,6 +2128,9 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
if (geometry.present) {
|
if (geometry.present) {
|
||||||
map['geometry'] = Variable<String>(geometry.value);
|
map['geometry'] = Variable<String>(geometry.value);
|
||||||
}
|
}
|
||||||
|
if (isClosedLoop.present) {
|
||||||
|
map['isClosedLoop'] = Variable<bool>(isClosedLoop.value);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2088,7 +2143,8 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
..write('activity: $activity, ')
|
..write('activity: $activity, ')
|
||||||
..write('distanceM: $distanceM, ')
|
..write('distanceM: $distanceM, ')
|
||||||
..write('estimatedMillis: $estimatedMillis, ')
|
..write('estimatedMillis: $estimatedMillis, ')
|
||||||
..write('geometry: $geometry')
|
..write('geometry: $geometry, ')
|
||||||
|
..write('isClosedLoop: $isClosedLoop')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
@@ -3980,6 +4036,7 @@ typedef $$RoutePlansTableCreateCompanionBuilder = RoutePlansCompanion Function({
|
|||||||
Value<double> distanceM,
|
Value<double> distanceM,
|
||||||
Value<int?> estimatedMillis,
|
Value<int?> estimatedMillis,
|
||||||
Value<String?> geometry,
|
Value<String?> geometry,
|
||||||
|
Value<bool> isClosedLoop,
|
||||||
});
|
});
|
||||||
typedef $$RoutePlansTableUpdateCompanionBuilder = RoutePlansCompanion Function({
|
typedef $$RoutePlansTableUpdateCompanionBuilder = RoutePlansCompanion Function({
|
||||||
Value<int> id,
|
Value<int> id,
|
||||||
@@ -3989,6 +4046,7 @@ typedef $$RoutePlansTableUpdateCompanionBuilder = RoutePlansCompanion Function({
|
|||||||
Value<double> distanceM,
|
Value<double> distanceM,
|
||||||
Value<int?> estimatedMillis,
|
Value<int?> estimatedMillis,
|
||||||
Value<String?> geometry,
|
Value<String?> geometry,
|
||||||
|
Value<bool> isClosedLoop,
|
||||||
});
|
});
|
||||||
|
|
||||||
final class $$RoutePlansTableReferences
|
final class $$RoutePlansTableReferences
|
||||||
@@ -4059,6 +4117,11 @@ class $$RoutePlansTableFilterComposer
|
|||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnFilters<bool> get isClosedLoop => $composableBuilder(
|
||||||
|
column: $table.isClosedLoop,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
Expression<bool> waypointsRefs(
|
Expression<bool> waypointsRefs(
|
||||||
Expression<bool> Function($$WaypointsTableFilterComposer f) f,
|
Expression<bool> Function($$WaypointsTableFilterComposer f) f,
|
||||||
) {
|
) {
|
||||||
@@ -4128,6 +4191,11 @@ class $$RoutePlansTableOrderingComposer
|
|||||||
column: $table.geometry,
|
column: $table.geometry,
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<bool> get isClosedLoop => $composableBuilder(
|
||||||
|
column: $table.isClosedLoop,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$RoutePlansTableAnnotationComposer
|
class $$RoutePlansTableAnnotationComposer
|
||||||
@@ -4162,6 +4230,11 @@ class $$RoutePlansTableAnnotationComposer
|
|||||||
GeneratedColumn<String> get geometry =>
|
GeneratedColumn<String> get geometry =>
|
||||||
$composableBuilder(column: $table.geometry, builder: (column) => column);
|
$composableBuilder(column: $table.geometry, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<bool> get isClosedLoop => $composableBuilder(
|
||||||
|
column: $table.isClosedLoop,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
|
|
||||||
Expression<T> waypointsRefs<T extends Object>(
|
Expression<T> waypointsRefs<T extends Object>(
|
||||||
Expression<T> Function($$WaypointsTableAnnotationComposer a) f,
|
Expression<T> Function($$WaypointsTableAnnotationComposer a) f,
|
||||||
) {
|
) {
|
||||||
@@ -4223,6 +4296,7 @@ class $$RoutePlansTableTableManager
|
|||||||
Value<double> distanceM = const Value.absent(),
|
Value<double> distanceM = const Value.absent(),
|
||||||
Value<int?> estimatedMillis = const Value.absent(),
|
Value<int?> estimatedMillis = const Value.absent(),
|
||||||
Value<String?> geometry = const Value.absent(),
|
Value<String?> geometry = const Value.absent(),
|
||||||
|
Value<bool> isClosedLoop = const Value.absent(),
|
||||||
}) => RoutePlansCompanion(
|
}) => RoutePlansCompanion(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
@@ -4231,6 +4305,7 @@ class $$RoutePlansTableTableManager
|
|||||||
distanceM: distanceM,
|
distanceM: distanceM,
|
||||||
estimatedMillis: estimatedMillis,
|
estimatedMillis: estimatedMillis,
|
||||||
geometry: geometry,
|
geometry: geometry,
|
||||||
|
isClosedLoop: isClosedLoop,
|
||||||
),
|
),
|
||||||
createCompanionCallback:
|
createCompanionCallback:
|
||||||
({
|
({
|
||||||
@@ -4241,6 +4316,7 @@ class $$RoutePlansTableTableManager
|
|||||||
Value<double> distanceM = const Value.absent(),
|
Value<double> distanceM = const Value.absent(),
|
||||||
Value<int?> estimatedMillis = const Value.absent(),
|
Value<int?> estimatedMillis = const Value.absent(),
|
||||||
Value<String?> geometry = const Value.absent(),
|
Value<String?> geometry = const Value.absent(),
|
||||||
|
Value<bool> isClosedLoop = const Value.absent(),
|
||||||
}) => RoutePlansCompanion.insert(
|
}) => RoutePlansCompanion.insert(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
@@ -4249,6 +4325,7 @@ class $$RoutePlansTableTableManager
|
|||||||
distanceM: distanceM,
|
distanceM: distanceM,
|
||||||
estimatedMillis: estimatedMillis,
|
estimatedMillis: estimatedMillis,
|
||||||
geometry: geometry,
|
geometry: geometry,
|
||||||
|
isClosedLoop: isClosedLoop,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ class RoutePlanRepository {
|
|||||||
|
|
||||||
Future<void> renameRoutePlan(int id, String name) => _db.renameRoutePlan(id, name);
|
Future<void> renameRoutePlan(int id, String name) => _db.renameRoutePlan(id, name);
|
||||||
|
|
||||||
|
/// Toggles whether the route loops back to its first waypoint. The closing segment
|
||||||
|
/// changes the total distance, so this recomputes it same as any waypoint edit.
|
||||||
|
Future<void> setClosedLoop(int routeId, bool value) async {
|
||||||
|
await _db.setRoutePlanClosedLoop(routeId, value);
|
||||||
|
await _recomputeDistance(routeId);
|
||||||
|
}
|
||||||
|
|
||||||
/// Waypoints cascade with it.
|
/// Waypoints cascade with it.
|
||||||
Future<void> deleteRoutePlan(int id) => _db.deleteRoutePlan(id);
|
Future<void> deleteRoutePlan(int id) => _db.deleteRoutePlan(id);
|
||||||
|
|
||||||
@@ -93,10 +100,13 @@ class RoutePlanRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _recomputeDistance(int routeId) async {
|
Future<void> _recomputeDistance(int routeId) async {
|
||||||
|
final route = await _db.getRoutePlan(routeId);
|
||||||
final waypoints = await _db.waypointsForRoute(routeId);
|
final waypoints = await _db.waypointsForRoute(routeId);
|
||||||
final distance = geo.pathLengthMeters([
|
final points = [for (final w in waypoints) geo.LatLon(w.latitude, w.longitude)];
|
||||||
for (final w in waypoints) geo.LatLon(w.latitude, w.longitude),
|
if (route?.isClosedLoop == true && points.length >= 2) {
|
||||||
]);
|
points.add(points.first);
|
||||||
|
}
|
||||||
|
final distance = geo.pathLengthMeters(points);
|
||||||
await _db.setRoutePlanDistance(routeId, distance);
|
await _db.setRoutePlanDistance(routeId, distance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ class RoutePlan {
|
|||||||
this.distanceM = 0.0,
|
this.distanceM = 0.0,
|
||||||
this.estimatedMillis,
|
this.estimatedMillis,
|
||||||
this.geometry,
|
this.geometry,
|
||||||
|
this.isClosedLoop = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int id;
|
final int id;
|
||||||
@@ -256,6 +257,9 @@ class RoutePlan {
|
|||||||
/// never stored -- it is fully derived from them, so there is nothing to persist.
|
/// never stored -- it is fully derived from them, so there is nothing to persist.
|
||||||
final String? geometry;
|
final String? geometry;
|
||||||
|
|
||||||
|
/// Whether the route loops back to its first waypoint after the last one.
|
||||||
|
final bool isClosedLoop;
|
||||||
|
|
||||||
RoutePlan copyWith({
|
RoutePlan copyWith({
|
||||||
int? id,
|
int? id,
|
||||||
String? name,
|
String? name,
|
||||||
@@ -264,6 +268,7 @@ class RoutePlan {
|
|||||||
double? distanceM,
|
double? distanceM,
|
||||||
int? estimatedMillis,
|
int? estimatedMillis,
|
||||||
String? geometry,
|
String? geometry,
|
||||||
|
bool? isClosedLoop,
|
||||||
}) => RoutePlan(
|
}) => RoutePlan(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
@@ -272,6 +277,7 @@ class RoutePlan {
|
|||||||
distanceM: distanceM ?? this.distanceM,
|
distanceM: distanceM ?? this.distanceM,
|
||||||
estimatedMillis: estimatedMillis ?? this.estimatedMillis,
|
estimatedMillis: estimatedMillis ?? this.estimatedMillis,
|
||||||
geometry: geometry ?? this.geometry,
|
geometry: geometry ?? this.geometry,
|
||||||
|
isClosedLoop: isClosedLoop ?? this.isClosedLoop,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
97
lib/src/hud/hud_layout_controller.dart
Normal file
97
lib/src/hud/hud_layout_controller.dart
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/// UI-04/FB-03: 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;
|
||||||
|
|
||||||
|
/// "Reject and no-op" is the collision rule for drag/resize -- simplest correct
|
||||||
|
/// behaviour, and it's equivalent to "revert to last valid" here since `state` is
|
||||||
|
/// never mutated until the candidate passes the check. A drag/resize that would
|
||||||
|
/// overlap another visible widget simply has no effect for that gesture; the widget
|
||||||
|
/// stays at its last valid position.
|
||||||
|
void updatePosition(HudMetric metric, int col, int row) {
|
||||||
|
final current = state[metric];
|
||||||
|
if (current == null) return;
|
||||||
|
final candidate = current.copyWith(col: col, row: row).clampedToGrid();
|
||||||
|
if (_overlapsAnyOther(metric, candidate)) return;
|
||||||
|
state = {...state, metric: candidate};
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateSize(HudMetric metric, int colSpan, int rowSpan) {
|
||||||
|
final current = state[metric];
|
||||||
|
if (current == null) return;
|
||||||
|
final candidate = current.copyWith(colSpan: colSpan, rowSpan: rowSpan).clampedToGrid();
|
||||||
|
if (_overlapsAnyOther(metric, candidate)) return;
|
||||||
|
state = {...state, metric: candidate};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _overlapsAnyOther(HudMetric metric, HudWidgetLayout candidate) => state.values
|
||||||
|
.where((l) => l.visible && l.metric != metric)
|
||||||
|
.any((other) => hudRectsOverlap(candidate, other));
|
||||||
|
|
||||||
|
/// 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. A metric that already has a real saved position
|
||||||
|
/// keeps it -- unless that slot is now occupied by another visible widget (the
|
||||||
|
/// "goes crazy" bug this ticket exists to fix), in which case it's placed in the
|
||||||
|
/// next free grid cell instead of popping up on top of the occupier.
|
||||||
|
void setVisible(HudMetric metric, bool visible) {
|
||||||
|
var current = state[metric] ?? HudWidgetLayout.defaultFor(metric);
|
||||||
|
if (visible && _overlapsAnyOther(metric, current.copyWith(visible: true))) {
|
||||||
|
current = HudWidgetLayout.nextFreeSlot(
|
||||||
|
metric,
|
||||||
|
occupied: state,
|
||||||
|
colSpan: current.colSpan,
|
||||||
|
rowSpan: current.rowSpan,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final updated = {...state, metric: current.copyWith(visible: visible)};
|
||||||
|
state = _reflowRow(updated, current.row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spreads every visible widget in [row] evenly across the full grid width,
|
||||||
|
/// left to right in their existing column order. A widget whose row has no other
|
||||||
|
/// visible member is left alone -- there is nothing to redistribute.
|
||||||
|
Map<HudMetric, HudWidgetLayout> _reflowRow(
|
||||||
|
Map<HudMetric, HudWidgetLayout> layouts,
|
||||||
|
int row,
|
||||||
|
) {
|
||||||
|
final members = layouts.values.where((l) => l.visible && l.row == row).toList()
|
||||||
|
..sort((a, b) => a.col.compareTo(b.col));
|
||||||
|
if (members.length < 2) return layouts;
|
||||||
|
final baseSpan = hudGridColumns ~/ members.length;
|
||||||
|
final extra = hudGridColumns % members.length;
|
||||||
|
final result = Map<HudMetric, HudWidgetLayout>.from(layouts);
|
||||||
|
var col = 0;
|
||||||
|
for (var i = 0; i < members.length; i++) {
|
||||||
|
final span = baseSpan + (i < extra ? 1 : 0);
|
||||||
|
result[members[i].metric] = members[i].copyWith(col: col, colSpan: span);
|
||||||
|
col += span;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
193
lib/src/hud/hud_widget_layout.dart
Normal file
193
lib/src/hud/hud_widget_layout.dart
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/// FB-03: one telemetry widget's position, size, and visibility on the customizable
|
||||||
|
/// HUD -- an Android-home-screen-style snapped grid (see the ticket's Design section
|
||||||
|
/// for why: free-form fractional positioning had no concept of a sibling widget, so
|
||||||
|
/// toggling widgets on/off could stack one directly on top of another).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'hud_metric.dart';
|
||||||
|
|
||||||
|
/// Fixed grid spanning the HUD area -- matches the previous 4-column default-row
|
||||||
|
/// assumption and gives ample vertical room for the 8-metric list.
|
||||||
|
const int hudGridColumns = 4;
|
||||||
|
const int hudGridRows = 8;
|
||||||
|
|
||||||
|
/// Legibility floor and a sane ceiling, in grid cells -- a widget must never shrink to
|
||||||
|
/// the point its own number is unreadable, or grow to the point it swallows the whole
|
||||||
|
/// HUD.
|
||||||
|
const int hudMinSpan = 1;
|
||||||
|
const int hudMaxColSpan = 4;
|
||||||
|
const int hudMaxRowSpan = 3;
|
||||||
|
|
||||||
|
/// True if rectangles [a] and [b] (both in grid-cell coordinates) overlap -- edges that
|
||||||
|
/// merely touch do not count as overlapping.
|
||||||
|
/// A label under 10 characters fits one column. A label under 15 characters needs
|
||||||
|
/// two. Anything longer needs three. This is a character-count proxy for "how much
|
||||||
|
/// horizontal room this title needs," not an exact pixel measurement -- `defaultFor`
|
||||||
|
/// runs with no `BuildContext` and cannot measure real text width. Adjust these
|
||||||
|
/// thresholds directly if a future label reads too cramped or too loose in practice.
|
||||||
|
int minColSpanForLabel(String label) {
|
||||||
|
if (label.length < 10) return 1;
|
||||||
|
if (label.length < 15) return 2;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hudRectsOverlap(HudWidgetLayout a, HudWidgetLayout b) {
|
||||||
|
final aColEnd = a.col + a.colSpan;
|
||||||
|
final aRowEnd = a.row + a.rowSpan;
|
||||||
|
final bColEnd = b.col + b.colSpan;
|
||||||
|
final bRowEnd = b.row + b.rowSpan;
|
||||||
|
return a.col < bColEnd && aColEnd > b.col && a.row < bRowEnd && aRowEnd > b.row;
|
||||||
|
}
|
||||||
|
|
||||||
|
class HudWidgetLayout {
|
||||||
|
const HudWidgetLayout({
|
||||||
|
required this.metric,
|
||||||
|
required this.col,
|
||||||
|
required this.row,
|
||||||
|
required this.colSpan,
|
||||||
|
required this.rowSpan,
|
||||||
|
required this.visible,
|
||||||
|
});
|
||||||
|
|
||||||
|
final HudMetric metric;
|
||||||
|
|
||||||
|
/// Top-left cell, in grid coordinates (0-based).
|
||||||
|
final int col;
|
||||||
|
final int row;
|
||||||
|
|
||||||
|
/// How many cells wide/tall this widget spans.
|
||||||
|
final int colSpan;
|
||||||
|
final int rowSpan;
|
||||||
|
final bool visible;
|
||||||
|
|
||||||
|
HudWidgetLayout copyWith({
|
||||||
|
int? col,
|
||||||
|
int? row,
|
||||||
|
int? colSpan,
|
||||||
|
int? rowSpan,
|
||||||
|
bool? visible,
|
||||||
|
}) => HudWidgetLayout(
|
||||||
|
metric: metric,
|
||||||
|
col: col ?? this.col,
|
||||||
|
row: row ?? this.row,
|
||||||
|
colSpan: colSpan ?? this.colSpan,
|
||||||
|
rowSpan: rowSpan ?? this.rowSpan,
|
||||||
|
visible: visible ?? this.visible,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// A *self-contained* bounds check with no awareness of siblings -- corrects a
|
||||||
|
/// drag/resize result that ended outside the grid back to the nearest valid
|
||||||
|
/// position/size, span first (so a resize that would push a widget off-grid shrinks
|
||||||
|
/// it back on-grid rather than silently repositioning it), then position.
|
||||||
|
HudWidgetLayout clampedToGrid() {
|
||||||
|
final clampedColSpan = colSpan.clamp(hudMinSpan, hudMaxColSpan);
|
||||||
|
final clampedRowSpan = rowSpan.clamp(hudMinSpan, hudMaxRowSpan);
|
||||||
|
final clampedCol = col.clamp(0, hudGridColumns - clampedColSpan);
|
||||||
|
final clampedRow = row.clamp(0, hudGridRows - clampedRowSpan);
|
||||||
|
return HudWidgetLayout(
|
||||||
|
metric: metric,
|
||||||
|
col: clampedCol,
|
||||||
|
row: clampedRow,
|
||||||
|
colSpan: clampedColSpan,
|
||||||
|
rowSpan: clampedRowSpan,
|
||||||
|
visible: visible,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'col': col,
|
||||||
|
'row': row,
|
||||||
|
'colSpan': colSpan,
|
||||||
|
'rowSpan': rowSpan,
|
||||||
|
'visible': visible,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Falls back to [defaultFor] rather than throwing on a malformed/partial entry --
|
||||||
|
/// an old saved layout (pre-grid, with `x`/`y`/`width`/`height` keys) or one 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,
|
||||||
|
col: (json['col'] as num).toInt(),
|
||||||
|
row: (json['row'] as num).toInt(),
|
||||||
|
colSpan: (json['colSpan'] as num).toInt(),
|
||||||
|
rowSpan: (json['rowSpan'] as num).toInt(),
|
||||||
|
visible: json['visible'] as bool,
|
||||||
|
).clampedToGrid();
|
||||||
|
} 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. FB-09: rather than a fixed "always 4 per row, colSpan 1" assumption, this
|
||||||
|
/// packs metrics left-to-right with each one's own title-driven width
|
||||||
|
/// ([minColSpanForLabel]), wrapping to a new row when the current one would
|
||||||
|
/// overflow. Purely a function of the metric's own index/label -- these slots never
|
||||||
|
/// overlap each other by construction, so no runtime state is needed here.
|
||||||
|
factory HudWidgetLayout.defaultFor(HudMetric metric) {
|
||||||
|
const rowSpan = 2;
|
||||||
|
var col = 0;
|
||||||
|
var row = 0;
|
||||||
|
for (final m in HudMetric.values) {
|
||||||
|
final span = minColSpanForLabel(m.label);
|
||||||
|
if (col + span > hudGridColumns) {
|
||||||
|
col = 0;
|
||||||
|
row += rowSpan;
|
||||||
|
}
|
||||||
|
if (m == metric) {
|
||||||
|
return HudWidgetLayout(
|
||||||
|
metric: metric,
|
||||||
|
col: col,
|
||||||
|
row: row,
|
||||||
|
colSpan: span,
|
||||||
|
rowSpan: rowSpan,
|
||||||
|
// 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.
|
||||||
|
// Their exact column positions may no longer form four perfectly even
|
||||||
|
// columns once their individual widths differ -- that is the correct,
|
||||||
|
// intended result of sizing by title, not a bug (see FB-09's Outcome
|
||||||
|
// section).
|
||||||
|
visible: HudMetric.values.indexOf(metric) < 4,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
col += span;
|
||||||
|
}
|
||||||
|
// Unreachable: metric is always one of HudMetric.values.
|
||||||
|
throw StateError('Unknown metric: $metric');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scans row-major from (0,0) for the first [colSpan]x[rowSpan] slot that doesn't
|
||||||
|
/// overlap any `visible` entry in [occupied]. Falls back to (0,0) unconditionally if
|
||||||
|
/// the grid is fully packed -- a stacked default is better than a crash or an
|
||||||
|
/// exception the rider can't do anything about.
|
||||||
|
static HudWidgetLayout nextFreeSlot(
|
||||||
|
HudMetric metric, {
|
||||||
|
required Map<HudMetric, HudWidgetLayout> occupied,
|
||||||
|
int colSpan = 1,
|
||||||
|
int rowSpan = 2,
|
||||||
|
}) {
|
||||||
|
HudWidgetLayout candidate(int col, int row) => HudWidgetLayout(
|
||||||
|
metric: metric,
|
||||||
|
col: col,
|
||||||
|
row: row,
|
||||||
|
colSpan: colSpan,
|
||||||
|
rowSpan: rowSpan,
|
||||||
|
visible: true,
|
||||||
|
);
|
||||||
|
for (var row = 0; row <= hudGridRows - rowSpan; row++) {
|
||||||
|
for (var col = 0; col <= hudGridColumns - colSpan; col++) {
|
||||||
|
final c = candidate(col, row);
|
||||||
|
final overlapsAny = occupied.values
|
||||||
|
.where((l) => l.visible && l.metric != metric)
|
||||||
|
.any((other) => hudRectsOverlap(c, other));
|
||||||
|
if (!overlapsAny) return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidate(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,19 @@ 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 (e) {
|
||||||
|
// FB-11: record the URL and the underlying HTTP status/exception so a real
|
||||||
|
// fetch failure is visible on-device -- before this, `catch (_)` discarded the
|
||||||
|
// cause and there was no way to tell a real failure from a rendering bug.
|
||||||
|
debugPrint('CachedTileProvider: tile fetch failed for $url: $e');
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,16 +30,44 @@ abstract class TileCache {
|
|||||||
/// last-access time for LRU eviction. No database engine for what is, at the end of the
|
/// last-access time for LRU eviction. No database engine for what is, at the end of the
|
||||||
/// day, a directory of small binary blobs with one number (last access) attached to each.
|
/// day, a directory of small binary blobs with one number (last access) attached to each.
|
||||||
class FileTileCache implements TileCache {
|
class FileTileCache implements TileCache {
|
||||||
FileTileCache({required Directory directory, required this.maxBytes})
|
/// [debugArtificialManifestWriteDelay] is a test-only knob (defaults to a no-op,
|
||||||
: _dir = directory;
|
/// and every production caller leaves it unset): given the size of the manifest
|
||||||
|
/// about to be written, it returns how long to artificially pad that write by. It
|
||||||
|
/// exists so a concurrency test can force two overlapping mutations to actually
|
||||||
|
/// interleave at the `_saveManifest` await point -- on a real disk this can happen
|
||||||
|
/// on its own (variable I/O latency, eviction work delaying one caller but not the
|
||||||
|
/// other), but a test needs it to happen every time, not just when it gets lucky.
|
||||||
|
FileTileCache({
|
||||||
|
required Directory directory,
|
||||||
|
required this.maxBytes,
|
||||||
|
Duration Function(int entryCount)? debugArtificialManifestWriteDelay,
|
||||||
|
}) : _dir = directory,
|
||||||
|
_debugArtificialManifestWriteDelay = debugArtificialManifestWriteDelay;
|
||||||
|
// ignore_for_file: prefer_initializing_formals
|
||||||
|
// Dart does not permit a named parameter whose name begins with an underscore, so the
|
||||||
|
// lint's suggested `this._debugArtificialManifestWriteDelay` will not compile here.
|
||||||
|
|
||||||
final Directory _dir;
|
final Directory _dir;
|
||||||
final int maxBytes;
|
final int maxBytes;
|
||||||
|
final Duration Function(int entryCount)? _debugArtificialManifestWriteDelay;
|
||||||
|
|
||||||
final _manifest = <String, _Entry>{};
|
final _manifest = <String, _Entry>{};
|
||||||
bool _loaded = false;
|
bool _loaded = false;
|
||||||
int _clock = 0;
|
int _clock = 0;
|
||||||
|
|
||||||
|
/// Serializes every mutating (and manifest-reading) operation so two overlapping
|
||||||
|
/// calls -- e.g. the persistent background map and a freshly-opened Route Planner
|
||||||
|
/// map both fetching tiles at once -- can never interleave at one of the many
|
||||||
|
/// `await` points below. Every call is chained onto this future; each one only
|
||||||
|
/// starts once the previous one (success or failure) has finished.
|
||||||
|
Future<void> _queue = Future.value();
|
||||||
|
|
||||||
|
Future<T> _serialized<T>(Future<T> Function() op) {
|
||||||
|
final result = _queue.then((_) => op());
|
||||||
|
_queue = result.then((_) {}, onError: (_) {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
File get _manifestFile => File('${_dir.path}/manifest.json');
|
File get _manifestFile => File('${_dir.path}/manifest.json');
|
||||||
File _tileFile(TileKey key) => File('${_dir.path}/${_fileName(key)}');
|
File _tileFile(TileKey key) => File('${_dir.path}/${_fileName(key)}');
|
||||||
String _fileName(TileKey key) => '${key.z}_${key.x}_${key.y}.tile';
|
String _fileName(TileKey key) => '${key.z}_${key.x}_${key.y}.tile';
|
||||||
@@ -60,15 +88,20 @@ class FileTileCache implements TileCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveManifest() => _manifestFile.writeAsString(
|
Future<void> _saveManifest() async {
|
||||||
jsonEncode({
|
final encoded = jsonEncode({
|
||||||
for (final e in _manifest.entries)
|
for (final e in _manifest.entries)
|
||||||
e.key: {'bytes': e.value.bytes, 'lastAccess': e.value.lastAccess},
|
e.key: {'bytes': e.value.bytes, 'lastAccess': e.value.lastAccess},
|
||||||
}),
|
});
|
||||||
);
|
final delay = _debugArtificialManifestWriteDelay?.call(_manifest.length);
|
||||||
|
if (delay != null && delay > Duration.zero) {
|
||||||
|
await Future<void>.delayed(delay);
|
||||||
|
}
|
||||||
|
await _manifestFile.writeAsString(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> put(TileKey key, Uint8List bytes) async {
|
Future<void> put(TileKey key, Uint8List bytes) => _serialized(() async {
|
||||||
await _ensureLoaded();
|
await _ensureLoaded();
|
||||||
final name = _fileName(key);
|
final name = _fileName(key);
|
||||||
|
|
||||||
@@ -83,7 +116,7 @@ class FileTileCache implements TileCache {
|
|||||||
await _tileFile(key).writeAsBytes(bytes);
|
await _tileFile(key).writeAsBytes(bytes);
|
||||||
_manifest[name] = _Entry(bytes: bytes.length, lastAccess: _clock++);
|
_manifest[name] = _Entry(bytes: bytes.length, lastAccess: _clock++);
|
||||||
await _saveManifest();
|
await _saveManifest();
|
||||||
}
|
});
|
||||||
|
|
||||||
Future<void> _evictUntilFits(int incomingBytes) async {
|
Future<void> _evictUntilFits(int incomingBytes) async {
|
||||||
// Oldest-accessed first.
|
// Oldest-accessed first.
|
||||||
@@ -100,7 +133,7 @@ class FileTileCache implements TileCache {
|
|||||||
int _totalBytes() => _manifest.values.fold(0, (sum, e) => sum + e.bytes);
|
int _totalBytes() => _manifest.values.fold(0, (sum, e) => sum + e.bytes);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Uint8List?> get(TileKey key) async {
|
Future<Uint8List?> get(TileKey key) => _serialized(() async {
|
||||||
await _ensureLoaded();
|
await _ensureLoaded();
|
||||||
final name = _fileName(key);
|
final name = _fileName(key);
|
||||||
final entry = _manifest[name];
|
final entry = _manifest[name];
|
||||||
@@ -115,16 +148,16 @@ class FileTileCache implements TileCache {
|
|||||||
}
|
}
|
||||||
entry.lastAccess = _clock++;
|
entry.lastAccess = _clock++;
|
||||||
return file.readAsBytes();
|
return file.readAsBytes();
|
||||||
}
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<int> sizeBytes() async {
|
Future<int> sizeBytes() => _serialized(() async {
|
||||||
await _ensureLoaded();
|
await _ensureLoaded();
|
||||||
return _totalBytes();
|
return _totalBytes();
|
||||||
}
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> clear() async {
|
Future<void> clear() => _serialized(() async {
|
||||||
await _ensureLoaded();
|
await _ensureLoaded();
|
||||||
for (final key in _manifest.keys.toList()) {
|
for (final key in _manifest.keys.toList()) {
|
||||||
final f = File('${_dir.path}/$key');
|
final f = File('${_dir.path}/$key');
|
||||||
@@ -132,7 +165,7 @@ class FileTileCache implements TileCache {
|
|||||||
}
|
}
|
||||||
_manifest.clear();
|
_manifest.clear();
|
||||||
await _saveManifest();
|
await _saveManifest();
|
||||||
}
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> dispose() async {}
|
Future<void> dispose() async {}
|
||||||
|
|||||||
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';
|
||||||
175
lib/src/ui/app_shell.dart
Normal file
175
lib/src/ui/app_shell.dart
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/// 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 'package:latlong2/latlong.dart' as ll;
|
||||||
|
|
||||||
|
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>[];
|
||||||
|
// FB-01: only watched while idle, so there's never a duplicate GPS consumer during
|
||||||
|
// an active recording -- the moment a trip starts, this stops being watched at all.
|
||||||
|
final ambientFix =
|
||||||
|
trip == null ? ref.watch(ambientPositionProvider).valueOrNull : null;
|
||||||
|
final ambientPosition = ambientFix == null
|
||||||
|
? null
|
||||||
|
: ll.LatLng(ambientFix.latitude, ambientFix.longitude);
|
||||||
|
|
||||||
|
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,
|
||||||
|
ambientPosition: ambientPosition,
|
||||||
|
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'),
|
||||||
|
// Material 3's own default (80dp) reads as oversized for a 4-item bar with plain
|
||||||
|
// icon+label destinations -- direct feedback (`docs/FEEDBACK.md`) called this out.
|
||||||
|
// 64dp keeps every icon/label pair at its default size, just with less surrounding
|
||||||
|
// padding, and stays comfortably above the 48dp minimum touch target.
|
||||||
|
height: 64,
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
185
lib/src/ui/components/draggable_resizable_hud_widget.dart
Normal file
185
lib/src/ui/components/draggable_resizable_hud_widget.dart
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/// UI-04/FB-03: one telemetry widget on the customizable HUD -- positioned/sized from a
|
||||||
|
/// [HudWidgetLayout]'s grid cell against whatever pixel area it's given, draggable and
|
||||||
|
/// resizable only while [editing] is true. Drag/resize snap to whole grid cells on
|
||||||
|
/// gesture end (see [_DraggableResizableHudWidgetState]'s local pixel-offset fields);
|
||||||
|
/// during the gesture itself the widget tracks the finger/handle continuously in
|
||||||
|
/// pixels for a smooth feel.
|
||||||
|
///
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
/// Final snapped grid coordinates -- called exactly once, at gesture end, not on
|
||||||
|
/// every frame of movement (a local, uncommitted pixel offset tracks the live drag;
|
||||||
|
/// see [_DraggableResizableHudWidgetState]). The caller
|
||||||
|
/// (`HudLayoutController.updatePosition`) owns collision/bounds checking so there is
|
||||||
|
/// exactly one place that logic lives.
|
||||||
|
final void Function(int col, int row) onMoved;
|
||||||
|
final void Function(int colSpan, int rowSpan) onResized;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DraggableResizableHudWidget> createState() =>
|
||||||
|
_DraggableResizableHudWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidget> {
|
||||||
|
bool _grabbed = false;
|
||||||
|
|
||||||
|
/// Pixels, uncommitted, live only during a move gesture -- painted on top of the
|
||||||
|
/// last-committed grid position so the drag tracks the finger smoothly, then reset
|
||||||
|
/// to zero the instant the gesture ends (whether or not the parent accepted the
|
||||||
|
/// resulting snapped position), which is what makes the widget visually "snap back"
|
||||||
|
/// on a rejected move with no separate accept/reject callback needed.
|
||||||
|
Offset _dragOffset = Offset.zero;
|
||||||
|
|
||||||
|
/// Pixels, uncommitted, live only during a resize gesture -- same idea as
|
||||||
|
/// [_dragOffset].
|
||||||
|
Size _resizeDelta = Size.zero;
|
||||||
|
|
||||||
|
double get _cellWidth => widget.areaSize.width / hudGridColumns;
|
||||||
|
double get _cellHeight => widget.areaSize.height / hudGridRows;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final layout = widget.layout;
|
||||||
|
final left = layout.col * _cellWidth + _dragOffset.dx;
|
||||||
|
final top = layout.row * _cellHeight + _dragOffset.dy;
|
||||||
|
final width = layout.colSpan * _cellWidth + _resizeDelta.width;
|
||||||
|
final height = layout.rowSpan * _cellHeight + _resizeDelta.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) {
|
||||||
|
setState(() => _dragOffset = details.offsetFromOrigin);
|
||||||
|
},
|
||||||
|
onLongPressEnd: (_) {
|
||||||
|
final snappedCol =
|
||||||
|
((layout.col * _cellWidth + _dragOffset.dx) / _cellWidth).round();
|
||||||
|
final snappedRow =
|
||||||
|
((layout.row * _cellHeight + _dragOffset.dy) / _cellHeight).round();
|
||||||
|
widget.onMoved(snappedCol, snappedRow);
|
||||||
|
setState(() {
|
||||||
|
_grabbed = false;
|
||||||
|
_dragOffset = Offset.zero;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onLongPressCancel: () => setState(() {
|
||||||
|
_grabbed = false;
|
||||||
|
_dragOffset = Offset.zero;
|
||||||
|
}),
|
||||||
|
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) {
|
||||||
|
setState(() {
|
||||||
|
_resizeDelta = Size(
|
||||||
|
_resizeDelta.width + details.delta.dx,
|
||||||
|
_resizeDelta.height + details.delta.dy,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onPanEnd: (_) {
|
||||||
|
// FB-09: a rider can still make a widget larger than its title
|
||||||
|
// needs (for a bigger value reading), but never smaller than the
|
||||||
|
// title's own minimum -- clamped here rather than left to the
|
||||||
|
// generic hudMinSpan floor in HudLayoutController.
|
||||||
|
final snappedColSpan =
|
||||||
|
(((layout.colSpan * _cellWidth + _resizeDelta.width) /
|
||||||
|
_cellWidth)
|
||||||
|
.round())
|
||||||
|
.clamp(
|
||||||
|
minColSpanForLabel(layout.metric.label),
|
||||||
|
hudMaxColSpan,
|
||||||
|
);
|
||||||
|
final snappedRowSpan =
|
||||||
|
((layout.rowSpan * _cellHeight + _resizeDelta.height) /
|
||||||
|
_cellHeight)
|
||||||
|
.round();
|
||||||
|
widget.onResized(snappedColSpan, snappedRowSpan);
|
||||||
|
setState(() => _resizeDelta = Size.zero);
|
||||||
|
},
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
lib/src/ui/components/hud_edit_overlay.dart
Normal file
88
lib/src/ui/components/hud_edit_overlay.dart
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/// 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: (col, row) => notifier.updatePosition(entry.key, col, row),
|
||||||
|
onResized: (colSpan, rowSpan) =>
|
||||||
|
notifier.updateSize(entry.key, colSpan, rowSpan),
|
||||||
|
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,13 @@ 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 'glass_panel.dart';
|
||||||
|
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,14 +40,19 @@ 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.
|
/// Street-level zoom for the idle, no-recorded-points ambient position (FB-01). A
|
||||||
const String tileUserAgent = 'com.rippr.port';
|
/// distinct name from [shortRideZoom] even though the value happens to match -- one
|
||||||
|
/// means "a very short recorded ride," the other means "no ride at all, just ambient
|
||||||
|
/// GPS."
|
||||||
|
const double ambientZoom = 17.0;
|
||||||
|
|
||||||
class RideMap extends StatefulWidget {
|
class RideMap extends StatefulWidget {
|
||||||
const RideMap({
|
const RideMap({
|
||||||
@@ -45,12 +62,42 @@ 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,
|
||||||
|
this.ambientPosition,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<TrackPoint> points;
|
final List<TrackPoint> points;
|
||||||
final List<Segment> segments;
|
final List<Segment> segments;
|
||||||
final double height;
|
final double height;
|
||||||
|
|
||||||
|
/// FB-01: the device's current GPS position when there are no recorded [points] to
|
||||||
|
/// chase -- drives the shared background map's "look like Google Maps while idle"
|
||||||
|
/// behaviour. Only meaningful when [points] is empty; a recorded path always takes
|
||||||
|
/// priority over ambient position.
|
||||||
|
final ll.LatLng? ambientPosition;
|
||||||
|
|
||||||
|
/// 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 +109,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();
|
||||||
}
|
}
|
||||||
@@ -75,6 +133,14 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
/// primitive, and an app resume already triggers a full rebuild anyway.
|
/// primitive, and an app resume already triggers a full rebuild anyway.
|
||||||
bool _backgrounded = false;
|
bool _backgrounded = false;
|
||||||
|
|
||||||
|
/// FB-10: true from the moment a finger touches the map to the moment it lifts (or the
|
||||||
|
/// gesture is cancelled) -- independent of whether flutter_map has yet decided the
|
||||||
|
/// movement counts as a drag. Closes a race where a once-per-second ambient GPS tick's
|
||||||
|
/// `postFrameCallback` lands after the finger is down but before flutter_map has
|
||||||
|
/// reported `hasGesture: true`, snapping the camera back out from under the rider's
|
||||||
|
/// own in-progress pan.
|
||||||
|
bool _gestureInProgress = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -84,14 +150,39 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
@override
|
@override
|
||||||
void didUpdateWidget(RideMap old) {
|
void didUpdateWidget(RideMap old) {
|
||||||
super.didUpdateWidget(old);
|
super.didUpdateWidget(old);
|
||||||
if (!_following || widget.points.isEmpty || _backgrounded) return;
|
if (!_following || _backgrounded) return;
|
||||||
|
if (widget.points.isNotEmpty) {
|
||||||
final last = widget.points.last;
|
final last = widget.points.last;
|
||||||
// A manual camera move already exists once the controller has been used; jumping
|
// A manual camera move already exists once the controller has been used; jumping
|
||||||
// straight to the latest fix keeps the map from ever being one flush behind.
|
// straight to the latest fix keeps the map from ever being one flush behind.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted || !_following) return;
|
if (!mounted || !_following) return;
|
||||||
|
// FB-10: a pointer can go down after this callback is scheduled but before the
|
||||||
|
// next frame renders, so the check has to happen here, at the point where the
|
||||||
|
// camera would actually move, not when the callback is scheduled.
|
||||||
|
if (_gestureInProgress) return;
|
||||||
_controller.move(ll.LatLng(last.latitude, last.longitude), _controller.camera.zoom);
|
_controller.move(ll.LatLng(last.latitude, last.longitude), _controller.camera.zoom);
|
||||||
});
|
});
|
||||||
|
} else if (widget.ambientPosition != null &&
|
||||||
|
widget.ambientPosition != old.ambientPosition) {
|
||||||
|
// FB-01: no recorded path yet -- chase the ambient GPS fix instead, the same way
|
||||||
|
// a recording is chased above. The *first* fix has to also correct the zoom, not
|
||||||
|
// just the center: the very first build (before any fix has arrived) has no
|
||||||
|
// ambient position yet either, so `bounds == null` locks the initial camera at
|
||||||
|
// zoom 2 -- without this, every later fix would re-center at that same
|
||||||
|
// never-corrected zoom 2 forever, since a plain re-center only ever preserves
|
||||||
|
// whatever zoom the map is already at.
|
||||||
|
final isFirstFix = old.ambientPosition == null;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !_following) return;
|
||||||
|
// FB-10: see the identical check above -- same race, same reason.
|
||||||
|
if (_gestureInProgress) return;
|
||||||
|
_controller.move(
|
||||||
|
widget.ambientPosition!,
|
||||||
|
isFirstFix ? ambientZoom : _controller.camera.zoom,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -118,13 +209,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 +235,44 @@ 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),
|
||||||
|
// FB-10: raw pointer observation only -- this must not consume or claim the
|
||||||
|
// event, so flutter_map's own gesture recognizers underneath keep working exactly
|
||||||
|
// as they do today. A pointer is "in progress" from the instant a finger touches
|
||||||
|
// down, well before flutter_map's own recognizers decide the movement counts as a
|
||||||
|
// drag and report `hasGesture: true` -- that gap is the race this closes.
|
||||||
|
child: Listener(
|
||||||
|
onPointerDown: (_) => _gestureInProgress = true,
|
||||||
|
onPointerUp: (_) => _gestureInProgress = false,
|
||||||
|
onPointerCancel: (_) => _gestureInProgress = false,
|
||||||
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,9 +284,16 @@ 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,
|
? (widget.ambientPosition ?? const ll.LatLng(0, 0))
|
||||||
|
: ll.LatLng(bounds.centerLat, bounds.centerLon),
|
||||||
|
initialZoom: bounds == null
|
||||||
|
? (widget.ambientPosition == null ? 2 : ambientZoom)
|
||||||
|
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
|
||||||
maxZoom: maxTileZoom,
|
maxZoom: maxTileZoom,
|
||||||
|
// FB-06: pan/zoom must always be available, idle or recording -- the old
|
||||||
|
// `hasPoints` gate locked the map to `InteractiveFlag.none` whenever no trip
|
||||||
|
// was recording, which is exactly the "can't zoom and move around" report.
|
||||||
interactionOptions: const InteractionOptions(
|
interactionOptions: const InteractionOptions(
|
||||||
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
|
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
|
||||||
),
|
),
|
||||||
@@ -181,22 +309,86 @@ 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 || widget.ambientPosition != null))
|
||||||
|
MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
Marker(
|
||||||
|
key: const Key('location-marker'),
|
||||||
|
point: hasPoints
|
||||||
|
? ll.LatLng(
|
||||||
|
widget.points.last.latitude,
|
||||||
|
widget.points.last.longitude,
|
||||||
|
)
|
||||||
|
: widget.ambientPosition!,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: const PulsingLocationMarker(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (widget.showAttribution) const TileAttribution(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// FB-06: the recenter control only ever makes sense once there is a `follow`
|
||||||
|
// mode to return to and the rider has actually panned away from it -- a static
|
||||||
|
// (non-following) map, like a finished ride in Trip Detail, never shows this.
|
||||||
|
final showRecenter = widget.follow && !_following;
|
||||||
|
|
||||||
|
return _sized(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
map,
|
||||||
|
if (showRecenter)
|
||||||
|
Positioned(
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
child: GlassPanel(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(999)),
|
||||||
|
child: IconButton(
|
||||||
|
key: const Key('recenter-button'),
|
||||||
|
icon: const Icon(Icons.my_location),
|
||||||
|
onPressed: _recenter,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FB-06: return the camera to the rider's latest known position and resume
|
||||||
|
/// following new updates. Zoom is left untouched -- the rider may have
|
||||||
|
/// deliberately zoomed in or out, and recenter should not undo that.
|
||||||
|
void _recenter() {
|
||||||
|
final ll.LatLng? target = widget.points.isNotEmpty
|
||||||
|
? ll.LatLng(widget.points.last.latitude, widget.points.last.longitude)
|
||||||
|
: widget.ambientPosition;
|
||||||
|
if (target == null) return;
|
||||||
|
setState(() => _following = true);
|
||||||
|
_controller.move(target, _controller.camera.zoom);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One polyline per speed run within each segment.
|
/// One polyline per speed run within each segment.
|
||||||
@@ -266,9 +458,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,22 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 56dp 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. Mounted mode stays at 96dp (V3-05: not enough at speed, with gloves,
|
||||||
|
/// below that); the handheld case only needs a comfortable tap target, not a glove
|
||||||
|
/// target, and 56dp is still well above the 48dp accessibility floor.
|
||||||
|
double _controlBarHeight(bool mountedMode) => mountedMode ? 96 : 56;
|
||||||
|
|
||||||
|
/// The HUD stat grid used to fill the entire area down to the control bar, which
|
||||||
|
/// made even a two-row default layout balloon to take up most of the screen on a
|
||||||
|
/// tall phone -- direct feedback (`docs/FEEDBACK.md`) called this out explicitly.
|
||||||
|
/// Capping it to a fixed, compact band keeps every grid cell a sane physical size
|
||||||
|
/// regardless of screen height, leaving the rest of the screen as pure map. Mounted
|
||||||
|
/// mode gets a taller band to match its own larger text scale ([mountedTextScale]).
|
||||||
|
double _hudAreaHeight(bool mountedMode) => mountedMode ? 320 : 260;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final mountedMode = ref.watch(mountedModeProvider);
|
final mountedMode = ref.watch(mountedModeProvider);
|
||||||
@@ -158,11 +180,9 @@ 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);
|
||||||
final (speedValue, speedUnit) = formatSpeedParts(_liveSpeed, unit: units);
|
|
||||||
|
|
||||||
final ui = RecordUiState(
|
final ui = RecordUiState(
|
||||||
trip: trip,
|
trip: trip,
|
||||||
@@ -173,158 +193,64 @@ 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(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
Row(
|
if (!ui.isIdle)
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
Positioned(
|
||||||
children: [
|
top: 0,
|
||||||
Text(
|
left: 0,
|
||||||
'RIPPR',
|
right: 0,
|
||||||
style: TextStyle(
|
height: _hudAreaHeight(mountedMode),
|
||||||
fontSize: 34,
|
child: HudEditOverlay(
|
||||||
fontWeight: FontWeight.w900,
|
metricBuilder: (context, metric) => _HudMetricValue(
|
||||||
letterSpacing: 6,
|
metric: metric,
|
||||||
color: colors.primary,
|
ui: ui,
|
||||||
),
|
units: units,
|
||||||
),
|
|
||||||
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(
|
|
||||||
key: const Key('speed'),
|
|
||||||
label: 'SPEED',
|
|
||||||
value: speedValue,
|
|
||||||
unit: speedUnit,
|
|
||||||
scale: mountedMode ? mountedTextScale : 1.0,
|
scale: mountedMode ? mountedTextScale : 1.0,
|
||||||
|
mountedMode: mountedMode,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// V3-04: RecordingEngine has no idea this exists -- it is fed
|
|
||||||
// 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: 'Last ride',
|
|
||||||
value: 'See Rides',
|
|
||||||
),
|
|
||||||
] else ...[
|
|
||||||
StatRow(
|
|
||||||
label: 'Distance',
|
|
||||||
value: formatDistance(
|
|
||||||
ui.trip!.distanceM,
|
|
||||||
unit: units,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
StatRow(
|
|
||||||
label: 'Elapsed',
|
|
||||||
value: formatDuration(ui.elapsedMillis),
|
|
||||||
),
|
|
||||||
StatRow(
|
|
||||||
label: 'Moving time',
|
|
||||||
value: formatDuration(ui.trip!.movingMillis),
|
|
||||||
),
|
|
||||||
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,
|
mountedMode: 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 +267,133 @@ 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,
|
||||||
|
this.scale = 1.0,
|
||||||
|
this.mountedMode = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final HudMetric metric;
|
||||||
|
final RecordUiState ui;
|
||||||
|
final UnitSystem units;
|
||||||
|
|
||||||
|
/// V3-05: mounted mode reads bigger, at a glance, at speed. Same mechanism `BigStat`
|
||||||
|
/// used for the old idle Speed panel -- fixed pixel sizes below (not the ambient text
|
||||||
|
/// theme) are what a glove-and-visor readout actually is, so scaling has to happen
|
||||||
|
/// here rather than through `Theme.of(context).textTheme`.
|
||||||
|
final double scale;
|
||||||
|
|
||||||
|
/// FB-02: `_HudMetricValue` had no mounted-mode awareness at all before the idle
|
||||||
|
/// `BigStat` (which did) was removed as the idle panel's speed readout -- this
|
||||||
|
/// migration is what surfaced that gap. [scale] above covers the font-size half of
|
||||||
|
/// it; this covers the other half `BigStat` also handled by naming its headline
|
||||||
|
/// colour explicitly (`colors.onSurface`, never a metric accent) rather than trusting
|
||||||
|
/// the ambient theme: in the mounted theme specifically, `_valueColor`'s per-metric
|
||||||
|
/// accent colours (tuned for the dark theme's own contrast) fall a hair short of AA
|
||||||
|
/// (4.43:1, not 4.5:1) against `GlassPanel`'s mounted-theme surface. Reusing
|
||||||
|
/// `BigStat`'s own fallback -- plain, guaranteed-legible ink whenever mounted mode is
|
||||||
|
/// on -- fixes it without touching the accent colours the dark-theme HUD mockup
|
||||||
|
/// deliberately specifies.
|
||||||
|
final bool mountedMode;
|
||||||
|
|
||||||
|
/// 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. Mounted mode overrides
|
||||||
|
/// this with plain ink -- see [mountedMode] doc above.
|
||||||
|
Color _valueColor(ColorScheme colors) {
|
||||||
|
if (mountedMode) return colors.onSurface;
|
||||||
|
return 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;
|
||||||
|
// FB-09: the title is a plain, unwrapped Text at its fixed reference size -- it
|
||||||
|
// must always fit at a readable size, never shrinking along with the value. Only
|
||||||
|
// the value is wrapped in its own FittedBox, inside a Flexible so it claims
|
||||||
|
// whatever vertical space is left after the fixed-size title rather than both
|
||||||
|
// competing for space inside one shared FittedBox as before. maxLines/overflow are
|
||||||
|
// restored on the title as a safety net in case a future label is ever added that
|
||||||
|
// minColSpanForLabel's thresholds under-estimate.
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
metric.label.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10 * scale,
|
||||||
|
letterSpacing: 1,
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Flexible(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(
|
||||||
|
_value,
|
||||||
|
style: monoDigits.copyWith(
|
||||||
|
fontSize: 18 * scale,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _valueColor(colors),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
this.mountedMode = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final RecordUiState ui;
|
final RecordUiState ui;
|
||||||
@@ -391,133 +402,158 @@ 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.
|
/// V3-05: mounted mode keeps full-size, glove-friendly icons. The handheld case
|
||||||
final bool mounted;
|
/// only needs a comfortable tap target, not a glove target, so its icons shrink
|
||||||
|
/// along with the bar itself (`_controlBarHeight`) -- see [_Segment.iconSize].
|
||||||
|
final bool mountedMode;
|
||||||
|
|
||||||
@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,
|
||||||
|
mountedMode: mountedMode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
mountedMode: mountedMode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _Segment(
|
||||||
|
keyName: 'stop',
|
||||||
|
icon: Icons.stop,
|
||||||
|
background: colors.errorContainer,
|
||||||
|
foreground: colors.onErrorContainer,
|
||||||
|
onTap: onStop,
|
||||||
|
mountedMode: mountedMode,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
|
mountedMode: mountedMode,
|
||||||
),
|
),
|
||||||
child: Text(
|
),
|
||||||
label,
|
Expanded(
|
||||||
|
child: _Segment(
|
||||||
|
keyName: 'stop',
|
||||||
|
icon: Icons.stop,
|
||||||
|
background: colors.errorContainer,
|
||||||
|
foreground: colors.onErrorContainer,
|
||||||
|
onTap: onStop,
|
||||||
|
trailingBorder: true,
|
||||||
|
mountedMode: mountedMode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _Segment(
|
||||||
|
keyName: 'discard',
|
||||||
|
icon: Icons.delete_outline,
|
||||||
|
background: colors.surfaceContainerHighest,
|
||||||
|
foreground: colors.error,
|
||||||
|
onTap: onDiscard,
|
||||||
|
mountedMode: mountedMode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
this.mountedMode = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String keyName;
|
||||||
|
final IconData icon;
|
||||||
|
final Color background;
|
||||||
|
final Color foreground;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final String? label;
|
||||||
|
final bool trailingBorder;
|
||||||
|
final bool mountedMode;
|
||||||
|
|
||||||
|
double get _iconSize => mountedMode ? 32 : 24;
|
||||||
|
|
||||||
|
@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: _iconSize)
|
||||||
|
: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: foreground, size: _iconSize),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
label!,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: mounted ? 20 : 16,
|
color: foreground,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: mountedMode ? 16 : 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,25 @@ 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,
|
||||||
|
ambientZoom,
|
||||||
|
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 +51,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
|
||||||
@@ -65,14 +113,40 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
final route = ref.watch(routePlanProvider(widget.routeId)).valueOrNull;
|
final routeAsync = ref.watch(routePlanProvider(widget.routeId));
|
||||||
final waypointsAsync = ref.watch(routeWaypointsProvider(widget.routeId));
|
final waypointsAsync = ref.watch(routeWaypointsProvider(widget.routeId));
|
||||||
final waypoints = waypointsAsync.valueOrNull ?? const [];
|
final waypoints = waypointsAsync.valueOrNull ?? const [];
|
||||||
final units = ref.watch(unitSystemProvider);
|
final units = ref.watch(unitSystemProvider);
|
||||||
final repo = ref.read(routePlanRepositoryProvider);
|
final repo = ref.read(routePlanRepositoryProvider);
|
||||||
|
|
||||||
|
// FB-07: a brand-new route (or one with <2 pins) should open on the rider's real
|
||||||
|
// location rather than Null Island (0, 0). This mirrors ShellScaffold's own use of
|
||||||
|
// ambientPositionProvider (lib/src/ui/app_shell.dart). A missing fix (permission
|
||||||
|
// denied, service disabled, or not arrived yet) falls back to (0, 0) immediately --
|
||||||
|
// it must not block the map the way waypointsAsync is blocked above.
|
||||||
|
final ambientFix = ref.watch(ambientPositionProvider).valueOrNull;
|
||||||
|
final ambientPosition = ambientFix == null
|
||||||
|
? null
|
||||||
|
: ll.LatLng(ambientFix.latitude, ambientFix.longitude);
|
||||||
|
|
||||||
|
// Mirrors waypointsAsync's own guard below: `.valueOrNull` alone can't tell "still
|
||||||
|
// loading" apart from "genuinely doesn't exist" -- both collapse to null. On a
|
||||||
|
// brand-new route (fresh navigation right after `repo.createRoutePlan`), this
|
||||||
|
// provider's DB stream hasn't delivered its first row yet, so without this guard
|
||||||
|
// the screen would render "This route no longer exists." for a route that is
|
||||||
|
// completely valid, with zero FlutterMap in the tree, until that first emission
|
||||||
|
// arrives.
|
||||||
|
if (!routeAsync.hasValue) {
|
||||||
|
return const Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final route = routeAsync.value;
|
||||||
|
|
||||||
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,67 +159,50 @@ 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),
|
||||||
initialCenter: waypoints.isEmpty
|
initialCenter: waypoints.isEmpty
|
||||||
? const ll.LatLng(0, 0)
|
? (ambientPosition ?? const ll.LatLng(0, 0))
|
||||||
: 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 ? ambientZoom : 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),
|
||||||
),
|
),
|
||||||
@@ -155,11 +212,19 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
|||||||
Polyline(
|
Polyline(
|
||||||
points: [
|
points: [
|
||||||
for (final w in waypoints) ll.LatLng(w.latitude, w.longitude),
|
for (final w in waypoints) ll.LatLng(w.latitude, w.longitude),
|
||||||
|
if (route.isClosedLoop)
|
||||||
|
ll.LatLng(waypoints.first.latitude, waypoints.first.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 +262,122 @@ 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,
|
||||||
|
canCloseLoop: waypoints.length >= 2,
|
||||||
|
isClosedLoop: route.isClosedLoop,
|
||||||
|
onRename: () => _rename(context, repo, route),
|
||||||
|
onDownload: waypoints.isEmpty
|
||||||
|
? null
|
||||||
|
: () => _downloadOfflineTiles(context, waypoints),
|
||||||
|
onToggleLoop: () =>
|
||||||
|
repo.setClosedLoop(widget.routeId, !route.isClosedLoop),
|
||||||
|
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 +410,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 +467,121 @@ 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.canCloseLoop,
|
||||||
|
required this.isClosedLoop,
|
||||||
|
required this.onRename,
|
||||||
|
required this.onDownload,
|
||||||
|
required this.onToggleLoop,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool hasWaypoints;
|
||||||
|
final bool canCloseLoop;
|
||||||
|
final bool isClosedLoop;
|
||||||
|
final VoidCallback onRename;
|
||||||
|
final VoidCallback? onDownload;
|
||||||
|
final VoidCallback onToggleLoop;
|
||||||
|
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(),
|
||||||
|
'closeLoop' => onToggleLoop(),
|
||||||
|
'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'),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
key: const Key('toggle-closed-loop'),
|
||||||
|
value: 'closeLoop',
|
||||||
|
enabled: canCloseLoop,
|
||||||
|
child: Text(isClosedLoop ? 'Open the loop' : 'Close the loop'),
|
||||||
|
),
|
||||||
|
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 +610,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,71 @@ 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,
|
||||||
|
col: 2,
|
||||||
|
row: 3,
|
||||||
|
colSpan: 2,
|
||||||
|
rowSpan: 1,
|
||||||
|
visible: true,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
await config.setHudLayout(edited);
|
||||||
|
final reloaded = config.hudLayout;
|
||||||
|
|
||||||
|
expect(reloaded[HudMetric.speed]!.col, 2);
|
||||||
|
expect(reloaded[HudMetric.speed]!.row, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
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": {"col": 1, "row": 1, "colSpan": 1, "rowSpan": 2, '
|
||||||
|
'"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]!.col, expectedDefault.col);
|
||||||
|
expect(layout[HudMetric.distance]!.visible, expectedDefault.visible);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an old-shaped (pre-grid) saved layout falls back to defaults rather than '
|
||||||
|
'crashing', () 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.speed);
|
||||||
|
expect(layout[HudMetric.speed]!.col, expectedDefault.col);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
418
test/hud_edit_overlay_test.dart
Normal file
418
test/hud_edit_overlay_test.dart
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
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/hud/hud_widget_layout.dart';
|
||||||
|
import 'package:rippr/src/ui/components/draggable_resizable_hud_widget.dart';
|
||||||
|
import 'package:rippr/src/ui/components/hud_edit_overlay.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// A representative HUD child -- structurally the same fixed-title/flexible-value
|
||||||
|
/// shape as `record_screen.dart`'s private `_HudMetricValue`, which can't be
|
||||||
|
/// referenced directly from outside its library. FB-09: the title is a plain,
|
||||||
|
/// unwrapped `Text` at a fixed reference size; only the value is wrapped in its own
|
||||||
|
/// `Flexible(child: FittedBox(...))`.
|
||||||
|
Widget _representativeHudChild({String label = 'SPEED', String value = '12.3 km/h'}) =>
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 10, letterSpacing: 1),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Flexible(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
// Large enough to guarantee at least one grid cell of movement in both axes
|
||||||
|
// regardless of the test surface's exact pixel size.
|
||||||
|
await gesture.moveBy(const Offset(300, 300));
|
||||||
|
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.col, isNot(before.col));
|
||||||
|
expect(after.row, isNot(before.row));
|
||||||
|
});
|
||||||
|
|
||||||
|
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.col, before.col);
|
||||||
|
expect(after.row, before.row);
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a drag ending over a cell occupied by another visible widget leaves '
|
||||||
|
'the dragged widget\'s rendered position unchanged', (tester) async {
|
||||||
|
await tester.pumpWidget(host());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.longPress(find.byKey(const Key('hud-edit-background')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final speedFinder = find.byKey(const ValueKey(HudMetric.speed));
|
||||||
|
final before = tester.getTopLeft(speedFinder);
|
||||||
|
|
||||||
|
// Speed (col 0, row 0) dragged exactly one cell right lands squarely on
|
||||||
|
// avgSpeed's (col 1, row 0) cell -- both share the same row-0 default rowSpan, so
|
||||||
|
// this is a genuine, not merely edge-touching, overlap.
|
||||||
|
final gesture = await tester.startGesture(tester.getCenter(find.text('Speed')));
|
||||||
|
await tester.pump(kLongPressTimeout + kPressTimeout);
|
||||||
|
await gesture.moveBy(Offset(tester.view.physicalSize.width / tester.view.devicePixelRatio / hudGridColumns, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('hud-edit-done')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(config.hudLayout[HudMetric.speed]!.col, 0,
|
||||||
|
reason: 'the rejected move must not change the persisted grid cell either');
|
||||||
|
expect(tester.getTopLeft(speedFinder), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a resize past the point of colliding with a sibling leaves the widget '
|
||||||
|
'at its pre-gesture size', (tester) async {
|
||||||
|
await tester.pumpWidget(host());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.longPress(find.byKey(const Key('hud-edit-background')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final speedFinder = find.byKey(const ValueKey(HudMetric.speed));
|
||||||
|
final beforeSize = tester.getSize(speedFinder);
|
||||||
|
final handleFinder = find.descendant(
|
||||||
|
of: speedFinder,
|
||||||
|
matching: find.byKey(const Key('hud-resize-handle')),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Speed defaults to colSpan 1 at col 0; growing it across the full grid width
|
||||||
|
// would swallow avgSpeed/distance/elapsedTime's row-0 slots.
|
||||||
|
final cellWidth = tester.view.physicalSize.width / tester.view.devicePixelRatio / hudGridColumns;
|
||||||
|
final gesture = await tester.startGesture(tester.getCenter(handleFinder));
|
||||||
|
await gesture.moveBy(Offset(cellWidth * hudGridColumns, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('hud-edit-done')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(config.hudLayout[HudMetric.speed]!.colSpan, 1,
|
||||||
|
reason: 'the rejected resize must not change the persisted span either');
|
||||||
|
expect(tester.getSize(speedFinder), beforeSize);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('HUD widget text never overflows at the smallest (1x1) allowed size',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: SizedBox(
|
||||||
|
width: 400,
|
||||||
|
height: 400,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
DraggableResizableHudWidget(
|
||||||
|
layout: const HudWidgetLayout(
|
||||||
|
metric: HudMetric.speed,
|
||||||
|
col: 0,
|
||||||
|
row: 0,
|
||||||
|
colSpan: hudMinSpan,
|
||||||
|
rowSpan: hudMinSpan,
|
||||||
|
visible: true,
|
||||||
|
),
|
||||||
|
areaSize: const Size(400, 400),
|
||||||
|
editing: false,
|
||||||
|
onMoved: (_, _) {},
|
||||||
|
onResized: (_, _) {},
|
||||||
|
child: _representativeHudChild(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'HUD widget text never overflows at the largest (max col/row span) allowed size',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: SizedBox(
|
||||||
|
width: 400,
|
||||||
|
height: 400,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
DraggableResizableHudWidget(
|
||||||
|
layout: const HudWidgetLayout(
|
||||||
|
metric: HudMetric.speed,
|
||||||
|
col: 0,
|
||||||
|
row: 0,
|
||||||
|
colSpan: hudMaxColSpan,
|
||||||
|
rowSpan: hudMaxRowSpan,
|
||||||
|
visible: true,
|
||||||
|
),
|
||||||
|
areaSize: const Size(400, 400),
|
||||||
|
editing: false,
|
||||||
|
onMoved: (_, _) {},
|
||||||
|
onResized: (_, _) {},
|
||||||
|
child: _representativeHudChild(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'resizing a widget below its title\'s minimum colSpan via the resize handle '
|
||||||
|
'clamps to that minimum, not the generic hudMinSpan', (tester) async {
|
||||||
|
// Points captured is the longest label -- minColSpanForLabel gives it 3, well
|
||||||
|
// above the generic hudMinSpan of 1.
|
||||||
|
const metric = HudMetric.pointsCaptured;
|
||||||
|
final expectedMin = minColSpanForLabel(metric.label);
|
||||||
|
expect(expectedMin, greaterThan(hudMinSpan));
|
||||||
|
|
||||||
|
int? resizedColSpan;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: SizedBox(
|
||||||
|
width: 400,
|
||||||
|
height: 400,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
DraggableResizableHudWidget(
|
||||||
|
layout: HudWidgetLayout(
|
||||||
|
metric: metric,
|
||||||
|
col: 0,
|
||||||
|
row: 0,
|
||||||
|
colSpan: expectedMin,
|
||||||
|
rowSpan: 2,
|
||||||
|
visible: true,
|
||||||
|
),
|
||||||
|
areaSize: const Size(400, 400),
|
||||||
|
editing: true,
|
||||||
|
onMoved: (_, _) {},
|
||||||
|
onResized: (colSpan, _) => resizedColSpan = colSpan,
|
||||||
|
child: _representativeHudChild(label: metric.label.toUpperCase()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final handleFinder = find.byKey(const Key('hud-resize-handle'));
|
||||||
|
final gesture = await tester.startGesture(tester.getCenter(handleFinder));
|
||||||
|
// Drag far enough left/up to try to shrink well below every floor.
|
||||||
|
await gesture.moveBy(const Offset(-390, -390));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(resizedColSpan, expectedMin,
|
||||||
|
reason: 'the resize handle must clamp up to the title\'s own minimum, not '
|
||||||
|
'the generic hudMinSpan');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'the longest label renders in full, without overflow, at exactly its own '
|
||||||
|
'title-driven minimum colSpan', (tester) async {
|
||||||
|
const metric = HudMetric.pointsCaptured;
|
||||||
|
final minSpan = minColSpanForLabel(metric.label);
|
||||||
|
final cellWidth = 400 / hudGridColumns;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: SizedBox(
|
||||||
|
width: 400,
|
||||||
|
height: 400,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
DraggableResizableHudWidget(
|
||||||
|
layout: HudWidgetLayout(
|
||||||
|
metric: metric,
|
||||||
|
col: 0,
|
||||||
|
row: 0,
|
||||||
|
colSpan: minSpan,
|
||||||
|
rowSpan: 2,
|
||||||
|
visible: true,
|
||||||
|
),
|
||||||
|
areaSize: const Size(400, 400),
|
||||||
|
editing: false,
|
||||||
|
onMoved: (_, _) {},
|
||||||
|
onResized: (_, _) {},
|
||||||
|
child: _representativeHudChild(label: metric.label.toUpperCase()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text('POINTS CAPTURED'), findsOneWidget,
|
||||||
|
reason: 'the full title must be present, not an ellipsized fragment, at its '
|
||||||
|
'own minimum width ($minSpan cols * $cellWidth px each)');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'the title\'s rendered font size never grows past its fixed reference size, '
|
||||||
|
'while the value is free to size differently', (tester) async {
|
||||||
|
// Pumped directly inside a tightly-sized SizedBox (rather than through
|
||||||
|
// DraggableResizableHudWidget's GlassPanel/Center/AnimatedScale chain, which
|
||||||
|
// hands the content loose rather than tight constraints) so the Column's
|
||||||
|
// Flexible-wrapped value is actually forced to size against the box, exercising
|
||||||
|
// the fixed-title/flexible-value split this ticket adds.
|
||||||
|
Widget host(Size size) => MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: SizedBox(
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
child: _representativeHudChild(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// FittedBox scales its child at paint time via a transform, not by resizing the
|
||||||
|
// child's own layout box -- so the inner value Text's RenderBox size is always
|
||||||
|
// its unscaled natural size, regardless of how much the FittedBox actually
|
||||||
|
// shrank it visually. The FittedBox's own rendered size is what reflects the
|
||||||
|
// available space, so that -- not the Text inside it -- is what's compared here.
|
||||||
|
await tester.pumpWidget(host(const Size(60, 40)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final smallTitleFontSize = tester.widget<Text>(find.text('SPEED')).style?.fontSize;
|
||||||
|
final smallValueBoxSize = tester.getSize(find.byType(FittedBox));
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(const Size(600, 400)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final bigTitleFontSize = tester.widget<Text>(find.text('SPEED')).style?.fontSize;
|
||||||
|
final bigValueBoxSize = tester.getSize(find.byType(FittedBox));
|
||||||
|
|
||||||
|
expect(bigTitleFontSize, smallTitleFontSize,
|
||||||
|
reason: 'the title is a fixed-size Text, not wrapped in a FittedBox, so a '
|
||||||
|
'much larger widget must not change its style\'s font size');
|
||||||
|
expect(bigValueBoxSize, isNot(smallValueBoxSize),
|
||||||
|
reason: 'the value is the flexible part and its FittedBox is free to be '
|
||||||
|
'sized differently as the widget grows');
|
||||||
|
});
|
||||||
|
}
|
||||||
233
test/hud_layout_controller_test.dart
Normal file
233
test/hud_layout_controller_test.dart
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
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:rippr/src/hud/hud_widget_layout.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 to the grid', () async {
|
||||||
|
final controller = HudLayoutController(await freshConfig());
|
||||||
|
final before = controller.state[HudMetric.distance]!;
|
||||||
|
|
||||||
|
// Row 4 is unused by any default layout, so this can't collide.
|
||||||
|
controller.updatePosition(HudMetric.speed, 0, 4);
|
||||||
|
|
||||||
|
expect(controller.state[HudMetric.speed]!.row, 4);
|
||||||
|
expect(controller.state[HudMetric.distance]!.col, before.col,
|
||||||
|
reason: 'moving one metric must not disturb another');
|
||||||
|
expect(controller.state[HudMetric.distance]!.row, before.row);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updatePosition clamps a candidate that lands outside the grid', () async {
|
||||||
|
final controller = HudLayoutController(await freshConfig());
|
||||||
|
|
||||||
|
controller.updatePosition(HudMetric.speed, 99, 99);
|
||||||
|
|
||||||
|
final speed = controller.state[HudMetric.speed]!;
|
||||||
|
expect(speed.col, lessThanOrEqualTo(hudGridColumns - speed.colSpan));
|
||||||
|
expect(speed.row, lessThanOrEqualTo(hudGridRows - speed.rowSpan));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateSize clamps to the legibility floor/ceiling', () async {
|
||||||
|
final controller = HudLayoutController(await freshConfig());
|
||||||
|
|
||||||
|
controller.updateSize(HudMetric.speed, 0, 0);
|
||||||
|
expect(controller.state[HudMetric.speed]!.colSpan, hudMinSpan);
|
||||||
|
expect(controller.state[HudMetric.speed]!.rowSpan, hudMinSpan);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updatePosition onto a cell occupied by another visible metric is a no-op', () async {
|
||||||
|
final controller = HudLayoutController(await freshConfig());
|
||||||
|
final before = controller.state[HudMetric.speed]!;
|
||||||
|
final avgSpeed = controller.state[HudMetric.avgSpeed]!;
|
||||||
|
|
||||||
|
// avgSpeed is visible by default -- try to drag speed directly on top of it.
|
||||||
|
controller.updatePosition(HudMetric.speed, avgSpeed.col, avgSpeed.row);
|
||||||
|
|
||||||
|
expect(controller.state[HudMetric.speed]!.col, before.col);
|
||||||
|
expect(controller.state[HudMetric.speed]!.row, before.row);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateSize that would overlap a sibling is a no-op', () async {
|
||||||
|
final controller = HudLayoutController(await freshConfig());
|
||||||
|
final before = controller.state[HudMetric.speed]!;
|
||||||
|
|
||||||
|
// Growing speed (default col 0, row 0) to span all 4 columns would swallow
|
||||||
|
// avgSpeed/distance/elapsedTime's row-0 slots.
|
||||||
|
controller.updateSize(HudMetric.speed, hudGridColumns, before.rowSpan);
|
||||||
|
|
||||||
|
expect(controller.state[HudMetric.speed]!.colSpan, before.colSpan);
|
||||||
|
expect(controller.state[HudMetric.speed]!.rowSpan, before.rowSpan);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setVisible(false) then setVisible(true) restores the last saved position, '
|
||||||
|
'when that position is still free', () async {
|
||||||
|
final controller = HudLayoutController(await freshConfig());
|
||||||
|
// Row 4 is unused by any default layout, so this can't collide with anything.
|
||||||
|
controller.updatePosition(HudMetric.avgSpeed, 0, 4);
|
||||||
|
final moved = controller.state[HudMetric.avgSpeed]!;
|
||||||
|
|
||||||
|
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]!.col, moved.col,
|
||||||
|
reason: 'a metric with a real saved position keeps it when re-enabled');
|
||||||
|
expect(controller.state[HudMetric.avgSpeed]!.row, moved.row);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setVisible(true) when the metric\'s last position now collides with '
|
||||||
|
'another visible widget places it somewhere else instead', () async {
|
||||||
|
final controller = HudLayoutController(await freshConfig());
|
||||||
|
final maxSpeedDefault = controller.state[HudMetric.maxSpeed]!;
|
||||||
|
|
||||||
|
// maxSpeed starts hidden at its default slot; move a currently-visible metric
|
||||||
|
// onto that exact slot so it's occupied by the time maxSpeed is re-enabled.
|
||||||
|
controller.updatePosition(
|
||||||
|
HudMetric.speed,
|
||||||
|
maxSpeedDefault.col,
|
||||||
|
maxSpeedDefault.row,
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.setVisible(HudMetric.maxSpeed, true);
|
||||||
|
|
||||||
|
final maxSpeed = controller.state[HudMetric.maxSpeed]!;
|
||||||
|
expect(maxSpeed.visible, isTrue);
|
||||||
|
final others = controller.state.values.where(
|
||||||
|
(l) => l.visible && l.metric != HudMetric.maxSpeed,
|
||||||
|
);
|
||||||
|
for (final other in others) {
|
||||||
|
expect(hudRectsOverlap(maxSpeed, other), isFalse,
|
||||||
|
reason: 'the re-enabled metric must not stack on any other visible widget');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hiding one of four same-row metrics reflows the remaining three to fill '
|
||||||
|
'the row with no gap', () async {
|
||||||
|
final controller = HudLayoutController(await freshConfig());
|
||||||
|
|
||||||
|
// FB-09's title-driven defaultFor no longer puts all four default-visible
|
||||||
|
// metrics in row 0 with uniform colSpan 1 (avgSpeed and elapsedTime need 2
|
||||||
|
// columns for their longer titles). Force that uniform row-0 arrangement
|
||||||
|
// explicitly here so this test exercises _reflowRow's own algorithm in
|
||||||
|
// isolation, independent of exactly where defaultFor happens to place things.
|
||||||
|
controller.updateSize(HudMetric.elapsedTime, 1, 2);
|
||||||
|
controller.updateSize(HudMetric.avgSpeed, 1, 2);
|
||||||
|
controller.updatePosition(HudMetric.elapsedTime, 2, 0);
|
||||||
|
expect(
|
||||||
|
[HudMetric.speed, HudMetric.avgSpeed, HudMetric.elapsedTime, HudMetric.distance]
|
||||||
|
.map((m) => controller.state[m]!.row)
|
||||||
|
.toSet(),
|
||||||
|
{0},
|
||||||
|
reason: 'test setup assumes all four land in row 0',
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.setVisible(HudMetric.avgSpeed, false);
|
||||||
|
|
||||||
|
final remaining = [HudMetric.speed, HudMetric.distance, HudMetric.elapsedTime]
|
||||||
|
.map((m) => controller.state[m]!)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.col.compareTo(b.col));
|
||||||
|
final totalSpan = remaining.fold<int>(0, (sum, l) => sum + l.colSpan);
|
||||||
|
expect(totalSpan, hudGridColumns);
|
||||||
|
// baseSpan = 4 ~/ 3 = 1, extra = 1 -- the first (leftmost) member absorbs it.
|
||||||
|
expect(remaining[0].colSpan, 2);
|
||||||
|
expect(remaining[1].colSpan, 1);
|
||||||
|
expect(remaining[2].colSpan, 1);
|
||||||
|
expect(controller.state[HudMetric.avgSpeed]!.visible, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hiding a metric whose row has no other visible member leaves every '
|
||||||
|
'other metric untouched', () async {
|
||||||
|
final controller = HudLayoutController(await freshConfig());
|
||||||
|
// Row 4 is unused by any default layout -- move speed there alone.
|
||||||
|
controller.updatePosition(HudMetric.speed, 0, 4);
|
||||||
|
|
||||||
|
final others = {
|
||||||
|
for (final m in HudMetric.values.where((m) => m != HudMetric.speed))
|
||||||
|
m: controller.state[m]!,
|
||||||
|
};
|
||||||
|
|
||||||
|
controller.setVisible(HudMetric.speed, false);
|
||||||
|
|
||||||
|
for (final entry in others.entries) {
|
||||||
|
final after = controller.state[entry.key]!;
|
||||||
|
expect(after.col, entry.value.col, reason: '${entry.key} col changed');
|
||||||
|
expect(after.row, entry.value.row, reason: '${entry.key} row changed');
|
||||||
|
expect(after.colSpan, entry.value.colSpan,
|
||||||
|
reason: '${entry.key} colSpan changed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showing a previously-hidden metric back into a row with existing '
|
||||||
|
'members reflows that row to fit it', () async {
|
||||||
|
final controller = HudLayoutController(await freshConfig());
|
||||||
|
|
||||||
|
// FB-09's title-driven defaultFor no longer necessarily lands maxSpeed and
|
||||||
|
// movingTime in the same row (their default rows depend on every metric's
|
||||||
|
// individual title width). Move both hidden metrics into the same row
|
||||||
|
// explicitly, before enabling either, so this test is independent of exactly
|
||||||
|
// where defaultFor happens to place them.
|
||||||
|
controller.updatePosition(HudMetric.maxSpeed, 0, 6);
|
||||||
|
controller.updatePosition(HudMetric.movingTime, 1, 6);
|
||||||
|
|
||||||
|
controller.setVisible(HudMetric.maxSpeed, true);
|
||||||
|
controller.setVisible(HudMetric.movingTime, true);
|
||||||
|
|
||||||
|
final maxSpeed = controller.state[HudMetric.maxSpeed]!;
|
||||||
|
final movingTime = controller.state[HudMetric.movingTime]!;
|
||||||
|
expect(maxSpeed.row, movingTime.row,
|
||||||
|
reason: 'test setup assumes both land in the same row');
|
||||||
|
expect(maxSpeed.colSpan + movingTime.colSpan, hudGridColumns);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggling a metric in one row leaves a different row\'s members '
|
||||||
|
'untouched', () async {
|
||||||
|
final controller = HudLayoutController(await freshConfig());
|
||||||
|
final row2Before = {
|
||||||
|
for (final m in [HudMetric.maxSpeed, HudMetric.movingTime, HudMetric.elevationGain])
|
||||||
|
m: controller.state[m]!,
|
||||||
|
};
|
||||||
|
|
||||||
|
controller.setVisible(HudMetric.avgSpeed, false);
|
||||||
|
|
||||||
|
for (final entry in row2Before.entries) {
|
||||||
|
final after = controller.state[entry.key]!;
|
||||||
|
expect(after.col, entry.value.col, reason: '${entry.key} col changed');
|
||||||
|
expect(after.colSpan, entry.value.colSpan,
|
||||||
|
reason: '${entry.key} colSpan changed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persist writes the current state to Config', () async {
|
||||||
|
final config = await freshConfig();
|
||||||
|
final controller = HudLayoutController(config);
|
||||||
|
controller.updatePosition(HudMetric.speed, 0, 4);
|
||||||
|
|
||||||
|
await controller.persist();
|
||||||
|
|
||||||
|
final reloaded = config.hudLayout;
|
||||||
|
expect(reloaded[HudMetric.speed]!.row, 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);
|
||||||
|
|
||||||
|
// No persist() call yet.
|
||||||
|
final reloaded = config.hudLayout;
|
||||||
|
expect(reloaded[HudMetric.speed]!.row, isNot(4),
|
||||||
|
reason: 'a drag in progress must only touch in-memory state -- '
|
||||||
|
'persisting every frame is the ticket\'s own named risk');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
261
test/hud_widget_layout_test.dart
Normal file
261
test/hud_widget_layout_test.dart
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/hud/hud_metric.dart';
|
||||||
|
import 'package:rippr/src/hud/hud_widget_layout.dart';
|
||||||
|
|
||||||
|
/// FB-03: the JSON round-trip, grid-clamp math, overlap check, and free-slot search
|
||||||
|
/// are all exercised directly with fixed inputs -- no real gestures needed to test
|
||||||
|
/// any of these, per the ticket's own Tests section.
|
||||||
|
void main() {
|
||||||
|
group('JSON round trip', () {
|
||||||
|
test('encodes and decodes exactly', () {
|
||||||
|
const original = HudWidgetLayout(
|
||||||
|
metric: HudMetric.speed,
|
||||||
|
col: 1,
|
||||||
|
row: 2,
|
||||||
|
colSpan: 3,
|
||||||
|
rowSpan: 2,
|
||||||
|
visible: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
final decoded = HudWidgetLayout.fromJson(HudMetric.speed, original.toJson());
|
||||||
|
|
||||||
|
expect(decoded.col, original.col);
|
||||||
|
expect(decoded.row, original.row);
|
||||||
|
expect(decoded.colSpan, original.colSpan);
|
||||||
|
expect(decoded.rowSpan, original.rowSpan);
|
||||||
|
expect(decoded.visible, original.visible);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a malformed entry falls back to the default rather than crashing', () {
|
||||||
|
final decoded = HudWidgetLayout.fromJson(HudMetric.distance, {'col': 'not a number'});
|
||||||
|
final default_ = HudWidgetLayout.defaultFor(HudMetric.distance);
|
||||||
|
|
||||||
|
expect(decoded.col, default_.col);
|
||||||
|
expect(decoded.colSpan, default_.colSpan);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing field falls back to the default rather than crashing', () {
|
||||||
|
final decoded = HudWidgetLayout.fromJson(HudMetric.maxSpeed, {'col': 1, 'row': 1});
|
||||||
|
final default_ = HudWidgetLayout.defaultFor(HudMetric.maxSpeed);
|
||||||
|
|
||||||
|
expect(decoded.col, default_.col);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an old-shaped (pre-grid) saved layout falls back to the default rather '
|
||||||
|
'than crashing', () {
|
||||||
|
final decoded = HudWidgetLayout.fromJson(
|
||||||
|
HudMetric.speed,
|
||||||
|
{'x': 0.1, 'y': 0.2, 'width': 0.3, 'height': 0.15, 'visible': true},
|
||||||
|
);
|
||||||
|
final default_ = HudWidgetLayout.defaultFor(HudMetric.speed);
|
||||||
|
|
||||||
|
expect(decoded.col, default_.col);
|
||||||
|
expect(decoded.row, default_.row);
|
||||||
|
expect(decoded.colSpan, default_.colSpan);
|
||||||
|
expect(decoded.rowSpan, default_.rowSpan);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('defaultFor', () {
|
||||||
|
test('every metric gets a distinct, non-overlapping default rect', () {
|
||||||
|
final layouts = HudMetric.values.map(HudWidgetLayout.defaultFor).toList();
|
||||||
|
for (var i = 0; i < layouts.length; i++) {
|
||||||
|
for (var j = i + 1; j < layouts.length; j++) {
|
||||||
|
expect(hudRectsOverlap(layouts[i], layouts[j]), isFalse,
|
||||||
|
reason: 'no two metrics should default to overlapping rects');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 grid bounds', () {
|
||||||
|
for (final metric in HudMetric.values) {
|
||||||
|
final layout = HudWidgetLayout.defaultFor(metric);
|
||||||
|
expect(layout.clampedToGrid().col, layout.col);
|
||||||
|
expect(layout.clampedToGrid().row, layout.row);
|
||||||
|
expect(layout.clampedToGrid().colSpan, layout.colSpan);
|
||||||
|
expect(layout.clampedToGrid().rowSpan, layout.rowSpan);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every metric\'s colSpan is at least its own title-driven minimum', () {
|
||||||
|
for (final metric in HudMetric.values) {
|
||||||
|
final layout = HudWidgetLayout.defaultFor(metric);
|
||||||
|
expect(
|
||||||
|
layout.colSpan,
|
||||||
|
greaterThanOrEqualTo(minColSpanForLabel(metric.label)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no row\'s members ever sum past hudGridColumns (the packing '
|
||||||
|
'algorithm\'s own invariant)', () {
|
||||||
|
final layouts = HudMetric.values.map(HudWidgetLayout.defaultFor).toList();
|
||||||
|
final byRow = <int, int>{};
|
||||||
|
for (final layout in layouts) {
|
||||||
|
byRow[layout.row] = (byRow[layout.row] ?? 0) + layout.colSpan;
|
||||||
|
}
|
||||||
|
for (final sum in byRow.values) {
|
||||||
|
expect(sum, lessThanOrEqualTo(hudGridColumns));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('minColSpanForLabel', () {
|
||||||
|
test('a label under 10 characters fits one column', () {
|
||||||
|
expect(minColSpanForLabel(HudMetric.speed.label), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a label under 15 characters needs two', () {
|
||||||
|
expect(minColSpanForLabel(HudMetric.elapsedTime.label), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a label of 15 or more characters needs three', () {
|
||||||
|
expect(minColSpanForLabel(HudMetric.pointsCaptured.label), 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('clampedToGrid', () {
|
||||||
|
const base = HudWidgetLayout(
|
||||||
|
metric: HudMetric.speed,
|
||||||
|
col: 1,
|
||||||
|
row: 1,
|
||||||
|
colSpan: 2,
|
||||||
|
rowSpan: 2,
|
||||||
|
visible: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a position dragged past the right/bottom edge is pulled back onto the '
|
||||||
|
'grid', () {
|
||||||
|
final result = base.copyWith(col: 20, row: 20).clampedToGrid();
|
||||||
|
expect(result.col, hudGridColumns - base.colSpan);
|
||||||
|
expect(result.row, hudGridRows - base.rowSpan);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a position dragged past the left/top edge is pulled back onto the grid', () {
|
||||||
|
final result = base.copyWith(col: -5, row: -5).clampedToGrid();
|
||||||
|
expect(result.col, 0);
|
||||||
|
expect(result.row, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a resize below the legibility floor is corrected up to the minimum', () {
|
||||||
|
final result = base.copyWith(colSpan: 0, rowSpan: 0).clampedToGrid();
|
||||||
|
expect(result.colSpan, hudMinSpan);
|
||||||
|
expect(result.rowSpan, hudMinSpan);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a resize above the sane ceiling is corrected down to the maximum', () {
|
||||||
|
final result = base.copyWith(colSpan: 99, rowSpan: 99).clampedToGrid();
|
||||||
|
expect(result.colSpan, hudMaxColSpan);
|
||||||
|
expect(result.rowSpan, hudMaxRowSpan);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shrinking to fit happens before repositioning, so a widget resized past '
|
||||||
|
'the edge shrinks rather than silently relocates', () {
|
||||||
|
final result = base.copyWith(col: 3, colSpan: 4).clampedToGrid();
|
||||||
|
expect(result.colSpan, hudMaxColSpan);
|
||||||
|
// col itself is still clamped against the (now-bounded) span so the widget
|
||||||
|
// never sits even partially off the grid either.
|
||||||
|
expect(result.col + result.colSpan, lessThanOrEqualTo(hudGridColumns));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an already-valid layout is unchanged', () {
|
||||||
|
expect(base.clampedToGrid().col, base.col);
|
||||||
|
expect(base.clampedToGrid().row, base.row);
|
||||||
|
expect(base.clampedToGrid().colSpan, base.colSpan);
|
||||||
|
expect(base.clampedToGrid().rowSpan, base.rowSpan);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('hudRectsOverlap', () {
|
||||||
|
const a = HudWidgetLayout(
|
||||||
|
metric: HudMetric.speed,
|
||||||
|
col: 1,
|
||||||
|
row: 1,
|
||||||
|
colSpan: 2,
|
||||||
|
rowSpan: 2,
|
||||||
|
visible: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('two identical rects overlap', () {
|
||||||
|
expect(hudRectsOverlap(a, a), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two rects sharing only an edge (touching, not overlapping) do not overlap', () {
|
||||||
|
final rightNeighbor = a.copyWith(col: a.col + a.colSpan);
|
||||||
|
expect(hudRectsOverlap(a, rightNeighbor), isFalse);
|
||||||
|
|
||||||
|
final belowNeighbor = a.copyWith(row: a.row + a.rowSpan);
|
||||||
|
expect(hudRectsOverlap(a, belowNeighbor), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two disjoint rects do not overlap', () {
|
||||||
|
final far = a.copyWith(col: a.col + a.colSpan + 3, row: a.row + a.rowSpan + 3);
|
||||||
|
expect(hudRectsOverlap(a, far), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rect fully containing another overlaps', () {
|
||||||
|
const big = HudWidgetLayout(
|
||||||
|
metric: HudMetric.distance,
|
||||||
|
col: 0,
|
||||||
|
row: 0,
|
||||||
|
colSpan: 4,
|
||||||
|
rowSpan: 4,
|
||||||
|
visible: true,
|
||||||
|
);
|
||||||
|
expect(hudRectsOverlap(big, a), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two partially-overlapping rects overlap', () {
|
||||||
|
final overlapping = a.copyWith(col: a.col + 1, row: a.row + 1);
|
||||||
|
expect(hudRectsOverlap(a, overlapping), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('nextFreeSlot', () {
|
||||||
|
test('returns a rect that does not collide with any occupied visible layout, '
|
||||||
|
'when the metric\'s own default position is taken', () {
|
||||||
|
final occupier = HudWidgetLayout.defaultFor(HudMetric.speed);
|
||||||
|
final occupied = {HudMetric.speed: occupier};
|
||||||
|
|
||||||
|
final result = HudWidgetLayout.nextFreeSlot(
|
||||||
|
HudMetric.avgSpeed,
|
||||||
|
occupied: occupied,
|
||||||
|
colSpan: occupier.colSpan,
|
||||||
|
rowSpan: occupier.rowSpan,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hudRectsOverlap(result, occupier), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the same rect defaultFor would have (or another valid one) when '
|
||||||
|
'occupied is empty', () {
|
||||||
|
final result = HudWidgetLayout.nextFreeSlot(HudMetric.speed, occupied: {});
|
||||||
|
expect(result.col, 0);
|
||||||
|
expect(result.row, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the same rect defaultFor would have (or another valid one) when '
|
||||||
|
'occupied is all-hidden', () {
|
||||||
|
final hidden = HudWidgetLayout.defaultFor(HudMetric.speed).copyWith(visible: false);
|
||||||
|
final result = HudWidgetLayout.nextFreeSlot(
|
||||||
|
HudMetric.avgSpeed,
|
||||||
|
occupied: {HudMetric.speed: hidden},
|
||||||
|
);
|
||||||
|
expect(result.col, 0);
|
||||||
|
expect(result.row, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -177,4 +177,57 @@ void main() {
|
|||||||
final waypoints = await db.waypointsForRoute(routeId);
|
final waypoints = await db.waypointsForRoute(routeId);
|
||||||
expect(waypoints, hasLength(1));
|
expect(waypoints, hasLength(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a v3 database (FB-05) gains isClosedLoop defaulting to false', () async {
|
||||||
|
// v3: identical to the v2 seed above, plus the route_plans/waypoints tables V3-07
|
||||||
|
// added.
|
||||||
|
seedV1Database();
|
||||||
|
final raw = sqlite3.sqlite3.open(dbFile.path);
|
||||||
|
raw.execute('''
|
||||||
|
ALTER TABLE trips ADD COLUMN activity TEXT NOT NULL DEFAULT 'motorcycle';
|
||||||
|
''');
|
||||||
|
raw.execute('''
|
||||||
|
CREATE TABLE route_plans (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
activity TEXT NOT NULL DEFAULT 'motorcycle',
|
||||||
|
distanceM REAL NOT NULL DEFAULT 0,
|
||||||
|
estimatedMillis INTEGER NULL,
|
||||||
|
geometry TEXT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE waypoints (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
routeId INTEGER NOT NULL REFERENCES route_plans (id) ON DELETE CASCADE,
|
||||||
|
ordinal INTEGER NOT NULL,
|
||||||
|
latitude REAL NOT NULL,
|
||||||
|
longitude REAL NOT NULL,
|
||||||
|
name TEXT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_waypoints_route ON waypoints (routeId);
|
||||||
|
''');
|
||||||
|
raw.execute('''
|
||||||
|
INSERT INTO route_plans (id, name, createdAt) VALUES (1, 'Existing route', 8000);
|
||||||
|
''');
|
||||||
|
raw.execute('PRAGMA user_version = 3;');
|
||||||
|
raw.close();
|
||||||
|
|
||||||
|
final db = AppDatabase(NativeDatabase(dbFile));
|
||||||
|
addTearDown(db.close);
|
||||||
|
|
||||||
|
// The pre-existing route survives with the new column defaulting to false.
|
||||||
|
final existing = await db.getRoutePlan(1);
|
||||||
|
expect(existing, isNotNull);
|
||||||
|
expect(existing!.isClosedLoop, isFalse);
|
||||||
|
|
||||||
|
// A freshly inserted route also reads back false by default.
|
||||||
|
final newId = await db.insertRoutePlan(
|
||||||
|
const RoutePlan(name: 'New route', createdAt: 9000),
|
||||||
|
);
|
||||||
|
expect((await db.getRoutePlan(newId))!.isClosedLoop, isFalse);
|
||||||
|
|
||||||
|
// Toggling persists.
|
||||||
|
await db.setRoutePlanClosedLoop(1, true);
|
||||||
|
expect((await db.getRoutePlan(1))!.isClosedLoop, isTrue);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
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_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:latlong2/latlong.dart' as ll;
|
||||||
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 +119,513 @@ 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ambient position (FB-01)', () {
|
||||||
|
testWidgets('with no recorded points, an ambient position centers the map at '
|
||||||
|
'street level', (tester) async {
|
||||||
|
const fix = ll.LatLng(51.05, -114.05);
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: const Scaffold(
|
||||||
|
body: RideMap(
|
||||||
|
points: [],
|
||||||
|
segments: [],
|
||||||
|
showEmptyLabel: false,
|
||||||
|
ambientPosition: fix,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
expect(map.options.initialCenter, fix);
|
||||||
|
expect(map.options.initialZoom, ambientZoom);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('with neither recorded points nor an ambient fix, the map still '
|
||||||
|
'falls back to (0, 0) at zoom 2 (no regression, no crash)', (tester) async {
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: const Scaffold(
|
||||||
|
body: RideMap(points: [], segments: [], showEmptyLabel: false),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
expect(map.options.initialCenter, const ll.LatLng(0, 0));
|
||||||
|
expect(map.options.initialZoom, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('while following, a new ambient fix re-centers the map, exactly '
|
||||||
|
'like the recorded-path chase', (tester) async {
|
||||||
|
const fix1 = ll.LatLng(51.0, -114.0);
|
||||||
|
const fix2 = ll.LatLng(51.01, -114.01);
|
||||||
|
|
||||||
|
Widget build(ll.LatLng ambient) => MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: RideMap(
|
||||||
|
points: const [],
|
||||||
|
segments: const [],
|
||||||
|
showEmptyLabel: false,
|
||||||
|
follow: true,
|
||||||
|
ambientPosition: ambient,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(build(fix1));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.pumpWidget(build(fix2));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
final center = map.mapController!.camera.center;
|
||||||
|
expect(center.latitude, closeTo(fix2.latitude, 1e-9));
|
||||||
|
expect(center.longitude, closeTo(fix2.longitude, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the first ambient fix to arrive after construction corrects the '
|
||||||
|
'zoom, not just the center -- a plain re-center would otherwise leave the '
|
||||||
|
'map at the initial zoom-2 forever', (tester) async {
|
||||||
|
// Reproduces a real device bug found in emulator verification: the shell
|
||||||
|
// background map builds before any location permission/fix has resolved, so
|
||||||
|
// `ambientPosition` starts null and the initial camera locks at zoom 2 (the
|
||||||
|
// `bounds == null` fallback). The first fix to actually arrive must jump to
|
||||||
|
// `ambientZoom`, not just re-center at whatever zoom the map already happens
|
||||||
|
// to be sitting at.
|
||||||
|
const fix = ll.LatLng(51.0, -114.0);
|
||||||
|
|
||||||
|
Widget build(ll.LatLng? ambient) => MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: RideMap(
|
||||||
|
points: const [],
|
||||||
|
segments: const [],
|
||||||
|
showEmptyLabel: false,
|
||||||
|
follow: true,
|
||||||
|
ambientPosition: ambient,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(build(null));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
var map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
expect(map.mapController!.camera.zoom, 2);
|
||||||
|
|
||||||
|
await tester.pumpWidget(build(fix));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
expect(map.mapController!.camera.zoom, ambientZoom);
|
||||||
|
expect(map.mapController!.camera.center.latitude, closeTo(fix.latitude, 1e-9));
|
||||||
|
expect(map.mapController!.camera.center.longitude, closeTo(fix.longitude, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a manual pan cancels ambient following the same way it cancels '
|
||||||
|
'recording-follow', (tester) async {
|
||||||
|
const fix1 = ll.LatLng(51.0, -114.0);
|
||||||
|
const fix2 = ll.LatLng(51.01, -114.01);
|
||||||
|
|
||||||
|
Widget build(ll.LatLng ambient) => MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: RideMap(
|
||||||
|
points: const [],
|
||||||
|
segments: const [],
|
||||||
|
showEmptyLabel: false,
|
||||||
|
follow: true,
|
||||||
|
ambientPosition: ambient,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(build(fix1));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Simulate a real user gesture the same way flutter_map itself would report
|
||||||
|
// one to `onPositionChanged` -- calling the callback directly with
|
||||||
|
// `hasGesture: true` exercises the exact guard in `_RideMapState` without
|
||||||
|
// needing a real pointer gesture to get past `InteractionOptions`.
|
||||||
|
var map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
map.options.onPositionChanged!(map.mapController!.camera, true);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.pumpWidget(build(fix2));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
final center = map.mapController!.camera.center;
|
||||||
|
expect(center.latitude, closeTo(fix1.latitude, 1e-9),
|
||||||
|
reason: 'following was cancelled by the manual pan; a later ambient fix '
|
||||||
|
'must not move the camera');
|
||||||
|
expect(center.longitude, closeTo(fix1.longitude, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the pulsing location marker falls back to the ambient position '
|
||||||
|
'when there are no recorded points', (tester) async {
|
||||||
|
const fix = ll.LatLng(51.0, -114.0);
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: const Scaffold(
|
||||||
|
body: RideMap(
|
||||||
|
points: [],
|
||||||
|
segments: [],
|
||||||
|
showEmptyLabel: false,
|
||||||
|
showLocationMarker: true,
|
||||||
|
ambientPosition: fix,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('location-marker')), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('no marker is shown when there is neither a recorded point nor an '
|
||||||
|
'ambient position', (tester) async {
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: const Scaffold(
|
||||||
|
body: RideMap(
|
||||||
|
points: [],
|
||||||
|
segments: [],
|
||||||
|
showEmptyLabel: false,
|
||||||
|
showLocationMarker: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('location-marker')), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('always-interactive map + recenter (FB-06)', () {
|
||||||
|
testWidgets('pan/zoom is allowed even with no recorded points (idle map)',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: const Scaffold(
|
||||||
|
body: RideMap(points: [], segments: [], showEmptyLabel: false),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
expect(
|
||||||
|
map.options.interactionOptions.flags & InteractiveFlag.drag,
|
||||||
|
InteractiveFlag.drag,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
map.options.interactionOptions.flags & InteractiveFlag.pinchZoom,
|
||||||
|
InteractiveFlag.pinchZoom,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('pan/zoom is allowed while recording (has points)', (tester) async {
|
||||||
|
final points = [for (var i = 0; i < 4; i++) p(1, i)];
|
||||||
|
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)),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
expect(
|
||||||
|
map.options.interactionOptions.flags & InteractiveFlag.drag,
|
||||||
|
InteractiveFlag.drag,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
map.options.interactionOptions.flags & InteractiveFlag.pinchZoom,
|
||||||
|
InteractiveFlag.pinchZoom,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('no recenter button before any manual pan', (tester) async {
|
||||||
|
const fix = ll.LatLng(51.0, -114.0);
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: const Scaffold(
|
||||||
|
body: RideMap(
|
||||||
|
points: [],
|
||||||
|
segments: [],
|
||||||
|
showEmptyLabel: false,
|
||||||
|
follow: true,
|
||||||
|
ambientPosition: fix,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('recenter-button')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('no recenter button when follow is false, even after a pan',
|
||||||
|
(tester) async {
|
||||||
|
const fix = ll.LatLng(51.0, -114.0);
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: const Scaffold(
|
||||||
|
body: RideMap(
|
||||||
|
points: [],
|
||||||
|
segments: [],
|
||||||
|
showEmptyLabel: false,
|
||||||
|
ambientPosition: fix,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
map.options.onPositionChanged?.call(map.mapController!.camera, true);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('recenter-button')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a manual pan while following shows the recenter button',
|
||||||
|
(tester) async {
|
||||||
|
const fix = ll.LatLng(51.0, -114.0);
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: const Scaffold(
|
||||||
|
body: RideMap(
|
||||||
|
points: [],
|
||||||
|
segments: [],
|
||||||
|
showEmptyLabel: false,
|
||||||
|
follow: true,
|
||||||
|
ambientPosition: fix,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('recenter-button')), findsNothing);
|
||||||
|
|
||||||
|
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
map.options.onPositionChanged!(map.mapController!.camera, true);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('recenter-button')), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'tapping recenter moves the camera back to the ambient position and '
|
||||||
|
'hides the button again (following resumed)', (tester) async {
|
||||||
|
const fix1 = ll.LatLng(51.0, -114.0);
|
||||||
|
const fix2 = ll.LatLng(52.0, -115.0);
|
||||||
|
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: const Scaffold(
|
||||||
|
body: RideMap(
|
||||||
|
points: [],
|
||||||
|
segments: [],
|
||||||
|
showEmptyLabel: false,
|
||||||
|
follow: true,
|
||||||
|
ambientPosition: fix1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
var map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
// Simulate the rider manually panning away, then a fresh ambient fix
|
||||||
|
// arriving while following is off (so the camera does not auto-chase it).
|
||||||
|
map.options.onPositionChanged!(map.mapController!.camera, true);
|
||||||
|
map.mapController!.move(fix2, map.mapController!.camera.zoom);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('recenter-button')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('recenter-button')));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('recenter-button')), findsNothing);
|
||||||
|
map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
final center = map.mapController!.camera.center;
|
||||||
|
expect(center.latitude, closeTo(fix1.latitude, 1e-9),
|
||||||
|
reason: 'recenter should move back to the latest known ambient '
|
||||||
|
'position, not stay at the panned-to location');
|
||||||
|
expect(center.longitude, closeTo(fix1.longitude, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'a real drag beats an ambient tick that lands mid-drag (FB-10)',
|
||||||
|
(tester) async {
|
||||||
|
const fix1 = ll.LatLng(51.0, -114.0);
|
||||||
|
// The ambient tick that lands while the finger is down but before
|
||||||
|
// flutter_map's own gesture recognizer has reported `hasGesture: true` --
|
||||||
|
// this is the exact race the ticket describes.
|
||||||
|
const fix2 = ll.LatLng(51.5, -114.5);
|
||||||
|
|
||||||
|
Widget build(ll.LatLng ambient) => MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: RideMap(
|
||||||
|
points: const [],
|
||||||
|
segments: const [],
|
||||||
|
showEmptyLabel: false,
|
||||||
|
follow: true,
|
||||||
|
ambientPosition: ambient,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(build(fix1));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Put a real pointer down on the map -- this is what a rider's finger
|
||||||
|
// touching the screen looks like, well before flutter_map decides the
|
||||||
|
// movement counts as a drag.
|
||||||
|
final gesture =
|
||||||
|
await tester.startGesture(tester.getCenter(find.byType(FlutterMap)));
|
||||||
|
addTearDown(() => gesture.removePointer());
|
||||||
|
|
||||||
|
// Simulate a once-per-second ambient GPS tick landing while the pointer
|
||||||
|
// is already down but before it has moved -- exactly the race window
|
||||||
|
// the ticket describes: flutter_map has not yet reported `hasGesture:
|
||||||
|
// true`, so nothing has flipped `_following` off yet.
|
||||||
|
await tester.pumpWidget(build(fix2));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// The critical assertion: with the pointer still down and untouched by
|
||||||
|
// any real drag, the camera must not have snapped to the ambient tick's
|
||||||
|
// position. Without the fix, the race lets the scheduled
|
||||||
|
// `postFrameCallback` win here and the camera jumps to `fix2` before
|
||||||
|
// the rider's finger has moved at all.
|
||||||
|
var map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
var center = map.mapController!.camera.center;
|
||||||
|
expect(
|
||||||
|
center.latitude,
|
||||||
|
isNot(closeTo(fix2.latitude, 1e-6)),
|
||||||
|
reason: 'the mid-drag ambient tick must not win the race and snap the '
|
||||||
|
'camera back to the ambient position while the gesture is still '
|
||||||
|
'down',
|
||||||
|
);
|
||||||
|
expect(center.longitude, isNot(closeTo(fix2.longitude, 1e-6)));
|
||||||
|
|
||||||
|
// Now the finger actually moves and lifts -- the real drag completes
|
||||||
|
// normally, and the final position reflects it, not the ambient tick.
|
||||||
|
await gesture.moveBy(const Offset(-100, -100));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
center = map.mapController!.camera.center;
|
||||||
|
expect(
|
||||||
|
center.latitude,
|
||||||
|
isNot(closeTo(fix2.latitude, 1e-6)),
|
||||||
|
reason: 'the completed drag must still reflect the rider\'s own pan, '
|
||||||
|
'not the ambient position the mid-drag tick tried to recenter to',
|
||||||
|
);
|
||||||
|
expect(center.longitude, isNot(closeTo(fix2.longitude, 1e-6)));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping recenter moves the camera to the latest recorded '
|
||||||
|
'point when recording', (tester) async {
|
||||||
|
final points = [for (var i = 0; i < 4; i++) p(1, i)];
|
||||||
|
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,
|
||||||
|
follow: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
var map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
map.options.onPositionChanged!(map.mapController!.camera, true);
|
||||||
|
map.mapController!.move(const ll.LatLng(0, 0), map.mapController!.camera.zoom);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('recenter-button')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('recenter-button')));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('recenter-button')), findsNothing);
|
||||||
|
map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
final last = points.last;
|
||||||
|
final center = map.mapController!.camera.center;
|
||||||
|
expect(center.latitude, closeTo(last.latitude, 1e-9));
|
||||||
|
expect(center.longitude, closeTo(last.longitude, 1e-9));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,61 @@ void main() {
|
|||||||
expect(await db.waypointsForRoute(id), isEmpty);
|
expect(await db.waypointsForRoute(id), isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('setClosedLoop(true) adds the closing segment to distance', () async {
|
||||||
|
final id = await routes.createRoutePlan(1000);
|
||||||
|
await routes.addWaypoint(id, 51.0, -114.0);
|
||||||
|
await routes.addWaypoint(id, 51.01, -114.0);
|
||||||
|
await routes.addWaypoint(id, 51.02, -114.0);
|
||||||
|
|
||||||
|
final open = (await routes.routePlanById(id))!.distanceM;
|
||||||
|
final waypoints = await routes.waypointsFor(id);
|
||||||
|
final closingSegment = geo.pathLengthMeters([
|
||||||
|
geo.LatLon(waypoints.last.latitude, waypoints.last.longitude),
|
||||||
|
geo.LatLon(waypoints.first.latitude, waypoints.first.longitude),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await routes.setClosedLoop(id, true);
|
||||||
|
final closed = (await routes.routePlanById(id))!.distanceM;
|
||||||
|
|
||||||
|
expect(closed, closeTo(open + closingSegment, 1e-6));
|
||||||
|
expect((await routes.routePlanById(id))!.isClosedLoop, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setClosedLoop(false) after true returns distance to the open total', () async {
|
||||||
|
final id = await routes.createRoutePlan(1000);
|
||||||
|
await routes.addWaypoint(id, 51.0, -114.0);
|
||||||
|
await routes.addWaypoint(id, 51.01, -114.0);
|
||||||
|
await routes.addWaypoint(id, 51.02, -114.0);
|
||||||
|
|
||||||
|
final open = (await routes.routePlanById(id))!.distanceM;
|
||||||
|
|
||||||
|
await routes.setClosedLoop(id, true);
|
||||||
|
expect((await routes.routePlanById(id))!.distanceM, isNot(open));
|
||||||
|
|
||||||
|
await routes.setClosedLoop(id, false);
|
||||||
|
expect((await routes.routePlanById(id))!.distanceM, closeTo(open, 1e-6));
|
||||||
|
expect((await routes.routePlanById(id))!.isClosedLoop, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adding a waypoint to a closed-loop route keeps the closing segment correct',
|
||||||
|
() async {
|
||||||
|
final id = await routes.createRoutePlan(1000);
|
||||||
|
await routes.addWaypoint(id, 51.0, -114.0);
|
||||||
|
await routes.addWaypoint(id, 51.01, -114.0);
|
||||||
|
await routes.setClosedLoop(id, true);
|
||||||
|
|
||||||
|
// Adding a third waypoint should recompute the closing segment against the new
|
||||||
|
// last waypoint, not the old one.
|
||||||
|
await routes.addWaypoint(id, 51.02, -114.0);
|
||||||
|
|
||||||
|
final waypoints = await routes.waypointsFor(id);
|
||||||
|
final expected = geo.pathLengthMeters([
|
||||||
|
for (final w in waypoints) geo.LatLon(w.latitude, w.longitude),
|
||||||
|
geo.LatLon(waypoints.first.latitude, waypoints.first.longitude),
|
||||||
|
]);
|
||||||
|
expect((await routes.routePlanById(id))!.distanceM, closeTo(expected, 1e-6));
|
||||||
|
});
|
||||||
|
|
||||||
test('route plans never appear alongside trips and never affect ride totals',
|
test('route plans never appear alongside trips and never affect ride totals',
|
||||||
() async {
|
() async {
|
||||||
await routes.createRoutePlan(1000, name: 'Plan A');
|
await routes.createRoutePlan(1000, name: 'Plan A');
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ 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/recording/location_source.dart' show LocationFix;
|
||||||
|
import 'package:rippr/src/ui/app_shell.dart';
|
||||||
|
import 'package:rippr/src/ui/components/floating_pill.dart';
|
||||||
|
import 'package:rippr/src/ui/components/ride_map.dart' show ambientZoom;
|
||||||
|
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';
|
||||||
@@ -26,8 +31,8 @@ void main() {
|
|||||||
|
|
||||||
tearDown(() async => db.close());
|
tearDown(() async => db.close());
|
||||||
|
|
||||||
Widget host(Widget child) => ProviderScope(
|
Widget host(Widget child, {List<Override> overrides = const []}) => ProviderScope(
|
||||||
overrides: [databaseProvider.overrideWithValue(db)],
|
overrides: [databaseProvider.overrideWithValue(db), ...overrides],
|
||||||
child: MaterialApp(theme: ripprTheme(), home: child),
|
child: MaterialApp(theme: ripprTheme(), home: child),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -107,8 +112,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 +126,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 +181,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 +192,130 @@ 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(
|
||||||
'(V3-11)', (tester) async {
|
'a brand-new route never flashes "no longer exists" while its stream is '
|
||||||
|
'still resolving, and shows the real map once it settles (FB-04)',
|
||||||
|
(tester) async {
|
||||||
|
// Mirrors the real "+ New route" flow: `repo.createRoutePlan` returns as soon as
|
||||||
|
// the row is written, but `routePlanProvider`'s DB stream hasn't delivered its
|
||||||
|
// first emission by the very next frame -- both `routePlanProvider` and
|
||||||
|
// `routeWaypointsProvider` start out `AsyncLoading` on this fresh navigation.
|
||||||
|
final id = await repo.createRoutePlan(1000, name: 'Fresh route');
|
||||||
|
await tester.pumpWidget(host(RoutePlannerScreen(routeId: id)));
|
||||||
|
|
||||||
|
// Immediately after the very first frame -- before the stream has had any chance
|
||||||
|
// to deliver -- the loading spinner must show, never the "no longer exists" text.
|
||||||
|
// (Pre-fix, `route` collapsed "still loading" and "doesn't exist" into the same
|
||||||
|
// null via `.valueOrNull`, so this assertion is exactly the FB-04 regression.)
|
||||||
|
expect(find.textContaining('no longer exists'), findsNothing);
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
expect(find.byType(FlutterMap), findsNothing);
|
||||||
|
|
||||||
|
// Once both streams deliver their first value, the real map canvas appears.
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
}
|
||||||
|
expect(find.textContaining('no longer exists'), findsNothing);
|
||||||
|
expect(find.byType(FlutterMap), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('a route with no waypoints opens at street-level zoom, not zoomed out '
|
||||||
|
'(FB-04)', (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>(
|
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
expect(map.options.initialZoom, ambientZoom);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('a brand-new route opens centered on the rider\'s real location, '
|
||||||
|
'not Null Island (FB-07)', (tester) async {
|
||||||
|
const fix = LocationFix(
|
||||||
|
timestamp: 1000,
|
||||||
|
latitude: 51.05,
|
||||||
|
longitude: -114.07,
|
||||||
|
speedMps: 0,
|
||||||
|
altitudeM: 0,
|
||||||
|
accuracyM: 5,
|
||||||
|
bearingDeg: 0,
|
||||||
|
);
|
||||||
|
final id = await repo.createRoutePlan(1000);
|
||||||
|
await pumpMap(
|
||||||
|
tester,
|
||||||
|
host(
|
||||||
|
RoutePlannerScreen(routeId: id),
|
||||||
|
overrides: [
|
||||||
|
ambientPositionProvider.overrideWith((ref) => Stream.value(fix)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
expect(map.options.initialCenter.latitude, closeTo(51.05, 1e-9));
|
||||||
|
expect(map.options.initialCenter.longitude, closeTo(-114.07, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('a brand-new route falls back to (0, 0) when no ambient fix is '
|
||||||
|
'available (FB-07)', (tester) async {
|
||||||
|
final id = await repo.createRoutePlan(1000);
|
||||||
|
await pumpMap(
|
||||||
|
tester,
|
||||||
|
host(
|
||||||
|
RoutePlannerScreen(routeId: id),
|
||||||
|
overrides: [
|
||||||
|
ambientPositionProvider.overrideWith((ref) => Stream.value(null)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
expect(map.options.initialCenter.latitude, 0);
|
||||||
|
expect(map.options.initialCenter.longitude, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('a route with a real waypoint still centers on the pin, not the '
|
||||||
|
'ambient position (FB-07)', (tester) async {
|
||||||
|
const fix = LocationFix(
|
||||||
|
timestamp: 1000,
|
||||||
|
latitude: 51.05,
|
||||||
|
longitude: -114.07,
|
||||||
|
speedMps: 0,
|
||||||
|
altitudeM: 0,
|
||||||
|
accuracyM: 5,
|
||||||
|
bearingDeg: 0,
|
||||||
|
);
|
||||||
|
final id = await repo.createRoutePlan(1000);
|
||||||
|
await repo.addWaypoint(id, 51.0, -114.0);
|
||||||
|
await pumpMap(
|
||||||
|
tester,
|
||||||
|
host(
|
||||||
|
RoutePlannerScreen(routeId: id),
|
||||||
|
overrides: [
|
||||||
|
ambientPositionProvider.overrideWith((ref) => Stream.value(fix)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
expect(map.options.initialCenter.latitude, closeTo(51.0, 1e-9));
|
||||||
|
expect(map.options.initialCenter.longitude, closeTo(-114.0, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('the offline-tiles download menu item is disabled with no pins '
|
||||||
|
'(V3-11, UI-06: now a PopupMenuItem behind the overflow menu)',
|
||||||
|
(tester) async {
|
||||||
|
final id = await repo.createRoutePlan(1000);
|
||||||
|
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
|
||||||
|
|
||||||
|
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 +325,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 +347,97 @@ void main() {
|
|||||||
expect(find.textContaining('tiles?'), findsOneWidget);
|
expect(find.textContaining('tiles?'), findsOneWidget);
|
||||||
expect(find.textContaining('MB'), findsOneWidget);
|
expect(find.textContaining('MB'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
screenTest('the "Close the loop" menu item is disabled with 0-1 waypoints, '
|
||||||
|
'enabled with 2+ (FB-05)', (tester) async {
|
||||||
|
final id = await repo.createRoutePlan(1000);
|
||||||
|
await repo.addWaypoint(id, 51.0, -114.0);
|
||||||
|
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('route-overflow-menu')));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
var item = tester.widget<PopupMenuItem<String>>(
|
||||||
|
find.byKey(const Key('toggle-closed-loop')),
|
||||||
|
);
|
||||||
|
expect(item.enabled, isFalse);
|
||||||
|
|
||||||
|
// Close the (still-open) menu and add a second waypoint.
|
||||||
|
await tester.tapAt(const Offset(10, 10));
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await repo.addWaypoint(id, 51.01, -114.0);
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('route-overflow-menu')));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
item = tester.widget<PopupMenuItem<String>>(
|
||||||
|
find.byKey(const Key('toggle-closed-loop')),
|
||||||
|
);
|
||||||
|
expect(item.enabled, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('tapping "Close the loop" toggles isClosedLoop and flips the label '
|
||||||
|
'(FB-05)', (tester) async {
|
||||||
|
final id = await repo.createRoutePlan(1000);
|
||||||
|
await repo.addWaypoint(id, 51.0, -114.0);
|
||||||
|
await repo.addWaypoint(id, 51.01, -114.0);
|
||||||
|
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('route-overflow-menu')));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Close the loop'), findsOneWidget);
|
||||||
|
await tester.tap(find.byKey(const Key('toggle-closed-loop')));
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
final route = await repo.routePlanById(id);
|
||||||
|
expect(route?.isClosedLoop, isTrue);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('route-overflow-menu')));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Open the loop'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('a closed loop draws one more polyline point than there are waypoints '
|
||||||
|
'(FB-05)', (tester) async {
|
||||||
|
final id = await repo.createRoutePlan(1000);
|
||||||
|
await repo.addWaypoint(id, 51.0, -114.0);
|
||||||
|
await repo.addWaypoint(id, 51.01, -114.0);
|
||||||
|
await repo.addWaypoint(id, 51.02, -114.0);
|
||||||
|
await repo.setClosedLoop(id, true);
|
||||||
|
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
|
||||||
|
|
||||||
|
final layer = tester.widget<PolylineLayer>(find.byType(PolylineLayer));
|
||||||
|
expect(layer.polylines.single.points, hasLength(4));
|
||||||
|
});
|
||||||
|
|
||||||
|
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,65 @@ 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('overlapping put() calls for distinct keys are both readable afterward '
|
||||||
|
'(FB-11: concurrent background-map + Route Planner tile fetches must not race)',
|
||||||
|
() async {
|
||||||
|
// `_saveManifest()` computes its JSON snapshot synchronously, then writes it to
|
||||||
|
// disk. Two overlapping `put()` calls can interleave so that the call that
|
||||||
|
// captured the *older*, smaller snapshot (fewer entries) is also the one whose
|
||||||
|
// disk write finishes last -- silently overwriting the newer, complete manifest
|
||||||
|
// with a stale one that is missing the other call's tile. On a real device this
|
||||||
|
// depends on incidental I/O timing (which is exactly why it was so hard to catch
|
||||||
|
// and produced a rider-visible blank map only sometimes); this artificial delay
|
||||||
|
// makes that interleaving happen every single time instead of by chance, so the
|
||||||
|
// test is deterministic rather than flaky. It has no effect on production
|
||||||
|
// callers, which never pass it.
|
||||||
|
cache = FileTileCache(
|
||||||
|
directory: tempDir,
|
||||||
|
maxBytes: 1024 * 1024,
|
||||||
|
debugArtificialManifestWriteDelay: (entryCount) =>
|
||||||
|
entryCount < 2 ? const Duration(milliseconds: 50) : Duration.zero,
|
||||||
|
);
|
||||||
|
const a = TileKey(9, 1, 0);
|
||||||
|
const b = TileKey(9, 2, 0);
|
||||||
|
|
||||||
|
// Started without awaiting the first before starting the second, so both calls
|
||||||
|
// are in flight and racing across the same `await` points (`_ensureLoaded`,
|
||||||
|
// `writeAsBytes`, `_saveManifest`) at once.
|
||||||
|
final futureA = cache.put(a, bytesOfSize(1024));
|
||||||
|
final futureB = cache.put(b, bytesOfSize(1024));
|
||||||
|
await Future.wait([futureA, futureB]);
|
||||||
|
|
||||||
|
// Reopen over the same directory: this reads the manifest back from disk, which
|
||||||
|
// is exactly the file the two overlapping writes above raced to overwrite. An
|
||||||
|
// in-memory-only check wouldn't catch this -- the shared `_manifest` map itself
|
||||||
|
// is never corrupted (Dart is single-threaded), only what ends up on disk.
|
||||||
|
final reopened = FileTileCache(directory: tempDir, maxBytes: 1024 * 1024);
|
||||||
|
expect(await reopened.get(a), isNotNull, reason: 'tile a must survive the race');
|
||||||
|
expect(await reopened.get(b), isNotNull, reason: 'tile b must survive the race');
|
||||||
|
expect(await reopened.sizeBytes(), 2048);
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ 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/trip_repository.dart';
|
import 'package:rippr/src/data/trip_repository.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/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';
|
||||||
@@ -120,11 +124,10 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group('record screen', () {
|
group('record screen', () {
|
||||||
screenTest('idle shows Ready and only START', (tester) async {
|
screenTest('idle shows only START', (tester) async {
|
||||||
await tester.pumpWidget(host(const RecordScreen()));
|
await tester.pumpWidget(host(const RecordScreen()));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Ready'), findsOneWidget);
|
|
||||||
expect(find.byKey(const Key('start')), findsOneWidget);
|
expect(find.byKey(const Key('start')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('pause')), findsNothing);
|
expect(find.byKey(const Key('pause')), findsNothing);
|
||||||
expect(find.byKey(const Key('stop')), findsNothing);
|
expect(find.byKey(const Key('stop')), findsNothing);
|
||||||
@@ -136,8 +139,8 @@ void main() {
|
|||||||
// v2.0 shipped max speed as the headline and it read as a frozen, broken screen on
|
// v2.0 shipped max speed as the headline and it read as a frozen, broken screen on
|
||||||
// a real ride, because a max figure only moves when you beat it. This guards the
|
// a real ride, because a max figure only moves when you beat it. This guards the
|
||||||
// 2.0.1 fix.
|
// 2.0.1 fix.
|
||||||
await tester.pumpWidget(host(const RecordScreen()));
|
await repo.startTrip(1000);
|
||||||
await tester.pumpAndSettle();
|
await pumpLive(tester, host(const RecordScreen(), map: false));
|
||||||
|
|
||||||
expect(find.text('SPEED'), findsOneWidget);
|
expect(find.text('SPEED'), findsOneWidget);
|
||||||
expect(find.text('MAX SPEED'), findsNothing);
|
expect(find.text('MAX SPEED'), findsNothing);
|
||||||
@@ -184,16 +187,61 @@ 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
|
||||||
// test could catch it. This asserts the rendered colour differs from the ground.
|
// test could catch it. This asserts the rendered colour differs from the ground.
|
||||||
await tester.pumpWidget(host(const RecordScreen()));
|
await repo.startTrip(1000);
|
||||||
await tester.pumpAndSettle();
|
await pumpLive(tester, host(const RecordScreen(), map: false));
|
||||||
|
|
||||||
final speed = tester.widget<Text>(
|
final speed = tester.widget<Text>(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byKey(const Key('speed')),
|
of: find.byKey(const ValueKey(HudMetric.speed)),
|
||||||
matching: find.text('0'),
|
matching: find.text('0'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
@@ -345,23 +379,179 @@ void main() {
|
|||||||
|
|
||||||
screenTest('the mounted theme is high-contrast and text scales up (V3-05)',
|
screenTest('the mounted theme is high-contrast and text scales up (V3-05)',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(host(const RecordScreen(), mountedMode: true));
|
await repo.startTrip(1000);
|
||||||
await tester.pumpAndSettle();
|
await pumpLive(
|
||||||
|
tester,
|
||||||
|
host(const RecordScreen(), map: false, mountedMode: true),
|
||||||
|
);
|
||||||
|
|
||||||
final speed = tester.widget<Text>(
|
final speed = tester.widget<Text>(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byKey(const Key('speed')),
|
of: find.byKey(const ValueKey(HudMetric.speed)),
|
||||||
matching: find.text('0'),
|
matching: find.text('0'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(speed.style?.fontSize, 64 * mountedTextScale);
|
expect(speed.style?.fontSize, 18 * mountedTextScale);
|
||||||
expect(speed.style?.color, isNot(ripprBackground),
|
expect(speed.style?.color, isNot(ripprBackground),
|
||||||
reason: 'still legible, just against a different (lighter) ground');
|
reason: 'still legible, just against a different (lighter) ground');
|
||||||
|
|
||||||
final start = tester.getSize(find.byKey(const Key('start')));
|
final pause = tester.getSize(find.byKey(const Key('pause')));
|
||||||
expect(start.height, 96,
|
expect(pause.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 repo.startTrip(1000);
|
||||||
|
await pumpLive(
|
||||||
|
tester,
|
||||||
|
host(const RecordScreen(), map: false, mountedMode: true),
|
||||||
|
);
|
||||||
|
|
||||||
|
final speed = tester.widget<Text>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const ValueKey(HudMetric.speed)),
|
||||||
|
matching: find.text('0'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final panel = tester.widget<GlassPanel>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const ValueKey(HudMetric.speed)),
|
||||||
|
matching: find.byType(GlassPanel),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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('ambient position (FB-01)', () {
|
||||||
|
screenTest('with no active trip, an ambient GPS fix drives the shared '
|
||||||
|
'background map', (tester) async {
|
||||||
|
await tester.pumpWidget(host(
|
||||||
|
ShellScaffold(
|
||||||
|
currentIndex: 0,
|
||||||
|
onDestinationSelected: (_) {},
|
||||||
|
child: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
// A frame to build the tree, then time for `ambientPositionProvider`'s
|
||||||
|
// `await source.start()` to resolve and subscribe to `source.fixes`.
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
// No fix yet: still today's neutral fallback, no marker.
|
||||||
|
expect(find.byKey(const Key('location-marker')), findsNothing);
|
||||||
|
|
||||||
|
source.emitAt(timestamp: 1000, latitude: 51.2, longitude: -114.2);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('location-marker')), findsOneWidget,
|
||||||
|
reason: 'an ambient fix drives the pulsing marker even with no trip '
|
||||||
|
'ever recorded');
|
||||||
|
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
final center = map.mapController!.camera.center;
|
||||||
|
expect(center.latitude, closeTo(51.2, 1e-9));
|
||||||
|
expect(center.longitude, closeTo(-114.2, 1e-9));
|
||||||
|
|
||||||
|
// A second fix keeps the map following, exactly like the recorded-path chase.
|
||||||
|
source.emitAt(timestamp: 2000, latitude: 51.21, longitude: -114.21);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
final moved = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
final movedCenter = moved.mapController!.camera.center;
|
||||||
|
expect(movedCenter.latitude, closeTo(51.21, 1e-9));
|
||||||
|
expect(movedCenter.longitude, closeTo(-114.21, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('once a trip starts recording, the shell stops watching ambient '
|
||||||
|
'GPS -- no duplicate location consumer during a ride', (tester) async {
|
||||||
|
// Idle first: the ambient provider engages and calls start() at least once
|
||||||
|
// (activeTripProvider's own first, async emission of `null` briefly reads
|
||||||
|
// as "idle" too, which is expected -- same shape as `points`/`segments`
|
||||||
|
// falling back to empty lists until their streams first emit).
|
||||||
|
await tester.pumpWidget(host(
|
||||||
|
ShellScaffold(
|
||||||
|
currentIndex: 0,
|
||||||
|
onDestinationSelected: (_) {},
|
||||||
|
child: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
source.emitAt(timestamp: 1000, latitude: 51.0, longitude: -114.0);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('location-marker')), findsOneWidget,
|
||||||
|
reason: 'sanity check: ambient mode is actually engaged before the '
|
||||||
|
'trip starts');
|
||||||
|
final callsWhileIdle = source.startCalls;
|
||||||
|
expect(callsWhileIdle, greaterThanOrEqualTo(1));
|
||||||
|
|
||||||
|
// Now a trip starts recording. Nothing in this test ever taps Start, so the
|
||||||
|
// only thing that could call `LocationSource.start()` again is
|
||||||
|
// `ambientPositionProvider` -- and it must not, because `ShellScaffold`
|
||||||
|
// stops watching it entirely the moment `activeTripProvider` is non-null.
|
||||||
|
await repo.startTrip(2000);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
expect(source.startCalls, callsWhileIdle,
|
||||||
|
reason: 'once a trip is active, ambientPositionProvider must no '
|
||||||
|
'longer be watched at all');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('trips list', () {
|
group('trips list', () {
|
||||||
@@ -397,8 +587,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 {
|
||||||
@@ -654,4 +922,59 @@ void main() {
|
|||||||
expect(await repo.segmentsForTrip(h.tripId), hasLength(1));
|
expect(await repo.segmentsForTrip(h.tripId), hasLength(1));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('ambientPositionProvider (FB-01)', () {
|
||||||
|
// Provider-level, not widget-level: exercises the provider directly against a
|
||||||
|
// `ProviderContainer` so its lifecycle (including disposal) can be driven
|
||||||
|
// precisely, per the ticket's own suggested test shape.
|
||||||
|
test('never calls LocationSource.stop() -- the underlying source is shared '
|
||||||
|
'with an active recording and stop() is not reference-counted', () async {
|
||||||
|
final fakeSource = FakeLocationSource();
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [locationSourceProvider.overrideWithValue(fakeSource)],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
final sub = container.listen(ambientPositionProvider, (_, _) {});
|
||||||
|
// Let the provider's `await source.start()` resolve and subscribe.
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(fakeSource.startCalls, 1);
|
||||||
|
|
||||||
|
sub.close();
|
||||||
|
container.dispose();
|
||||||
|
|
||||||
|
expect(fakeSource.stopCalls, 0,
|
||||||
|
reason: 'ambientPositionProvider must only ever call start(), never '
|
||||||
|
'stop(), on the shared LocationSource');
|
||||||
|
await fakeSource.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('turning mapEnabledProvider off never starts location and yields null',
|
||||||
|
() async {
|
||||||
|
final fakeSource = FakeLocationSource();
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
locationSourceProvider.overrideWithValue(fakeSource),
|
||||||
|
mapEnabledProvider.overrideWith((ref) => false),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
final values = <AsyncValue<LocationFix?>>[];
|
||||||
|
final sub = container.listen(
|
||||||
|
ambientPositionProvider,
|
||||||
|
(_, next) => values.add(next),
|
||||||
|
fireImmediately: true,
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(fakeSource.startCalls, 0,
|
||||||
|
reason: 'no tile or location request should fire while the map is '
|
||||||
|
'disabled, matching the existing map-toggle guarantee');
|
||||||
|
expect(values.last.valueOrNull, isNull);
|
||||||
|
|
||||||
|
sub.close();
|
||||||
|
await fakeSource.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user