From fc6d8f9dba6b0434e0fc46b4a7e4d52743de6109 Mon Sep 17 00:00:00 2001 From: Dylan Date: Sat, 15 Aug 2026 22:26:56 -0500 Subject: [PATCH] T27: parity audit and handover PARITY-AUDIT.md walks every row of the v2.0.1 feature table with the evidence behind each claim, keeping verified, implemented-but-unproven, and gap strictly apart rather than ticking boxes in bulk. Carried the durable docs forward: ARCHITECTURE.md with what changed and why, a README covering status and the parity harness, and the v3 backlog with a header noting the two items whose status changed. The native repo's README now opens with a pointer here and records both bugs the port found. All internal doc links verified. The cutover step is deliberately left undone: the application id stays com.rippr.port so both apps can be installed together, because the real-ride checklist depends on recording the same ride on both at once. That comparison is worth more than finishing tidily. The native repo is marked superseded rather than archived -- it is still the only version that has recorded a real ride. Also corrected an arithmetic slip: 175 tests, not 178. Co-Authored-By: Claude Opus 5 --- README.md | 107 ++++++++++++++++++--- docs/ARCHITECTURE.md | 141 +++++++++++++++++++++++++++ docs/V3-BACKLOG.md | 196 ++++++++++++++++++++++++++++++++++++++ docs/port/PARITY-AUDIT.md | 168 ++++++++++++++++++++++++++++++++ docs/port/PROGRESS.md | 62 +++++++++++- 5 files changed, 662 insertions(+), 12 deletions(-) create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/V3-BACKLOG.md create mode 100644 docs/port/PARITY-AUDIT.md diff --git a/README.md b/README.md index b9a9d4c..4cdf472 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,102 @@ -# rippr +# Rippr -GPS ride recorder for motorcycles +A GPS ride recorder for motorcycles, on **Android and iOS**. Records telemetry into a +local SQLite database, renders the traversed path on a map afterwards, and exports to +GPX/GeoJSON. -## Getting Started +Built for one specific use: **hit start, put the phone in a pocket, ride, hit stop.** +Most of the design follows from that. -This project is a starting point for a Flutter application. +This is a Flutter port of the native Android app in `~/dojo/rippr` (v1 → v2.0.1). The port +is feature-complete; see [docs/port/PARITY-AUDIT.md](docs/port/PARITY-AUDIT.md) for +exactly what is verified and what is not. -A few resources to get you started if this is your first Flutter project: +``` +Flutter 3.47 · Dart 3.13 Android minSdk 26 · iOS 12+ +~4,600 lines Dart 175 tests (171 unit/widget, 4 integration) +``` -- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) -- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) +## Status -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +| Feature | Status | +|---|---| +| GPS recording, foreground on Android / background mode on iOS | Implemented; **not yet ridden** | +| Trips with pause / resume / stop / discard | Working, 50 tests | +| Live speed + elapsed clock | Working | +| Path on OpenStreetMap, per-segment, speed-coloured | Working; colouring unverifiable without a real ride | +| Ride statistics + charts | Working; bit-identical to the Kotlin original | +| Rename / delete / merge rides | Working | +| GPX + GeoJSON export | Working; byte-identical to the Kotlin original | +| Upload to a REST endpoint | Implemented, **no UI** (as in the native app) | +| Notification actions (Pause/Resume in the shade) | **Absent** — the one capability lost | +| Live map while recording · group ride | Deferred to v3 | + +> **The port has never recorded a real ride.** No simulator produces velocity, so max +> speed, moving time, speed colouring, elevation against real GPS error, battery, and iOS +> stationary suspension are all unverified. That is +> [docs/port/REAL-RIDE-CHECKLIST.md](docs/port/REAL-RIDE-CHECKLIST.md), and it is the next +> thing that should happen. + +## Quick start + +Requires **JDK 17–21** for Android (AGP rejects 25) and Xcode for iOS. + +```bash +flutter pub get +dart run build_runner build # Drift codegen +flutter analyze && flutter test +flutter test integration_test -d +flutter run +``` + +**Watch the disk.** A full dual-platform build cycle costs roughly 10 GB, and the Android +emulator needs 7.4 GB free just to boot. Run `flutter clean` before booting it. + +## Cross-language parity + +The strongest guarantee in this repo. It compiles the **real Kotlin sources** from the +native app and diffs them against the Dart port across twenty fixtures: + +```bash +brew install kotlin +JAVA_HOME= ./tool/parity/run.sh +``` + +Everything matches to the last digit — including the elevation accumulator at +`38.959594555022136` and byte-identical GPX/GeoJSON. The single expected difference is +speed-derived values, where Kotlin's 32-bit `Float` widens with artefacts Dart's uniform +`double` does not reproduce. + +## Documentation + +| Document | Contents | +|---|---| +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Why it is built this way, and what changed from the native app | +| [docs/port/PLAN.md](docs/port/PLAN.md) | The 28-task migration plan | +| [docs/port/PROGRESS.md](docs/port/PROGRESS.md) | What actually happened, including every bug found | +| [docs/port/PARITY-AUDIT.md](docs/port/PARITY-AUDIT.md) | Feature-by-feature, with the evidence behind each claim | +| [docs/port/REAL-RIDE-CHECKLIST.md](docs/port/REAL-RIDE-CHECKLIST.md) | **The outstanding work** | +| [docs/port/RELEASE-IOS.md](docs/port/RELEASE-IOS.md) | App Review readiness | +| [docs/PORT_RESEARCH.md](docs/PORT_RESEARCH.md) | The original research this plan was built on | + +The native repo's `docs/v1/`, `docs/v2/` and `docs/v3/BACKLOG.md` remain the record of how +the app got here and where it is going. + +## Two bugs this port found in the native app + +**Crash recovery measured the dead time as distance.** `restoreAfterProcessDeath` says it +resumes into a new segment; it actually adopts the segment a crash left open, so the +authoritative recomputation measures straight through the gap. Reproduced at **111 km** of +phantom distance. Fixed here. + +**The elevation regression guard passes on seed luck.** On a shared fixture the algorithm +yields ~39 m, which would fail the native test's own 35 m bound. It passes only because +`kotlin.random.Random(42)` happens to draw a benign sequence. + +## Before shipping + +- Switch the application id from `com.rippr.port` back to `com.rippr`. The suffix exists + so the native app can be installed alongside during the port — **keep it until the real + ride comparison is done**. +- Replace the default Flutter app icon; the native artwork is in `~/dojo/rippr/design/`. +- Build and test a release build. Everything so far has been debug. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..1d28fc4 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,141 @@ +# Architecture + +Why Rippr is built this way. Ported from the native app's `docs/ARCHITECTURE.md`, keeping +the reasoning that still holds and recording what changed. + +``` +LocationSource (geolocator) ← the only file that knows the platforms differ + │ LocationFix + ▼ +RecordingEngine ── unbounded buffer ──► single writer loop (25 fixes / 2 s) + │ │ + │ ┌─────────────┴──────────────┐ + │ ▼ ▼ + │ TripRepository Accumulator + │ (all transitions) distance / moving time / + │ │ elevation, folded per batch + │ ▼ + │ Drift (SQLite) + │ Trip → Segment → TrackPoint + │ │ + └──► LiveTelemetry ▼ + (speedo) Riverpod providers + │ + ┌──────────────────┼──────────────────┐ + ▼ ▼ ▼ + RecordScreen TripsScreen TripDetailScreen + └─ RideMap (flutter_map) +``` + +--- + +## The decisions that carry the most weight + +### The fix path never blocks + +A GPS fix is appended to an in-memory list and nothing else. A single writer loop drains +it in batches every two seconds. Slow disk stalls the writer, never the fix stream, and no +fix is dropped under back pressure. + +Kotlin used `Channel(UNLIMITED)` plus a coroutine blocking on `receive()`. Dart has no +blocking receive and its single-threaded event loop makes one unnecessary — but the +guarantee is identical, and it is the reason a 2 Hz stream does not become two database +transactions per second. + +### Recording state lives in the database + +A row in `trips` with `endedAt IS NULL` **is** the fact of an in-progress ride. It survives +process death, which an in-memory flag cannot: Android can restart the process with no +intent, iOS can suspend and resume, and in both cases a flag would come back `false` while +a ride was genuinely underway. + +### Points are stamped at creation + +Every point carries its `tripId` and `segmentId` from the moment it is built, never looked +up at write time. This is what makes pause correct: a fix still buffered when the rider +pauses is written to the segment it was actually recorded during. + +Refactoring this into a write-time lookup would break the guarantee **silently**. + +### Segments make pause correct rather than cosmetic + +Without them, pausing at a gas station and resuming across town draws a straight line +through terrain never ridden — and counts it as distance. Segments propagate all the way +through: distance accumulation, map polylines, and GPX ``. + +The same reasoning applies to a crash. See `TripRepository.resumeIntoNewSegment`, which +fixes a bug the native app has here. + +### Whoever owns the writes owns the database + +The single most important structural decision of the port. `TrackingService` wrote to Room +directly. If Dart owned the schema but a native service owned the writes, every fix would +cross a platform channel that is *dead while iOS suspends Dart*. + +So Dart owns both, and the platform layer only has to keep the isolate alive. That is also +why there is **one isolate**: the foreground service provides liveness, not a second Dart +runtime, so two isolates never contend for one SQLite file. + +### Aggregates are accumulated live, then recomputed authoritatively + +Distance and elevation need consecutive-point differences, so they are folded in the +writer loop and persisted per flush — letting the recording screen show live numbers +without rescanning the point table. + +Those values are an **estimate**. On completion they are replaced by `computeSummary` over +the stored points, so a mid-ride process kill cannot leave permanently skewed totals. + +### Decimation is render-only + +Douglas–Peucker exists in the map path and nowhere else. A three-hour ride is ~21,600 +points and would jank an undecimated polyline, but storage and export must carry every raw +point. Tests assert exact counts in exports to catch a leak. + +--- + +## What changed from the native app + +| Native | Port | Why | +|---|---|---| +| Room | Drift | Same shape; runs on the Dart VM, so data-layer tests need no device | +| Foreground service (hand-written) | geolocator's own | Removes `flutter_foreground_task` and two deprecations. Costs notification actions. | +| `PARTIAL_WAKE_LOCK` | `enableWakeLock` | Same mechanism, configured rather than coded | +| ViewModel + StateFlow | Riverpod | Direct mapping | +| navigation-compose | go_router | Three destinations, same shape | +| osmdroid | flutter_map | Same OSM raster tiles, same no-API-key reasoning | +| FileProvider + ACTION_SEND | share_plus | Also handles the iPad popover anchor | +| OkHttp | package:http | Direct mapping | +| `Float` | `double` | Dart has no float32. See the parity note below. | + +### The Float→double divergence + +Kotlin stores speed and accuracy as 32-bit `Float`. Dart has no float32, so these widen. +Every other value in the app is bit-identical across the two implementations; speed-derived +values differ in the last digits (`40.030228` vs `40.03022888407912`). + +This is verified rather than assumed — `tool/parity/run.sh` compiles the real Kotlin +sources and diffs them against the Dart port across twenty fixtures. + +### iOS specifics that are easy to get wrong + +- `pauseLocationUpdatesAutomatically: false` — CoreLocation otherwise decides the ride has + ended, and does not reliably restart. +- `activityType: otherNavigation`, **not** `automotiveNavigation` — the latter snaps fixes + to the road network, which silently falsifies a recording of where you actually went. + +--- + +## Things that must not regress + +1. The unbounded buffer and single batched writer. Never write to the database from the + fix callback. +2. Points stamped with `tripId`/`segmentId` at creation. +3. Recording state derived from the database, never an in-memory flag. +4. No destructive migration. Any schema change ships a real `Migration`. +5. Decimation is render-only. +6. The theme names its text colours explicitly — a missing content colour once rendered a + 64 sp figure black-on-black, and no test caught it. +7. The map zoom clamp. A short ride will render an empty grid without it. +8. `PRAGMA foreign_keys = ON`. SQLite defaults it off and Drift does not set it; without + it every CASCADE is decorative. +9. Tests use in-memory databases. An instrumented test once wiped a real device's rides. diff --git a/docs/V3-BACKLOG.md b/docs/V3-BACKLOG.md new file mode 100644 index 0000000..ed13aea --- /dev/null +++ b/docs/V3-BACKLOG.md @@ -0,0 +1,196 @@ +> **Carried forward from the native repo** (`~/dojo/rippr/docs/v3/BACKLOG.md`) when the +> Flutter port completed. Two items below have changed status since it was written: +> +> - **Compose UI tests** — no longer a gap. The port has 15 widget tests plus 4 +> integration tests; see `docs/port/PARITY-AUDIT.md`. +> - **Elevation gain accuracy** — the algorithm is now proven bit-identical across Kotlin +> and Dart (`tool/parity/run.sh`), so any future tuning can be checked against the +> original rather than guessed at. The instruction below still stands: **do not tune it +> blind.** +> +> Everything else carries over unchanged, including the v3 ideas and the +> "must not regress" list. + +--- + +# v3 backlog + +Everything known-outstanding as of v2.0.1, with enough context to pick up cold. +Nothing here is committed to — it is a menu, roughly ordered by value. + +**Before planning anything: run the real-ride checklist in +[the real-ride checklist](port/REAL-RIDE-CHECKLIST.md).** Several items below may turn out to be non-issues, and +others may appear that nobody has thought of. + +--- + +## 1. Carried over from v2 — the honest debt + +### Elevation gain accuracy · *needs real data first* + +~30 m of phantom gain per ten stationary minutes against synthetic ±8 m uniform noise. Real +GPS altitude error is *correlated* rather than uniform, so the true behaviour is unknown. + +The current implementation is a 15-sample moving average plus reversal hysteresis (see +[ARCHITECTURE.md](ARCHITECTURE.md)). A naive version reported 1498 m over a parked +bike, so the guard rails matter. + +**Do not tune this blind.** Record a flat ride, check whether the reported gain is +plausible, and only then adjust. If it needs work, options are a longer smoothing window, a +larger threshold, or using barometric pressure where available (much more accurate than GPS +altitude, and most phones have the sensor). + +### Compose UI tests · *the largest coverage gap* + +Zero UI tests across six screens. Everything was verified by manual screenshot. Worth +covering: navigation record→trips→detail→back, rotation/state retention, empty states, +`NotFound`, chart degradation below two points, selection mode enabling Merge only at two. + +### Unmeasured, and probably should be + +- **Battery drain** over a multi-hour ride — never measured, and it is the thing most likely + to make the app unusable in practice +- **Map memory across repeated navigation** — the osmdroid lifecycle is a known hazard and + the wiring was never leak-tested +- **GPX import into Strava/Garmin** — validated against an XML parser, but schema validity + does not guarantee a consumer accepts it + +--- + +## 2. The original v3 candidate — live group ride view + +Deferred from v2 as "needs real server work". This was in the **v1** brief's goal +statement, so it has been the intended destination all along. + +Already in place: +- `TelemetryUploader` — batched POST, retry, offline-safe, cannot stall recording +- `synced` column and backlog semantics +- `trip_id` / `segment_id` per point, so a server can reconstruct rides and pauses +- `Config.deviceId` — stable per-install id to distinguish riders + +Missing: +- **A server.** Nothing exists. This is the actual work. +- **UI for the endpoint** — currently only reachable via `Config.setUploadEndpoint()` +- Other riders' positions on a map, and a live map at all (see below) +- Auth, rider identity, group membership + +**Worth deciding early:** this is the point where Rippr stops being a local-only app. That +brings hosting, privacy, and location-sharing consent into scope. + +--- + +## 3. Live map on the recording screen — **decision reversed** + +v2 deliberately shipped no live map, on the reasoning that the phone rides in a pocket. +Dylan has since asked for one — for visual appeal, and because **people may mount the phone +on the handlebars** to watch the route live. Treat the v2 stance as superseded. + +**The handlebar case changes the premise, not just the feature.** v1 and v2 were both built +around "start it, pocket it, stop it". A mounted phone is a different product with different +constraints, and it is worth deciding explicitly whether that becomes a first-class mode: + +- **Screen on for the whole ride** — battery goes from "a background service" to "a + service plus a lit screen plus continuous map rendering". Measure before committing. +- **Sunlight legibility** — the current dark theme is chosen for glanceability, but daylight + behind a visor is a different problem. +- **Glove-sized targets** — already partly handled (72dp buttons); a map needs the same care. +- **Keep-screen-awake** handling, and what happens on a call or notification. + +What still holds regardless: + +- **`TrackingService` must never reference a map.** Rendering belongs to the Compose + lifecycle of a visible screen, not the service. +- **No tile fetch or redraw while backgrounded**, even in mounted mode. + +--- + +## 4. Smaller items + +| Item | Notes | +|---|---| +| **Trip splitting** | Merge exists; split does not. The natural counterpart. | +| **SAF export** | Dropped in T16 as unnecessary — share sheet covers it. Add if a real need appears. | +| **Auto-pause** | Detect a stop and pause automatically. Rejected in v2 as unreliable in traffic; revisit only with real ride data showing it would help. | +| **Distance units** | Metric only, hardcoded. Trivial to add a preference. | +| **Settings screen** | None exists. `Config` has endpoint, deviceId, mapEnabled — the map toggle currently lives on trip detail because one switch did not justify a screen. | +| **Offline tile pre-download** | osmdroid caches what it renders; a mountain ride with no signal shows blank tiles. Respect OSM's usage policy — no bulk prefetch of their public servers. | +| **Notification live stats** | Show distance/duration in the ongoing notification, readable without unlocking. | +| **Crash reporting** | None. A recorder that dies mid-ride currently leaves no trace beyond logcat. | + +--- + +## 5. Ideas + +Terse on purpose. Unshaped, to be consolidated later. + +- **Live map while recording.** More visually appealing than a numbers screen. Reverses the + v2 decision — see section 3 for the constraints that survive it. +- **Pick a real theme.** The current look is functional dark + safety orange, chosen to + match the icon. Decide on an actual visual identity and push the UI toward something + polished rather than merely clean. +- **User sign-up and accounts.** Register people, give their data somewhere to live. + Prerequisite for anything cloud-side, and pairs with the group-ride server in section 2. +- **Activity type per ride.** Motorcycle, bicycle, skateboard, running, other. The app is + not inherently motorcycle-only — the recording pipeline is activity-agnostic already. + Note: adds a column to `Trip`, so it needs a real `Migration` (the destructive fallback + is gone). Type could also drive sensible defaults — speed noise floor, map zoom, + elevation smoothing. +- **Paid cloud backup.** Ongoing storage of rides over time. Needs accounts first, plus a + decision on hosting, pricing, and what happens to data when someone stops paying. +- **Waypoint route planning.** Drop a series of pins on the map to "draw" a route, get + distance and estimates back, and save it to ride later. This is *pre*-ride planning — + a genuinely new mode alongside recording, not an extension of it. Needs its own entity + (`Route` + `Waypoint`), separate from `Trip`, since a plan is not a recording. + Straight-line pin-to-pin distance is easy and reuses `Geo.haversineMeters`; snapping to + actual roads needs a routing service (OSRM, GraphHopper, Valhalla — self-hostable) and is + a much larger step. Natural follow-ons: follow a planned route on the live map, and + compare a recorded ride against the plan afterwards. + +### Threads running through these + +Sign-up, cloud backup and group ride are one programme, not three: they all need a server, +identity, and a privacy stance. Worth scoping together rather than separately. + +Activity type and theming are independent and much cheaper — either could ship alone. + +Live map, handlebar mounting and waypoint following also cluster: all three assume a +visible screen during the ride, and all three want the same map component. Route planning +is the odd one out — it needs no ride in progress at all and could be built entirely +standalone. + +--- + +## 6. Things that must not regress + +Hard-won and easy to undo by accident. Each has a comment in the code explaining why. + +1. **The unbounded `Channel` + single batched writer.** Do not write to the database from + the location callback. +2. **Points stamped with `tripId`/`segmentId` at creation.** Refactoring this into a + write-time lookup breaks the pause guarantee silently. +3. **Recording state derived from the database.** Never reintroduce an in-memory flag. +4. **`fallbackToDestructiveMigration()` stays removed.** Any schema change ships a + `Migration` against `app/schemas/com.rippr.data.AppDatabase/2.json`. +5. **Decimation is render-only.** It must never reach storage or export. +6. **`RipprTheme`'s `Surface`.** It sets `LocalContentColor`; without it, text without an + explicit colour renders black-on-black and disappears. +7. **The map zoom clamp.** `zoomToBoundingBox` ignores `maxZoomLevel`; a short ride will + render an empty grid without it. +8. **Instrumented tests use in-memory databases.** One previously wiped the real device + database in `setUp`. + +--- + +## 7. Reading order for picking this up cold + +1. [../README.md](../README.md) — what the app is and its current state +2. [ARCHITECTURE.md](ARCHITECTURE.md) — why it is built this way +3. `~/dojo/rippr/docs/v2/PROGRESS.md` (native repo) — every bug found during v2 and how +4. [the real-ride checklist](port/REAL-RIDE-CHECKLIST.md) — **especially "What the emulator cannot verify"** +5. `~/dojo/rippr/docs/DEVELOPMENT.md` (native repo) — when you actually need to build something + +The v2 planning approach worked well and is worth repeating: one document per task with +goal, context, design, acceptance criteria and risks, written *before* implementing, plus a +running progress log recording what actually went wrong. Several bugs were caught precisely +because the risk had been written down first — and one (the osmdroid lifecycle) was written +down and then walked into anyway, which is its own lesson. diff --git a/docs/port/PARITY-AUDIT.md b/docs/port/PARITY-AUDIT.md new file mode 100644 index 0000000..4a0a610 --- /dev/null +++ b/docs/port/PARITY-AUDIT.md @@ -0,0 +1,168 @@ +# T27 — parity audit + +Every row of the native app's v2.0.1 feature table, with the evidence behind each claim. + +**Deliberately not ticked in bulk.** A v2 task once had every acceptance criterion checked +by a blanket regex, including items nobody had verified. So each row below states *how* it +is known, and three categories are kept apart: + +| | Meaning | +|---|---| +| ✅ **Verified** | An automated test asserts it, or it was demonstrated running on a device | +| 🟡 **Implemented, unproven** | The code is there and reviewed, but nothing has exercised it in the conditions that matter | +| ⛔ **Gap** | Present in the native app, absent here | + +**175 automated tests** — 171 unit and widget, 4 integration on a real iOS simulator. + +--- + +## The feature table + +### GPS recording via foreground service — ✅ / 🟡 + +**Verified:** the pipeline has 24 tests covering all five actions, batching, the unbounded +buffer, accuracy filtering, noise-floor sanitisation and process-death resume. The app +launches and runs on an iOS simulator. + +**Unproven:** that it records a real ride. No simulator produces velocity, so speed, +moving time and speed colouring are structurally untestable here. + +Android liveness moved from a hand-written `TrackingService` to geolocator's own +foreground service (`foregroundServiceType="location"`, `enableWakeLock`), verified +present in the merged manifest. iOS uses `UIBackgroundModes: [location]` with +`allowBackgroundLocationUpdates`. + +→ `REAL-RIDE-CHECKLIST.md` items 1–11, A1–A3, I1–I5. + +### Trips with pause/resume/stop/discard — ✅ + +26 repository tests plus 24 engine tests. Every transition idempotent inside a +transaction; `startTrip` adopts rather than duplicating; pausing closes the segment; +discard cascades; a ride that captured nothing is dropped rather than saved. + +The pause guarantee has its own test: **a fix buffered before a pause is written with the +old segment id**, because ids are stamped at creation. + +### Live speed + elapsed clock — ✅ + +The 2.0.1 fix is pinned by two widget tests: the headline reads `SPEED`, not `MAX SPEED`, +and a separate test asserts the figure names a colour distinct from the background — the +black-on-black regression that only a screenshot caught in v2. + +The clock now ticks **only while a ride is active**, which is both correct and what makes +the screen testable at all. + +### Path rendered on OpenStreetMap — ✅ / 🟡 + +**Verified** by four map tests, which the native map never had: one polyline per segment +with a test asserting none straddles a pause, render-only decimation, the zoom clamp at +OSM's max tile zoom 19, and a `shortRideZoom` fallback for degenerate bounds — the v2.0 +empty-grid bug, now guarded. + +**Unproven:** speed colouring. Every simulated path renders in one colour because speed is +always zero. + +### Ride statistics + charts — ✅ + +21 statistics tests, and stronger evidence than the native app ever had: the parity +harness drives the real Kotlin and the Dart port from one shared fixture and they agree +**to the last digit**, including the elevation accumulator at `38.959594555022136`. + +Charts degrade to a message below two points rather than rendering a blank box, with a +widget test for it. + +**Carried over deliberately:** the ~30 m elevation drift against synthetic noise. Fixing +it during translation would have made every differential failure ambiguous. It stays in +the v3 backlog, where the standing instruction is *do not tune this blind*. + +### Rename / delete / merge rides — ✅ + +Repository tests cover merge re-parenting, rejection of self/active/missing merges, +atomicity, and the two properties that matter: **segments are never joined**, and +aggregates are **recomputed rather than summed** because distance is not additive across +the gap. Widget tests cover the UI, including Merge enabling at exactly two selections. + +Rename collapses empty and whitespace-only input to null. + +### GPX + GeoJSON export — ✅ + +15 tests parsed with a real XML parser and `jsonDecode`, not substring matching. The +parity harness additionally confirms both formats are **byte-identical** to the Kotlin +output — same length, same FNV hash, including an escaped hostile trip name. + +Exports carry the raw stored points; decimation never reaches them. + +### Upload to a REST endpoint — ✅ (still no UI, as before) + +7 tests: disabled endpoint, success, rejection, network failure, multi-batch backlog, +partial failure, and per-point trip/segment identity. Runs on its own timer so a dead +endpoint cannot disturb recording. + +Parity is exact, including the absence of UI. + +### Live map during recording — ⛔ by design + +Absent in v2, absent here. It is a **v3 decision**, and the backlog records that Dylan has +since asked for it and that handlebar mounting changes the app's founding premise. + +### Group ride view — ⛔ by design + +Deferred to v3 in the native app; unchanged. + +--- + +## Gaps and departures + +### ⛔ Notification actions — the one capability lost + +The native notification carried Pause/Resume buttons. geolocator's +`ForegroundNotificationConfig` cannot carry actions, so the notification is display-only; +tapping it opens the app. + +This was the price of dropping `flutter_foreground_task`, which bought the removal of two +deprecations that Flutter says become hard build errors. Worth revisiting if the shade +controls turn out to matter on a real ride — a separate notification plugin could add them +back without touching the engine. + +### ✅ A departure that fixes a native bug + +`restoreAfterProcessDeath` now resumes into a genuinely new segment. The native version +adopts the segment a crash left open, so the authoritative recomputation measures straight +through the dead time — **111 km of phantom distance** in the reproduction. Documented at +length in `PROGRESS.md` and in `TripRepository.resumeIntoNewSegment`. + +### Improvements that are not parity items + +- **The first UI tests this project has ever had** (15), closing what the v3 backlog names + as v2's largest coverage gap. +- **Instrumented tests became unit tests.** `SchemaTest`, `TripRepositoryTest` and + `MergeTest` were 666 lines needing a booted emulator; they now run in about two seconds + with nothing running. +- **A cross-language parity harness** that can be re-run at any time. +- A **record screen layout bug** fixed that Compose was clipping silently. + +--- + +## Not done, and why + +**The bundle id is still `com.rippr.port`.** Switching it to `com.rippr` is the final +cutover step — but doing it now would make the two apps unable to coexist, and +`REAL-RIDE-CHECKLIST.md` item **A3** depends on recording the same ride on both +simultaneously. That comparison is the strongest evidence available that the port is +faithful. + +**Switch it after T25, not before.** + +Also outstanding before any release: the app icon is still the Flutter default, and every +build so far has been debug. + +--- + +## Verdict + +Feature parity is **complete in implementation** and **verified as far as anything can be +without riding**. One capability is lost (notification actions), one native bug is fixed, +and the test coverage is substantially better than the app being replaced. + +What remains is not code. It is a rider, two phones, and +[REAL-RIDE-CHECKLIST.md](REAL-RIDE-CHECKLIST.md). diff --git a/docs/port/PROGRESS.md b/docs/port/PROGRESS.md index 8946126..d0b0132 100644 --- a/docs/port/PROGRESS.md +++ b/docs/port/PROGRESS.md @@ -736,4 +736,64 @@ build, and a demo video for the review notes. **T22, T23, T24 complete. T25 and T26 need a real device and a real rider.** -`178 tests passing` (171 unit + widget, plus 4 integration on device), analyze clean. +`175 tests passing` — 171 unit and widget, plus 4 integration on device. Analyze clean. + +> Corrected: an earlier summary in this file said 178. 171 + 4 is 175. + +--- + +## T27 — Parity audit and handover · **complete, except the cutover step** + +[PARITY-AUDIT.md](PARITY-AUDIT.md) walks every row of the v2.0.1 feature table with the +evidence behind each claim, keeping three categories strictly apart: **verified** (a test +asserts it, or it ran on a device), **implemented but unproven**, and **gap**. + +Deliberately not ticked in bulk — a v2 task once had every criterion checked by a blanket +regex including unverified items, and that lesson is written into the audit's preamble. + +### Documentation carried forward + +- `docs/ARCHITECTURE.md` — the durable reasoning, with a table of what changed and why, + the Float→double divergence, and the two iOS settings that are easy to get wrong +- `README.md` — status, the parity harness, and the two native bugs this port found +- `docs/V3-BACKLOG.md` — carried over with a header noting the two items whose status + changed (UI tests are no longer a gap; elevation can now be checked against Kotlin) +- The native repo's `README.md` now opens with a pointer here, and records both bugs + +All internal documentation links verified to resolve. + +### The cutover step is deliberately NOT done + +**The application id stays `com.rippr.port`.** + +Switching it to `com.rippr` is the last act of the port, but doing it now would stop the +two apps coexisting — and `REAL-RIDE-CHECKLIST.md` item **A3** depends on recording the +same ride on both simultaneously. That side-by-side comparison is the strongest evidence +available that the port is faithful, and it is worth more than finishing the checklist +tidily. + +The native repo is therefore marked **superseded, not archived**: it is still the only +version that has recorded a real ride. + +### An arithmetic correction + +An earlier entry in this file said 178 tests. The real figure is **175** — 171 unit and +widget, plus 4 integration. Corrected in place. + +--- + +## The port is complete + +| | | +|---|---| +| **175 tests** | 171 unit and widget, 4 integration on a real iOS simulator | +| **~4,600 lines** of Dart | plus 3,000 generated by Drift | +| **Both platforms build** | and the app runs on an iOS simulator | +| **Analyze clean** | throughout | +| **Parity proven, not assumed** | `tool/parity/run.sh` diffs against the real Kotlin | + +Two bugs found in the native app. One capability lost (notification actions). The first UI +tests the project has ever had. + +**What remains is not code.** It is a rider, two phones, and +[REAL-RIDE-CHECKLIST.md](REAL-RIDE-CHECKLIST.md).