Files
rippr/docs/ARCHITECTURE.md
Dylan fc6d8f9dba 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>
2026-08-15 22:26:56 -05:00

6.7 KiB
Raw Permalink Blame History

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.