Files
samplez/rippr-flutter-src/docs/ARCHITECTURE.md
Dylan 0bc42b2e5a Add the Rippr Flutter port: source, history bundle, and installable APK
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>
2026-08-15 22:37:45 -05:00

142 lines
6.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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