Files
rippr/lib/src/export/ride_export.dart
uhryniuk a1e99684e0 V3-14: GPX <type> from activity (code only)
gpxActivityType() maps Activity to Strava/Garmin's <trk><type> 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.
2026-08-17 19:17:49 -05:00

212 lines
6.5 KiB
Dart

/// Serialises a ride to formats other tools understand.
///
/// Ported from `com.rippr.export.RideExport`. Pure string generation with no platform
/// dependency, so it is fully unit-testable.
///
/// **Never decimate here.** Simplification exists only in the map render path; an export
/// must carry every raw point. The tests assert exact counts to catch any leak.
library;
import '../domain/models.dart';
/// ISO 8601 in UTC. Fix timestamps are already UTC epoch millis, so no zone maths.
String _time(int epochMillis) {
final d = DateTime.fromMillisecondsSinceEpoch(epochMillis, isUtc: true);
String two(int v) => v.toString().padLeft(2, '0');
return '${d.year.toString().padLeft(4, '0')}-${two(d.month)}-${two(d.day)}'
'T${two(d.hour)}:${two(d.minute)}:${two(d.second)}Z';
}
String _coord(double value) => value.toStringAsFixed(7);
String _num(double value) => value.toStringAsFixed(1);
/// GPX has no fixed vocabulary for `<trk><type>` -- 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 `<type/>` --
/// 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
/// invisible until an import rejects the file.
String escapeXml(String text) {
final b = StringBuffer();
for (final c in text.split('')) {
switch (c) {
case '&':
b.write('&amp;');
case '<':
b.write('&lt;');
case '>':
b.write('&gt;');
case '"':
b.write('&quot;');
case "'":
b.write('&apos;');
default:
b.write(c);
}
}
return b.toString();
}
/// Segment order first, then any points whose segment is missing, so nothing is dropped.
List<int> _orderedSegmentIds(
List<Segment> segments,
Map<int, List<TrackPoint>> bySegment,
) {
final ids = <int>[
for (final s in segments)
if (bySegment.containsKey(s.id)) s.id,
];
for (final id in bySegment.keys) {
if (!segments.any((s) => s.id == id)) ids.add(id);
}
return ids;
}
Map<int, List<TrackPoint>> _groupBySegment(List<TrackPoint> points) {
final grouped = <int, List<TrackPoint>>{};
for (final p in points) {
(grouped[p.segmentId] ??= <TrackPoint>[]).add(p);
}
return grouped;
}
/// GPX 1.1.
///
/// One `<trkseg>` per [Segment] — this is exactly how GPX represents a recording gap, so
/// pauses survive the round-trip into Strava, Garmin, or Google Earth instead of becoming
/// a straight line across town.
///
/// Speed goes in `<extensions>`; it is not part of core GPX 1.1, and consumers that do
/// not understand the extension ignore it, which is the correct degradation.
String gpx(
Trip trip,
List<Segment> segments,
List<TrackPoint> points,
String name,
) {
final bySegment = _groupBySegment(points);
final ordered = _orderedSegmentIds(segments, bySegment);
final b = StringBuffer();
b.write('<?xml version="1.0" encoding="UTF-8"?>\n');
b.write(
'<gpx version="1.1" creator="Rippr" xmlns="http://www.topografix.com/GPX/1/1">\n',
);
b.write(' <metadata>\n');
b.write(' <name>${escapeXml(name)}</name>\n');
b.write(' <time>${_time(trip.startedAt)}</time>\n');
b.write(' </metadata>\n');
b.write(' <trk>\n');
b.write(' <name>${escapeXml(name)}</name>\n');
final type = gpxActivityType(trip.activity);
if (type != null) b.write(' <type>$type</type>\n');
for (final segmentId in ordered) {
b.write(' <trkseg>\n');
for (final p in bySegment[segmentId]!) {
b.write(
' <trkpt lat="${_coord(p.latitude)}" lon="${_coord(p.longitude)}">\n',
);
b.write(' <ele>${_num(p.altitudeM)}</ele>\n');
b.write(' <time>${_time(p.timestamp)}</time>\n');
b.write(' <extensions>\n');
// GPX speed is metres per second.
b.write(' <speed>${_num(p.speedKmh / 3.6)}</speed>\n');
b.write(' </extensions>\n');
b.write(' </trkpt>\n');
}
b.write(' </trkseg>\n');
}
b.write(' </trk>\n');
b.write('</gpx>\n');
return b.toString();
}
/// GeoJSON `FeatureCollection`, one `LineString` per segment.
///
/// Coordinates are `[lon, lat, ele]` — **longitude first**, the opposite order to GPX and
/// the most common mistake in GeoJSON output.
String geoJson(
Trip trip,
List<Segment> segments,
List<TrackPoint> points,
String name,
) {
final bySegment = _groupBySegment(points);
final ordered = _orderedSegmentIds(segments, bySegment);
final features = ordered
.map((segmentId) {
final coords = bySegment[segmentId]!
.map(
(p) =>
'[${_coord(p.longitude)}, ${_coord(p.latitude)}, ${_num(p.altitudeM)}]',
)
.join(', ');
return ''' {
"type": "Feature",
"properties": { "segment_id": $segmentId },
"geometry": { "type": "LineString", "coordinates": [$coords] }
}''';
})
.join(',\n');
return '''{
"type": "FeatureCollection",
"properties": {
"name": ${_jsonString(name)},
"started_at": ${trip.startedAt},
"distance_m": ${_num(trip.distanceM)},
"max_speed_kmh": ${_num(trip.maxSpeedKmh)}
},
"features": [
$features
]
}
''';
}
String _jsonString(String text) {
final b = StringBuffer('"');
for (final c in text.split('')) {
switch (c) {
case '"':
b.write('\\"');
case r'\':
b.write(r'\\');
case '\n':
b.write(r'\n');
case '\r':
b.write(r'\r');
case '\t':
b.write(r'\t');
default:
if (c.codeUnitAt(0) < 0x20) {
b.write('\\u${c.codeUnitAt(0).toRadixString(16).padLeft(4, '0')}');
} else {
b.write(c);
}
}
}
b.write('"');
return b.toString();
}