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

@@ -0,0 +1,55 @@
/// Ported from `com.rippr.export.ExportManager`.
///
/// Android's FileProvider plus an ACTION_SEND intent becomes `share_plus`, which handles
/// the equivalent plumbing on both platforms — including the iOS popover anchor that
/// iPad requires and that a naive port forgets.
library;
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import '../domain/models.dart';
import '../ui/format.dart';
import 'ride_export.dart';
enum ExportFormat { gpx, geojson }
extension on ExportFormat {
String get extension => this == ExportFormat.gpx ? 'gpx' : 'geojson';
String get mime =>
this == ExportFormat.gpx ? 'application/gpx+xml' : 'application/geo+json';
}
/// Writes the export to a temporary file and hands it to the system share sheet.
///
/// Files go to the app's temporary directory: they are a transfer artefact, not storage,
/// and the OS may reclaim them freely. The stored ride remains the source of truth.
Future<void> shareRide({
required Trip trip,
required List<Segment> segments,
required List<TrackPoint> points,
required ExportFormat format,
}) async {
final label = tripLabel(trip);
final contents = switch (format) {
ExportFormat.gpx => gpx(trip, segments, points, label),
ExportFormat.geojson => geoJson(trip, segments, points, label),
};
final dir = Directory('${(await getTemporaryDirectory()).path}/exports');
await dir.create(recursive: true);
final name =
'rippr-${formatFileTimestamp(trip.startedAt)}.${format.extension}';
final file = File('${dir.path}/$name');
await file.writeAsString(contents);
await SharePlus.instance.share(
ShareParams(
files: [XFile(file.path, mimeType: format.mime)],
subject: label,
),
);
}

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",