Break v3 into 16 tickets
One file per feature, in the v2 shape that worked: goal, context, design, implementation, acceptance criteria, tests, risks, out of scope -- written before implementing so the risks are on paper rather than walked into. Three carry more weight than their size suggests. V3-01 ships the port's first real migration, and since the destructive fallback is gone, getting addColumn and a v1-database test right matters more than the feature. V3-08 forces a routing-engine decision with ongoing cost, so it sits behind a RoutingService interface mirroring what LocationSource did for GPS. V3-13 is not code at all -- it answers the three questions open since v2, and V3-15 may close unbuilt as a result, which is a legitimate outcome. Several tickets record constraints that are easy to lose: no activity picker in front of Start, because the founding premise is press-and-go with gloves; the Route-to-Trip foreign key must not cascade, or deleting an old plan deletes the ride; V3-14 will deliberately break the parity harness by adding GPX <type>, and that expectation should be updated rather than the check dropped; and V3-16 must not regress the explicit text colours that exist because of the black-on-black bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,9 @@
|
||||
Everything known-outstanding, with enough context to pick up cold. Nothing here is
|
||||
committed to — it is a menu, roughly ordered by value.
|
||||
|
||||
**v3 is now broken out into tickets: [v3/README.md](v3/README.md).** This document stays
|
||||
as the reasoning; the tickets are the executable form.
|
||||
|
||||
**v3 is everything that can be built with no server.** **v4 is everything that cannot.**
|
||||
That split is the most useful thing in this document: it means v3 can proceed indefinitely
|
||||
without anyone deciding to run infrastructure or hold other people's location data.
|
||||
|
||||
72
docs/v3/README.md
Normal file
72
docs/v3/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# v3 tickets
|
||||
|
||||
One file per feature, in the shape that worked for v2: Goal · Context · Design ·
|
||||
Implementation · Acceptance criteria · Tests · Risks · Out of scope. Written before
|
||||
implementing, so the risks are on paper before they are walked into.
|
||||
|
||||
Menu, not a commitment. Nothing here is scheduled.
|
||||
|
||||
**Everything in v3 is buildable with no server.** Group rides, accounts and paid cloud
|
||||
backup are v4 — see [../BACKLOG.md](../BACKLOG.md).
|
||||
|
||||
> **Before starting anything:** [../port/REAL-RIDE-CHECKLIST.md](../port/REAL-RIDE-CHECKLIST.md).
|
||||
> The port has never recorded a real ride, and item I3 may still force a change of GPS
|
||||
> engine — which would land underneath several of these tickets.
|
||||
|
||||
## The tickets
|
||||
|
||||
| # | Ticket | Size | Depends on |
|
||||
|---|---|---|---|
|
||||
| [V3-01](V3-01-activity-type.md) | Activity type per ride | M | — |
|
||||
| [V3-02](V3-02-settings-screen.md) | Settings screen | S | — |
|
||||
| [V3-03](V3-03-units.md) | Distance and speed units | S | V3-02 |
|
||||
| [V3-04](V3-04-live-map.md) | Live map on the recording screen | M | — |
|
||||
| [V3-05](V3-05-mounted-mode.md) | Mounted (handlebar) mode | M | V3-04 |
|
||||
| [V3-06](V3-06-notification-stats.md) | Live stats in the notification | S | — |
|
||||
| [V3-07](V3-07-route-drawing.md) | Route drawing (pins, straight lines) | M | — |
|
||||
| [V3-08](V3-08-road-routing.md) | Road-snapped routing and ETA | L | V3-07, V3-01 |
|
||||
| [V3-09](V3-09-route-following.md) | Follow a planned route | M | V3-04, V3-08 |
|
||||
| [V3-10](V3-10-trip-splitting.md) | Trip splitting | S | — |
|
||||
| [V3-11](V3-11-offline-tiles.md) | Offline tile pre-download | M | V3-04 |
|
||||
| [V3-12](V3-12-crash-reporting.md) | Crash reporting | S | — |
|
||||
| [V3-13](V3-13-real-ride-measurements.md) | Real-ride measurements | M | **riding** |
|
||||
| [V3-14](V3-14-gpx-interop.md) | GPX interoperability | S | V3-01 |
|
||||
| [V3-15](V3-15-auto-pause.md) | Auto-pause | M | V3-13 *(gated)* |
|
||||
| [V3-16](V3-16-visual-identity.md) | Visual identity | M | V3-04, V3-05 |
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
V3-01 ──┬────────────► V3-08 ──► V3-09
|
||||
└──► V3-14 ▲
|
||||
V3-07 ──────► V3-08 │
|
||||
V3-02 ──► V3-03 │
|
||||
V3-04 ──┬──► V3-05 ──┬───────┘
|
||||
├──► V3-11 └──► V3-16
|
||||
└──► V3-09
|
||||
V3-13 ──► V3-15 (gate: may close unbuilt)
|
||||
|
||||
no dependencies: V3-01 · V3-02 · V3-04 · V3-06 · V3-07 · V3-10 · V3-12
|
||||
```
|
||||
|
||||
## Three that carry more weight than their size suggests
|
||||
|
||||
**V3-01** ships the port's **first real migration**. The destructive fallback is gone, so
|
||||
getting `addColumn` plus a v1-database test right matters more than the feature does —
|
||||
V3-07 and V3-09 both add migrations behind it.
|
||||
|
||||
**V3-08** forces a routing-engine decision with ongoing cost and vendor implications.
|
||||
Behind a `RoutingService` interface, mirroring what `LocationSource` did for GPS.
|
||||
|
||||
**V3-13** is not code. It answers the three questions that have been open since v2, and
|
||||
**V3-15 may close unbuilt** as a result — a legitimate and probably likely outcome.
|
||||
|
||||
## Suggested order, if starting cold
|
||||
|
||||
1. **V3-01** — proves the migration path while the stakes are low, and unblocks V3-08/14
|
||||
2. **V3-02 + V3-03** — small, self-contained, gives V3-01 a home
|
||||
3. **V3-04** — the most visible change, and the gateway to four other tickets
|
||||
4. **V3-07** — entirely independent; useful on its own before the routing decision
|
||||
5. **V3-13** — as soon as there is a ride to measure
|
||||
|
||||
V3-12 is a good filler at any point. V3-16 should wait until the screens stop moving.
|
||||
69
docs/v3/V3-01-activity-type.md
Normal file
69
docs/v3/V3-01-activity-type.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# V3-01 — Activity type per ride
|
||||
|
||||
**Phase** Foundations · **Depends on** nothing · **Size** M · **Status** Not started
|
||||
|
||||
## Goal
|
||||
Every ride records what it was done on — motorcycle, bicycle, scooter, skateboard,
|
||||
running, walking, other — and that choice drives per-activity defaults rather than just
|
||||
labelling the row.
|
||||
|
||||
## Context
|
||||
The recording pipeline never cared what you were sitting on: it records positions, speeds
|
||||
and altitudes. Only the UI says "motorcycle". Dylan rides both bikes and motorcycles.
|
||||
|
||||
This is also a **positioning decision** (see [../LAUNCH.md](../LAUNCH.md)): it determines
|
||||
the store category, the screenshots, and who ever finds the app. Cyclists are a much larger
|
||||
audience and already pay for ride apps. Cheaper to settle before a store listing exists.
|
||||
|
||||
**This ticket ships the port's first real migration.** That matters more than the feature.
|
||||
|
||||
## Design
|
||||
Text enum column on `Trip`, exactly like `TripState`:
|
||||
`motorcycle · bicycle · scooter · skateboard · running · walking · other`
|
||||
|
||||
**Type drives defaults, not labels.** Introduce an `ActivityProfile` rather than scattering
|
||||
`if (activity == …)`:
|
||||
|
||||
| Setting | Today | Why it must vary |
|
||||
|---|---|---|
|
||||
| Speed noise floor | 1.5 km/h | Right for a motorcycle; walking lives near it |
|
||||
| Histogram bucket | 10 km/h | Useless for running — one bucket. Wants ~1 km/h |
|
||||
| Accuracy gate | 50 m | A bike at speed tolerates looser fixes than a walker |
|
||||
| Elevation smoothing window | 15 samples | Tuned for 2 Hz at road speed |
|
||||
| Map fit zoom | — | A 2 km walk and a 200 km ride differ |
|
||||
|
||||
**No picker in front of Start.** The founding premise is press-and-go with gloves on.
|
||||
Default to the last activity used; make it editable on trip detail next to rename.
|
||||
|
||||
## Implementation
|
||||
1. `Activity` enum in `domain/models.dart`; `activity` field on `Trip`
|
||||
2. Drift column with `.withDefault(Constant('motorcycle'))`
|
||||
3. **Migration**: `schemaVersion` 1 → 2, `m.addColumn(trips, trips.activity)`
|
||||
4. `ActivityProfile` in `stats/` holding the constants above; thread it through
|
||||
`computeSummary`, `speedHistogram`, `Accumulator`, `isUsableFix`
|
||||
5. Persist last-used activity in `Config`
|
||||
6. Trip detail: activity row, editable via the same pattern as rename
|
||||
7. Trips list: show the activity icon on each tile
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] A new ride records an activity; existing rides read `motorcycle`
|
||||
- [ ] A v1 database opens, migrates, and **keeps every ride and point**
|
||||
- [ ] Changing a trip's activity recomputes its aggregates under the new profile
|
||||
- [ ] Start still takes exactly one tap
|
||||
- [ ] `flutter analyze` clean, all existing tests still pass
|
||||
|
||||
## Tests
|
||||
- **Migration test** — build a v1 database, migrate, assert rides and points survive.
|
||||
Drift's `MigrationTestHelper` with a generated v1 schema.
|
||||
- Profile selection: each activity yields its own noise floor and bucket size
|
||||
- A running-activity ride produces a histogram with more than one bucket
|
||||
- Round-trip the enum through the database
|
||||
|
||||
## Risks
|
||||
- **The migration is the risk.** Getting it wrong destroys real rides, and the destructive
|
||||
fallback is deliberately gone. Write the migration test first.
|
||||
- Recomputing aggregates on activity change is easy to forget — a ride switched from
|
||||
motorcycle to walking keeps a wrong moving time otherwise.
|
||||
|
||||
## Out of scope
|
||||
Per-activity totals or a stats screen. GPX `<type>` export (see V3-14).
|
||||
49
docs/v3/V3-02-settings-screen.md
Normal file
49
docs/v3/V3-02-settings-screen.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# V3-02 — Settings screen
|
||||
|
||||
**Phase** Foundations · **Depends on** nothing (pairs with V3-01, V3-03) · **Size** S · **Status** Not started
|
||||
|
||||
## Goal
|
||||
One place for the preferences that currently have nowhere to live.
|
||||
|
||||
## Context
|
||||
`Config` already holds `uploadEndpoint`, `deviceId` and `mapEnabled`, but there is no UI
|
||||
for any of them. The map toggle sits on trip detail because a single switch did not justify
|
||||
a screen. V3-01 and V3-03 both add preferences, which finally does justify one.
|
||||
|
||||
The upload endpoint has **never** had UI, in the native app or the port. This is where it
|
||||
stops being a code-only setting.
|
||||
|
||||
## Design
|
||||
Reached from the record screen. Sections:
|
||||
|
||||
- **Recording** — default activity (V3-01), units (V3-03)
|
||||
- **Map** — render maps on trip detail; later, live map (V3-04)
|
||||
- **Sync** — upload endpoint, with the device id shown read-only and copyable
|
||||
- **About** — version, a link to the privacy policy, licences
|
||||
|
||||
Keep it plain. This is not a screen anyone should spend time in.
|
||||
|
||||
## Implementation
|
||||
1. `SettingsScreen` + a `go_router` route
|
||||
2. Make `Config` reactive — it is currently read once into a provider; settings need writes
|
||||
to propagate. A `ConfigNotifier` over `shared_preferences`.
|
||||
3. Move the map toggle off trip detail
|
||||
4. Endpoint field validates it parses as a URL and is http(s)
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Every `Config` value is viewable and editable
|
||||
- [ ] Changing the map toggle takes effect without an app restart
|
||||
- [ ] An invalid endpoint is rejected with a readable message, not silently stored
|
||||
- [ ] Device id is copyable — it is the only way to identify this install to a server
|
||||
|
||||
## Tests
|
||||
- Widget: each control renders and writes through to `Config`
|
||||
- Widget: invalid URL rejected
|
||||
- Changing the map toggle rebuilds trip detail
|
||||
|
||||
## Risks
|
||||
`Config` is currently loaded once at startup into a `StateProvider`. Making it writable
|
||||
without introducing a second source of truth is the only subtle part.
|
||||
|
||||
## Out of scope
|
||||
Account settings (v4). Theme selection (V3-16).
|
||||
50
docs/v3/V3-03-units.md
Normal file
50
docs/v3/V3-03-units.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# V3-03 — Distance and speed units
|
||||
|
||||
**Phase** Foundations · **Depends on** V3-02 · **Size** S · **Status** Not started
|
||||
|
||||
## Goal
|
||||
Imperial as well as metric, chosen once and applied everywhere.
|
||||
|
||||
## Context
|
||||
Everything is hardcoded metric: `formatDistance` switches m/km at 1000, `formatSpeed`
|
||||
prints km/h, elevation prints metres. Fine in Canada, useless to anyone in the US or UK.
|
||||
|
||||
Cheap, and the kind of thing that makes an app feel unfinished when missing.
|
||||
|
||||
## Design
|
||||
A `UnitSystem` enum (`metric`, `imperial`) in `Config`, defaulting from the device locale
|
||||
on first launch.
|
||||
|
||||
**Conversion belongs in formatting only.** Storage stays SI — metres, km/h, metres of
|
||||
altitude — forever. Converting at the storage layer would corrupt every existing ride and
|
||||
break the parity harness.
|
||||
|
||||
| Value | Metric | Imperial |
|
||||
|---|---|---|
|
||||
| Distance | m / km | ft / mi |
|
||||
| Speed | km/h | mph |
|
||||
| Elevation | m | ft |
|
||||
|
||||
## Implementation
|
||||
1. `UnitSystem` in `Config`; default from `Platform.localeName`
|
||||
2. Extend `ui/format.dart` — every formatter takes the unit system
|
||||
3. Thread it through: record screen, trips list, trip detail, charts, map legend
|
||||
4. Exports stay SI regardless. GPX is metres by specification; changing that breaks
|
||||
consumers.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Switching units updates every screen immediately
|
||||
- [ ] Stored values are unchanged — verified by exporting before and after
|
||||
- [ ] GPX/GeoJSON output is byte-identical across the two settings
|
||||
- [ ] First launch picks a sensible default from the locale
|
||||
|
||||
## Tests
|
||||
- Formatter tests for both systems, including the m→km and ft→mi boundaries
|
||||
- **A test asserting export output does not change with the unit setting**
|
||||
- Widget test toggling units and checking a rendered label
|
||||
|
||||
## Risks
|
||||
The obvious trap is converting too deep in the stack. Guard it with the export test.
|
||||
|
||||
## Out of scope
|
||||
Temperature, pace (min/km) — pace is arguably right for running, revisit after V3-01.
|
||||
62
docs/v3/V3-04-live-map.md
Normal file
62
docs/v3/V3-04-live-map.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# V3-04 — Live map on the recording screen
|
||||
|
||||
**Phase** Live map · **Depends on** nothing · **Size** M · **Status** Not started
|
||||
|
||||
## Goal
|
||||
While recording, show the path as it is drawn, on the recording screen.
|
||||
|
||||
## Context
|
||||
v2 shipped without one deliberately: the phone rides in a pocket, so a live map would burn
|
||||
battery for something nobody is looking at. **That decision is reversed** — Dylan wants it
|
||||
for visual appeal, and because a mounted phone is now a real use case (V3-05).
|
||||
|
||||
The map component already exists (`RideMap`) and already handles per-segment polylines,
|
||||
render-only decimation and the zoom clamp. This ticket is mostly about *lifecycle*, not
|
||||
drawing.
|
||||
|
||||
## Design
|
||||
Two constraints survive the reversal and are non-negotiable:
|
||||
|
||||
- **`RecordingEngine` must never reference a map.** Rendering belongs to a visible screen's
|
||||
widget lifecycle. The engine already exposes everything needed.
|
||||
- **No tile fetch or redraw while backgrounded.** A pocketed phone must cost exactly what
|
||||
it costs today.
|
||||
|
||||
Feed the map from a stream of the current trip's points. `watchTripStats` exists but
|
||||
returns aggregates; this needs the points themselves — add a `watchPointsForTrip` Drift
|
||||
stream, which updates naturally on each writer flush (~2 s), not per fix.
|
||||
|
||||
Follow the rider: keep the latest point centred, with a manual-pan override that stops
|
||||
auto-follow until re-enabled.
|
||||
|
||||
Behind the existing map toggle, off by default while it is unproven on battery.
|
||||
|
||||
## Implementation
|
||||
1. `watchPointsForTrip(tripId)` in `AppDatabase`
|
||||
2. Hoist the map above the stats card on the record screen, behind the toggle
|
||||
3. `WidgetsBindingObserver` — on `AppLifecycleState.paused`, stop tile fetching; resume on
|
||||
`resumed`. This is the load-bearing part.
|
||||
4. Auto-follow with a pan override
|
||||
5. Keep the numeric readout visible; the map must not push SPEED off screen (the record
|
||||
screen already scrolls — see the overflow fix in `port/PROGRESS.md`)
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] The path appears and extends while recording
|
||||
- [ ] Backgrounding the app stops all tile activity, verified in a network log
|
||||
- [ ] `RecordingEngine` still has no map import — grep it
|
||||
- [ ] With the toggle off, no map widget is constructed at all
|
||||
- [ ] Speed and elapsed remain visible without scrolling on a common phone size
|
||||
|
||||
## Tests
|
||||
- Widget: map appears only when recording and the toggle is on
|
||||
- Widget: lifecycle transition to paused stops the tile layer
|
||||
- The existing map tests still pass
|
||||
|
||||
## Risks
|
||||
- **Battery.** This is the whole reason v2 said no. Measure before defaulting it on
|
||||
(V3-13).
|
||||
- Redrawing per fix rather than per flush would be wasteful; drive from the database
|
||||
stream, which is already batched.
|
||||
|
||||
## Out of scope
|
||||
Mounted mode (V3-05). Other riders on the map (v4).
|
||||
53
docs/v3/V3-05-mounted-mode.md
Normal file
53
docs/v3/V3-05-mounted-mode.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# V3-05 — Mounted (handlebar) mode
|
||||
|
||||
**Phase** Live map · **Depends on** V3-04 · **Size** M · **Status** Not started
|
||||
|
||||
## Goal
|
||||
Make the app usable on handlebars in daylight, at speed, with gloves — as an explicit mode
|
||||
rather than an accident.
|
||||
|
||||
## Context
|
||||
v1 and v2 were built entirely around "start it, pocket it, stop it". **A mounted phone is a
|
||||
different product.** Treating it as a mode makes the differences deliberate instead of
|
||||
half-met.
|
||||
|
||||
## Design
|
||||
A toggle that changes several things at once:
|
||||
|
||||
- **Keep the screen awake** for the whole ride (`wakelock_plus`). Currently the screen
|
||||
sleeps and recording continues; mounted, that is wrong.
|
||||
- **Sunlight legibility.** The dark theme was chosen for glanceability at night and in a
|
||||
pocket-glance. Behind a visor in daylight it is the wrong choice — a high-contrast
|
||||
variant with larger figures is needed. This is not the same as V3-16's visual identity.
|
||||
- **Larger touch targets still.** 72 dp works stopped; at speed with gloves it does not.
|
||||
- **Interruptions.** What happens on an incoming call or a notification — the recording
|
||||
must survive and the screen must come back.
|
||||
|
||||
## Implementation
|
||||
1. `mountedMode` in `Config`, surfaced in settings and as a quick toggle on the record
|
||||
screen
|
||||
2. `wakelock_plus`, acquired on start when mounted, released on stop/pause — and released
|
||||
on `dispose`, or the screen stays lit after the app closes
|
||||
3. A high-contrast text scale applied when mounted
|
||||
4. Handle `AppLifecycleState.inactive` (a call arriving) distinctly from `paused`
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Mounted: the screen never sleeps during a ride
|
||||
- [ ] Un-mounted: behaviour is exactly as today
|
||||
- [ ] The wake lock is released on stop, on discard, and on app exit
|
||||
- [ ] An incoming call does not stop recording
|
||||
- [ ] Battery cost of mounted mode is measured and written down (V3-13)
|
||||
|
||||
## Tests
|
||||
- Widget: mounted toggle changes text scale and requests the wake lock (fake the plugin)
|
||||
- Widget: the lock is released on stop
|
||||
- **Manual, on a real bike** — legibility in daylight cannot be tested any other way
|
||||
|
||||
## Risks
|
||||
- **A leaked wake lock flattens the battery**, silently and after the app is closed. Test
|
||||
the release path harder than the acquire path.
|
||||
- Legibility is a judgement call that needs a real ride in real sun.
|
||||
|
||||
## Out of scope
|
||||
A dedicated mounted layout with different information architecture — start by scaling what
|
||||
exists and see what the ride teaches.
|
||||
52
docs/v3/V3-06-notification-stats.md
Normal file
52
docs/v3/V3-06-notification-stats.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# V3-06 — Live stats in the notification
|
||||
|
||||
**Phase** Live map · **Depends on** nothing · **Size** S · **Status** Not started
|
||||
|
||||
## Goal
|
||||
Distance and duration readable from the notification shade without unlocking.
|
||||
|
||||
## Context
|
||||
The ongoing notification currently says "Rippr is recording / Tracking your ride" — static
|
||||
text. During a pocketed ride that is a wasted surface.
|
||||
|
||||
**Constraint that shapes this whole ticket:** the notification is owned by `geolocator`'s
|
||||
`ForegroundNotificationConfig`, which takes fixed strings at stream-subscription time and
|
||||
offers no update path and no actions. That is the parity gap recorded in
|
||||
[../port/PARITY-AUDIT.md](../port/PARITY-AUDIT.md).
|
||||
|
||||
## Design
|
||||
Two honest options:
|
||||
|
||||
**A. Restart the position stream with new text.** Cheap to write, but it tears down and
|
||||
re-establishes location updates every time — unacceptable during recording.
|
||||
|
||||
**B. Take the notification back with `flutter_local_notifications`,** and let geolocator
|
||||
raise a silent minimal one. More moving parts, but it also **restores Pause/Resume actions**
|
||||
— closing the one capability lost in the port.
|
||||
|
||||
**Recommend B**, precisely because it buys back the parity gap as well.
|
||||
|
||||
## Implementation
|
||||
1. Add `flutter_local_notifications`
|
||||
2. Own a notification on the same channel, updated on each writer flush (~2 s), not per fix
|
||||
3. Pause/Resume actions routed back into `RecordingEngine`
|
||||
4. Android 13+ notification permission is already requested
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Distance and elapsed update while riding, without unlocking
|
||||
- [ ] Pause and Resume work from the shade
|
||||
- [ ] Location updates are **not** interrupted when the notification changes
|
||||
- [ ] The notification cannot be swiped away mid-ride
|
||||
|
||||
## Tests
|
||||
- Unit: notification text formatting from a trip
|
||||
- Integration on a device: start, confirm the text advances, pause from the shade, confirm
|
||||
the engine actually paused (check the database, do not trust the UI)
|
||||
|
||||
## Risks
|
||||
- Two notification sources fighting is the obvious failure. Verify only one is visible.
|
||||
- An update per fix would be a battery and jank problem; drive it from the flush.
|
||||
|
||||
## Out of scope
|
||||
iOS. There is no equivalent live notification surface; a Live Activity is a much larger
|
||||
piece of work and belongs in its own ticket.
|
||||
58
docs/v3/V3-07-route-drawing.md
Normal file
58
docs/v3/V3-07-route-drawing.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# V3-07 — Route drawing (pins and straight lines)
|
||||
|
||||
**Phase** Route planning · **Depends on** nothing · **Size** M · **Status** Not started
|
||||
|
||||
## Goal
|
||||
Drop pins on a map to sketch a route, see the straight-line distance, and save it. The
|
||||
foundation for V3-08, deliberately shipped without a routing engine.
|
||||
|
||||
## Context
|
||||
Pre-ride planning is a **genuinely new mode**, not an extension of recording. It needs no
|
||||
ride in progress, no server, and nothing else in v3 — the most independent thing in the
|
||||
backlog.
|
||||
|
||||
Split from road-snapped routing (V3-08) on purpose: pins, storage, editing and the map
|
||||
interaction are all needed either way, and none of them require choosing a routing vendor.
|
||||
That decision should not block a usable feature.
|
||||
|
||||
## Design
|
||||
**New entities, separate from `Trip`.** A plan is not a recording, and conflating them
|
||||
would put unridden kilometres into ride totals.
|
||||
|
||||
```
|
||||
Route(id, name, createdAt, activity?, distanceM, estimatedMillis?, geometry?)
|
||||
Waypoint(id, routeId, ordinal, latitude, longitude, name?)
|
||||
```
|
||||
|
||||
`geometry` is null in this ticket; V3-08 fills it with the road-snapped polyline.
|
||||
`ordinal` rather than relying on insertion id, so waypoints can be reordered.
|
||||
|
||||
Straight-line distance reuses `haversineMeters` — already ported and parity-proven.
|
||||
|
||||
## Implementation
|
||||
1. Drift tables + **migration** (the second one; V3-01 proves the path)
|
||||
2. `RouteRepository` mirroring `TripRepository`'s shape
|
||||
3. `RoutePlannerScreen` — tap to add a pin, drag to move, tap a pin to delete, reorder
|
||||
4. Straight-line polyline between pins, visibly distinct from a recorded path
|
||||
5. Routes list, reachable from the record screen alongside Rides
|
||||
6. Name, rename, delete
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Pins can be added, moved, reordered and deleted
|
||||
- [ ] Distance updates live as pins change
|
||||
- [ ] A saved route survives an app restart
|
||||
- [ ] Routes never appear in the rides list, and never contribute to ride totals
|
||||
- [ ] Deleting a route cascades to its waypoints
|
||||
|
||||
## Tests
|
||||
- Repository: create, reorder, delete, cascade — in-memory, like the trip tests
|
||||
- Distance matches `pathLengthMeters` over the same points
|
||||
- Widget: tapping the map adds a pin; the distance label updates
|
||||
- **A test asserting routes are absent from `watchCompletedTrips`**
|
||||
|
||||
## Risks
|
||||
The main one is scope drift into V3-08. Ship straight lines first; they are genuinely
|
||||
useful for a rough plan.
|
||||
|
||||
## Out of scope
|
||||
Road snapping, ETA, following a route while riding. Import of existing GPX routes.
|
||||
76
docs/v3/V3-08-road-routing.md
Normal file
76
docs/v3/V3-08-road-routing.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# V3-08 — Road-snapped routing and ETA
|
||||
|
||||
**Phase** Route planning · **Depends on** V3-07, benefits from V3-01 · **Size** L · **Status** Not started
|
||||
|
||||
## Goal
|
||||
Resolve the actual shortest path along roads between pins, and estimate how long the ride
|
||||
will take.
|
||||
|
||||
## Context
|
||||
This is what Dylan asked for. It is also the ticket with a **decision that cannot be
|
||||
deferred**: road-snapped routing needs a routing engine over OpenStreetMap data, and the
|
||||
choice has ongoing consequences.
|
||||
|
||||
## Design
|
||||
|
||||
### Choosing an engine — decide before writing code
|
||||
|
||||
| Option | Trade-off |
|
||||
|---|---|
|
||||
| **Public OSRM demo** | Free, zero setup, **explicitly not for production**, rate-limited. Prototype only. |
|
||||
| **Self-hosted OSRM** | Fast, well understood. Needs a machine and a regional OSM extract (a province is a few GB). |
|
||||
| **GraphHopper** | Self-hostable, good cycling/motorcycle profiles, friendlier ETAs |
|
||||
| **Valhalla** | Best multi-modal profiles, heaviest to run |
|
||||
| **Mapbox / Google** | No ops, per-request billing, an API key shipped in the app |
|
||||
|
||||
**Profiles matter more here than usual.** A motorcycle route and a bicycle route between
|
||||
the same pins genuinely differ — cycling engines avoid motorways, and a motorcyclist often
|
||||
wants the twisty road rather than the fast one. This is where V3-01 pays off: the activity
|
||||
selects the profile.
|
||||
|
||||
Put it behind a `RoutingService` interface with a fake, exactly as `LocationSource` did for
|
||||
GPS. That seam is what made swapping the location engine cheap, and the same argument
|
||||
applies here.
|
||||
|
||||
### ETA is a promise, and easy to get wrong
|
||||
Engines estimate from posted speed limits. That is not how long *you* take. Once there is
|
||||
history, the rider's own average moving speed for that activity — already stored on every
|
||||
`Trip` — is a better predictor.
|
||||
|
||||
Show the engine's estimate, then replace it with a personal one once there is enough
|
||||
history to justify it. Label which is which.
|
||||
|
||||
### Caching
|
||||
Cache the returned polyline on `Route.geometry`. A saved plan must open offline and must
|
||||
not re-bill a request every time it is viewed.
|
||||
|
||||
## Implementation
|
||||
1. Decide the engine. Write the decision and its reasoning into this file.
|
||||
2. `RoutingService` interface + implementation + `FakeRoutingService`
|
||||
3. Resolve on pin change, debounced — not on every drag frame
|
||||
4. Persist geometry, distance and duration on `Route`
|
||||
5. Personal ETA from `Trip` history, once ≥5 rides of that activity exist
|
||||
6. Graceful offline behaviour: fall back to straight lines and say so
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Pins resolve to a road-following polyline
|
||||
- [ ] Distance reflects the road path, not the straight line
|
||||
- [ ] Activity changes the profile and can change the route
|
||||
- [ ] A saved route renders offline from cached geometry, with no network call
|
||||
- [ ] Offline with no cache degrades to straight lines with a visible explanation
|
||||
- [ ] No API key is committed to the repository
|
||||
|
||||
## Tests
|
||||
- `FakeRoutingService` drives every path: success, failure, offline, empty
|
||||
- Cached geometry means no second request — assert the fake is called once
|
||||
- Personal ETA maths against fixed history
|
||||
- **No live network calls in any test**
|
||||
|
||||
## Risks
|
||||
- **Vendor lock-in and cost.** The interface is the mitigation.
|
||||
- Debouncing matters: dragging a pin could otherwise fire dozens of requests.
|
||||
- OSM route quality varies. It will occasionally suggest something daft; that is the data,
|
||||
not a bug to chase.
|
||||
|
||||
## Out of scope
|
||||
Turn-by-turn navigation and voice guidance. That is a different product.
|
||||
53
docs/v3/V3-09-route-following.md
Normal file
53
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 · **Size** M · **Status** Not started
|
||||
|
||||
## Goal
|
||||
Pick a saved route before starting, see it on the live map underneath your actual track,
|
||||
and afterwards compare what you rode against what you planned.
|
||||
|
||||
## Context
|
||||
The payoff that makes V3-07 and V3-08 worth building, and the point where route planning
|
||||
meets the live map. Not navigation — no turn-by-turn, no voice. Just the line you meant to
|
||||
follow, drawn under the line you actually rode.
|
||||
|
||||
## Design
|
||||
Attach an optional `routeId` to `Trip`. That is enough for both the live overlay and the
|
||||
after-the-fact comparison.
|
||||
|
||||
Live: the planned route in a muted colour, the recorded track drawn over it in the existing
|
||||
speed colours. Immediately obvious when you have left the plan.
|
||||
|
||||
Afterwards, on trip detail: both lines, plus how far you deviated and how the real duration
|
||||
compared with the estimate — which also, over time, tells you how honest the ETA is.
|
||||
|
||||
**Deliberately not:** rerouting, off-route alerts, or anything that demands attention while
|
||||
riding. A rider glancing at handlebars wants a picture, not an interruption.
|
||||
|
||||
## Implementation
|
||||
1. `routeId` on `Trip` — **third migration**
|
||||
2. Route picker on the record screen before Start, defaulting to none
|
||||
3. Live map renders the planned polyline beneath the track
|
||||
4. Trip detail renders both, with a comparison block
|
||||
5. Deviation: max and mean distance from the recorded points to the planned polyline —
|
||||
`perpendicularDistanceMeters` already exists and is parity-proven
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] A route can be selected before starting, or not
|
||||
- [ ] Both lines render, visually distinguishable
|
||||
- [ ] Deviation and duration-vs-estimate appear on trip detail
|
||||
- [ ] A ride with no route behaves exactly as today
|
||||
- [ ] Deleting a route does not delete rides that referenced it
|
||||
|
||||
## Tests
|
||||
- Deviation maths against a known track and route
|
||||
- Repository: deleting a route nulls `routeId` rather than cascading to the trip —
|
||||
**the cascade direction here is the opposite of segments and is easy to get wrong**
|
||||
- Widget: both polylines present when a route is attached
|
||||
|
||||
## Risks
|
||||
The `Route` → `Trip` foreign key must **not** cascade. Deleting an old plan must never
|
||||
delete the ride you did.
|
||||
|
||||
## Out of scope
|
||||
Turn-by-turn, off-route alerts, rerouting.
|
||||
53
docs/v3/V3-10-trip-splitting.md
Normal file
53
docs/v3/V3-10-trip-splitting.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# V3-10 — Trip splitting
|
||||
|
||||
**Phase** Ride management · **Depends on** nothing · **Size** S · **Status** Not started
|
||||
|
||||
## Goal
|
||||
Split one recorded ride into two at a chosen point. The natural counterpart to merge.
|
||||
|
||||
## Context
|
||||
Merge exists and is well tested; split does not. The case is a rider who forgot to stop —
|
||||
one "ride" that is really the trip out, lunch, and the trip home.
|
||||
|
||||
Merge already establishes the hard parts: re-parenting points and segments inside a
|
||||
transaction, and recomputing aggregates rather than summing them.
|
||||
|
||||
## Design
|
||||
Split at a **segment boundary** rather than an arbitrary point. Segments already mark where
|
||||
the rider paused, which is exactly where a forgotten stop shows up — and it avoids
|
||||
inventing a new boundary type or splitting a segment in half.
|
||||
|
||||
The original trip keeps the earlier segments; a new trip takes the later ones. Both get
|
||||
aggregates recomputed from the points they actually own.
|
||||
|
||||
If a ride has only one segment there is nothing to split, and the UI should say so rather
|
||||
than offering a dead control.
|
||||
|
||||
## Implementation
|
||||
1. `TripRepository.splitTrip(tripId, atSegmentId)` inside a transaction:
|
||||
create the new trip, re-parent segments and points from `atSegmentId` onward,
|
||||
set `startedAt`/`endedAt` from the segments each trip now owns,
|
||||
recompute aggregates for both
|
||||
2. Trip detail: a split action listing segment boundaries with their times
|
||||
3. Confirmation naming what the two resulting rides will be
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Splitting produces two trips whose point counts sum to the original
|
||||
- [ ] Neither trip's distance includes the gap between them
|
||||
- [ ] Both have plausible `startedAt`/`endedAt`
|
||||
- [ ] Single-segment rides cannot be split, and the UI explains why
|
||||
- [ ] Atomic — a failure part-way leaves the original intact
|
||||
|
||||
## Tests
|
||||
- Point counts sum; no points orphaned
|
||||
- Distance of the parts is less than the original by roughly the gap
|
||||
- Split then merge returns to the original aggregates — a good round-trip property
|
||||
- Rejects a single-segment trip
|
||||
- Atomicity under a forced mid-transaction failure
|
||||
|
||||
## Risks
|
||||
Getting `startedAt`/`endedAt` from the wrong source. Derive them from the segments each
|
||||
trip owns, not from the original trip.
|
||||
|
||||
## Out of scope
|
||||
Splitting mid-segment.
|
||||
51
docs/v3/V3-11-offline-tiles.md
Normal file
51
docs/v3/V3-11-offline-tiles.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# V3-11 — Offline tile pre-download
|
||||
|
||||
**Phase** Ride management · **Depends on** V3-04 · **Size** M · **Status** Not started
|
||||
|
||||
## Goal
|
||||
Have map tiles available where there is no signal.
|
||||
|
||||
## Context
|
||||
`flutter_map` caches what it renders, so a re-viewed ride works. A **mountain ride with no
|
||||
signal shows blank tiles** — precisely where a map is most wanted.
|
||||
|
||||
## Design
|
||||
**Respect OSM's tile usage policy. Bulk prefetching their public servers is prohibited**
|
||||
and would get the app blocked. This constraint decides the design:
|
||||
|
||||
- Pre-download only a **user-chosen area**, at a **limited zoom range**, with a visible
|
||||
tile count and size estimate before starting
|
||||
- Rate-limited, sequential, cancellable
|
||||
- If this becomes a headline feature, move to a paid tile provider or self-hosted tiles.
|
||||
Do not scale it on OSM's donated infrastructure.
|
||||
|
||||
Natural pairing with V3-07: pre-download the corridor along a planned route rather than a
|
||||
rectangle — far fewer tiles for the same usefulness.
|
||||
|
||||
## Implementation
|
||||
1. Persistent tile cache with a size cap and eviction (`flutter_map_cache` or similar)
|
||||
2. Area selection on the map, plus a "download along this route" option
|
||||
3. Tile count and MB estimate **before** any request
|
||||
4. Sequential fetch with a delay, a progress indicator and cancellation
|
||||
5. Settings: cache size, and a way to clear it
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] A downloaded area renders with the network off
|
||||
- [ ] Count and size shown before download starts
|
||||
- [ ] Cancellable mid-download, keeping what has already arrived
|
||||
- [ ] A hard cap on tiles per request — no unbounded area selection
|
||||
- [ ] Cache size visible and clearable
|
||||
|
||||
## Tests
|
||||
- Tile-count maths for a bounding box across zoom levels
|
||||
- Cache eviction at the cap
|
||||
- Cancellation leaves a consistent cache
|
||||
- **Manual:** aeroplane mode over a downloaded area
|
||||
|
||||
## Risks
|
||||
- **Abusing OSM's servers.** Cap, rate-limit, and be conservative. A blocked user agent
|
||||
would break the map for everyone.
|
||||
- Storage growth. Tiles add up fast; the cap is not optional.
|
||||
|
||||
## Out of scope
|
||||
Vector tiles or a full offline basemap.
|
||||
52
docs/v3/V3-12-crash-reporting.md
Normal file
52
docs/v3/V3-12-crash-reporting.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# V3-12 — Crash reporting
|
||||
|
||||
**Phase** Quality · **Depends on** nothing · **Size** S · **Status** Not started
|
||||
|
||||
## Goal
|
||||
Know when the app dies mid-ride.
|
||||
|
||||
## Context
|
||||
There is none. A recorder that crashes during a ride currently leaves no trace beyond
|
||||
logcat, which nobody reads — and the failure mode that matters most (recording stopping
|
||||
silently) is exactly the one the user cannot report usefully.
|
||||
|
||||
Becomes important the moment anyone who is not Dylan uses it.
|
||||
|
||||
## Design
|
||||
Sentry or Firebase Crashlytics. **Sentry is the better fit**: it is not tied to Google
|
||||
services, works identically on both platforms, and its free tier is ample here.
|
||||
|
||||
**A crash reporter in a location app is a privacy surface.** Configure it deliberately:
|
||||
|
||||
- No location data in breadcrumbs or context, ever
|
||||
- No device id, no ride contents
|
||||
- Explicit opt-out in settings, and disclosed in the privacy policy
|
||||
- Debug builds report nowhere
|
||||
|
||||
Beyond crashes, one custom event is worth having: **recording ended unexpectedly** — the
|
||||
engine stopping without a user stop. That is the failure the app exists to avoid.
|
||||
|
||||
## Implementation
|
||||
1. Add `sentry_flutter`, initialised in `main()` behind a config flag
|
||||
2. Scrub: no coordinates, no ids, no trip contents in any payload
|
||||
3. Breadcrumbs for lifecycle transitions only
|
||||
4. A custom event when a recording ends without a user action
|
||||
5. Settings toggle, defaulting **off** until a privacy policy exists
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] A forced crash appears in Sentry from a release build
|
||||
- [ ] No coordinate ever appears in a payload — inspect a real one
|
||||
- [ ] The toggle genuinely disables reporting
|
||||
- [ ] Debug builds send nothing
|
||||
|
||||
## Tests
|
||||
- The scrubber strips coordinates from a representative payload
|
||||
- Reporting disabled means the client is never initialised
|
||||
- Manual: force a crash in a release build and check it lands
|
||||
|
||||
## Risks
|
||||
Leaking location through breadcrumbs or a stack frame's captured state. Inspect a real
|
||||
payload rather than assuming the scrubber works.
|
||||
|
||||
## Out of scope
|
||||
Analytics or usage tracking. Different purpose, different consent.
|
||||
61
docs/v3/V3-13-real-ride-measurements.md
Normal file
61
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.
|
||||
51
docs/v3/V3-14-gpx-interop.md
Normal file
51
docs/v3/V3-14-gpx-interop.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# V3-14 — GPX interoperability
|
||||
|
||||
**Phase** Quality · **Depends on** V3-01 for `<type>` · **Size** S · **Status** Not started
|
||||
|
||||
## Goal
|
||||
Confirm an exported ride actually imports into Strava, Garmin Connect and Google Earth —
|
||||
and add the activity type so it lands as the right kind of activity.
|
||||
|
||||
## Context
|
||||
Export is well tested: 15 tests, parsed with a real XML parser, and **byte-identical to the
|
||||
Kotlin original** under the parity harness. But every one of those tests proves *structural
|
||||
validity*, and structural validity does not mean a consumer accepts the file. That gap has
|
||||
been open since v2.
|
||||
|
||||
## Design
|
||||
Two parts.
|
||||
|
||||
**Verification** — export a real ride and import it into each of Strava, Garmin Connect and
|
||||
Google Earth. Record what each does with pauses, elevation and timestamps. Pauses are the
|
||||
interesting case: `<trkseg>` per segment is the correct GPX representation, but consumers
|
||||
vary in whether they honour it.
|
||||
|
||||
**`<type>` on `<trk>`** — Strava and Garmin read it to decide the activity. Without it a
|
||||
bicycle ride may import as a run. Needs V3-01's activity, mapped to each consumer's
|
||||
vocabulary (Strava uses `ride`, `run`, and so on).
|
||||
|
||||
## Implementation
|
||||
1. Add `<type>` to the `<trk>` element, from the trip's activity
|
||||
2. Map the internal enum to GPX conventions; document the mapping in the code
|
||||
3. Export a real multi-segment ride and import it into all three consumers
|
||||
4. Write the findings into this file, including anything that surprises
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] A real ride imports into Strava with the right activity type
|
||||
- [ ] It imports into Garmin Connect
|
||||
- [ ] It opens in Google Earth with the path in the right place
|
||||
- [ ] Pause behaviour in each consumer is documented, whatever it turns out to be
|
||||
- [ ] Existing export tests still pass, including the byte-identical parity check —
|
||||
**this one will need updating, since `<type>` changes the output**
|
||||
|
||||
## Tests
|
||||
- `<type>` present and correct per activity
|
||||
- Absent, not empty, when the activity is `other`
|
||||
- **The parity harness will now differ from Kotlin here. That is expected and correct —
|
||||
update its expectation and note why, rather than dropping the check.**
|
||||
|
||||
## Risks
|
||||
Silently breaking the parity harness by changing export output. Update it deliberately.
|
||||
|
||||
## Out of scope
|
||||
GPX import into Rippr. FIT and TCX formats.
|
||||
57
docs/v3/V3-15-auto-pause.md
Normal file
57
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.
|
||||
58
docs/v3/V3-16-visual-identity.md
Normal file
58
docs/v3/V3-16-visual-identity.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# V3-16 — Visual identity
|
||||
|
||||
**Phase** Quality · **Depends on** V3-04, V3-05 · **Size** M · **Status** Not started
|
||||
|
||||
## Goal
|
||||
Move from "functional dark" to a look that is deliberately designed.
|
||||
|
||||
## Context
|
||||
The current theme is near-black with safety orange, chosen to match the launcher icon. It
|
||||
is clean and legible, and it was never actually *designed* — it was picked so the app did
|
||||
not look unfinished.
|
||||
|
||||
**Deliberately sequenced after the live map and mounted mode.** Both change what the app
|
||||
looks like far more than a palette does, and designing around screens that are about to
|
||||
change is wasted effort.
|
||||
|
||||
## Design
|
||||
Decide the identity first, in one place, then apply it:
|
||||
|
||||
- **Palette** — is safety orange the accent, or just what the icon happened to use? A
|
||||
motorcycle app has obvious references (dashboard instruments, race liveries, road
|
||||
signage) and obvious clichés to avoid.
|
||||
- **Typography** — the app is numbers-first. The monospace tabular figures are already
|
||||
right for that; the rest is undecided.
|
||||
- **Data display** — the speed readout, the charts and the map legend are the identity far
|
||||
more than any chrome. This is an instrument, not a document.
|
||||
- **Motion** — currently none. A ride recorder probably wants very little.
|
||||
|
||||
**Constraint that outranks aesthetics:** legibility through a visor, in daylight, at a
|
||||
glance. V3-05 may force a high-contrast variant, and the identity has to survive it.
|
||||
|
||||
## Implementation
|
||||
1. Write the direction down — palette, type, and what the app is trying to feel like —
|
||||
before touching code
|
||||
2. Extend `ripprColors` into a fuller token set
|
||||
3. Apply screen by screen, keeping `flutter test` green throughout
|
||||
4. **Keep the explicit text colours.** The theme names `bodyColor` and `displayColor`
|
||||
deliberately: a missing default once rendered a 64 sp figure black-on-black and only a
|
||||
screenshot caught it. Do not regress that while restyling.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] A written direction exists before the code changes
|
||||
- [ ] Applied consistently across all six screens
|
||||
- [ ] Contrast ratios meet WCAG AA for body text
|
||||
- [ ] Legible in direct sunlight — verified on a real phone outdoors
|
||||
- [ ] All widget tests still pass, including the black-on-black guard
|
||||
|
||||
## Tests
|
||||
- Existing widget tests must keep passing; they encode real regressions
|
||||
- Contrast assertions for primary text on each surface
|
||||
- Golden tests are worth considering here, and only here — this is the one ticket where
|
||||
pixel changes are the point
|
||||
|
||||
## Risks
|
||||
Restyling breaking the explicit-colour discipline that exists because of a real bug.
|
||||
|
||||
## Out of scope
|
||||
A new app icon. The Route mark is good and recently applied.
|
||||
Reference in New Issue
Block a user