The port runs on Android and iOS and is feature-complete; the native Android app is superseded but kept, since it is still the only version that has recorded real rides. rippr-flutter-1.0-debug.apk is package com.rippr.port, deliberately different from the native com.rippr so both install side by side. Recording the same ride on both at once is the strongest available check that the port is faithful. Added INSTALL.md covering both platforms. Android is a one-line adb install; iOS has no APK equivalent and must be built and signed through Xcode with a free Apple ID, which gives a 7-day profile. 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.
|