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

@@ -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 Next: **Phase 4, the UI** — six screens, and the first widget tests this project has ever
had. 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`.

View File

@@ -49,8 +49,9 @@ final recordingEngineProvider = Provider<RecordingEngine>((ref) {
}); });
/// The live speedo feed, published from the fix callback at GPS rate. /// The live speedo feed, published from the fix callback at GPS rate.
final liveTelemetryProvider = final liveTelemetryProvider = Provider<LiveTelemetry>(
Provider<LiveTelemetry>((ref) => LiveTelemetry.instance); (ref) => LiveTelemetry.instance,
);
// --- Reactive state --------------------------------------------------------- // --- Reactive state ---------------------------------------------------------
@@ -70,3 +71,9 @@ final recorderStateProvider = StreamProvider<RecorderState>((ref) {
final engine = ref.watch(recordingEngineProvider); final engine = ref.watch(recordingEngineProvider);
return engine.stateStream; 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<bool>((ref) => true);

View File

@@ -122,7 +122,8 @@ class AppDatabase extends _$AppDatabase {
/// The in-progress ride, or null. This is the source of truth for "are we recording" — /// 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. /// it survives process death, which an in-memory flag cannot.
Stream<domain.Trip?> watchActiveTrip() => (select(trips) Stream<domain.Trip?> watchActiveTrip() =>
(select(trips)
..where((t) => t.endedAt.isNull()) ..where((t) => t.endedAt.isNull())
..orderBy([(t) => OrderingTerm.desc(t.id)]) ..orderBy([(t) => OrderingTerm.desc(t.id)])
..limit(1)) ..limit(1))
@@ -130,7 +131,8 @@ class AppDatabase extends _$AppDatabase {
.map((r) => r == null ? null : _toTrip(r)); .map((r) => r == null ? null : _toTrip(r));
Future<domain.Trip?> getActiveTrip() async { Future<domain.Trip?> getActiveTrip() async {
final row = await (select(trips) final row =
await (select(trips)
..where((t) => t.endedAt.isNull()) ..where((t) => t.endedAt.isNull())
..orderBy([(t) => OrderingTerm.desc(t.id)]) ..orderBy([(t) => OrderingTerm.desc(t.id)])
..limit(1)) ..limit(1))
@@ -138,20 +140,22 @@ class AppDatabase extends _$AppDatabase {
return row == null ? null : _toTrip(row); return row == null ? null : _toTrip(row);
} }
Stream<List<domain.Trip>> watchCompletedTrips() => (select(trips) Stream<List<domain.Trip>> watchCompletedTrips() =>
(select(trips)
..where((t) => t.endedAt.isNotNull()) ..where((t) => t.endedAt.isNotNull())
..orderBy([(t) => OrderingTerm.desc(t.startedAt)])) ..orderBy([(t) => OrderingTerm.desc(t.startedAt)]))
.watch() .watch()
.map((rows) => rows.map(_toTrip).toList()); .map((rows) => rows.map(_toTrip).toList());
Stream<domain.Trip?> watchTrip(int id) => Stream<domain.Trip?> watchTrip(int id) =>
(select(trips)..where((t) => t.id.equals(id))) (select(trips)..where((t) => t.id.equals(id))).watchSingleOrNull().map(
.watchSingleOrNull() (r) => r == null ? null : _toTrip(r),
.map((r) => r == null ? null : _toTrip(r)); );
Future<domain.Trip?> getTrip(int id) async { Future<domain.Trip?> getTrip(int id) async {
final row = final row = await (select(
await (select(trips)..where((t) => t.id.equals(id))).getSingleOrNull(); trips,
)..where((t) => t.id.equals(id))).getSingleOrNull();
return row == null ? null : _toTrip(row); return row == null ? null : _toTrip(row);
} }
@@ -169,18 +173,21 @@ class AppDatabase extends _$AppDatabase {
), ),
); );
Future<void> renameTrip(int id, String? name) => Future<void> renameTrip(int id, String? name) => (update(
(update(trips)..where((t) => t.id.equals(id))) trips,
.write(TripsCompanion(name: Value(name))); )..where((t) => t.id.equals(id))).write(TripsCompanion(name: Value(name)));
Future<void> setTripState(int id, domain.TripState state) => Future<void> setTripState(int id, domain.TripState state) => (update(
(update(trips)..where((t) => t.id.equals(id))) trips,
.write(TripsCompanion(state: Value(state))); )..where((t) => t.id.equals(id))).write(TripsCompanion(state: Value(state)));
Future<void> closeTrip(int id, int endedAt, Future<void> closeTrip(
{domain.TripState state = domain.TripState.completed}) => int id,
(update(trips)..where((t) => t.id.equals(id))).write( int endedAt, {
TripsCompanion(endedAt: Value(endedAt), state: Value(state))); 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 /// 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. /// a narrow targeted update rather than a full row rewrite.
@@ -191,34 +198,33 @@ class AppDatabase extends _$AppDatabase {
required double maxSpeedKmh, required double maxSpeedKmh,
required double elevationGainM, required double elevationGainM,
required int pointCount, required int pointCount,
}) => }) => (update(trips)..where((t) => t.id.equals(id))).write(
(update(trips)..where((t) => t.id.equals(id))).write(TripsCompanion( TripsCompanion(
distanceM: Value(distanceM), distanceM: Value(distanceM),
movingMillis: Value(movingMillis), movingMillis: Value(movingMillis),
maxSpeedKmh: Value(maxSpeedKmh), maxSpeedKmh: Value(maxSpeedKmh),
elevationGainM: Value(elevationGainM), elevationGainM: Value(elevationGainM),
pointCount: Value(pointCount), pointCount: Value(pointCount),
)); ),
);
/// Segments and points go with it via CASCADE. /// Segments and points go with it via CASCADE.
Future<void> deleteTrip(int id) => Future<void> deleteTrip(int id) =>
(delete(trips)..where((t) => t.id.equals(id))).go(); (delete(trips)..where((t) => t.id.equals(id))).go();
Future<int> countTrips() async => Future<int> countTrips() async => (await select(trips).get()).length;
(await select(trips).get()).length;
/// Test/maintenance helper. Segments and points follow via CASCADE. /// Test/maintenance helper. Segments and points follow via CASCADE.
Future<void> deleteAllTrips() => delete(trips).go(); Future<void> deleteAllTrips() => delete(trips).go();
// --- Segments ------------------------------------------------------------ // --- Segments ------------------------------------------------------------
Future<int> insertSegment(int tripId, int startedAt) => Future<int> insertSegment(int tripId, int startedAt) => into(segments)
into(segments).insert( .insert(SegmentsCompanion.insert(tripId: tripId, startedAt: startedAt));
SegmentsCompanion.insert(tripId: tripId, startedAt: startedAt),
);
Future<List<domain.Segment>> segmentsForTrip(int tripId) async { Future<List<domain.Segment>> segmentsForTrip(int tripId) async {
final rows = await (select(segments) final rows =
await (select(segments)
..where((s) => s.tripId.equals(tripId)) ..where((s) => s.tripId.equals(tripId))
..orderBy([(s) => OrderingTerm.asc(s.id)])) ..orderBy([(s) => OrderingTerm.asc(s.id)]))
.get(); .get();
@@ -227,7 +233,8 @@ class AppDatabase extends _$AppDatabase {
/// The segment currently being recorded into, if any. /// The segment currently being recorded into, if any.
Future<domain.Segment?> openSegment(int tripId) async { Future<domain.Segment?> openSegment(int tripId) async {
final row = await (select(segments) final row =
await (select(segments)
..where((s) => s.tripId.equals(tripId) & s.endedAt.isNull()) ..where((s) => s.tripId.equals(tripId) & s.endedAt.isNull())
..orderBy([(s) => OrderingTerm.desc(s.id)]) ..orderBy([(s) => OrderingTerm.desc(s.id)])
..limit(1)) ..limit(1))
@@ -236,17 +243,19 @@ class AppDatabase extends _$AppDatabase {
} }
Future<void> closeSegment(int id, int endedAt) => Future<void> closeSegment(int id, int endedAt) =>
(update(segments)..where((s) => s.id.equals(id))) (update(segments)..where((s) => s.id.equals(id))).write(
.write(SegmentsCompanion(endedAt: Value(endedAt))); SegmentsCompanion(endedAt: Value(endedAt)),
);
/// Used by merge: re-parents a trip's segments onto the surviving trip. /// Used by merge: re-parents a trip's segments onto the surviving trip.
Future<void> reparentSegments(int oldTripId, int newTripId) => Future<void> reparentSegments(int oldTripId, int newTripId) =>
(update(segments)..where((s) => s.tripId.equals(oldTripId))) (update(segments)..where((s) => s.tripId.equals(oldTripId))).write(
.write(SegmentsCompanion(tripId: Value(newTripId))); SegmentsCompanion(tripId: Value(newTripId)),
);
Future<int> countSegmentsForTrip(int tripId) async => Future<int> countSegmentsForTrip(int tripId) async => (await (select(
(await (select(segments)..where((s) => s.tripId.equals(tripId))).get()) segments,
.length; )..where((s) => s.tripId.equals(tripId))).get()).length;
// --- Track points -------------------------------------------------------- // --- Track points --------------------------------------------------------
@@ -256,7 +265,8 @@ class AppDatabase extends _$AppDatabase {
await batch((b) { await batch((b) {
b.insertAll( b.insertAll(
trackPoints, trackPoints,
points.map((p) => TrackPointsCompanion.insert( points.map(
(p) => TrackPointsCompanion.insert(
tripId: p.tripId, tripId: p.tripId,
segmentId: p.segmentId, segmentId: p.segmentId,
timestamp: p.timestamp, timestamp: p.timestamp,
@@ -267,13 +277,14 @@ class AppDatabase extends _$AppDatabase {
accuracyM: Value(p.accuracyM), accuracyM: Value(p.accuracyM),
bearingDeg: Value(p.bearingDeg), bearingDeg: Value(p.bearingDeg),
synced: Value(p.synced), synced: Value(p.synced),
)), ),
),
); );
}); });
} }
Future<int> insertPoint(domain.TrackPoint p) => Future<int> insertPoint(domain.TrackPoint p) => into(trackPoints).insert(
into(trackPoints).insert(TrackPointsCompanion.insert( TrackPointsCompanion.insert(
tripId: p.tripId, tripId: p.tripId,
segmentId: p.segmentId, segmentId: p.segmentId,
timestamp: p.timestamp, timestamp: p.timestamp,
@@ -284,13 +295,15 @@ class AppDatabase extends _$AppDatabase {
accuracyM: Value(p.accuracyM), accuracyM: Value(p.accuracyM),
bearingDeg: Value(p.bearingDeg), bearingDeg: Value(p.bearingDeg),
synced: Value(p.synced), synced: Value(p.synced),
)); ),
);
/// Ordered by segment then id so consumers walk the ride in recording order with pause /// 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 /// boundaries intact. **Not** ordered by timestamp: that value is GPS-derived and can
/// jump, whereas id is monotonic in write order. /// jump, whereas id is monotonic in write order.
Future<List<domain.TrackPoint>> pointsForTrip(int tripId) async { Future<List<domain.TrackPoint>> pointsForTrip(int tripId) async {
final rows = await (select(trackPoints) final rows =
await (select(trackPoints)
..where((p) => p.tripId.equals(tripId)) ..where((p) => p.tripId.equals(tripId))
..orderBy([ ..orderBy([
(p) => OrderingTerm.asc(p.segmentId), (p) => OrderingTerm.asc(p.segmentId),
@@ -301,7 +314,8 @@ class AppDatabase extends _$AppDatabase {
} }
Future<List<domain.TrackPoint>> pointsForSegment(int segmentId) async { Future<List<domain.TrackPoint>> pointsForSegment(int segmentId) async {
final rows = await (select(trackPoints) final rows =
await (select(trackPoints)
..where((p) => p.segmentId.equals(segmentId)) ..where((p) => p.segmentId.equals(segmentId))
..orderBy([(p) => OrderingTerm.asc(p.id)])) ..orderBy([(p) => OrderingTerm.asc(p.id)]))
.get(); .get();
@@ -310,7 +324,8 @@ class AppDatabase extends _$AppDatabase {
/// The anchor a restarted recorder needs to continue accumulating distance. /// The anchor a restarted recorder needs to continue accumulating distance.
Future<domain.TrackPoint?> lastInSegment(int segmentId) async { Future<domain.TrackPoint?> lastInSegment(int segmentId) async {
final row = await (select(trackPoints) final row =
await (select(trackPoints)
..where((p) => p.segmentId.equals(segmentId)) ..where((p) => p.segmentId.equals(segmentId))
..orderBy([(p) => OrderingTerm.desc(p.id)]) ..orderBy([(p) => OrderingTerm.desc(p.id)])
..limit(1)) ..limit(1))
@@ -318,9 +333,9 @@ class AppDatabase extends _$AppDatabase {
return row == null ? null : _toPoint(row); return row == null ? null : _toPoint(row);
} }
Future<int> countPointsForTrip(int tripId) async => Future<int> countPointsForTrip(int tripId) async => (await (select(
(await (select(trackPoints)..where((p) => p.tripId.equals(tripId))).get()) trackPoints,
.length; )..where((p) => p.tripId.equals(tripId))).get()).length;
/// Live stats for one trip. /// Live stats for one trip.
/// ///
@@ -342,14 +357,16 @@ class AppDatabase extends _$AppDatabase {
variables: [Variable.withInt(tripId)], variables: [Variable.withInt(tripId)],
readsFrom: {trackPoints}, readsFrom: {trackPoints},
); );
return q.watchSingle().map((row) => domain.RideStats( return q.watchSingle().map(
(row) => domain.RideStats(
pointCount: row.read<int>('pointCount'), pointCount: row.read<int>('pointCount'),
maxSpeedKmh: row.read<double>('maxSpeedKmh'), maxSpeedKmh: row.read<double>('maxSpeedKmh'),
avgSpeedKmh: row.read<double>('avgSpeedKmh'), avgSpeedKmh: row.read<double>('avgSpeedKmh'),
firstTimestamp: row.read<int>('firstTimestamp'), firstTimestamp: row.read<int>('firstTimestamp'),
lastTimestamp: row.read<int>('lastTimestamp'), lastTimestamp: row.read<int>('lastTimestamp'),
pendingUpload: row.read<int>('pendingUpload'), pendingUpload: row.read<int>('pendingUpload'),
)); ),
);
} }
// --- Upload backlog ------------------------------------------------------ // --- Upload backlog ------------------------------------------------------
@@ -357,7 +374,8 @@ class AppDatabase extends _$AppDatabase {
// the payload carries trip/segment identity per point rather than per batch. // the payload carries trip/segment identity per point rather than per batch.
Future<List<domain.TrackPoint>> unsyncedPoints(int limit) async { Future<List<domain.TrackPoint>> unsyncedPoints(int limit) async {
final rows = await (select(trackPoints) final rows =
await (select(trackPoints)
..where((p) => p.synced.equals(false)) ..where((p) => p.synced.equals(false))
..orderBy([(p) => OrderingTerm.asc(p.id)]) ..orderBy([(p) => OrderingTerm.asc(p.id)])
..limit(limit)) ..limit(limit))
@@ -367,25 +385,27 @@ class AppDatabase extends _$AppDatabase {
Future<void> markSynced(List<int> ids) async { Future<void> markSynced(List<int> ids) async {
if (ids.isEmpty) return; if (ids.isEmpty) return;
await (update(trackPoints)..where((p) => p.id.isIn(ids))) await (update(trackPoints)..where((p) => p.id.isIn(ids))).write(
.write(const TrackPointsCompanion(synced: Value(true))); const TrackPointsCompanion(synced: Value(true)),
);
} }
Future<int> countUnsynced() async => Future<int> countUnsynced() async => (await (select(
(await (select(trackPoints)..where((p) => p.synced.equals(false))).get()) trackPoints,
.length; )..where((p) => p.synced.equals(false))).get()).length;
// --- Maintenance --------------------------------------------------------- // --- Maintenance ---------------------------------------------------------
/// Used by merge: points carry tripId directly, so they re-parent alongside segments. /// Used by merge: points carry tripId directly, so they re-parent alongside segments.
Future<void> reparentPoints(int oldTripId, int newTripId) => Future<void> reparentPoints(int oldTripId, int newTripId) =>
(update(trackPoints)..where((p) => p.tripId.equals(oldTripId))) (update(trackPoints)..where((p) => p.tripId.equals(oldTripId))).write(
.write(TrackPointsCompanion(tripId: Value(newTripId))); TrackPointsCompanion(tripId: Value(newTripId)),
);
Future<List<domain.TrackPoint>> allPoints() async { Future<List<domain.TrackPoint>> allPoints() async {
final rows = await (select(trackPoints) final rows = await (select(
..orderBy([(p) => OrderingTerm.asc(p.id)])) trackPoints,
.get(); )..orderBy([(p) => OrderingTerm.asc(p.id)])).get();
return rows.map(_toPoint).toList(); return rows.map(_toPoint).toList();
} }
} }

View File

@@ -80,8 +80,9 @@ class TripRepository {
if (existing != null) { if (existing != null) {
return _adoptOrOpenSegment(existing, now); return _adoptOrOpenSegment(existing, now);
} }
final tripId = await _db final tripId = await _db.insertTrip(
.insertTrip(Trip(startedAt: now, state: TripState.recording)); Trip(startedAt: now, state: TripState.recording),
);
final segmentId = await _db.insertSegment(tripId, now); final segmentId = await _db.insertSegment(tripId, now);
return TripHandle(tripId, segmentId); return TripHandle(tripId, segmentId);
}); });
@@ -132,7 +133,8 @@ class TripRepository {
/// recording genuinely stopped when the process died, and `computeSummary` sums closed /// 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 /// segment spans for elapsed time, so closing at `now` would bill the dead time as
/// ride time. /// ride time.
Future<TripHandle?> resumeIntoNewSegment(int now) => _db.transaction(() async { Future<TripHandle?> resumeIntoNewSegment(int now) =>
_db.transaction(() async {
final trip = await _db.getActiveTrip(); final trip = await _db.getActiveTrip();
if (trip == null) return null; if (trip == null) return null;
@@ -170,7 +172,10 @@ class TripRepository {
// Empty input must collapse to null, or the UI's "derive a label from the date" // Empty input must collapse to null, or the UI's "derive a label from the date"
// branch and a stored "" would diverge. // branch and a stored "" would diverge.
final trimmed = name?.trim(); 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<void> deleteTrip(int id) => _db.deleteTrip(id); Future<void> deleteTrip(int id) => _db.deleteTrip(id);
@@ -261,8 +266,7 @@ class TripRepository {
required double maxSpeedKmh, required double maxSpeedKmh,
required double elevationGainM, required double elevationGainM,
required int pointCount, required int pointCount,
}) => }) => _db.updateAggregates(
_db.updateAggregates(
id: tripId, id: tripId,
distanceM: distanceM, distanceM: distanceM,
movingMillis: movingMillis, movingMillis: movingMillis,

View File

@@ -74,8 +74,7 @@ class Trip {
double? maxSpeedKmh, double? maxSpeedKmh,
double? elevationGainM, double? elevationGainM,
int? pointCount, int? pointCount,
}) => }) => Trip(
Trip(
id: id ?? this.id, id: id ?? this.id,
startedAt: startedAt ?? this.startedAt, startedAt: startedAt ?? this.startedAt,
endedAt: endedAt ?? this.endedAt, endedAt: endedAt ?? this.endedAt,

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. /// Segment order first, then any points whose segment is missing, so nothing is dropped.
List<int> _orderedSegmentIds( List<int> _orderedSegmentIds(
List<Segment> segments, Map<int, List<TrackPoint>> bySegment) { List<Segment> segments,
Map<int, List<TrackPoint>> bySegment,
) {
final ids = <int>[ final ids = <int>[
for (final s in segments) for (final s in segments)
if (bySegment.containsKey(s.id)) s.id, if (bySegment.containsKey(s.id)) s.id,
@@ -87,7 +89,8 @@ String gpx(
final b = StringBuffer(); final b = StringBuffer();
b.write('<?xml version="1.0" encoding="UTF-8"?>\n'); b.write('<?xml version="1.0" encoding="UTF-8"?>\n');
b.write( 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(' <metadata>\n');
b.write(' <name>${escapeXml(name)}</name>\n'); b.write(' <name>${escapeXml(name)}</name>\n');
b.write(' <time>${_time(trip.startedAt)}</time>\n'); b.write(' <time>${_time(trip.startedAt)}</time>\n');
@@ -99,7 +102,8 @@ String gpx(
b.write(' <trkseg>\n'); b.write(' <trkseg>\n');
for (final p in bySegment[segmentId]!) { for (final p in bySegment[segmentId]!) {
b.write( 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(' <ele>${_num(p.altitudeM)}</ele>\n');
b.write(' <time>${_time(p.timestamp)}</time>\n'); b.write(' <time>${_time(p.timestamp)}</time>\n');
b.write(' <extensions>\n'); b.write(' <extensions>\n');
@@ -129,17 +133,21 @@ String geoJson(
final bySegment = _groupBySegment(points); final bySegment = _groupBySegment(points);
final ordered = _orderedSegmentIds(segments, bySegment); final ordered = _orderedSegmentIds(segments, bySegment);
final features = ordered.map((segmentId) { final features = ordered
.map((segmentId) {
final coords = bySegment[segmentId]! final coords = bySegment[segmentId]!
.map((p) => .map(
'[${_coord(p.longitude)}, ${_coord(p.latitude)}, ${_num(p.altitudeM)}]') (p) =>
'[${_coord(p.longitude)}, ${_coord(p.latitude)}, ${_num(p.altitudeM)}]',
)
.join(', '); .join(', ');
return ''' { return ''' {
"type": "Feature", "type": "Feature",
"properties": { "segment_id": $segmentId }, "properties": { "segment_id": $segmentId },
"geometry": { "type": "LineString", "coordinates": [$coords] } "geometry": { "type": "LineString", "coordinates": [$coords] }
}'''; }''';
}).join(',\n'); })
.join(',\n');
return '''{ return '''{
"type": "FeatureCollection", "type": "FeatureCollection",

View File

@@ -62,7 +62,8 @@ double haversineMeters(double lat1, double lon1, double lat2, double lon2) {
final dLon = (lon2 - lon1) * _degToRad; final dLon = (lon2 - lon1) * _degToRad;
final sinLat = math.sin(dLat / 2); final sinLat = math.sin(dLat / 2);
final sinLon = math.sin(dLon / 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; math.cos(lat1 * _degToRad) * math.cos(lat2 * _degToRad) * sinLon * sinLon;
// asin(sqrt(a)) rather than atan2 — better conditioned for the very short hops // asin(sqrt(a)) rather than atan2 — better conditioned for the very short hops
// between consecutive GPS fixes. // between consecutive GPS fixes.
@@ -102,7 +103,11 @@ List<LatLon> simplify(List<LatLon> points, double epsilonMeters) {
var maxDist = 0.0; var maxDist = 0.0;
var index = first; var index = first;
for (var i = first + 1; i < last; i++) { 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) { if (d > maxDist) {
maxDist = d; maxDist = d;
index = i; index = i;

View File

@@ -68,9 +68,8 @@ class GeolocatorLocationSource implements LocationSource {
await _ensurePermission(); await _ensurePermission();
_subscription = Geolocator.getPositionStream( _subscription = Geolocator.getPositionStream(locationSettings: _settings())
locationSettings: _settings(), .listen(
).listen(
(p) => _controller.add(_toFix(p)), (p) => _controller.add(_toFix(p)),
// A transient platform error must not close the stream: the engine treats a closed // A transient platform error must not close the stream: the engine treats a closed
// stream as "recording stopped", which is a user-visible event. // stream as "recording stopped", which is a user-visible event.

View File

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

View File

@@ -63,11 +63,20 @@ class RideSummary {
other.pointCount == pointCount; other.pointCount == pointCount;
@override @override
int get hashCode => Object.hash(distanceM, elapsedMillis, movingMillis, int get hashCode => Object.hash(
maxSpeedKmh, avgMovingSpeedKmh, elevationGainM, elevationLossM, pointCount); distanceM,
elapsedMillis,
movingMillis,
maxSpeedKmh,
avgMovingSpeedKmh,
elevationGainM,
elevationLossM,
pointCount,
);
@override @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, ' 'moving: $movingMillis ms, max: $maxSpeedKmh km/h, gain: $elevationGainM m, '
'loss: $elevationLossM m, points: $pointCount)'; 'loss: $elevationLossM m, points: $pointCount)';
} }
@@ -199,7 +208,10 @@ int _elapsedFor(List<TrackPoint> points, List<Segment> segments) {
/// Time spent in each speed band. Buckets are keyed on the *interval* between fixes, so /// 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 /// 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. /// at 2 Hz would otherwise dominate purely by producing more samples.
List<SpeedBucket> speedHistogram(List<TrackPoint> points, {int bucketKmh = 10}) { List<SpeedBucket> speedHistogram(
List<TrackPoint> points, {
int bucketKmh = 10,
}) {
if (points.length < 2 || bucketKmh <= 0) return const []; if (points.length < 2 || bucketKmh <= 0) return const [];
final millisByBucket = <int, int>{}; final millisByBucket = <int, int>{};
@@ -216,8 +228,11 @@ List<SpeedBucket> speedHistogram(List<TrackPoint> points, {int bucketKmh = 10})
final keys = millisByBucket.keys.toList()..sort(); final keys = millisByBucket.keys.toList()..sort();
return [ return [
for (final bucket in keys) for (final bucket in keys)
SpeedBucket(bucket * bucketKmh, (bucket + 1) * bucketKmh, SpeedBucket(
millisByBucket[bucket]!), bucket * bucketKmh,
(bucket + 1) * bucketKmh,
millisByBucket[bucket]!,
),
]; ];
} }
@@ -360,7 +375,9 @@ class ElevationAccumulator {
final committed = _lastCommitted; final committed = _lastCommitted;
if (committed == null) return _gain; if (committed == null) return _gain;
final tip = _direction == 1 ? math.max(_extreme, _lastRaw) : _extreme; 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 /// Banks the run still in progress. Must be called once the last point is added, or a

View File

@@ -42,7 +42,8 @@ String formatDuration(int millis) {
/// boundary. Hoisting them to batch level would silently mislabel points. /// boundary. Hoisting them to batch level would silently mislabel points.
String encodeBatch(String deviceId, List<TrackPoint> points) { String encodeBatch(String deviceId, List<TrackPoint> points) {
final array = points final array = points
.map((p) => <String, Object?>{ .map(
(p) => <String, Object?>{
'id': p.id, 'id': p.id,
'trip_id': p.tripId, 'trip_id': p.tripId,
'segment_id': p.segmentId, 'segment_id': p.segmentId,
@@ -53,11 +54,9 @@ String encodeBatch(String deviceId, List<TrackPoint> points) {
'alt_m': p.altitudeM, 'alt_m': p.altitudeM,
'acc_m': p.accuracyM, 'acc_m': p.accuracyM,
'bearing': p.bearingDeg, 'bearing': p.bearingDeg,
}) },
)
.toList(growable: false); .toList(growable: false);
return jsonEncode(<String, Object?>{ return jsonEncode(<String, Object?>{'device_id': deviceId, 'points': array});
'device_id': deviceId,
'points': array,
});
} }

View File

@@ -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<TrackPoint> points;
final List<Segment> segments;
final double height;
@override
State<RideMap> createState() => _RideMapState();
}
class _RideMapState extends State<RideMap> {
final _controller = MapController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/// Points grouped by segment, in recording order.
List<List<TrackPoint>> get _bySegment {
final grouped = <int, List<TrackPoint>>{};
for (final p in widget.points) {
(grouped[p.segmentId] ??= <TrackPoint>[]).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<Polyline> _buildPolylines(ColorScheme colors) {
final maxSpeed = widget.points
.map((p) => p.speedKmh)
.fold<double>(0, (a, b) => a > b ? a : b);
final out = <Polyline>[];
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<TrackPoint> 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)!;
}
}

View File

@@ -8,9 +8,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/providers.dart'; import '../../app/providers.dart';
import '../../export/export_manager.dart';
import '../../domain/models.dart'; import '../../domain/models.dart';
import '../../stats/ride_statistics.dart'; import '../../stats/ride_statistics.dart';
import '../../telemetry/telemetry.dart'; import '../../telemetry/telemetry.dart';
import '../components/ride_map.dart';
import '../components/stats.dart'; import '../components/stats.dart';
import '../format.dart'; import '../format.dart';
@@ -23,6 +25,7 @@ class TripDetail {
required this.segments, required this.segments,
required this.histogram, required this.histogram,
required this.profile, required this.profile,
required this.mapEnabled,
}); });
final Trip trip; final Trip trip;
@@ -31,10 +34,16 @@ class TripDetail {
final List<Segment> segments; final List<Segment> segments;
final List<SpeedBucket> histogram; final List<SpeedBucket> histogram;
final List<ElevationSample> profile; final List<ElevationSample> 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 = final tripDetailProvider = FutureProvider.autoDispose.family<TripDetail?, int>((
FutureProvider.autoDispose.family<TripDetail?, int>((ref, tripId) async { ref,
tripId,
) async {
final repo = ref.watch(tripRepositoryProvider); final repo = ref.watch(tripRepositoryProvider);
final trip = await repo.tripById(tripId); final trip = await repo.tripById(tripId);
if (trip == null) return null; if (trip == null) return null;
@@ -49,6 +58,7 @@ final tripDetailProvider =
segments: segments, segments: segments,
histogram: speedHistogram(points), histogram: speedHistogram(points),
profile: elevationProfile(points), profile: elevationProfile(points),
mapEnabled: ref.watch(mapEnabledProvider),
); );
}); });
@@ -69,11 +79,16 @@ class TripDetailScreen extends ConsumerWidget {
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: onBack, onPressed: onBack,
), ),
title: Text(async.valueOrNull == null title: Text(
? 'Ride' async.valueOrNull == null ? 'Ride' : tripLabel(async.value!.trip),
: tripLabel(async.value!.trip)), ),
actions: [ actions: [
if (async.valueOrNull != null) if (async.valueOrNull != null)
IconButton(
key: const Key('export'),
icon: const Icon(Icons.ios_share),
onPressed: () => _export(context, async.value!),
),
IconButton( IconButton(
key: const Key('rename'), key: const Key('rename'),
icon: const Icon(Icons.edit_outlined), icon: const Icon(Icons.edit_outlined),
@@ -87,15 +102,50 @@ class TripDetailScreen extends ConsumerWidget {
icon: Icons.help_outline, icon: Icons.help_outline,
), ),
AsyncValue(hasValue: true, value: final d?) => _Body(detail: d), AsyncValue(hasValue: true, value: final d?) => _Body(detail: d),
AsyncValue(hasError: true, :final error) => AsyncValue(hasError: true, :final error) => EmptyState(
EmptyState(message: 'Could not load this ride.\n$error'), message: 'Could not load this ride.\n$error',
),
_ => const Center(child: CircularProgressIndicator()), _ => const Center(child: CircularProgressIndicator()),
}, },
); );
} }
Future<void> _rename( Future<void> _export(BuildContext context, TripDetail d) async {
BuildContext context, WidgetRef ref, Trip trip) async { final format = await showModalBottomSheet<ExportFormat>(
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<void> _rename(BuildContext context, WidgetRef ref, Trip trip) async {
final controller = TextEditingController(text: trip.name ?? ''); final controller = TextEditingController(text: trip.name ?? '');
final name = await showDialog<String>( final name = await showDialog<String>(
context: context, context: context,
@@ -146,6 +196,9 @@ class _Body extends StatelessWidget {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
if (detail.mapEnabled)
RideMap(points: detail.points, segments: detail.segments),
if (detail.mapEnabled) const SizedBox(height: 24),
Card( Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -153,22 +206,29 @@ class _Body extends StatelessWidget {
children: [ children: [
StatRow( StatRow(
label: 'Moving time', label: 'Moving time',
value: formatDuration(s.movingMillis)), value: formatDuration(s.movingMillis),
),
StatRow( StatRow(
label: 'Elapsed', value: formatDuration(s.elapsedMillis)), label: 'Elapsed',
value: formatDuration(s.elapsedMillis),
),
StatRow( StatRow(
label: 'Stopped', value: formatDuration(s.stoppedMillis)), label: 'Stopped',
StatRow( value: formatDuration(s.stoppedMillis),
label: 'Max speed', value: formatSpeed(s.maxSpeedKmh)), ),
StatRow(label: 'Max speed', value: formatSpeed(s.maxSpeedKmh)),
StatRow( StatRow(
label: 'Avg moving speed', label: 'Avg moving speed',
value: formatSpeed(s.avgMovingSpeedKmh)), value: formatSpeed(s.avgMovingSpeedKmh),
),
StatRow( StatRow(
label: 'Elevation gain', label: 'Elevation gain',
value: formatElevation(s.elevationGainM)), value: formatElevation(s.elevationGainM),
),
StatRow( StatRow(
label: 'Elevation loss', label: 'Elevation loss',
value: formatElevation(s.elevationLossM)), value: formatElevation(s.elevationLossM),
),
StatRow(label: 'Points', value: '${s.pointCount}'), StatRow(label: 'Points', value: '${s.pointCount}'),
StatRow(label: 'Segments', value: '${detail.segments.length}'), StatRow(label: 'Segments', value: '${detail.segments.length}'),
], ],
@@ -220,8 +280,9 @@ class _Histogram extends StatelessWidget {
return const EmptyState(message: 'Not enough points to chart.'); return const EmptyState(message: 'Not enough points to chart.');
} }
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
final maxMillis = final maxMillis = buckets
buckets.map((b) => b.millis).reduce((a, b) => a > b ? a : b); .map((b) => b.millis)
.reduce((a, b) => a > b ? a : b);
return Column( return Column(
children: [ children: [
@@ -232,8 +293,10 @@ class _Histogram extends StatelessWidget {
children: [ children: [
SizedBox( SizedBox(
width: 64, width: 64,
child: Text(b.label, child: Text(
style: TextStyle(fontSize: 12, color: colors.outline)), b.label,
style: TextStyle(fontSize: 12, color: colors.outline),
),
), ),
Expanded( Expanded(
child: LayoutBuilder( child: LayoutBuilder(
@@ -331,8 +394,7 @@ class _ElevationPainter extends CustomPainter {
final path = Path(); final path = Path();
for (var i = 0; i < samples.length; i++) { for (var i = 0; i < samples.length; i++) {
final x = size.width * (samples[i].distanceM / xSpan); final x = size.width * (samples[i].distanceM / xSpan);
final y = final y = size.height * (1 - (samples[i].altitudeM - minAlt) / span);
size.height * (1 - (samples[i].altitudeM - minAlt) / span);
if (i == 0) { if (i == 0) {
path.moveTo(x, y); path.moveTo(x, y);
} else { } else {

View File

@@ -25,9 +25,8 @@ GoRouter buildRouter() => GoRouter(
routes: [ routes: [
GoRoute( GoRoute(
path: Routes.record, path: Routes.record,
builder: (context, state) => RecordScreen( builder: (context, state) =>
onOpenTrips: () => context.push(Routes.trips), RecordScreen(onOpenTrips: () => context.push(Routes.trips)),
),
), ),
GoRoute( GoRoute(
path: Routes.trips, path: Routes.trips,
@@ -60,9 +59,8 @@ class _NotFound extends StatelessWidget {
appBar: AppBar( appBar: AppBar(
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => context.canPop() onPressed: () =>
? context.pop() context.canPop() ? context.pop() : context.go(Routes.record),
: context.go(Routes.record),
), ),
), ),
body: const Center(child: Text('That ride could not be found.')), body: const Center(child: Text('That ride could not be found.')),

View File

@@ -15,8 +15,9 @@ import '../format.dart';
/// Selection is screen-local: it must not survive navigating away, and nothing else /// Selection is screen-local: it must not survive navigating away, and nothing else
/// needs to observe it. /// needs to observe it.
final _selectionProvider = final _selectionProvider = StateProvider.autoDispose<Set<int>>(
StateProvider.autoDispose<Set<int>>((ref) => <int>{}); (ref) => <int>{},
);
class TripsScreen extends ConsumerWidget { class TripsScreen extends ConsumerWidget {
const TripsScreen({super.key, this.onOpenTrip, this.onBack}); const TripsScreen({super.key, this.onOpenTrip, this.onBack});
@@ -31,9 +32,9 @@ class TripsScreen extends ConsumerWidget {
final trips = tripsAsync.valueOrNull ?? const <Trip>[]; final trips = tripsAsync.valueOrNull ?? const <Trip>[];
// Drop ids that no longer exist, so a deleted trip cannot linger in the selection. // Drop ids that no longer exist, so a deleted trip cannot linger in the selection.
final live = ref.watch(_selectionProvider).intersection( final live = ref
trips.map((t) => t.id).toSet(), .watch(_selectionProvider)
); .intersection(trips.map((t) => t.id).toSet());
final selecting = live.isNotEmpty; final selecting = live.isNotEmpty;
// Merge combines exactly two rides; anything else is ambiguous. // Merge combines exactly two rides; anything else is ambiguous.
final canMerge = live.length == 2; final canMerge = live.length == 2;
@@ -61,8 +62,7 @@ class TripsScreen extends ConsumerWidget {
padding: const EdgeInsets.only(left: 8), padding: const EdgeInsets.only(left: 8),
child: Text( child: Text(
'${live.length} selected', '${live.length} selected',
style: style: TextStyle(fontSize: 14, color: colors.outline),
TextStyle(fontSize: 14, color: colors.outline),
), ),
), ),
] else ...[ ] else ...[
@@ -96,8 +96,10 @@ class TripsScreen extends ConsumerWidget {
TextButton( TextButton(
key: const Key('delete'), key: const Key('delete'),
onPressed: () => _confirmDelete(context, ref, live), onPressed: () => _confirmDelete(context, ref, live),
child: Text('Delete', child: Text(
style: TextStyle(color: colors.error)), 'Delete',
style: TextStyle(color: colors.error),
),
), ),
], ],
], ],
@@ -108,8 +110,7 @@ class TripsScreen extends ConsumerWidget {
AsyncValue(hasValue: true, value: final list) AsyncValue(hasValue: true, value: final list)
when (list ?? const []).isEmpty => when (list ?? const []).isEmpty =>
const EmptyState( const EmptyState(
message: message: 'No rides yet.\nPress Start on the record screen to capture one.',
'No rides yet.\nPress Start on the record screen to capture one.',
icon: Icons.two_wheeler, icon: Icons.two_wheeler,
), ),
AsyncValue(hasValue: true) => ListView.builder( AsyncValue(hasValue: true) => ListView.builder(
@@ -150,7 +151,10 @@ class TripsScreen extends ConsumerWidget {
} }
Future<void> _confirmDelete( Future<void> _confirmDelete(
BuildContext context, WidgetRef ref, Set<int> selection) async { BuildContext context,
WidgetRef ref,
Set<int> selection,
) async {
final ok = await confirmDialog( final ok = await confirmDialog(
context, context,
title: 'Delete ${selection.length} ride(s)?', title: 'Delete ${selection.length} ride(s)?',
@@ -166,15 +170,20 @@ class TripsScreen extends ConsumerWidget {
ref.read(_selectionProvider.notifier).state = <int>{}; ref.read(_selectionProvider.notifier).state = <int>{};
} }
Future<void> _confirmMerge(BuildContext context, WidgetRef ref, Future<void> _confirmMerge(
List<Trip> trips, Set<int> selection) async { BuildContext context,
WidgetRef ref,
List<Trip> trips,
Set<int> selection,
) async {
final total = trips final total = trips
.where((t) => selection.contains(t.id)) .where((t) => selection.contains(t.id))
.fold<double>(0, (a, t) => a + t.distanceM); .fold<double>(0, (a, t) => a + t.distanceM);
final ok = await confirmDialog( final ok = await confirmDialog(
context, context,
title: 'Merge two rides?', 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 ' 'The join is kept as a segment break, so no straight line is drawn '
'across it.', 'across it.',
confirmLabel: 'Merge', confirmLabel: 'Merge',
@@ -218,8 +227,10 @@ class _TripTile extends StatelessWidget {
color: selected ? colors.primary : colors.outline, color: selected ? colors.primary : colors.outline,
) )
: null, : null,
title: Text(tripLabel(trip), title: Text(
style: const TextStyle(fontWeight: FontWeight.w600)), tripLabel(trip),
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text( subtitle: Text(
'${formatDistance(trip.distanceM)} · ' '${formatDistance(trip.distanceM)} · '
'${formatDuration(trip.movingMillis)} · ' '${formatDuration(trip.movingMillis)} · '

120
test/ride_map_test.dart Normal file
View File

@@ -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<List<Polyline>> render(
WidgetTester tester, {
required List<TrackPoint> points,
required List<Segment> segments,
}) async {
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: Scaffold(body: RideMap(points: points, segments: segments)),
));
await tester.pump();
final layers = tester
.widgetList<PolylineLayer>(find.byType(PolylineLayer))
.toList();
return layers.isEmpty ? const [] : layers.first.polylines.cast<Polyline>();
}
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<int>(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<FlutterMap>(find.byType(FlutterMap));
expect(map.options.initialZoom, shortRideZoom);
expect(map.options.maxZoom, maxTileZoom,
reason: 'exceeding OSM max tile zoom renders an empty grid');
});
}

View File

@@ -66,10 +66,13 @@ void main() {
/// ///
/// The theme is deliberately included: the black-on-black bug was invisible to logic /// The theme is deliberately included: the black-on-black bug was invisible to logic
/// tests and only a rendered widget can catch its equivalent. /// tests and only a rendered widget can catch its equivalent.
Widget host(Widget child) => ProviderScope( Widget host(Widget child, {bool map = true}) => ProviderScope(
overrides: [ overrides: [
databaseProvider.overrideWithValue(db), databaseProvider.overrideWithValue(db),
locationSourceProvider.overrideWithValue(source), 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), child: MaterialApp(theme: ripprTheme(), home: child),
); );
@@ -286,7 +289,7 @@ void main() {
final id = await seedCompletedTrip( final id = await seedCompletedTrip(
startedAt: 1000, endedAt: 5000, name: 'Sunday blast', points: 11); 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(); await tester.pumpAndSettle();
expect(find.text('Sunday blast'), findsOneWidget); expect(find.text('Sunday blast'), findsOneWidget);
@@ -297,7 +300,7 @@ void main() {
screenTest('a missing ride says so instead of rendering blank', screenTest('a missing ride says so instead of rendering blank',
(tester) async { (tester) async {
await tester.pumpWidget(host(const TripDetailScreen(tripId: 9999))); await tester.pumpWidget(host(const TripDetailScreen(tripId: 9999), map: false));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.textContaining('no longer exists'), findsOneWidget); expect(find.textContaining('no longer exists'), findsOneWidget);
@@ -307,7 +310,7 @@ void main() {
final id = await seedCompletedTrip( final id = await seedCompletedTrip(
startedAt: 1000, endedAt: 2000, points: 1); 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(); await tester.pumpAndSettle();
// Both charts need intervals; one point has none. An unexplained blank box would // 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 { screenTest('renaming updates the title', (tester) async {
final id = await seedCompletedTrip(startedAt: 1000, endedAt: 5000); 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.pumpAndSettle();
await tester.tap(find.byKey(const Key('rename'))); await tester.tap(find.byKey(const Key('rename')));