Route library: storage, list UI, distance and elevation profile (#25) #88

Merged
robert merged 2 commits from area/route-library into main 2026-09-04 10:50:28 +02:00
Owner

Closes #25.

Summary

Persists imported GPX routes and turns the share-sheet import (#24) into a real library
instead of a status message that discards its result: GpxShareImportActivity now saves
every successful GpxImportResult.Success via the new route store, a Compose list/detail UI
shows every saved route with a preview and elevation profile, rename/delete work, and one
route can be marked active for the next ride, surviving an app restart.

Persistence mechanism

Room for the routes themselves (companion/route/.../route/store/RouteEntity.kt /
RouteDao.kt / RouteDatabase.kt), SharedPreferences for the single active-route
pointer (SharedPreferencesActiveRoutePointerStorage.kt). Checked the existing Gradle setup
from #14/#75 first — no serialization/storage library was there yet, so this is a genuinely
new dependency, not a reuse. Room over hand-rolled files because the list UI needs to query
every route's name/distance/enrichment status without parsing every route's full geometry —
SharedPreferences has no query surface at all and is used here only for the one scalar it's
actually suited to (the active-route id).

The route's polyline itself is stored as two TEXT columns via a hand-rolled (not JSON — no
serialization dependency existed to reuse, and this format has exactly one reader/writer)
codec in :companion:core (RoutePointsCodec.kt), round-trip tested there including missing
elevation/timestamps and waypoint names containing the codec's own separators.

Design split, and why: all business logic — import, list, rename, delete, select-active —
lives in :companion:core as RouteLibrary, operating over two small storage interfaces
(RouteRecordStorage, ActiveRoutePointerStorage). :companion:route's Room/SharedPreferences
code is a thin adapter implementing those interfaces. That's what makes the actual semantics
testable on the JVM against in-memory fakes, matching this module's existing "no android.*
import" boundary.

Ascent with possibly-missing elevation

computeRouteMetrics (core/route/library/RouteMetrics.kt) sums positive elevation deltas
only across consecutive point pairs where both points carry <ele> (optional per #24). A
pair straddling a missing-elevation point contributes nothing — not bridged across the gap,
not treated as a drop to/from zero. This under-reports ascent on routes with patchy <ele>
coverage rather than risk fabricating a spike; a route with no elevation data anywhere reports
ascentMeters == 0.0 as an honest "no data", not a claim the route is flat. Distance reuses
RouteGeodesy.kt's cumulativeDistancesMeters (#26/#63) rather than re-deriving it.

Enrichment status

RouteEnrichmentState (core/route/enrich/RouteEnrichmentState.kt): PENDING / ENRICHED /
FAILED, plus which EnrichmentTier (reusing #26's existing enum rather than inventing a
parallel one) when ENRICHED. No auto-enrichment trigger is wired in this issue — every
newly imported route is persisted as PENDING and stays there. Only tier 0 (GpxCueEnricher,
#63/#79) actually exists; tiers 1-3 are unbuilt, so running the chain automatically on import
today would only ever produce tier-0-or-nothing, and wiring that trigger felt like a decision
for the issue that actually owns "when does enrichment run", not a drive-by addition here. The
status field is real and persisted; RouteLibrary.updateEnrichment exists for a future issue
to call once it decides on a trigger.

UI

Jetpack Compose — this is :companion:route's first screen. Checked: :companion's
MainActivity already uses ComponentActivity/setContent, and the Compose BOM/plugin were
already wired at the app level (not yet used in any feature module). Followed that precedent
rather than introducing classic Views as a second toolkit; flagging as a design call since
there was no committed direction, per the issue.

List + detail screens (route/store/RouteLibraryScreen.kt, RoutePreview.kt,
ElevationProfile.kt, RouteLibraryActivity.kt): name (or a localised "unnamed route"
fallback — matching #24's own choice not to bake one locale's fallback text into persisted
data), distance/ascent, enrichment status text, a schematic no-tiles polyline preview
(:companion:map's MapViewport is still a placeholder, no tile rendering exists anywhere
yet), an elevation-profile line chart plotted against real distance-along-route, a rename
dialog, a delete confirmation, and "use for next ride" / an "Active" badge. State is kept with
plain remember/mutableStateOf, not a ViewModel (no lifecycle-viewmodel-compose
dependency exists yet) — reasonable for one screen, flagged in that file's KDoc as not
necessarily the pattern to keep once there's a second stateful screen.

MainActivity gets a plain button into the new screen via a same-app explicit Intent (no
package-visibility concern — D36 is a PebbleKit-only issue).

File layout deviation from the issue

The issue suggested .../route/import/RouteStore.kt. Actual layout from #24/#14/#75:
companion/route/.../route/ is this module's package root, with route/gpximport/ already
used for the share-sheet activity (not a generic import/), and a placeholder
route/RouteStore.kt already existed there. This PR replaces that placeholder in place and
adds a new route/store/ subpackage for the Room/SharedPreferences/Compose code, rather than
inventing a parallel route/import/ package the rest of the module doesn't otherwise use.

Dependencies added

gradle/libs.versions.toml: Room 2.8.4 + KSP 2.3.11 (current stable per
dl.google.com/repo1.maven.org metadata checked 2026-09-04) for :companion:route; Compose
(BOM/ui/material3/foundation/activity-compose) extended to that module using the versions
already pinned for :companion. Caveat: KSP 2.3.11's own POM depends on kotlin-stdlib
2.3.20, one minor behind this project's Kotlin 2.4.10 — this pairing is not verified
against 2.4.10 specifically. No Android SDK in this sandbox means KSP's annotation processing
never actually ran here; first real build should confirm this resolves and the version may
need bumping.

Verification

  • ./gradlew :companion:core:test — 83/83 pass, host-run in this sandbox (34 new: 15
    RouteLibraryTest covering import/list/rename/delete/select-active/app-restart-survival
    semantics against in-memory fakes, 7 RouteMetricsTest covering distance/ascent/bounding-box
    including the missing-elevation cases, 8 RoutePointsCodecTest round-tripping the polyline
    encoding, 4 RouteEnrichmentStateTest).
  • :companion:route and :companion (Room codegen, Compose UI, the manifest) could NOT be
    compiled here
    — no Android SDK (ANDROID_HOME unset, no local.properties), confirmed by
    :companion:route:compileDebugKotlin failing at SDK-location resolution before reaching any
    Kotlin source. That code (all of route/store/*.kt, the MainActivity/manifest/build-file
    changes) is reviewed by hand against documented Room/Compose API shapes, not proven to
    compile
    — matching #78/#79's established honesty pattern for this constraint.
Closes #25. ## Summary Persists imported GPX routes and turns the share-sheet import (#24) into a real library instead of a status message that discards its result: `GpxShareImportActivity` now saves every successful `GpxImportResult.Success` via the new route store, a Compose list/detail UI shows every saved route with a preview and elevation profile, rename/delete work, and one route can be marked active for the next ride, surviving an app restart. ## Persistence mechanism **Room** for the routes themselves (`companion/route/.../route/store/RouteEntity.kt` / `RouteDao.kt` / `RouteDatabase.kt`), **`SharedPreferences`** for the single active-route pointer (`SharedPreferencesActiveRoutePointerStorage.kt`). Checked the existing Gradle setup from #14/#75 first — no serialization/storage library was there yet, so this is a genuinely new dependency, not a reuse. Room over hand-rolled files because the list UI needs to query every route's name/distance/enrichment status without parsing every route's full geometry — `SharedPreferences` has no query surface at all and is used here only for the one scalar it's actually suited to (the active-route id). The route's polyline itself is stored as two `TEXT` columns via a hand-rolled (not JSON — no serialization dependency existed to reuse, and this format has exactly one reader/writer) codec in `:companion:core` (`RoutePointsCodec.kt`), round-trip tested there including missing elevation/timestamps and waypoint names containing the codec's own separators. **Design split, and why**: all business logic — import, list, rename, delete, select-active — lives in `:companion:core` as `RouteLibrary`, operating over two small storage interfaces (`RouteRecordStorage`, `ActiveRoutePointerStorage`). `:companion:route`'s Room/`SharedPreferences` code is a thin adapter implementing those interfaces. That's what makes the actual semantics testable on the JVM against in-memory fakes, matching this module's existing "no android.* import" boundary. ## Ascent with possibly-missing elevation `computeRouteMetrics` (`core/route/library/RouteMetrics.kt`) sums positive elevation deltas **only across consecutive point pairs where both points carry `<ele>`** (optional per #24). A pair straddling a missing-elevation point contributes nothing — not bridged across the gap, not treated as a drop to/from zero. This under-reports ascent on routes with patchy `<ele>` coverage rather than risk fabricating a spike; a route with no elevation data anywhere reports `ascentMeters == 0.0` as an honest "no data", not a claim the route is flat. Distance reuses `RouteGeodesy.kt`'s `cumulativeDistancesMeters` (#26/#63) rather than re-deriving it. ## Enrichment status `RouteEnrichmentState` (`core/route/enrich/RouteEnrichmentState.kt`): `PENDING` / `ENRICHED` / `FAILED`, plus which `EnrichmentTier` (reusing #26's existing enum rather than inventing a parallel one) when `ENRICHED`. **No auto-enrichment trigger is wired in this issue** — every newly imported route is persisted as `PENDING` and stays there. Only tier 0 (`GpxCueEnricher`, #63/#79) actually exists; tiers 1-3 are unbuilt, so running the chain automatically on import today would only ever produce tier-0-or-nothing, and wiring that trigger felt like a decision for the issue that actually owns "when does enrichment run", not a drive-by addition here. The status field is real and persisted; `RouteLibrary.updateEnrichment` exists for a future issue to call once it decides on a trigger. ## UI Jetpack Compose — this is `:companion:route`'s first screen. Checked: `:companion`'s `MainActivity` already uses `ComponentActivity`/`setContent`, and the Compose BOM/plugin were already wired at the app level (not yet used in any feature module). Followed that precedent rather than introducing classic Views as a second toolkit; flagging as a design call since there was no committed direction, per the issue. List + detail screens (`route/store/RouteLibraryScreen.kt`, `RoutePreview.kt`, `ElevationProfile.kt`, `RouteLibraryActivity.kt`): name (or a localised "unnamed route" fallback — matching #24's own choice not to bake one locale's fallback text into persisted data), distance/ascent, enrichment status text, a schematic no-tiles polyline preview (`:companion:map`'s `MapViewport` is still a placeholder, no tile rendering exists anywhere yet), an elevation-profile line chart plotted against real distance-along-route, a rename dialog, a delete confirmation, and "use for next ride" / an "Active" badge. State is kept with plain `remember`/`mutableStateOf`, not a `ViewModel` (no `lifecycle-viewmodel-compose` dependency exists yet) — reasonable for one screen, flagged in that file's KDoc as not necessarily the pattern to keep once there's a second stateful screen. `MainActivity` gets a plain button into the new screen via a same-app explicit `Intent` (no package-visibility concern — D36 is a PebbleKit-only issue). ## File layout deviation from the issue The issue suggested `.../route/import/RouteStore.kt`. Actual layout from #24/#14/#75: `companion/route/.../route/` is this module's package root, with `route/gpximport/` already used for the share-sheet activity (not a generic `import/`), and a placeholder `route/RouteStore.kt` already existed there. This PR replaces that placeholder in place and adds a new `route/store/` subpackage for the Room/`SharedPreferences`/Compose code, rather than inventing a parallel `route/import/` package the rest of the module doesn't otherwise use. ## Dependencies added `gradle/libs.versions.toml`: **Room 2.8.4** + **KSP 2.3.11** (current stable per dl.google.com/repo1.maven.org metadata checked 2026-09-04) for `:companion:route`; Compose (BOM/ui/material3/foundation/activity-compose) extended to that module using the versions already pinned for `:companion`. **Caveat**: KSP 2.3.11's own POM depends on kotlin-stdlib 2.3.20, one minor behind this project's Kotlin 2.4.10 — this pairing is **not verified** against 2.4.10 specifically. No Android SDK in this sandbox means KSP's annotation processing never actually ran here; first real build should confirm this resolves and the version may need bumping. ## Verification - `./gradlew :companion:core:test` — **83/83 pass**, host-run in this sandbox (34 new: 15 `RouteLibraryTest` covering import/list/rename/delete/select-active/app-restart-survival semantics against in-memory fakes, 7 `RouteMetricsTest` covering distance/ascent/bounding-box including the missing-elevation cases, 8 `RoutePointsCodecTest` round-tripping the polyline encoding, 4 `RouteEnrichmentStateTest`). - `:companion:route` and `:companion` (Room codegen, Compose UI, the manifest) **could NOT be compiled here** — no Android SDK (`ANDROID_HOME` unset, no `local.properties`), confirmed by `:companion:route:compileDebugKotlin` failing at SDK-location resolution before reaching any Kotlin source. That code (all of `route/store/*.kt`, the `MainActivity`/manifest/build-file changes) is reviewed by hand against documented Room/Compose API shapes, **not proven to compile** — matching #78/#79's established honesty pattern for this constraint.
Route library: storage, list UI, distance and elevation profile (#25)
Some checks failed
dev-artifact / build-pbw (push) Has been cancelled
dev-artifact / build-apk (push) Has been cancelled
dev-artifact / publish (push) Has been cancelled
fast-lane / host-c-tests (pull_request) Has been cancelled
fast-lane / jvm-tests (pull_request) Has been cancelled
fast-lane / pebble-build (pull_request) Has been cancelled
fast-lane / lint-and-secrets (pull_request) Has been cancelled
fast-lane / meta-declares-required-jobs (pull_request) Has been cancelled
fast-lane / jvm-tests (push) Has been cancelled
fast-lane / pebble-build (push) Has been cancelled
fast-lane / lint-and-secrets (push) Has been cancelled
fast-lane / meta-declares-required-jobs (push) Has been cancelled
fast-lane / host-c-tests (push) Has been cancelled
df8620528e
Persists imported GPX routes and turns the share-sheet import (#24) into a
real library instead of a status message that discards its result.

Persistence, distance/ascent computation, and rename/delete/select-active
semantics are all pure JVM logic in :companion:core, tested with in-memory
fakes (34 new Kotest cases, ./gradlew :companion:core:test — 83/83 pass):

- core/route/library/RouteMetrics.kt: distance (reuses RouteGeodesy's
  cumulativeDistancesMeters, #26/#63), ascent (sums positive elevation
  deltas only across consecutive point pairs that BOTH carry <ele> — a
  gap contributes nothing, never bridged or zero-filled), bounding box.
- core/route/library/RouteLibrary.kt: import/list/rename/delete/
  setActive/getActiveRoute over RouteRecordStorage + ActiveRoutePointerStorage
  abstractions, so the real rules are testable without Android at all.
- core/route/enrich/RouteEnrichmentState.kt: PENDING/ENRICHED/FAILED plus
  which EnrichmentTier (reusing #26's existing enum). Every route imported
  today lands as PENDING — no auto-enrichment trigger is wired in this
  issue, per the honesty note in that file's KDoc; a future issue's job.
- core/route/library/RoutePointsCodec.kt: hand-rolled (not JSON — no
  serialization dependency exists yet, and this format has exactly one
  reader/writer) text encoding for a route's polyline, round-trip tested
  including missing elevation/timestamp and waypoint names containing the
  format's own separators.

Android-specific storage lives in :companion:route (companion/route/src/
main/kotlin/.../route/store/): Room (RouteEntity/RouteDao/RouteDatabase)
for the routes themselves — chosen over SharedPreferences (no query
surface for a list) or hand-rolled files (reimplementing what an embedded
DB already does) — and SharedPreferences for the single active-route
pointer, which is a scalar, not structured data. RouteStore.kt (replacing
the #14/#75 placeholder) wires both into one RouteLibrary per process.

Deviation from the issue's suggested path: RouteStore.kt stays at the
package root :companion:route already established in #24/#14, with a new
route/store/ subpackage for the Room/SharedPreferences specifics, rather
than the issue's route/import/ (which doesn't otherwise exist in this
module — #24 already used route/gpximport/ for the share-sheet activity).

UI is Jetpack Compose (companion/route/.../route/store/RouteLibraryScreen.kt,
RoutePreview.kt, ElevationProfile.kt, RouteLibraryActivity.kt) — this is
:companion:route's first screen; Compose follows :companion's own existing
MainActivity precedent rather than introducing classic Views as a second
toolkit. List + detail screens: name (or a localised "unnamed route"
fallback, matching #24's own choice not to bake an English default into
persisted data), distance/ascent, per-route enrichment status text,
schematic polyline preview, elevation profile chart, rename dialog,
delete confirmation, and "use for next ride". GpxShareImportActivity now
calls RouteStore.get(applicationContext).importRoute(...) on a successful
parse. MainActivity gets a plain button into the new screen (same-app
explicit Intent — no package-visibility concern, that's a PebbleKit-only
issue per D36).

Dependencies added (gradle/libs.versions.toml): Room 2.8.4 + KSP 2.3.11
(current stable per dl.google.com/repo1.maven.org metadata as of
2026-09-04) for :companion:route; Compose extended to that module
(BOM/ui/material3/foundation/activity-compose), following the versions
already pinned for :companion. KSP 2.3.11's own POM depends on
kotlin-stdlib 2.3.20, one minor behind this project's Kotlin 2.4.10 —
flagged as NOT verified against 2.4.10 specifically; no Android SDK in
this sandbox means KSP's annotation processing never actually ran here.

Verification: ./gradlew :companion:core:test passes (83/83, host-run in
this sandbox). :companion:route and :companion (Room codegen, Compose
UI, the manifest) could NOT be compiled here — no Android SDK
(ANDROID_HOME unset, no local.properties), confirmed by
:companion:route:compileDebugKotlin failing at SDK-location resolution
before reaching any Kotlin source. That code is reviewed by hand against
documented Room/Compose API shapes, not proven to compile, matching
#78/#79's established honesty pattern for this constraint.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
Correct KSP to 2.3.10, the version kotlinlang.org's own quickstart pairs with Kotlin 2.4.10
Some checks failed
dev-artifact / build-pbw (push) Has been cancelled
dev-artifact / build-apk (push) Has been cancelled
fast-lane / host-c-tests (pull_request) Has been cancelled
fast-lane / jvm-tests (pull_request) Has been cancelled
fast-lane / pebble-build (pull_request) Has been cancelled
fast-lane / lint-and-secrets (pull_request) Has been cancelled
fast-lane / meta-declares-required-jobs (pull_request) Has been cancelled
fast-lane / host-c-tests (push) Has been cancelled
fast-lane / jvm-tests (push) Has been cancelled
fast-lane / pebble-build (push) Has been cancelled
fast-lane / lint-and-secrets (push) Has been cancelled
fast-lane / meta-declares-required-jobs (push) Has been cancelled
dev-artifact / publish (push) Has been cancelled
f4d573d6be
The PR picked 2.3.11 (latest on repo1.maven.org) but flagged it explicitly as
unverified against this project's exact Kotlin version. Checked
https://kotlinlang.org/docs/ksp-quickstart.html live: its example plugins {}
block pairs Kotlin 2.4.10 with KSP 2.3.10 specifically. Still unexecuted here
(no Android SDK in this sandbox) but this is now the vendor-documented
pairing rather than a guess from 'latest'.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
robert merged commit d12fd39791 into main 2026-09-04 10:50:28 +02:00
Sign in to join this conversation.
No description provided.