RIPPR.md said the native snapshot was at ba57a92, but rippr-src and the bundle were still at d418920 -- so the superseded notice and the two recorded bugs were missing from the copy. Re-exported both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
19 KiB
Markdown
350 lines
19 KiB
Markdown
# Rippr — Flutter Port (Android + iOS)
|
||
|
||
## Context
|
||
|
||
Rippr is a native Android GPS ride recorder: ~3,800 lines of Kotlin across 34 files,
|
||
shipped through v1 and v2.0.1, validated on real rides. It records telemetry from a
|
||
foreground service into Room, renders the path on osmdroid, and exports GPX/GeoJSON.
|
||
|
||
Dylan wants one codebase running on **both iOS and Android** before any further features
|
||
are built. This is a **full rewrite in Flutter**, not an incremental or add-to-app
|
||
migration — the research in `docs/PORT_RESEARCH.md` puts the cutover threshold at 10
|
||
screens and Rippr has **six**, with no legacy debt worth preserving in Kotlin form.
|
||
|
||
**The goal is parity, not improvement.** Every capability in the v2.0.1 feature table
|
||
works on both platforms; v3 features stay in the backlog. Deliberate scope discipline —
|
||
the port is finished when it does exactly what the Kotlin app does.
|
||
|
||
### Three findings that shape this plan
|
||
|
||
**The pure-logic core ports almost verbatim.** Eight files (~890 lines) carry zero Android
|
||
imports — a discipline held deliberately since v1. `Geo`, `RideStatistics`,
|
||
`RideAccumulator`, `RideExport`, `Telemetry`, `Format`, `LiveTelemetry`, `UploadStatus`.
|
||
Their ~965 lines of existing JVM tests become a **differential oracle**: the same fixtures
|
||
must produce the same numbers in Dart. This is the single biggest de-risker available and
|
||
Phase 1 exists to cash it in first.
|
||
|
||
**Whoever owns the writes must own the database.** `TrackingService` writes to Room
|
||
directly from its writer loop. If Dart owns the schema but a native service owns the
|
||
writes, every fix crosses a platform channel that is *dead while iOS suspends Dart*. So
|
||
Dart owns both, and the background layer only has to keep the Dart isolate alive.
|
||
|
||
**iOS suspends a stationary app; Android does not.** This is the one place where "write
|
||
once" is partly an illusion, and it gets its own task and its own real-world validation
|
||
rather than being discovered late.
|
||
|
||
### Decisions taken
|
||
|
||
| Decision | Choice | Rationale |
|
||
|---|---|---|
|
||
| Strategy | **Full rewrite** | 6 screens, well below the 10-screen threshold |
|
||
| Repo | **`~/dojo/rippr-flutter`**, new git history | Native app stays installable as reference and fallback |
|
||
| Existing rides | **Start fresh** | No importer; export to GPX first if any ride matters |
|
||
| Location engine | **`geolocator` + `flutter_foreground_task`** | MIT/free; avoids the ~$500/yr Transistor licence |
|
||
| Isolate model | **Single isolate** | Foreground service for process liveness only — no second isolate, no cross-isolate SQLite |
|
||
| Database | **Drift** | Type-safe, streams, real migrations, runs on the Dart VM |
|
||
| State | **Riverpod** | Maps cleanly from ViewModel + StateFlow |
|
||
| Routing | **go_router** | Three destinations, same shape as navigation-compose |
|
||
| Map | **flutter_map** | OSM raster tiles, no API key — same reasoning that chose osmdroid |
|
||
| Live map while recording | **Still no** | Parity target is v2.0.1; it is a v3 decision |
|
||
|
||
### Non-goals
|
||
|
||
Every v3 backlog item: live map, waypointing, activity type, accounts, cloud backup,
|
||
theming overhaul, group ride. Also no new features of any kind, and no data importer.
|
||
|
||
---
|
||
|
||
## Architecture mapping
|
||
|
||
```
|
||
TrackingService (Kotlin) RecordingEngine (Dart, main isolate)
|
||
FusedLocationProviderClient ──► geolocator.getPositionStream
|
||
Channel<TrackPoint>(UNLIMITED) ──► StreamController (unbounded)
|
||
single writer coroutine ──► single async drain loop (batch 25 / 2s)
|
||
Mutex ──► package:synchronized Lock
|
||
Room + @Transaction ──► Drift + transaction()
|
||
Flow<Trip?> ──► Stream<Trip?> (Drift .watch)
|
||
PARTIAL_WAKE_LOCK ──► flutter_foreground_task (Android)
|
||
START_STICKY + adopt active ──► on-launch adopt of `endedAt IS NULL`
|
||
|
||
ViewModel + StateFlow ──► Riverpod Notifier / AsyncNotifier
|
||
navigation-compose ──► go_router
|
||
osmdroid MapView ──► flutter_map
|
||
FileProvider + ACTION_SEND ──► share_plus
|
||
SharedPreferences ──► shared_preferences
|
||
OkHttp ──► package:http
|
||
```
|
||
|
||
**Platform-divergent, by necessity:** Android runs a foreground service with a persistent
|
||
notification to keep the process alive. iOS declares `UIBackgroundModes: location` and
|
||
sets `allowsBackgroundLocationUpdates` with `pauseLocationUpdatesAutomatically = false`.
|
||
Both keep the *same* Dart pipeline running; only the liveness mechanism differs.
|
||
|
||
**Invariants carried over verbatim** (from `docs/v3/BACKLOG.md` §6 — each has a comment in
|
||
the Kotlin explaining why, and each must survive the port):
|
||
|
||
1. The location callback never blocks on disk — unbounded buffer, single batched writer
|
||
2. Points stamped with `tripId`/`segmentId` **at creation**, never looked up at write time
|
||
3. Recording state derived from the database, never an in-memory flag
|
||
4. No destructive migration — Drift migrations from v1 of the Dart schema onward
|
||
5. Decimation is render-only, never reaching storage or export
|
||
6. Theme sets a default content colour (Kotlin's `Surface` lesson; Dart's is `DefaultTextStyle`)
|
||
7. The map zoom clamp — a short ride must not zoom past the tile server's max
|
||
8. Tests use in-memory databases
|
||
|
||
---
|
||
|
||
## Phases and tasks
|
||
|
||
Each task gets `docs/port/NN-slug.md` in the new repo, following the v2 template that
|
||
worked well: Goal · Context · Design · Implementation · Acceptance criteria · Tests ·
|
||
Risks · Out of scope. Plus a running `docs/port/PROGRESS.md` recording what actually went
|
||
wrong — the v2 feedback loop caught real bugs and is worth repeating.
|
||
|
||
### Phase 0 — Ground clearing *(blocking; nothing else can start)*
|
||
|
||
| # | Task | Depends |
|
||
|---|---|---|
|
||
| T00 | Disk space + toolchain | — |
|
||
| T01 | Repo scaffold | T00 |
|
||
|
||
**T00 — This is a genuine blocker, not a formality.** The machine has **16 GiB free at 92%
|
||
capacity** and needs, roughly: Flutter SDK + artifacts ~5 GB, an iOS simulator runtime
|
||
(**none installed**) ~9 GB, CocoaPods (**not installed**; system Ruby is 2.6.10, so install
|
||
via Homebrew, not `gem`), plus the Android emulator's non-negotiable **7.4 GB free-space
|
||
floor** and two build trees. That does not fit — v1 hit this same wall and lost real time
|
||
to it. Reclaim first (`brew cleanup -s`, Homebrew and Playwright caches, `pip cache purge`,
|
||
`go clean -cache`, old Gradle caches, stale AVDs), then install. Xcode 26.0.1 is present.
|
||
**Exit criteria:** `flutter doctor -v` clean for both toolchains, an iOS simulator *and*
|
||
the Android emulator each boot, and ≥15 GB still free afterwards.
|
||
|
||
**T01** — `flutter create` with both platforms, bundle/application id `com.rippr`, git
|
||
init, initial commit. Add the dependency set. Write `docs/port/` scaffolding and a
|
||
`README` pointing back at the native repo's `ARCHITECTURE.md`, `TESTING.md`, and v2
|
||
`PROGRESS.md` as the source of truth for *why* things are shaped as they are. Confirm
|
||
`flutter test` and a debug build on both platforms before a line of real code.
|
||
|
||
### Phase 1 — Pure logic *(no platform, no UI, no database)*
|
||
|
||
Highest value per unit risk, and it builds Dart fluency on code whose correct answers are
|
||
already known. Each task ports the Kotlin file **and its existing test suite**.
|
||
|
||
| # | Task | Ports | Depends |
|
||
|---|---|---|---|
|
||
| T02 | Geo utilities | `geo/Geo.kt` + `GeoTest` (165 + 210 ln) | T01 |
|
||
| T03 | Telemetry + formatting | `Telemetry.kt`, `ui/Format.kt`, `UploadStatus.kt` + `TelemetryTest` | T01 |
|
||
| T04 | Ride statistics | `stats/RideStatistics.kt` + `RideStatisticsTest` (302 + 265 ln) | T02, T03 |
|
||
| T05 | Live accumulator | `RideAccumulator.kt`, `LiveTelemetry.kt` + `AccumulatorTest` | T04 |
|
||
| T06 | Export writers | `export/RideExport.kt` + `RideExportTest` (151 + 211 ln) | T02 |
|
||
| T07 | Cross-language parity harness | — | T02–T06 |
|
||
|
||
**T04 is the hardest-won code in the project.** `ElevationAccumulator` — 15-sample moving
|
||
average, reversal hysteresis, `gainIncludingPending()`, and a `finish()` that reconciles
|
||
against `lastRaw`. A naive version once reported **1498 m of climbing over a parked bike**.
|
||
Port it structurally faithfully; do not "improve" it during translation.
|
||
|
||
**T07** — Drive identical fixtures through both implementations and assert agreement to
|
||
six decimal places: haversine over known pairs, Douglas–Peucker output, elevation gain over
|
||
the noisy-stationary fixture, batched-vs-single-batch distance, GPX/GeoJSON byte output.
|
||
**Watch for a harness that reports suspiciously identical results** — a v2 sweep returned
|
||
four identical values because a quoting bug corrupted the source while the compile error
|
||
hid behind `/dev/null`. Never redirect a build to `/dev/null` inside a measurement loop.
|
||
|
||
### Phase 2 — Data layer
|
||
|
||
| # | Task | Depends |
|
||
|---|---|---|
|
||
| T08 | Drift schema | T01 |
|
||
| T09 | Trip repository | T08, T05 |
|
||
|
||
**T08** — Mirror `app/schemas/com.rippr.data.AppDatabase/2.json`: `Trip` (with
|
||
`TripState` RECORDING/PAUSED/COMPLETED), `Segment`, `TrackPoint`; FK `CASCADE`, indices on
|
||
`tripId`/`segmentId`, WAL. Starts at Dart schema version 1 with real migrations from day
|
||
one — the destructive fallback never comes back.
|
||
|
||
**T09** — Port `TripRepository`: every transition idempotent inside a transaction,
|
||
`startTrip` **adopts** an active trip rather than duplicating one, `mergeTrips`
|
||
re-parents segments and points without ever joining segments, then recomputes aggregates.
|
||
|
||
**A quiet win here:** `TripRepositoryTest`, `SchemaTest`, and `MergeTest` (666 lines) are
|
||
*instrumented* tests today, needing a device. Against Drift on the Dart VM they become
|
||
plain unit tests — faster, and runnable without an emulator booted.
|
||
|
||
### Phase 3 — Recording engine *(the risky phase)*
|
||
|
||
| # | Task | Depends |
|
||
|---|---|---|
|
||
| T10 | Location source seam | T03 |
|
||
| T11 | Recording pipeline | T09, T10 |
|
||
| T12 | Android foreground service | T11 |
|
||
| T13 | iOS background location | T11 |
|
||
| T14 | Process-death resume | T11, T12, T13 |
|
||
|
||
**T10** — A `LocationSource` interface with a `geolocator` implementation and a fake for
|
||
tests. This seam is what makes the engine testable without a device, and it is also the
|
||
escape hatch: if `geolocator` proves unreliable on a real iOS ride, swapping in
|
||
`flutter_background_geolocation` becomes one implementation rather than a rewrite.
|
||
|
||
**T11** — The heart. Unbounded `StreamController`, single async drain loop batching 25
|
||
fixes / 2 s, accumulator folded per batch, aggregates persisted per flush. Five actions:
|
||
start / pause / resume / stop / discard. Pause closes the open segment; resume opens a new
|
||
one. **Stamp `tripId`/`segmentId` at point creation** — a fix in flight during a pause must
|
||
land in the segment it actually belongs to. Stop discards a trip with zero points.
|
||
|
||
**T12** — `flutter_foreground_task` for process liveness and the persistent notification,
|
||
`foregroundServiceType="location"`, wake lock, notification actions. Configured **without**
|
||
a separate Dart isolate — the recording loop stays on the main isolate, so there is never
|
||
cross-isolate access to one SQLite file.
|
||
|
||
**T13 — where the platforms genuinely diverge.** `Info.plist` needs
|
||
`NSLocationWhenInUseUsageDescription`, `NSLocationAlwaysAndWhenInUseUsageDescription`, and
|
||
`UIBackgroundModes: [location]`, with **context-rich** strings — generic ones are the
|
||
leading cause of Guideline 5.1.1 rejection. Set `allowsBackgroundLocationUpdates = true`
|
||
and `pauseLocationUpdatesAutomatically = false`. Then **document and measure** what
|
||
actually happens when the bike stops at a light versus parks for ten minutes; the app must
|
||
resume cleanly rather than silently ending a ride.
|
||
|
||
**T14** — On launch, adopt any trip with `endedAt IS NULL` and restore the accumulator from
|
||
the persisted row, matching the Kotlin restart path. Verify by force-killing mid-ride on
|
||
both platforms.
|
||
|
||
### Phase 4 — UI
|
||
|
||
| # | Task | Ports | Depends |
|
||
|---|---|---|---|
|
||
| T15 | Shell: router, Riverpod, theme | `RipprNavHost`, `ui/theme/` | T01 |
|
||
| T16 | Record screen | `ui/record/` | T11, T15 |
|
||
| T17 | Trips list | `ui/trips/` | T09, T15 |
|
||
| T18 | Trip detail: stats + charts | `ui/detail/`, `ui/components/Stats.kt` | T04, T15 |
|
||
| T19 | Map + path rendering | `ui/components/RideMap.kt` | T02, T18 |
|
||
| T20 | Rename / delete / merge | | T17, T18 |
|
||
| T21 | Export UI | T06 | T18 |
|
||
|
||
**T15** — Functional dark + safety orange, matching the icon. The theme must set a default
|
||
content colour: in Compose, removing `Surface` once made a 64 sp speed figure render
|
||
black-on-black and **no test caught it — only a screenshot did**. Flutter's equivalent
|
||
exposure is `DefaultTextStyle`.
|
||
|
||
**T16** — Headline is **live speed plus a wall-clock elapsed ticker**, not max speed. v2.0
|
||
shipped max-speed-as-headline and it read as a frozen, broken screen on a real ride,
|
||
because on an emulator every value is zero and a number that never moves looks fine.
|
||
Keep the 72 dp glove-sized controls; confirm on Discard only.
|
||
|
||
**T19** — Per-segment polylines so pauses leave visible gaps, speed-bucketed colouring,
|
||
Douglas–Peucker decimation **render-only**, fit-to-bounds followed by a **zoom clamp**: a
|
||
50 m ride once zoomed past OSM's max tile zoom of 19 and rendered an empty grid. Set a real
|
||
user agent before any tile fetch or OSM returns 403, and cache tiles in app-private
|
||
storage. Guard the map's own bounds so it cannot overdraw adjacent controls.
|
||
|
||
### Phase 5 — Verification
|
||
|
||
| # | Task | Depends |
|
||
|---|---|---|
|
||
| T22 | Uploader + config | T09 |
|
||
| T23 | Widget tests | Phase 4 |
|
||
| T24 | Integration tests, both platforms | Phase 4 |
|
||
| T25 | Real-ride validation | T24 |
|
||
| T26 | iOS release readiness | T25 |
|
||
|
||
**T22** — `package:http` uploader with batching, retry, offline-safe backlog via the
|
||
`synced` column, carrying `trip_id`/`segment_id` in the payload. `shared_preferences` for
|
||
endpoint, `deviceId`, `mapEnabled`. Parity note: this still has **no UI**, exactly as today.
|
||
|
||
**T23 — closes v2's largest known gap.** Zero UI tests exist across six screens today;
|
||
Flutter makes widget tests cheap enough that there is no excuse to carry that debt into the
|
||
port. Cover navigation record→trips→detail→back, empty states, chart degradation below two
|
||
points, and merge enabled only at exactly two selections.
|
||
|
||
**T25** — Run the checklist in `docs/TESTING.md` on **both** platforms. Non-negotiable,
|
||
because **neither simulator can produce velocity** — `adb emu geo fix` teleports and the
|
||
iOS simulator's synthetic locations are no better, so max speed, average moving speed,
|
||
moving time, and **speed colouring on the map** are all unverifiable in CI. Also ride
|
||
**short and long**: a ~900 m fixture hid the short-ride zoom bug completely.
|
||
|
||
**T26** — Privacy nutrition labels matching actual runtime behaviour, usage strings
|
||
audited, background-location justification ready for review.
|
||
|
||
### Phase 6 — Cutover
|
||
|
||
| # | Task | Depends |
|
||
|---|---|---|
|
||
| T27 | Parity audit and handover | all |
|
||
|
||
Walk the v2.0.1 feature table row by row and demonstrate each on both platforms. **Do not
|
||
tick boxes in bulk** — a v2 task once had every criterion checked by a blanket regex
|
||
including items never actually verified. Then: port `ARCHITECTURE.md` with the decisions
|
||
that changed, carry `docs/v3/BACKLOG.md` forward, and mark the native repo archived with a
|
||
pointer to its replacement.
|
||
|
||
---
|
||
|
||
## Dependency graph
|
||
|
||
```
|
||
T00 ─► T01 ─┬─► T02 ─┬─► T04 ─► T05 ─┐
|
||
│ └─► T06 ─┐ │
|
||
│ T03 ──► T04 │ │
|
||
│ └─► T10 │ │
|
||
│ │ │
|
||
├─► T08 ─► T09 ───┼──────┴─► T11 ─┬─► T12 ─┐
|
||
│ │ │ ├─► T13 ─┼─► T14
|
||
│ │ │ │ │
|
||
└─► T15 ─┬───┼────┼───────────────┘ │
|
||
│ │ │ │
|
||
T02─┬─► T07 │ │ │ │
|
||
│ │ │ │ │
|
||
└────────┴───┴────┴─► T16/T17/T18 ─► T19 ─► T20/T21
|
||
│
|
||
T22 ──────────────────────► T23 ─► T24 ─► T25 ─► T26 ─► T27
|
||
```
|
||
|
||
Critical path: **T00 → T01 → T08 → T09 → T11 → T12/T13 → T14 → T16 → T24 → T25 → T27**.
|
||
|
||
Phase 1 is almost entirely parallelisable and carries near-zero risk — but per your v2
|
||
preference, everything runs **sequentially** so dependent architecture surfaces before it
|
||
becomes expensive to change.
|
||
|
||
---
|
||
|
||
## Key risks
|
||
|
||
| Risk | Mitigation |
|
||
|---|---|
|
||
| **Disk space blocks the toolchain** | T00 gates everything; hard exit criteria before any code |
|
||
| **iOS suspends a stationary app mid-ride** | T13 measures it explicitly; T10's seam makes swapping to `flutter_background_geolocation` cheap if free tooling loses rides |
|
||
| `geolocator` proves less reliable than FusedLocation | T25 rides both a real Android and a real iOS device before the native app is retired |
|
||
| Elevation hysteresis subtly mistranslated | T07 asserts six-decimal agreement against the Kotlin implementation |
|
||
| Two isolates racing one SQLite file | Avoided by design — foreground service provides liveness only, recording stays on the main isolate |
|
||
| Decimation leaking into storage or export | Render-only, and T06's ported tests assert exact point counts |
|
||
| Simulators hide velocity-dependent bugs | Stated up front in T25; **a green suite proves nothing about speed** |
|
||
| Silent parity loss | T27 audits the feature table row by row, demonstrated not asserted |
|
||
|
||
---
|
||
|
||
## Verification
|
||
|
||
```bash
|
||
flutter analyze && flutter test # pure logic, data layer, widgets
|
||
flutter test integration_test -d <android-emulator>
|
||
flutter test integration_test -d <ios-simulator>
|
||
flutter build apk --debug && flutter build ios --debug --no-codesign
|
||
```
|
||
|
||
Plus the **cross-language parity harness** (T07): identical fixtures through Kotlin and
|
||
Dart, agreement asserted to six decimals — the port's strongest single guarantee, and the
|
||
reason Phase 1 comes first.
|
||
|
||
**Definition of done:** every row of the v2.0.1 feature table demonstrated on a real
|
||
Android phone *and* a real iPhone, the `docs/TESTING.md` checklist passed on both, and no
|
||
capability lost.
|
||
|
||
---
|
||
|
||
## Open question, deferred deliberately
|
||
|
||
The native app's known **elevation drift** (~30 m per ten stationary minutes against
|
||
synthetic noise) ports along with the algorithm — faithfully, bug included. That is correct
|
||
for a parity port: fixing it during translation would make any differential test failure
|
||
ambiguous. It stays in the v3 backlog, where it already says *do not tune this blind*.
|