# 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](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=` — 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.rippr`** at 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`: ```kotlin 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.** --- ## T09 — Trip repository · **complete** `lib/src/data/trip_repository.dart`. **26 tests, green on the first run.** Every lifecycle transition ported: `startTrip` (adopting, never duplicating), `pauseTrip`, `resumeTrip`, `completeTrip`, `discardTrip`, `renameTrip`, `deleteTrip`, `mergeTrips`, `recomputeAggregates`. Each runs inside `_db.transaction` and each is idempotent, because the platform can restart the recorder from any state. `Room.withTransaction` maps onto Drift's `transaction()` almost exactly, so this was the most mechanical port so far. ### What the tests protect - **Adoption over rejection.** `startTrip` on an already-active trip returns the *same* handle rather than opening a second trip — the behaviour that makes a process kill survivable. - **Merge never joins segments.** The boundary between two merged rides stays a segment boundary, exactly like a pause. The regression test puts the two rides a degree of latitude apart and asserts the ~111 km gap never reaches `distanceM`. - **Aggregates are recomputed, not summed** — because distance is not additive across that gap. - **Rename collapses empty and whitespace-only input to null**, so a stored `""` can never diverge from the UI's date-label branch. - **Merge rejects** self-merge, an active trip, and a missing id, and leaves no orphans. ### One piece of speculative code removed A `mergeTableUpdates` helper was written to nudge Drift's stream queries after re-parenting, then deleted before commit: Drift's own `update()` already notifies dependent streams, so it earned nothing and would have been misleading scaffolding. ### The instrumented-to-unit win, totalled `TripRepositoryTest` + `MergeTest` + `SchemaTest` were **666 lines of instrumented tests requiring a booted emulator**. All three are now plain unit tests finishing in about two seconds with nothing running. --- ## Phase 2 complete **121 tests passing, `flutter analyze` clean.** The data layer is done and the domain, statistics and export layers above it are proven equivalent to the Kotlin original. Next: **Phase 3, the recording engine** — the risky phase. T10's `LocationSource` seam first, then the pipeline, then the two platform liveness stories. The `flutter_foreground_task` deprecation warnings recorded under T01 become relevant at T12.