From d501528e69714445b7b285fdef1b98e2688378a1 Mon Sep 17 00:00:00 2001 From: Dylan Date: Sat, 15 Aug 2026 16:27:42 -0500 Subject: [PATCH] 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 --- docs/port/PROGRESS.md | 85 ++++ lib/main.dart | 10 +- lib/src/app/providers.dart | 11 +- lib/src/data/database.dart | 390 +++++++++--------- lib/src/data/trip_repository.dart | 148 +++---- lib/src/domain/models.dart | 25 +- lib/src/export/export_manager.dart | 55 +++ lib/src/export/ride_export.dart | 28 +- lib/src/geo/geo.dart | 9 +- .../recording/geolocator_location_source.dart | 33 +- lib/src/recording/location_source.dart | 24 +- lib/src/recording/recording_engine.dart | 30 +- lib/src/stats/ride_statistics.dart | 31 +- lib/src/telemetry/telemetry.dart | 31 +- lib/src/ui/components/ride_map.dart | 213 ++++++++++ lib/src/ui/detail/trip_detail_screen.dart | 148 +++++-- lib/src/ui/router.dart | 78 ++-- lib/src/ui/trips/trips_screen.dart | 81 ++-- test/ride_map_test.dart | 120 ++++++ test/widget_test.dart | 13 +- 20 files changed, 1085 insertions(+), 478 deletions(-) create mode 100644 lib/src/export/export_manager.dart create mode 100644 lib/src/ui/components/ride_map.dart create mode 100644 test/ride_map_test.dart diff --git a/docs/port/PROGRESS.md b/docs/port/PROGRESS.md index 9715b26..b082aed 100644 --- a/docs/port/PROGRESS.md +++ b/docs/port/PROGRESS.md @@ -576,3 +576,88 @@ Realistically this machine needs headroom freed outside the dev tooling before P Next: **Phase 4, the UI** — six screens, and the first widget tests this project has ever had. + +--- + +## Phase 4 — UI · **complete** + +**T15** shell (theme, `go_router`, shared components) · **T16** record screen · **T17** +trips list · **T18** trip detail with stats and charts · **T19** map · **T20** +rename/delete/merge · **T21** export · **T23** widget tests, brought forward. + +**164 tests passing, analyze clean.** + +### The theme's load-bearing detail, made structural + +Compose's `Surface` set `LocalContentColor`; removing it once made a 64 sp speed figure +render black-on-black, and **no test caught it — only a screenshot did**. The Dart theme +sets `bodyColor`/`displayColor` on `TextTheme` explicitly rather than relying on a +wrapping widget, so the failure cannot recur by someone deleting a container. `BigStat` +also names its colour directly, and a widget test asserts that colour differs from the +ground. + +### The widget tests immediately earned their keep + +**A real layout bug, first run:** with a ride active the record screen grows to six stat +rows and `RenderFlex overflowed by 20 pixels`. Compose *clips this silently*, so the same +bug may well be latent in the native app and simply invisible. Fixed by making the screen +scrollable while still centring when there is room — which matters more here than usual, +because 72 dp glove-sized controls make the content genuinely tall. + +### Two Flutter-testing traps, both costly + +**`pumpAndSettle` never settles against a repeating timer.** The elapsed clock ticks every +second, so the first widget-test run sat at the framework's 10-minute timeout — for +*every* test. Two fixes: the ticker now runs **only while a ride is active** (better +behaviour regardless — an idle screen has no clock to advance), and tests that do have a +live ride use explicit `pump()` calls. + +**`flutter_test` asserts no `Timer` is pending after disposal**, which Drift trips: it +keeps a stream query alive briefly after its last listener leaves so re-subscribing is +cheap. Tests now run through a `screenTest` wrapper that removes the tree and pumps past +that window. Run time went from *timeout* to **two seconds**. + +> And again: a killed background job reports exit code 0. Twice during this phase a +> "completed" run had actually been terminated. Always re-read the log. + +### Map + +`flutter_map`, same OSM raster tiles and same no-API-key reasoning that chose osmdroid. +All four load-bearing behaviours carried over and now **tested**, which the native app's +map never was: + +- one polyline per segment, so a pause is a visible gap — the test puts two segments a + degree apart and asserts no polyline straddles it +- decimation **render-only**; a test asserts vertices drop while endpoints survive +- the **zoom clamp** at OSM's max tile zoom of 19, plus a `shortRideZoom` fallback for + degenerate bounds — this is the v2.0 empty-grid bug, now pinned by a test +- a real user agent, or the tile servers return 403 + +Speed colouring is bucketed into one polyline per run rather than per-vertex paint — +`PolyChromaticPaintList` was fiddly in osmdroid and flutter_map has no equivalent either. +**Still unvalidatable:** no simulator produces velocity, so every path renders in one +colour until a real ride. + +### Export + +`share_plus` replaces `FileProvider` + `ACTION_SEND`, and handles the iOS popover anchor +an iPad needs. Files are written to the temporary directory — they are a transfer +artefact, not storage. The export deliberately passes the **raw stored points**, never +the map's decimated path. + +--- + +## Remaining + +Phases 0–4 are complete. What is left is verification and cutover: + +- **T22** uploader + config (the last piece of parity; still no UI, exactly as today) +- **T24** integration tests on both platforms — needs the emulator, so check disk first +- **T25** the real-ride checklist on **both** platforms. Nothing above substitutes for it: + neither simulator produces velocity, so max speed, moving time and speed colouring are + all still unverified. +- **T26** iOS release readiness · **T27** parity audit, including switching the + applicationId from `com.rippr.port` back to `com.rippr` + +**Known parity gap so far:** notification actions (Pause/Resume in the shade), lost with +`flutter_foreground_task`. diff --git a/lib/main.dart b/lib/main.dart index 110bfee..5f383e3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -31,9 +31,9 @@ class _RipprAppState extends ConsumerState { @override Widget build(BuildContext context) => MaterialApp.router( - title: 'Rippr', - debugShowCheckedModeBanner: false, - theme: ripprTheme(), - routerConfig: _router, - ); + title: 'Rippr', + debugShowCheckedModeBanner: false, + theme: ripprTheme(), + routerConfig: _router, + ); } diff --git a/lib/src/app/providers.dart b/lib/src/app/providers.dart index 17afb14..9d89c95 100644 --- a/lib/src/app/providers.dart +++ b/lib/src/app/providers.dart @@ -49,8 +49,9 @@ final recordingEngineProvider = Provider((ref) { }); /// The live speedo feed, published from the fix callback at GPS rate. -final liveTelemetryProvider = - Provider((ref) => LiveTelemetry.instance); +final liveTelemetryProvider = Provider( + (ref) => LiveTelemetry.instance, +); // --- Reactive state --------------------------------------------------------- @@ -70,3 +71,9 @@ final recorderStateProvider = StreamProvider((ref) { final engine = ref.watch(recordingEngineProvider); return engine.stateStream; }); + +/// Whether the trip-detail map renders. +/// +/// Kept as a toggle from v2: the map must only ever be live on a visible screen, and no +/// tile is fetched while recording in the background. +final mapEnabledProvider = StateProvider((ref) => true); diff --git a/lib/src/data/database.dart b/lib/src/data/database.dart index 4c6b82d..8da75e2 100644 --- a/lib/src/data/database.dart +++ b/lib/src/data/database.dart @@ -105,82 +105,89 @@ class AppDatabase extends _$AppDatabase { @override MigrationStrategy get migration => MigrationStrategy( - onCreate: (m) => m.createAll(), - beforeOpen: (details) async { - // Non-negotiable: without this the CASCADE relationships above do nothing. - await customStatement('PRAGMA foreign_keys = ON'); - // A ride is unrecoverable if a write is lost to a crash mid-flush, but full - // sync on every insert at 2 Hz burns battery. WAL with NORMAL sync is the - // standard compromise and survives app crashes; only an OS-level crash can - // lose the last few points. - await customStatement('PRAGMA journal_mode = WAL'); - await customStatement('PRAGMA synchronous = NORMAL'); - }, - ); + onCreate: (m) => m.createAll(), + beforeOpen: (details) async { + // Non-negotiable: without this the CASCADE relationships above do nothing. + await customStatement('PRAGMA foreign_keys = ON'); + // A ride is unrecoverable if a write is lost to a crash mid-flush, but full + // sync on every insert at 2 Hz burns battery. WAL with NORMAL sync is the + // standard compromise and survives app crashes; only an OS-level crash can + // lose the last few points. + await customStatement('PRAGMA journal_mode = WAL'); + await customStatement('PRAGMA synchronous = NORMAL'); + }, + ); // --- Trips --------------------------------------------------------------- /// The in-progress ride, or null. This is the source of truth for "are we recording" — /// it survives process death, which an in-memory flag cannot. - Stream watchActiveTrip() => (select(trips) - ..where((t) => t.endedAt.isNull()) - ..orderBy([(t) => OrderingTerm.desc(t.id)]) - ..limit(1)) - .watchSingleOrNull() - .map((r) => r == null ? null : _toTrip(r)); - - Future getActiveTrip() async { - final row = await (select(trips) - ..where((t) => t.endedAt.isNull()) - ..orderBy([(t) => OrderingTerm.desc(t.id)]) - ..limit(1)) - .getSingleOrNull(); - return row == null ? null : _toTrip(row); - } - - Stream> watchCompletedTrips() => (select(trips) - ..where((t) => t.endedAt.isNotNull()) - ..orderBy([(t) => OrderingTerm.desc(t.startedAt)])) - .watch() - .map((rows) => rows.map(_toTrip).toList()); - - Stream watchTrip(int id) => - (select(trips)..where((t) => t.id.equals(id))) + Stream watchActiveTrip() => + (select(trips) + ..where((t) => t.endedAt.isNull()) + ..orderBy([(t) => OrderingTerm.desc(t.id)]) + ..limit(1)) .watchSingleOrNull() .map((r) => r == null ? null : _toTrip(r)); - Future getTrip(int id) async { + Future getActiveTrip() async { final row = - await (select(trips)..where((t) => t.id.equals(id))).getSingleOrNull(); + await (select(trips) + ..where((t) => t.endedAt.isNull()) + ..orderBy([(t) => OrderingTerm.desc(t.id)]) + ..limit(1)) + .getSingleOrNull(); + return row == null ? null : _toTrip(row); + } + + Stream> watchCompletedTrips() => + (select(trips) + ..where((t) => t.endedAt.isNotNull()) + ..orderBy([(t) => OrderingTerm.desc(t.startedAt)])) + .watch() + .map((rows) => rows.map(_toTrip).toList()); + + Stream watchTrip(int id) => + (select(trips)..where((t) => t.id.equals(id))).watchSingleOrNull().map( + (r) => r == null ? null : _toTrip(r), + ); + + Future getTrip(int id) async { + final row = await (select( + trips, + )..where((t) => t.id.equals(id))).getSingleOrNull(); return row == null ? null : _toTrip(row); } Future insertTrip(domain.Trip trip) => into(trips).insert( - TripsCompanion.insert( - startedAt: trip.startedAt, - endedAt: Value(trip.endedAt), - name: Value(trip.name), - state: trip.state, - distanceM: Value(trip.distanceM), - movingMillis: Value(trip.movingMillis), - maxSpeedKmh: Value(trip.maxSpeedKmh), - elevationGainM: Value(trip.elevationGainM), - pointCount: Value(trip.pointCount), - ), - ); + TripsCompanion.insert( + startedAt: trip.startedAt, + endedAt: Value(trip.endedAt), + name: Value(trip.name), + state: trip.state, + distanceM: Value(trip.distanceM), + movingMillis: Value(trip.movingMillis), + maxSpeedKmh: Value(trip.maxSpeedKmh), + elevationGainM: Value(trip.elevationGainM), + pointCount: Value(trip.pointCount), + ), + ); - Future renameTrip(int id, String? name) => - (update(trips)..where((t) => t.id.equals(id))) - .write(TripsCompanion(name: Value(name))); + Future renameTrip(int id, String? name) => (update( + trips, + )..where((t) => t.id.equals(id))).write(TripsCompanion(name: Value(name))); - Future setTripState(int id, domain.TripState state) => - (update(trips)..where((t) => t.id.equals(id))) - .write(TripsCompanion(state: Value(state))); + Future setTripState(int id, domain.TripState state) => (update( + trips, + )..where((t) => t.id.equals(id))).write(TripsCompanion(state: Value(state))); - Future closeTrip(int id, int endedAt, - {domain.TripState state = domain.TripState.completed}) => - (update(trips)..where((t) => t.id.equals(id))).write( - TripsCompanion(endedAt: Value(endedAt), state: Value(state))); + Future closeTrip( + int id, + int endedAt, { + domain.TripState state = domain.TripState.completed, + }) => (update(trips)..where((t) => t.id.equals(id))).write( + TripsCompanion(endedAt: Value(endedAt), state: Value(state)), + ); /// Persists the running totals. Called once per writer flush (~every 2 s), so it stays /// a narrow targeted update rather than a full row rewrite. @@ -191,62 +198,64 @@ class AppDatabase extends _$AppDatabase { required double maxSpeedKmh, required double elevationGainM, required int pointCount, - }) => - (update(trips)..where((t) => t.id.equals(id))).write(TripsCompanion( - distanceM: Value(distanceM), - movingMillis: Value(movingMillis), - maxSpeedKmh: Value(maxSpeedKmh), - elevationGainM: Value(elevationGainM), - pointCount: Value(pointCount), - )); + }) => (update(trips)..where((t) => t.id.equals(id))).write( + TripsCompanion( + distanceM: Value(distanceM), + movingMillis: Value(movingMillis), + maxSpeedKmh: Value(maxSpeedKmh), + elevationGainM: Value(elevationGainM), + pointCount: Value(pointCount), + ), + ); /// Segments and points go with it via CASCADE. Future deleteTrip(int id) => (delete(trips)..where((t) => t.id.equals(id))).go(); - Future countTrips() async => - (await select(trips).get()).length; + Future countTrips() async => (await select(trips).get()).length; /// Test/maintenance helper. Segments and points follow via CASCADE. Future deleteAllTrips() => delete(trips).go(); // --- Segments ------------------------------------------------------------ - Future insertSegment(int tripId, int startedAt) => - into(segments).insert( - SegmentsCompanion.insert(tripId: tripId, startedAt: startedAt), - ); + Future insertSegment(int tripId, int startedAt) => into(segments) + .insert(SegmentsCompanion.insert(tripId: tripId, startedAt: startedAt)); Future> segmentsForTrip(int tripId) async { - final rows = await (select(segments) - ..where((s) => s.tripId.equals(tripId)) - ..orderBy([(s) => OrderingTerm.asc(s.id)])) - .get(); + final rows = + await (select(segments) + ..where((s) => s.tripId.equals(tripId)) + ..orderBy([(s) => OrderingTerm.asc(s.id)])) + .get(); return rows.map(_toSegment).toList(); } /// The segment currently being recorded into, if any. Future openSegment(int tripId) async { - final row = await (select(segments) - ..where((s) => s.tripId.equals(tripId) & s.endedAt.isNull()) - ..orderBy([(s) => OrderingTerm.desc(s.id)]) - ..limit(1)) - .getSingleOrNull(); + final row = + await (select(segments) + ..where((s) => s.tripId.equals(tripId) & s.endedAt.isNull()) + ..orderBy([(s) => OrderingTerm.desc(s.id)]) + ..limit(1)) + .getSingleOrNull(); return row == null ? null : _toSegment(row); } Future closeSegment(int id, int endedAt) => - (update(segments)..where((s) => s.id.equals(id))) - .write(SegmentsCompanion(endedAt: Value(endedAt))); + (update(segments)..where((s) => s.id.equals(id))).write( + SegmentsCompanion(endedAt: Value(endedAt)), + ); /// Used by merge: re-parents a trip's segments onto the surviving trip. Future reparentSegments(int oldTripId, int newTripId) => - (update(segments)..where((s) => s.tripId.equals(oldTripId))) - .write(SegmentsCompanion(tripId: Value(newTripId))); + (update(segments)..where((s) => s.tripId.equals(oldTripId))).write( + SegmentsCompanion(tripId: Value(newTripId)), + ); - Future countSegmentsForTrip(int tripId) async => - (await (select(segments)..where((s) => s.tripId.equals(tripId))).get()) - .length; + Future countSegmentsForTrip(int tripId) async => (await (select( + segments, + )..where((s) => s.tripId.equals(tripId))).get()).length; // --- Track points -------------------------------------------------------- @@ -256,71 +265,77 @@ class AppDatabase extends _$AppDatabase { await batch((b) { b.insertAll( trackPoints, - points.map((p) => TrackPointsCompanion.insert( - tripId: p.tripId, - segmentId: p.segmentId, - timestamp: p.timestamp, - latitude: p.latitude, - longitude: p.longitude, - speedKmh: p.speedKmh, - altitudeM: p.altitudeM, - accuracyM: Value(p.accuracyM), - bearingDeg: Value(p.bearingDeg), - synced: Value(p.synced), - )), + points.map( + (p) => TrackPointsCompanion.insert( + tripId: p.tripId, + segmentId: p.segmentId, + timestamp: p.timestamp, + latitude: p.latitude, + longitude: p.longitude, + speedKmh: p.speedKmh, + altitudeM: p.altitudeM, + accuracyM: Value(p.accuracyM), + bearingDeg: Value(p.bearingDeg), + synced: Value(p.synced), + ), + ), ); }); } - Future insertPoint(domain.TrackPoint p) => - into(trackPoints).insert(TrackPointsCompanion.insert( - tripId: p.tripId, - segmentId: p.segmentId, - timestamp: p.timestamp, - latitude: p.latitude, - longitude: p.longitude, - speedKmh: p.speedKmh, - altitudeM: p.altitudeM, - accuracyM: Value(p.accuracyM), - bearingDeg: Value(p.bearingDeg), - synced: Value(p.synced), - )); + Future insertPoint(domain.TrackPoint p) => into(trackPoints).insert( + TrackPointsCompanion.insert( + tripId: p.tripId, + segmentId: p.segmentId, + timestamp: p.timestamp, + latitude: p.latitude, + longitude: p.longitude, + speedKmh: p.speedKmh, + altitudeM: p.altitudeM, + accuracyM: Value(p.accuracyM), + bearingDeg: Value(p.bearingDeg), + synced: Value(p.synced), + ), + ); /// Ordered by segment then id so consumers walk the ride in recording order with pause /// boundaries intact. **Not** ordered by timestamp: that value is GPS-derived and can /// jump, whereas id is monotonic in write order. Future> pointsForTrip(int tripId) async { - final rows = await (select(trackPoints) - ..where((p) => p.tripId.equals(tripId)) - ..orderBy([ - (p) => OrderingTerm.asc(p.segmentId), - (p) => OrderingTerm.asc(p.id), - ])) - .get(); + final rows = + await (select(trackPoints) + ..where((p) => p.tripId.equals(tripId)) + ..orderBy([ + (p) => OrderingTerm.asc(p.segmentId), + (p) => OrderingTerm.asc(p.id), + ])) + .get(); return rows.map(_toPoint).toList(); } Future> pointsForSegment(int segmentId) async { - final rows = await (select(trackPoints) - ..where((p) => p.segmentId.equals(segmentId)) - ..orderBy([(p) => OrderingTerm.asc(p.id)])) - .get(); + final rows = + await (select(trackPoints) + ..where((p) => p.segmentId.equals(segmentId)) + ..orderBy([(p) => OrderingTerm.asc(p.id)])) + .get(); return rows.map(_toPoint).toList(); } /// The anchor a restarted recorder needs to continue accumulating distance. Future lastInSegment(int segmentId) async { - final row = await (select(trackPoints) - ..where((p) => p.segmentId.equals(segmentId)) - ..orderBy([(p) => OrderingTerm.desc(p.id)]) - ..limit(1)) - .getSingleOrNull(); + final row = + await (select(trackPoints) + ..where((p) => p.segmentId.equals(segmentId)) + ..orderBy([(p) => OrderingTerm.desc(p.id)]) + ..limit(1)) + .getSingleOrNull(); return row == null ? null : _toPoint(row); } - Future countPointsForTrip(int tripId) async => - (await (select(trackPoints)..where((p) => p.tripId.equals(tripId))).get()) - .length; + Future countPointsForTrip(int tripId) async => (await (select( + trackPoints, + )..where((p) => p.tripId.equals(tripId))).get()).length; /// Live stats for one trip. /// @@ -342,14 +357,16 @@ class AppDatabase extends _$AppDatabase { variables: [Variable.withInt(tripId)], readsFrom: {trackPoints}, ); - return q.watchSingle().map((row) => domain.RideStats( - pointCount: row.read('pointCount'), - maxSpeedKmh: row.read('maxSpeedKmh'), - avgSpeedKmh: row.read('avgSpeedKmh'), - firstTimestamp: row.read('firstTimestamp'), - lastTimestamp: row.read('lastTimestamp'), - pendingUpload: row.read('pendingUpload'), - )); + return q.watchSingle().map( + (row) => domain.RideStats( + pointCount: row.read('pointCount'), + maxSpeedKmh: row.read('maxSpeedKmh'), + avgSpeedKmh: row.read('avgSpeedKmh'), + firstTimestamp: row.read('firstTimestamp'), + lastTimestamp: row.read('lastTimestamp'), + pendingUpload: row.read('pendingUpload'), + ), + ); } // --- Upload backlog ------------------------------------------------------ @@ -357,35 +374,38 @@ class AppDatabase extends _$AppDatabase { // the payload carries trip/segment identity per point rather than per batch. Future> unsyncedPoints(int limit) async { - final rows = await (select(trackPoints) - ..where((p) => p.synced.equals(false)) - ..orderBy([(p) => OrderingTerm.asc(p.id)]) - ..limit(limit)) - .get(); + final rows = + await (select(trackPoints) + ..where((p) => p.synced.equals(false)) + ..orderBy([(p) => OrderingTerm.asc(p.id)]) + ..limit(limit)) + .get(); return rows.map(_toPoint).toList(); } Future markSynced(List ids) async { if (ids.isEmpty) return; - await (update(trackPoints)..where((p) => p.id.isIn(ids))) - .write(const TrackPointsCompanion(synced: Value(true))); + await (update(trackPoints)..where((p) => p.id.isIn(ids))).write( + const TrackPointsCompanion(synced: Value(true)), + ); } - Future countUnsynced() async => - (await (select(trackPoints)..where((p) => p.synced.equals(false))).get()) - .length; + Future countUnsynced() async => (await (select( + trackPoints, + )..where((p) => p.synced.equals(false))).get()).length; // --- Maintenance --------------------------------------------------------- /// Used by merge: points carry tripId directly, so they re-parent alongside segments. Future reparentPoints(int oldTripId, int newTripId) => - (update(trackPoints)..where((p) => p.tripId.equals(oldTripId))) - .write(TrackPointsCompanion(tripId: Value(newTripId))); + (update(trackPoints)..where((p) => p.tripId.equals(oldTripId))).write( + TrackPointsCompanion(tripId: Value(newTripId)), + ); Future> allPoints() async { - final rows = await (select(trackPoints) - ..orderBy([(p) => OrderingTerm.asc(p.id)])) - .get(); + final rows = await (select( + trackPoints, + )..orderBy([(p) => OrderingTerm.asc(p.id)])).get(); return rows.map(_toPoint).toList(); } } @@ -395,35 +415,35 @@ class AppDatabase extends _$AppDatabase { // entirely unaware that Drift exists. domain.Trip _toTrip(TripRow r) => domain.Trip( - id: r.id, - startedAt: r.startedAt, - endedAt: r.endedAt, - name: r.name, - state: r.state, - distanceM: r.distanceM, - movingMillis: r.movingMillis, - maxSpeedKmh: r.maxSpeedKmh, - elevationGainM: r.elevationGainM, - pointCount: r.pointCount, - ); + id: r.id, + startedAt: r.startedAt, + endedAt: r.endedAt, + name: r.name, + state: r.state, + distanceM: r.distanceM, + movingMillis: r.movingMillis, + maxSpeedKmh: r.maxSpeedKmh, + elevationGainM: r.elevationGainM, + pointCount: r.pointCount, +); domain.Segment _toSegment(SegmentRow r) => domain.Segment( - id: r.id, - tripId: r.tripId, - startedAt: r.startedAt, - endedAt: r.endedAt, - ); + id: r.id, + tripId: r.tripId, + startedAt: r.startedAt, + endedAt: r.endedAt, +); domain.TrackPoint _toPoint(TrackPointRow r) => domain.TrackPoint( - id: r.id, - tripId: r.tripId, - segmentId: r.segmentId, - timestamp: r.timestamp, - latitude: r.latitude, - longitude: r.longitude, - speedKmh: r.speedKmh, - altitudeM: r.altitudeM, - accuracyM: r.accuracyM, - bearingDeg: r.bearingDeg, - synced: r.synced, - ); + id: r.id, + tripId: r.tripId, + segmentId: r.segmentId, + timestamp: r.timestamp, + latitude: r.latitude, + longitude: r.longitude, + speedKmh: r.speedKmh, + altitudeM: r.altitudeM, + accuracyM: r.accuracyM, + bearingDeg: r.bearingDeg, + synced: r.synced, +); diff --git a/lib/src/data/trip_repository.dart b/lib/src/data/trip_repository.dart index 3706057..4d64eb2 100644 --- a/lib/src/data/trip_repository.dart +++ b/lib/src/data/trip_repository.dart @@ -76,37 +76,38 @@ class TripRepository { /// Adoption rather than rejection is deliberate: after a process kill the trip row /// still exists, and the restarted recorder needs to continue it, not start a second. Future startTrip(int now) => _db.transaction(() async { - final existing = await _db.getActiveTrip(); - if (existing != null) { - return _adoptOrOpenSegment(existing, now); - } - final tripId = await _db - .insertTrip(Trip(startedAt: now, state: TripState.recording)); - final segmentId = await _db.insertSegment(tripId, now); - return TripHandle(tripId, segmentId); - }); + final existing = await _db.getActiveTrip(); + if (existing != null) { + return _adoptOrOpenSegment(existing, now); + } + final tripId = await _db.insertTrip( + Trip(startedAt: now, state: TripState.recording), + ); + final segmentId = await _db.insertSegment(tripId, now); + return TripHandle(tripId, segmentId); + }); /// Closes the open segment and marks the trip paused. The trip itself stays open — /// only [completeTrip] sets `endedAt`. Future pauseTrip(int now) => _db.transaction(() async { - final trip = await _db.getActiveTrip(); - if (trip == null) return false; - if (trip.state == TripState.paused) return true; + final trip = await _db.getActiveTrip(); + if (trip == null) return false; + if (trip.state == TripState.paused) return true; - final open = await _db.openSegment(trip.id); - if (open != null) await _db.closeSegment(open.id, now); - await _db.setTripState(trip.id, TripState.paused); - return true; - }); + final open = await _db.openSegment(trip.id); + if (open != null) await _db.closeSegment(open.id, now); + await _db.setTripState(trip.id, TripState.paused); + return true; + }); /// Opens a fresh segment so the pause leaves a real gap in the recorded path. Future resumeTrip(int now) => _db.transaction(() async { - final trip = await _db.getActiveTrip(); - if (trip == null) return null; - final handle = await _adoptOrOpenSegment(trip, now); - await _db.setTripState(trip.id, TripState.recording); - return handle; - }); + final trip = await _db.getActiveTrip(); + if (trip == null) return null; + final handle = await _adoptOrOpenSegment(trip, now); + await _db.setTripState(trip.id, TripState.recording); + return handle; + }); /// Resumes an active trip into a **genuinely new** segment, closing any segment left /// open by a process death. @@ -132,7 +133,8 @@ class TripRepository { /// recording genuinely stopped when the process died, and `computeSummary` sums closed /// segment spans for elapsed time, so closing at `now` would bill the dead time as /// ride time. - Future resumeIntoNewSegment(int now) => _db.transaction(() async { + Future resumeIntoNewSegment(int now) => + _db.transaction(() async { final trip = await _db.getActiveTrip(); if (trip == null) return null; @@ -148,21 +150,21 @@ class TripRepository { }); Future completeTrip(int now) => _db.transaction(() async { - final trip = await _db.getActiveTrip(); - if (trip == null) return null; - final open = await _db.openSegment(trip.id); - if (open != null) await _db.closeSegment(open.id, now); - await _db.closeTrip(trip.id, now, state: TripState.completed); - return trip.id; - }); + final trip = await _db.getActiveTrip(); + if (trip == null) return null; + final open = await _db.openSegment(trip.id); + if (open != null) await _db.closeSegment(open.id, now); + await _db.closeTrip(trip.id, now, state: TripState.completed); + return trip.id; + }); /// Deletes the active trip outright. Segments and points follow via CASCADE. Future discardTrip() => _db.transaction(() async { - final trip = await _db.getActiveTrip(); - if (trip == null) return false; - await _db.deleteTrip(trip.id); - return true; - }); + final trip = await _db.getActiveTrip(); + if (trip == null) return false; + await _db.deleteTrip(trip.id); + return true; + }); // --- Management ---------------------------------------------------------- @@ -170,7 +172,10 @@ class TripRepository { // Empty input must collapse to null, or the UI's "derive a label from the date" // branch and a stored "" would diverge. final trimmed = name?.trim(); - return _db.renameTrip(id, (trimmed == null || trimmed.isEmpty) ? null : trimmed); + return _db.renameTrip( + id, + (trimmed == null || trimmed.isEmpty) ? null : trimmed, + ); } Future deleteTrip(int id) => _db.deleteTrip(id); @@ -188,36 +193,36 @@ class TripRepository { /// /// Returns the surviving trip id, or null if the merge was rejected. Future mergeTrips(int a, int b) => _db.transaction(() async { - if (a == b) return null; - final first = await _db.getTrip(a); - if (first == null) return null; - final second = await _db.getTrip(b); - if (second == null) return null; - if (first.isActive || second.isActive) { - // Refusing to merge an active trip. - return null; - } + if (a == b) return null; + final first = await _db.getTrip(a); + if (first == null) return null; + final second = await _db.getTrip(b); + if (second == null) return null; + if (first.isActive || second.isActive) { + // Refusing to merge an active trip. + return null; + } - // Selection order is not ride order. - final (survivor, absorbed) = first.startedAt <= second.startedAt - ? (first, second) - : (second, first); + // Selection order is not ride order. + final (survivor, absorbed) = first.startedAt <= second.startedAt + ? (first, second) + : (second, first); - await _db.reparentSegments(absorbed.id, survivor.id); - await _db.reparentPoints(absorbed.id, survivor.id); - await _db.deleteTrip(absorbed.id); + await _db.reparentSegments(absorbed.id, survivor.id); + await _db.reparentPoints(absorbed.id, survivor.id); + await _db.deleteTrip(absorbed.id); - final endedAt = (survivor.endedAt ?? 0) > (absorbed.endedAt ?? 0) - ? (survivor.endedAt ?? 0) - : (absorbed.endedAt ?? 0); - await _db.closeTrip(survivor.id, endedAt, state: TripState.completed); - if (survivor.name == null && absorbed.name != null) { - await _db.renameTrip(survivor.id, absorbed.name); - } + final endedAt = (survivor.endedAt ?? 0) > (absorbed.endedAt ?? 0) + ? (survivor.endedAt ?? 0) + : (absorbed.endedAt ?? 0); + await _db.closeTrip(survivor.id, endedAt, state: TripState.completed); + if (survivor.name == null && absorbed.name != null) { + await _db.renameTrip(survivor.id, absorbed.name); + } - await recomputeAggregates(survivor.id); - return survivor.id; - }); + await recomputeAggregates(survivor.id); + return survivor.id; + }); /// Recomputes a trip's stored totals from the points it actually owns. /// @@ -261,15 +266,14 @@ class TripRepository { required double maxSpeedKmh, required double elevationGainM, required int pointCount, - }) => - _db.updateAggregates( - id: tripId, - distanceM: distanceM, - movingMillis: movingMillis, - maxSpeedKmh: maxSpeedKmh, - elevationGainM: elevationGainM, - pointCount: pointCount, - ); + }) => _db.updateAggregates( + id: tripId, + distanceM: distanceM, + movingMillis: movingMillis, + maxSpeedKmh: maxSpeedKmh, + elevationGainM: elevationGainM, + pointCount: pointCount, + ); // --- Internals ----------------------------------------------------------- diff --git a/lib/src/domain/models.dart b/lib/src/domain/models.dart index 02c29b7..328b6bd 100644 --- a/lib/src/domain/models.dart +++ b/lib/src/domain/models.dart @@ -74,19 +74,18 @@ class Trip { double? maxSpeedKmh, double? elevationGainM, int? pointCount, - }) => - Trip( - id: id ?? this.id, - startedAt: startedAt ?? this.startedAt, - endedAt: endedAt ?? this.endedAt, - name: name ?? this.name, - state: state ?? this.state, - distanceM: distanceM ?? this.distanceM, - movingMillis: movingMillis ?? this.movingMillis, - maxSpeedKmh: maxSpeedKmh ?? this.maxSpeedKmh, - elevationGainM: elevationGainM ?? this.elevationGainM, - pointCount: pointCount ?? this.pointCount, - ); + }) => Trip( + id: id ?? this.id, + startedAt: startedAt ?? this.startedAt, + endedAt: endedAt ?? this.endedAt, + name: name ?? this.name, + state: state ?? this.state, + distanceM: distanceM ?? this.distanceM, + movingMillis: movingMillis ?? this.movingMillis, + maxSpeedKmh: maxSpeedKmh ?? this.maxSpeedKmh, + elevationGainM: elevationGainM ?? this.elevationGainM, + pointCount: pointCount ?? this.pointCount, + ); } /// One pause-free stretch of recording within a [Trip]. diff --git a/lib/src/export/export_manager.dart b/lib/src/export/export_manager.dart new file mode 100644 index 0000000..ec4baea --- /dev/null +++ b/lib/src/export/export_manager.dart @@ -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 shareRide({ + required Trip trip, + required List segments, + required List 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, + ), + ); +} diff --git a/lib/src/export/ride_export.dart b/lib/src/export/ride_export.dart index a6326ff..e80f13a 100644 --- a/lib/src/export/ride_export.dart +++ b/lib/src/export/ride_export.dart @@ -48,7 +48,9 @@ String escapeXml(String text) { /// Segment order first, then any points whose segment is missing, so nothing is dropped. List _orderedSegmentIds( - List segments, Map> bySegment) { + List segments, + Map> bySegment, +) { final ids = [ for (final s in segments) if (bySegment.containsKey(s.id)) s.id, @@ -87,7 +89,8 @@ String gpx( final b = StringBuffer(); b.write('\n'); b.write( - '\n'); + '\n', + ); b.write(' \n'); b.write(' ${escapeXml(name)}\n'); b.write(' \n'); @@ -99,7 +102,8 @@ String gpx( b.write(' \n'); for (final p in bySegment[segmentId]!) { b.write( - ' \n'); + ' \n', + ); b.write(' ${_num(p.altitudeM)}\n'); b.write(' \n'); b.write(' \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", diff --git a/lib/src/geo/geo.dart b/lib/src/geo/geo.dart index 3e0d472..61c5723 100644 --- a/lib/src/geo/geo.dart +++ b/lib/src/geo/geo.dart @@ -62,7 +62,8 @@ double haversineMeters(double lat1, double lon1, double lat2, double lon2) { final dLon = (lon2 - lon1) * _degToRad; final sinLat = math.sin(dLat / 2); final sinLon = math.sin(dLon / 2); - final a = sinLat * sinLat + + final a = + sinLat * sinLat + math.cos(lat1 * _degToRad) * math.cos(lat2 * _degToRad) * sinLon * sinLon; // asin(sqrt(a)) rather than atan2 — better conditioned for the very short hops // between consecutive GPS fixes. @@ -102,7 +103,11 @@ List simplify(List points, double epsilonMeters) { var maxDist = 0.0; var index = first; for (var i = first + 1; i < last; i++) { - final d = perpendicularDistanceMeters(points[i], points[first], points[last]); + final d = perpendicularDistanceMeters( + points[i], + points[first], + points[last], + ); if (d > maxDist) { maxDist = d; index = i; diff --git a/lib/src/recording/geolocator_location_source.dart b/lib/src/recording/geolocator_location_source.dart index 4cbb30a..8e4392f 100644 --- a/lib/src/recording/geolocator_location_source.dart +++ b/lib/src/recording/geolocator_location_source.dart @@ -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, + ); } diff --git a/lib/src/recording/location_source.dart b/lib/src/recording/location_source.dart index 624feea..fa66d6e 100644 --- a/lib/src/recording/location_source.dart +++ b/lib/src/recording/location_source.dart @@ -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, + ), + ); } diff --git a/lib/src/recording/recording_engine.dart b/lib/src/recording/recording_engine.dart index b542152..c5e978c 100644 --- a/lib/src/recording/recording_engine.dart +++ b/lib/src/recording/recording_engine.dart @@ -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 _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 _drain() => _writeLock.synchronized(() async { - if (_pending.isEmpty) return; - final batch = List.from(_pending); - _pending.clear(); - await _persist(batch); - }); + if (_pending.isEmpty) return; + final batch = List.from(_pending); + _pending.clear(); + await _persist(batch); + }); Future _persist(List points) async { if (points.isEmpty) return; diff --git a/lib/src/stats/ride_statistics.dart b/lib/src/stats/ride_statistics.dart index c95d313..76861bc 100644 --- a/lib/src/stats/ride_statistics.dart +++ b/lib/src/stats/ride_statistics.dart @@ -63,11 +63,20 @@ class RideSummary { other.pointCount == pointCount; @override - int get hashCode => Object.hash(distanceM, elapsedMillis, movingMillis, - maxSpeedKmh, avgMovingSpeedKmh, elevationGainM, elevationLossM, pointCount); + int get hashCode => Object.hash( + distanceM, + elapsedMillis, + movingMillis, + maxSpeedKmh, + avgMovingSpeedKmh, + elevationGainM, + elevationLossM, + pointCount, + ); @override - String toString() => 'RideSummary(distance: $distanceM m, elapsed: $elapsedMillis ms, ' + String toString() => + 'RideSummary(distance: $distanceM m, elapsed: $elapsedMillis ms, ' 'moving: $movingMillis ms, max: $maxSpeedKmh km/h, gain: $elevationGainM m, ' 'loss: $elevationLossM m, points: $pointCount)'; } @@ -199,7 +208,10 @@ int _elapsedFor(List points, List segments) { /// Time spent in each speed band. Buckets are keyed on the *interval* between fixes, so /// the result is a time distribution rather than a sample count — a bike that sits idle /// at 2 Hz would otherwise dominate purely by producing more samples. -List speedHistogram(List points, {int bucketKmh = 10}) { +List speedHistogram( + List points, { + int bucketKmh = 10, +}) { if (points.length < 2 || bucketKmh <= 0) return const []; final millisByBucket = {}; @@ -216,8 +228,11 @@ List speedHistogram(List points, {int bucketKmh = 10}) final keys = millisByBucket.keys.toList()..sort(); return [ for (final bucket in keys) - SpeedBucket(bucket * bucketKmh, (bucket + 1) * bucketKmh, - millisByBucket[bucket]!), + SpeedBucket( + bucket * bucketKmh, + (bucket + 1) * bucketKmh, + millisByBucket[bucket]!, + ), ]; } @@ -360,7 +375,9 @@ class ElevationAccumulator { final committed = _lastCommitted; if (committed == null) return _gain; final tip = _direction == 1 ? math.max(_extreme, _lastRaw) : _extreme; - return _direction == 1 && tip > committed ? _gain + (tip - committed) : _gain; + return _direction == 1 && tip > committed + ? _gain + (tip - committed) + : _gain; } /// Banks the run still in progress. Must be called once the last point is added, or a diff --git a/lib/src/telemetry/telemetry.dart b/lib/src/telemetry/telemetry.dart index d886d21..5092eaa 100644 --- a/lib/src/telemetry/telemetry.dart +++ b/lib/src/telemetry/telemetry.dart @@ -42,22 +42,21 @@ String formatDuration(int millis) { /// boundary. Hoisting them to batch level would silently mislabel points. String encodeBatch(String deviceId, List points) { final array = points - .map((p) => { - 'id': p.id, - 'trip_id': p.tripId, - 'segment_id': p.segmentId, - 'ts': p.timestamp, - 'lat': p.latitude, - 'lon': p.longitude, - 'speed_kmh': p.speedKmh, - 'alt_m': p.altitudeM, - 'acc_m': p.accuracyM, - 'bearing': p.bearingDeg, - }) + .map( + (p) => { + 'id': p.id, + 'trip_id': p.tripId, + 'segment_id': p.segmentId, + 'ts': p.timestamp, + 'lat': p.latitude, + 'lon': p.longitude, + 'speed_kmh': p.speedKmh, + 'alt_m': p.altitudeM, + 'acc_m': p.accuracyM, + 'bearing': p.bearingDeg, + }, + ) .toList(growable: false); - return jsonEncode({ - 'device_id': deviceId, - 'points': array, - }); + return jsonEncode({'device_id': deviceId, 'points': array}); } diff --git a/lib/src/ui/components/ride_map.dart b/lib/src/ui/components/ride_map.dart new file mode 100644 index 0000000..840c0a0 --- /dev/null +++ b/lib/src/ui/components/ride_map.dart @@ -0,0 +1,213 @@ +/// Ported from `com.rippr.ui.components.RideMap` (osmdroid → flutter_map). +/// +/// Same tile source, same reasoning that chose osmdroid: OpenStreetMap raster tiles, no +/// API key, no billing. +/// +/// ## Four things here are load-bearing +/// +/// 1. **One polyline per segment**, so a pause leaves a visible gap instead of a +/// straight line drawn across town. +/// 2. **Decimation is render-only.** `simplify` never touches storage or export. +/// 3. **The zoom clamp.** Fitting a 50 m ride zooms past OpenStreetMap's maximum tile +/// zoom of 19 and renders an empty grid — that shipped in v2.0 and is invisible to +/// any fixture of the wrong size. +/// 4. **A real user agent**, or OSM's tile servers return 403. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart' as ll; + +import '../../domain/models.dart'; +import '../../geo/geo.dart' as geo; + +/// Metres. Render-only: a 3-hour ride is ~21,600 points and would jank undecimated. +const double simplifyEpsilonM = 5.0; + +/// Speed colouring is bucketed rather than continuous — osmdroid's per-vertex paint was +/// fiddly, and flutter_map has no equivalent either. Buckets also read better at a glance. +const int speedBucketKmh = 10; + +/// OpenStreetMap serves no raster tiles above this. Exceeding it renders a blank grid. +const double maxTileZoom = 19.0; + +/// What a very short ride falls back to, so streets stay visible. +const double shortRideZoom = 17.0; + +/// Identifies the app to OSM's servers. Anonymous bulk requests get 403. +const String tileUserAgent = 'com.rippr.port'; + +class RideMap extends StatefulWidget { + const RideMap({ + super.key, + required this.points, + required this.segments, + this.height = 320, + }); + + final List points; + final List segments; + final double height; + + @override + State createState() => _RideMapState(); +} + +class _RideMapState extends State { + final _controller = MapController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + /// Points grouped by segment, in recording order. + List> get _bySegment { + final grouped = >{}; + for (final p in widget.points) { + (grouped[p.segmentId] ??= []).add(p); + } + return grouped.values.toList(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + if (widget.points.isEmpty) { + return SizedBox( + height: widget.height, + child: Center( + child: Text( + 'No path recorded', + style: TextStyle(color: colors.outline), + ), + ), + ); + } + + final all = [ + for (final p in widget.points) geo.LatLon(p.latitude, p.longitude), + ]; + final bounds = geo.bounds(all)!; + final polylines = _buildPolylines(colors); + + return SizedBox( + height: widget.height, + // The map draws to the edge of its box; clipping keeps it from painting over + // adjacent controls, which osmdroid did until it was explicitly bounded. + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: FlutterMap( + mapController: _controller, + options: MapOptions( + initialCameraFit: bounds.isDegenerate + // Every point at one spot — a parked "ride". Fitting this would zoom to + // infinity, so centre and use a sane street-level zoom instead. + ? null + : CameraFit.bounds( + bounds: LatLngBounds( + ll.LatLng(bounds.minLat, bounds.minLon), + ll.LatLng(bounds.maxLat, bounds.maxLon), + ), + padding: const EdgeInsets.all(24), + // The clamp. Without it a short ride lands past OSM's max tile zoom + // and renders an empty grid. + maxZoom: maxTileZoom, + ), + initialCenter: ll.LatLng(bounds.centerLat, bounds.centerLon), + initialZoom: bounds.isDegenerate ? shortRideZoom : maxTileZoom, + maxZoom: maxTileZoom, + interactionOptions: const InteractionOptions( + flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag, + ), + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: tileUserAgent, + maxNativeZoom: maxTileZoom.toInt(), + // Respect OSM's usage policy: render what is looked at, never bulk prefetch. + panBuffer: 0, + ), + PolylineLayer(polylines: polylines), + ], + ), + ), + ); + } + + /// One polyline per speed run within each segment. + /// + /// Splitting on bucket change gives speed colouring without per-vertex paint. Segments + /// are never joined, so a pause is a genuine gap. + List _buildPolylines(ColorScheme colors) { + final maxSpeed = widget.points + .map((p) => p.speedKmh) + .fold(0, (a, b) => a > b ? a : b); + + final out = []; + + for (final segment in _bySegment) { + if (segment.isEmpty) continue; + + // Decimate for rendering only. The stored and exported points are untouched. + final simplified = geo.simplify([ + for (final p in segment) geo.LatLon(p.latitude, p.longitude), + ], simplifyEpsilonM); + if (simplified.length < 2) continue; + + // Walk the simplified path, splitting whenever the speed bucket changes. + var runStart = 0; + var runBucket = _bucketOf(segment.first.speedKmh); + + for (var i = 1; i <= simplified.length; i++) { + final bucket = i < simplified.length + ? _bucketOf(_speedNear(segment, simplified[i])) + : -1; + if (bucket != runBucket || i == simplified.length) { + if (i - runStart >= 2) { + out.add( + Polyline( + points: [ + for (final p in simplified.sublist(runStart, i)) + ll.LatLng(p.lat, p.lon), + ], + strokeWidth: 4, + color: _colorFor(runBucket, maxSpeed, colors), + ), + ); + } + runStart = i - 1; + runBucket = bucket; + } + } + } + + return out; + } + + /// The speed of the original point nearest a simplified vertex. + /// + /// Simplification drops points, so colouring has to come from the raw data rather than + /// from the decimated path. + double _speedNear(List segment, geo.LatLon vertex) { + for (final p in segment) { + if (p.latitude == vertex.lat && p.longitude == vertex.lon) { + return p.speedKmh; + } + } + return 0; + } + + int _bucketOf(double speedKmh) => (speedKmh / speedBucketKmh).floor(); + + /// Cool for slow, accent for fast. A single hue ramp reads better through a visor than + /// a rainbow. + Color _colorFor(int bucket, double maxSpeed, ColorScheme colors) { + if (bucket < 0 || maxSpeed <= 0) return colors.primary; + final t = ((bucket * speedBucketKmh) / maxSpeed).clamp(0.0, 1.0); + return Color.lerp(const Color(0xFF4FC3F7), colors.primary, t)!; + } +} diff --git a/lib/src/ui/detail/trip_detail_screen.dart b/lib/src/ui/detail/trip_detail_screen.dart index 63b7145..0d81b5e 100644 --- a/lib/src/ui/detail/trip_detail_screen.dart +++ b/lib/src/ui/detail/trip_detail_screen.dart @@ -8,9 +8,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/providers.dart'; +import '../../export/export_manager.dart'; import '../../domain/models.dart'; import '../../stats/ride_statistics.dart'; import '../../telemetry/telemetry.dart'; +import '../components/ride_map.dart'; import '../components/stats.dart'; import '../format.dart'; @@ -23,6 +25,7 @@ class TripDetail { required this.segments, required this.histogram, required this.profile, + required this.mapEnabled, }); final Trip trip; @@ -31,10 +34,16 @@ class TripDetail { final List segments; final List histogram; final List profile; + + /// The map stays behind a toggle, as in v2: it is only ever live on a visible screen, + /// and never while recording in the background. + final bool mapEnabled; } -final tripDetailProvider = - FutureProvider.autoDispose.family((ref, tripId) async { +final tripDetailProvider = FutureProvider.autoDispose.family(( + ref, + tripId, +) async { final repo = ref.watch(tripRepositoryProvider); final trip = await repo.tripById(tripId); if (trip == null) return null; @@ -49,6 +58,7 @@ final tripDetailProvider = segments: segments, histogram: speedHistogram(points), profile: elevationProfile(points), + mapEnabled: ref.watch(mapEnabledProvider), ); }); @@ -69,33 +79,73 @@ class TripDetailScreen extends ConsumerWidget { icon: const Icon(Icons.arrow_back), onPressed: onBack, ), - title: Text(async.valueOrNull == null - ? 'Ride' - : tripLabel(async.value!.trip)), + title: Text( + async.valueOrNull == null ? 'Ride' : tripLabel(async.value!.trip), + ), actions: [ if (async.valueOrNull != null) IconButton( - key: const Key('rename'), - icon: const Icon(Icons.edit_outlined), - onPressed: () => _rename(context, ref, async.value!.trip), + key: const Key('export'), + icon: const Icon(Icons.ios_share), + onPressed: () => _export(context, async.value!), ), + IconButton( + key: const Key('rename'), + icon: const Icon(Icons.edit_outlined), + onPressed: () => _rename(context, ref, async.value!.trip), + ), ], ), body: switch (async) { AsyncValue(hasValue: true, value: null) => const EmptyState( - message: 'This ride no longer exists.', - icon: Icons.help_outline, - ), + message: 'This ride no longer exists.', + icon: Icons.help_outline, + ), AsyncValue(hasValue: true, value: final d?) => _Body(detail: d), - AsyncValue(hasError: true, :final error) => - EmptyState(message: 'Could not load this ride.\n$error'), + AsyncValue(hasError: true, :final error) => EmptyState( + message: 'Could not load this ride.\n$error', + ), _ => const Center(child: CircularProgressIndicator()), }, ); } - Future _rename( - BuildContext context, WidgetRef ref, Trip trip) async { + Future _export(BuildContext context, TripDetail d) async { + final format = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: const Key('export-gpx'), + leading: const Icon(Icons.route), + title: const Text('GPX'), + subtitle: const Text('Strava, Garmin, Google Earth'), + onTap: () => Navigator.of(context).pop(ExportFormat.gpx), + ), + ListTile( + key: const Key('export-geojson'), + leading: const Icon(Icons.data_object), + title: const Text('GeoJSON'), + subtitle: const Text('Mapping and analysis tools'), + onTap: () => Navigator.of(context).pop(ExportFormat.geojson), + ), + ], + ), + ), + ); + if (format == null) return; + await shareRide( + trip: d.trip, + segments: d.segments, + // Deliberately d.points, the raw stored points -- never the map's decimated path. + points: d.points, + format: format, + ); + } + + Future _rename(BuildContext context, WidgetRef ref, Trip trip) async { final controller = TextEditingController(text: trip.name ?? ''); final name = await showDialog( context: context, @@ -146,29 +196,39 @@ class _Body extends StatelessWidget { ), ), const SizedBox(height: 24), + if (detail.mapEnabled) + RideMap(points: detail.points, segments: detail.segments), + if (detail.mapEnabled) const SizedBox(height: 24), Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ StatRow( - label: 'Moving time', - value: formatDuration(s.movingMillis)), + label: 'Moving time', + value: formatDuration(s.movingMillis), + ), StatRow( - label: 'Elapsed', value: formatDuration(s.elapsedMillis)), + label: 'Elapsed', + value: formatDuration(s.elapsedMillis), + ), StatRow( - label: 'Stopped', value: formatDuration(s.stoppedMillis)), + label: 'Stopped', + value: formatDuration(s.stoppedMillis), + ), + StatRow(label: 'Max speed', value: formatSpeed(s.maxSpeedKmh)), StatRow( - label: 'Max speed', value: formatSpeed(s.maxSpeedKmh)), + label: 'Avg moving speed', + value: formatSpeed(s.avgMovingSpeedKmh), + ), StatRow( - label: 'Avg moving speed', - value: formatSpeed(s.avgMovingSpeedKmh)), + label: 'Elevation gain', + value: formatElevation(s.elevationGainM), + ), StatRow( - label: 'Elevation gain', - value: formatElevation(s.elevationGainM)), - StatRow( - label: 'Elevation loss', - value: formatElevation(s.elevationLossM)), + label: 'Elevation loss', + value: formatElevation(s.elevationLossM), + ), StatRow(label: 'Points', value: '${s.pointCount}'), StatRow(label: 'Segments', value: '${detail.segments.length}'), ], @@ -193,16 +253,16 @@ class _SectionTitle extends StatelessWidget { @override Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - text.toUpperCase(), - style: TextStyle( - fontSize: 12, - letterSpacing: 2, - color: Theme.of(context).colorScheme.outline, - ), - ), - ); + padding: const EdgeInsets.only(bottom: 8), + child: Text( + text.toUpperCase(), + style: TextStyle( + fontSize: 12, + letterSpacing: 2, + color: Theme.of(context).colorScheme.outline, + ), + ), + ); } /// Time in each speed band. @@ -220,8 +280,9 @@ class _Histogram extends StatelessWidget { return const EmptyState(message: 'Not enough points to chart.'); } final colors = Theme.of(context).colorScheme; - final maxMillis = - buckets.map((b) => b.millis).reduce((a, b) => a > b ? a : b); + final maxMillis = buckets + .map((b) => b.millis) + .reduce((a, b) => a > b ? a : b); return Column( children: [ @@ -232,8 +293,10 @@ class _Histogram extends StatelessWidget { children: [ SizedBox( width: 64, - child: Text(b.label, - style: TextStyle(fontSize: 12, color: colors.outline)), + child: Text( + b.label, + style: TextStyle(fontSize: 12, color: colors.outline), + ), ), Expanded( child: LayoutBuilder( @@ -331,8 +394,7 @@ class _ElevationPainter extends CustomPainter { final path = Path(); for (var i = 0; i < samples.length; i++) { final x = size.width * (samples[i].distanceM / xSpan); - final y = - size.height * (1 - (samples[i].altitudeM - minAlt) / span); + final y = size.height * (1 - (samples[i].altitudeM - minAlt) / span); if (i == 0) { path.moveTo(x, y); } else { diff --git a/lib/src/ui/router.dart b/lib/src/ui/router.dart index 7821d8e..2856f05 100644 --- a/lib/src/ui/router.dart +++ b/lib/src/ui/router.dart @@ -21,50 +21,48 @@ abstract final class Routes { } GoRouter buildRouter() => GoRouter( - initialLocation: Routes.record, - routes: [ - GoRoute( - path: Routes.record, - builder: (context, state) => RecordScreen( - onOpenTrips: () => context.push(Routes.trips), - ), - ), - GoRoute( - path: Routes.trips, - builder: (context, state) => TripsScreen( - onOpenTrip: (id) => context.push(Routes.detailFor(id)), - onBack: () => context.pop(), - ), - ), - GoRoute( - path: Routes.tripDetail, - builder: (context, state) { - // Parsed explicitly. A malformed or missing id must land on a real screen - // saying so, never on a blank one or a crash. - final id = int.tryParse(state.pathParameters['tripId'] ?? ''); - if (id == null) { - return const _NotFound(); - } - return TripDetailScreen(tripId: id, onBack: () => context.pop()); - }, - ), - ], - errorBuilder: (context, state) => const _NotFound(), - ); + initialLocation: Routes.record, + routes: [ + GoRoute( + path: Routes.record, + builder: (context, state) => + RecordScreen(onOpenTrips: () => context.push(Routes.trips)), + ), + GoRoute( + path: Routes.trips, + builder: (context, state) => TripsScreen( + onOpenTrip: (id) => context.push(Routes.detailFor(id)), + onBack: () => context.pop(), + ), + ), + GoRoute( + path: Routes.tripDetail, + builder: (context, state) { + // Parsed explicitly. A malformed or missing id must land on a real screen + // saying so, never on a blank one or a crash. + final id = int.tryParse(state.pathParameters['tripId'] ?? ''); + if (id == null) { + return const _NotFound(); + } + return TripDetailScreen(tripId: id, onBack: () => context.pop()); + }, + ), + ], + errorBuilder: (context, state) => const _NotFound(), +); class _NotFound extends StatelessWidget { const _NotFound(); @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.canPop() - ? context.pop() - : context.go(Routes.record), - ), - ), - body: const Center(child: Text('That ride could not be found.')), - ); + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => + context.canPop() ? context.pop() : context.go(Routes.record), + ), + ), + body: const Center(child: Text('That ride could not be found.')), + ); } diff --git a/lib/src/ui/trips/trips_screen.dart b/lib/src/ui/trips/trips_screen.dart index e1ea7da..20db349 100644 --- a/lib/src/ui/trips/trips_screen.dart +++ b/lib/src/ui/trips/trips_screen.dart @@ -15,8 +15,9 @@ import '../format.dart'; /// Selection is screen-local: it must not survive navigating away, and nothing else /// needs to observe it. -final _selectionProvider = - StateProvider.autoDispose>((ref) => {}); +final _selectionProvider = StateProvider.autoDispose>( + (ref) => {}, +); class TripsScreen extends ConsumerWidget { const TripsScreen({super.key, this.onOpenTrip, this.onBack}); @@ -31,9 +32,9 @@ class TripsScreen extends ConsumerWidget { final trips = tripsAsync.valueOrNull ?? const []; // Drop ids that no longer exist, so a deleted trip cannot linger in the selection. - final live = ref.watch(_selectionProvider).intersection( - trips.map((t) => t.id).toSet(), - ); + final live = ref + .watch(_selectionProvider) + .intersection(trips.map((t) => t.id).toSet()); final selecting = live.isNotEmpty; // Merge combines exactly two rides; anything else is ambiguous. final canMerge = live.length == 2; @@ -61,8 +62,7 @@ class TripsScreen extends ConsumerWidget { padding: const EdgeInsets.only(left: 8), child: Text( '${live.length} selected', - style: - TextStyle(fontSize: 14, color: colors.outline), + style: TextStyle(fontSize: 14, color: colors.outline), ), ), ] else ...[ @@ -96,8 +96,10 @@ class TripsScreen extends ConsumerWidget { TextButton( key: const Key('delete'), onPressed: () => _confirmDelete(context, ref, live), - child: Text('Delete', - style: TextStyle(color: colors.error)), + child: Text( + 'Delete', + style: TextStyle(color: colors.error), + ), ), ], ], @@ -108,29 +110,28 @@ class TripsScreen extends ConsumerWidget { AsyncValue(hasValue: true, value: final list) when (list ?? const []).isEmpty => const EmptyState( - message: - 'No rides yet.\nPress Start on the record screen to capture one.', + message: 'No rides yet.\nPress Start on the record screen to capture one.', icon: Icons.two_wheeler, ), AsyncValue(hasValue: true) => ListView.builder( - itemCount: trips.length, - itemBuilder: (context, i) { - final trip = trips[i]; - return _TripTile( - trip: trip, - selected: live.contains(trip.id), - selecting: selecting, - onTap: () { - if (selecting) { - _toggle(ref, trip.id); - } else { - onOpenTrip?.call(trip.id); - } - }, - onLongPress: () => _toggle(ref, trip.id), - ); - }, - ), + itemCount: trips.length, + itemBuilder: (context, i) { + final trip = trips[i]; + return _TripTile( + trip: trip, + selected: live.contains(trip.id), + selecting: selecting, + onTap: () { + if (selecting) { + _toggle(ref, trip.id); + } else { + onOpenTrip?.call(trip.id); + } + }, + onLongPress: () => _toggle(ref, trip.id), + ); + }, + ), _ => const Center(child: CircularProgressIndicator()), }, ), @@ -150,7 +151,10 @@ class TripsScreen extends ConsumerWidget { } Future _confirmDelete( - BuildContext context, WidgetRef ref, Set selection) async { + BuildContext context, + WidgetRef ref, + Set selection, + ) async { final ok = await confirmDialog( context, title: 'Delete ${selection.length} ride(s)?', @@ -166,15 +170,20 @@ class TripsScreen extends ConsumerWidget { ref.read(_selectionProvider.notifier).state = {}; } - Future _confirmMerge(BuildContext context, WidgetRef ref, - List trips, Set selection) async { + Future _confirmMerge( + BuildContext context, + WidgetRef ref, + List trips, + Set selection, + ) async { final total = trips .where((t) => selection.contains(t.id)) .fold(0, (a, t) => a + t.distanceM); final ok = await confirmDialog( context, title: 'Merge two rides?', - message: 'They become one ride of about ${formatDistance(total)}. ' + message: + 'They become one ride of about ${formatDistance(total)}. ' 'The join is kept as a segment break, so no straight line is drawn ' 'across it.', confirmLabel: 'Merge', @@ -218,8 +227,10 @@ class _TripTile extends StatelessWidget { color: selected ? colors.primary : colors.outline, ) : null, - title: Text(tripLabel(trip), - style: const TextStyle(fontWeight: FontWeight.w600)), + title: Text( + tripLabel(trip), + style: const TextStyle(fontWeight: FontWeight.w600), + ), subtitle: Text( '${formatDistance(trip.distanceM)} · ' '${formatDuration(trip.movingMillis)} · ' diff --git a/test/ride_map_test.dart b/test/ride_map_test.dart new file mode 100644 index 0000000..2ff907f --- /dev/null +++ b/test/ride_map_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rippr/src/domain/models.dart'; +import 'package:rippr/src/ui/components/ride_map.dart'; +import 'package:rippr/src/ui/theme.dart'; + +/// Tiles are never fetched here — a widget test cannot serve them — but the polyline +/// geometry, which is where every real map bug in this project has lived, is fully +/// exercisable. +void main() { + TrackPoint p(int segmentId, int i, {double speed = 40, double lat = 51.0}) => + TrackPoint( + id: i + 1, + tripId: 1, + segmentId: segmentId, + timestamp: 1000 + i * 1000, + latitude: lat + i * 0.0005, + longitude: -114.0, + speedKmh: speed, + altitudeM: 1000, + ); + + Future> render( + WidgetTester tester, { + required List points, + required List segments, + }) async { + await tester.pumpWidget(MaterialApp( + theme: ripprTheme(), + home: Scaffold(body: RideMap(points: points, segments: segments)), + )); + await tester.pump(); + final layers = tester + .widgetList(find.byType(PolylineLayer)) + .toList(); + return layers.isEmpty ? const [] : layers.first.polylines.cast(); + } + + testWidgets('an empty ride says so instead of drawing a blank map', + (tester) async { + await tester.pumpWidget(MaterialApp( + theme: ripprTheme(), + home: const Scaffold( + body: RideMap(points: [], segments: []), + ), + )); + await tester.pump(); + expect(find.text('No path recorded'), findsOneWidget); + expect(find.byType(FlutterMap), findsNothing); + }); + + testWidgets('a pause leaves a gap: segments are never joined', (tester) async { + // Two segments a degree apart. If they were joined, one polyline would span ~111 km. + final points = [ + for (var i = 0; i < 6; i++) p(1, i, lat: 51.0), + for (var i = 0; i < 6; i++) p(2, i, lat: 52.0), + ]; + const segments = [ + Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1), + Segment(id: 2, tripId: 1, startedAt: 2, endedAt: 3), + ]; + + final polylines = await render(tester, points: points, segments: segments); + + expect(polylines, isNotEmpty); + for (final line in polylines) { + final lats = line.points.map((c) => c.latitude); + // No single polyline may straddle the gap between the two rides. + expect(lats.every((l) => l < 51.5) || lats.every((l) => l > 51.5), isTrue, + reason: 'a polyline spanned the pause'); + } + }); + + testWidgets('decimation reduces rendered vertices but keeps the shape', + (tester) async { + // A dense straight run: Douglas-Peucker should collapse it hard. + final points = [for (var i = 0; i < 400; i++) p(1, i)]; + const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)]; + + final polylines = await render(tester, points: points, segments: segments); + + final rendered = + polylines.fold(0, (a, line) => a + line.points.length); + expect(rendered, lessThan(points.length), + reason: 'render-only decimation should reduce vertex count'); + expect(rendered, greaterThan(1)); + }); + + testWidgets('a stationary ride still renders without zooming to infinity', + (tester) async { + // Every point at one spot — degenerate bounds. Fitting these would zoom past the + // tile server's maximum and render an empty grid, the v2.0 short-ride bug. + final points = [ + for (var i = 0; i < 5; i++) + TrackPoint( + id: i + 1, + tripId: 1, + segmentId: 1, + timestamp: 1000 + i * 1000, + latitude: 51.0, + longitude: -114.0, + speedKmh: 0, + altitudeM: 1000, + ), + ]; + const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)]; + + await tester.pumpWidget(MaterialApp( + theme: ripprTheme(), + home: Scaffold(body: RideMap(points: points, segments: segments)), + )); + await tester.pump(); + + final map = tester.widget(find.byType(FlutterMap)); + expect(map.options.initialZoom, shortRideZoom); + expect(map.options.maxZoom, maxTileZoom, + reason: 'exceeding OSM max tile zoom renders an empty grid'); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index d772d6a..5c532b2 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -66,10 +66,13 @@ void main() { /// /// The theme is deliberately included: the black-on-black bug was invisible to logic /// tests and only a rendered widget can catch its equivalent. - Widget host(Widget child) => ProviderScope( + Widget host(Widget child, {bool map = true}) => ProviderScope( overrides: [ databaseProvider.overrideWithValue(db), locationSourceProvider.overrideWithValue(source), + // The map is off by default in tests that are not about the map: it fetches + // tiles, which a widget test cannot serve, and it changes scroll geometry. + mapEnabledProvider.overrideWith((ref) => map), ], child: MaterialApp(theme: ripprTheme(), home: child), ); @@ -286,7 +289,7 @@ void main() { final id = await seedCompletedTrip( startedAt: 1000, endedAt: 5000, name: 'Sunday blast', points: 11); - await tester.pumpWidget(host(TripDetailScreen(tripId: id))); + await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false)); await tester.pumpAndSettle(); expect(find.text('Sunday blast'), findsOneWidget); @@ -297,7 +300,7 @@ void main() { screenTest('a missing ride says so instead of rendering blank', (tester) async { - await tester.pumpWidget(host(const TripDetailScreen(tripId: 9999))); + await tester.pumpWidget(host(const TripDetailScreen(tripId: 9999), map: false)); await tester.pumpAndSettle(); expect(find.textContaining('no longer exists'), findsOneWidget); @@ -307,7 +310,7 @@ void main() { final id = await seedCompletedTrip( startedAt: 1000, endedAt: 2000, points: 1); - await tester.pumpWidget(host(TripDetailScreen(tripId: id))); + await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false)); await tester.pumpAndSettle(); // Both charts need intervals; one point has none. An unexplained blank box would @@ -325,7 +328,7 @@ void main() { screenTest('renaming updates the title', (tester) async { final id = await seedCompletedTrip(startedAt: 1000, endedAt: 5000); - await tester.pumpWidget(host(TripDetailScreen(tripId: id))); + await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false)); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('rename')));