Built together because V3-02's settings screen needed something real for V3-03's units to control -- executed in reverse of the ticket numbering, but both tickets are independently complete. V3-03: UnitSystem (metric/imperial) lives in domain/models.dart alongside Activity, defaulting from Platform.localeName on first launch. Storage stays SI everywhere -- conversion happens only in ui/format.dart, at the last possible moment, which is now documented as the file's central invariant. Threaded through all three screens plus the speed histogram's bucket labels, which relabel for display without changing how speedHistogram itself bins. Export was deliberately left untouched: gpx()/geoJson() take no UnitSystem parameter at all, a stronger guarantee than validating one would be. V3-02: SettingsScreen reachable from the record screen. Map render toggle (nothing previously exposed mapEnabledProvider to the user, despite it existing since T15 -- there was nothing to "move off trip detail" as drafted), unit selector, upload endpoint with http(s) validation, a copyable device id, and a licences page via Flutter's built-in showLicensePage. Skipped package_info_plus (static version string instead) and a privacy-policy link (none published yet) as disproportionate to an S-sized ticket. Neither ticket needed the ConfigNotifier the implementation notes proposed: unitSystemProvider reuses the exact StateProvider-seeded-from-Config pattern mapEnabledProvider already established, since Config mutates its own backing SharedPreferences in place and re-assigning the same instance would never notify a watcher anyway. A real locale-dependent flake was caught, not just anticipated: a test asserting a fresh Config defaults to metric failed, because this machine's own locale resolves to a region in the imperial set. Fixed by seeding an explicit value before asserting, and config_test.dart's locale test was written from the start to only prove the fallback path doesn't throw, not to assert which value it returns. 221 tests (211 -> 221): 13 format_test, 9 config_test, 9 settings_screen_test, 1 confirming the record screen's settings button is genuinely wired. Analyze clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rippr
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.
Built for one specific use: hit start, put the phone in a pocket, ride, hit stop. Most of the design follows from that.
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 for
exactly what is verified and what is not.
Flutter 3.47 · Dart 3.13 Android minSdk 26 · iOS 12+
~4,600 lines Dart 175 tests (171 unit/widget, 4 integration)
Status
| 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, and it is the next thing that should happen.
Quick start
Requires JDK 17–21 for Android (AGP rejects 25) and Xcode for iOS.
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:
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 | Why it is built this way, and what changed from the native app |
| docs/v3/ | v3 tickets — one file per feature, ready to pick up |
| docs/BACKLOG.md | The v3/v4 reasoning behind those tickets |
| docs/LAUNCH.md | Getting from "on my phone" to the app stores, in stages, with costs |
| docs/port/IOS-VERIFICATION.md | What a Mac can prove about the iPhone build, and what cannot |
| docs/port/PLAN.md | The 28-task migration plan |
| docs/port/PROGRESS.md | What actually happened, including every bug found |
| docs/port/PARITY-AUDIT.md | Feature-by-feature, with the evidence behind each claim |
| docs/port/REAL-RIDE-CHECKLIST.md | The outstanding work |
| docs/port/RELEASE-IOS.md | App Review readiness |
| 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.portback tocom.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.