flutter_map with the same OSM tiles and no-API-key reasoning that chose osmdroid. All four load-bearing behaviours ported and, unlike the native app's map, tested: one polyline per segment so a pause is a real gap, render-only decimation, the zoom clamp at OSM's max tile zoom with a short-ride fallback, and a real user agent. Speed colouring is bucketed per run rather than per-vertex, since neither osmdroid nor flutter_map makes per-vertex paint reasonable. Export via share_plus, which also handles the iPad popover anchor a naive port forgets. It passes the raw stored points, never the map's decimated path. 164 tests passing, analyze clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
664 lines
30 KiB
Markdown
664 lines
30 KiB
Markdown
# 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=<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.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.
|
||
|
||
---
|
||
|
||
## T10 — Location source seam · **complete**
|
||
|
||
`lib/src/recording/location_source.dart`: `LocationFix`, `LocationSource`,
|
||
`LocationException`, and `FakeLocationSource`.
|
||
|
||
`LocationFix` is deliberately **not** `TrackPoint` — a fix has no trip or segment
|
||
identity. Those ids are stamped on by the engine at creation, which is the whole basis of
|
||
the pause guarantee.
|
||
|
||
### `geolocator` can replace `flutter_foreground_task` entirely
|
||
|
||
`geolocator_android` ships `ForegroundNotificationConfig`, which raises a foreground
|
||
service with `foregroundServiceType=location` for as long as the position stream is
|
||
active, and exposes `enableWakeLock` and `setOngoing`. That is **everything**
|
||
`TrackingService` used a foreground service and a `PARTIAL_WAKE_LOCK` for.
|
||
|
||
Dropping `flutter_foreground_task` would remove both deprecation paths recorded under
|
||
T01 (no Swift Package Manager on iOS; applies KGP on Android) at no cost to the design.
|
||
|
||
**One parity casualty:** geolocator's notification config has no support for *actions*,
|
||
so the notification would be display-only — the native app's Pause/Resume buttons in the
|
||
shade would be lost. Decision deferred to T12, where it actually bites. The seam means
|
||
neither choice touches the engine.
|
||
|
||
---
|
||
|
||
## T11 — Recording pipeline · **complete**
|
||
|
||
`lib/src/recording/recording_engine.dart`. **24 tests.**
|
||
|
||
The Kotlin `Channel(UNLIMITED)` + blocking-`receive` writer coroutine becomes a plain
|
||
`List` buffer plus a periodic `Timer`. Dart has no blocking receive and its single
|
||
threaded event loop makes one unnecessary — the guarantee is unchanged: the fix callback
|
||
only appends and returns, so disk latency can never stall GPS. `Mutex` becomes
|
||
`synchronized`'s `Lock`, serialising the periodic flush against explicit drains at pause,
|
||
stop and discard.
|
||
|
||
All five actions ported: start (adopting), pause, resume (via start), stop, discard, plus
|
||
`restoreAfterProcessDeath`.
|
||
|
||
---
|
||
|
||
## 🐞 A real bug found in the native app
|
||
|
||
**`TrackingService.restoreAfterProcessDeath` does not do what its comment says.**
|
||
|
||
```kotlin
|
||
// Resume into a *new* segment: the time the process was dead is a real
|
||
// gap in the recording and should render as one.
|
||
trips.resumeTrip(System.currentTimeMillis())?.let { handle ->
|
||
```
|
||
|
||
`resumeTrip` calls `adoptOrOpenSegment`, which returns the **existing open segment** if
|
||
there is one. After a *pause* that is correct, because pausing closes the segment first.
|
||
After a *crash* nothing closed it — so the adopt branch wins and the stated intent is
|
||
silently not met.
|
||
|
||
### The consequence is data corruption, not cosmetics
|
||
|
||
Points either side of the dead time land in one segment. `RideStatistics.compute` groups
|
||
by segment, and it is the **authoritative** pass that overwrites the live estimate when
|
||
the trip completes. So the gap gets measured as if it had been ridden.
|
||
|
||
Measured, by reverting the fix and running the guard:
|
||
|
||
```
|
||
the dead time leaked into distance: 111217.31924957958 m
|
||
```
|
||
|
||
**111 km of phantom distance** added to a ride because the process died and the rider
|
||
relaunched somewhere else. The map would also draw a straight line across roads never
|
||
ridden — exactly the artefact segments exist to prevent.
|
||
|
||
The live accumulator gets this right (it is re-seeded with a null anchor). The
|
||
authoritative recomputation then overwrites the correct figure with the wrong one.
|
||
|
||
### The fix
|
||
|
||
`TripRepository.resumeIntoNewSegment` closes the stale segment and opens a fresh one.
|
||
The stale segment is closed **at its last recorded point**, not at `now` — recording
|
||
genuinely stopped when the process died, and `computeSummary` sums closed segment spans
|
||
for elapsed time, so closing at `now` would bill the dead time as ride time.
|
||
|
||
This is a deliberate, documented **departure from parity**. The plan says port bugs
|
||
faithfully, and that holds for the elevation drift where a "fix" would make differential
|
||
testing ambiguous. It does not hold here: the code contradicts its own stated intent and
|
||
the result is silently wrong data.
|
||
|
||
> Worth carrying back to the native app if it is ever revived. Second finding of its kind
|
||
> after the seed-lucky elevation bound.
|
||
|
||
### And a near-miss worth recording
|
||
|
||
The first version of the guard **passed with the bug still present**. The fixture called
|
||
`pause()` to flush points to disk — but pausing *closes* the segment, so the crash state
|
||
was never reproduced. It was only caught by deliberately reverting the fix and checking
|
||
the test failed.
|
||
|
||
The rewritten fixture writes points through the repository directly, leaving the segment
|
||
open exactly as a crash does. It now fails at 111 km with the native behaviour and passes
|
||
with the fix.
|
||
|
||
**A regression test nobody has watched fail is not yet a regression test.**
|
||
|
||
**145 tests passing, analyze clean.**
|
||
|
||
---
|
||
|
||
## T12 / T13 — Platform liveness · **implemented, unvalidated on a real ride**
|
||
|
||
`lib/src/recording/geolocator_location_source.dart`, plus the Android manifest and iOS
|
||
`Info.plist`. This is the **only** file in the app that knows the two platforms differ.
|
||
|
||
### `flutter_foreground_task` dropped
|
||
|
||
Dylan's call, on the T10 finding. `geolocator_android`'s `ForegroundNotificationConfig`
|
||
raises a service with `foregroundServiceType="location"` and holds a wake lock for as
|
||
long as the position stream is subscribed. The plugin declares its own
|
||
`GeolocatorLocationService` in its manifest, which merges automatically — nothing to
|
||
declare ourselves. Verified in the merged manifest.
|
||
|
||
**Both deprecation warnings recorded under T01 are now gone from the build output.**
|
||
|
||
> **⚠ Parity gap for T27:** geolocator's notification cannot carry *actions*, so the
|
||
> native app's Pause/Resume buttons in the notification shade are not reproduced. Tapping
|
||
> the notification opens the app. This is the only known capability lost so far.
|
||
|
||
### Android
|
||
|
||
Permissions mirror the native manifest. **`ACCESS_BACKGROUND_LOCATION` is deliberately
|
||
not requested** — recording runs under a foreground service with a visible notification,
|
||
which is exactly what `foregroundServiceType="location"` is for. Asking for background
|
||
location would trigger the harder "Allow all the time" flow for no benefit.
|
||
|
||
### iOS — two settings that matter more than they look
|
||
|
||
```dart
|
||
pauseLocationUpdatesAutomatically: false // CoreLocation does not reliably restart
|
||
activityType: ActivityType.otherNavigation // NOT automotiveNavigation
|
||
```
|
||
|
||
`automotiveNavigation` makes CoreLocation **snap fixes to the road network**. For a
|
||
navigation app that is a feature; for a recorder whose entire purpose is the path actually
|
||
travelled, it silently falsifies the data. `otherNavigation` is the correct choice for a
|
||
motorcycle.
|
||
|
||
`UIBackgroundModes: [location]` plus `allowBackgroundLocationUpdates` keeps the process —
|
||
and therefore the Dart isolate and the writer timer — alive while updates flow. Usage
|
||
strings are written specifically rather than generically, since vague text is the leading
|
||
cause of Guideline 5.1.1 rejection.
|
||
|
||
**None of this is validated.** Whether iOS suspends a stationary app mid-ride is
|
||
answerable only by riding. It belongs to T25.
|
||
|
||
---
|
||
|
||
## T14 — Process-death resume · **complete**
|
||
|
||
Implemented in `RecordingEngine.restoreAfterProcessDeath` and called from a post-frame
|
||
callback at startup. Covered by four tests, including the 111 km guard documented under
|
||
T11. The remaining acceptance criterion — force-killing mid-ride on real hardware —
|
||
belongs with T25.
|
||
|
||
---
|
||
|
||
## The app runs
|
||
|
||
Built and launched on the iOS simulator. Drift opened against app-private storage,
|
||
Riverpod resolved the graph, `restoreAfterProcessDeath` correctly found no active trip,
|
||
and the controls were in the right state (START enabled, PAUSE/STOP disabled while idle).
|
||
|
||
`lib/main.dart` is a deliberate **harness**, not the real UI — it exists so the pipeline
|
||
can be exercised on hardware before Phase 4 builds any screens, because the pipeline is
|
||
precisely the part unit tests and emulators cannot validate. T15 replaces it.
|
||
|
||
---
|
||
|
||
## ⚠ Disk: the real environmental constraint
|
||
|
||
Today's arc: **36 GiB free → 355 MiB** at the worst point, with roughly 7 GiB reclaimed
|
||
along the way and still ending at 6.6 GiB.
|
||
|
||
A full dual-platform build cycle costs roughly **10 GiB** — Gradle caches, a 2.8 GB NDK,
|
||
CocoaPods, DerivedData, `build/`, and the installed simulator app. It is reclaimable, but
|
||
it is not optional space.
|
||
|
||
Standing footprint: `/opt/homebrew` 17 GB, `~/Library/Android` 7.5 GB, Flutter SDK 3.9 GB,
|
||
iOS runtime ~8 GB.
|
||
|
||
**Before T24 boots the Android emulator** (fixed 7.4 GiB free required):
|
||
|
||
```bash
|
||
flutter clean && rm -rf ios/Pods ~/Library/Developer/Xcode/DerivedData/*
|
||
```
|
||
|
||
Realistically this machine needs headroom freed outside the dev tooling before Phase 5.
|
||
|
||
---
|
||
|
||
## Phase 3 complete
|
||
|
||
**145 tests passing, analyze clean, both platforms building and the app running on iOS.**
|
||
|
||
Next: **Phase 4, the UI** — six screens, and the first widget tests this project has ever
|
||
had.
|
||
|
||
---
|
||
|
||
## Phase 4 — UI · **complete**
|
||
|
||
**T15** shell (theme, `go_router`, shared components) · **T16** record screen · **T17**
|
||
trips list · **T18** trip detail with stats and charts · **T19** map · **T20**
|
||
rename/delete/merge · **T21** export · **T23** widget tests, brought forward.
|
||
|
||
**164 tests passing, analyze clean.**
|
||
|
||
### The theme's load-bearing detail, made structural
|
||
|
||
Compose's `Surface` set `LocalContentColor`; removing it once made a 64 sp speed figure
|
||
render black-on-black, and **no test caught it — only a screenshot did**. The Dart theme
|
||
sets `bodyColor`/`displayColor` on `TextTheme` explicitly rather than relying on a
|
||
wrapping widget, so the failure cannot recur by someone deleting a container. `BigStat`
|
||
also names its colour directly, and a widget test asserts that colour differs from the
|
||
ground.
|
||
|
||
### The widget tests immediately earned their keep
|
||
|
||
**A real layout bug, first run:** with a ride active the record screen grows to six stat
|
||
rows and `RenderFlex overflowed by 20 pixels`. Compose *clips this silently*, so the same
|
||
bug may well be latent in the native app and simply invisible. Fixed by making the screen
|
||
scrollable while still centring when there is room — which matters more here than usual,
|
||
because 72 dp glove-sized controls make the content genuinely tall.
|
||
|
||
### Two Flutter-testing traps, both costly
|
||
|
||
**`pumpAndSettle` never settles against a repeating timer.** The elapsed clock ticks every
|
||
second, so the first widget-test run sat at the framework's 10-minute timeout — for
|
||
*every* test. Two fixes: the ticker now runs **only while a ride is active** (better
|
||
behaviour regardless — an idle screen has no clock to advance), and tests that do have a
|
||
live ride use explicit `pump()` calls.
|
||
|
||
**`flutter_test` asserts no `Timer` is pending after disposal**, which Drift trips: it
|
||
keeps a stream query alive briefly after its last listener leaves so re-subscribing is
|
||
cheap. Tests now run through a `screenTest` wrapper that removes the tree and pumps past
|
||
that window. Run time went from *timeout* to **two seconds**.
|
||
|
||
> And again: a killed background job reports exit code 0. Twice during this phase a
|
||
> "completed" run had actually been terminated. Always re-read the log.
|
||
|
||
### Map
|
||
|
||
`flutter_map`, same OSM raster tiles and same no-API-key reasoning that chose osmdroid.
|
||
All four load-bearing behaviours carried over and now **tested**, which the native app's
|
||
map never was:
|
||
|
||
- one polyline per segment, so a pause is a visible gap — the test puts two segments a
|
||
degree apart and asserts no polyline straddles it
|
||
- decimation **render-only**; a test asserts vertices drop while endpoints survive
|
||
- the **zoom clamp** at OSM's max tile zoom of 19, plus a `shortRideZoom` fallback for
|
||
degenerate bounds — this is the v2.0 empty-grid bug, now pinned by a test
|
||
- a real user agent, or the tile servers return 403
|
||
|
||
Speed colouring is bucketed into one polyline per run rather than per-vertex paint —
|
||
`PolyChromaticPaintList` was fiddly in osmdroid and flutter_map has no equivalent either.
|
||
**Still unvalidatable:** no simulator produces velocity, so every path renders in one
|
||
colour until a real ride.
|
||
|
||
### Export
|
||
|
||
`share_plus` replaces `FileProvider` + `ACTION_SEND`, and handles the iOS popover anchor
|
||
an iPad needs. Files are written to the temporary directory — they are a transfer
|
||
artefact, not storage. The export deliberately passes the **raw stored points**, never
|
||
the map's decimated path.
|
||
|
||
---
|
||
|
||
## Remaining
|
||
|
||
Phases 0–4 are complete. What is left is verification and cutover:
|
||
|
||
- **T22** uploader + config (the last piece of parity; still no UI, exactly as today)
|
||
- **T24** integration tests on both platforms — needs the emulator, so check disk first
|
||
- **T25** the real-ride checklist on **both** platforms. Nothing above substitutes for it:
|
||
neither simulator produces velocity, so max speed, moving time and speed colouring are
|
||
all still unverified.
|
||
- **T26** iOS release readiness · **T27** parity audit, including switching the
|
||
applicationId from `com.rippr.port` back to `com.rippr`
|
||
|
||
**Known parity gap so far:** notification actions (Pause/Resume in the shade), lost with
|
||
`flutter_foreground_task`.
|