Refresh the Flutter snapshot: v3 tickets V3-04 through V3-16, plus a fresh installable APK

V3-04 through V3-07, V3-10, V3-16 shipped complete; V3-11/V3-12/V3-14 shipped code-complete pending device/account verification; V3-08/V3-09 deferred behind a new V3-17 (self-hosted OSRM investigation). 316 tests passing, up from 221.

The APK is a fresh release build (debug-signed, no release signing config exists yet) with two build fixes applied: core library desugaring enabled for flutter_local_notifications, and sentry_flutter bumped to 9.27.0 (8.14.2's bundled Kotlin plugin was incompatible with this project's Kotlin 2.4.0 toolchain).
This commit is contained in:
2026-08-19 13:36:04 -05:00
parent 14289befb0
commit 4c634354bd
48 changed files with 4436 additions and 224 deletions

View File

@@ -0,0 +1,83 @@
# V3-08 — Road-snapped routing and ETA
**Phase** Route planning · **Depends on** V3-07, benefits from V3-01, **blocked on V3-17**
· **Size** L · **Status** Deferred
## Goal
Resolve the actual shortest path along roads between pins, and estimate how long the ride
will take.
## Context
This is what Dylan asked for. It is also the ticket with a **decision that cannot be
deferred**: road-snapped routing needs a routing engine over OpenStreetMap data, and the
choice has ongoing consequences.
**Decided, not deferred:** self-hosted OSRM (see the table below). What's actually
deferred is standing one up — that's [V3-17](V3-17-osrm-hosting.md), split out on
request because provisioning a server is a different kind of work from building the app
code that calls it, and this ticket cannot start until V3-17 has something to point
`RoutingService` at.
## Design
### Choosing an engine — decide before writing code
| Option | Trade-off |
|---|---|
| **Public OSRM demo** | Free, zero setup, **explicitly not for production**, rate-limited. Prototype only. |
| **Self-hosted OSRM** | Fast, well understood. Needs a machine and a regional OSM extract (a province is a few GB). |
| **GraphHopper** | Self-hostable, good cycling/motorcycle profiles, friendlier ETAs |
| **Valhalla** | Best multi-modal profiles, heaviest to run |
| **Mapbox / Google** | No ops, per-request billing, an API key shipped in the app |
**Profiles matter more here than usual.** A motorcycle route and a bicycle route between
the same pins genuinely differ — cycling engines avoid motorways, and a motorcyclist often
wants the twisty road rather than the fast one. This is where V3-01 pays off: the activity
selects the profile.
Put it behind a `RoutingService` interface with a fake, exactly as `LocationSource` did for
GPS. That seam is what made swapping the location engine cheap, and the same argument
applies here.
### ETA is a promise, and easy to get wrong
Engines estimate from posted speed limits. That is not how long *you* take. Once there is
history, the rider's own average moving speed for that activity — already stored on every
`Trip` — is a better predictor.
Show the engine's estimate, then replace it with a personal one once there is enough
history to justify it. Label which is which.
### Caching
Cache the returned polyline on `Route.geometry`. A saved plan must open offline and must
not re-bill a request every time it is viewed.
## Implementation
1. Decide the engine. Write the decision and its reasoning into this file.
2. `RoutingService` interface + implementation + `FakeRoutingService`
3. Resolve on pin change, debounced — not on every drag frame
4. Persist geometry, distance and duration on `Route`
5. Personal ETA from `Trip` history, once ≥5 rides of that activity exist
6. Graceful offline behaviour: fall back to straight lines and say so
## Acceptance criteria
- [ ] Pins resolve to a road-following polyline
- [ ] Distance reflects the road path, not the straight line
- [ ] Activity changes the profile and can change the route
- [ ] A saved route renders offline from cached geometry, with no network call
- [ ] Offline with no cache degrades to straight lines with a visible explanation
- [ ] No API key is committed to the repository
## Tests
- `FakeRoutingService` drives every path: success, failure, offline, empty
- Cached geometry means no second request — assert the fake is called once
- Personal ETA maths against fixed history
- **No live network calls in any test**
## Risks
- **Vendor lock-in and cost.** The interface is the mitigation.
- Debouncing matters: dragging a pin could otherwise fire dozens of requests.
- OSM route quality varies. It will occasionally suggest something daft; that is the data,
not a bug to chase.
## Out of scope
Turn-by-turn navigation and voice guidance. That is a different product.