# 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 ``. 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.