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:
2026-08-15 22:26:56 -05:00
parent a3bcd014c0
commit fc6d8f9dba
5 changed files with 662 additions and 12 deletions

107
README.md
View File

@@ -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
View 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
View 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
View 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).

View File

@@ -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).