Files
samplez/rippr-src/docs/port/PLAN.md
Dylan 3c801e9114 Refresh the native Rippr snapshot to match the claimed commit
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>
2026-08-15 22:38:02 -05:00

350 lines
19 KiB
Markdown
Raw 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.

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