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>
This commit is contained in:
2026-08-15 22:38:02 -05:00
parent 0bc42b2e5a
commit 3c801e9114
3 changed files with 378 additions and 1 deletions

349
rippr-src/docs/port/PLAN.md Normal file
View File

@@ -0,0 +1,349 @@
# 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*.