/// 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 `` -- 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 /// 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 _orderedSegmentIds( List segments, Map> bySegment, ) { final ids = [ 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> _groupBySegment(List points) { final grouped = >{}; for (final p in points) { (grouped[p.segmentId] ??= []).add(p); } return grouped; } /// GPX 1.1. /// /// One `` 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 ``; 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 segments, List points, String name, ) { final bySegment = _groupBySegment(points); final ordered = _orderedSegmentIds(segments, bySegment); final b = StringBuffer(); b.write('\n'); b.write( '\n', ); b.write(' \n'); b.write(' ${escapeXml(name)}\n'); b.write(' \n'); 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'); for (final p in bySegment[segmentId]!) { b.write( ' \n', ); b.write(' ${_num(p.altitudeM)}\n'); b.write(' \n'); b.write(' \n'); // GPX speed is metres per second. b.write(' ${_num(p.speedKmh / 3.6)}\n'); b.write(' \n'); b.write(' \n'); } b.write(' \n'); } b.write(' \n'); b.write('\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 segments, List 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(); }