From a1e99684e0ec9d5906a3889043466e56867a9c82 Mon Sep 17 00:00:00 2001 From: uhryniuk Date: Mon, 17 Aug 2026 19:17:49 -0500 Subject: [PATCH] V3-14: GPX from activity (code only) gpxActivityType() maps Activity to Strava/Garmin's vocabulary; Activity.other omits the element rather than writing it empty. The ticket's real acceptance criteria are device/account verification (Strava, Garmin Connect, Google Earth import) which can't happen in this environment -- marked partially done and paired with V3-13's real-ride work in the Outcome section. --- docs/v3/README.md | 2 +- docs/v3/V3-14-gpx-interop.md | 31 ++++++++++++++++++++++++++++++- lib/src/export/ride_export.dart | 20 ++++++++++++++++++++ test/ride_export_test.dart | 27 +++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/docs/v3/README.md b/docs/v3/README.md index c42ad2d..ba63fa1 100644 --- a/docs/v3/README.md +++ b/docs/v3/README.md @@ -30,7 +30,7 @@ backup are v4 — see [../BACKLOG.md](../BACKLOG.md). | [V3-11](V3-11-offline-tiles.md) | Offline tile pre-download | M | V3-04 | Not started | | [V3-12](V3-12-crash-reporting.md) | Crash reporting | S | — | Not started | | [V3-13](V3-13-real-ride-measurements.md) | Real-ride measurements | M | **riding** | Not started | -| [V3-14](V3-14-gpx-interop.md) | GPX interoperability | S | V3-01 | Not started | +| [V3-14](V3-14-gpx-interop.md) | GPX interoperability | S | V3-01 | Partially done (code only; needs real-device verification) | | [V3-15](V3-15-auto-pause.md) | Auto-pause | M | V3-13 *(gated)* | Not started | | [V3-16](V3-16-visual-identity.md) | Visual identity | M | V3-04, V3-05 | Not started | diff --git a/docs/v3/V3-14-gpx-interop.md b/docs/v3/V3-14-gpx-interop.md index 7ef8781..9c05597 100644 --- a/docs/v3/V3-14-gpx-interop.md +++ b/docs/v3/V3-14-gpx-interop.md @@ -1,6 +1,6 @@ # V3-14 — GPX interoperability -**Phase** Quality · **Depends on** V3-01 for `` · **Size** S · **Status** Not started +**Phase** Quality · **Depends on** V3-01 for `` · **Size** S · **Status** Partially done ## Goal Confirm an exported ride actually imports into Strava, Garmin Connect and Google Earth — @@ -49,3 +49,32 @@ Silently breaking the parity harness by changing export output. Update it delibe ## Out of scope GPX import into Rippr. FIT and TCX formats. + +## Outcome +The code half is done; the verification half is explicitly not, and can't be from here. + +`gpxActivityType(Activity)` maps the internal enum to GPX `` values chosen to +match Strava's and Garmin Connect's published import vocabularies (`motorcycling`, +`cycling`, `skateboarding`, `running`, `walking`). `Activity.other` maps to `null`, and +`gpx()` omits the element entirely rather than writing `` — an empty element would +claim "this ride has a type, and it's nothing," which isn't the same fact as "no type was +recorded." Scooter reuses `motorcycling`: GPX has no dedicated vocabulary entry for it and +that is the closer of the two categories a consumer actually offers. + +No existing test needed updating, and there is no byte-identical Kotlin-comparison harness +for GPX in this repo to speak of — the parity harness described in the original port plan +compares pure-logic modules (geo, telemetry, ride statistics) against fixtures, not a live +GPX diff against a Kotlin process. `` is a pure addition; the nine existing GPX tests +assert specific element counts and positions that a new sibling element doesn't disturb, +confirmed by running them unchanged. Three new tests added: `` present and correct +for a mapped activity, absent (not empty) for `other`, and every `Activity` value covered +without throwing. + +**Not done, and cannot be done in this environment:** the ticket's actual acceptance +criteria are entirely device/account verification — export a real ride and import it into +Strava, Garmin Connect, and Google Earth; confirm the activity type lands correctly; +document how each consumer treats `` boundaries at a pause. None of that is +reachable without real accounts on those services and a phone to generate a real multi- +segment ride. This ticket should be reopened for that verification pass once V3-13's +real-ride work happens — the two naturally pair, since V3-13 already requires an actual +ride to exist. `flutter analyze` clean; full suite green (257 tests, up from 254). diff --git a/lib/src/export/ride_export.dart b/lib/src/export/ride_export.dart index e80f13a..b1d2332 100644 --- a/lib/src/export/ride_export.dart +++ b/lib/src/export/ride_export.dart @@ -21,6 +21,24 @@ String _coord(double value) => value.toStringAsFixed(7); String _num(double value) => value.toStringAsFixed(1); +/// GPX has no fixed vocabulary for `` -- it is free text, and every consumer +/// reads its own dialect of it. Strava and Garmin Connect both recognise the values used +/// here (checked against their published import docs, not just guessed); Google Earth +/// ignores the element entirely and only cares about the geometry. See V3-14. +/// +/// [Activity.other] maps to `null`, deliberately absent rather than an empty `` -- +/// an empty element is what "unlabelled" already looks like to a consumer, so writing one +/// out would claim a fact ("this element exists and is empty") that isn't true. +String? gpxActivityType(Activity activity) => switch (activity) { + Activity.motorcycle => 'motorcycling', + Activity.bicycle => 'cycling', + Activity.scooter => 'motorcycling', + Activity.skateboard => 'skateboarding', + Activity.running => 'running', + Activity.walking => 'walking', + Activity.other => null, +}; + /// XML-escapes text destined for an attribute or element body. /// /// A trip named "Sam & Dave's" would otherwise produce malformed XML, and the failure is @@ -97,6 +115,8 @@ String gpx( b.write(' \n'); b.write(' \n'); b.write(' ${escapeXml(name)}\n'); + final type = gpxActivityType(trip.activity); + if (type != null) b.write(' $type\n'); for (final segmentId in ordered) { b.write(' \n'); diff --git a/test/ride_export_test.dart b/test/ride_export_test.dart index 061f616..d399524 100644 --- a/test/ride_export_test.dart +++ b/test/ride_export_test.dart @@ -141,6 +141,33 @@ void main() { expect(doc.findAllElements('trkpt').length, 1, reason: 'no point may be silently dropped'); }); + + test(' reflects the trip activity (V3-14)', () { + final r = ride(segmentCount: 1, perSegment: 1); + final bike = trip.copyWith(activity: Activity.bicycle); + + final doc = XmlDocument.parse(gpx(bike, r.segments, r.points, 'Ride')); + + expect(doc.findAllElements('type').single.innerText, 'cycling'); + }); + + test(' is absent, not empty, for Activity.other (V3-14)', () { + final r = ride(segmentCount: 1, perSegment: 1); + final other = trip.copyWith(activity: Activity.other); + + final doc = XmlDocument.parse(gpx(other, r.segments, r.points, 'Ride')); + + expect(doc.findAllElements('type'), isEmpty, + reason: 'an empty would falsely claim the element exists'); + }); + + test('gpxActivityType covers every activity without throwing', () { + for (final activity in Activity.values) { + // Must not throw; only Activity.other is allowed to map to null. + final type = gpxActivityType(activity); + expect(type == null, activity == Activity.other); + } + }); }); group('geojson', () {