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 <noreply@anthropic.com>
This commit is contained in:
107
README.md
107
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 <device-id>
|
||||
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=<jdk-21> ./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.
|
||||
|
||||
141
docs/ARCHITECTURE.md
Normal file
141
docs/ARCHITECTURE.md
Normal file
@@ -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 `<trkseg>`.
|
||||
|
||||
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.
|
||||
196
docs/V3-BACKLOG.md
Normal file
196
docs/V3-BACKLOG.md
Normal file
@@ -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.
|
||||
168
docs/port/PARITY-AUDIT.md
Normal file
168
docs/port/PARITY-AUDIT.md
Normal file
@@ -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).
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user