T09: trip repository, completing Phase 2
All lifecycle transitions inside Drift transactions, each idempotent: start (adopting an active trip rather than duplicating it), pause, resume, complete, discard, rename, delete, merge, and the authoritative aggregate recomputation. Room.withTransaction maps onto Drift's transaction() almost exactly, so this was the most mechanical port so far. 26 tests, green first run. Merge keeps its hard-won properties: segments are never joined, so the boundary between two merged rides stays a segment boundary like a pause, and aggregates are recomputed rather than summed because distance is not additive across the gap. The regression test puts the two rides a degree of latitude apart and asserts the ~111km gap never reaches distanceM. Dropped a mergeTableUpdates helper before committing -- Drift's update() already notifies dependent streams, so it was scaffolding that earned nothing. TripRepositoryTest, MergeTest and SchemaTest were 666 lines of instrumented tests needing a booted emulator. All three now run as unit tests in ~2 seconds. Phase 2 done: 121 tests passing, analyze clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
243
lib/src/data/trip_repository.dart
Normal file
243
lib/src/data/trip_repository.dart
Normal file
@@ -0,0 +1,243 @@
|
||||
/// Owns every trip lifecycle transition.
|
||||
///
|
||||
/// Ported from `com.rippr.data.TripRepository`.
|
||||
///
|
||||
/// Recording state lives in the database rather than in memory. A row in `trips` with
|
||||
/// `endedAt IS NULL` *is* the fact of an in-progress ride, so it survives process death —
|
||||
/// which an in-memory flag could not. On Android the OS can restart the process with no
|
||||
/// intent, and on iOS the app can be suspended and resumed; in both cases a flag would
|
||||
/// come back `false` while a ride was genuinely underway.
|
||||
///
|
||||
/// Every transition is idempotent and safe to call from an unexpected state. The platform
|
||||
/// can restart the recorder at any moment, so calling resume on an already-recording trip
|
||||
/// must be a no-op rather than opening a duplicate segment.
|
||||
library;
|
||||
|
||||
import '../domain/models.dart';
|
||||
import '../stats/ride_statistics.dart';
|
||||
import 'database.dart';
|
||||
|
||||
/// The ids the recorder stamps onto each fix.
|
||||
class TripHandle {
|
||||
const TripHandle(this.tripId, this.segmentId);
|
||||
|
||||
final int tripId;
|
||||
final int segmentId;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is TripHandle &&
|
||||
other.tripId == tripId &&
|
||||
other.segmentId == segmentId;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(tripId, segmentId);
|
||||
|
||||
@override
|
||||
String toString() => 'TripHandle(trip: $tripId, segment: $segmentId)';
|
||||
}
|
||||
|
||||
class TripRepository {
|
||||
TripRepository(this._db);
|
||||
|
||||
final AppDatabase _db;
|
||||
|
||||
AppDatabase get db => _db;
|
||||
|
||||
// --- Reads ---------------------------------------------------------------
|
||||
|
||||
Stream<Trip?> watchActiveTrip() => _db.watchActiveTrip();
|
||||
|
||||
Stream<Trip?> watchTrip(int id) => _db.watchTrip(id);
|
||||
|
||||
Stream<List<Trip>> watchCompletedTrips() => _db.watchCompletedTrips();
|
||||
|
||||
Future<Trip?> activeTrip() => _db.getActiveTrip();
|
||||
|
||||
Future<Trip?> tripById(int id) => _db.getTrip(id);
|
||||
|
||||
Future<List<TrackPoint>> pointsForTrip(int id) => _db.pointsForTrip(id);
|
||||
|
||||
Future<List<Segment>> segmentsForTrip(int id) => _db.segmentsForTrip(id);
|
||||
|
||||
Stream<RideStats> watchTripStats(int id) => _db.watchTripStats(id);
|
||||
|
||||
/// The segment currently being written into, if a ride is active and not paused.
|
||||
Future<Segment?> openSegment() async {
|
||||
final trip = await _db.getActiveTrip();
|
||||
if (trip == null) return null;
|
||||
return _db.openSegment(trip.id);
|
||||
}
|
||||
|
||||
// --- Lifecycle -----------------------------------------------------------
|
||||
|
||||
/// Starts a ride, or adopts one already in progress.
|
||||
///
|
||||
/// Adoption rather than rejection is deliberate: after a process kill the trip row
|
||||
/// still exists, and the restarted recorder needs to continue it, not start a second.
|
||||
Future<TripHandle> startTrip(int now) => _db.transaction(() async {
|
||||
final existing = await _db.getActiveTrip();
|
||||
if (existing != null) {
|
||||
return _adoptOrOpenSegment(existing, now);
|
||||
}
|
||||
final tripId = await _db
|
||||
.insertTrip(Trip(startedAt: now, state: TripState.recording));
|
||||
final segmentId = await _db.insertSegment(tripId, now);
|
||||
return TripHandle(tripId, segmentId);
|
||||
});
|
||||
|
||||
/// Closes the open segment and marks the trip paused. The trip itself stays open —
|
||||
/// only [completeTrip] sets `endedAt`.
|
||||
Future<bool> pauseTrip(int now) => _db.transaction(() async {
|
||||
final trip = await _db.getActiveTrip();
|
||||
if (trip == null) return false;
|
||||
if (trip.state == TripState.paused) return true;
|
||||
|
||||
final open = await _db.openSegment(trip.id);
|
||||
if (open != null) await _db.closeSegment(open.id, now);
|
||||
await _db.setTripState(trip.id, TripState.paused);
|
||||
return true;
|
||||
});
|
||||
|
||||
/// Opens a fresh segment so the pause leaves a real gap in the recorded path.
|
||||
Future<TripHandle?> resumeTrip(int now) => _db.transaction(() async {
|
||||
final trip = await _db.getActiveTrip();
|
||||
if (trip == null) return null;
|
||||
final handle = await _adoptOrOpenSegment(trip, now);
|
||||
await _db.setTripState(trip.id, TripState.recording);
|
||||
return handle;
|
||||
});
|
||||
|
||||
Future<int?> completeTrip(int now) => _db.transaction(() async {
|
||||
final trip = await _db.getActiveTrip();
|
||||
if (trip == null) return null;
|
||||
final open = await _db.openSegment(trip.id);
|
||||
if (open != null) await _db.closeSegment(open.id, now);
|
||||
await _db.closeTrip(trip.id, now, state: TripState.completed);
|
||||
return trip.id;
|
||||
});
|
||||
|
||||
/// Deletes the active trip outright. Segments and points follow via CASCADE.
|
||||
Future<bool> discardTrip() => _db.transaction(() async {
|
||||
final trip = await _db.getActiveTrip();
|
||||
if (trip == null) return false;
|
||||
await _db.deleteTrip(trip.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
// --- Management ----------------------------------------------------------
|
||||
|
||||
Future<void> renameTrip(int id, String? name) {
|
||||
// Empty input must collapse to null, or the UI's "derive a label from the date"
|
||||
// branch and a stored "" would diverge.
|
||||
final trimmed = name?.trim();
|
||||
return _db.renameTrip(id, (trimmed == null || trimmed.isEmpty) ? null : trimmed);
|
||||
}
|
||||
|
||||
Future<void> deleteTrip(int id) => _db.deleteTrip(id);
|
||||
|
||||
/// Combines two completed rides into one.
|
||||
///
|
||||
/// The earlier trip survives, the later one's segments and points are re-parented onto
|
||||
/// it, and the later row is deleted. Segments are **never joined** — the boundary
|
||||
/// between the two rides becomes a segment boundary exactly like a pause, which is
|
||||
/// correct: the rider genuinely was not recording in between, and joining them would
|
||||
/// draw a straight line across the gap.
|
||||
///
|
||||
/// Aggregates are recomputed from scratch rather than summed, because distance is not
|
||||
/// additive across that gap.
|
||||
///
|
||||
/// Returns the surviving trip id, or null if the merge was rejected.
|
||||
Future<int?> mergeTrips(int a, int b) => _db.transaction(() async {
|
||||
if (a == b) return null;
|
||||
final first = await _db.getTrip(a);
|
||||
if (first == null) return null;
|
||||
final second = await _db.getTrip(b);
|
||||
if (second == null) return null;
|
||||
if (first.isActive || second.isActive) {
|
||||
// Refusing to merge an active trip.
|
||||
return null;
|
||||
}
|
||||
|
||||
// Selection order is not ride order.
|
||||
final (survivor, absorbed) = first.startedAt <= second.startedAt
|
||||
? (first, second)
|
||||
: (second, first);
|
||||
|
||||
await _db.reparentSegments(absorbed.id, survivor.id);
|
||||
await _db.reparentPoints(absorbed.id, survivor.id);
|
||||
await _db.deleteTrip(absorbed.id);
|
||||
|
||||
final endedAt = (survivor.endedAt ?? 0) > (absorbed.endedAt ?? 0)
|
||||
? (survivor.endedAt ?? 0)
|
||||
: (absorbed.endedAt ?? 0);
|
||||
await _db.closeTrip(survivor.id, endedAt, state: TripState.completed);
|
||||
if (survivor.name == null && absorbed.name != null) {
|
||||
await _db.renameTrip(survivor.id, absorbed.name);
|
||||
}
|
||||
|
||||
await recomputeAggregates(survivor.id);
|
||||
return survivor.id;
|
||||
});
|
||||
|
||||
/// Recomputes a trip's stored totals from the points it actually owns.
|
||||
///
|
||||
/// This is the authoritative pass. The recorder's live accumulation is an estimate, so
|
||||
/// running this on completion means a mid-ride process kill cannot leave permanently
|
||||
/// skewed totals.
|
||||
Future<void> recomputeAggregates(int tripId) async {
|
||||
final summary = computeSummary(
|
||||
await _db.pointsForTrip(tripId),
|
||||
segments: await _db.segmentsForTrip(tripId),
|
||||
);
|
||||
await _db.updateAggregates(
|
||||
id: tripId,
|
||||
distanceM: summary.distanceM,
|
||||
movingMillis: summary.movingMillis,
|
||||
maxSpeedKmh: summary.maxSpeedKmh,
|
||||
elevationGainM: summary.elevationGainM,
|
||||
pointCount: summary.pointCount,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Writes from the recorder --------------------------------------------
|
||||
|
||||
/// Appends a batch of fixes.
|
||||
///
|
||||
/// Points arrive already stamped with their trip and segment ids — see the recorder.
|
||||
/// They are **never** looked up at write time, so a fix still in flight when a pause
|
||||
/// happens lands in the segment it actually belongs to.
|
||||
Future<void> appendPoints(List<TrackPoint> points) async {
|
||||
if (points.isEmpty) return;
|
||||
await _db.insertPoints(points);
|
||||
}
|
||||
|
||||
Future<TrackPoint?> lastPointInSegment(int segmentId) =>
|
||||
_db.lastInSegment(segmentId);
|
||||
|
||||
Future<void> persistAggregates({
|
||||
required int tripId,
|
||||
required double distanceM,
|
||||
required int movingMillis,
|
||||
required double maxSpeedKmh,
|
||||
required double elevationGainM,
|
||||
required int pointCount,
|
||||
}) =>
|
||||
_db.updateAggregates(
|
||||
id: tripId,
|
||||
distanceM: distanceM,
|
||||
movingMillis: movingMillis,
|
||||
maxSpeedKmh: maxSpeedKmh,
|
||||
elevationGainM: elevationGainM,
|
||||
pointCount: pointCount,
|
||||
);
|
||||
|
||||
// --- Internals -----------------------------------------------------------
|
||||
|
||||
Future<TripHandle> _adoptOrOpenSegment(Trip trip, int now) async {
|
||||
final existing = await _db.openSegment(trip.id);
|
||||
if (existing != null) return TripHandle(trip.id, existing.id);
|
||||
final segmentId = await _db.insertSegment(trip.id, now);
|
||||
return TripHandle(trip.id, segmentId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user