T27: parity audit and handover
PARITY-AUDIT.md walks every row of the v2.0.1 feature table with the evidence behind each claim, keeping verified, implemented-but-unproven, and gap strictly apart rather than ticking boxes in bulk. Carried the durable docs forward: ARCHITECTURE.md with what changed and why, a README covering status and the parity harness, and the v3 backlog with a header noting the two items whose status changed. The native repo's README now opens with a pointer here and records both bugs the port found. All internal doc links verified. The cutover step is deliberately left undone: the application id stays com.rippr.port so both apps can be installed together, because the real-ride checklist depends on recording the same ride on both at once. That comparison is worth more than finishing tidily. The native repo is marked superseded rather than archived -- it is still the only version that has recorded a real ride. Also corrected an arithmetic slip: 175 tests, not 178. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
107
README.md
107
README.md
@@ -1,17 +1,102 @@
|
|||||||
# rippr
|
# Rippr
|
||||||
|
|
||||||
GPS ride recorder for motorcycles
|
A GPS ride recorder for motorcycles, on **Android and iOS**. Records telemetry into a
|
||||||
|
local SQLite database, renders the traversed path on a map afterwards, and exports to
|
||||||
|
GPX/GeoJSON.
|
||||||
|
|
||||||
## Getting Started
|
Built for one specific use: **hit start, put the phone in a pocket, ride, hit stop.**
|
||||||
|
Most of the design follows from that.
|
||||||
|
|
||||||
This project is a starting point for a Flutter application.
|
This is a Flutter port of the native Android app in `~/dojo/rippr` (v1 → v2.0.1). The port
|
||||||
|
is feature-complete; see [docs/port/PARITY-AUDIT.md](docs/port/PARITY-AUDIT.md) for
|
||||||
|
exactly what is verified and what is not.
|
||||||
|
|
||||||
A few resources to get you started if this is your first Flutter project:
|
```
|
||||||
|
Flutter 3.47 · Dart 3.13 Android minSdk 26 · iOS 12+
|
||||||
|
~4,600 lines Dart 175 tests (171 unit/widget, 4 integration)
|
||||||
|
```
|
||||||
|
|
||||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
## Status
|
||||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
|
||||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
|
||||||
|
|
||||||
For help getting started with Flutter development, view the
|
| Feature | Status |
|
||||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
|---|---|
|
||||||
samples, guidance on mobile development, and a full API reference.
|
| GPS recording, foreground on Android / background mode on iOS | Implemented; **not yet ridden** |
|
||||||
|
| Trips with pause / resume / stop / discard | Working, 50 tests |
|
||||||
|
| Live speed + elapsed clock | Working |
|
||||||
|
| Path on OpenStreetMap, per-segment, speed-coloured | Working; colouring unverifiable without a real ride |
|
||||||
|
| Ride statistics + charts | Working; bit-identical to the Kotlin original |
|
||||||
|
| Rename / delete / merge rides | Working |
|
||||||
|
| GPX + GeoJSON export | Working; byte-identical to the Kotlin original |
|
||||||
|
| Upload to a REST endpoint | Implemented, **no UI** (as in the native app) |
|
||||||
|
| Notification actions (Pause/Resume in the shade) | **Absent** — the one capability lost |
|
||||||
|
| Live map while recording · group ride | Deferred to v3 |
|
||||||
|
|
||||||
|
> **The port has never recorded a real ride.** No simulator produces velocity, so max
|
||||||
|
> speed, moving time, speed colouring, elevation against real GPS error, battery, and iOS
|
||||||
|
> stationary suspension are all unverified. That is
|
||||||
|
> [docs/port/REAL-RIDE-CHECKLIST.md](docs/port/REAL-RIDE-CHECKLIST.md), and it is the next
|
||||||
|
> thing that should happen.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Requires **JDK 17–21** for Android (AGP rejects 25) and Xcode for iOS.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
dart run build_runner build # Drift codegen
|
||||||
|
flutter analyze && flutter test
|
||||||
|
flutter test integration_test -d <device-id>
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
**Watch the disk.** A full dual-platform build cycle costs roughly 10 GB, and the Android
|
||||||
|
emulator needs 7.4 GB free just to boot. Run `flutter clean` before booting it.
|
||||||
|
|
||||||
|
## Cross-language parity
|
||||||
|
|
||||||
|
The strongest guarantee in this repo. It compiles the **real Kotlin sources** from the
|
||||||
|
native app and diffs them against the Dart port across twenty fixtures:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install kotlin
|
||||||
|
JAVA_HOME=<jdk-21> ./tool/parity/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything matches to the last digit — including the elevation accumulator at
|
||||||
|
`38.959594555022136` and byte-identical GPX/GeoJSON. The single expected difference is
|
||||||
|
speed-derived values, where Kotlin's 32-bit `Float` widens with artefacts Dart's uniform
|
||||||
|
`double` does not reproduce.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Document | Contents |
|
||||||
|
|---|---|
|
||||||
|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Why it is built this way, and what changed from the native app |
|
||||||
|
| [docs/port/PLAN.md](docs/port/PLAN.md) | The 28-task migration plan |
|
||||||
|
| [docs/port/PROGRESS.md](docs/port/PROGRESS.md) | What actually happened, including every bug found |
|
||||||
|
| [docs/port/PARITY-AUDIT.md](docs/port/PARITY-AUDIT.md) | Feature-by-feature, with the evidence behind each claim |
|
||||||
|
| [docs/port/REAL-RIDE-CHECKLIST.md](docs/port/REAL-RIDE-CHECKLIST.md) | **The outstanding work** |
|
||||||
|
| [docs/port/RELEASE-IOS.md](docs/port/RELEASE-IOS.md) | App Review readiness |
|
||||||
|
| [docs/PORT_RESEARCH.md](docs/PORT_RESEARCH.md) | The original research this plan was built on |
|
||||||
|
|
||||||
|
The native repo's `docs/v1/`, `docs/v2/` and `docs/v3/BACKLOG.md` remain the record of how
|
||||||
|
the app got here and where it is going.
|
||||||
|
|
||||||
|
## Two bugs this port found in the native app
|
||||||
|
|
||||||
|
**Crash recovery measured the dead time as distance.** `restoreAfterProcessDeath` says it
|
||||||
|
resumes into a new segment; it actually adopts the segment a crash left open, so the
|
||||||
|
authoritative recomputation measures straight through the gap. Reproduced at **111 km** of
|
||||||
|
phantom distance. Fixed here.
|
||||||
|
|
||||||
|
**The elevation regression guard passes on seed luck.** On a shared fixture the algorithm
|
||||||
|
yields ~39 m, which would fail the native test's own 35 m bound. It passes only because
|
||||||
|
`kotlin.random.Random(42)` happens to draw a benign sequence.
|
||||||
|
|
||||||
|
## Before shipping
|
||||||
|
|
||||||
|
- Switch the application id from `com.rippr.port` back to `com.rippr`. The suffix exists
|
||||||
|
so the native app can be installed alongside during the port — **keep it until the real
|
||||||
|
ride comparison is done**.
|
||||||
|
- Replace the default Flutter app icon; the native artwork is in `~/dojo/rippr/design/`.
|
||||||
|
- Build and test a release build. Everything so far has been debug.
|
||||||
|
|||||||
141
docs/ARCHITECTURE.md
Normal file
141
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
Why Rippr is built this way. Ported from the native app's `docs/ARCHITECTURE.md`, keeping
|
||||||
|
the reasoning that still holds and recording what changed.
|
||||||
|
|
||||||
|
```
|
||||||
|
LocationSource (geolocator) ← the only file that knows the platforms differ
|
||||||
|
│ LocationFix
|
||||||
|
▼
|
||||||
|
RecordingEngine ── unbounded buffer ──► single writer loop (25 fixes / 2 s)
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┴──────────────┐
|
||||||
|
│ ▼ ▼
|
||||||
|
│ TripRepository Accumulator
|
||||||
|
│ (all transitions) distance / moving time /
|
||||||
|
│ │ elevation, folded per batch
|
||||||
|
│ ▼
|
||||||
|
│ Drift (SQLite)
|
||||||
|
│ Trip → Segment → TrackPoint
|
||||||
|
│ │
|
||||||
|
└──► LiveTelemetry ▼
|
||||||
|
(speedo) Riverpod providers
|
||||||
|
│
|
||||||
|
┌──────────────────┼──────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
RecordScreen TripsScreen TripDetailScreen
|
||||||
|
└─ RideMap (flutter_map)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The decisions that carry the most weight
|
||||||
|
|
||||||
|
### The fix path never blocks
|
||||||
|
|
||||||
|
A GPS fix is appended to an in-memory list and nothing else. A single writer loop drains
|
||||||
|
it in batches every two seconds. Slow disk stalls the writer, never the fix stream, and no
|
||||||
|
fix is dropped under back pressure.
|
||||||
|
|
||||||
|
Kotlin used `Channel(UNLIMITED)` plus a coroutine blocking on `receive()`. Dart has no
|
||||||
|
blocking receive and its single-threaded event loop makes one unnecessary — but the
|
||||||
|
guarantee is identical, and it is the reason a 2 Hz stream does not become two database
|
||||||
|
transactions per second.
|
||||||
|
|
||||||
|
### Recording state lives in the database
|
||||||
|
|
||||||
|
A row in `trips` with `endedAt IS NULL` **is** the fact of an in-progress ride. It survives
|
||||||
|
process death, which an in-memory flag cannot: Android can restart the process with no
|
||||||
|
intent, iOS can suspend and resume, and in both cases a flag would come back `false` while
|
||||||
|
a ride was genuinely underway.
|
||||||
|
|
||||||
|
### Points are stamped at creation
|
||||||
|
|
||||||
|
Every point carries its `tripId` and `segmentId` from the moment it is built, never looked
|
||||||
|
up at write time. This is what makes pause correct: a fix still buffered when the rider
|
||||||
|
pauses is written to the segment it was actually recorded during.
|
||||||
|
|
||||||
|
Refactoring this into a write-time lookup would break the guarantee **silently**.
|
||||||
|
|
||||||
|
### Segments make pause correct rather than cosmetic
|
||||||
|
|
||||||
|
Without them, pausing at a gas station and resuming across town draws a straight line
|
||||||
|
through terrain never ridden — and counts it as distance. Segments propagate all the way
|
||||||
|
through: distance accumulation, map polylines, and GPX `<trkseg>`.
|
||||||
|
|
||||||
|
The same reasoning applies to a crash. See `TripRepository.resumeIntoNewSegment`, which
|
||||||
|
fixes a bug the native app has here.
|
||||||
|
|
||||||
|
### Whoever owns the writes owns the database
|
||||||
|
|
||||||
|
The single most important structural decision of the port. `TrackingService` wrote to Room
|
||||||
|
directly. If Dart owned the schema but a native service owned the writes, every fix would
|
||||||
|
cross a platform channel that is *dead while iOS suspends Dart*.
|
||||||
|
|
||||||
|
So Dart owns both, and the platform layer only has to keep the isolate alive. That is also
|
||||||
|
why there is **one isolate**: the foreground service provides liveness, not a second Dart
|
||||||
|
runtime, so two isolates never contend for one SQLite file.
|
||||||
|
|
||||||
|
### Aggregates are accumulated live, then recomputed authoritatively
|
||||||
|
|
||||||
|
Distance and elevation need consecutive-point differences, so they are folded in the
|
||||||
|
writer loop and persisted per flush — letting the recording screen show live numbers
|
||||||
|
without rescanning the point table.
|
||||||
|
|
||||||
|
Those values are an **estimate**. On completion they are replaced by `computeSummary` over
|
||||||
|
the stored points, so a mid-ride process kill cannot leave permanently skewed totals.
|
||||||
|
|
||||||
|
### Decimation is render-only
|
||||||
|
|
||||||
|
Douglas–Peucker exists in the map path and nowhere else. A three-hour ride is ~21,600
|
||||||
|
points and would jank an undecimated polyline, but storage and export must carry every raw
|
||||||
|
point. Tests assert exact counts in exports to catch a leak.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed from the native app
|
||||||
|
|
||||||
|
| Native | Port | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Room | Drift | Same shape; runs on the Dart VM, so data-layer tests need no device |
|
||||||
|
| Foreground service (hand-written) | geolocator's own | Removes `flutter_foreground_task` and two deprecations. Costs notification actions. |
|
||||||
|
| `PARTIAL_WAKE_LOCK` | `enableWakeLock` | Same mechanism, configured rather than coded |
|
||||||
|
| ViewModel + StateFlow | Riverpod | Direct mapping |
|
||||||
|
| navigation-compose | go_router | Three destinations, same shape |
|
||||||
|
| osmdroid | flutter_map | Same OSM raster tiles, same no-API-key reasoning |
|
||||||
|
| FileProvider + ACTION_SEND | share_plus | Also handles the iPad popover anchor |
|
||||||
|
| OkHttp | package:http | Direct mapping |
|
||||||
|
| `Float` | `double` | Dart has no float32. See the parity note below. |
|
||||||
|
|
||||||
|
### The Float→double divergence
|
||||||
|
|
||||||
|
Kotlin stores speed and accuracy as 32-bit `Float`. Dart has no float32, so these widen.
|
||||||
|
Every other value in the app is bit-identical across the two implementations; speed-derived
|
||||||
|
values differ in the last digits (`40.030228` vs `40.03022888407912`).
|
||||||
|
|
||||||
|
This is verified rather than assumed — `tool/parity/run.sh` compiles the real Kotlin
|
||||||
|
sources and diffs them against the Dart port across twenty fixtures.
|
||||||
|
|
||||||
|
### iOS specifics that are easy to get wrong
|
||||||
|
|
||||||
|
- `pauseLocationUpdatesAutomatically: false` — CoreLocation otherwise decides the ride has
|
||||||
|
ended, and does not reliably restart.
|
||||||
|
- `activityType: otherNavigation`, **not** `automotiveNavigation` — the latter snaps fixes
|
||||||
|
to the road network, which silently falsifies a recording of where you actually went.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Things that must not regress
|
||||||
|
|
||||||
|
1. The unbounded buffer and single batched writer. Never write to the database from the
|
||||||
|
fix callback.
|
||||||
|
2. Points stamped with `tripId`/`segmentId` at creation.
|
||||||
|
3. Recording state derived from the database, never an in-memory flag.
|
||||||
|
4. No destructive migration. Any schema change ships a real `Migration`.
|
||||||
|
5. Decimation is render-only.
|
||||||
|
6. The theme names its text colours explicitly — a missing content colour once rendered a
|
||||||
|
64 sp figure black-on-black, and no test caught it.
|
||||||
|
7. The map zoom clamp. A short ride will render an empty grid without it.
|
||||||
|
8. `PRAGMA foreign_keys = ON`. SQLite defaults it off and Drift does not set it; without
|
||||||
|
it every CASCADE is decorative.
|
||||||
|
9. Tests use in-memory databases. An instrumented test once wiped a real device's rides.
|
||||||
196
docs/V3-BACKLOG.md
Normal file
196
docs/V3-BACKLOG.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
> **Carried forward from the native repo** (`~/dojo/rippr/docs/v3/BACKLOG.md`) when the
|
||||||
|
> Flutter port completed. Two items below have changed status since it was written:
|
||||||
|
>
|
||||||
|
> - **Compose UI tests** — no longer a gap. The port has 15 widget tests plus 4
|
||||||
|
> integration tests; see `docs/port/PARITY-AUDIT.md`.
|
||||||
|
> - **Elevation gain accuracy** — the algorithm is now proven bit-identical across Kotlin
|
||||||
|
> and Dart (`tool/parity/run.sh`), so any future tuning can be checked against the
|
||||||
|
> original rather than guessed at. The instruction below still stands: **do not tune it
|
||||||
|
> blind.**
|
||||||
|
>
|
||||||
|
> Everything else carries over unchanged, including the v3 ideas and the
|
||||||
|
> "must not regress" list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v3 backlog
|
||||||
|
|
||||||
|
Everything known-outstanding as of v2.0.1, with enough context to pick up cold.
|
||||||
|
Nothing here is committed to — it is a menu, roughly ordered by value.
|
||||||
|
|
||||||
|
**Before planning anything: run the real-ride checklist in
|
||||||
|
[the real-ride checklist](port/REAL-RIDE-CHECKLIST.md).** Several items below may turn out to be non-issues, and
|
||||||
|
others may appear that nobody has thought of.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Carried over from v2 — the honest debt
|
||||||
|
|
||||||
|
### Elevation gain accuracy · *needs real data first*
|
||||||
|
|
||||||
|
~30 m of phantom gain per ten stationary minutes against synthetic ±8 m uniform noise. Real
|
||||||
|
GPS altitude error is *correlated* rather than uniform, so the true behaviour is unknown.
|
||||||
|
|
||||||
|
The current implementation is a 15-sample moving average plus reversal hysteresis (see
|
||||||
|
[ARCHITECTURE.md](ARCHITECTURE.md)). A naive version reported 1498 m over a parked
|
||||||
|
bike, so the guard rails matter.
|
||||||
|
|
||||||
|
**Do not tune this blind.** Record a flat ride, check whether the reported gain is
|
||||||
|
plausible, and only then adjust. If it needs work, options are a longer smoothing window, a
|
||||||
|
larger threshold, or using barometric pressure where available (much more accurate than GPS
|
||||||
|
altitude, and most phones have the sensor).
|
||||||
|
|
||||||
|
### Compose UI tests · *the largest coverage gap*
|
||||||
|
|
||||||
|
Zero UI tests across six screens. Everything was verified by manual screenshot. Worth
|
||||||
|
covering: navigation record→trips→detail→back, rotation/state retention, empty states,
|
||||||
|
`NotFound`, chart degradation below two points, selection mode enabling Merge only at two.
|
||||||
|
|
||||||
|
### Unmeasured, and probably should be
|
||||||
|
|
||||||
|
- **Battery drain** over a multi-hour ride — never measured, and it is the thing most likely
|
||||||
|
to make the app unusable in practice
|
||||||
|
- **Map memory across repeated navigation** — the osmdroid lifecycle is a known hazard and
|
||||||
|
the wiring was never leak-tested
|
||||||
|
- **GPX import into Strava/Garmin** — validated against an XML parser, but schema validity
|
||||||
|
does not guarantee a consumer accepts it
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The original v3 candidate — live group ride view
|
||||||
|
|
||||||
|
Deferred from v2 as "needs real server work". This was in the **v1** brief's goal
|
||||||
|
statement, so it has been the intended destination all along.
|
||||||
|
|
||||||
|
Already in place:
|
||||||
|
- `TelemetryUploader` — batched POST, retry, offline-safe, cannot stall recording
|
||||||
|
- `synced` column and backlog semantics
|
||||||
|
- `trip_id` / `segment_id` per point, so a server can reconstruct rides and pauses
|
||||||
|
- `Config.deviceId` — stable per-install id to distinguish riders
|
||||||
|
|
||||||
|
Missing:
|
||||||
|
- **A server.** Nothing exists. This is the actual work.
|
||||||
|
- **UI for the endpoint** — currently only reachable via `Config.setUploadEndpoint()`
|
||||||
|
- Other riders' positions on a map, and a live map at all (see below)
|
||||||
|
- Auth, rider identity, group membership
|
||||||
|
|
||||||
|
**Worth deciding early:** this is the point where Rippr stops being a local-only app. That
|
||||||
|
brings hosting, privacy, and location-sharing consent into scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Live map on the recording screen — **decision reversed**
|
||||||
|
|
||||||
|
v2 deliberately shipped no live map, on the reasoning that the phone rides in a pocket.
|
||||||
|
Dylan has since asked for one — for visual appeal, and because **people may mount the phone
|
||||||
|
on the handlebars** to watch the route live. Treat the v2 stance as superseded.
|
||||||
|
|
||||||
|
**The handlebar case changes the premise, not just the feature.** v1 and v2 were both built
|
||||||
|
around "start it, pocket it, stop it". A mounted phone is a different product with different
|
||||||
|
constraints, and it is worth deciding explicitly whether that becomes a first-class mode:
|
||||||
|
|
||||||
|
- **Screen on for the whole ride** — battery goes from "a background service" to "a
|
||||||
|
service plus a lit screen plus continuous map rendering". Measure before committing.
|
||||||
|
- **Sunlight legibility** — the current dark theme is chosen for glanceability, but daylight
|
||||||
|
behind a visor is a different problem.
|
||||||
|
- **Glove-sized targets** — already partly handled (72dp buttons); a map needs the same care.
|
||||||
|
- **Keep-screen-awake** handling, and what happens on a call or notification.
|
||||||
|
|
||||||
|
What still holds regardless:
|
||||||
|
|
||||||
|
- **`TrackingService` must never reference a map.** Rendering belongs to the Compose
|
||||||
|
lifecycle of a visible screen, not the service.
|
||||||
|
- **No tile fetch or redraw while backgrounded**, even in mounted mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Smaller items
|
||||||
|
|
||||||
|
| Item | Notes |
|
||||||
|
|---|---|
|
||||||
|
| **Trip splitting** | Merge exists; split does not. The natural counterpart. |
|
||||||
|
| **SAF export** | Dropped in T16 as unnecessary — share sheet covers it. Add if a real need appears. |
|
||||||
|
| **Auto-pause** | Detect a stop and pause automatically. Rejected in v2 as unreliable in traffic; revisit only with real ride data showing it would help. |
|
||||||
|
| **Distance units** | Metric only, hardcoded. Trivial to add a preference. |
|
||||||
|
| **Settings screen** | None exists. `Config` has endpoint, deviceId, mapEnabled — the map toggle currently lives on trip detail because one switch did not justify a screen. |
|
||||||
|
| **Offline tile pre-download** | osmdroid caches what it renders; a mountain ride with no signal shows blank tiles. Respect OSM's usage policy — no bulk prefetch of their public servers. |
|
||||||
|
| **Notification live stats** | Show distance/duration in the ongoing notification, readable without unlocking. |
|
||||||
|
| **Crash reporting** | None. A recorder that dies mid-ride currently leaves no trace beyond logcat. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ideas
|
||||||
|
|
||||||
|
Terse on purpose. Unshaped, to be consolidated later.
|
||||||
|
|
||||||
|
- **Live map while recording.** More visually appealing than a numbers screen. Reverses the
|
||||||
|
v2 decision — see section 3 for the constraints that survive it.
|
||||||
|
- **Pick a real theme.** The current look is functional dark + safety orange, chosen to
|
||||||
|
match the icon. Decide on an actual visual identity and push the UI toward something
|
||||||
|
polished rather than merely clean.
|
||||||
|
- **User sign-up and accounts.** Register people, give their data somewhere to live.
|
||||||
|
Prerequisite for anything cloud-side, and pairs with the group-ride server in section 2.
|
||||||
|
- **Activity type per ride.** Motorcycle, bicycle, skateboard, running, other. The app is
|
||||||
|
not inherently motorcycle-only — the recording pipeline is activity-agnostic already.
|
||||||
|
Note: adds a column to `Trip`, so it needs a real `Migration` (the destructive fallback
|
||||||
|
is gone). Type could also drive sensible defaults — speed noise floor, map zoom,
|
||||||
|
elevation smoothing.
|
||||||
|
- **Paid cloud backup.** Ongoing storage of rides over time. Needs accounts first, plus a
|
||||||
|
decision on hosting, pricing, and what happens to data when someone stops paying.
|
||||||
|
- **Waypoint route planning.** Drop a series of pins on the map to "draw" a route, get
|
||||||
|
distance and estimates back, and save it to ride later. This is *pre*-ride planning —
|
||||||
|
a genuinely new mode alongside recording, not an extension of it. Needs its own entity
|
||||||
|
(`Route` + `Waypoint`), separate from `Trip`, since a plan is not a recording.
|
||||||
|
Straight-line pin-to-pin distance is easy and reuses `Geo.haversineMeters`; snapping to
|
||||||
|
actual roads needs a routing service (OSRM, GraphHopper, Valhalla — self-hostable) and is
|
||||||
|
a much larger step. Natural follow-ons: follow a planned route on the live map, and
|
||||||
|
compare a recorded ride against the plan afterwards.
|
||||||
|
|
||||||
|
### Threads running through these
|
||||||
|
|
||||||
|
Sign-up, cloud backup and group ride are one programme, not three: they all need a server,
|
||||||
|
identity, and a privacy stance. Worth scoping together rather than separately.
|
||||||
|
|
||||||
|
Activity type and theming are independent and much cheaper — either could ship alone.
|
||||||
|
|
||||||
|
Live map, handlebar mounting and waypoint following also cluster: all three assume a
|
||||||
|
visible screen during the ride, and all three want the same map component. Route planning
|
||||||
|
is the odd one out — it needs no ride in progress at all and could be built entirely
|
||||||
|
standalone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Things that must not regress
|
||||||
|
|
||||||
|
Hard-won and easy to undo by accident. Each has a comment in the code explaining why.
|
||||||
|
|
||||||
|
1. **The unbounded `Channel` + single batched writer.** Do not write to the database from
|
||||||
|
the location callback.
|
||||||
|
2. **Points stamped with `tripId`/`segmentId` at creation.** Refactoring this into a
|
||||||
|
write-time lookup breaks the pause guarantee silently.
|
||||||
|
3. **Recording state derived from the database.** Never reintroduce an in-memory flag.
|
||||||
|
4. **`fallbackToDestructiveMigration()` stays removed.** Any schema change ships a
|
||||||
|
`Migration` against `app/schemas/com.rippr.data.AppDatabase/2.json`.
|
||||||
|
5. **Decimation is render-only.** It must never reach storage or export.
|
||||||
|
6. **`RipprTheme`'s `Surface`.** It sets `LocalContentColor`; without it, text without an
|
||||||
|
explicit colour renders black-on-black and disappears.
|
||||||
|
7. **The map zoom clamp.** `zoomToBoundingBox` ignores `maxZoomLevel`; a short ride will
|
||||||
|
render an empty grid without it.
|
||||||
|
8. **Instrumented tests use in-memory databases.** One previously wiped the real device
|
||||||
|
database in `setUp`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Reading order for picking this up cold
|
||||||
|
|
||||||
|
1. [../README.md](../README.md) — what the app is and its current state
|
||||||
|
2. [ARCHITECTURE.md](ARCHITECTURE.md) — why it is built this way
|
||||||
|
3. `~/dojo/rippr/docs/v2/PROGRESS.md` (native repo) — every bug found during v2 and how
|
||||||
|
4. [the real-ride checklist](port/REAL-RIDE-CHECKLIST.md) — **especially "What the emulator cannot verify"**
|
||||||
|
5. `~/dojo/rippr/docs/DEVELOPMENT.md` (native repo) — when you actually need to build something
|
||||||
|
|
||||||
|
The v2 planning approach worked well and is worth repeating: one document per task with
|
||||||
|
goal, context, design, acceptance criteria and risks, written *before* implementing, plus a
|
||||||
|
running progress log recording what actually went wrong. Several bugs were caught precisely
|
||||||
|
because the risk had been written down first — and one (the osmdroid lifecycle) was written
|
||||||
|
down and then walked into anyway, which is its own lesson.
|
||||||
168
docs/port/PARITY-AUDIT.md
Normal file
168
docs/port/PARITY-AUDIT.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# T27 — parity audit
|
||||||
|
|
||||||
|
Every row of the native app's v2.0.1 feature table, with the evidence behind each claim.
|
||||||
|
|
||||||
|
**Deliberately not ticked in bulk.** A v2 task once had every acceptance criterion checked
|
||||||
|
by a blanket regex, including items nobody had verified. So each row below states *how* it
|
||||||
|
is known, and three categories are kept apart:
|
||||||
|
|
||||||
|
| | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| ✅ **Verified** | An automated test asserts it, or it was demonstrated running on a device |
|
||||||
|
| 🟡 **Implemented, unproven** | The code is there and reviewed, but nothing has exercised it in the conditions that matter |
|
||||||
|
| ⛔ **Gap** | Present in the native app, absent here |
|
||||||
|
|
||||||
|
**175 automated tests** — 171 unit and widget, 4 integration on a real iOS simulator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The feature table
|
||||||
|
|
||||||
|
### GPS recording via foreground service — ✅ / 🟡
|
||||||
|
|
||||||
|
**Verified:** the pipeline has 24 tests covering all five actions, batching, the unbounded
|
||||||
|
buffer, accuracy filtering, noise-floor sanitisation and process-death resume. The app
|
||||||
|
launches and runs on an iOS simulator.
|
||||||
|
|
||||||
|
**Unproven:** that it records a real ride. No simulator produces velocity, so speed,
|
||||||
|
moving time and speed colouring are structurally untestable here.
|
||||||
|
|
||||||
|
Android liveness moved from a hand-written `TrackingService` to geolocator's own
|
||||||
|
foreground service (`foregroundServiceType="location"`, `enableWakeLock`), verified
|
||||||
|
present in the merged manifest. iOS uses `UIBackgroundModes: [location]` with
|
||||||
|
`allowBackgroundLocationUpdates`.
|
||||||
|
|
||||||
|
→ `REAL-RIDE-CHECKLIST.md` items 1–11, A1–A3, I1–I5.
|
||||||
|
|
||||||
|
### Trips with pause/resume/stop/discard — ✅
|
||||||
|
|
||||||
|
26 repository tests plus 24 engine tests. Every transition idempotent inside a
|
||||||
|
transaction; `startTrip` adopts rather than duplicating; pausing closes the segment;
|
||||||
|
discard cascades; a ride that captured nothing is dropped rather than saved.
|
||||||
|
|
||||||
|
The pause guarantee has its own test: **a fix buffered before a pause is written with the
|
||||||
|
old segment id**, because ids are stamped at creation.
|
||||||
|
|
||||||
|
### Live speed + elapsed clock — ✅
|
||||||
|
|
||||||
|
The 2.0.1 fix is pinned by two widget tests: the headline reads `SPEED`, not `MAX SPEED`,
|
||||||
|
and a separate test asserts the figure names a colour distinct from the background — the
|
||||||
|
black-on-black regression that only a screenshot caught in v2.
|
||||||
|
|
||||||
|
The clock now ticks **only while a ride is active**, which is both correct and what makes
|
||||||
|
the screen testable at all.
|
||||||
|
|
||||||
|
### Path rendered on OpenStreetMap — ✅ / 🟡
|
||||||
|
|
||||||
|
**Verified** by four map tests, which the native map never had: one polyline per segment
|
||||||
|
with a test asserting none straddles a pause, render-only decimation, the zoom clamp at
|
||||||
|
OSM's max tile zoom 19, and a `shortRideZoom` fallback for degenerate bounds — the v2.0
|
||||||
|
empty-grid bug, now guarded.
|
||||||
|
|
||||||
|
**Unproven:** speed colouring. Every simulated path renders in one colour because speed is
|
||||||
|
always zero.
|
||||||
|
|
||||||
|
### Ride statistics + charts — ✅
|
||||||
|
|
||||||
|
21 statistics tests, and stronger evidence than the native app ever had: the parity
|
||||||
|
harness drives the real Kotlin and the Dart port from one shared fixture and they agree
|
||||||
|
**to the last digit**, including the elevation accumulator at `38.959594555022136`.
|
||||||
|
|
||||||
|
Charts degrade to a message below two points rather than rendering a blank box, with a
|
||||||
|
widget test for it.
|
||||||
|
|
||||||
|
**Carried over deliberately:** the ~30 m elevation drift against synthetic noise. Fixing
|
||||||
|
it during translation would have made every differential failure ambiguous. It stays in
|
||||||
|
the v3 backlog, where the standing instruction is *do not tune this blind*.
|
||||||
|
|
||||||
|
### Rename / delete / merge rides — ✅
|
||||||
|
|
||||||
|
Repository tests cover merge re-parenting, rejection of self/active/missing merges,
|
||||||
|
atomicity, and the two properties that matter: **segments are never joined**, and
|
||||||
|
aggregates are **recomputed rather than summed** because distance is not additive across
|
||||||
|
the gap. Widget tests cover the UI, including Merge enabling at exactly two selections.
|
||||||
|
|
||||||
|
Rename collapses empty and whitespace-only input to null.
|
||||||
|
|
||||||
|
### GPX + GeoJSON export — ✅
|
||||||
|
|
||||||
|
15 tests parsed with a real XML parser and `jsonDecode`, not substring matching. The
|
||||||
|
parity harness additionally confirms both formats are **byte-identical** to the Kotlin
|
||||||
|
output — same length, same FNV hash, including an escaped hostile trip name.
|
||||||
|
|
||||||
|
Exports carry the raw stored points; decimation never reaches them.
|
||||||
|
|
||||||
|
### Upload to a REST endpoint — ✅ (still no UI, as before)
|
||||||
|
|
||||||
|
7 tests: disabled endpoint, success, rejection, network failure, multi-batch backlog,
|
||||||
|
partial failure, and per-point trip/segment identity. Runs on its own timer so a dead
|
||||||
|
endpoint cannot disturb recording.
|
||||||
|
|
||||||
|
Parity is exact, including the absence of UI.
|
||||||
|
|
||||||
|
### Live map during recording — ⛔ by design
|
||||||
|
|
||||||
|
Absent in v2, absent here. It is a **v3 decision**, and the backlog records that Dylan has
|
||||||
|
since asked for it and that handlebar mounting changes the app's founding premise.
|
||||||
|
|
||||||
|
### Group ride view — ⛔ by design
|
||||||
|
|
||||||
|
Deferred to v3 in the native app; unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gaps and departures
|
||||||
|
|
||||||
|
### ⛔ Notification actions — the one capability lost
|
||||||
|
|
||||||
|
The native notification carried Pause/Resume buttons. geolocator's
|
||||||
|
`ForegroundNotificationConfig` cannot carry actions, so the notification is display-only;
|
||||||
|
tapping it opens the app.
|
||||||
|
|
||||||
|
This was the price of dropping `flutter_foreground_task`, which bought the removal of two
|
||||||
|
deprecations that Flutter says become hard build errors. Worth revisiting if the shade
|
||||||
|
controls turn out to matter on a real ride — a separate notification plugin could add them
|
||||||
|
back without touching the engine.
|
||||||
|
|
||||||
|
### ✅ A departure that fixes a native bug
|
||||||
|
|
||||||
|
`restoreAfterProcessDeath` now resumes into a genuinely new segment. The native version
|
||||||
|
adopts the segment a crash left open, so the authoritative recomputation measures straight
|
||||||
|
through the dead time — **111 km of phantom distance** in the reproduction. Documented at
|
||||||
|
length in `PROGRESS.md` and in `TripRepository.resumeIntoNewSegment`.
|
||||||
|
|
||||||
|
### Improvements that are not parity items
|
||||||
|
|
||||||
|
- **The first UI tests this project has ever had** (15), closing what the v3 backlog names
|
||||||
|
as v2's largest coverage gap.
|
||||||
|
- **Instrumented tests became unit tests.** `SchemaTest`, `TripRepositoryTest` and
|
||||||
|
`MergeTest` were 666 lines needing a booted emulator; they now run in about two seconds
|
||||||
|
with nothing running.
|
||||||
|
- **A cross-language parity harness** that can be re-run at any time.
|
||||||
|
- A **record screen layout bug** fixed that Compose was clipping silently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Not done, and why
|
||||||
|
|
||||||
|
**The bundle id is still `com.rippr.port`.** Switching it to `com.rippr` is the final
|
||||||
|
cutover step — but doing it now would make the two apps unable to coexist, and
|
||||||
|
`REAL-RIDE-CHECKLIST.md` item **A3** depends on recording the same ride on both
|
||||||
|
simultaneously. That comparison is the strongest evidence available that the port is
|
||||||
|
faithful.
|
||||||
|
|
||||||
|
**Switch it after T25, not before.**
|
||||||
|
|
||||||
|
Also outstanding before any release: the app icon is still the Flutter default, and every
|
||||||
|
build so far has been debug.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
Feature parity is **complete in implementation** and **verified as far as anything can be
|
||||||
|
without riding**. One capability is lost (notification actions), one native bug is fixed,
|
||||||
|
and the test coverage is substantially better than the app being replaced.
|
||||||
|
|
||||||
|
What remains is not code. It is a rider, two phones, and
|
||||||
|
[REAL-RIDE-CHECKLIST.md](REAL-RIDE-CHECKLIST.md).
|
||||||
@@ -736,4 +736,64 @@ build, and a demo video for the review notes.
|
|||||||
|
|
||||||
**T22, T23, T24 complete. T25 and T26 need a real device and a real rider.**
|
**T22, T23, T24 complete. T25 and T26 need a real device and a real rider.**
|
||||||
|
|
||||||
`178 tests passing` (171 unit + widget, plus 4 integration on device), analyze clean.
|
`175 tests passing` — 171 unit and widget, plus 4 integration on device. Analyze clean.
|
||||||
|
|
||||||
|
> Corrected: an earlier summary in this file said 178. 171 + 4 is 175.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T27 — Parity audit and handover · **complete, except the cutover step**
|
||||||
|
|
||||||
|
[PARITY-AUDIT.md](PARITY-AUDIT.md) walks every row of the v2.0.1 feature table with the
|
||||||
|
evidence behind each claim, keeping three categories strictly apart: **verified** (a test
|
||||||
|
asserts it, or it ran on a device), **implemented but unproven**, and **gap**.
|
||||||
|
|
||||||
|
Deliberately not ticked in bulk — a v2 task once had every criterion checked by a blanket
|
||||||
|
regex including unverified items, and that lesson is written into the audit's preamble.
|
||||||
|
|
||||||
|
### Documentation carried forward
|
||||||
|
|
||||||
|
- `docs/ARCHITECTURE.md` — the durable reasoning, with a table of what changed and why,
|
||||||
|
the Float→double divergence, and the two iOS settings that are easy to get wrong
|
||||||
|
- `README.md` — status, the parity harness, and the two native bugs this port found
|
||||||
|
- `docs/V3-BACKLOG.md` — carried over with a header noting the two items whose status
|
||||||
|
changed (UI tests are no longer a gap; elevation can now be checked against Kotlin)
|
||||||
|
- The native repo's `README.md` now opens with a pointer here, and records both bugs
|
||||||
|
|
||||||
|
All internal documentation links verified to resolve.
|
||||||
|
|
||||||
|
### The cutover step is deliberately NOT done
|
||||||
|
|
||||||
|
**The application id stays `com.rippr.port`.**
|
||||||
|
|
||||||
|
Switching it to `com.rippr` is the last act of the port, but doing it now would stop the
|
||||||
|
two apps coexisting — and `REAL-RIDE-CHECKLIST.md` item **A3** depends on recording the
|
||||||
|
same ride on both simultaneously. That side-by-side comparison is the strongest evidence
|
||||||
|
available that the port is faithful, and it is worth more than finishing the checklist
|
||||||
|
tidily.
|
||||||
|
|
||||||
|
The native repo is therefore marked **superseded, not archived**: it is still the only
|
||||||
|
version that has recorded a real ride.
|
||||||
|
|
||||||
|
### An arithmetic correction
|
||||||
|
|
||||||
|
An earlier entry in this file said 178 tests. The real figure is **175** — 171 unit and
|
||||||
|
widget, plus 4 integration. Corrected in place.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The port is complete
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **175 tests** | 171 unit and widget, 4 integration on a real iOS simulator |
|
||||||
|
| **~4,600 lines** of Dart | plus 3,000 generated by Drift |
|
||||||
|
| **Both platforms build** | and the app runs on an iOS simulator |
|
||||||
|
| **Analyze clean** | throughout |
|
||||||
|
| **Parity proven, not assumed** | `tool/parity/run.sh` diffs against the real Kotlin |
|
||||||
|
|
||||||
|
Two bugs found in the native app. One capability lost (notification actions). The first UI
|
||||||
|
tests the project has ever had.
|
||||||
|
|
||||||
|
**What remains is not code.** It is a rider, two phones, and
|
||||||
|
[REAL-RIDE-CHECKLIST.md](REAL-RIDE-CHECKLIST.md).
|
||||||
|
|||||||
Reference in New Issue
Block a user