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:
55
lib/src/export/export_manager.dart
Normal file
55
lib/src/export/export_manager.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user