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>
142 lines
6.7 KiB
Markdown
142 lines
6.7 KiB
Markdown
# 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.
|