Files
rippr/docs/port/PLAN.md
Dylan 725e6f63b7 Scaffold Flutter port: toolchain, project, and Geo
T00 — toolchain green on both platforms. flutter doctor reports no issues.
Pinned Flutter to Temurin 21 (AGP rejects the default JDK 25, same constraint
as the native build) and installed Android cmdline-tools with an explicit
--sdk_root, the trap already documented in the native DEVELOPMENT.md.
No caches were deleted: the 16 GiB disk reading that drove the cleanup plan
re-measured at 36 GiB before anything was removed.

T01 — flutter create for android+ios. applicationId is com.rippr.port, not
com.rippr, so the native app stays installable alongside it during the port;
T27 switches it at cutover.

T02 — geo/geo.dart ported from com.rippr.geo.Geo with all 23 tests, tolerances
and comments carried over unchanged. Kotlin's object namespace became top-level
functions; LatLon stays our own type so the pure layer never depends on
flutter_map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:13:15 -05:00

19 KiB
Raw Blame History

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

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.