Break v3 into 16 tickets

One file per feature, in the v2 shape that worked: goal, context, design,
implementation, acceptance criteria, tests, risks, out of scope -- written before
implementing so the risks are on paper rather than walked into.

Three carry more weight than their size suggests. V3-01 ships the port's first
real migration, and since the destructive fallback is gone, getting addColumn and
a v1-database test right matters more than the feature. V3-08 forces a
routing-engine decision with ongoing cost, so it sits behind a RoutingService
interface mirroring what LocationSource did for GPS. V3-13 is not code at all --
it answers the three questions open since v2, and V3-15 may close unbuilt as a
result, which is a legitimate outcome.

Several tickets record constraints that are easy to lose: no activity picker in
front of Start, because the founding premise is press-and-go with gloves; the
Route-to-Trip foreign key must not cascade, or deleting an old plan deletes the
ride; V3-14 will deliberately break the parity harness by adding GPX <type>, and
that expectation should be updated rather than the check dropped; and V3-16 must
not regress the explicit text colours that exist because of the black-on-black bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 10:41:35 -05:00
parent 82754de9c4
commit 342a8f8382
19 changed files with 982 additions and 0 deletions

View File

@@ -72,6 +72,8 @@ speed-derived values, where Kotlin's 32-bit `Float` widens with artefacts Dart's
| Document | Contents | | Document | Contents |
|---|---| |---|---|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Why it is built this way, and what changed from the native app | | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Why it is built this way, and what changed from the native app |
| [docs/v3/](docs/v3/) | v3 tickets — one file per feature, ready to pick up |
| [docs/BACKLOG.md](docs/BACKLOG.md) | The v3/v4 reasoning behind those tickets |
| [docs/LAUNCH.md](docs/LAUNCH.md) | Getting from "on my phone" to the app stores, in stages, with costs | | [docs/LAUNCH.md](docs/LAUNCH.md) | Getting from "on my phone" to the app stores, in stages, with costs |
| [docs/port/IOS-VERIFICATION.md](docs/port/IOS-VERIFICATION.md) | What a Mac can prove about the iPhone build, and what cannot | | [docs/port/IOS-VERIFICATION.md](docs/port/IOS-VERIFICATION.md) | What a Mac can prove about the iPhone build, and what cannot |
| [docs/port/PLAN.md](docs/port/PLAN.md) | The 28-task migration plan | | [docs/port/PLAN.md](docs/port/PLAN.md) | The 28-task migration plan |

View File

@@ -18,6 +18,9 @@
Everything known-outstanding, with enough context to pick up cold. Nothing here is Everything known-outstanding, with enough context to pick up cold. Nothing here is
committed to — it is a menu, roughly ordered by value. committed to — it is a menu, roughly ordered by value.
**v3 is now broken out into tickets: [v3/README.md](v3/README.md).** This document stays
as the reasoning; the tickets are the executable form.
**v3 is everything that can be built with no server.** **v4 is everything that cannot.** **v3 is everything that can be built with no server.** **v4 is everything that cannot.**
That split is the most useful thing in this document: it means v3 can proceed indefinitely That split is the most useful thing in this document: it means v3 can proceed indefinitely
without anyone deciding to run infrastructure or hold other people's location data. without anyone deciding to run infrastructure or hold other people's location data.

72
docs/v3/README.md Normal file
View File

@@ -0,0 +1,72 @@
# 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 |
|---|---|---|---|
| [V3-01](V3-01-activity-type.md) | Activity type per ride | M | — |
| [V3-02](V3-02-settings-screen.md) | Settings screen | S | — |
| [V3-03](V3-03-units.md) | Distance and speed units | S | V3-02 |
| [V3-04](V3-04-live-map.md) | Live map on the recording screen | M | — |
| [V3-05](V3-05-mounted-mode.md) | Mounted (handlebar) mode | M | V3-04 |
| [V3-06](V3-06-notification-stats.md) | Live stats in the notification | S | — |
| [V3-07](V3-07-route-drawing.md) | Route drawing (pins, straight lines) | M | — |
| [V3-08](V3-08-road-routing.md) | Road-snapped routing and ETA | L | V3-07, V3-01 |
| [V3-09](V3-09-route-following.md) | Follow a planned route | M | V3-04, V3-08 |
| [V3-10](V3-10-trip-splitting.md) | Trip splitting | S | — |
| [V3-11](V3-11-offline-tiles.md) | Offline tile pre-download | M | V3-04 |
| [V3-12](V3-12-crash-reporting.md) | Crash reporting | S | — |
| [V3-13](V3-13-real-ride-measurements.md) | Real-ride measurements | M | **riding** |
| [V3-14](V3-14-gpx-interop.md) | GPX interoperability | S | V3-01 |
| [V3-15](V3-15-auto-pause.md) | Auto-pause | M | V3-13 *(gated)* |
| [V3-16](V3-16-visual-identity.md) | Visual identity | M | V3-04, V3-05 |
## Dependencies
```
V3-01 ──┬────────────► V3-08 ──► V3-09
└──► V3-14 ▲
V3-07 ──────► 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
```
## 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.
**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,69 @@
# V3-01 — Activity type per ride
**Phase** Foundations · **Depends on** nothing · **Size** M · **Status** Not started
## 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).

View File

@@ -0,0 +1,49 @@
# V3-02 — Settings screen
**Phase** Foundations · **Depends on** nothing (pairs with V3-01, V3-03) · **Size** S · **Status** Not started
## 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).

50
docs/v3/V3-03-units.md Normal file
View File

@@ -0,0 +1,50 @@
# V3-03 — Distance and speed units
**Phase** Foundations · **Depends on** V3-02 · **Size** S · **Status** Not started
## 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.

62
docs/v3/V3-04-live-map.md Normal file
View File

@@ -0,0 +1,62 @@
# V3-04 — Live map on the recording screen
**Phase** Live map · **Depends on** nothing · **Size** M · **Status** Not started
## 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).

View File

@@ -0,0 +1,53 @@
# V3-05 — Mounted (handlebar) mode
**Phase** Live map · **Depends on** V3-04 · **Size** M · **Status** Not started
## 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.

View File

@@ -0,0 +1,52 @@
# V3-06 — Live stats in the notification
**Phase** Live map · **Depends on** nothing · **Size** S · **Status** Not started
## 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.

View File

@@ -0,0 +1,58 @@
# V3-07 — Route drawing (pins and straight lines)
**Phase** Route planning · **Depends on** nothing · **Size** M · **Status** Not started
## 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.

View File

@@ -0,0 +1,76 @@
# V3-08 — Road-snapped routing and ETA
**Phase** Route planning · **Depends on** V3-07, benefits from V3-01 · **Size** L · **Status** Not started
## 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.
## 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 · **Size** M · **Status** Not started
## 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,53 @@
# V3-10 — Trip splitting
**Phase** Ride management · **Depends on** nothing · **Size** S · **Status** Not started
## 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.

View File

@@ -0,0 +1,51 @@
# V3-11 — Offline tile pre-download
**Phase** Ride management · **Depends on** V3-04 · **Size** M · **Status** Not started
## 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.

View File

@@ -0,0 +1,52 @@
# V3-12 — Crash reporting
**Phase** Quality · **Depends on** nothing · **Size** S · **Status** Not started
## 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.

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,51 @@
# V3-14 — GPX interoperability
**Phase** Quality · **Depends on** V3-01 for `<type>` · **Size** S · **Status** Not started
## 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.

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,58 @@
# V3-16 — Visual identity
**Phase** Quality · **Depends on** V3-04, V3-05 · **Size** M · **Status** Not started
## 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.