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

@@ -68,15 +68,14 @@ class GeolocatorLocationSource implements LocationSource {
await _ensurePermission();
_subscription = Geolocator.getPositionStream(
locationSettings: _settings(),
).listen(
(p) => _controller.add(_toFix(p)),
// A transient platform error must not close the stream: the engine treats a closed
// stream as "recording stopped", which is a user-visible event.
onError: (Object _) {},
cancelOnError: false,
);
_subscription = Geolocator.getPositionStream(locationSettings: _settings())
.listen(
(p) => _controller.add(_toFix(p)),
// A transient platform error must not close the stream: the engine treats a closed
// stream as "recording stopped", which is a user-visible event.
onError: (Object _) {},
cancelOnError: false,
);
}
@override
@@ -153,12 +152,12 @@ class GeolocatorLocationSource implements LocationSource {
}
LocationFix _toFix(Position p) => LocationFix(
timestamp: p.timestamp.millisecondsSinceEpoch,
latitude: p.latitude,
longitude: p.longitude,
speedMps: p.speed,
altitudeM: p.altitude,
accuracyM: p.accuracy,
bearingDeg: p.heading,
);
timestamp: p.timestamp.millisecondsSinceEpoch,
latitude: p.latitude,
longitude: p.longitude,
speedMps: p.speed,
altitudeM: p.altitude,
accuracyM: p.accuracy,
bearingDeg: p.heading,
);
}

View File

@@ -67,7 +67,8 @@ class LocationException implements Exception {
final String? message;
@override
String toString() => 'LocationException($failure${message == null ? '' : ': $message'})';
String toString() =>
'LocationException($failure${message == null ? '' : ': $message'})';
}
/// A source of GPS fixes.
@@ -154,14 +155,15 @@ class FakeLocationSource implements LocationSource {
double altitudeM = 1000.0,
double accuracyM = 5.0,
double bearingDeg = 0.0,
}) =>
emit(LocationFix(
timestamp: timestamp,
latitude: latitude,
longitude: longitude,
speedMps: speedMps,
altitudeM: altitudeM,
accuracyM: accuracyM,
bearingDeg: bearingDeg,
));
}) => emit(
LocationFix(
timestamp: timestamp,
latitude: latitude,
longitude: longitude,
speedMps: speedMps,
altitudeM: altitudeM,
accuracyM: accuracyM,
bearingDeg: bearingDeg,
),
);
}

View File

@@ -55,10 +55,10 @@ class RecordingEngine {
required LocationSource locationSource,
LiveTelemetry? liveTelemetry,
int Function()? clock,
}) : _repo = repository,
_source = locationSource,
_live = liveTelemetry ?? LiveTelemetry.instance,
_now = clock ?? (() => DateTime.now().millisecondsSinceEpoch);
}) : _repo = repository,
_source = locationSource,
_live = liveTelemetry ?? LiveTelemetry.instance,
_now = clock ?? (() => DateTime.now().millisecondsSinceEpoch);
final TripRepository _repo;
final LocationSource _source;
@@ -265,20 +265,20 @@ class RecordingEngine {
/// One flush cycle: take up to [flushSize] buffered points and write them.
Future<void> _flush() => _writeLock.synchronized(() async {
if (_pending.isEmpty) return;
final take = _pending.length < flushSize ? _pending.length : flushSize;
final batch = _pending.sublist(0, take);
_pending.removeRange(0, take);
await _persist(batch);
});
if (_pending.isEmpty) return;
final take = _pending.length < flushSize ? _pending.length : flushSize;
final batch = _pending.sublist(0, take);
_pending.removeRange(0, take);
await _persist(batch);
});
/// Writes everything currently queued and returns once it has landed.
Future<void> _drain() => _writeLock.synchronized(() async {
if (_pending.isEmpty) return;
final batch = List<TrackPoint>.from(_pending);
_pending.clear();
await _persist(batch);
});
if (_pending.isEmpty) return;
final batch = List<TrackPoint>.from(_pending);
_pending.clear();
await _persist(batch);
});
Future<void> _persist(List<TrackPoint> points) async {
if (points.isEmpty) return;