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.
212 lines
6.5 KiB
Dart
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('&');
|
|
case '<':
|
|
b.write('<');
|
|
case '>':
|
|
b.write('>');
|
|
case '"':
|
|
b.write('"');
|
|
case "'":
|
|
b.write(''');
|
|
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();
|
|
}
|