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

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.