From 342a8f8382710f9a8351a31e5b6149554532abf9 Mon Sep 17 00:00:00 2001 From: Dylan Date: Mon, 17 Aug 2026 10:41:35 -0500 Subject: [PATCH] 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 , 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 --- README.md | 2 + docs/BACKLOG.md | 3 + docs/v3/README.md | 72 +++++++++++++++++++++++ docs/v3/V3-01-activity-type.md | 69 ++++++++++++++++++++++ docs/v3/V3-02-settings-screen.md | 49 ++++++++++++++++ docs/v3/V3-03-units.md | 50 ++++++++++++++++ docs/v3/V3-04-live-map.md | 62 ++++++++++++++++++++ docs/v3/V3-05-mounted-mode.md | 53 +++++++++++++++++ docs/v3/V3-06-notification-stats.md | 52 +++++++++++++++++ docs/v3/V3-07-route-drawing.md | 58 +++++++++++++++++++ docs/v3/V3-08-road-routing.md | 76 +++++++++++++++++++++++++ docs/v3/V3-09-route-following.md | 53 +++++++++++++++++ docs/v3/V3-10-trip-splitting.md | 53 +++++++++++++++++ docs/v3/V3-11-offline-tiles.md | 51 +++++++++++++++++ docs/v3/V3-12-crash-reporting.md | 52 +++++++++++++++++ docs/v3/V3-13-real-ride-measurements.md | 61 ++++++++++++++++++++ docs/v3/V3-14-gpx-interop.md | 51 +++++++++++++++++ docs/v3/V3-15-auto-pause.md | 57 +++++++++++++++++++ docs/v3/V3-16-visual-identity.md | 58 +++++++++++++++++++ 19 files changed, 982 insertions(+) create mode 100644 docs/v3/README.md create mode 100644 docs/v3/V3-01-activity-type.md create mode 100644 docs/v3/V3-02-settings-screen.md create mode 100644 docs/v3/V3-03-units.md create mode 100644 docs/v3/V3-04-live-map.md create mode 100644 docs/v3/V3-05-mounted-mode.md create mode 100644 docs/v3/V3-06-notification-stats.md create mode 100644 docs/v3/V3-07-route-drawing.md create mode 100644 docs/v3/V3-08-road-routing.md create mode 100644 docs/v3/V3-09-route-following.md create mode 100644 docs/v3/V3-10-trip-splitting.md create mode 100644 docs/v3/V3-11-offline-tiles.md create mode 100644 docs/v3/V3-12-crash-reporting.md create mode 100644 docs/v3/V3-13-real-ride-measurements.md create mode 100644 docs/v3/V3-14-gpx-interop.md create mode 100644 docs/v3/V3-15-auto-pause.md create mode 100644 docs/v3/V3-16-visual-identity.md diff --git a/README.md b/README.md index db69760..8ecbfcb 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ speed-derived values, where Kotlin's 32-bit `Float` widens with artefacts Dart's | Document | Contents | |---|---| | [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/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 | diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 494273d..50993e7 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -18,6 +18,9 @@ Everything known-outstanding, with enough context to pick up cold. Nothing here is 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.** 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. diff --git a/docs/v3/README.md b/docs/v3/README.md new file mode 100644 index 0000000..f74690c --- /dev/null +++ b/docs/v3/README.md @@ -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. diff --git a/docs/v3/V3-01-activity-type.md b/docs/v3/V3-01-activity-type.md new file mode 100644 index 0000000..c80133c --- /dev/null +++ b/docs/v3/V3-01-activity-type.md @@ -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 `` export (see V3-14). diff --git a/docs/v3/V3-02-settings-screen.md b/docs/v3/V3-02-settings-screen.md new file mode 100644 index 0000000..7d2b235 --- /dev/null +++ b/docs/v3/V3-02-settings-screen.md @@ -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). diff --git a/docs/v3/V3-03-units.md b/docs/v3/V3-03-units.md new file mode 100644 index 0000000..772b781 --- /dev/null +++ b/docs/v3/V3-03-units.md @@ -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. diff --git a/docs/v3/V3-04-live-map.md b/docs/v3/V3-04-live-map.md new file mode 100644 index 0000000..d537fce --- /dev/null +++ b/docs/v3/V3-04-live-map.md @@ -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). diff --git a/docs/v3/V3-05-mounted-mode.md b/docs/v3/V3-05-mounted-mode.md new file mode 100644 index 0000000..80132d4 --- /dev/null +++ b/docs/v3/V3-05-mounted-mode.md @@ -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. diff --git a/docs/v3/V3-06-notification-stats.md b/docs/v3/V3-06-notification-stats.md new file mode 100644 index 0000000..5b357de --- /dev/null +++ b/docs/v3/V3-06-notification-stats.md @@ -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. diff --git a/docs/v3/V3-07-route-drawing.md b/docs/v3/V3-07-route-drawing.md new file mode 100644 index 0000000..82e8607 --- /dev/null +++ b/docs/v3/V3-07-route-drawing.md @@ -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. diff --git a/docs/v3/V3-08-road-routing.md b/docs/v3/V3-08-road-routing.md new file mode 100644 index 0000000..13e04ef --- /dev/null +++ b/docs/v3/V3-08-road-routing.md @@ -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. diff --git a/docs/v3/V3-09-route-following.md b/docs/v3/V3-09-route-following.md new file mode 100644 index 0000000..cc50459 --- /dev/null +++ b/docs/v3/V3-09-route-following.md @@ -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. diff --git a/docs/v3/V3-10-trip-splitting.md b/docs/v3/V3-10-trip-splitting.md new file mode 100644 index 0000000..6378df9 --- /dev/null +++ b/docs/v3/V3-10-trip-splitting.md @@ -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. diff --git a/docs/v3/V3-11-offline-tiles.md b/docs/v3/V3-11-offline-tiles.md new file mode 100644 index 0000000..76553e7 --- /dev/null +++ b/docs/v3/V3-11-offline-tiles.md @@ -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. diff --git a/docs/v3/V3-12-crash-reporting.md b/docs/v3/V3-12-crash-reporting.md new file mode 100644 index 0000000..d99be28 --- /dev/null +++ b/docs/v3/V3-12-crash-reporting.md @@ -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. diff --git a/docs/v3/V3-13-real-ride-measurements.md b/docs/v3/V3-13-real-ride-measurements.md new file mode 100644 index 0000000..7f6fb90 --- /dev/null +++ b/docs/v3/V3-13-real-ride-measurements.md @@ -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. diff --git a/docs/v3/V3-14-gpx-interop.md b/docs/v3/V3-14-gpx-interop.md new file mode 100644 index 0000000..7ef8781 --- /dev/null +++ b/docs/v3/V3-14-gpx-interop.md @@ -0,0 +1,51 @@ +# V3-14 — GPX interoperability + +**Phase** Quality · **Depends on** V3-01 for `` · **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: `` per segment is the correct GPX representation, but consumers +vary in whether they honour it. + +**`` on ``** — 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 `` to the `` 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 `` changes the output** + +## Tests +- `` 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. diff --git a/docs/v3/V3-15-auto-pause.md b/docs/v3/V3-15-auto-pause.md new file mode 100644 index 0000000..4c99eb2 --- /dev/null +++ b/docs/v3/V3-15-auto-pause.md @@ -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. diff --git a/docs/v3/V3-16-visual-identity.md b/docs/v3/V3-16-visual-identity.md new file mode 100644 index 0000000..fbfc942 --- /dev/null +++ b/docs/v3/V3-16-visual-identity.md @@ -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.