Three tables with CASCADE foreign keys, indices, WAL, and a real MigrationStrategy from schema version 1. No destructive fallback, ever. Two things needed deliberate handling. SQLite defaults foreign_keys to OFF and Drift, unlike Room, does not enable it -- without the pragma every CASCADE is decorative, so there is now a test that reads the pragma back. And Drift both snake_cases columns and names row classes after tables; build.yaml sets case_from_dart_to_sql: preserve so the schema stays column-for-column identical to Room's, and @DataClassName keeps row types from colliding with the domain models. SchemaTest was instrumented and needed a device; the Drift version is a plain unit test that runs in under a second with no emulator. 16 tests. 95 tests passing, analyze clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 KiB
Port progress log
Running record of what actually happened, task by task — including what went wrong. The v2 equivalent of this file caught real bugs by making risks explicit before they were walked into, so the practice carries over.
Plan: PLAN.md · Source of truth for why: the native repo's
docs/ARCHITECTURE.md, docs/TESTING.md, docs/v2/PROGRESS.md.
T00 — Disk space + toolchain · complete
Outcome: flutter doctor reports no issues in any category. Flutter 3.47.0 (Dart
3.13.0), Xcode 26.0.1, CocoaPods 1.17.0, Android SDK 36.0.0, iOS 26.0.1 simulator runtime.
The disk panic was largely a false alarm — but measure twice
Planning measured 16 GiB free at 92%, which drove a whole cleanup strategy. A second measurement minutes later, before deleting anything, showed 36 GiB free at 81%. Most likely APFS local snapshots aging out.
Nothing was deleted. Gradle caches, AVDs, and ~/.cargo were all left intact.
Post-install the machine sits at ~22 GiB free.
Lesson: re-measure immediately before acting on a disk-space number. Had the plan been followed literally, ~9 GB of still-useful caches would have been destroyed for no reason.
Things that actually needed fixing
| Problem | Fix |
|---|---|
cmdline-tools component is missing |
sdkmanager --sdk_root=$ANDROID_HOME "cmdline-tools;latest" — the exact trap already documented in the native repo's DEVELOPMENT.md: brew's sdkmanager resolves its own SDK root and does not see ~/Library/Android/sdk unless --sdk_root is passed explicitly |
| Android licenses unaccepted | yes | sdkmanager --sdk_root=$ANDROID_HOME --licenses |
| Default JDK is 25, which AGP rejects | flutter config --jdk-dir=<temurin-21> — same constraint as the native build, now pinned in Flutter's config rather than relying on an exported JAVA_HOME |
| No iOS simulator runtime installed | xcodebuild -downloadPlatform iOS (8.05 GB). Note simulator devices already existed for a runtime that did not — simctl list devices looked populated while list runtimes was empty |
| CocoaPods absent, system Ruby 2.6.10 | brew install cocoapods, deliberately not gem install |
T01 — Repo scaffold · complete
~/dojo/rippr-flutter, flutter create --org com.rippr --project-name rippr.
Two deliberate deviations from the generated defaults
Bundle id is com.rippr.port, not com.rippr. --org com.rippr + name rippr
produces com.rippr.rippr, which is wrong either way. The choice of com.rippr.port is
deliberate and temporary: the plan requires the native app to stay installable as a
reference and fallback, and two apps cannot share an applicationId. Keeping them
distinct means both can sit on the same phone — which also enables the strongest possible
validation in T25: record the same ride on both simultaneously and compare the numbers.
T27 must switch this to
com.ripprat cutover. Recorded here because it is exactly the kind of temporary decision that silently becomes permanent.
The Android namespace stays com.rippr and the Kotlin source was moved from
kotlin/com/rippr/rippr/ to kotlin/com/rippr/ to match.
Builds verified on both platforms
✓ build/ios/iphonesimulator/Runner.app and ✓ build/app/outputs/flutter-apk/app-debug.apk.
Android needed three changes to the generated build.gradle.kts:
compileSdk = 37 // a dependency demands it; the build fails outright on 36
minSdk = 26 // parity with the native app (Android 8.0)
targetSdk = 36
sdkmanager cannot fetch platforms;android-37 from the stable channel — it reports
"Failed to find package". AGP installed it automatically during the build (as
android-37.0), along with CMake 3.22.1 and a 2.8 GB NDK, because the licences had
already been accepted. Convenient, but see the disk note below.
⚠ flutter_foreground_task is on two deprecation paths
Both warnings name the same package — the one chosen for Android background liveness in T12:
- iOS: does not support Swift Package Manager. "This will become an error in a future version of Flutter."
- Android: applies the Kotlin Gradle Plugin. "Future versions of Flutter will fail to build if your app uses plugins that apply KGP."
Neither breaks today's build. Both should be re-checked at T12, and they strengthen the
case for the LocationSource seam in T10 — the background layer needs to stay swappable.
The disk problem was real, just not where the plan predicted
The plan braced for SDK installs filling the disk. The actual consumption was builds:
free space fell from 22 GiB to 3.9 GiB during the first Android build — Gradle caches
grew 3.7 → 8.0 GB, the NDK added 2.8 GB, and build/ alone reached 2.7 GB.
Recovery, in order of how safe each step was:
| Action | Reclaimed |
|---|---|
Delete build/ + flutter clean + the native app's app/build |
~3.8 GiB |
Prune Gradle caches for versions no project uses (9.5.0, 9.7.0), build-cache-1, and the native project's 8.11.1 distribution |
~3 GiB |
Ended at 11 GiB free (94% used). modules-2 (1.9 GB) was deliberately kept —
deleting it forces a re-download of every dependency, which is a real time cost for space
we do not currently need. Only two Gradle versions are actually in use: 9.3.1 (this
project) and 8.11.1 (the native app, whose distribution cache re-downloads on demand).
Standing risk for T24: the Android emulator needs a fixed 7.4 GiB free to boot. At 11
GiB that works, but one more full build cycle could eat the margin. Run flutter clean
before booting the emulator.
Dependencies
flutter_riverpod · go_router · drift + drift_flutter · path_provider ·
geolocator · flutter_foreground_task · permission_handler · flutter_map +
latlong2 · share_plus · shared_preferences · http · synchronized.
Dev: drift_dev, build_runner, mocktail, integration_test.
sqlite3_flutter_libs resolves to an +eol-tagged release (0.6.0+eol). It was added
explicitly at first, then removed — drift_flutter depends on it transitively regardless,
so the pin belongs to drift, not to us. Worth watching when drift next majors, but not
actionable now.
T02 — Geo utilities · complete
lib/src/geo/geo.dart + test/geo_test.dart. 23/23 passing.
Ported structurally faithfully from com.rippr.geo.Geo: haversine (with the
asin(sqrt(a)) conditioning note), iterative Douglas–Peucker with an explicit stack,
equirectangular perpendicular distance with clamped projection, null-on-empty bounds,
path length. Every test case and tolerance carried over unchanged, including the
Calgary–Edmonton 280.9 km figure that was corrected during v2 after the test proved
wrong rather than the code.
Deliberate API divergence: Kotlin's object Geo namespace became top-level functions,
which is idiomatic Dart. LatLon is our own type rather than latlong2's LatLng — the
pure layer must not depend on the map package; conversion happens at the render boundary.
Two things went wrong
library; after the import. Dart requires the library directive before all other
directives. Caught immediately by the compiler — noted only because a file-level doc
comment is otherwise easy to attach wrongly.
A hang that was not a hang. flutter test and flutter build ios were run
concurrently and both sat at 0% CPU for minutes. The suspicion was Rosetta, because
flutter_tester lives under artifacts/engine/darwin-x64/ — that was wrong: the
binary there is arm64 and the directory name is legacy. The real cause was
ibtool/actool spawning IBAgent-iOS and AssetCatalogSimulatorAgent, which deadlock
against a booted simulator. Run alone with simulators shut down, the same suite finishes
in under a second.
Two lessons, both echoing v2's harness troubles:
- Do not run an iOS build against a booted simulator if anything else needs it.
- A killed background job still reports exit code 0. Both jobs "completed successfully" because they were killed. Never read a success code from a process you terminated — re-run it cleanly.
T03 — Telemetry, formatting, ephemeral state · complete
telemetry.dart (msToKmh, sanitizeSpeedKmh, isUsableFix, formatDuration, encodeBatch),
ui/format.dart, telemetry/live_telemetry.dart. 8 tests.
Also added domain/models.dart — Trip, Segment, TrackPoint, TripState,
RideStats as plain Dart with no persistence dependency. Drift will map to these
in T08 rather than the domain depending on the database. This is the Dart equivalent of
the discipline that made the Kotlin logic testable on the JVM.
Kotlin Float becomes Dart double. Dart has no float32. Widening is the right
call — a shim would be friction for sub-millimetre precision on GPS-derived values — but
it means speed-derived values cannot be compared bit-for-bit. See T07 for the measured
consequence.
Format builds its DateFormat per call, unlike the Kotlin original which captured
Locale.getDefault() once at class-init. Doing the same in Dart would freeze the format
for the process lifetime and ignore a locale change.
T04 — Ride statistics · complete
stats/ride_statistics.dart including ElevationAccumulator. 21 tests.
Ported structurally faithfully — moving average, reversal hysteresis,
gainIncludingPending(), and the finish() reconciliation against lastRaw. Nothing
was "improved" during translation, per the plan.
The failure that proved the port correct
The ported elevation test failed: 50.86 m against Kotlin's 35 m bound. That looks exactly like a porting bug in the hardest code in the project.
It was not. The two languages' Random(42) are different streams. Rather than tune the
bound blind — which docs/v3/BACKLOG.md explicitly warns against — the question was
settled by building the T07 harness early and driving both implementations from one
shared LCG:
noisy_gain = 38.959594555022136 ← Kotlin
noisy_gain = 38.959594555022136 ← Dart
Bit-identical. The port is exact.
This also found something about the native app. On the shared fixture the algorithm yields ~39 m, which would fail Kotlin's own 35 m bound. The native test passes on seed luck, not on a property of the algorithm. A sweep of 25 Dart seeds spanned 24.7–46.7 m (median 36). The Dart test now uses the shared LCG, asserts bit-equality with Kotlin, and sets its bound from measured behaviour with headroom.
Worth carrying back to the native repo if it is ever revived: that guard is weaker than it looks.
T05 — Live accumulator · complete
recording/ride_accumulator.dart. 12 tests, green on the first run.
The cross-batch anchor is intact — distance across many small batches matches one big batch is the guard, and its absence under-reports distance by a few percent invisibly.
T06 — Export writers · complete
export/ride_export.dart. 15 tests, green on the first run. Parsed with a real XML
parser and jsonDecode, not substring matching, exactly as the Kotlin suite did.
T07 — Cross-language parity harness · complete
tool/parity/ — run.sh, main.kt (Kotlin oracle), probe.dart. Run it with
JAVA_HOME set to a 17–21 JDK; needs kotlinc (brew install kotlin, ~95 MB).
It copies Geo.kt, RideStatistics.kt and RideExport.kt verbatim from the native
repo and compiles them against minimal stand-ins for the Room-annotated holders and one
Telemetry constant. The files under test are never reimplemented.
Result
Every key byte-identical across 20 fixtures, including:
noisy_gain |
38.959594555022136 — the elevation accumulator, to the last digit |
simplify_count |
218 of 21,600 points, identical Douglas–Peucker decisions |
gpx_len / gpx_fnv |
GPX output byte-identical, including the escaped hostile name |
geojson_len / geojson_fnv |
GeoJSON byte-identical |
The single accepted difference is run_avg_speed: 40.030228 (Kotlin Float) vs
40.03022888407912 (Dart double) — precisely the divergence predicted in T03. The
harness prints this explanation on failure so a future reader is not left guessing.
Two traps hit while building it
String.hashCode is not comparable across languages. The first version compared
Java's and Dart's hashes of the GPX output — which would have "failed" forever for no
reason. Replaced with an FNV-1a implemented identically in both.
A missing jar silently passed as success. When RideExport.kt was not yet copied,
compilation failed, java -jar errored, and the pipeline continued. run.sh now checks
for the jar and exits non-zero. This is the same class of bug as the v2 sweep that
returned four identical results because a compile error hid behind /dev/null — the
comment in run.sh says so explicitly.
Phase 1 complete
79 tests passing, flutter analyze clean. ~890 lines of Kotlin logic ported, with
its ~965 lines of tests, and proven equivalent rather than assumed equivalent.
Next: T08 (Drift schema), the first task that touches persistence.
T08 — Drift schema · complete
lib/src/data/database.dart (+ generated database.g.dart). 16 tests.
Mirrors app/schemas/com.rippr.data.AppDatabase/2.json: three tables, CASCADE foreign
keys, indices on tripId / segmentId / synced, WAL with synchronous = NORMAL.
Starts at Dart schema version 1 with a real MigrationStrategy — the destructive
fallback never comes back.
Foreign keys are OFF by default in SQLite
Room switched them on for us. Drift does not. Without PRAGMA foreign_keys = ON in
beforeOpen, every CASCADE in the schema is decorative and deleting a trip silently
orphans all of its points. There is now a test that reads the pragma back and asserts it
is 1, because this is invisible until data is already wrong.
Two collisions worth recording
Drift generates row classes named after the table. Trips → Trip, colliding with
the domain model of the same name and producing 21 confusing analyzer errors of the form
"Trip can't be assigned to Trip". Fixed with @DataClassName('TripRow') etc. The
mapping functions _toTrip / _toSegment / _toPoint convert row → domain, so the
domain layer stays unaware Drift exists.
Drift snake_cases column names. speedKmh became speed_kmh, which broke the one
raw-SQL query (the live stats aggregate) with no such column: speedKmh. Rather than 30
.named() annotations, build.yaml sets case_from_dart_to_sql: preserve. That keeps
the schema column-for-column identical to Room's, lets the raw SQL stay byte-identical to
the Kotlin DAO query it was ported from, and leaves a Room-file importer possible later.
The instrumented-to-unit win, realised
SchemaTest needed a device and an emulator. The Drift equivalent runs on the Dart VM in
well under a second with nothing booted. TripRepositoryTest and MergeTest (441 more
lines) should convert the same way in T09.
95 tests passing, analyze clean.