T19/T21: map and export, completing Phase 4

flutter_map with the same OSM tiles and no-API-key reasoning that chose osmdroid.
All four load-bearing behaviours ported and, unlike the native app's map, tested:
one polyline per segment so a pause is a real gap, render-only decimation, the
zoom clamp at OSM's max tile zoom with a short-ride fallback, and a real user
agent. Speed colouring is bucketed per run rather than per-vertex, since neither
osmdroid nor flutter_map makes per-vertex paint reasonable.

Export via share_plus, which also handles the iPad popover anchor a naive port
forgets. It passes the raw stored points, never the map's decimated path.

164 tests passing, analyze clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 16:27:42 -05:00
parent bcc12b314f
commit d501528e69
20 changed files with 1085 additions and 478 deletions

View File

@@ -48,7 +48,9 @@ String escapeXml(String text) {
/// 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) {
List<Segment> segments,
Map<int, List<TrackPoint>> bySegment,
) {
final ids = <int>[
for (final s in segments)
if (bySegment.containsKey(s.id)) s.id,
@@ -87,7 +89,8 @@ String gpx(
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');
'<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');
@@ -99,7 +102,8 @@ String gpx(
b.write(' <trkseg>\n');
for (final p in bySegment[segmentId]!) {
b.write(
' <trkpt lat="${_coord(p.latitude)}" lon="${_coord(p.longitude)}">\n');
' <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');
@@ -129,17 +133,21 @@ String geoJson(
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 ''' {
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');
})
.join(',\n');
return '''{
"type": "FeatureCollection",