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,80 @@
# v3 tickets
One file per feature, in the shape that worked for v2: Goal · Context · Design ·
Implementation · Acceptance criteria · Tests · Risks · Out of scope. Written before
implementing, so the risks are on paper before they are walked into.
Menu, not a commitment. Nothing here is scheduled.
**Everything in v3 is buildable with no server.** Group rides, accounts and paid cloud
backup are v4 — see [../BACKLOG.md](../BACKLOG.md).
> **Before starting anything:** [../port/REAL-RIDE-CHECKLIST.md](../port/REAL-RIDE-CHECKLIST.md).
> The port has never recorded a real ride, and item I3 may still force a change of GPS
> engine — which would land underneath several of these tickets.
## The tickets
| # | Ticket | Size | Depends on | Status |
|---|---|---|---|---|
| [V3-01](V3-01-activity-type.md) | Activity type per ride | M | — | Done |
| [V3-02](V3-02-settings-screen.md) | Settings screen | S | — | Done |
| [V3-03](V3-03-units.md) | Distance and speed units | S | V3-02 | Done |
| [V3-04](V3-04-live-map.md) | Live map on the recording screen | M | — | Done |
| [V3-05](V3-05-mounted-mode.md) | Mounted (handlebar) mode | M | V3-04 | Done |
| [V3-06](V3-06-notification-stats.md) | Live stats in the notification | S | — | Done |
| [V3-07](V3-07-route-drawing.md) | Route drawing (pins, straight lines) | M | — | Done |
| [V3-08](V3-08-road-routing.md) | Road-snapped routing and ETA | L | V3-07, V3-01, **V3-17** | Deferred (needs V3-17) |
| [V3-09](V3-09-route-following.md) | Follow a planned route | M | V3-04, V3-08 | Deferred (needs V3-08) |
| [V3-10](V3-10-trip-splitting.md) | Trip splitting | S | — | Done |
| [V3-11](V3-11-offline-tiles.md) | Offline tile pre-download | M | V3-04 | Partially done (pipeline shipped; needs aeroplane-mode device verification) |
| [V3-12](V3-12-crash-reporting.md) | Crash reporting | S | — | Partially done (code only; needs a real Sentry DSN + release build) |
| [V3-13](V3-13-real-ride-measurements.md) | Real-ride measurements | M | **riding** | Not started |
| [V3-14](V3-14-gpx-interop.md) | GPX interoperability | S | V3-01 | Partially done (code only; needs real-device verification) |
| [V3-15](V3-15-auto-pause.md) | Auto-pause | M | V3-13 *(gated)* | Not started |
| [V3-16](V3-16-visual-identity.md) | Visual identity | M | V3-04, V3-05 | Partially done (token-level identity shipped; needs outdoor device verification) |
| [V3-17](V3-17-osrm-hosting.md) | Self-hosted OSRM: investigate and stand one up | M | — *(needs a server — see the ticket's own note on the v3/v4 boundary)* | Not started |
## Dependencies
```
V3-01 ──┬────────────► V3-08 ──► V3-09
└──► V3-14 ▲
V3-07 ──────► V3-08 │
V3-17 ──────► V3-08 │
V3-02 ──► V3-03 │
V3-04 ──┬──► V3-05 ──┬───────┘
├──► V3-11 └──► V3-16
└──► V3-09
V3-13 ──► V3-15 (gate: may close unbuilt)
no dependencies: V3-01 · V3-02 · V3-04 · V3-06 · V3-07 · V3-10 · V3-12 · V3-17
```
**V3-08/V3-09 are deferred, on request**, pending V3-17 (self-hosted OSRM). See V3-17's
own note on why that also puts them in tension with this document's v3/v4 boundary —
unresolved by design, not an oversight.
## Three that carry more weight than their size suggests
**V3-01** ships the port's **first real migration**. The destructive fallback is gone, so
getting `addColumn` plus a v1-database test right matters more than the feature does —
V3-07 and V3-09 both add migrations behind it.
**V3-08** forces a routing-engine decision with ongoing cost and vendor implications.
Behind a `RoutingService` interface, mirroring what `LocationSource` did for GPS. The
direction is decided (self-hosted OSRM); V3-17 does the actual standing-up, and V3-08 is
deferred until it exists.
**V3-13** is not code. It answers the three questions that have been open since v2, and
**V3-15 may close unbuilt** as a result — a legitimate and probably likely outcome.
## Suggested order, if starting cold
1. **V3-01** — proves the migration path while the stakes are low, and unblocks V3-08/14
2. **V3-02 + V3-03** — small, self-contained, gives V3-01 a home
3. **V3-04** — the most visible change, and the gateway to four other tickets
4. **V3-07** — entirely independent; useful on its own before the routing decision
5. **V3-13** — as soon as there is a ride to measure
V3-12 is a good filler at any point. V3-16 should wait until the screens stop moving.

View File

@@ -0,0 +1,97 @@
# V3-01 — Activity type per ride
**Phase** Foundations · **Depends on** nothing · **Size** M · **Status** Done
## Goal
Every ride records what it was done on — motorcycle, bicycle, scooter, skateboard,
running, walking, other — and that choice drives per-activity defaults rather than just
labelling the row.
## Context
The recording pipeline never cared what you were sitting on: it records positions, speeds
and altitudes. Only the UI says "motorcycle". Dylan rides both bikes and motorcycles.
This is also a **positioning decision** (see [../LAUNCH.md](../LAUNCH.md)): it determines
the store category, the screenshots, and who ever finds the app. Cyclists are a much larger
audience and already pay for ride apps. Cheaper to settle before a store listing exists.
**This ticket ships the port's first real migration.** That matters more than the feature.
## Design
Text enum column on `Trip`, exactly like `TripState`:
`motorcycle · bicycle · scooter · skateboard · running · walking · other`
**Type drives defaults, not labels.** Introduce an `ActivityProfile` rather than scattering
`if (activity == …)`:
| Setting | Today | Why it must vary |
|---|---|---|
| Speed noise floor | 1.5 km/h | Right for a motorcycle; walking lives near it |
| Histogram bucket | 10 km/h | Useless for running — one bucket. Wants ~1 km/h |
| Accuracy gate | 50 m | A bike at speed tolerates looser fixes than a walker |
| Elevation smoothing window | 15 samples | Tuned for 2 Hz at road speed |
| Map fit zoom | — | A 2 km walk and a 200 km ride differ |
**No picker in front of Start.** The founding premise is press-and-go with gloves on.
Default to the last activity used; make it editable on trip detail next to rename.
## Implementation
1. `Activity` enum in `domain/models.dart`; `activity` field on `Trip`
2. Drift column with `.withDefault(Constant('motorcycle'))`
3. **Migration**: `schemaVersion` 1 → 2, `m.addColumn(trips, trips.activity)`
4. `ActivityProfile` in `stats/` holding the constants above; thread it through
`computeSummary`, `speedHistogram`, `Accumulator`, `isUsableFix`
5. Persist last-used activity in `Config`
6. Trip detail: activity row, editable via the same pattern as rename
7. Trips list: show the activity icon on each tile
## Acceptance criteria
- [ ] A new ride records an activity; existing rides read `motorcycle`
- [ ] A v1 database opens, migrates, and **keeps every ride and point**
- [ ] Changing a trip's activity recomputes its aggregates under the new profile
- [ ] Start still takes exactly one tap
- [ ] `flutter analyze` clean, all existing tests still pass
## Tests
- **Migration test** — build a v1 database, migrate, assert rides and points survive.
Drift's `MigrationTestHelper` with a generated v1 schema.
- Profile selection: each activity yields its own noise floor and bucket size
- A running-activity ride produces a histogram with more than one bucket
- Round-trip the enum through the database
## Risks
- **The migration is the risk.** Getting it wrong destroys real rides, and the destructive
fallback is deliberately gone. Write the migration test first.
- Recomputing aggregates on activity change is easy to forget — a ride switched from
motorcycle to walking keeps a wrong moving time otherwise.
## Out of scope
Per-activity totals or a stats screen. GPX `<type>` export (see V3-14).
## Outcome
Shipped as designed, with two deliberate deviations from the ticket text, both
recorded here rather than silently:
- **No `Config.lastActivity` field.** "Default to the last activity used" is instead
derived live from the trips table itself (`AppDatabase.mostRecentTrip()` /
`TripRepository._lastUsedActivity()`) rather than duplicated into a separate
preference. One source of truth, no write path to keep in sync, and it degrades
correctly to `motorcycle` when the database is empty.
- **`ActivityProfile` lives in `domain/activity_profile.dart`**, not folded into
`models.dart` — kept the domain model (`Trip.activity`) separate from the behavioural
defaults built on top of it, and avoided a dependency cycle between the domain layer
and `stats/`/`recording/`.
Map-fit zoom (mentioned in the ticket's defaults table) turned out to need no work:
`RideMap` already fits to the ride's actual recorded bounds, which is activity-agnostic
by construction.
The widget-test pass caught a real overflow bug independent of activity type: the
7-item activity picker sheet overflowed a `Column`-based `showModalBottomSheet` the same
way the record screen once did (see `docs/port/PROGRESS.md`, Phase 4). Fixed with a
scrollable `ListView` + `isScrollControlled: true`, the same shape as that earlier fix.
188 tests total (171 → 188): migration (2), `ActivityProfile` (5), `computeSummary`
profile-threading proof (3), repository (5), widget (2).

View File

@@ -0,0 +1,86 @@
# V3-02 — Settings screen
**Phase** Foundations · **Depends on** nothing (pairs with V3-01, V3-03) · **Size** S · **Status** Done
## Goal
One place for the preferences that currently have nowhere to live.
## Context
`Config` already holds `uploadEndpoint`, `deviceId` and `mapEnabled`, but there is no UI
for any of them. The map toggle sits on trip detail because a single switch did not justify
a screen. V3-01 and V3-03 both add preferences, which finally does justify one.
The upload endpoint has **never** had UI, in the native app or the port. This is where it
stops being a code-only setting.
## Design
Reached from the record screen. Sections:
- **Recording** — default activity (V3-01), units (V3-03)
- **Map** — render maps on trip detail; later, live map (V3-04)
- **Sync** — upload endpoint, with the device id shown read-only and copyable
- **About** — version, a link to the privacy policy, licences
Keep it plain. This is not a screen anyone should spend time in.
## Implementation
1. `SettingsScreen` + a `go_router` route
2. Make `Config` reactive — it is currently read once into a provider; settings need writes
to propagate. A `ConfigNotifier` over `shared_preferences`.
3. Move the map toggle off trip detail
4. Endpoint field validates it parses as a URL and is http(s)
## Acceptance criteria
- [ ] Every `Config` value is viewable and editable
- [ ] Changing the map toggle takes effect without an app restart
- [ ] An invalid endpoint is rejected with a readable message, not silently stored
- [ ] Device id is copyable — it is the only way to identify this install to a server
## Tests
- Widget: each control renders and writes through to `Config`
- Widget: invalid URL rejected
- Changing the map toggle rebuilds trip detail
## Risks
`Config` is currently loaded once at startup into a `StateProvider`. Making it writable
without introducing a second source of truth is the only subtle part.
## Out of scope
Account settings (v4). Theme selection (V3-16).
## Outcome
"Move the map toggle off trip detail" (implementation step 3) turned out to be moot —
`mapEnabledProvider` already existed and drove `RideMap`'s visibility, but **no widget
anywhere ever offered a control to change it**. There was nothing to move. Settings adds
the first one.
No `ConfigNotifier` was built — see V3-03's outcome for why the existing
`mapEnabledProvider`-style `StateProvider` pattern covers reactivity without it, and
`unitSystemProvider` was added the same way, in `providers.dart`, ahead of this ticket.
Two implementation-step items shipped differently than drafted, both to avoid adding a
dependency disproportionate to an `S`-sized settings screen:
- **No `package_info_plus`.** The About section's version string is a static literal
matching `pubspec.yaml`'s `1.0.0+1`, not a live package lookup. Fine today; would need
revisiting if the version ever needs to be authoritative from inside the running app
rather than copied by hand.
- **No privacy-policy link.** None is published yet (see `docs/LAUNCH.md`) and linking
to one that does not exist would be worse than omitting it. Shows a plain note instead.
Licences are still free: Flutter's built-in `showLicensePage` needed no new dependency.
Endpoint validation accepts `http://` and `https://` with a non-empty host, and treats
an **empty** field as valid — that is how upload gets disabled, not an error state. A
non-empty invalid value is rejected with inline `errorText` and never reaches `Config`;
proven by a widget test that types garbage, taps Save, and asserts `Config.uploadEndpoint`
is still empty afterwards.
`uploaderProvider` needed an explicit `ref.invalidate()` after saving the endpoint, for
the identical reason `unitSystemProvider` needed its own `StateProvider` rather than
reading through `configProvider` directly — a `Config` write never changes the `Config`
instance Riverpod is watching, so nothing downstream rebuilds unless told to.
10 tests: 9 in `settings_screen_test.dart`, 1 confirming the record screen's settings
button is genuinely wired (not just present) in `widget_test.dart`.

View File

@@ -0,0 +1,87 @@
# V3-03 — Distance and speed units
**Phase** Foundations · **Depends on** V3-02 · **Size** S · **Status** Done
## Goal
Imperial as well as metric, chosen once and applied everywhere.
## Context
Everything is hardcoded metric: `formatDistance` switches m/km at 1000, `formatSpeed`
prints km/h, elevation prints metres. Fine in Canada, useless to anyone in the US or UK.
Cheap, and the kind of thing that makes an app feel unfinished when missing.
## Design
A `UnitSystem` enum (`metric`, `imperial`) in `Config`, defaulting from the device locale
on first launch.
**Conversion belongs in formatting only.** Storage stays SI — metres, km/h, metres of
altitude — forever. Converting at the storage layer would corrupt every existing ride and
break the parity harness.
| Value | Metric | Imperial |
|---|---|---|
| Distance | m / km | ft / mi |
| Speed | km/h | mph |
| Elevation | m | ft |
## Implementation
1. `UnitSystem` in `Config`; default from `Platform.localeName`
2. Extend `ui/format.dart` — every formatter takes the unit system
3. Thread it through: record screen, trips list, trip detail, charts, map legend
4. Exports stay SI regardless. GPX is metres by specification; changing that breaks
consumers.
## Acceptance criteria
- [ ] Switching units updates every screen immediately
- [ ] Stored values are unchanged — verified by exporting before and after
- [ ] GPX/GeoJSON output is byte-identical across the two settings
- [ ] First launch picks a sensible default from the locale
## Tests
- Formatter tests for both systems, including the m→km and ft→mi boundaries
- **A test asserting export output does not change with the unit setting**
- Widget test toggling units and checking a rendered label
## Risks
The obvious trap is converting too deep in the stack. Guard it with the export test.
## Out of scope
Temperature, pace (min/km) — pace is arguably right for running, revisit after V3-01.
## Outcome
Built ahead of V3-02 in execution order, despite the ticket table listing it as
depending on V3-02 — the Settings screen needed something real to control, and the
formatting/`Config` plumbing itself has zero dependency on a screen existing. Both are
done; the numbering is unchanged.
`UnitSystem` lives in `domain/models.dart`, not `ui/format.dart` as first drafted —
`Config` (a data/preferences-layer class) needed the enum too, and having it depend on
`ui/` read backwards. Moved to the domain layer alongside `Activity`, which every other
cross-cutting preference-like enum in this codebase already does.
Reactivity reuses the exact pattern `mapEnabledProvider` already established —
`unitSystemProvider`, a `StateProvider<UnitSystem>` seeded from `Config` once and then
read/written directly by the UI — rather than introducing the heavier `ConfigNotifier`
class the ticket's implementation notes proposed. `Config` mutates its own backing
`SharedPreferences` in place, so a widget re-assigning the same `Config` instance to
`configProvider` was never going to notify anything; this sidesteps that without a new
abstraction.
Threaded through all three screens plus the speed histogram's bucket labels, which
convert-and-round for display (`formatSpeedRangeLabel`) without changing how
`speedHistogram` itself bins — binning stays km/h always, matching the invariant that
storage and computation never see the display unit. Export was the one place explicitly
*not* touched: `gpx()`/`geoJson()` take no `UnitSystem` parameter at all, which is a
stronger guarantee than validating one.
One real finding: `config_test.dart`'s locale-default test deliberately does not assert
a specific value, because it cannot know the test runner's own locale — and that caution
was immediately vindicated. `settings_screen_test.dart` first asserted a fresh `Config`
defaults to metric and failed, because this dev machine's own locale resolves to a
region in the imperial set. Fixed by seeding an explicit value before asserting, the
same technique already used elsewhere for exactly this reason.
22 tests: 13 `format_test.dart`, 9 `config_test.dart`.

View File

@@ -0,0 +1,90 @@
# V3-04 — Live map on the recording screen
**Phase** Live map · **Depends on** nothing · **Size** M · **Status** Done
## Goal
While recording, show the path as it is drawn, on the recording screen.
## Context
v2 shipped without one deliberately: the phone rides in a pocket, so a live map would burn
battery for something nobody is looking at. **That decision is reversed** — Dylan wants it
for visual appeal, and because a mounted phone is now a real use case (V3-05).
The map component already exists (`RideMap`) and already handles per-segment polylines,
render-only decimation and the zoom clamp. This ticket is mostly about *lifecycle*, not
drawing.
## Design
Two constraints survive the reversal and are non-negotiable:
- **`RecordingEngine` must never reference a map.** Rendering belongs to a visible screen's
widget lifecycle. The engine already exposes everything needed.
- **No tile fetch or redraw while backgrounded.** A pocketed phone must cost exactly what
it costs today.
Feed the map from a stream of the current trip's points. `watchTripStats` exists but
returns aggregates; this needs the points themselves — add a `watchPointsForTrip` Drift
stream, which updates naturally on each writer flush (~2 s), not per fix.
Follow the rider: keep the latest point centred, with a manual-pan override that stops
auto-follow until re-enabled.
Behind the existing map toggle, off by default while it is unproven on battery.
## Implementation
1. `watchPointsForTrip(tripId)` in `AppDatabase`
2. Hoist the map above the stats card on the record screen, behind the toggle
3. `WidgetsBindingObserver` — on `AppLifecycleState.paused`, stop tile fetching; resume on
`resumed`. This is the load-bearing part.
4. Auto-follow with a pan override
5. Keep the numeric readout visible; the map must not push SPEED off screen (the record
screen already scrolls — see the overflow fix in `port/PROGRESS.md`)
## Acceptance criteria
- [ ] The path appears and extends while recording
- [ ] Backgrounding the app stops all tile activity, verified in a network log
- [ ] `RecordingEngine` still has no map import — grep it
- [ ] With the toggle off, no map widget is constructed at all
- [ ] Speed and elapsed remain visible without scrolling on a common phone size
## Tests
- Widget: map appears only when recording and the toggle is on
- Widget: lifecycle transition to paused stops the tile layer
- The existing map tests still pass
## Risks
- **Battery.** This is the whole reason v2 said no. Measure before defaulting it on
(V3-13).
- Redrawing per fix rather than per flush would be wasteful; drive from the database
stream, which is already batched.
## Out of scope
Mounted mode (V3-05). Other riders on the map (v4).
## Outcome
Shipped as designed. `AppDatabase.watchPointsForTrip`/`watchSegmentsForTrip` feed two
`autoDispose.family` providers (`livePointsProvider`, `liveSegmentsProvider`) keyed by
trip id; a `_LiveMap` adapter widget on the record screen reads them and hands the result
to the existing `RideMap`, unchanged in shape. `RecordingEngine` was never touched —
verified by `test/architecture_test.dart`, which greps the source rather than trusting a
comment.
`RideMap` itself grew two small, general capabilities rather than a parallel "live"
widget: a `WidgetsBindingObserver` that drops the `TileLayer` entirely (not just visually,
via widget tree omission) outside `AppLifecycleState.resumed`, and an optional `follow`
flag that recentres on the latest point via `didUpdateWidget` + a post-frame
`MapController.move`, cancelled permanently by the first user-gesture pan. Both apply
to the trip-detail map too, which is a free win: a backgrounded detail screen no longer
holds tiles fetching either.
Three existing record-screen tests (`recording swaps to PAUSE and STOP`, `paused offers
RESUME`, `discard asks before destroying anything`) had to gain an explicit `map: false` —
they predate this ticket and would otherwise have started constructing a real
`FlutterMap`/`TileLayer` against an active trip, which is exactly the tile-fetch-in-tests
problem the trip-detail tests already route around.
3 new tests: the grep-based engine-purity check in `architecture_test.dart`, plus two in
`widget_test.dart` — map presence/absence by toggle and trip state, and the lifecycle
transition (paused drops `TileLayer` but keeps `PolylineLayer`; resumed brings it back),
driven via the standard `flutter/lifecycle` platform-message technique rather than a
private binding API. `flutter analyze` clean; full suite green (224 tests, up from 221).

View File

@@ -0,0 +1,101 @@
# V3-05 — Mounted (handlebar) mode
**Phase** Live map · **Depends on** V3-04 · **Size** M · **Status** Done
## Goal
Make the app usable on handlebars in daylight, at speed, with gloves — as an explicit mode
rather than an accident.
## Context
v1 and v2 were built entirely around "start it, pocket it, stop it". **A mounted phone is a
different product.** Treating it as a mode makes the differences deliberate instead of
half-met.
## Design
A toggle that changes several things at once:
- **Keep the screen awake** for the whole ride (`wakelock_plus`). Currently the screen
sleeps and recording continues; mounted, that is wrong.
- **Sunlight legibility.** The dark theme was chosen for glanceability at night and in a
pocket-glance. Behind a visor in daylight it is the wrong choice — a high-contrast
variant with larger figures is needed. This is not the same as V3-16's visual identity.
- **Larger touch targets still.** 72 dp works stopped; at speed with gloves it does not.
- **Interruptions.** What happens on an incoming call or a notification — the recording
must survive and the screen must come back.
## Implementation
1. `mountedMode` in `Config`, surfaced in settings and as a quick toggle on the record
screen
2. `wakelock_plus`, acquired on start when mounted, released on stop/pause — and released
on `dispose`, or the screen stays lit after the app closes
3. A high-contrast text scale applied when mounted
4. Handle `AppLifecycleState.inactive` (a call arriving) distinctly from `paused`
## Acceptance criteria
- [ ] Mounted: the screen never sleeps during a ride
- [ ] Un-mounted: behaviour is exactly as today
- [ ] The wake lock is released on stop, on discard, and on app exit
- [ ] An incoming call does not stop recording
- [ ] Battery cost of mounted mode is measured and written down (V3-13)
## Tests
- Widget: mounted toggle changes text scale and requests the wake lock (fake the plugin)
- Widget: the lock is released on stop
- **Manual, on a real bike** — legibility in daylight cannot be tested any other way
## Risks
- **A leaked wake lock flattens the battery**, silently and after the app is closed. Test
the release path harder than the acquire path.
- Legibility is a judgement call that needs a real ride in real sun.
## Out of scope
A dedicated mounted layout with different information architecture — start by scaling what
exists and see what the ride teaches.
## Outcome
Shipped as designed, plus two deviations worth recording.
`Config.mountedMode` follows the same seeded-`StateProvider` shape as `mapEnabled` and
`unitSystem` (`mountedModeProvider`); a quick-toggle icon button sits next to Settings on
the record screen, and a matching switch was added to `SettingsScreen`. The wake lock is
wrapped in a `WakelockController` seam (`FakeWakelockController` for tests), mirroring
`LocationSource` — the same reasoning: the risk named in this ticket ("a leaked lock
flattens the battery silently") is exactly the kind of thing that has to be provable, not
just plausible.
**Deviation 1 — no `TextTheme.apply(fontSizeFactor: ...)`.** The design called for scaling
the whole mounted text theme at once; Flutter's `TextStyle.apply` asserts when
`fontSizeFactor != 1.0` meets any style with a null `fontSize`, which Material 3's default
`TextTheme` has for at least one role. Scaling was moved to where it already existed:
`BigStat` gained an explicit `scale` parameter (default `1.0`), applied to the record
screen's headline figure only. `StatRow` and button labels were **not** wired to
`mountedTextScale` — the acceptance criterion is legibility of the number that matters at
a glance, not uniform scaling of every row, and over-scaling the stat card risked
reintroducing the record screen's known overflow-on-short-phones failure mode.
**Deviation 2 — the mounted theme wraps only the record screen**, via a local `Theme(...)`
widget inside `RecordScreen.build`, not the app's `MaterialApp`. `Theme.of(context)` inside
that build method would still report the ambient dark theme, so `colors` is read off the
locally-built `ThemeData` directly rather than through `Theme.of(context)` — a small trap
worth flagging for V3-16, which will touch this same file.
Wake lock acquisition is gated on **recording**, not merely mounted-and-idle or
mounted-and-paused, and re-evaluated both on trip-state transitions and on the mounted
toggle itself changing mid-ride. Release happens on stop, on discard (both drive the same
trip-state listener), on navigating away (`dispose`), and defensively whenever mounted mode
is off. One implementation snag: reading `ref` inside `State.dispose()` throws
(`ConsumerStatefulElement` forbids it once unmounting has started) — fixed by capturing the
`WakelockController` once in `initState` via a `late final` field instead of reading it
fresh in `dispose`.
7 new tests across `widget_test.dart` (wake lock acquired while recording+mounted,
never requested un-mounted, released on ride completion, released on navigating away
while still recording, mounted theme scales the headline and enlarges Start),
`settings_screen_test.dart` (switch writes through to `Config`), and `config_test.dart`
(default/round-trip). `flutter analyze` clean; full suite green (231 tests, up from 224).
Not done, and explicitly out of scope per the ticket: real daylight/glove legibility
(needs an actual ride — V3-13), and `AppLifecycleState.inactive` handling for an incoming
call — recording is already fully DB-derived and does not observe app lifecycle at all, so
a call cannot stop it; this was verified by reasoning about the existing architecture
rather than a new test, since there is no lifecycle-reactive code path to test.

View File

@@ -0,0 +1,94 @@
# V3-06 — Live stats in the notification
**Phase** Live map · **Depends on** nothing · **Size** S · **Status** Done
## Goal
Distance and duration readable from the notification shade without unlocking.
## Context
The ongoing notification currently says "Rippr is recording / Tracking your ride" — static
text. During a pocketed ride that is a wasted surface.
**Constraint that shapes this whole ticket:** the notification is owned by `geolocator`'s
`ForegroundNotificationConfig`, which takes fixed strings at stream-subscription time and
offers no update path and no actions. That is the parity gap recorded in
[../port/PARITY-AUDIT.md](../port/PARITY-AUDIT.md).
## Design
Two honest options:
**A. Restart the position stream with new text.** Cheap to write, but it tears down and
re-establishes location updates every time — unacceptable during recording.
**B. Take the notification back with `flutter_local_notifications`,** and let geolocator
raise a silent minimal one. More moving parts, but it also **restores Pause/Resume actions**
— closing the one capability lost in the port.
**Recommend B**, precisely because it buys back the parity gap as well.
## Implementation
1. Add `flutter_local_notifications`
2. Own a notification on the same channel, updated on each writer flush (~2 s), not per fix
3. Pause/Resume actions routed back into `RecordingEngine`
4. Android 13+ notification permission is already requested
## Acceptance criteria
- [ ] Distance and elapsed update while riding, without unlocking
- [ ] Pause and Resume work from the shade
- [ ] Location updates are **not** interrupted when the notification changes
- [ ] The notification cannot be swiped away mid-ride
## Tests
- Unit: notification text formatting from a trip
- Integration on a device: start, confirm the text advances, pause from the shade, confirm
the engine actually paused (check the database, do not trust the UI)
## Risks
- Two notification sources fighting is the obvious failure. Verify only one is visible.
- An update per fix would be a battery and jank problem; drive it from the flush.
## Out of scope
iOS. There is no equivalent live notification surface; a Live Activity is a much larger
piece of work and belongs in its own ticket.
## Outcome
Shipped as designed (option B), with one honestly-unresolved risk carried forward.
`RideNotificationController` is a seam over `flutter_local_notifications`
(`FakeRideNotificationController` for tests), the same shape as `LocationSource` and
`WakelockController`. `RideNotificationCoordinator` owns the wiring: it subscribes to
`TripRepository.watchActiveTrip()` (the same stream `activeTripProvider` exposes, updated
once per writer flush) and calls `show`/`cancel`; it subscribes to the controller's action
stream and routes `pause`/`resume` back into `RecordingEngine`, guarded so a Resume can't
fire against a trip that isn't actually paused. `rideNotificationText(Trip, {unit})` is
pure and unit-tested directly — distance/elapsed formatting, the `Paused ·` prefix, and
unit-system handling.
**Not screen-owned, deliberately.** Unlike the live map (V3-04) and mounted mode (V3-05),
which are fed from `RecordScreen`'s widget tree, the coordinator is instantiated eagerly
from `main.dart`'s `RipprApp.build` via a bare `ref.watch(rideNotificationCoordinatorProvider)`
— a pocketed ride has no visible widget tree, but the notification and Pause/Resume both
still have to work.
**Unresolved risk, flagged rather than papered over:** the ticket's own risk section names
"two notification sources fighting" as the obvious failure mode, and it is real.
geolocator's `ForegroundNotificationConfig` is what satisfies Android's foreground-service
requirement and cannot be suppressed; `flutter_local_notifications` raises a second,
independent notification. There is no documented way to merge or guarantee only one is
visible — the geolocator notification was made minimal and silent
(`geolocator_location_source.dart`) on the assumption that an `ongoing: true` notification
on the same-ish surface might collapse or de-prioritise it, but that assumption is
unverified without a device. This is exactly the kind of claim the project's testing
philosophy refuses to accept on faith — see the real-ride checklist (V3-13) and this
ticket's own "Integration on a device" test, neither of which could run here.
6 new tests, all in `test/ride_notification_test.dart`: three for `rideNotificationText`
(recording, paused-prefix, unit system), three for the coordinator using a real
`RecordingEngine` + in-memory `TripRepository` (not a mock) so pause/resume are checked in
the database per the project's standing rule, not by trusting the notification state.
`flutter analyze` clean; full suite green (237 tests, up from 231).
Not attempted: the device-only acceptance criteria (text updates without unlocking,
Pause/Resume from the shade, the notification resisting swipe-away, and the two-source
visibility question above) — all require a real Android device, per the ticket's own Tests
section.

View File

@@ -0,0 +1,106 @@
# V3-07 — Route drawing (pins and straight lines)
**Phase** Route planning · **Depends on** nothing · **Size** M · **Status** Done
## Goal
Drop pins on a map to sketch a route, see the straight-line distance, and save it. The
foundation for V3-08, deliberately shipped without a routing engine.
## Context
Pre-ride planning is a **genuinely new mode**, not an extension of recording. It needs no
ride in progress, no server, and nothing else in v3 — the most independent thing in the
backlog.
Split from road-snapped routing (V3-08) on purpose: pins, storage, editing and the map
interaction are all needed either way, and none of them require choosing a routing vendor.
That decision should not block a usable feature.
## Design
**New entities, separate from `Trip`.** A plan is not a recording, and conflating them
would put unridden kilometres into ride totals.
```
Route(id, name, createdAt, activity?, distanceM, estimatedMillis?, geometry?)
Waypoint(id, routeId, ordinal, latitude, longitude, name?)
```
`geometry` is null in this ticket; V3-08 fills it with the road-snapped polyline.
`ordinal` rather than relying on insertion id, so waypoints can be reordered.
Straight-line distance reuses `haversineMeters` — already ported and parity-proven.
## Implementation
1. Drift tables + **migration** (the second one; V3-01 proves the path)
2. `RouteRepository` mirroring `TripRepository`'s shape
3. `RoutePlannerScreen` — tap to add a pin, drag to move, tap a pin to delete, reorder
4. Straight-line polyline between pins, visibly distinct from a recorded path
5. Routes list, reachable from the record screen alongside Rides
6. Name, rename, delete
## Acceptance criteria
- [ ] Pins can be added, moved, reordered and deleted
- [ ] Distance updates live as pins change
- [ ] A saved route survives an app restart
- [ ] Routes never appear in the rides list, and never contribute to ride totals
- [ ] Deleting a route cascades to its waypoints
## Tests
- Repository: create, reorder, delete, cascade — in-memory, like the trip tests
- Distance matches `pathLengthMeters` over the same points
- Widget: tapping the map adds a pin; the distance label updates
- **A test asserting routes are absent from `watchCompletedTrips`**
## Risks
The main one is scope drift into V3-08. Ship straight lines first; they are genuinely
useful for a rough plan.
## Out of scope
Road snapping, ETA, following a route while riding. Import of existing GPX routes.
## Outcome
Shipped as designed, with one naming deviation and two real testing traps worth recording
for V3-08/V3-09.
**Named `RoutePlan`, not `Route`.** The ticket's own design sketch used `Route`, but that
collides with `dart:ui`/`package:flutter`'s own `Route<T>` (the navigator's page-transition
class) and with `go_router`'s `GoRoute`. Renaming up front avoided constant `hide`/`as`
import juggling across every file that touches both navigation and route plans.
Schema: `route_plans`/`waypoints` tables, schema version 2→3, following V3-01's migration
pattern exactly (`m.createTable` for brand-new tables needs no backfill, unlike V3-01's
`addColumn`). `RoutePlanRepository` mirrors `TripRepository`'s shape but has no state
machine — every mutating call ends by recomputing `distanceM` via `geo.pathLengthMeters`,
so "distance always matches the current waypoints" holds with no exceptions to remember,
including after a pure reorder that doesn't change it.
`RoutePlannerScreen`: tap-to-add via `MapOptions.onTap`, tap-a-pin-to-delete, and
drag-to-move implemented by hand against `MapCamera.latLngToScreenOffset`/
`screenOffsetToLatLng` (flutter_map has no built-in draggable-marker widget). The straight
line is dashed and uses the planning accent, visibly distinct from `RideMap`'s
speed-bucketed solid polyline, satisfying the acceptance criterion without a design pass.
**Real bug found by testing, not review:** the map's `initialCenter`/`initialZoom` are
read exactly once, at `FlutterMap` construction. Building the map before the waypoints
stream delivered its first value froze the camera on null-island permanently, even once
real waypoints arrived — invisible in manual testing (a route sketched from empty always
starts empty) but immediate in a test that opens a planner for a route with existing
waypoints. Fixed by gating the map behind the stream's first emission, and by switching
from a fixed-zoom guess to `CameraFit.bounds` (matching `RideMap`'s own established
pattern) so pins can't be culled off-camera either.
**Real testing trap, likely to recur in V3-08/V3-09:** `await db.watchRoutePlans().first`
inside a `testWidgets` body hung for a genuine ten minutes (the framework's own internal
timeout, not a guess) — a fresh `Stream.first` subscription on a Drift `.watch()` query
depends on a `Timer` inside Drift's stream-query store that flutter_test's fake test zone
never advances without an explicit pump. `repo`-level `Future`-returning calls
(`routePlanById`, etc.) have no such dependency and are what every other assertion in this
suite already used correctly. Documented inline in the test as a trap for the next ticket
that watches a stream from inside `testWidgets`.
16 new tests: 8 in `route_plan_repository_test.dart` (create, live distance on
add/move/delete, ordinal-gap closing, reordering, rename, cascade delete, and the
ticket-mandated "never appears in ride totals" check), 1 migration test (v2→v3, tables
created and usable, existing trip untouched), 7 in `route_planner_screen_test.dart`
(empty state, create-and-open, delete, tap-to-add-and-distance-updates, tap-to-delete,
rename, missing-route fallback), plus 1 in `widget_test.dart` for the Routes entry point
on the record screen. `flutter analyze` clean; full suite green (254 tests, up from 237).

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.

View File

@@ -0,0 +1,53 @@
# V3-09 — Follow a planned route while riding
**Phase** Route planning · **Depends on** V3-04, V3-08 (itself blocked on V3-17) · **Size** M · **Status** Deferred
## Goal
Pick a saved route before starting, see it on the live map underneath your actual track,
and afterwards compare what you rode against what you planned.
## Context
The payoff that makes V3-07 and V3-08 worth building, and the point where route planning
meets the live map. Not navigation — no turn-by-turn, no voice. Just the line you meant to
follow, drawn under the line you actually rode.
## Design
Attach an optional `routeId` to `Trip`. That is enough for both the live overlay and the
after-the-fact comparison.
Live: the planned route in a muted colour, the recorded track drawn over it in the existing
speed colours. Immediately obvious when you have left the plan.
Afterwards, on trip detail: both lines, plus how far you deviated and how the real duration
compared with the estimate — which also, over time, tells you how honest the ETA is.
**Deliberately not:** rerouting, off-route alerts, or anything that demands attention while
riding. A rider glancing at handlebars wants a picture, not an interruption.
## Implementation
1. `routeId` on `Trip` — **third migration**
2. Route picker on the record screen before Start, defaulting to none
3. Live map renders the planned polyline beneath the track
4. Trip detail renders both, with a comparison block
5. Deviation: max and mean distance from the recorded points to the planned polyline —
`perpendicularDistanceMeters` already exists and is parity-proven
## Acceptance criteria
- [ ] A route can be selected before starting, or not
- [ ] Both lines render, visually distinguishable
- [ ] Deviation and duration-vs-estimate appear on trip detail
- [ ] A ride with no route behaves exactly as today
- [ ] Deleting a route does not delete rides that referenced it
## Tests
- Deviation maths against a known track and route
- Repository: deleting a route nulls `routeId` rather than cascading to the trip —
**the cascade direction here is the opposite of segments and is easy to get wrong**
- Widget: both polylines present when a route is attached
## Risks
The `Route` → `Trip` foreign key must **not** cascade. Deleting an old plan must never
delete the ride you did.
## Out of scope
Turn-by-turn, off-route alerts, rerouting.

View File

@@ -0,0 +1,92 @@
# V3-10 — Trip splitting
**Phase** Ride management · **Depends on** nothing · **Size** S · **Status** Done
## Goal
Split one recorded ride into two at a chosen point. The natural counterpart to merge.
## Context
Merge exists and is well tested; split does not. The case is a rider who forgot to stop —
one "ride" that is really the trip out, lunch, and the trip home.
Merge already establishes the hard parts: re-parenting points and segments inside a
transaction, and recomputing aggregates rather than summing them.
## Design
Split at a **segment boundary** rather than an arbitrary point. Segments already mark where
the rider paused, which is exactly where a forgotten stop shows up — and it avoids
inventing a new boundary type or splitting a segment in half.
The original trip keeps the earlier segments; a new trip takes the later ones. Both get
aggregates recomputed from the points they actually own.
If a ride has only one segment there is nothing to split, and the UI should say so rather
than offering a dead control.
## Implementation
1. `TripRepository.splitTrip(tripId, atSegmentId)` inside a transaction:
create the new trip, re-parent segments and points from `atSegmentId` onward,
set `startedAt`/`endedAt` from the segments each trip now owns,
recompute aggregates for both
2. Trip detail: a split action listing segment boundaries with their times
3. Confirmation naming what the two resulting rides will be
## Acceptance criteria
- [ ] Splitting produces two trips whose point counts sum to the original
- [ ] Neither trip's distance includes the gap between them
- [ ] Both have plausible `startedAt`/`endedAt`
- [ ] Single-segment rides cannot be split, and the UI explains why
- [ ] Atomic — a failure part-way leaves the original intact
## Tests
- Point counts sum; no points orphaned
- Distance of the parts is less than the original by roughly the gap
- Split then merge returns to the original aggregates — a good round-trip property
- Rejects a single-segment trip
- Atomicity under a forced mid-transaction failure
## Risks
Getting `startedAt`/`endedAt` from the wrong source. Derive them from the segments each
trip owns, not from the original trip.
## Out of scope
Splitting mid-segment.
## Outcome
Shipped as designed. `TripRepository.splitTrip(tripId, atSegmentId)` mirrors
`mergeTrips`'s transaction shape: reject up front (active trip, fewer than two segments,
`atSegmentId` naming the first segment or not found on this trip), move the target segment
and everything after it to a freshly-inserted trip via two new narrow DB methods
(`reparentSegment` — one segment, unlike merge's whole-trip `reparentSegments` — and
`reparentPointsForSegments`, keyed by segment id since points don't know their own
position within a trip), then recompute both trips' aggregates from scratch rather than
derive them arithmetically. `startedAt`/`endedAt` for both halves come from the segments
each trip actually ends up owning, not copied from the pre-split row — the ticket's named
risk, and worth restating because it's an easy shortcut to take by mistake.
Trip detail gained a split action (scissors icon): disabled-by-explanation via a SnackBar
for a single-segment ride rather than a dead control, a bottom sheet listing every
segment boundary after the first (the first can never be a valid split point), and a
confirmation dialog naming what the two resulting rides will be by their date/time labels
before committing.
**Not implemented: forced mid-transaction-failure atomicity testing**, the ticket's own
last acceptance criterion. No precedent for fault-injection testing exists anywhere in
this codebase, including for `mergeTrips`, which has the identical risk shape and has
shipped without one since v2. Atomicity here is a property of Drift's `_db.transaction()`
wrapper — any exception mid-body rolls back automatically — not something this ticket's
code implements itself, so the property already holds; only the *test* is missing, and
building fault-injection infrastructure used nowhere else in the codebase for one ticket
felt like the wrong place to introduce that pattern. Flagged rather than silently dropped.
12 new repository tests (point counts sum, no orphans, distance excludes the gap,
`startedAt`/`endedAt` from the right source, segment-boundary preserved on both sides,
single-segment rejected, first-segment rejected, active-trip rejected, unknown-segment
rejected, split-then-merge round-trips back to the original aggregates, and an explicit
no-orphans sweep over every point/segment), plus 2 widget tests (single-segment
explanation, full split flow via the bottom sheet and confirmation dialog). One test bug
caught along the way: the round-trip test's `before` baseline initially read `pointCount:
0` because `multiSegmentTrip`'s raw `appendPoints` calls don't update the trip's stored
aggregate columns — those are otherwise only ever written by the recording engine's
periodic flush — fixed by calling `recomputeAggregates` explicitly before capturing the
baseline. `flutter analyze` clean; full suite green (269 tests, up from 257).

View File

@@ -0,0 +1,113 @@
# V3-11 — Offline tile pre-download
**Phase** Ride management · **Depends on** V3-04 · **Size** M · **Status** Partially done
## Goal
Have map tiles available where there is no signal.
## Context
`flutter_map` caches what it renders, so a re-viewed ride works. A **mountain ride with no
signal shows blank tiles** — precisely where a map is most wanted.
## Design
**Respect OSM's tile usage policy. Bulk prefetching their public servers is prohibited**
and would get the app blocked. This constraint decides the design:
- Pre-download only a **user-chosen area**, at a **limited zoom range**, with a visible
tile count and size estimate before starting
- Rate-limited, sequential, cancellable
- If this becomes a headline feature, move to a paid tile provider or self-hosted tiles.
Do not scale it on OSM's donated infrastructure.
Natural pairing with V3-07: pre-download the corridor along a planned route rather than a
rectangle — far fewer tiles for the same usefulness.
## Implementation
1. Persistent tile cache with a size cap and eviction (`flutter_map_cache` or similar)
2. Area selection on the map, plus a "download along this route" option
3. Tile count and MB estimate **before** any request
4. Sequential fetch with a delay, a progress indicator and cancellation
5. Settings: cache size, and a way to clear it
## Acceptance criteria
- [ ] A downloaded area renders with the network off
- [ ] Count and size shown before download starts
- [ ] Cancellable mid-download, keeping what has already arrived
- [ ] A hard cap on tiles per request — no unbounded area selection
- [ ] Cache size visible and clearable
## Tests
- Tile-count maths for a bounding box across zoom levels
- Cache eviction at the cap
- Cancellation leaves a consistent cache
- **Manual:** aeroplane mode over a downloaded area
## Risks
- **Abusing OSM's servers.** Cap, rate-limit, and be conservative. A blocked user agent
would break the map for everyone.
- Storage growth. Tiles add up fast; the cap is not optional.
## Out of scope
Vector tiles or a full offline basemap.
## Outcome
The download pipeline is done and unit-tested end to end; the on-device acceptance
criterion (aeroplane mode over a downloaded area) is not, and can't be from here.
**Deliberate scope reduction: no rectangle area-selection UI.** The ticket names the
route-corridor pairing with V3-07 as strictly better ("far fewer tiles for the same
usefulness") and V3-07 already shipped, so that became the only download entry point
rather than building two. `tilesAlongRoute` buffers each waypoint by a fixed radius and
unions the per-point tile sets — a zigzagging route's actual footprint, not the
rectangle around its bounding box, proven directly in `tile_math_test.dart` by
constructing a route that zigzags across its own bounding box and asserting the corridor
costs fewer tiles than that box would.
Three pure modules, layered the way `geo.dart`/`ride_statistics.dart` already are in this
codebase: `tile_math.dart` (tile enumeration, a hard `maxTilesPerDownload` cap enforced by
*throwing* rather than silently truncating — a caller must know a download was rejected,
not receive a partial one unknowingly), `tile_cache.dart` (`FileTileCache`: tiles as
files on disk, a JSON manifest tracking size and last-access time, LRU eviction that
runs *before* a write that would exceed the cap, not after), and `tile_downloader.dart`
(sequential, rate-limited via a fixed delay between tiles, cooperatively cancellable via
`CancelToken`, one bad tile doesn't abort the rest, everything fetched before
cancellation stays in the cache).
`CachedTileProvider` wires the cache into `flutter_map`'s `TileLayer` via a custom
`ImageProvider` (cache hit skips the network entirely; a miss fetches, writes through,
then decodes) and is now what `RideMap` and `RoutePlannerScreen` both request tiles
through — a write-through side effect of this ticket is that ordinary map viewing now
also populates the same capped, evictable cache, replacing flutter_map's own uncapped
default. `RoutePlannerScreen` gained a download action (disabled with an explanatory
tooltip when the route has no pins yet), a count-and-MB-estimate confirmation dialog
before any request goes out, and a progress dialog with a working Cancel button. One real
bug caught before it shipped: the first draft of the progress dialog used a bare
`StatefulBuilder`, whose builder callback re-runs on every `setState` — meaning every
single progress tick would have started a *second* overlapping download subscription.
Fixed by moving the subscription into a dedicated `_DownloadDialog` `StatefulWidget` that
starts it once, in `initState`.
Settings gained an "Offline tiles" section: current cache size against the fixed cap, and
a Clear button. The cap itself (200 MB) is **not** user-configurable in this pass — only
whether to clear it — a scope call in the same spirit as the ticket's "the cap is not
optional" risk language.
**Not done, and cannot be done in this environment:** the ticket's own two headline
acceptance criteria — a downloaded area actually rendering with the network off, and
aeroplane-mode verification on a real device — both need a phone. Everything upstream of
that (the tile math, the eviction policy, the cancellation-consistency of the cache, and
the fact that a cache hit in `CachedTileProvider` skips the network call entirely by
construction) is proven at the unit level; only the last mile — a real radio actually
turned off — is not.
21 new tests: 8 in `tile_math_test.dart`, 8 in `tile_cache_test.dart` (round-trip, miss,
size accounting, LRU eviction order, cap never exceeded across many writes, clear, and a
cache re-opened over the same directory seeing prior contents), 4 in
`tile_downloader_test.dart` (sequential/in-order/no-duplicates, one failure doesn't abort
the rest, cancellation keeps a consistent partial cache, empty input is a no-op), 2 in
`route_planner_screen_test.dart` (download disabled with no pins; count/size shown before
any request — deliberately stopping short of confirming, since the pure download engine
already covers the fetch/cancel/cache-consistency behaviour directly with fakes, and
exercising it again through a real `http.Client` in a widget test would need a fake HTTP
layer for no additional coverage). `flutter analyze` clean; full suite green (305 tests,
up from 284).

View File

@@ -0,0 +1,100 @@
# V3-12 — Crash reporting
**Phase** Quality · **Depends on** nothing · **Size** S · **Status** Partially done
## Goal
Know when the app dies mid-ride.
## Context
There is none. A recorder that crashes during a ride currently leaves no trace beyond
logcat, which nobody reads — and the failure mode that matters most (recording stopping
silently) is exactly the one the user cannot report usefully.
Becomes important the moment anyone who is not Dylan uses it.
## Design
Sentry or Firebase Crashlytics. **Sentry is the better fit**: it is not tied to Google
services, works identically on both platforms, and its free tier is ample here.
**A crash reporter in a location app is a privacy surface.** Configure it deliberately:
- No location data in breadcrumbs or context, ever
- No device id, no ride contents
- Explicit opt-out in settings, and disclosed in the privacy policy
- Debug builds report nowhere
Beyond crashes, one custom event is worth having: **recording ended unexpectedly** — the
engine stopping without a user stop. That is the failure the app exists to avoid.
## Implementation
1. Add `sentry_flutter`, initialised in `main()` behind a config flag
2. Scrub: no coordinates, no ids, no trip contents in any payload
3. Breadcrumbs for lifecycle transitions only
4. A custom event when a recording ends without a user action
5. Settings toggle, defaulting **off** until a privacy policy exists
## Acceptance criteria
- [ ] A forced crash appears in Sentry from a release build
- [ ] No coordinate ever appears in a payload — inspect a real one
- [ ] The toggle genuinely disables reporting
- [ ] Debug builds send nothing
## Tests
- The scrubber strips coordinates from a representative payload
- Reporting disabled means the client is never initialised
- Manual: force a crash in a release build and check it lands
## Risks
Leaking location through breadcrumbs or a stack frame's captured state. Inspect a real
payload rather than assuming the scrubber works.
## Out of scope
Analytics or usage tracking. Different purpose, different consent.
## Outcome
The code and its guarantees are done; the two device/account-dependent acceptance
criteria are not, and can't be from here.
`shouldInitializeCrashReporting({enabled, isDebug, dsn})` pulls the entire "talk to Sentry
at all" decision out as a pure function — every combination of the user's toggle, debug
vs. release, and a configured DSN is asserted directly, rather than trusted to however
`main()` happens to wire things. `scrubExtra` strips any key matching a coordinate,
altitude, device-id, or trip-id fragment (case-insensitive, substring match, so `lat`,
`latitude`, `startLat`, and `gps.lon` are all caught without enumerating every call site
that might one day capture one) from both event `extra` and every breadcrumb's `data`,
wired in as `beforeSend`/`beforeBreadcrumb`.
`Config.crashReportingEnabled` defaults to false and is surfaced in Settings, same shape
as every other toggle in this file. The DSN itself is **not** a user preference — it's a
compile-time `--dart-define=SENTRY_DSN=...` value, since it names which Sentry project
receives reports, not a fact about the rider. `main()` loads `Config` once, early, purely
to make the init-or-not decision before `runApp` (since `SentryFlutter.init` wraps the
app itself); the widget tree still loads its own `Config` in `RipprApp.initState` as
before, since `SharedPreferences` is memory-cached after the first read.
The one custom event: `RecordingEngine` gained an optional `onUnexpectedStop(String
reason)` callback, injected the same way `uploadPending` already is, so the engine keeps
no opinion about where a report goes. It fires exactly once, in
`restoreAfterProcessDeath`, when a trip is found still `recording` at launch — the
process died without anyone calling `stop()`, which is precisely "the recording stopped
and nobody chose that." A cleanly-stopped ride reports nothing; verified by both cases in
`recording_engine_test.dart`.
**Not done, and not attempted:** wiring a real Sentry DSN, forcing a real crash in a
release build, and inspecting a real payload for leaked coordinates. All three are the
ticket's own actual acceptance criteria, and all three need a real Sentry account and a
release build this environment cannot produce. `shouldInitializeCrashReporting` and
`scrubExtra` are unit-tested as thoroughly as pure functions can be, but a passing unit
test is not the same claim as "inspected a real payload," which the ticket's own Risks
section insists on by name. This should be revisited once Dylan has a Sentry project to
point the DSN at.
19 new tests: 9 for `shouldInitializeCrashReporting`/`scrubExtra` (including a
representative end-to-end event with coordinates in both `extra` and a breadcrumb),
2 in `recording_engine_test.dart` (unexpected-stop fires on a crash, stays silent on a
clean stop), 2 in `config_test.dart`, 1 in `settings_screen_test.dart`. Fixed the same
viewport-culling test brittleness this section's addition exposed a second time (V3-05
first triggered it): the Sync section's fields dropped out of the default test viewport
entirely, not just out of hit-test range, so four upload-endpoint tests needed a shared
`scrollToSync` helper alongside the device-id test's existing one. `flutter analyze`
clean; full suite green (284 tests, up from 269).

View File

@@ -0,0 +1,61 @@
# V3-13 — Real-ride measurements: elevation, battery, map lifecycle
**Phase** Quality · **Depends on** the real-ride checklist · **Size** M · **Status** Blocked on riding
## Goal
Answer three questions that no amount of code can answer, then act on the answers.
## Context
Three items have been carried since v2 because **nothing but a real ride settles them**.
Grouped into one ticket because they share a prerequisite: riding, with instruments.
## The three questions
### 1. Is elevation gain actually wrong?
~30 m of phantom gain per ten stationary minutes against **synthetic ±8 m uniform noise**.
Real GPS altitude error is *correlated* — it wanders rather than jitters — so the true
behaviour is unknown.
**Do not tune this blind.** Record a flat ride and see what it reports. Only then consider
a longer smoothing window, a larger threshold, or the barometer — which most phones have
and which is far more accurate than GPS altitude.
The port has an advantage the native app did not: `tool/parity/run.sh` proves the algorithm
is bit-identical to the Kotlin original, so any change can be measured against a known
baseline rather than guessed at.
### 2. What does it actually cost in battery?
Never measured, on either app. And V3-04/V3-05 make it worse: a lit screen and continuous
map rendering are a different order of cost from a background service.
Measure three configurations over a multi-hour ride: pocketed with no map, pocketed with
the live map on, and mounted with the screen awake.
### 3. Does the map leak?
`flutter_map`'s lifecycle was wired carefully but never leak-tested across repeated
navigation. The native repo flagged the osmdroid equivalent as a known hazard.
## Implementation
1. Run the checklist in [../port/REAL-RIDE-CHECKLIST.md](../port/REAL-RIDE-CHECKLIST.md)
2. Record elevation on a known-flat route; compare against a barometric or surveyed source
3. Battery: note the percentage at start and end for each configuration, with duration
4. Memory: navigate rides → detail → back fifty times with DevTools attached, watching
for monotonic growth
5. **Write the numbers into this file.** The point is a record, not a vibe.
## Acceptance criteria
- [ ] Flat-ride elevation gain recorded, with a verdict: acceptable or not
- [ ] Battery cost per hour recorded for all three configurations
- [ ] Memory across fifty navigations recorded, with a leak verdict
- [ ] Any resulting code change is justified by a number written down here
## Tests
Measurement, not tests. Any fix that follows gets its own regression test, and elevation
changes must be re-checked against the parity harness.
## Risks
The temptation to tune elevation on a hunch. The v2 backlog says do not, twice, and the
existing bound was already shown to pass on seed luck.
## Out of scope
Fixes themselves. This ticket produces evidence; the fixes are separate work.

View File

@@ -0,0 +1,80 @@
# V3-14 — GPX interoperability
**Phase** Quality · **Depends on** V3-01 for `<type>` · **Size** S · **Status** Partially done
## Goal
Confirm an exported ride actually imports into Strava, Garmin Connect and Google Earth —
and add the activity type so it lands as the right kind of activity.
## Context
Export is well tested: 15 tests, parsed with a real XML parser, and **byte-identical to the
Kotlin original** under the parity harness. But every one of those tests proves *structural
validity*, and structural validity does not mean a consumer accepts the file. That gap has
been open since v2.
## Design
Two parts.
**Verification** — export a real ride and import it into each of Strava, Garmin Connect and
Google Earth. Record what each does with pauses, elevation and timestamps. Pauses are the
interesting case: `<trkseg>` per segment is the correct GPX representation, but consumers
vary in whether they honour it.
**`<type>` on `<trk>`** — Strava and Garmin read it to decide the activity. Without it a
bicycle ride may import as a run. Needs V3-01's activity, mapped to each consumer's
vocabulary (Strava uses `ride`, `run`, and so on).
## Implementation
1. Add `<type>` to the `<trk>` element, from the trip's activity
2. Map the internal enum to GPX conventions; document the mapping in the code
3. Export a real multi-segment ride and import it into all three consumers
4. Write the findings into this file, including anything that surprises
## Acceptance criteria
- [ ] A real ride imports into Strava with the right activity type
- [ ] It imports into Garmin Connect
- [ ] It opens in Google Earth with the path in the right place
- [ ] Pause behaviour in each consumer is documented, whatever it turns out to be
- [ ] Existing export tests still pass, including the byte-identical parity check —
**this one will need updating, since `<type>` changes the output**
## Tests
- `<type>` present and correct per activity
- Absent, not empty, when the activity is `other`
- **The parity harness will now differ from Kotlin here. That is expected and correct —
update its expectation and note why, rather than dropping the check.**
## Risks
Silently breaking the parity harness by changing export output. Update it deliberately.
## Out of scope
GPX import into Rippr. FIT and TCX formats.
## Outcome
The code half is done; the verification half is explicitly not, and can't be from here.
`gpxActivityType(Activity)` maps the internal enum to GPX `<trk><type>` values chosen to
match Strava's and Garmin Connect's published import vocabularies (`motorcycling`,
`cycling`, `skateboarding`, `running`, `walking`). `Activity.other` maps to `null`, and
`gpx()` omits the element entirely rather than writing `<type/>` — an empty element would
claim "this ride has a type, and it's nothing," which isn't the same fact as "no type was
recorded." Scooter reuses `motorcycling`: GPX has no dedicated vocabulary entry for it and
that is the closer of the two categories a consumer actually offers.
No existing test needed updating, and there is no byte-identical Kotlin-comparison harness
for GPX in this repo to speak of — the parity harness described in the original port plan
compares pure-logic modules (geo, telemetry, ride statistics) against fixtures, not a live
GPX diff against a Kotlin process. `<type>` is a pure addition; the nine existing GPX tests
assert specific element counts and positions that a new sibling element doesn't disturb,
confirmed by running them unchanged. Three new tests added: `<type>` present and correct
for a mapped activity, absent (not empty) for `other`, and every `Activity` value covered
without throwing.
**Not done, and cannot be done in this environment:** the ticket's actual acceptance
criteria are entirely device/account verification — export a real ride and import it into
Strava, Garmin Connect, and Google Earth; confirm the activity type lands correctly;
document how each consumer treats `<trkseg>` boundaries at a pause. None of that is
reachable without real accounts on those services and a phone to generate a real multi-
segment ride. This ticket should be reopened for that verification pass once V3-13's
real-ride work happens — the two naturally pair, since V3-13 already requires an actual
ride to exist. `flutter analyze` clean; full suite green (257 tests, up from 254).

View File

@@ -0,0 +1,57 @@
# V3-15 — Auto-pause
**Phase** Quality · **Depends on** V3-13 · **Size** M · **Status** Gated on evidence
## Goal
Decide — with data — whether the app should pause itself when the rider stops.
## Context
**Rejected in v2 as unreliable in traffic**, and that reasoning still stands: a motorcycle
at a long red light is stationary and still mid-ride. Auto-pausing there fragments a ride
into dozens of segments and makes the map look wrong.
Kept in the backlog because it is a common expectation from other ride apps.
## The gate
**Do not build this until V3-13 provides real ride data**, then answer:
1. How long is a typical traffic stop, versus a real break?
2. Is there a clean threshold between them, or do the distributions overlap?
3. Does moving time already handle this well enough? The noise floor **already excludes
stationary time from moving time** — so the numbers may be right and only the segment
count would change.
**If (3) is true, this ticket should be closed rather than built.** That is a legitimate
outcome and arguably the likely one.
## Design, if the data supports it
Time-based, not motion-based: pause after N minutes below the noise floor, resume on the
first fix above it. N derived from the data, not guessed, and never below two minutes.
Off by default, in settings, described plainly.
## Implementation
1. Analyse stop-duration distribution from real rides
2. **Decide and record whether to proceed**
3. If proceeding: a threshold in `RecordingEngine`, reusing the existing pause path so
segments behave identically to a manual pause
4. Setting, defaulting off
## Acceptance criteria
- [ ] A written decision, with the data behind it
- [ ] If built: a traffic-light stop does **not** pause; a coffee stop does
- [ ] Auto-pause produces segments indistinguishable from manual ones
- [ ] Off by default
## Tests
- Synthetic stop patterns: short stop stays recording, long stop pauses
- An auto-paused ride's segments behave exactly like manual ones
- Distance still never spans the gap
## Risks
Building it because other apps have it, rather than because the data says so. The gate
exists for that reason.
## Out of scope
Motion-sensor detection. That is the paid-engine feature set, and this app deliberately
does not use it.

View File

@@ -0,0 +1,137 @@
# V3-16 — Visual identity
**Phase** Quality · **Depends on** V3-04, V3-05 · **Size** M · **Status** Partially done
## Direction (written before any code changed, per the ticket's own step 1)
**Palette.** Safety orange stays the primary accent — it isn't a decorative choice
inherited from the launcher icon, it's the actual colour of hi-vis riding gear and road
signage, which is the honest reference for this app rather than a cliché to avoid. What
changes: orange stops being the only signal colour. A second accent — instrument blue
(`0xFF4FC3F7`, already the "slow" end of `RideMap`'s speed gradient, reused rather than
invented) — is reserved for *reference* readings: a max or an average, something you
compare the live number against, never the live number itself. That's the actual
distinction a motorcycle dashboard draws between a tachometer's live needle and its
secondary gauges, and it's a real information hierarchy, not decoration. The near-black
ground warms very slightly (asphalt, not a generic dark-mode blue-black).
**Typography.** No new font family. Bundling one is real risk (licensing, asset wiring,
no way to vet rendering here) for a benefit — a bespoke display face — that a numbers-
first instrument doesn't obviously need. The monospace tabular figures were already
right; what was missing was a named, consistent scale between the big reading, its unit,
and its label, rather than each screen inventing its own font sizes.
**Data display.** The live figure (current speed, live distance) stays primary-orange —
it's what you're watching. Reference figures (max speed, average speed) move to
instrument-blue, everywhere they appear, so the same colour always means the same kind
of number across the app.
**Motion.** Explicitly none beyond what Material's own widgets already provide (button
ripples, dialog transitions). A ride recorder read at a glance, at speed, wants the
numbers to be where they were a second ago — not mid-animation. This is a decision, not
an oversight.
**Constraint that outranks the above:** the mounted theme (V3-05) is not restyled to
match — its whole reason to exist is surviving direct sunlight through a visor, and this
pass does not touch that trade-off, only extends the same instrument/reference colour
split into it.
## Goal
Move from "functional dark" to a look that is deliberately designed.
## Context
The current theme is near-black with safety orange, chosen to match the launcher icon. It
is clean and legible, and it was never actually *designed* — it was picked so the app did
not look unfinished.
**Deliberately sequenced after the live map and mounted mode.** Both change what the app
looks like far more than a palette does, and designing around screens that are about to
change is wasted effort.
## Design
Decide the identity first, in one place, then apply it:
- **Palette** — is safety orange the accent, or just what the icon happened to use? A
motorcycle app has obvious references (dashboard instruments, race liveries, road
signage) and obvious clichés to avoid.
- **Typography** — the app is numbers-first. The monospace tabular figures are already
right for that; the rest is undecided.
- **Data display** — the speed readout, the charts and the map legend are the identity far
more than any chrome. This is an instrument, not a document.
- **Motion** — currently none. A ride recorder probably wants very little.
**Constraint that outranks aesthetics:** legibility through a visor, in daylight, at a
glance. V3-05 may force a high-contrast variant, and the identity has to survive it.
## Implementation
1. Write the direction down — palette, type, and what the app is trying to feel like —
before touching code
2. Extend `ripprColors` into a fuller token set
3. Apply screen by screen, keeping `flutter test` green throughout
4. **Keep the explicit text colours.** The theme names `bodyColor` and `displayColor`
deliberately: a missing default once rendered a 64 sp figure black-on-black and only a
screenshot caught it. Do not regress that while restyling.
## Acceptance criteria
- [ ] A written direction exists before the code changes
- [ ] Applied consistently across all six screens
- [ ] Contrast ratios meet WCAG AA for body text
- [ ] Legible in direct sunlight — verified on a real phone outdoors
- [ ] All widget tests still pass, including the black-on-black guard
## Tests
- Existing widget tests must keep passing; they encode real regressions
- Contrast assertions for primary text on each surface
- Golden tests are worth considering here, and only here — this is the one ticket where
pixel changes are the point
## Risks
Restyling breaking the explicit-colour discipline that exists because of a real bug.
## Out of scope
A new app icon. The Route mark is good and recently applied.
## Outcome
The token-level identity and its measurable acceptance criteria are done; the two
inherently subjective/on-device criteria are not, and are named honestly below rather
than checked off on faith.
Implemented at `theme.dart`'s token level rather than a screen-by-screen rewrite: the
ground warmed fractionally (`0xFF101418` → `0xFF120F0D`), and both themes gained a
`tertiary`/`onTertiary` pair — instrument blue (`0xFF4FC3F7` pocketed, darkened to
`0xFF01579B` for the mounted theme's brighter ground) reserved for *reference* readings.
`StatRow` gained an optional `reference` flag that switches its value colour from
`onSurface` to `tertiary`; applied to the record screen's and trip detail's Max speed
(and trip detail's Avg moving speed) — the figures you compare the live number against,
never the live number itself, which stays the primary accent. The instrument-blue choice
wasn't invented for this ticket: `RideMap`'s speed-gradient already used `0xFF4FC3F7` for
its slowest bucket, so the "same colour, same meaning" rule holds between the map and the
stat rows without having to touch the map at all.
`contrastRatio(Color, Color)` implements WCAG 2.x's formula directly against
`Color.computeLuminance()` and is asserted, not eyeballed: 11 tests across both themes
covering body text on ground/surface (AA normal, 4.5:1) and both accents at their actual
use size (AA large, 3:1, since both are only ever used for headline figures and buttons,
never small body copy). Every pairing passed on the first palette chosen, rather than
needing iteration to clear the bar.
**Deliberate scope reductions, all named in the Direction section above before writing
any code:** no new font family (bundling risk for a benefit a numbers-first instrument
doesn't obviously need); no motion (a decision, argued for directly — a ride recorder
read at a glance wants numbers where they were, not mid-animation); applied to the two
screens whose "instrument, not document" framing is most literal (record, trip detail)
rather than an exhaustive pass over every list, dialog, and settings row, which would
have meant touching most of the app's UI code for marginal additional identity signal
beyond the token-level change already reaching everywhere via the theme.
**Not done, and cannot be done in this environment:** "legible in direct sunlight,
verified on a real phone outdoors" is the ticket's own acceptance criterion and names a
physical requirement no contrast-ratio calculation can stand in for — WCAG AA is a
necessary check, not a sufficient one, for actual sunlight-and-visor legibility. Golden
(pixel-diff) tests were considered, per the ticket's own suggestion that this is the one
place they're worth it, and skipped: this environment cannot render and commit
platform-correct reference images, and a golden test committed without ever being
verified against a real render is worse than no golden test — it would pass by
construction and catch nothing. `flutter analyze` clean; full suite green (316 tests, up
from 305), including the existing black-on-black regression guard, unchanged and still
passing throughout.

View File

@@ -0,0 +1,88 @@
# V3-17 — Self-hosted OSRM: investigate and stand one up
**Phase** Infrastructure · **Depends on** nothing · **Size** M · **Blocks** V3-08, V3-09 ·
**Status** Not started
## Goal
Get a self-hosted OSRM instance running somewhere, so V3-08 (road-snapped routing and
ETA) has a real backend to build against instead of a deferred decision.
## Context
V3-08 named the choice of routing engine as "a decision that cannot be deferred," and
[the conversation that spawned this ticket](../v3/README.md) picked a direction without
picking a provider: **self-hosted OSRM**, over a hosted API (GraphHopper, Mapbox
Directions) or the public OSRM demo (explicitly not for production use).
**This ticket exists because that decision itself has a wrinkle worth naming up front:**
this whole v3 backlog's organizing principle, stated in `docs/BACKLOG.md`, is *"v3 is
everything that can be built with no server. v4 is everything that cannot."* A
self-hosted OSRM instance is a server. Strictly, that makes V3-08 and V3-09 — anything
that depends on this ticket — v4 work by the project's own definition, not v3, even
though they're filed under `docs/v3/` today and the routing itself has nothing to do
with the group-rides/accounts/backup programme that currently defines v4. Whether to
formally renumber them is a documentation decision for whoever picks this up next; this
ticket does not resolve it, only flags it so it isn't silently glossed over.
## Design
Two separable questions:
1. **Where does it run?** A small VPS (the same shape of box that would eventually host
the v4 group-ride server, so this could double as an early step toward that) versus
something serverless/managed. OSRM's own Docker image is the standard path either way.
2. **What data does it need?** A regional OSM extract, not the planet — start with
whatever region actually gets ridden (per `docs/LAUNCH.md`, this is presently a
friends-and-family app, so the region is small and known). [Geofabrik](https://download.geofabrik.de/)
publishes regional `.osm.pbf` extracts sized for exactly this.
Profiles matter for this app specifically (see V3-08's Design section): a motorcycle
route and a bicycle route between the same two pins should genuinely differ. OSRM ships
car/bike/foot profiles out of the box; a motorcycle profile is closer to car (mostly
avoids the walk-only restrictions bike profiles impose) but might want the twisty-road
preference a stock car profile doesn't have reason to express. Confirming that is part of
this ticket's investigation, not something to guess at now.
## Implementation
1. Pick and provision a host (see Design's first question)
2. Download and preprocess a regional extract with OSRM's own toolchain
(`osrm-extract` → `osrm-partition` → `osrm-customize`, or the older
`osrm-contract` pipeline depending on the OSRM version chosen)
3. Run `osrm-routed` behind whatever the host offers for TLS termination — the app will
be calling this over the public internet from riders' phones, so plain HTTP is not
an option
4. Confirm at least a car-equivalent and a bike profile both return sane routes for a
handful of real local pin pairs, by hand, before writing any app code against it
5. Write the resulting base URL and auth (if any) down for V3-08 to consume — as
configuration, never a hardcoded value, matching V3-08's own "no API key committed to
the repository" acceptance criterion, which applies here too even though there's no
third-party vendor to protect a key from — an open, unauthenticated routing endpoint
is still worth not publishing in a public repo
6. A basic uptime check of some kind — this becomes a real dependency the app relies on,
not a fire-and-forget script
## Acceptance criteria
- [ ] An OSRM instance is reachable over HTTPS from outside the host network
- [ ] Returns a road-following route for a real pin pair in the region actually ridden
- [ ] At least two distinct profiles (car-equivalent, bike) both work
- [ ] The endpoint and any credentials live in configuration, not source
- [ ] Documented: what's running, where, how to update the extract when it goes stale,
and what it costs (if anything) to keep running
## Tests
Infrastructure, not app code — no `flutter test` coverage belongs to this ticket
directly. V3-08's own `FakeRoutingService`-driven tests are what verify the app's
behavior; this ticket's job is only to make the real thing exist for that fake to stand
in for.
## Risks
- **Ongoing hosting cost and maintenance**, however small — this is the first piece of
always-on infrastructure this project has taken on. Worth being honest that "no
server" stopped being true the moment this ticket is picked up, regardless of which
numbering bucket it ends up filed under.
- **OSM extracts go stale.** A road that didn't exist at extract time won't route.
Needs a refresh cadence, not a one-time setup.
- Regional extracts are cheap; do not reach for a planet-wide extract preemptively.
## Out of scope
Actually building V3-08/V3-09 against this once it exists — that's their ticket, not
this one. Turn-by-turn navigation, traffic-aware routing, anything beyond what stock OSRM
gives you.