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:
@@ -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`.
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ class _RipprAppState extends ConsumerState<RipprApp> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => MaterialApp.router(
|
Widget build(BuildContext context) => MaterialApp.router(
|
||||||
title: 'Rippr',
|
title: 'Rippr',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: ripprTheme(),
|
theme: ripprTheme(),
|
||||||
routerConfig: _router,
|
routerConfig: _router,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -105,82 +105,89 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
onCreate: (m) => m.createAll(),
|
onCreate: (m) => m.createAll(),
|
||||||
beforeOpen: (details) async {
|
beforeOpen: (details) async {
|
||||||
// Non-negotiable: without this the CASCADE relationships above do nothing.
|
// Non-negotiable: without this the CASCADE relationships above do nothing.
|
||||||
await customStatement('PRAGMA foreign_keys = ON');
|
await customStatement('PRAGMA foreign_keys = ON');
|
||||||
// A ride is unrecoverable if a write is lost to a crash mid-flush, but full
|
// 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
|
// 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
|
// standard compromise and survives app crashes; only an OS-level crash can
|
||||||
// lose the last few points.
|
// lose the last few points.
|
||||||
await customStatement('PRAGMA journal_mode = WAL');
|
await customStatement('PRAGMA journal_mode = WAL');
|
||||||
await customStatement('PRAGMA synchronous = NORMAL');
|
await customStatement('PRAGMA synchronous = NORMAL');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Trips ---------------------------------------------------------------
|
// --- Trips ---------------------------------------------------------------
|
||||||
|
|
||||||
/// 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() =>
|
||||||
..where((t) => t.endedAt.isNull())
|
(select(trips)
|
||||||
..orderBy([(t) => OrderingTerm.desc(t.id)])
|
..where((t) => t.endedAt.isNull())
|
||||||
..limit(1))
|
..orderBy([(t) => OrderingTerm.desc(t.id)])
|
||||||
.watchSingleOrNull()
|
..limit(1))
|
||||||
.map((r) => r == null ? null : _toTrip(r));
|
|
||||||
|
|
||||||
Future<domain.Trip?> 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<List<domain.Trip>> watchCompletedTrips() => (select(trips)
|
|
||||||
..where((t) => t.endedAt.isNotNull())
|
|
||||||
..orderBy([(t) => OrderingTerm.desc(t.startedAt)]))
|
|
||||||
.watch()
|
|
||||||
.map((rows) => rows.map(_toTrip).toList());
|
|
||||||
|
|
||||||
Stream<domain.Trip?> watchTrip(int id) =>
|
|
||||||
(select(trips)..where((t) => t.id.equals(id)))
|
|
||||||
.watchSingleOrNull()
|
.watchSingleOrNull()
|
||||||
.map((r) => r == null ? null : _toTrip(r));
|
.map((r) => r == null ? null : _toTrip(r));
|
||||||
|
|
||||||
Future<domain.Trip?> getTrip(int id) async {
|
Future<domain.Trip?> getActiveTrip() async {
|
||||||
final row =
|
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<List<domain.Trip>> watchCompletedTrips() =>
|
||||||
|
(select(trips)
|
||||||
|
..where((t) => t.endedAt.isNotNull())
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.startedAt)]))
|
||||||
|
.watch()
|
||||||
|
.map((rows) => rows.map(_toTrip).toList());
|
||||||
|
|
||||||
|
Stream<domain.Trip?> watchTrip(int id) =>
|
||||||
|
(select(trips)..where((t) => t.id.equals(id))).watchSingleOrNull().map(
|
||||||
|
(r) => r == null ? null : _toTrip(r),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<domain.Trip?> getTrip(int id) async {
|
||||||
|
final row = await (select(
|
||||||
|
trips,
|
||||||
|
)..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||||
return row == null ? null : _toTrip(row);
|
return row == null ? null : _toTrip(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> insertTrip(domain.Trip trip) => into(trips).insert(
|
Future<int> insertTrip(domain.Trip trip) => into(trips).insert(
|
||||||
TripsCompanion.insert(
|
TripsCompanion.insert(
|
||||||
startedAt: trip.startedAt,
|
startedAt: trip.startedAt,
|
||||||
endedAt: Value(trip.endedAt),
|
endedAt: Value(trip.endedAt),
|
||||||
name: Value(trip.name),
|
name: Value(trip.name),
|
||||||
state: trip.state,
|
state: trip.state,
|
||||||
distanceM: Value(trip.distanceM),
|
distanceM: Value(trip.distanceM),
|
||||||
movingMillis: Value(trip.movingMillis),
|
movingMillis: Value(trip.movingMillis),
|
||||||
maxSpeedKmh: Value(trip.maxSpeedKmh),
|
maxSpeedKmh: Value(trip.maxSpeedKmh),
|
||||||
elevationGainM: Value(trip.elevationGainM),
|
elevationGainM: Value(trip.elevationGainM),
|
||||||
pointCount: Value(trip.pointCount),
|
pointCount: Value(trip.pointCount),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
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,62 +198,64 @@ 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 =
|
||||||
..where((s) => s.tripId.equals(tripId))
|
await (select(segments)
|
||||||
..orderBy([(s) => OrderingTerm.asc(s.id)]))
|
..where((s) => s.tripId.equals(tripId))
|
||||||
.get();
|
..orderBy([(s) => OrderingTerm.asc(s.id)]))
|
||||||
|
.get();
|
||||||
return rows.map(_toSegment).toList();
|
return rows.map(_toSegment).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 =
|
||||||
..where((s) => s.tripId.equals(tripId) & s.endedAt.isNull())
|
await (select(segments)
|
||||||
..orderBy([(s) => OrderingTerm.desc(s.id)])
|
..where((s) => s.tripId.equals(tripId) & s.endedAt.isNull())
|
||||||
..limit(1))
|
..orderBy([(s) => OrderingTerm.desc(s.id)])
|
||||||
.getSingleOrNull();
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
return row == null ? null : _toSegment(row);
|
return row == null ? null : _toSegment(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
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,71 +265,77 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
await batch((b) {
|
await batch((b) {
|
||||||
b.insertAll(
|
b.insertAll(
|
||||||
trackPoints,
|
trackPoints,
|
||||||
points.map((p) => TrackPointsCompanion.insert(
|
points.map(
|
||||||
tripId: p.tripId,
|
(p) => TrackPointsCompanion.insert(
|
||||||
segmentId: p.segmentId,
|
tripId: p.tripId,
|
||||||
timestamp: p.timestamp,
|
segmentId: p.segmentId,
|
||||||
latitude: p.latitude,
|
timestamp: p.timestamp,
|
||||||
longitude: p.longitude,
|
latitude: p.latitude,
|
||||||
speedKmh: p.speedKmh,
|
longitude: p.longitude,
|
||||||
altitudeM: p.altitudeM,
|
speedKmh: p.speedKmh,
|
||||||
accuracyM: Value(p.accuracyM),
|
altitudeM: p.altitudeM,
|
||||||
bearingDeg: Value(p.bearingDeg),
|
accuracyM: Value(p.accuracyM),
|
||||||
synced: Value(p.synced),
|
bearingDeg: Value(p.bearingDeg),
|
||||||
)),
|
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,
|
||||||
latitude: p.latitude,
|
latitude: p.latitude,
|
||||||
longitude: p.longitude,
|
longitude: p.longitude,
|
||||||
speedKmh: p.speedKmh,
|
speedKmh: p.speedKmh,
|
||||||
altitudeM: p.altitudeM,
|
altitudeM: p.altitudeM,
|
||||||
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 =
|
||||||
..where((p) => p.tripId.equals(tripId))
|
await (select(trackPoints)
|
||||||
..orderBy([
|
..where((p) => p.tripId.equals(tripId))
|
||||||
(p) => OrderingTerm.asc(p.segmentId),
|
..orderBy([
|
||||||
(p) => OrderingTerm.asc(p.id),
|
(p) => OrderingTerm.asc(p.segmentId),
|
||||||
]))
|
(p) => OrderingTerm.asc(p.id),
|
||||||
.get();
|
]))
|
||||||
|
.get();
|
||||||
return rows.map(_toPoint).toList();
|
return rows.map(_toPoint).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<domain.TrackPoint>> pointsForSegment(int segmentId) async {
|
Future<List<domain.TrackPoint>> pointsForSegment(int segmentId) async {
|
||||||
final rows = await (select(trackPoints)
|
final rows =
|
||||||
..where((p) => p.segmentId.equals(segmentId))
|
await (select(trackPoints)
|
||||||
..orderBy([(p) => OrderingTerm.asc(p.id)]))
|
..where((p) => p.segmentId.equals(segmentId))
|
||||||
.get();
|
..orderBy([(p) => OrderingTerm.asc(p.id)]))
|
||||||
|
.get();
|
||||||
return rows.map(_toPoint).toList();
|
return rows.map(_toPoint).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 =
|
||||||
..where((p) => p.segmentId.equals(segmentId))
|
await (select(trackPoints)
|
||||||
..orderBy([(p) => OrderingTerm.desc(p.id)])
|
..where((p) => p.segmentId.equals(segmentId))
|
||||||
..limit(1))
|
..orderBy([(p) => OrderingTerm.desc(p.id)])
|
||||||
.getSingleOrNull();
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
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(
|
||||||
pointCount: row.read<int>('pointCount'),
|
(row) => domain.RideStats(
|
||||||
maxSpeedKmh: row.read<double>('maxSpeedKmh'),
|
pointCount: row.read<int>('pointCount'),
|
||||||
avgSpeedKmh: row.read<double>('avgSpeedKmh'),
|
maxSpeedKmh: row.read<double>('maxSpeedKmh'),
|
||||||
firstTimestamp: row.read<int>('firstTimestamp'),
|
avgSpeedKmh: row.read<double>('avgSpeedKmh'),
|
||||||
lastTimestamp: row.read<int>('lastTimestamp'),
|
firstTimestamp: row.read<int>('firstTimestamp'),
|
||||||
pendingUpload: row.read<int>('pendingUpload'),
|
lastTimestamp: row.read<int>('lastTimestamp'),
|
||||||
));
|
pendingUpload: row.read<int>('pendingUpload'),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Upload backlog ------------------------------------------------------
|
// --- Upload backlog ------------------------------------------------------
|
||||||
@@ -357,35 +374,38 @@ 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 =
|
||||||
..where((p) => p.synced.equals(false))
|
await (select(trackPoints)
|
||||||
..orderBy([(p) => OrderingTerm.asc(p.id)])
|
..where((p) => p.synced.equals(false))
|
||||||
..limit(limit))
|
..orderBy([(p) => OrderingTerm.asc(p.id)])
|
||||||
.get();
|
..limit(limit))
|
||||||
|
.get();
|
||||||
return rows.map(_toPoint).toList();
|
return rows.map(_toPoint).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,35 +415,35 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
// entirely unaware that Drift exists.
|
// entirely unaware that Drift exists.
|
||||||
|
|
||||||
domain.Trip _toTrip(TripRow r) => domain.Trip(
|
domain.Trip _toTrip(TripRow r) => domain.Trip(
|
||||||
id: r.id,
|
id: r.id,
|
||||||
startedAt: r.startedAt,
|
startedAt: r.startedAt,
|
||||||
endedAt: r.endedAt,
|
endedAt: r.endedAt,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
state: r.state,
|
state: r.state,
|
||||||
distanceM: r.distanceM,
|
distanceM: r.distanceM,
|
||||||
movingMillis: r.movingMillis,
|
movingMillis: r.movingMillis,
|
||||||
maxSpeedKmh: r.maxSpeedKmh,
|
maxSpeedKmh: r.maxSpeedKmh,
|
||||||
elevationGainM: r.elevationGainM,
|
elevationGainM: r.elevationGainM,
|
||||||
pointCount: r.pointCount,
|
pointCount: r.pointCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
domain.Segment _toSegment(SegmentRow r) => domain.Segment(
|
domain.Segment _toSegment(SegmentRow r) => domain.Segment(
|
||||||
id: r.id,
|
id: r.id,
|
||||||
tripId: r.tripId,
|
tripId: r.tripId,
|
||||||
startedAt: r.startedAt,
|
startedAt: r.startedAt,
|
||||||
endedAt: r.endedAt,
|
endedAt: r.endedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
domain.TrackPoint _toPoint(TrackPointRow r) => domain.TrackPoint(
|
domain.TrackPoint _toPoint(TrackPointRow r) => domain.TrackPoint(
|
||||||
id: r.id,
|
id: r.id,
|
||||||
tripId: r.tripId,
|
tripId: r.tripId,
|
||||||
segmentId: r.segmentId,
|
segmentId: r.segmentId,
|
||||||
timestamp: r.timestamp,
|
timestamp: r.timestamp,
|
||||||
latitude: r.latitude,
|
latitude: r.latitude,
|
||||||
longitude: r.longitude,
|
longitude: r.longitude,
|
||||||
speedKmh: r.speedKmh,
|
speedKmh: r.speedKmh,
|
||||||
altitudeM: r.altitudeM,
|
altitudeM: r.altitudeM,
|
||||||
accuracyM: r.accuracyM,
|
accuracyM: r.accuracyM,
|
||||||
bearingDeg: r.bearingDeg,
|
bearingDeg: r.bearingDeg,
|
||||||
synced: r.synced,
|
synced: r.synced,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,37 +76,38 @@ class TripRepository {
|
|||||||
/// Adoption rather than rejection is deliberate: after a process kill the trip row
|
/// 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.
|
/// still exists, and the restarted recorder needs to continue it, not start a second.
|
||||||
Future<TripHandle> startTrip(int now) => _db.transaction(() async {
|
Future<TripHandle> startTrip(int now) => _db.transaction(() async {
|
||||||
final existing = await _db.getActiveTrip();
|
final existing = await _db.getActiveTrip();
|
||||||
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);
|
);
|
||||||
return TripHandle(tripId, segmentId);
|
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 —
|
/// Closes the open segment and marks the trip paused. The trip itself stays open —
|
||||||
/// only [completeTrip] sets `endedAt`.
|
/// only [completeTrip] sets `endedAt`.
|
||||||
Future<bool> pauseTrip(int now) => _db.transaction(() async {
|
Future<bool> pauseTrip(int now) => _db.transaction(() async {
|
||||||
final trip = await _db.getActiveTrip();
|
final trip = await _db.getActiveTrip();
|
||||||
if (trip == null) return false;
|
if (trip == null) return false;
|
||||||
if (trip.state == TripState.paused) return true;
|
if (trip.state == TripState.paused) return true;
|
||||||
|
|
||||||
final open = await _db.openSegment(trip.id);
|
final open = await _db.openSegment(trip.id);
|
||||||
if (open != null) await _db.closeSegment(open.id, now);
|
if (open != null) await _db.closeSegment(open.id, now);
|
||||||
await _db.setTripState(trip.id, TripState.paused);
|
await _db.setTripState(trip.id, TripState.paused);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Opens a fresh segment so the pause leaves a real gap in the recorded path.
|
/// Opens a fresh segment so the pause leaves a real gap in the recorded path.
|
||||||
Future<TripHandle?> resumeTrip(int now) => _db.transaction(() async {
|
Future<TripHandle?> resumeTrip(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;
|
||||||
final handle = await _adoptOrOpenSegment(trip, now);
|
final handle = await _adoptOrOpenSegment(trip, now);
|
||||||
await _db.setTripState(trip.id, TripState.recording);
|
await _db.setTripState(trip.id, TripState.recording);
|
||||||
return handle;
|
return handle;
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Resumes an active trip into a **genuinely new** segment, closing any segment left
|
/// Resumes an active trip into a **genuinely new** segment, closing any segment left
|
||||||
/// open by a process death.
|
/// open by a process death.
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -148,21 +150,21 @@ class TripRepository {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Future<int?> completeTrip(int now) => _db.transaction(() async {
|
Future<int?> completeTrip(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;
|
||||||
final open = await _db.openSegment(trip.id);
|
final open = await _db.openSegment(trip.id);
|
||||||
if (open != null) await _db.closeSegment(open.id, now);
|
if (open != null) await _db.closeSegment(open.id, now);
|
||||||
await _db.closeTrip(trip.id, now, state: TripState.completed);
|
await _db.closeTrip(trip.id, now, state: TripState.completed);
|
||||||
return trip.id;
|
return trip.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Deletes the active trip outright. Segments and points follow via CASCADE.
|
/// Deletes the active trip outright. Segments and points follow via CASCADE.
|
||||||
Future<bool> discardTrip() => _db.transaction(() async {
|
Future<bool> discardTrip() => _db.transaction(() async {
|
||||||
final trip = await _db.getActiveTrip();
|
final trip = await _db.getActiveTrip();
|
||||||
if (trip == null) return false;
|
if (trip == null) return false;
|
||||||
await _db.deleteTrip(trip.id);
|
await _db.deleteTrip(trip.id);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Management ----------------------------------------------------------
|
// --- Management ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -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);
|
||||||
@@ -188,36 +193,36 @@ class TripRepository {
|
|||||||
///
|
///
|
||||||
/// Returns the surviving trip id, or null if the merge was rejected.
|
/// Returns the surviving trip id, or null if the merge was rejected.
|
||||||
Future<int?> mergeTrips(int a, int b) => _db.transaction(() async {
|
Future<int?> mergeTrips(int a, int b) => _db.transaction(() async {
|
||||||
if (a == b) return null;
|
if (a == b) return null;
|
||||||
final first = await _db.getTrip(a);
|
final first = await _db.getTrip(a);
|
||||||
if (first == null) return null;
|
if (first == null) return null;
|
||||||
final second = await _db.getTrip(b);
|
final second = await _db.getTrip(b);
|
||||||
if (second == null) return null;
|
if (second == null) return null;
|
||||||
if (first.isActive || second.isActive) {
|
if (first.isActive || second.isActive) {
|
||||||
// Refusing to merge an active trip.
|
// Refusing to merge an active trip.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selection order is not ride order.
|
// Selection order is not ride order.
|
||||||
final (survivor, absorbed) = first.startedAt <= second.startedAt
|
final (survivor, absorbed) = first.startedAt <= second.startedAt
|
||||||
? (first, second)
|
? (first, second)
|
||||||
: (second, first);
|
: (second, first);
|
||||||
|
|
||||||
await _db.reparentSegments(absorbed.id, survivor.id);
|
await _db.reparentSegments(absorbed.id, survivor.id);
|
||||||
await _db.reparentPoints(absorbed.id, survivor.id);
|
await _db.reparentPoints(absorbed.id, survivor.id);
|
||||||
await _db.deleteTrip(absorbed.id);
|
await _db.deleteTrip(absorbed.id);
|
||||||
|
|
||||||
final endedAt = (survivor.endedAt ?? 0) > (absorbed.endedAt ?? 0)
|
final endedAt = (survivor.endedAt ?? 0) > (absorbed.endedAt ?? 0)
|
||||||
? (survivor.endedAt ?? 0)
|
? (survivor.endedAt ?? 0)
|
||||||
: (absorbed.endedAt ?? 0);
|
: (absorbed.endedAt ?? 0);
|
||||||
await _db.closeTrip(survivor.id, endedAt, state: TripState.completed);
|
await _db.closeTrip(survivor.id, endedAt, state: TripState.completed);
|
||||||
if (survivor.name == null && absorbed.name != null) {
|
if (survivor.name == null && absorbed.name != null) {
|
||||||
await _db.renameTrip(survivor.id, absorbed.name);
|
await _db.renameTrip(survivor.id, absorbed.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
await recomputeAggregates(survivor.id);
|
await recomputeAggregates(survivor.id);
|
||||||
return survivor.id;
|
return survivor.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Recomputes a trip's stored totals from the points it actually owns.
|
/// Recomputes a trip's stored totals from the points it actually owns.
|
||||||
///
|
///
|
||||||
@@ -261,15 +266,14 @@ 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,
|
maxSpeedKmh: maxSpeedKmh,
|
||||||
maxSpeedKmh: maxSpeedKmh,
|
elevationGainM: elevationGainM,
|
||||||
elevationGainM: elevationGainM,
|
pointCount: pointCount,
|
||||||
pointCount: pointCount,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
// --- Internals -----------------------------------------------------------
|
// --- Internals -----------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -74,19 +74,18 @@ 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,
|
name: name ?? this.name,
|
||||||
name: name ?? this.name,
|
state: state ?? this.state,
|
||||||
state: state ?? this.state,
|
distanceM: distanceM ?? this.distanceM,
|
||||||
distanceM: distanceM ?? this.distanceM,
|
movingMillis: movingMillis ?? this.movingMillis,
|
||||||
movingMillis: movingMillis ?? this.movingMillis,
|
maxSpeedKmh: maxSpeedKmh ?? this.maxSpeedKmh,
|
||||||
maxSpeedKmh: maxSpeedKmh ?? this.maxSpeedKmh,
|
elevationGainM: elevationGainM ?? this.elevationGainM,
|
||||||
elevationGainM: elevationGainM ?? this.elevationGainM,
|
pointCount: pointCount ?? this.pointCount,
|
||||||
pointCount: pointCount ?? this.pointCount,
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One pause-free stretch of recording within a [Trip].
|
/// One pause-free stretch of recording within a [Trip].
|
||||||
|
|||||||
55
lib/src/export/export_manager.dart
Normal file
55
lib/src/export/export_manager.dart
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/// Ported from `com.rippr.export.ExportManager`.
|
||||||
|
///
|
||||||
|
/// Android's FileProvider plus an ACTION_SEND intent becomes `share_plus`, which handles
|
||||||
|
/// the equivalent plumbing on both platforms — including the iOS popover anchor that
|
||||||
|
/// iPad requires and that a naive port forgets.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
|
import '../domain/models.dart';
|
||||||
|
import '../ui/format.dart';
|
||||||
|
import 'ride_export.dart';
|
||||||
|
|
||||||
|
enum ExportFormat { gpx, geojson }
|
||||||
|
|
||||||
|
extension on ExportFormat {
|
||||||
|
String get extension => this == ExportFormat.gpx ? 'gpx' : 'geojson';
|
||||||
|
String get mime =>
|
||||||
|
this == ExportFormat.gpx ? 'application/gpx+xml' : 'application/geo+json';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the export to a temporary file and hands it to the system share sheet.
|
||||||
|
///
|
||||||
|
/// Files go to the app's temporary directory: they are a transfer artefact, not storage,
|
||||||
|
/// and the OS may reclaim them freely. The stored ride remains the source of truth.
|
||||||
|
Future<void> shareRide({
|
||||||
|
required Trip trip,
|
||||||
|
required List<Segment> segments,
|
||||||
|
required List<TrackPoint> points,
|
||||||
|
required ExportFormat format,
|
||||||
|
}) async {
|
||||||
|
final label = tripLabel(trip);
|
||||||
|
final contents = switch (format) {
|
||||||
|
ExportFormat.gpx => gpx(trip, segments, points, label),
|
||||||
|
ExportFormat.geojson => geoJson(trip, segments, points, label),
|
||||||
|
};
|
||||||
|
|
||||||
|
final dir = Directory('${(await getTemporaryDirectory()).path}/exports');
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
|
||||||
|
final name =
|
||||||
|
'rippr-${formatFileTimestamp(trip.startedAt)}.${format.extension}';
|
||||||
|
final file = File('${dir.path}/$name');
|
||||||
|
await file.writeAsString(contents);
|
||||||
|
|
||||||
|
await SharePlus.instance.share(
|
||||||
|
ShareParams(
|
||||||
|
files: [XFile(file.path, mimeType: format.mime)],
|
||||||
|
subject: label,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,7 +48,9 @@ String escapeXml(String text) {
|
|||||||
|
|
||||||
/// Segment order first, then any points whose segment is missing, so nothing is dropped.
|
/// 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
|
||||||
final coords = bySegment[segmentId]!
|
.map((segmentId) {
|
||||||
.map((p) =>
|
final coords = bySegment[segmentId]!
|
||||||
'[${_coord(p.longitude)}, ${_coord(p.latitude)}, ${_num(p.altitudeM)}]')
|
.map(
|
||||||
.join(', ');
|
(p) =>
|
||||||
return ''' {
|
'[${_coord(p.longitude)}, ${_coord(p.latitude)}, ${_num(p.altitudeM)}]',
|
||||||
|
)
|
||||||
|
.join(', ');
|
||||||
|
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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -68,15 +68,14 @@ 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.
|
onError: (Object _) {},
|
||||||
onError: (Object _) {},
|
cancelOnError: false,
|
||||||
cancelOnError: false,
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -153,12 +152,12 @@ class GeolocatorLocationSource implements LocationSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LocationFix _toFix(Position p) => LocationFix(
|
LocationFix _toFix(Position p) => LocationFix(
|
||||||
timestamp: p.timestamp.millisecondsSinceEpoch,
|
timestamp: p.timestamp.millisecondsSinceEpoch,
|
||||||
latitude: p.latitude,
|
latitude: p.latitude,
|
||||||
longitude: p.longitude,
|
longitude: p.longitude,
|
||||||
speedMps: p.speed,
|
speedMps: p.speed,
|
||||||
altitudeM: p.altitude,
|
altitudeM: p.altitude,
|
||||||
accuracyM: p.accuracy,
|
accuracyM: p.accuracy,
|
||||||
bearingDeg: p.heading,
|
bearingDeg: p.heading,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +155,15 @@ 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,
|
||||||
speedMps: speedMps,
|
speedMps: speedMps,
|
||||||
altitudeM: altitudeM,
|
altitudeM: altitudeM,
|
||||||
accuracyM: accuracyM,
|
accuracyM: accuracyM,
|
||||||
bearingDeg: bearingDeg,
|
bearingDeg: bearingDeg,
|
||||||
));
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,10 +55,10 @@ class RecordingEngine {
|
|||||||
required LocationSource locationSource,
|
required LocationSource locationSource,
|
||||||
LiveTelemetry? liveTelemetry,
|
LiveTelemetry? liveTelemetry,
|
||||||
int Function()? clock,
|
int Function()? clock,
|
||||||
}) : _repo = repository,
|
}) : _repo = repository,
|
||||||
_source = locationSource,
|
_source = locationSource,
|
||||||
_live = liveTelemetry ?? LiveTelemetry.instance,
|
_live = liveTelemetry ?? LiveTelemetry.instance,
|
||||||
_now = clock ?? (() => DateTime.now().millisecondsSinceEpoch);
|
_now = clock ?? (() => DateTime.now().millisecondsSinceEpoch);
|
||||||
|
|
||||||
final TripRepository _repo;
|
final TripRepository _repo;
|
||||||
final LocationSource _source;
|
final LocationSource _source;
|
||||||
@@ -265,20 +265,20 @@ class RecordingEngine {
|
|||||||
|
|
||||||
/// One flush cycle: take up to [flushSize] buffered points and write them.
|
/// One flush cycle: take up to [flushSize] buffered points and write them.
|
||||||
Future<void> _flush() => _writeLock.synchronized(() async {
|
Future<void> _flush() => _writeLock.synchronized(() async {
|
||||||
if (_pending.isEmpty) return;
|
if (_pending.isEmpty) return;
|
||||||
final take = _pending.length < flushSize ? _pending.length : flushSize;
|
final take = _pending.length < flushSize ? _pending.length : flushSize;
|
||||||
final batch = _pending.sublist(0, take);
|
final batch = _pending.sublist(0, take);
|
||||||
_pending.removeRange(0, take);
|
_pending.removeRange(0, take);
|
||||||
await _persist(batch);
|
await _persist(batch);
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Writes everything currently queued and returns once it has landed.
|
/// Writes everything currently queued and returns once it has landed.
|
||||||
Future<void> _drain() => _writeLock.synchronized(() async {
|
Future<void> _drain() => _writeLock.synchronized(() async {
|
||||||
if (_pending.isEmpty) return;
|
if (_pending.isEmpty) return;
|
||||||
final batch = List<TrackPoint>.from(_pending);
|
final batch = List<TrackPoint>.from(_pending);
|
||||||
_pending.clear();
|
_pending.clear();
|
||||||
await _persist(batch);
|
await _persist(batch);
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> _persist(List<TrackPoint> points) async {
|
Future<void> _persist(List<TrackPoint> points) async {
|
||||||
if (points.isEmpty) return;
|
if (points.isEmpty) return;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -42,22 +42,21 @@ 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(
|
||||||
'id': p.id,
|
(p) => <String, Object?>{
|
||||||
'trip_id': p.tripId,
|
'id': p.id,
|
||||||
'segment_id': p.segmentId,
|
'trip_id': p.tripId,
|
||||||
'ts': p.timestamp,
|
'segment_id': p.segmentId,
|
||||||
'lat': p.latitude,
|
'ts': p.timestamp,
|
||||||
'lon': p.longitude,
|
'lat': p.latitude,
|
||||||
'speed_kmh': p.speedKmh,
|
'lon': p.longitude,
|
||||||
'alt_m': p.altitudeM,
|
'speed_kmh': p.speedKmh,
|
||||||
'acc_m': p.accuracyM,
|
'alt_m': p.altitudeM,
|
||||||
'bearing': p.bearingDeg,
|
'acc_m': p.accuracyM,
|
||||||
})
|
'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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
213
lib/src/ui/components/ride_map.dart
Normal file
213
lib/src/ui/components/ride_map.dart
Normal 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)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,33 +79,73 @@ 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(
|
IconButton(
|
||||||
key: const Key('rename'),
|
key: const Key('export'),
|
||||||
icon: const Icon(Icons.edit_outlined),
|
icon: const Icon(Icons.ios_share),
|
||||||
onPressed: () => _rename(context, ref, async.value!.trip),
|
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) {
|
body: switch (async) {
|
||||||
AsyncValue(hasValue: true, value: null) => const EmptyState(
|
AsyncValue(hasValue: true, value: null) => const EmptyState(
|
||||||
message: 'This ride no longer exists.',
|
message: 'This ride no longer exists.',
|
||||||
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,29 +196,39 @@ 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),
|
||||||
child: Column(
|
child: Column(
|
||||||
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',
|
||||||
|
value: formatDuration(s.stoppedMillis),
|
||||||
|
),
|
||||||
|
StatRow(label: 'Max speed', value: formatSpeed(s.maxSpeedKmh)),
|
||||||
StatRow(
|
StatRow(
|
||||||
label: 'Max speed', value: formatSpeed(s.maxSpeedKmh)),
|
label: 'Avg moving speed',
|
||||||
|
value: formatSpeed(s.avgMovingSpeedKmh),
|
||||||
|
),
|
||||||
StatRow(
|
StatRow(
|
||||||
label: 'Avg moving speed',
|
label: 'Elevation gain',
|
||||||
value: formatSpeed(s.avgMovingSpeedKmh)),
|
value: formatElevation(s.elevationGainM),
|
||||||
|
),
|
||||||
StatRow(
|
StatRow(
|
||||||
label: 'Elevation gain',
|
label: 'Elevation loss',
|
||||||
value: formatElevation(s.elevationGainM)),
|
value: formatElevation(s.elevationLossM),
|
||||||
StatRow(
|
),
|
||||||
label: 'Elevation loss',
|
|
||||||
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}'),
|
||||||
],
|
],
|
||||||
@@ -193,16 +253,16 @@ class _SectionTitle extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Padding(
|
Widget build(BuildContext context) => Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
text.toUpperCase(),
|
text.toUpperCase(),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
color: Theme.of(context).colorScheme.outline,
|
color: Theme.of(context).colorScheme.outline,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Time in each speed band.
|
/// Time in each speed band.
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -21,50 +21,48 @@ abstract final class Routes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
GoRouter buildRouter() => GoRouter(
|
GoRouter buildRouter() => GoRouter(
|
||||||
initialLocation: Routes.record,
|
initialLocation: Routes.record,
|
||||||
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,
|
builder: (context, state) => TripsScreen(
|
||||||
builder: (context, state) => TripsScreen(
|
onOpenTrip: (id) => context.push(Routes.detailFor(id)),
|
||||||
onOpenTrip: (id) => context.push(Routes.detailFor(id)),
|
onBack: () => context.pop(),
|
||||||
onBack: () => context.pop(),
|
),
|
||||||
),
|
),
|
||||||
),
|
GoRoute(
|
||||||
GoRoute(
|
path: Routes.tripDetail,
|
||||||
path: Routes.tripDetail,
|
builder: (context, state) {
|
||||||
builder: (context, state) {
|
// Parsed explicitly. A malformed or missing id must land on a real screen
|
||||||
// Parsed explicitly. A malformed or missing id must land on a real screen
|
// saying so, never on a blank one or a crash.
|
||||||
// saying so, never on a blank one or a crash.
|
final id = int.tryParse(state.pathParameters['tripId'] ?? '');
|
||||||
final id = int.tryParse(state.pathParameters['tripId'] ?? '');
|
if (id == null) {
|
||||||
if (id == null) {
|
return const _NotFound();
|
||||||
return const _NotFound();
|
}
|
||||||
}
|
return TripDetailScreen(tripId: id, onBack: () => context.pop());
|
||||||
return TripDetailScreen(tripId: id, onBack: () => context.pop());
|
},
|
||||||
},
|
),
|
||||||
),
|
],
|
||||||
],
|
errorBuilder: (context, state) => const _NotFound(),
|
||||||
errorBuilder: (context, state) => const _NotFound(),
|
);
|
||||||
);
|
|
||||||
|
|
||||||
class _NotFound extends StatelessWidget {
|
class _NotFound extends StatelessWidget {
|
||||||
const _NotFound();
|
const _NotFound();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Scaffold(
|
Widget build(BuildContext context) => Scaffold(
|
||||||
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.')),
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,29 +110,28 @@ 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(
|
||||||
itemCount: trips.length,
|
itemCount: trips.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final trip = trips[i];
|
final trip = trips[i];
|
||||||
return _TripTile(
|
return _TripTile(
|
||||||
trip: trip,
|
trip: trip,
|
||||||
selected: live.contains(trip.id),
|
selected: live.contains(trip.id),
|
||||||
selecting: selecting,
|
selecting: selecting,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (selecting) {
|
if (selecting) {
|
||||||
_toggle(ref, trip.id);
|
_toggle(ref, trip.id);
|
||||||
} else {
|
} else {
|
||||||
onOpenTrip?.call(trip.id);
|
onOpenTrip?.call(trip.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongPress: () => _toggle(ref, trip.id),
|
onLongPress: () => _toggle(ref, trip.id),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_ => const Center(child: CircularProgressIndicator()),
|
_ => const Center(child: CircularProgressIndicator()),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -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
120
test/ride_map_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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')));
|
||||||
|
|||||||
Reference in New Issue
Block a user