# 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(UNLIMITED) ──► StreamController (unbounded) single writer coroutine ──► single async drain loop (batch 25 / 2s) Mutex ──► package:synchronized Lock Room + @Transaction ──► Drift + transaction() Flow ──► Stream (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 flutter test integration_test -d 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*.