/// 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 watchActiveTrip() => _db.watchActiveTrip(); Stream watchTrip(int id) => _db.watchTrip(id); Stream> watchCompletedTrips() => _db.watchCompletedTrips(); Future activeTrip() => _db.getActiveTrip(); Future tripById(int id) => _db.getTrip(id); Future> pointsForTrip(int id) => _db.pointsForTrip(id); Future> segmentsForTrip(int id) => _db.segmentsForTrip(id); Stream watchTripStats(int id) => _db.watchTripStats(id); /// The segment currently being written into, if a ride is active and not paused. Future 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 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 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 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 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 discardTrip() => _db.transaction(() async { final trip = await _db.getActiveTrip(); if (trip == null) return false; await _db.deleteTrip(trip.id); return true; }); // --- Management ---------------------------------------------------------- Future 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 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 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 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 appendPoints(List points) async { if (points.isEmpty) return; await _db.insertPoints(points); } Future lastPointInSegment(int segmentId) => _db.lastInSegment(segmentId); Future 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 _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); } }