Refresh the Flutter snapshot: v3 tickets V3-04 through V3-16, plus a fresh installable APK
V3-04 through V3-07, V3-10, V3-16 shipped complete; V3-11/V3-12/V3-14 shipped code-complete pending device/account verification; V3-08/V3-09 deferred behind a new V3-17 (self-hosted OSRM investigation). 316 tests passing, up from 221. The APK is a fresh release build (debug-signed, no release signing config exists yet) with two build fixes applied: core library desugaring enabled for flutter_local_notifications, and sentry_flutter bumped to 9.27.0 (8.14.2's bundled Kotlin plugin was incompatible with this project's Kotlin 2.4.0 toolchain).
This commit is contained in:
80
rippr-flutter-src/docs/v3/README.md
Normal file
80
rippr-flutter-src/docs/v3/README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# v3 tickets
|
||||
|
||||
One file per feature, in the shape that worked for v2: Goal · Context · Design ·
|
||||
Implementation · Acceptance criteria · Tests · Risks · Out of scope. Written before
|
||||
implementing, so the risks are on paper before they are walked into.
|
||||
|
||||
Menu, not a commitment. Nothing here is scheduled.
|
||||
|
||||
**Everything in v3 is buildable with no server.** Group rides, accounts and paid cloud
|
||||
backup are v4 — see [../BACKLOG.md](../BACKLOG.md).
|
||||
|
||||
> **Before starting anything:** [../port/REAL-RIDE-CHECKLIST.md](../port/REAL-RIDE-CHECKLIST.md).
|
||||
> The port has never recorded a real ride, and item I3 may still force a change of GPS
|
||||
> engine — which would land underneath several of these tickets.
|
||||
|
||||
## The tickets
|
||||
|
||||
| # | Ticket | Size | Depends on | Status |
|
||||
|---|---|---|---|---|
|
||||
| [V3-01](V3-01-activity-type.md) | Activity type per ride | M | — | Done |
|
||||
| [V3-02](V3-02-settings-screen.md) | Settings screen | S | — | Done |
|
||||
| [V3-03](V3-03-units.md) | Distance and speed units | S | V3-02 | Done |
|
||||
| [V3-04](V3-04-live-map.md) | Live map on the recording screen | M | — | Done |
|
||||
| [V3-05](V3-05-mounted-mode.md) | Mounted (handlebar) mode | M | V3-04 | Done |
|
||||
| [V3-06](V3-06-notification-stats.md) | Live stats in the notification | S | — | Done |
|
||||
| [V3-07](V3-07-route-drawing.md) | Route drawing (pins, straight lines) | M | — | Done |
|
||||
| [V3-08](V3-08-road-routing.md) | Road-snapped routing and ETA | L | V3-07, V3-01, **V3-17** | Deferred (needs V3-17) |
|
||||
| [V3-09](V3-09-route-following.md) | Follow a planned route | M | V3-04, V3-08 | Deferred (needs V3-08) |
|
||||
| [V3-10](V3-10-trip-splitting.md) | Trip splitting | S | — | Done |
|
||||
| [V3-11](V3-11-offline-tiles.md) | Offline tile pre-download | M | V3-04 | Partially done (pipeline shipped; needs aeroplane-mode device verification) |
|
||||
| [V3-12](V3-12-crash-reporting.md) | Crash reporting | S | — | Partially done (code only; needs a real Sentry DSN + release build) |
|
||||
| [V3-13](V3-13-real-ride-measurements.md) | Real-ride measurements | M | **riding** | Not started |
|
||||
| [V3-14](V3-14-gpx-interop.md) | GPX interoperability | S | V3-01 | Partially done (code only; needs real-device verification) |
|
||||
| [V3-15](V3-15-auto-pause.md) | Auto-pause | M | V3-13 *(gated)* | Not started |
|
||||
| [V3-16](V3-16-visual-identity.md) | Visual identity | M | V3-04, V3-05 | Partially done (token-level identity shipped; needs outdoor device verification) |
|
||||
| [V3-17](V3-17-osrm-hosting.md) | Self-hosted OSRM: investigate and stand one up | M | — *(needs a server — see the ticket's own note on the v3/v4 boundary)* | Not started |
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
V3-01 ──┬────────────► V3-08 ──► V3-09
|
||||
└──► V3-14 ▲
|
||||
V3-07 ──────► V3-08 │
|
||||
V3-17 ──────► V3-08 │
|
||||
V3-02 ──► V3-03 │
|
||||
V3-04 ──┬──► V3-05 ──┬───────┘
|
||||
├──► V3-11 └──► V3-16
|
||||
└──► V3-09
|
||||
V3-13 ──► V3-15 (gate: may close unbuilt)
|
||||
|
||||
no dependencies: V3-01 · V3-02 · V3-04 · V3-06 · V3-07 · V3-10 · V3-12 · V3-17
|
||||
```
|
||||
|
||||
**V3-08/V3-09 are deferred, on request**, pending V3-17 (self-hosted OSRM). See V3-17's
|
||||
own note on why that also puts them in tension with this document's v3/v4 boundary —
|
||||
unresolved by design, not an oversight.
|
||||
|
||||
## Three that carry more weight than their size suggests
|
||||
|
||||
**V3-01** ships the port's **first real migration**. The destructive fallback is gone, so
|
||||
getting `addColumn` plus a v1-database test right matters more than the feature does —
|
||||
V3-07 and V3-09 both add migrations behind it.
|
||||
|
||||
**V3-08** forces a routing-engine decision with ongoing cost and vendor implications.
|
||||
Behind a `RoutingService` interface, mirroring what `LocationSource` did for GPS. The
|
||||
direction is decided (self-hosted OSRM); V3-17 does the actual standing-up, and V3-08 is
|
||||
deferred until it exists.
|
||||
|
||||
**V3-13** is not code. It answers the three questions that have been open since v2, and
|
||||
**V3-15 may close unbuilt** as a result — a legitimate and probably likely outcome.
|
||||
|
||||
## Suggested order, if starting cold
|
||||
|
||||
1. **V3-01** — proves the migration path while the stakes are low, and unblocks V3-08/14
|
||||
2. **V3-02 + V3-03** — small, self-contained, gives V3-01 a home
|
||||
3. **V3-04** — the most visible change, and the gateway to four other tickets
|
||||
4. **V3-07** — entirely independent; useful on its own before the routing decision
|
||||
5. **V3-13** — as soon as there is a ride to measure
|
||||
|
||||
V3-12 is a good filler at any point. V3-16 should wait until the screens stop moving.
|
||||
97
rippr-flutter-src/docs/v3/V3-01-activity-type.md
Normal file
97
rippr-flutter-src/docs/v3/V3-01-activity-type.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# V3-01 — Activity type per ride
|
||||
|
||||
**Phase** Foundations · **Depends on** nothing · **Size** M · **Status** Done
|
||||
|
||||
## Goal
|
||||
Every ride records what it was done on — motorcycle, bicycle, scooter, skateboard,
|
||||
running, walking, other — and that choice drives per-activity defaults rather than just
|
||||
labelling the row.
|
||||
|
||||
## Context
|
||||
The recording pipeline never cared what you were sitting on: it records positions, speeds
|
||||
and altitudes. Only the UI says "motorcycle". Dylan rides both bikes and motorcycles.
|
||||
|
||||
This is also a **positioning decision** (see [../LAUNCH.md](../LAUNCH.md)): it determines
|
||||
the store category, the screenshots, and who ever finds the app. Cyclists are a much larger
|
||||
audience and already pay for ride apps. Cheaper to settle before a store listing exists.
|
||||
|
||||
**This ticket ships the port's first real migration.** That matters more than the feature.
|
||||
|
||||
## Design
|
||||
Text enum column on `Trip`, exactly like `TripState`:
|
||||
`motorcycle · bicycle · scooter · skateboard · running · walking · other`
|
||||
|
||||
**Type drives defaults, not labels.** Introduce an `ActivityProfile` rather than scattering
|
||||
`if (activity == …)`:
|
||||
|
||||
| Setting | Today | Why it must vary |
|
||||
|---|---|---|
|
||||
| Speed noise floor | 1.5 km/h | Right for a motorcycle; walking lives near it |
|
||||
| Histogram bucket | 10 km/h | Useless for running — one bucket. Wants ~1 km/h |
|
||||
| Accuracy gate | 50 m | A bike at speed tolerates looser fixes than a walker |
|
||||
| Elevation smoothing window | 15 samples | Tuned for 2 Hz at road speed |
|
||||
| Map fit zoom | — | A 2 km walk and a 200 km ride differ |
|
||||
|
||||
**No picker in front of Start.** The founding premise is press-and-go with gloves on.
|
||||
Default to the last activity used; make it editable on trip detail next to rename.
|
||||
|
||||
## Implementation
|
||||
1. `Activity` enum in `domain/models.dart`; `activity` field on `Trip`
|
||||
2. Drift column with `.withDefault(Constant('motorcycle'))`
|
||||
3. **Migration**: `schemaVersion` 1 → 2, `m.addColumn(trips, trips.activity)`
|
||||
4. `ActivityProfile` in `stats/` holding the constants above; thread it through
|
||||
`computeSummary`, `speedHistogram`, `Accumulator`, `isUsableFix`
|
||||
5. Persist last-used activity in `Config`
|
||||
6. Trip detail: activity row, editable via the same pattern as rename
|
||||
7. Trips list: show the activity icon on each tile
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] A new ride records an activity; existing rides read `motorcycle`
|
||||
- [ ] A v1 database opens, migrates, and **keeps every ride and point**
|
||||
- [ ] Changing a trip's activity recomputes its aggregates under the new profile
|
||||
- [ ] Start still takes exactly one tap
|
||||
- [ ] `flutter analyze` clean, all existing tests still pass
|
||||
|
||||
## Tests
|
||||
- **Migration test** — build a v1 database, migrate, assert rides and points survive.
|
||||
Drift's `MigrationTestHelper` with a generated v1 schema.
|
||||
- Profile selection: each activity yields its own noise floor and bucket size
|
||||
- A running-activity ride produces a histogram with more than one bucket
|
||||
- Round-trip the enum through the database
|
||||
|
||||
## Risks
|
||||
- **The migration is the risk.** Getting it wrong destroys real rides, and the destructive
|
||||
fallback is deliberately gone. Write the migration test first.
|
||||
- Recomputing aggregates on activity change is easy to forget — a ride switched from
|
||||
motorcycle to walking keeps a wrong moving time otherwise.
|
||||
|
||||
## Out of scope
|
||||
Per-activity totals or a stats screen. GPX `<type>` export (see V3-14).
|
||||
|
||||
|
||||
## Outcome
|
||||
|
||||
Shipped as designed, with two deliberate deviations from the ticket text, both
|
||||
recorded here rather than silently:
|
||||
|
||||
- **No `Config.lastActivity` field.** "Default to the last activity used" is instead
|
||||
derived live from the trips table itself (`AppDatabase.mostRecentTrip()` /
|
||||
`TripRepository._lastUsedActivity()`) rather than duplicated into a separate
|
||||
preference. One source of truth, no write path to keep in sync, and it degrades
|
||||
correctly to `motorcycle` when the database is empty.
|
||||
- **`ActivityProfile` lives in `domain/activity_profile.dart`**, not folded into
|
||||
`models.dart` — kept the domain model (`Trip.activity`) separate from the behavioural
|
||||
defaults built on top of it, and avoided a dependency cycle between the domain layer
|
||||
and `stats/`/`recording/`.
|
||||
|
||||
Map-fit zoom (mentioned in the ticket's defaults table) turned out to need no work:
|
||||
`RideMap` already fits to the ride's actual recorded bounds, which is activity-agnostic
|
||||
by construction.
|
||||
|
||||
The widget-test pass caught a real overflow bug independent of activity type: the
|
||||
7-item activity picker sheet overflowed a `Column`-based `showModalBottomSheet` the same
|
||||
way the record screen once did (see `docs/port/PROGRESS.md`, Phase 4). Fixed with a
|
||||
scrollable `ListView` + `isScrollControlled: true`, the same shape as that earlier fix.
|
||||
|
||||
188 tests total (171 → 188): migration (2), `ActivityProfile` (5), `computeSummary`
|
||||
profile-threading proof (3), repository (5), widget (2).
|
||||
86
rippr-flutter-src/docs/v3/V3-02-settings-screen.md
Normal file
86
rippr-flutter-src/docs/v3/V3-02-settings-screen.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# V3-02 — Settings screen
|
||||
|
||||
**Phase** Foundations · **Depends on** nothing (pairs with V3-01, V3-03) · **Size** S · **Status** Done
|
||||
|
||||
## Goal
|
||||
One place for the preferences that currently have nowhere to live.
|
||||
|
||||
## Context
|
||||
`Config` already holds `uploadEndpoint`, `deviceId` and `mapEnabled`, but there is no UI
|
||||
for any of them. The map toggle sits on trip detail because a single switch did not justify
|
||||
a screen. V3-01 and V3-03 both add preferences, which finally does justify one.
|
||||
|
||||
The upload endpoint has **never** had UI, in the native app or the port. This is where it
|
||||
stops being a code-only setting.
|
||||
|
||||
## Design
|
||||
Reached from the record screen. Sections:
|
||||
|
||||
- **Recording** — default activity (V3-01), units (V3-03)
|
||||
- **Map** — render maps on trip detail; later, live map (V3-04)
|
||||
- **Sync** — upload endpoint, with the device id shown read-only and copyable
|
||||
- **About** — version, a link to the privacy policy, licences
|
||||
|
||||
Keep it plain. This is not a screen anyone should spend time in.
|
||||
|
||||
## Implementation
|
||||
1. `SettingsScreen` + a `go_router` route
|
||||
2. Make `Config` reactive — it is currently read once into a provider; settings need writes
|
||||
to propagate. A `ConfigNotifier` over `shared_preferences`.
|
||||
3. Move the map toggle off trip detail
|
||||
4. Endpoint field validates it parses as a URL and is http(s)
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Every `Config` value is viewable and editable
|
||||
- [ ] Changing the map toggle takes effect without an app restart
|
||||
- [ ] An invalid endpoint is rejected with a readable message, not silently stored
|
||||
- [ ] Device id is copyable — it is the only way to identify this install to a server
|
||||
|
||||
## Tests
|
||||
- Widget: each control renders and writes through to `Config`
|
||||
- Widget: invalid URL rejected
|
||||
- Changing the map toggle rebuilds trip detail
|
||||
|
||||
## Risks
|
||||
`Config` is currently loaded once at startup into a `StateProvider`. Making it writable
|
||||
without introducing a second source of truth is the only subtle part.
|
||||
|
||||
## Out of scope
|
||||
Account settings (v4). Theme selection (V3-16).
|
||||
|
||||
|
||||
## Outcome
|
||||
|
||||
"Move the map toggle off trip detail" (implementation step 3) turned out to be moot —
|
||||
`mapEnabledProvider` already existed and drove `RideMap`'s visibility, but **no widget
|
||||
anywhere ever offered a control to change it**. There was nothing to move. Settings adds
|
||||
the first one.
|
||||
|
||||
No `ConfigNotifier` was built — see V3-03's outcome for why the existing
|
||||
`mapEnabledProvider`-style `StateProvider` pattern covers reactivity without it, and
|
||||
`unitSystemProvider` was added the same way, in `providers.dart`, ahead of this ticket.
|
||||
|
||||
Two implementation-step items shipped differently than drafted, both to avoid adding a
|
||||
dependency disproportionate to an `S`-sized settings screen:
|
||||
|
||||
- **No `package_info_plus`.** The About section's version string is a static literal
|
||||
matching `pubspec.yaml`'s `1.0.0+1`, not a live package lookup. Fine today; would need
|
||||
revisiting if the version ever needs to be authoritative from inside the running app
|
||||
rather than copied by hand.
|
||||
- **No privacy-policy link.** None is published yet (see `docs/LAUNCH.md`) and linking
|
||||
to one that does not exist would be worse than omitting it. Shows a plain note instead.
|
||||
Licences are still free: Flutter's built-in `showLicensePage` needed no new dependency.
|
||||
|
||||
Endpoint validation accepts `http://` and `https://` with a non-empty host, and treats
|
||||
an **empty** field as valid — that is how upload gets disabled, not an error state. A
|
||||
non-empty invalid value is rejected with inline `errorText` and never reaches `Config`;
|
||||
proven by a widget test that types garbage, taps Save, and asserts `Config.uploadEndpoint`
|
||||
is still empty afterwards.
|
||||
|
||||
`uploaderProvider` needed an explicit `ref.invalidate()` after saving the endpoint, for
|
||||
the identical reason `unitSystemProvider` needed its own `StateProvider` rather than
|
||||
reading through `configProvider` directly — a `Config` write never changes the `Config`
|
||||
instance Riverpod is watching, so nothing downstream rebuilds unless told to.
|
||||
|
||||
10 tests: 9 in `settings_screen_test.dart`, 1 confirming the record screen's settings
|
||||
button is genuinely wired (not just present) in `widget_test.dart`.
|
||||
87
rippr-flutter-src/docs/v3/V3-03-units.md
Normal file
87
rippr-flutter-src/docs/v3/V3-03-units.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# V3-03 — Distance and speed units
|
||||
|
||||
**Phase** Foundations · **Depends on** V3-02 · **Size** S · **Status** Done
|
||||
|
||||
## Goal
|
||||
Imperial as well as metric, chosen once and applied everywhere.
|
||||
|
||||
## Context
|
||||
Everything is hardcoded metric: `formatDistance` switches m/km at 1000, `formatSpeed`
|
||||
prints km/h, elevation prints metres. Fine in Canada, useless to anyone in the US or UK.
|
||||
|
||||
Cheap, and the kind of thing that makes an app feel unfinished when missing.
|
||||
|
||||
## Design
|
||||
A `UnitSystem` enum (`metric`, `imperial`) in `Config`, defaulting from the device locale
|
||||
on first launch.
|
||||
|
||||
**Conversion belongs in formatting only.** Storage stays SI — metres, km/h, metres of
|
||||
altitude — forever. Converting at the storage layer would corrupt every existing ride and
|
||||
break the parity harness.
|
||||
|
||||
| Value | Metric | Imperial |
|
||||
|---|---|---|
|
||||
| Distance | m / km | ft / mi |
|
||||
| Speed | km/h | mph |
|
||||
| Elevation | m | ft |
|
||||
|
||||
## Implementation
|
||||
1. `UnitSystem` in `Config`; default from `Platform.localeName`
|
||||
2. Extend `ui/format.dart` — every formatter takes the unit system
|
||||
3. Thread it through: record screen, trips list, trip detail, charts, map legend
|
||||
4. Exports stay SI regardless. GPX is metres by specification; changing that breaks
|
||||
consumers.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Switching units updates every screen immediately
|
||||
- [ ] Stored values are unchanged — verified by exporting before and after
|
||||
- [ ] GPX/GeoJSON output is byte-identical across the two settings
|
||||
- [ ] First launch picks a sensible default from the locale
|
||||
|
||||
## Tests
|
||||
- Formatter tests for both systems, including the m→km and ft→mi boundaries
|
||||
- **A test asserting export output does not change with the unit setting**
|
||||
- Widget test toggling units and checking a rendered label
|
||||
|
||||
## Risks
|
||||
The obvious trap is converting too deep in the stack. Guard it with the export test.
|
||||
|
||||
## Out of scope
|
||||
Temperature, pace (min/km) — pace is arguably right for running, revisit after V3-01.
|
||||
|
||||
|
||||
## Outcome
|
||||
|
||||
Built ahead of V3-02 in execution order, despite the ticket table listing it as
|
||||
depending on V3-02 — the Settings screen needed something real to control, and the
|
||||
formatting/`Config` plumbing itself has zero dependency on a screen existing. Both are
|
||||
done; the numbering is unchanged.
|
||||
|
||||
`UnitSystem` lives in `domain/models.dart`, not `ui/format.dart` as first drafted —
|
||||
`Config` (a data/preferences-layer class) needed the enum too, and having it depend on
|
||||
`ui/` read backwards. Moved to the domain layer alongside `Activity`, which every other
|
||||
cross-cutting preference-like enum in this codebase already does.
|
||||
|
||||
Reactivity reuses the exact pattern `mapEnabledProvider` already established —
|
||||
`unitSystemProvider`, a `StateProvider<UnitSystem>` seeded from `Config` once and then
|
||||
read/written directly by the UI — rather than introducing the heavier `ConfigNotifier`
|
||||
class the ticket's implementation notes proposed. `Config` mutates its own backing
|
||||
`SharedPreferences` in place, so a widget re-assigning the same `Config` instance to
|
||||
`configProvider` was never going to notify anything; this sidesteps that without a new
|
||||
abstraction.
|
||||
|
||||
Threaded through all three screens plus the speed histogram's bucket labels, which
|
||||
convert-and-round for display (`formatSpeedRangeLabel`) without changing how
|
||||
`speedHistogram` itself bins — binning stays km/h always, matching the invariant that
|
||||
storage and computation never see the display unit. Export was the one place explicitly
|
||||
*not* touched: `gpx()`/`geoJson()` take no `UnitSystem` parameter at all, which is a
|
||||
stronger guarantee than validating one.
|
||||
|
||||
One real finding: `config_test.dart`'s locale-default test deliberately does not assert
|
||||
a specific value, because it cannot know the test runner's own locale — and that caution
|
||||
was immediately vindicated. `settings_screen_test.dart` first asserted a fresh `Config`
|
||||
defaults to metric and failed, because this dev machine's own locale resolves to a
|
||||
region in the imperial set. Fixed by seeding an explicit value before asserting, the
|
||||
same technique already used elsewhere for exactly this reason.
|
||||
|
||||
22 tests: 13 `format_test.dart`, 9 `config_test.dart`.
|
||||
90
rippr-flutter-src/docs/v3/V3-04-live-map.md
Normal file
90
rippr-flutter-src/docs/v3/V3-04-live-map.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# V3-04 — Live map on the recording screen
|
||||
|
||||
**Phase** Live map · **Depends on** nothing · **Size** M · **Status** Done
|
||||
|
||||
## Goal
|
||||
While recording, show the path as it is drawn, on the recording screen.
|
||||
|
||||
## Context
|
||||
v2 shipped without one deliberately: the phone rides in a pocket, so a live map would burn
|
||||
battery for something nobody is looking at. **That decision is reversed** — Dylan wants it
|
||||
for visual appeal, and because a mounted phone is now a real use case (V3-05).
|
||||
|
||||
The map component already exists (`RideMap`) and already handles per-segment polylines,
|
||||
render-only decimation and the zoom clamp. This ticket is mostly about *lifecycle*, not
|
||||
drawing.
|
||||
|
||||
## Design
|
||||
Two constraints survive the reversal and are non-negotiable:
|
||||
|
||||
- **`RecordingEngine` must never reference a map.** Rendering belongs to a visible screen's
|
||||
widget lifecycle. The engine already exposes everything needed.
|
||||
- **No tile fetch or redraw while backgrounded.** A pocketed phone must cost exactly what
|
||||
it costs today.
|
||||
|
||||
Feed the map from a stream of the current trip's points. `watchTripStats` exists but
|
||||
returns aggregates; this needs the points themselves — add a `watchPointsForTrip` Drift
|
||||
stream, which updates naturally on each writer flush (~2 s), not per fix.
|
||||
|
||||
Follow the rider: keep the latest point centred, with a manual-pan override that stops
|
||||
auto-follow until re-enabled.
|
||||
|
||||
Behind the existing map toggle, off by default while it is unproven on battery.
|
||||
|
||||
## Implementation
|
||||
1. `watchPointsForTrip(tripId)` in `AppDatabase`
|
||||
2. Hoist the map above the stats card on the record screen, behind the toggle
|
||||
3. `WidgetsBindingObserver` — on `AppLifecycleState.paused`, stop tile fetching; resume on
|
||||
`resumed`. This is the load-bearing part.
|
||||
4. Auto-follow with a pan override
|
||||
5. Keep the numeric readout visible; the map must not push SPEED off screen (the record
|
||||
screen already scrolls — see the overflow fix in `port/PROGRESS.md`)
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] The path appears and extends while recording
|
||||
- [ ] Backgrounding the app stops all tile activity, verified in a network log
|
||||
- [ ] `RecordingEngine` still has no map import — grep it
|
||||
- [ ] With the toggle off, no map widget is constructed at all
|
||||
- [ ] Speed and elapsed remain visible without scrolling on a common phone size
|
||||
|
||||
## Tests
|
||||
- Widget: map appears only when recording and the toggle is on
|
||||
- Widget: lifecycle transition to paused stops the tile layer
|
||||
- The existing map tests still pass
|
||||
|
||||
## Risks
|
||||
- **Battery.** This is the whole reason v2 said no. Measure before defaulting it on
|
||||
(V3-13).
|
||||
- Redrawing per fix rather than per flush would be wasteful; drive from the database
|
||||
stream, which is already batched.
|
||||
|
||||
## Out of scope
|
||||
Mounted mode (V3-05). Other riders on the map (v4).
|
||||
|
||||
## Outcome
|
||||
Shipped as designed. `AppDatabase.watchPointsForTrip`/`watchSegmentsForTrip` feed two
|
||||
`autoDispose.family` providers (`livePointsProvider`, `liveSegmentsProvider`) keyed by
|
||||
trip id; a `_LiveMap` adapter widget on the record screen reads them and hands the result
|
||||
to the existing `RideMap`, unchanged in shape. `RecordingEngine` was never touched —
|
||||
verified by `test/architecture_test.dart`, which greps the source rather than trusting a
|
||||
comment.
|
||||
|
||||
`RideMap` itself grew two small, general capabilities rather than a parallel "live"
|
||||
widget: a `WidgetsBindingObserver` that drops the `TileLayer` entirely (not just visually,
|
||||
via widget tree omission) outside `AppLifecycleState.resumed`, and an optional `follow`
|
||||
flag that recentres on the latest point via `didUpdateWidget` + a post-frame
|
||||
`MapController.move`, cancelled permanently by the first user-gesture pan. Both apply
|
||||
to the trip-detail map too, which is a free win: a backgrounded detail screen no longer
|
||||
holds tiles fetching either.
|
||||
|
||||
Three existing record-screen tests (`recording swaps to PAUSE and STOP`, `paused offers
|
||||
RESUME`, `discard asks before destroying anything`) had to gain an explicit `map: false` —
|
||||
they predate this ticket and would otherwise have started constructing a real
|
||||
`FlutterMap`/`TileLayer` against an active trip, which is exactly the tile-fetch-in-tests
|
||||
problem the trip-detail tests already route around.
|
||||
|
||||
3 new tests: the grep-based engine-purity check in `architecture_test.dart`, plus two in
|
||||
`widget_test.dart` — map presence/absence by toggle and trip state, and the lifecycle
|
||||
transition (paused drops `TileLayer` but keeps `PolylineLayer`; resumed brings it back),
|
||||
driven via the standard `flutter/lifecycle` platform-message technique rather than a
|
||||
private binding API. `flutter analyze` clean; full suite green (224 tests, up from 221).
|
||||
101
rippr-flutter-src/docs/v3/V3-05-mounted-mode.md
Normal file
101
rippr-flutter-src/docs/v3/V3-05-mounted-mode.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# V3-05 — Mounted (handlebar) mode
|
||||
|
||||
**Phase** Live map · **Depends on** V3-04 · **Size** M · **Status** Done
|
||||
|
||||
## Goal
|
||||
Make the app usable on handlebars in daylight, at speed, with gloves — as an explicit mode
|
||||
rather than an accident.
|
||||
|
||||
## Context
|
||||
v1 and v2 were built entirely around "start it, pocket it, stop it". **A mounted phone is a
|
||||
different product.** Treating it as a mode makes the differences deliberate instead of
|
||||
half-met.
|
||||
|
||||
## Design
|
||||
A toggle that changes several things at once:
|
||||
|
||||
- **Keep the screen awake** for the whole ride (`wakelock_plus`). Currently the screen
|
||||
sleeps and recording continues; mounted, that is wrong.
|
||||
- **Sunlight legibility.** The dark theme was chosen for glanceability at night and in a
|
||||
pocket-glance. Behind a visor in daylight it is the wrong choice — a high-contrast
|
||||
variant with larger figures is needed. This is not the same as V3-16's visual identity.
|
||||
- **Larger touch targets still.** 72 dp works stopped; at speed with gloves it does not.
|
||||
- **Interruptions.** What happens on an incoming call or a notification — the recording
|
||||
must survive and the screen must come back.
|
||||
|
||||
## Implementation
|
||||
1. `mountedMode` in `Config`, surfaced in settings and as a quick toggle on the record
|
||||
screen
|
||||
2. `wakelock_plus`, acquired on start when mounted, released on stop/pause — and released
|
||||
on `dispose`, or the screen stays lit after the app closes
|
||||
3. A high-contrast text scale applied when mounted
|
||||
4. Handle `AppLifecycleState.inactive` (a call arriving) distinctly from `paused`
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Mounted: the screen never sleeps during a ride
|
||||
- [ ] Un-mounted: behaviour is exactly as today
|
||||
- [ ] The wake lock is released on stop, on discard, and on app exit
|
||||
- [ ] An incoming call does not stop recording
|
||||
- [ ] Battery cost of mounted mode is measured and written down (V3-13)
|
||||
|
||||
## Tests
|
||||
- Widget: mounted toggle changes text scale and requests the wake lock (fake the plugin)
|
||||
- Widget: the lock is released on stop
|
||||
- **Manual, on a real bike** — legibility in daylight cannot be tested any other way
|
||||
|
||||
## Risks
|
||||
- **A leaked wake lock flattens the battery**, silently and after the app is closed. Test
|
||||
the release path harder than the acquire path.
|
||||
- Legibility is a judgement call that needs a real ride in real sun.
|
||||
|
||||
## Out of scope
|
||||
A dedicated mounted layout with different information architecture — start by scaling what
|
||||
exists and see what the ride teaches.
|
||||
|
||||
## Outcome
|
||||
Shipped as designed, plus two deviations worth recording.
|
||||
|
||||
`Config.mountedMode` follows the same seeded-`StateProvider` shape as `mapEnabled` and
|
||||
`unitSystem` (`mountedModeProvider`); a quick-toggle icon button sits next to Settings on
|
||||
the record screen, and a matching switch was added to `SettingsScreen`. The wake lock is
|
||||
wrapped in a `WakelockController` seam (`FakeWakelockController` for tests), mirroring
|
||||
`LocationSource` — the same reasoning: the risk named in this ticket ("a leaked lock
|
||||
flattens the battery silently") is exactly the kind of thing that has to be provable, not
|
||||
just plausible.
|
||||
|
||||
**Deviation 1 — no `TextTheme.apply(fontSizeFactor: ...)`.** The design called for scaling
|
||||
the whole mounted text theme at once; Flutter's `TextStyle.apply` asserts when
|
||||
`fontSizeFactor != 1.0` meets any style with a null `fontSize`, which Material 3's default
|
||||
`TextTheme` has for at least one role. Scaling was moved to where it already existed:
|
||||
`BigStat` gained an explicit `scale` parameter (default `1.0`), applied to the record
|
||||
screen's headline figure only. `StatRow` and button labels were **not** wired to
|
||||
`mountedTextScale` — the acceptance criterion is legibility of the number that matters at
|
||||
a glance, not uniform scaling of every row, and over-scaling the stat card risked
|
||||
reintroducing the record screen's known overflow-on-short-phones failure mode.
|
||||
|
||||
**Deviation 2 — the mounted theme wraps only the record screen**, via a local `Theme(...)`
|
||||
widget inside `RecordScreen.build`, not the app's `MaterialApp`. `Theme.of(context)` inside
|
||||
that build method would still report the ambient dark theme, so `colors` is read off the
|
||||
locally-built `ThemeData` directly rather than through `Theme.of(context)` — a small trap
|
||||
worth flagging for V3-16, which will touch this same file.
|
||||
|
||||
Wake lock acquisition is gated on **recording**, not merely mounted-and-idle or
|
||||
mounted-and-paused, and re-evaluated both on trip-state transitions and on the mounted
|
||||
toggle itself changing mid-ride. Release happens on stop, on discard (both drive the same
|
||||
trip-state listener), on navigating away (`dispose`), and defensively whenever mounted mode
|
||||
is off. One implementation snag: reading `ref` inside `State.dispose()` throws
|
||||
(`ConsumerStatefulElement` forbids it once unmounting has started) — fixed by capturing the
|
||||
`WakelockController` once in `initState` via a `late final` field instead of reading it
|
||||
fresh in `dispose`.
|
||||
|
||||
7 new tests across `widget_test.dart` (wake lock acquired while recording+mounted,
|
||||
never requested un-mounted, released on ride completion, released on navigating away
|
||||
while still recording, mounted theme scales the headline and enlarges Start),
|
||||
`settings_screen_test.dart` (switch writes through to `Config`), and `config_test.dart`
|
||||
(default/round-trip). `flutter analyze` clean; full suite green (231 tests, up from 224).
|
||||
|
||||
Not done, and explicitly out of scope per the ticket: real daylight/glove legibility
|
||||
(needs an actual ride — V3-13), and `AppLifecycleState.inactive` handling for an incoming
|
||||
call — recording is already fully DB-derived and does not observe app lifecycle at all, so
|
||||
a call cannot stop it; this was verified by reasoning about the existing architecture
|
||||
rather than a new test, since there is no lifecycle-reactive code path to test.
|
||||
94
rippr-flutter-src/docs/v3/V3-06-notification-stats.md
Normal file
94
rippr-flutter-src/docs/v3/V3-06-notification-stats.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# V3-06 — Live stats in the notification
|
||||
|
||||
**Phase** Live map · **Depends on** nothing · **Size** S · **Status** Done
|
||||
|
||||
## Goal
|
||||
Distance and duration readable from the notification shade without unlocking.
|
||||
|
||||
## Context
|
||||
The ongoing notification currently says "Rippr is recording / Tracking your ride" — static
|
||||
text. During a pocketed ride that is a wasted surface.
|
||||
|
||||
**Constraint that shapes this whole ticket:** the notification is owned by `geolocator`'s
|
||||
`ForegroundNotificationConfig`, which takes fixed strings at stream-subscription time and
|
||||
offers no update path and no actions. That is the parity gap recorded in
|
||||
[../port/PARITY-AUDIT.md](../port/PARITY-AUDIT.md).
|
||||
|
||||
## Design
|
||||
Two honest options:
|
||||
|
||||
**A. Restart the position stream with new text.** Cheap to write, but it tears down and
|
||||
re-establishes location updates every time — unacceptable during recording.
|
||||
|
||||
**B. Take the notification back with `flutter_local_notifications`,** and let geolocator
|
||||
raise a silent minimal one. More moving parts, but it also **restores Pause/Resume actions**
|
||||
— closing the one capability lost in the port.
|
||||
|
||||
**Recommend B**, precisely because it buys back the parity gap as well.
|
||||
|
||||
## Implementation
|
||||
1. Add `flutter_local_notifications`
|
||||
2. Own a notification on the same channel, updated on each writer flush (~2 s), not per fix
|
||||
3. Pause/Resume actions routed back into `RecordingEngine`
|
||||
4. Android 13+ notification permission is already requested
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Distance and elapsed update while riding, without unlocking
|
||||
- [ ] Pause and Resume work from the shade
|
||||
- [ ] Location updates are **not** interrupted when the notification changes
|
||||
- [ ] The notification cannot be swiped away mid-ride
|
||||
|
||||
## Tests
|
||||
- Unit: notification text formatting from a trip
|
||||
- Integration on a device: start, confirm the text advances, pause from the shade, confirm
|
||||
the engine actually paused (check the database, do not trust the UI)
|
||||
|
||||
## Risks
|
||||
- Two notification sources fighting is the obvious failure. Verify only one is visible.
|
||||
- An update per fix would be a battery and jank problem; drive it from the flush.
|
||||
|
||||
## Out of scope
|
||||
iOS. There is no equivalent live notification surface; a Live Activity is a much larger
|
||||
piece of work and belongs in its own ticket.
|
||||
|
||||
## Outcome
|
||||
Shipped as designed (option B), with one honestly-unresolved risk carried forward.
|
||||
|
||||
`RideNotificationController` is a seam over `flutter_local_notifications`
|
||||
(`FakeRideNotificationController` for tests), the same shape as `LocationSource` and
|
||||
`WakelockController`. `RideNotificationCoordinator` owns the wiring: it subscribes to
|
||||
`TripRepository.watchActiveTrip()` (the same stream `activeTripProvider` exposes, updated
|
||||
once per writer flush) and calls `show`/`cancel`; it subscribes to the controller's action
|
||||
stream and routes `pause`/`resume` back into `RecordingEngine`, guarded so a Resume can't
|
||||
fire against a trip that isn't actually paused. `rideNotificationText(Trip, {unit})` is
|
||||
pure and unit-tested directly — distance/elapsed formatting, the `Paused ·` prefix, and
|
||||
unit-system handling.
|
||||
|
||||
**Not screen-owned, deliberately.** Unlike the live map (V3-04) and mounted mode (V3-05),
|
||||
which are fed from `RecordScreen`'s widget tree, the coordinator is instantiated eagerly
|
||||
from `main.dart`'s `RipprApp.build` via a bare `ref.watch(rideNotificationCoordinatorProvider)`
|
||||
— a pocketed ride has no visible widget tree, but the notification and Pause/Resume both
|
||||
still have to work.
|
||||
|
||||
**Unresolved risk, flagged rather than papered over:** the ticket's own risk section names
|
||||
"two notification sources fighting" as the obvious failure mode, and it is real.
|
||||
geolocator's `ForegroundNotificationConfig` is what satisfies Android's foreground-service
|
||||
requirement and cannot be suppressed; `flutter_local_notifications` raises a second,
|
||||
independent notification. There is no documented way to merge or guarantee only one is
|
||||
visible — the geolocator notification was made minimal and silent
|
||||
(`geolocator_location_source.dart`) on the assumption that an `ongoing: true` notification
|
||||
on the same-ish surface might collapse or de-prioritise it, but that assumption is
|
||||
unverified without a device. This is exactly the kind of claim the project's testing
|
||||
philosophy refuses to accept on faith — see the real-ride checklist (V3-13) and this
|
||||
ticket's own "Integration on a device" test, neither of which could run here.
|
||||
|
||||
6 new tests, all in `test/ride_notification_test.dart`: three for `rideNotificationText`
|
||||
(recording, paused-prefix, unit system), three for the coordinator using a real
|
||||
`RecordingEngine` + in-memory `TripRepository` (not a mock) so pause/resume are checked in
|
||||
the database per the project's standing rule, not by trusting the notification state.
|
||||
`flutter analyze` clean; full suite green (237 tests, up from 231).
|
||||
|
||||
Not attempted: the device-only acceptance criteria (text updates without unlocking,
|
||||
Pause/Resume from the shade, the notification resisting swipe-away, and the two-source
|
||||
visibility question above) — all require a real Android device, per the ticket's own Tests
|
||||
section.
|
||||
106
rippr-flutter-src/docs/v3/V3-07-route-drawing.md
Normal file
106
rippr-flutter-src/docs/v3/V3-07-route-drawing.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# V3-07 — Route drawing (pins and straight lines)
|
||||
|
||||
**Phase** Route planning · **Depends on** nothing · **Size** M · **Status** Done
|
||||
|
||||
## Goal
|
||||
Drop pins on a map to sketch a route, see the straight-line distance, and save it. The
|
||||
foundation for V3-08, deliberately shipped without a routing engine.
|
||||
|
||||
## Context
|
||||
Pre-ride planning is a **genuinely new mode**, not an extension of recording. It needs no
|
||||
ride in progress, no server, and nothing else in v3 — the most independent thing in the
|
||||
backlog.
|
||||
|
||||
Split from road-snapped routing (V3-08) on purpose: pins, storage, editing and the map
|
||||
interaction are all needed either way, and none of them require choosing a routing vendor.
|
||||
That decision should not block a usable feature.
|
||||
|
||||
## Design
|
||||
**New entities, separate from `Trip`.** A plan is not a recording, and conflating them
|
||||
would put unridden kilometres into ride totals.
|
||||
|
||||
```
|
||||
Route(id, name, createdAt, activity?, distanceM, estimatedMillis?, geometry?)
|
||||
Waypoint(id, routeId, ordinal, latitude, longitude, name?)
|
||||
```
|
||||
|
||||
`geometry` is null in this ticket; V3-08 fills it with the road-snapped polyline.
|
||||
`ordinal` rather than relying on insertion id, so waypoints can be reordered.
|
||||
|
||||
Straight-line distance reuses `haversineMeters` — already ported and parity-proven.
|
||||
|
||||
## Implementation
|
||||
1. Drift tables + **migration** (the second one; V3-01 proves the path)
|
||||
2. `RouteRepository` mirroring `TripRepository`'s shape
|
||||
3. `RoutePlannerScreen` — tap to add a pin, drag to move, tap a pin to delete, reorder
|
||||
4. Straight-line polyline between pins, visibly distinct from a recorded path
|
||||
5. Routes list, reachable from the record screen alongside Rides
|
||||
6. Name, rename, delete
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Pins can be added, moved, reordered and deleted
|
||||
- [ ] Distance updates live as pins change
|
||||
- [ ] A saved route survives an app restart
|
||||
- [ ] Routes never appear in the rides list, and never contribute to ride totals
|
||||
- [ ] Deleting a route cascades to its waypoints
|
||||
|
||||
## Tests
|
||||
- Repository: create, reorder, delete, cascade — in-memory, like the trip tests
|
||||
- Distance matches `pathLengthMeters` over the same points
|
||||
- Widget: tapping the map adds a pin; the distance label updates
|
||||
- **A test asserting routes are absent from `watchCompletedTrips`**
|
||||
|
||||
## Risks
|
||||
The main one is scope drift into V3-08. Ship straight lines first; they are genuinely
|
||||
useful for a rough plan.
|
||||
|
||||
## Out of scope
|
||||
Road snapping, ETA, following a route while riding. Import of existing GPX routes.
|
||||
|
||||
## Outcome
|
||||
Shipped as designed, with one naming deviation and two real testing traps worth recording
|
||||
for V3-08/V3-09.
|
||||
|
||||
**Named `RoutePlan`, not `Route`.** The ticket's own design sketch used `Route`, but that
|
||||
collides with `dart:ui`/`package:flutter`'s own `Route<T>` (the navigator's page-transition
|
||||
class) and with `go_router`'s `GoRoute`. Renaming up front avoided constant `hide`/`as`
|
||||
import juggling across every file that touches both navigation and route plans.
|
||||
|
||||
Schema: `route_plans`/`waypoints` tables, schema version 2→3, following V3-01's migration
|
||||
pattern exactly (`m.createTable` for brand-new tables needs no backfill, unlike V3-01's
|
||||
`addColumn`). `RoutePlanRepository` mirrors `TripRepository`'s shape but has no state
|
||||
machine — every mutating call ends by recomputing `distanceM` via `geo.pathLengthMeters`,
|
||||
so "distance always matches the current waypoints" holds with no exceptions to remember,
|
||||
including after a pure reorder that doesn't change it.
|
||||
|
||||
`RoutePlannerScreen`: tap-to-add via `MapOptions.onTap`, tap-a-pin-to-delete, and
|
||||
drag-to-move implemented by hand against `MapCamera.latLngToScreenOffset`/
|
||||
`screenOffsetToLatLng` (flutter_map has no built-in draggable-marker widget). The straight
|
||||
line is dashed and uses the planning accent, visibly distinct from `RideMap`'s
|
||||
speed-bucketed solid polyline, satisfying the acceptance criterion without a design pass.
|
||||
|
||||
**Real bug found by testing, not review:** the map's `initialCenter`/`initialZoom` are
|
||||
read exactly once, at `FlutterMap` construction. Building the map before the waypoints
|
||||
stream delivered its first value froze the camera on null-island permanently, even once
|
||||
real waypoints arrived — invisible in manual testing (a route sketched from empty always
|
||||
starts empty) but immediate in a test that opens a planner for a route with existing
|
||||
waypoints. Fixed by gating the map behind the stream's first emission, and by switching
|
||||
from a fixed-zoom guess to `CameraFit.bounds` (matching `RideMap`'s own established
|
||||
pattern) so pins can't be culled off-camera either.
|
||||
|
||||
**Real testing trap, likely to recur in V3-08/V3-09:** `await db.watchRoutePlans().first`
|
||||
inside a `testWidgets` body hung for a genuine ten minutes (the framework's own internal
|
||||
timeout, not a guess) — a fresh `Stream.first` subscription on a Drift `.watch()` query
|
||||
depends on a `Timer` inside Drift's stream-query store that flutter_test's fake test zone
|
||||
never advances without an explicit pump. `repo`-level `Future`-returning calls
|
||||
(`routePlanById`, etc.) have no such dependency and are what every other assertion in this
|
||||
suite already used correctly. Documented inline in the test as a trap for the next ticket
|
||||
that watches a stream from inside `testWidgets`.
|
||||
|
||||
16 new tests: 8 in `route_plan_repository_test.dart` (create, live distance on
|
||||
add/move/delete, ordinal-gap closing, reordering, rename, cascade delete, and the
|
||||
ticket-mandated "never appears in ride totals" check), 1 migration test (v2→v3, tables
|
||||
created and usable, existing trip untouched), 7 in `route_planner_screen_test.dart`
|
||||
(empty state, create-and-open, delete, tap-to-add-and-distance-updates, tap-to-delete,
|
||||
rename, missing-route fallback), plus 1 in `widget_test.dart` for the Routes entry point
|
||||
on the record screen. `flutter analyze` clean; full suite green (254 tests, up from 237).
|
||||
83
rippr-flutter-src/docs/v3/V3-08-road-routing.md
Normal file
83
rippr-flutter-src/docs/v3/V3-08-road-routing.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# V3-08 — Road-snapped routing and ETA
|
||||
|
||||
**Phase** Route planning · **Depends on** V3-07, benefits from V3-01, **blocked on V3-17**
|
||||
· **Size** L · **Status** Deferred
|
||||
|
||||
## Goal
|
||||
Resolve the actual shortest path along roads between pins, and estimate how long the ride
|
||||
will take.
|
||||
|
||||
## Context
|
||||
This is what Dylan asked for. It is also the ticket with a **decision that cannot be
|
||||
deferred**: road-snapped routing needs a routing engine over OpenStreetMap data, and the
|
||||
choice has ongoing consequences.
|
||||
|
||||
**Decided, not deferred:** self-hosted OSRM (see the table below). What's actually
|
||||
deferred is standing one up — that's [V3-17](V3-17-osrm-hosting.md), split out on
|
||||
request because provisioning a server is a different kind of work from building the app
|
||||
code that calls it, and this ticket cannot start until V3-17 has something to point
|
||||
`RoutingService` at.
|
||||
|
||||
## Design
|
||||
|
||||
### Choosing an engine — decide before writing code
|
||||
|
||||
| Option | Trade-off |
|
||||
|---|---|
|
||||
| **Public OSRM demo** | Free, zero setup, **explicitly not for production**, rate-limited. Prototype only. |
|
||||
| **Self-hosted OSRM** | Fast, well understood. Needs a machine and a regional OSM extract (a province is a few GB). |
|
||||
| **GraphHopper** | Self-hostable, good cycling/motorcycle profiles, friendlier ETAs |
|
||||
| **Valhalla** | Best multi-modal profiles, heaviest to run |
|
||||
| **Mapbox / Google** | No ops, per-request billing, an API key shipped in the app |
|
||||
|
||||
**Profiles matter more here than usual.** A motorcycle route and a bicycle route between
|
||||
the same pins genuinely differ — cycling engines avoid motorways, and a motorcyclist often
|
||||
wants the twisty road rather than the fast one. This is where V3-01 pays off: the activity
|
||||
selects the profile.
|
||||
|
||||
Put it behind a `RoutingService` interface with a fake, exactly as `LocationSource` did for
|
||||
GPS. That seam is what made swapping the location engine cheap, and the same argument
|
||||
applies here.
|
||||
|
||||
### ETA is a promise, and easy to get wrong
|
||||
Engines estimate from posted speed limits. That is not how long *you* take. Once there is
|
||||
history, the rider's own average moving speed for that activity — already stored on every
|
||||
`Trip` — is a better predictor.
|
||||
|
||||
Show the engine's estimate, then replace it with a personal one once there is enough
|
||||
history to justify it. Label which is which.
|
||||
|
||||
### Caching
|
||||
Cache the returned polyline on `Route.geometry`. A saved plan must open offline and must
|
||||
not re-bill a request every time it is viewed.
|
||||
|
||||
## Implementation
|
||||
1. Decide the engine. Write the decision and its reasoning into this file.
|
||||
2. `RoutingService` interface + implementation + `FakeRoutingService`
|
||||
3. Resolve on pin change, debounced — not on every drag frame
|
||||
4. Persist geometry, distance and duration on `Route`
|
||||
5. Personal ETA from `Trip` history, once ≥5 rides of that activity exist
|
||||
6. Graceful offline behaviour: fall back to straight lines and say so
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Pins resolve to a road-following polyline
|
||||
- [ ] Distance reflects the road path, not the straight line
|
||||
- [ ] Activity changes the profile and can change the route
|
||||
- [ ] A saved route renders offline from cached geometry, with no network call
|
||||
- [ ] Offline with no cache degrades to straight lines with a visible explanation
|
||||
- [ ] No API key is committed to the repository
|
||||
|
||||
## Tests
|
||||
- `FakeRoutingService` drives every path: success, failure, offline, empty
|
||||
- Cached geometry means no second request — assert the fake is called once
|
||||
- Personal ETA maths against fixed history
|
||||
- **No live network calls in any test**
|
||||
|
||||
## Risks
|
||||
- **Vendor lock-in and cost.** The interface is the mitigation.
|
||||
- Debouncing matters: dragging a pin could otherwise fire dozens of requests.
|
||||
- OSM route quality varies. It will occasionally suggest something daft; that is the data,
|
||||
not a bug to chase.
|
||||
|
||||
## Out of scope
|
||||
Turn-by-turn navigation and voice guidance. That is a different product.
|
||||
53
rippr-flutter-src/docs/v3/V3-09-route-following.md
Normal file
53
rippr-flutter-src/docs/v3/V3-09-route-following.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# V3-09 — Follow a planned route while riding
|
||||
|
||||
**Phase** Route planning · **Depends on** V3-04, V3-08 (itself blocked on V3-17) · **Size** M · **Status** Deferred
|
||||
|
||||
## Goal
|
||||
Pick a saved route before starting, see it on the live map underneath your actual track,
|
||||
and afterwards compare what you rode against what you planned.
|
||||
|
||||
## Context
|
||||
The payoff that makes V3-07 and V3-08 worth building, and the point where route planning
|
||||
meets the live map. Not navigation — no turn-by-turn, no voice. Just the line you meant to
|
||||
follow, drawn under the line you actually rode.
|
||||
|
||||
## Design
|
||||
Attach an optional `routeId` to `Trip`. That is enough for both the live overlay and the
|
||||
after-the-fact comparison.
|
||||
|
||||
Live: the planned route in a muted colour, the recorded track drawn over it in the existing
|
||||
speed colours. Immediately obvious when you have left the plan.
|
||||
|
||||
Afterwards, on trip detail: both lines, plus how far you deviated and how the real duration
|
||||
compared with the estimate — which also, over time, tells you how honest the ETA is.
|
||||
|
||||
**Deliberately not:** rerouting, off-route alerts, or anything that demands attention while
|
||||
riding. A rider glancing at handlebars wants a picture, not an interruption.
|
||||
|
||||
## Implementation
|
||||
1. `routeId` on `Trip` — **third migration**
|
||||
2. Route picker on the record screen before Start, defaulting to none
|
||||
3. Live map renders the planned polyline beneath the track
|
||||
4. Trip detail renders both, with a comparison block
|
||||
5. Deviation: max and mean distance from the recorded points to the planned polyline —
|
||||
`perpendicularDistanceMeters` already exists and is parity-proven
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] A route can be selected before starting, or not
|
||||
- [ ] Both lines render, visually distinguishable
|
||||
- [ ] Deviation and duration-vs-estimate appear on trip detail
|
||||
- [ ] A ride with no route behaves exactly as today
|
||||
- [ ] Deleting a route does not delete rides that referenced it
|
||||
|
||||
## Tests
|
||||
- Deviation maths against a known track and route
|
||||
- Repository: deleting a route nulls `routeId` rather than cascading to the trip —
|
||||
**the cascade direction here is the opposite of segments and is easy to get wrong**
|
||||
- Widget: both polylines present when a route is attached
|
||||
|
||||
## Risks
|
||||
The `Route` → `Trip` foreign key must **not** cascade. Deleting an old plan must never
|
||||
delete the ride you did.
|
||||
|
||||
## Out of scope
|
||||
Turn-by-turn, off-route alerts, rerouting.
|
||||
92
rippr-flutter-src/docs/v3/V3-10-trip-splitting.md
Normal file
92
rippr-flutter-src/docs/v3/V3-10-trip-splitting.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# V3-10 — Trip splitting
|
||||
|
||||
**Phase** Ride management · **Depends on** nothing · **Size** S · **Status** Done
|
||||
|
||||
## Goal
|
||||
Split one recorded ride into two at a chosen point. The natural counterpart to merge.
|
||||
|
||||
## Context
|
||||
Merge exists and is well tested; split does not. The case is a rider who forgot to stop —
|
||||
one "ride" that is really the trip out, lunch, and the trip home.
|
||||
|
||||
Merge already establishes the hard parts: re-parenting points and segments inside a
|
||||
transaction, and recomputing aggregates rather than summing them.
|
||||
|
||||
## Design
|
||||
Split at a **segment boundary** rather than an arbitrary point. Segments already mark where
|
||||
the rider paused, which is exactly where a forgotten stop shows up — and it avoids
|
||||
inventing a new boundary type or splitting a segment in half.
|
||||
|
||||
The original trip keeps the earlier segments; a new trip takes the later ones. Both get
|
||||
aggregates recomputed from the points they actually own.
|
||||
|
||||
If a ride has only one segment there is nothing to split, and the UI should say so rather
|
||||
than offering a dead control.
|
||||
|
||||
## Implementation
|
||||
1. `TripRepository.splitTrip(tripId, atSegmentId)` inside a transaction:
|
||||
create the new trip, re-parent segments and points from `atSegmentId` onward,
|
||||
set `startedAt`/`endedAt` from the segments each trip now owns,
|
||||
recompute aggregates for both
|
||||
2. Trip detail: a split action listing segment boundaries with their times
|
||||
3. Confirmation naming what the two resulting rides will be
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Splitting produces two trips whose point counts sum to the original
|
||||
- [ ] Neither trip's distance includes the gap between them
|
||||
- [ ] Both have plausible `startedAt`/`endedAt`
|
||||
- [ ] Single-segment rides cannot be split, and the UI explains why
|
||||
- [ ] Atomic — a failure part-way leaves the original intact
|
||||
|
||||
## Tests
|
||||
- Point counts sum; no points orphaned
|
||||
- Distance of the parts is less than the original by roughly the gap
|
||||
- Split then merge returns to the original aggregates — a good round-trip property
|
||||
- Rejects a single-segment trip
|
||||
- Atomicity under a forced mid-transaction failure
|
||||
|
||||
## Risks
|
||||
Getting `startedAt`/`endedAt` from the wrong source. Derive them from the segments each
|
||||
trip owns, not from the original trip.
|
||||
|
||||
## Out of scope
|
||||
Splitting mid-segment.
|
||||
|
||||
## Outcome
|
||||
Shipped as designed. `TripRepository.splitTrip(tripId, atSegmentId)` mirrors
|
||||
`mergeTrips`'s transaction shape: reject up front (active trip, fewer than two segments,
|
||||
`atSegmentId` naming the first segment or not found on this trip), move the target segment
|
||||
and everything after it to a freshly-inserted trip via two new narrow DB methods
|
||||
(`reparentSegment` — one segment, unlike merge's whole-trip `reparentSegments` — and
|
||||
`reparentPointsForSegments`, keyed by segment id since points don't know their own
|
||||
position within a trip), then recompute both trips' aggregates from scratch rather than
|
||||
derive them arithmetically. `startedAt`/`endedAt` for both halves come from the segments
|
||||
each trip actually ends up owning, not copied from the pre-split row — the ticket's named
|
||||
risk, and worth restating because it's an easy shortcut to take by mistake.
|
||||
|
||||
Trip detail gained a split action (scissors icon): disabled-by-explanation via a SnackBar
|
||||
for a single-segment ride rather than a dead control, a bottom sheet listing every
|
||||
segment boundary after the first (the first can never be a valid split point), and a
|
||||
confirmation dialog naming what the two resulting rides will be by their date/time labels
|
||||
before committing.
|
||||
|
||||
**Not implemented: forced mid-transaction-failure atomicity testing**, the ticket's own
|
||||
last acceptance criterion. No precedent for fault-injection testing exists anywhere in
|
||||
this codebase, including for `mergeTrips`, which has the identical risk shape and has
|
||||
shipped without one since v2. Atomicity here is a property of Drift's `_db.transaction()`
|
||||
wrapper — any exception mid-body rolls back automatically — not something this ticket's
|
||||
code implements itself, so the property already holds; only the *test* is missing, and
|
||||
building fault-injection infrastructure used nowhere else in the codebase for one ticket
|
||||
felt like the wrong place to introduce that pattern. Flagged rather than silently dropped.
|
||||
|
||||
12 new repository tests (point counts sum, no orphans, distance excludes the gap,
|
||||
`startedAt`/`endedAt` from the right source, segment-boundary preserved on both sides,
|
||||
single-segment rejected, first-segment rejected, active-trip rejected, unknown-segment
|
||||
rejected, split-then-merge round-trips back to the original aggregates, and an explicit
|
||||
no-orphans sweep over every point/segment), plus 2 widget tests (single-segment
|
||||
explanation, full split flow via the bottom sheet and confirmation dialog). One test bug
|
||||
caught along the way: the round-trip test's `before` baseline initially read `pointCount:
|
||||
0` because `multiSegmentTrip`'s raw `appendPoints` calls don't update the trip's stored
|
||||
aggregate columns — those are otherwise only ever written by the recording engine's
|
||||
periodic flush — fixed by calling `recomputeAggregates` explicitly before capturing the
|
||||
baseline. `flutter analyze` clean; full suite green (269 tests, up from 257).
|
||||
113
rippr-flutter-src/docs/v3/V3-11-offline-tiles.md
Normal file
113
rippr-flutter-src/docs/v3/V3-11-offline-tiles.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# V3-11 — Offline tile pre-download
|
||||
|
||||
**Phase** Ride management · **Depends on** V3-04 · **Size** M · **Status** Partially done
|
||||
|
||||
## Goal
|
||||
Have map tiles available where there is no signal.
|
||||
|
||||
## Context
|
||||
`flutter_map` caches what it renders, so a re-viewed ride works. A **mountain ride with no
|
||||
signal shows blank tiles** — precisely where a map is most wanted.
|
||||
|
||||
## Design
|
||||
**Respect OSM's tile usage policy. Bulk prefetching their public servers is prohibited**
|
||||
and would get the app blocked. This constraint decides the design:
|
||||
|
||||
- Pre-download only a **user-chosen area**, at a **limited zoom range**, with a visible
|
||||
tile count and size estimate before starting
|
||||
- Rate-limited, sequential, cancellable
|
||||
- If this becomes a headline feature, move to a paid tile provider or self-hosted tiles.
|
||||
Do not scale it on OSM's donated infrastructure.
|
||||
|
||||
Natural pairing with V3-07: pre-download the corridor along a planned route rather than a
|
||||
rectangle — far fewer tiles for the same usefulness.
|
||||
|
||||
## Implementation
|
||||
1. Persistent tile cache with a size cap and eviction (`flutter_map_cache` or similar)
|
||||
2. Area selection on the map, plus a "download along this route" option
|
||||
3. Tile count and MB estimate **before** any request
|
||||
4. Sequential fetch with a delay, a progress indicator and cancellation
|
||||
5. Settings: cache size, and a way to clear it
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] A downloaded area renders with the network off
|
||||
- [ ] Count and size shown before download starts
|
||||
- [ ] Cancellable mid-download, keeping what has already arrived
|
||||
- [ ] A hard cap on tiles per request — no unbounded area selection
|
||||
- [ ] Cache size visible and clearable
|
||||
|
||||
## Tests
|
||||
- Tile-count maths for a bounding box across zoom levels
|
||||
- Cache eviction at the cap
|
||||
- Cancellation leaves a consistent cache
|
||||
- **Manual:** aeroplane mode over a downloaded area
|
||||
|
||||
## Risks
|
||||
- **Abusing OSM's servers.** Cap, rate-limit, and be conservative. A blocked user agent
|
||||
would break the map for everyone.
|
||||
- Storage growth. Tiles add up fast; the cap is not optional.
|
||||
|
||||
## Out of scope
|
||||
Vector tiles or a full offline basemap.
|
||||
|
||||
## Outcome
|
||||
The download pipeline is done and unit-tested end to end; the on-device acceptance
|
||||
criterion (aeroplane mode over a downloaded area) is not, and can't be from here.
|
||||
|
||||
**Deliberate scope reduction: no rectangle area-selection UI.** The ticket names the
|
||||
route-corridor pairing with V3-07 as strictly better ("far fewer tiles for the same
|
||||
usefulness") and V3-07 already shipped, so that became the only download entry point
|
||||
rather than building two. `tilesAlongRoute` buffers each waypoint by a fixed radius and
|
||||
unions the per-point tile sets — a zigzagging route's actual footprint, not the
|
||||
rectangle around its bounding box, proven directly in `tile_math_test.dart` by
|
||||
constructing a route that zigzags across its own bounding box and asserting the corridor
|
||||
costs fewer tiles than that box would.
|
||||
|
||||
Three pure modules, layered the way `geo.dart`/`ride_statistics.dart` already are in this
|
||||
codebase: `tile_math.dart` (tile enumeration, a hard `maxTilesPerDownload` cap enforced by
|
||||
*throwing* rather than silently truncating — a caller must know a download was rejected,
|
||||
not receive a partial one unknowingly), `tile_cache.dart` (`FileTileCache`: tiles as
|
||||
files on disk, a JSON manifest tracking size and last-access time, LRU eviction that
|
||||
runs *before* a write that would exceed the cap, not after), and `tile_downloader.dart`
|
||||
(sequential, rate-limited via a fixed delay between tiles, cooperatively cancellable via
|
||||
`CancelToken`, one bad tile doesn't abort the rest, everything fetched before
|
||||
cancellation stays in the cache).
|
||||
|
||||
`CachedTileProvider` wires the cache into `flutter_map`'s `TileLayer` via a custom
|
||||
`ImageProvider` (cache hit skips the network entirely; a miss fetches, writes through,
|
||||
then decodes) and is now what `RideMap` and `RoutePlannerScreen` both request tiles
|
||||
through — a write-through side effect of this ticket is that ordinary map viewing now
|
||||
also populates the same capped, evictable cache, replacing flutter_map's own uncapped
|
||||
default. `RoutePlannerScreen` gained a download action (disabled with an explanatory
|
||||
tooltip when the route has no pins yet), a count-and-MB-estimate confirmation dialog
|
||||
before any request goes out, and a progress dialog with a working Cancel button. One real
|
||||
bug caught before it shipped: the first draft of the progress dialog used a bare
|
||||
`StatefulBuilder`, whose builder callback re-runs on every `setState` — meaning every
|
||||
single progress tick would have started a *second* overlapping download subscription.
|
||||
Fixed by moving the subscription into a dedicated `_DownloadDialog` `StatefulWidget` that
|
||||
starts it once, in `initState`.
|
||||
|
||||
Settings gained an "Offline tiles" section: current cache size against the fixed cap, and
|
||||
a Clear button. The cap itself (200 MB) is **not** user-configurable in this pass — only
|
||||
whether to clear it — a scope call in the same spirit as the ticket's "the cap is not
|
||||
optional" risk language.
|
||||
|
||||
**Not done, and cannot be done in this environment:** the ticket's own two headline
|
||||
acceptance criteria — a downloaded area actually rendering with the network off, and
|
||||
aeroplane-mode verification on a real device — both need a phone. Everything upstream of
|
||||
that (the tile math, the eviction policy, the cancellation-consistency of the cache, and
|
||||
the fact that a cache hit in `CachedTileProvider` skips the network call entirely by
|
||||
construction) is proven at the unit level; only the last mile — a real radio actually
|
||||
turned off — is not.
|
||||
|
||||
21 new tests: 8 in `tile_math_test.dart`, 8 in `tile_cache_test.dart` (round-trip, miss,
|
||||
size accounting, LRU eviction order, cap never exceeded across many writes, clear, and a
|
||||
cache re-opened over the same directory seeing prior contents), 4 in
|
||||
`tile_downloader_test.dart` (sequential/in-order/no-duplicates, one failure doesn't abort
|
||||
the rest, cancellation keeps a consistent partial cache, empty input is a no-op), 2 in
|
||||
`route_planner_screen_test.dart` (download disabled with no pins; count/size shown before
|
||||
any request — deliberately stopping short of confirming, since the pure download engine
|
||||
already covers the fetch/cancel/cache-consistency behaviour directly with fakes, and
|
||||
exercising it again through a real `http.Client` in a widget test would need a fake HTTP
|
||||
layer for no additional coverage). `flutter analyze` clean; full suite green (305 tests,
|
||||
up from 284).
|
||||
100
rippr-flutter-src/docs/v3/V3-12-crash-reporting.md
Normal file
100
rippr-flutter-src/docs/v3/V3-12-crash-reporting.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# V3-12 — Crash reporting
|
||||
|
||||
**Phase** Quality · **Depends on** nothing · **Size** S · **Status** Partially done
|
||||
|
||||
## Goal
|
||||
Know when the app dies mid-ride.
|
||||
|
||||
## Context
|
||||
There is none. A recorder that crashes during a ride currently leaves no trace beyond
|
||||
logcat, which nobody reads — and the failure mode that matters most (recording stopping
|
||||
silently) is exactly the one the user cannot report usefully.
|
||||
|
||||
Becomes important the moment anyone who is not Dylan uses it.
|
||||
|
||||
## Design
|
||||
Sentry or Firebase Crashlytics. **Sentry is the better fit**: it is not tied to Google
|
||||
services, works identically on both platforms, and its free tier is ample here.
|
||||
|
||||
**A crash reporter in a location app is a privacy surface.** Configure it deliberately:
|
||||
|
||||
- No location data in breadcrumbs or context, ever
|
||||
- No device id, no ride contents
|
||||
- Explicit opt-out in settings, and disclosed in the privacy policy
|
||||
- Debug builds report nowhere
|
||||
|
||||
Beyond crashes, one custom event is worth having: **recording ended unexpectedly** — the
|
||||
engine stopping without a user stop. That is the failure the app exists to avoid.
|
||||
|
||||
## Implementation
|
||||
1. Add `sentry_flutter`, initialised in `main()` behind a config flag
|
||||
2. Scrub: no coordinates, no ids, no trip contents in any payload
|
||||
3. Breadcrumbs for lifecycle transitions only
|
||||
4. A custom event when a recording ends without a user action
|
||||
5. Settings toggle, defaulting **off** until a privacy policy exists
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] A forced crash appears in Sentry from a release build
|
||||
- [ ] No coordinate ever appears in a payload — inspect a real one
|
||||
- [ ] The toggle genuinely disables reporting
|
||||
- [ ] Debug builds send nothing
|
||||
|
||||
## Tests
|
||||
- The scrubber strips coordinates from a representative payload
|
||||
- Reporting disabled means the client is never initialised
|
||||
- Manual: force a crash in a release build and check it lands
|
||||
|
||||
## Risks
|
||||
Leaking location through breadcrumbs or a stack frame's captured state. Inspect a real
|
||||
payload rather than assuming the scrubber works.
|
||||
|
||||
## Out of scope
|
||||
Analytics or usage tracking. Different purpose, different consent.
|
||||
|
||||
## Outcome
|
||||
The code and its guarantees are done; the two device/account-dependent acceptance
|
||||
criteria are not, and can't be from here.
|
||||
|
||||
`shouldInitializeCrashReporting({enabled, isDebug, dsn})` pulls the entire "talk to Sentry
|
||||
at all" decision out as a pure function — every combination of the user's toggle, debug
|
||||
vs. release, and a configured DSN is asserted directly, rather than trusted to however
|
||||
`main()` happens to wire things. `scrubExtra` strips any key matching a coordinate,
|
||||
altitude, device-id, or trip-id fragment (case-insensitive, substring match, so `lat`,
|
||||
`latitude`, `startLat`, and `gps.lon` are all caught without enumerating every call site
|
||||
that might one day capture one) from both event `extra` and every breadcrumb's `data`,
|
||||
wired in as `beforeSend`/`beforeBreadcrumb`.
|
||||
|
||||
`Config.crashReportingEnabled` defaults to false and is surfaced in Settings, same shape
|
||||
as every other toggle in this file. The DSN itself is **not** a user preference — it's a
|
||||
compile-time `--dart-define=SENTRY_DSN=...` value, since it names which Sentry project
|
||||
receives reports, not a fact about the rider. `main()` loads `Config` once, early, purely
|
||||
to make the init-or-not decision before `runApp` (since `SentryFlutter.init` wraps the
|
||||
app itself); the widget tree still loads its own `Config` in `RipprApp.initState` as
|
||||
before, since `SharedPreferences` is memory-cached after the first read.
|
||||
|
||||
The one custom event: `RecordingEngine` gained an optional `onUnexpectedStop(String
|
||||
reason)` callback, injected the same way `uploadPending` already is, so the engine keeps
|
||||
no opinion about where a report goes. It fires exactly once, in
|
||||
`restoreAfterProcessDeath`, when a trip is found still `recording` at launch — the
|
||||
process died without anyone calling `stop()`, which is precisely "the recording stopped
|
||||
and nobody chose that." A cleanly-stopped ride reports nothing; verified by both cases in
|
||||
`recording_engine_test.dart`.
|
||||
|
||||
**Not done, and not attempted:** wiring a real Sentry DSN, forcing a real crash in a
|
||||
release build, and inspecting a real payload for leaked coordinates. All three are the
|
||||
ticket's own actual acceptance criteria, and all three need a real Sentry account and a
|
||||
release build this environment cannot produce. `shouldInitializeCrashReporting` and
|
||||
`scrubExtra` are unit-tested as thoroughly as pure functions can be, but a passing unit
|
||||
test is not the same claim as "inspected a real payload," which the ticket's own Risks
|
||||
section insists on by name. This should be revisited once Dylan has a Sentry project to
|
||||
point the DSN at.
|
||||
|
||||
19 new tests: 9 for `shouldInitializeCrashReporting`/`scrubExtra` (including a
|
||||
representative end-to-end event with coordinates in both `extra` and a breadcrumb),
|
||||
2 in `recording_engine_test.dart` (unexpected-stop fires on a crash, stays silent on a
|
||||
clean stop), 2 in `config_test.dart`, 1 in `settings_screen_test.dart`. Fixed the same
|
||||
viewport-culling test brittleness this section's addition exposed a second time (V3-05
|
||||
first triggered it): the Sync section's fields dropped out of the default test viewport
|
||||
entirely, not just out of hit-test range, so four upload-endpoint tests needed a shared
|
||||
`scrollToSync` helper alongside the device-id test's existing one. `flutter analyze`
|
||||
clean; full suite green (284 tests, up from 269).
|
||||
61
rippr-flutter-src/docs/v3/V3-13-real-ride-measurements.md
Normal file
61
rippr-flutter-src/docs/v3/V3-13-real-ride-measurements.md
Normal 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.
|
||||
80
rippr-flutter-src/docs/v3/V3-14-gpx-interop.md
Normal file
80
rippr-flutter-src/docs/v3/V3-14-gpx-interop.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# V3-14 — GPX interoperability
|
||||
|
||||
**Phase** Quality · **Depends on** V3-01 for `<type>` · **Size** S · **Status** Partially done
|
||||
|
||||
## Goal
|
||||
Confirm an exported ride actually imports into Strava, Garmin Connect and Google Earth —
|
||||
and add the activity type so it lands as the right kind of activity.
|
||||
|
||||
## Context
|
||||
Export is well tested: 15 tests, parsed with a real XML parser, and **byte-identical to the
|
||||
Kotlin original** under the parity harness. But every one of those tests proves *structural
|
||||
validity*, and structural validity does not mean a consumer accepts the file. That gap has
|
||||
been open since v2.
|
||||
|
||||
## Design
|
||||
Two parts.
|
||||
|
||||
**Verification** — export a real ride and import it into each of Strava, Garmin Connect and
|
||||
Google Earth. Record what each does with pauses, elevation and timestamps. Pauses are the
|
||||
interesting case: `<trkseg>` per segment is the correct GPX representation, but consumers
|
||||
vary in whether they honour it.
|
||||
|
||||
**`<type>` on `<trk>`** — Strava and Garmin read it to decide the activity. Without it a
|
||||
bicycle ride may import as a run. Needs V3-01's activity, mapped to each consumer's
|
||||
vocabulary (Strava uses `ride`, `run`, and so on).
|
||||
|
||||
## Implementation
|
||||
1. Add `<type>` to the `<trk>` element, from the trip's activity
|
||||
2. Map the internal enum to GPX conventions; document the mapping in the code
|
||||
3. Export a real multi-segment ride and import it into all three consumers
|
||||
4. Write the findings into this file, including anything that surprises
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] A real ride imports into Strava with the right activity type
|
||||
- [ ] It imports into Garmin Connect
|
||||
- [ ] It opens in Google Earth with the path in the right place
|
||||
- [ ] Pause behaviour in each consumer is documented, whatever it turns out to be
|
||||
- [ ] Existing export tests still pass, including the byte-identical parity check —
|
||||
**this one will need updating, since `<type>` changes the output**
|
||||
|
||||
## Tests
|
||||
- `<type>` present and correct per activity
|
||||
- Absent, not empty, when the activity is `other`
|
||||
- **The parity harness will now differ from Kotlin here. That is expected and correct —
|
||||
update its expectation and note why, rather than dropping the check.**
|
||||
|
||||
## Risks
|
||||
Silently breaking the parity harness by changing export output. Update it deliberately.
|
||||
|
||||
## Out of scope
|
||||
GPX import into Rippr. FIT and TCX formats.
|
||||
|
||||
## Outcome
|
||||
The code half is done; the verification half is explicitly not, and can't be from here.
|
||||
|
||||
`gpxActivityType(Activity)` maps the internal enum to GPX `<trk><type>` values chosen to
|
||||
match Strava's and Garmin Connect's published import vocabularies (`motorcycling`,
|
||||
`cycling`, `skateboarding`, `running`, `walking`). `Activity.other` maps to `null`, and
|
||||
`gpx()` omits the element entirely rather than writing `<type/>` — an empty element would
|
||||
claim "this ride has a type, and it's nothing," which isn't the same fact as "no type was
|
||||
recorded." Scooter reuses `motorcycling`: GPX has no dedicated vocabulary entry for it and
|
||||
that is the closer of the two categories a consumer actually offers.
|
||||
|
||||
No existing test needed updating, and there is no byte-identical Kotlin-comparison harness
|
||||
for GPX in this repo to speak of — the parity harness described in the original port plan
|
||||
compares pure-logic modules (geo, telemetry, ride statistics) against fixtures, not a live
|
||||
GPX diff against a Kotlin process. `<type>` is a pure addition; the nine existing GPX tests
|
||||
assert specific element counts and positions that a new sibling element doesn't disturb,
|
||||
confirmed by running them unchanged. Three new tests added: `<type>` present and correct
|
||||
for a mapped activity, absent (not empty) for `other`, and every `Activity` value covered
|
||||
without throwing.
|
||||
|
||||
**Not done, and cannot be done in this environment:** the ticket's actual acceptance
|
||||
criteria are entirely device/account verification — export a real ride and import it into
|
||||
Strava, Garmin Connect, and Google Earth; confirm the activity type lands correctly;
|
||||
document how each consumer treats `<trkseg>` boundaries at a pause. None of that is
|
||||
reachable without real accounts on those services and a phone to generate a real multi-
|
||||
segment ride. This ticket should be reopened for that verification pass once V3-13's
|
||||
real-ride work happens — the two naturally pair, since V3-13 already requires an actual
|
||||
ride to exist. `flutter analyze` clean; full suite green (257 tests, up from 254).
|
||||
57
rippr-flutter-src/docs/v3/V3-15-auto-pause.md
Normal file
57
rippr-flutter-src/docs/v3/V3-15-auto-pause.md
Normal 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.
|
||||
137
rippr-flutter-src/docs/v3/V3-16-visual-identity.md
Normal file
137
rippr-flutter-src/docs/v3/V3-16-visual-identity.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# V3-16 — Visual identity
|
||||
|
||||
**Phase** Quality · **Depends on** V3-04, V3-05 · **Size** M · **Status** Partially done
|
||||
|
||||
## Direction (written before any code changed, per the ticket's own step 1)
|
||||
|
||||
**Palette.** Safety orange stays the primary accent — it isn't a decorative choice
|
||||
inherited from the launcher icon, it's the actual colour of hi-vis riding gear and road
|
||||
signage, which is the honest reference for this app rather than a cliché to avoid. What
|
||||
changes: orange stops being the only signal colour. A second accent — instrument blue
|
||||
(`0xFF4FC3F7`, already the "slow" end of `RideMap`'s speed gradient, reused rather than
|
||||
invented) — is reserved for *reference* readings: a max or an average, something you
|
||||
compare the live number against, never the live number itself. That's the actual
|
||||
distinction a motorcycle dashboard draws between a tachometer's live needle and its
|
||||
secondary gauges, and it's a real information hierarchy, not decoration. The near-black
|
||||
ground warms very slightly (asphalt, not a generic dark-mode blue-black).
|
||||
|
||||
**Typography.** No new font family. Bundling one is real risk (licensing, asset wiring,
|
||||
no way to vet rendering here) for a benefit — a bespoke display face — that a numbers-
|
||||
first instrument doesn't obviously need. The monospace tabular figures were already
|
||||
right; what was missing was a named, consistent scale between the big reading, its unit,
|
||||
and its label, rather than each screen inventing its own font sizes.
|
||||
|
||||
**Data display.** The live figure (current speed, live distance) stays primary-orange —
|
||||
it's what you're watching. Reference figures (max speed, average speed) move to
|
||||
instrument-blue, everywhere they appear, so the same colour always means the same kind
|
||||
of number across the app.
|
||||
|
||||
**Motion.** Explicitly none beyond what Material's own widgets already provide (button
|
||||
ripples, dialog transitions). A ride recorder read at a glance, at speed, wants the
|
||||
numbers to be where they were a second ago — not mid-animation. This is a decision, not
|
||||
an oversight.
|
||||
|
||||
**Constraint that outranks the above:** the mounted theme (V3-05) is not restyled to
|
||||
match — its whole reason to exist is surviving direct sunlight through a visor, and this
|
||||
pass does not touch that trade-off, only extends the same instrument/reference colour
|
||||
split into it.
|
||||
|
||||
## Goal
|
||||
Move from "functional dark" to a look that is deliberately designed.
|
||||
|
||||
## Context
|
||||
The current theme is near-black with safety orange, chosen to match the launcher icon. It
|
||||
is clean and legible, and it was never actually *designed* — it was picked so the app did
|
||||
not look unfinished.
|
||||
|
||||
**Deliberately sequenced after the live map and mounted mode.** Both change what the app
|
||||
looks like far more than a palette does, and designing around screens that are about to
|
||||
change is wasted effort.
|
||||
|
||||
## Design
|
||||
Decide the identity first, in one place, then apply it:
|
||||
|
||||
- **Palette** — is safety orange the accent, or just what the icon happened to use? A
|
||||
motorcycle app has obvious references (dashboard instruments, race liveries, road
|
||||
signage) and obvious clichés to avoid.
|
||||
- **Typography** — the app is numbers-first. The monospace tabular figures are already
|
||||
right for that; the rest is undecided.
|
||||
- **Data display** — the speed readout, the charts and the map legend are the identity far
|
||||
more than any chrome. This is an instrument, not a document.
|
||||
- **Motion** — currently none. A ride recorder probably wants very little.
|
||||
|
||||
**Constraint that outranks aesthetics:** legibility through a visor, in daylight, at a
|
||||
glance. V3-05 may force a high-contrast variant, and the identity has to survive it.
|
||||
|
||||
## Implementation
|
||||
1. Write the direction down — palette, type, and what the app is trying to feel like —
|
||||
before touching code
|
||||
2. Extend `ripprColors` into a fuller token set
|
||||
3. Apply screen by screen, keeping `flutter test` green throughout
|
||||
4. **Keep the explicit text colours.** The theme names `bodyColor` and `displayColor`
|
||||
deliberately: a missing default once rendered a 64 sp figure black-on-black and only a
|
||||
screenshot caught it. Do not regress that while restyling.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] A written direction exists before the code changes
|
||||
- [ ] Applied consistently across all six screens
|
||||
- [ ] Contrast ratios meet WCAG AA for body text
|
||||
- [ ] Legible in direct sunlight — verified on a real phone outdoors
|
||||
- [ ] All widget tests still pass, including the black-on-black guard
|
||||
|
||||
## Tests
|
||||
- Existing widget tests must keep passing; they encode real regressions
|
||||
- Contrast assertions for primary text on each surface
|
||||
- Golden tests are worth considering here, and only here — this is the one ticket where
|
||||
pixel changes are the point
|
||||
|
||||
## Risks
|
||||
Restyling breaking the explicit-colour discipline that exists because of a real bug.
|
||||
|
||||
## Out of scope
|
||||
A new app icon. The Route mark is good and recently applied.
|
||||
|
||||
## Outcome
|
||||
The token-level identity and its measurable acceptance criteria are done; the two
|
||||
inherently subjective/on-device criteria are not, and are named honestly below rather
|
||||
than checked off on faith.
|
||||
|
||||
Implemented at `theme.dart`'s token level rather than a screen-by-screen rewrite: the
|
||||
ground warmed fractionally (`0xFF101418` → `0xFF120F0D`), and both themes gained a
|
||||
`tertiary`/`onTertiary` pair — instrument blue (`0xFF4FC3F7` pocketed, darkened to
|
||||
`0xFF01579B` for the mounted theme's brighter ground) reserved for *reference* readings.
|
||||
`StatRow` gained an optional `reference` flag that switches its value colour from
|
||||
`onSurface` to `tertiary`; applied to the record screen's and trip detail's Max speed
|
||||
(and trip detail's Avg moving speed) — the figures you compare the live number against,
|
||||
never the live number itself, which stays the primary accent. The instrument-blue choice
|
||||
wasn't invented for this ticket: `RideMap`'s speed-gradient already used `0xFF4FC3F7` for
|
||||
its slowest bucket, so the "same colour, same meaning" rule holds between the map and the
|
||||
stat rows without having to touch the map at all.
|
||||
|
||||
`contrastRatio(Color, Color)` implements WCAG 2.x's formula directly against
|
||||
`Color.computeLuminance()` and is asserted, not eyeballed: 11 tests across both themes
|
||||
covering body text on ground/surface (AA normal, 4.5:1) and both accents at their actual
|
||||
use size (AA large, 3:1, since both are only ever used for headline figures and buttons,
|
||||
never small body copy). Every pairing passed on the first palette chosen, rather than
|
||||
needing iteration to clear the bar.
|
||||
|
||||
**Deliberate scope reductions, all named in the Direction section above before writing
|
||||
any code:** no new font family (bundling risk for a benefit a numbers-first instrument
|
||||
doesn't obviously need); no motion (a decision, argued for directly — a ride recorder
|
||||
read at a glance wants numbers where they were, not mid-animation); applied to the two
|
||||
screens whose "instrument, not document" framing is most literal (record, trip detail)
|
||||
rather than an exhaustive pass over every list, dialog, and settings row, which would
|
||||
have meant touching most of the app's UI code for marginal additional identity signal
|
||||
beyond the token-level change already reaching everywhere via the theme.
|
||||
|
||||
**Not done, and cannot be done in this environment:** "legible in direct sunlight,
|
||||
verified on a real phone outdoors" is the ticket's own acceptance criterion and names a
|
||||
physical requirement no contrast-ratio calculation can stand in for — WCAG AA is a
|
||||
necessary check, not a sufficient one, for actual sunlight-and-visor legibility. Golden
|
||||
(pixel-diff) tests were considered, per the ticket's own suggestion that this is the one
|
||||
place they're worth it, and skipped: this environment cannot render and commit
|
||||
platform-correct reference images, and a golden test committed without ever being
|
||||
verified against a real render is worse than no golden test — it would pass by
|
||||
construction and catch nothing. `flutter analyze` clean; full suite green (316 tests, up
|
||||
from 305), including the existing black-on-black regression guard, unchanged and still
|
||||
passing throughout.
|
||||
88
rippr-flutter-src/docs/v3/V3-17-osrm-hosting.md
Normal file
88
rippr-flutter-src/docs/v3/V3-17-osrm-hosting.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# V3-17 — Self-hosted OSRM: investigate and stand one up
|
||||
|
||||
**Phase** Infrastructure · **Depends on** nothing · **Size** M · **Blocks** V3-08, V3-09 ·
|
||||
**Status** Not started
|
||||
|
||||
## Goal
|
||||
Get a self-hosted OSRM instance running somewhere, so V3-08 (road-snapped routing and
|
||||
ETA) has a real backend to build against instead of a deferred decision.
|
||||
|
||||
## Context
|
||||
V3-08 named the choice of routing engine as "a decision that cannot be deferred," and
|
||||
[the conversation that spawned this ticket](../v3/README.md) picked a direction without
|
||||
picking a provider: **self-hosted OSRM**, over a hosted API (GraphHopper, Mapbox
|
||||
Directions) or the public OSRM demo (explicitly not for production use).
|
||||
|
||||
**This ticket exists because that decision itself has a wrinkle worth naming up front:**
|
||||
this whole v3 backlog's organizing principle, stated in `docs/BACKLOG.md`, is *"v3 is
|
||||
everything that can be built with no server. v4 is everything that cannot."* A
|
||||
self-hosted OSRM instance is a server. Strictly, that makes V3-08 and V3-09 — anything
|
||||
that depends on this ticket — v4 work by the project's own definition, not v3, even
|
||||
though they're filed under `docs/v3/` today and the routing itself has nothing to do
|
||||
with the group-rides/accounts/backup programme that currently defines v4. Whether to
|
||||
formally renumber them is a documentation decision for whoever picks this up next; this
|
||||
ticket does not resolve it, only flags it so it isn't silently glossed over.
|
||||
|
||||
## Design
|
||||
Two separable questions:
|
||||
|
||||
1. **Where does it run?** A small VPS (the same shape of box that would eventually host
|
||||
the v4 group-ride server, so this could double as an early step toward that) versus
|
||||
something serverless/managed. OSRM's own Docker image is the standard path either way.
|
||||
2. **What data does it need?** A regional OSM extract, not the planet — start with
|
||||
whatever region actually gets ridden (per `docs/LAUNCH.md`, this is presently a
|
||||
friends-and-family app, so the region is small and known). [Geofabrik](https://download.geofabrik.de/)
|
||||
publishes regional `.osm.pbf` extracts sized for exactly this.
|
||||
|
||||
Profiles matter for this app specifically (see V3-08's Design section): a motorcycle
|
||||
route and a bicycle route between the same two pins should genuinely differ. OSRM ships
|
||||
car/bike/foot profiles out of the box; a motorcycle profile is closer to car (mostly
|
||||
avoids the walk-only restrictions bike profiles impose) but might want the twisty-road
|
||||
preference a stock car profile doesn't have reason to express. Confirming that is part of
|
||||
this ticket's investigation, not something to guess at now.
|
||||
|
||||
## Implementation
|
||||
1. Pick and provision a host (see Design's first question)
|
||||
2. Download and preprocess a regional extract with OSRM's own toolchain
|
||||
(`osrm-extract` → `osrm-partition` → `osrm-customize`, or the older
|
||||
`osrm-contract` pipeline depending on the OSRM version chosen)
|
||||
3. Run `osrm-routed` behind whatever the host offers for TLS termination — the app will
|
||||
be calling this over the public internet from riders' phones, so plain HTTP is not
|
||||
an option
|
||||
4. Confirm at least a car-equivalent and a bike profile both return sane routes for a
|
||||
handful of real local pin pairs, by hand, before writing any app code against it
|
||||
5. Write the resulting base URL and auth (if any) down for V3-08 to consume — as
|
||||
configuration, never a hardcoded value, matching V3-08's own "no API key committed to
|
||||
the repository" acceptance criterion, which applies here too even though there's no
|
||||
third-party vendor to protect a key from — an open, unauthenticated routing endpoint
|
||||
is still worth not publishing in a public repo
|
||||
6. A basic uptime check of some kind — this becomes a real dependency the app relies on,
|
||||
not a fire-and-forget script
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] An OSRM instance is reachable over HTTPS from outside the host network
|
||||
- [ ] Returns a road-following route for a real pin pair in the region actually ridden
|
||||
- [ ] At least two distinct profiles (car-equivalent, bike) both work
|
||||
- [ ] The endpoint and any credentials live in configuration, not source
|
||||
- [ ] Documented: what's running, where, how to update the extract when it goes stale,
|
||||
and what it costs (if anything) to keep running
|
||||
|
||||
## Tests
|
||||
Infrastructure, not app code — no `flutter test` coverage belongs to this ticket
|
||||
directly. V3-08's own `FakeRoutingService`-driven tests are what verify the app's
|
||||
behavior; this ticket's job is only to make the real thing exist for that fake to stand
|
||||
in for.
|
||||
|
||||
## Risks
|
||||
- **Ongoing hosting cost and maintenance**, however small — this is the first piece of
|
||||
always-on infrastructure this project has taken on. Worth being honest that "no
|
||||
server" stopped being true the moment this ticket is picked up, regardless of which
|
||||
numbering bucket it ends up filed under.
|
||||
- **OSM extracts go stale.** A road that didn't exist at extract time won't route.
|
||||
Needs a refresh cadence, not a one-time setup.
|
||||
- Regional extracts are cheap; do not reach for a planet-wide extract preemptively.
|
||||
|
||||
## Out of scope
|
||||
Actually building V3-08/V3-09 against this once it exists — that's their ticket, not
|
||||
this one. Turn-by-turn navigation, traffic-aware routing, anything beyond what stock OSRM
|
||||
gives you.
|
||||
Reference in New Issue
Block a user