Route library: storage, list UI, distance and elevation profile (#25) #88
No reviewers
Labels
No labels
area:companion
area:docs
area:shared
area:tooling
area:watchapp
blocker
kind:chore
kind:feature
kind:spike
kind:test
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/PedalPebble!88
Loading…
Reference in a new issue
No description provided.
Delete branch "area/route-library"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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:
GpxShareImportActivitynow savesevery successful
GpxImportResult.Successvia the new route store, a Compose list/detail UIshows 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),SharedPreferencesfor the single active-routepointer (
SharedPreferencesActiveRoutePointerStorage.kt). Checked the existing Gradle setupfrom #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 —
SharedPreferenceshas no query surface at all and is used here only for the one scalar it'sactually suited to (the active-route id).
The route's polyline itself is stored as two
TEXTcolumns via a hand-rolled (not JSON — noserialization dependency existed to reuse, and this format has exactly one reader/writer)
codec in
:companion:core(RoutePointsCodec.kt), round-trip tested there including missingelevation/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:coreasRouteLibrary, operating over two small storage interfaces(
RouteRecordStorage,ActiveRoutePointerStorage).:companion:route's Room/SharedPreferencescode 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 deltasonly across consecutive point pairs where both points carry
<ele>(optional per #24). Apair 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.0as an honest "no data", not a claim the route is flat. Distance reusesRouteGeodesy.kt'scumulativeDistancesMeters(#26/#63) rather than re-deriving it.Enrichment status
RouteEnrichmentState(core/route/enrich/RouteEnrichmentState.kt):PENDING/ENRICHED/FAILED, plus whichEnrichmentTier(reusing #26's existing enum rather than inventing aparallel one) when
ENRICHED. No auto-enrichment trigger is wired in this issue — everynewly imported route is persisted as
PENDINGand 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.updateEnrichmentexists for a future issueto call once it decides on a trigger.
UI
Jetpack Compose — this is
:companion:route's first screen. Checked::companion'sMainActivityalready usesComponentActivity/setContent, and the Compose BOM/plugin werealready 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'sMapViewportis still a placeholder, no tile rendering exists anywhereyet), 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 aViewModel(nolifecycle-viewmodel-composedependency 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.
MainActivitygets a plain button into the new screen via a same-app explicitIntent(nopackage-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, withroute/gpximport/alreadyused for the share-sheet activity (not a generic
import/), and a placeholderroute/RouteStore.ktalready existed there. This PR replaces that placeholder in place andadds a new
route/store/subpackage for the Room/SharedPreferences/Compose code, rather thaninventing 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 perdl.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-stdlib2.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: 15RouteLibraryTestcovering import/list/rename/delete/select-active/app-restart-survivalsemantics against in-memory fakes, 7
RouteMetricsTestcovering distance/ascent/bounding-boxincluding the missing-elevation cases, 8
RoutePointsCodecTestround-tripping the polylineencoding, 4
RouteEnrichmentStateTest).:companion:routeand:companion(Room codegen, Compose UI, the manifest) could NOT becompiled here — no Android SDK (
ANDROID_HOMEunset, nolocal.properties), confirmed by:companion:route:compileDebugKotlinfailing at SDK-location resolution before reaching anyKotlin source. That code (all of
route/store/*.kt, theMainActivity/manifest/build-filechanges) is reviewed by hand against documented Room/Compose API shapes, not proven to
compile — matching #78/#79's established honesty pattern for this constraint.