/// Drift schema, mirroring the Room schema at /// `app/schemas/com.rippr.data.AppDatabase/2.json` in the native repo. /// /// Ported from `com.rippr.data.AppDatabase` plus the three DAOs. /// /// ## Two invariants carried over verbatim /// /// **No destructive migration, ever.** v2 removed `fallbackToDestructiveMigration()` /// because rides are real data. This starts at Dart schema version 1 with a real /// [MigrationStrategy] from day one; reinstating a destructive fallback would silently /// delete every stored ride on the next version bump. /// /// **Foreign keys must be switched on explicitly.** SQLite defaults `foreign_keys` to /// OFF. Room turned it on for us; Drift does not. Without the pragma in [beforeOpen] the /// `CASCADE` deletes below are decorative, and deleting a trip would silently orphan /// every one of its points. There is a test for exactly this. library; import 'package:drift/drift.dart'; import '../domain/models.dart' as domain; part 'database.g.dart'; /// One ride, from pressing Start to pressing Stop. /// /// The aggregate columns are denormalised on purpose — accumulated as points arrive and /// recomputed authoritatively on completion, so the trips list never touches the point /// table. @DataClassName('TripRow') @TableIndex(name: 'idx_trips_ended', columns: {#endedAt}) class Trips extends Table { @override String get tableName => 'trips'; IntColumn get id => integer().autoIncrement()(); IntColumn get startedAt => integer()(); /// Null while the ride is still active. This column *is* the fact of an in-progress /// ride, which is why recording state survives process death. IntColumn get endedAt => integer().nullable()(); /// Null means the UI derives a label from [startedAt]. Never store an empty string. TextColumn get name => text().nullable()(); TextColumn get state => textEnum()(); /// Defaults to motorcycle so existing rows survive the migration that adds this /// column unchanged — see [MigrationStrategy.onUpgrade] below. TextColumn get activity => textEnum().withDefault(const Constant('motorcycle'))(); RealColumn get distanceM => real().withDefault(const Constant(0))(); IntColumn get movingMillis => integer().withDefault(const Constant(0))(); RealColumn get maxSpeedKmh => real().withDefault(const Constant(0))(); RealColumn get elevationGainM => real().withDefault(const Constant(0))(); IntColumn get pointCount => integer().withDefault(const Constant(0))(); } /// One pause-free stretch of recording within a trip. /// /// This layer is what makes pause correct rather than cosmetic. @DataClassName('SegmentRow') @TableIndex(name: 'idx_segments_trip', columns: {#tripId}) class Segments extends Table { @override String get tableName => 'segments'; IntColumn get id => integer().autoIncrement()(); IntColumn get tripId => integer().references(Trips, #id, onDelete: KeyAction.cascade)(); IntColumn get startedAt => integer()(); /// Null while this segment is still being recorded into. IntColumn get endedAt => integer().nullable()(); } /// A single GPS fix. @DataClassName('TrackPointRow') @TableIndex(name: 'idx_points_trip', columns: {#tripId}) @TableIndex(name: 'idx_points_segment', columns: {#segmentId}) @TableIndex(name: 'idx_points_synced', columns: {#synced}) class TrackPoints extends Table { @override String get tableName => 'track_points'; IntColumn get id => integer().autoIncrement()(); IntColumn get tripId => integer().references(Trips, #id, onDelete: KeyAction.cascade)(); IntColumn get segmentId => integer().references(Segments, #id, onDelete: KeyAction.cascade)(); IntColumn get timestamp => integer()(); RealColumn get latitude => real()(); RealColumn get longitude => real()(); RealColumn get speedKmh => real()(); RealColumn get altitudeM => real()(); RealColumn get accuracyM => real().withDefault(const Constant(0))(); RealColumn get bearingDeg => real().withDefault(const Constant(0))(); /// Set once the point has been accepted by the remote endpoint. BoolColumn get synced => boolean().withDefault(const Constant(false))(); } /// A planned ride, sketched before setting off. See V3-07. Deliberately its own table, /// never joined into [Trips] or ride totals -- a plan is not a recording. @DataClassName('RoutePlanRow') class RoutePlans extends Table { @override String get tableName => 'route_plans'; IntColumn get id => integer().autoIncrement()(); TextColumn get name => text()(); IntColumn get createdAt => integer()(); TextColumn get activity => textEnum().withDefault(const Constant('motorcycle'))(); RealColumn get distanceM => real().withDefault(const Constant(0))(); /// Null until V3-08 fills it in from a routing engine. IntColumn get estimatedMillis => integer().nullable()(); /// Null in this ticket -- the polyline is derived from waypoints, not stored. V3-08 /// fills it with the road-snapped geometry, which is not cheaply re-derivable. TextColumn get geometry => text().nullable()(); /// Whether the route loops back to its first waypoint after the last one. See FB-05. BoolColumn get isClosedLoop => boolean().withDefault(const Constant(false))(); } /// One pin on a [RoutePlans] row. @DataClassName('WaypointRow') @TableIndex(name: 'idx_waypoints_route', columns: {#routeId}) class Waypoints extends Table { @override String get tableName => 'waypoints'; IntColumn get id => integer().autoIncrement()(); IntColumn get routeId => integer().references(RoutePlans, #id, onDelete: KeyAction.cascade)(); /// Position along the route, not insertion order -- see the domain doc comment. IntColumn get ordinal => integer()(); RealColumn get latitude => real()(); RealColumn get longitude => real()(); TextColumn get name => text().nullable()(); } @DriftDatabase(tables: [Trips, Segments, TrackPoints, RoutePlans, Waypoints]) class AppDatabase extends _$AppDatabase { AppDatabase(super.e); @override int get schemaVersion => 4; @override MigrationStrategy get migration => MigrationStrategy( onCreate: (m) => m.createAll(), // The port's first real migration. The destructive fallback the native app removed // in v2 stays removed here too — see the file doc comment above. onUpgrade: (m, from, to) async { if (from < 2) { await m.addColumn(trips, trips.activity); } // V3-07: brand new tables, so a plain create is enough -- nothing to backfill. if (from < 3) { await m.createTable(routePlans); await m.createTable(waypoints); } // FB-05: closed-loop toggle, defaults to false for every pre-existing route. // Guarded to `from >= 3` because a v2 -> v4+ upgrade just created `routePlans` // fresh (above) with every current column, `isClosedLoop` included -- adding it // again here would be a duplicate-column error. if (from >= 3 && from < 4) { await m.addColumn(routePlans, routePlans.isClosedLoop); } }, beforeOpen: (details) async { // Non-negotiable: without this the CASCADE relationships above do nothing. await customStatement('PRAGMA foreign_keys = ON'); // A ride is unrecoverable if a write is lost to a crash mid-flush, but full // sync on every insert at 2 Hz burns battery. WAL with NORMAL sync is the // standard compromise and survives app crashes; only an OS-level crash can // lose the last few points. await customStatement('PRAGMA journal_mode = WAL'); await customStatement('PRAGMA synchronous = NORMAL'); }, ); // --- Trips --------------------------------------------------------------- /// The in-progress ride, or null. This is the source of truth for "are we recording" — /// it survives process death, which an in-memory flag cannot. Stream watchActiveTrip() => (select(trips) ..where((t) => t.endedAt.isNull()) ..orderBy([(t) => OrderingTerm.desc(t.id)]) ..limit(1)) .watchSingleOrNull() .map((r) => r == null ? null : _toTrip(r)); Future getActiveTrip() async { final row = await (select(trips) ..where((t) => t.endedAt.isNull()) ..orderBy([(t) => OrderingTerm.desc(t.id)]) ..limit(1)) .getSingleOrNull(); return row == null ? null : _toTrip(row); } Stream> watchCompletedTrips() => (select(trips) ..where((t) => t.endedAt.isNotNull()) ..orderBy([(t) => OrderingTerm.desc(t.startedAt)])) .watch() .map((rows) => rows.map(_toTrip).toList()); Stream watchTrip(int id) => (select(trips)..where((t) => t.id.equals(id))).watchSingleOrNull().map( (r) => r == null ? null : _toTrip(r), ); Future getTrip(int id) async { final row = await (select( trips, )..where((t) => t.id.equals(id))).getSingleOrNull(); return row == null ? null : _toTrip(row); } Future insertTrip(domain.Trip trip) => into(trips).insert( TripsCompanion.insert( startedAt: trip.startedAt, endedAt: Value(trip.endedAt), name: Value(trip.name), state: trip.state, activity: Value(trip.activity), distanceM: Value(trip.distanceM), movingMillis: Value(trip.movingMillis), maxSpeedKmh: Value(trip.maxSpeedKmh), elevationGainM: Value(trip.elevationGainM), pointCount: Value(trip.pointCount), ), ); Future setTripActivity(int id, domain.Activity activity) => (update( trips, )..where((t) => t.id.equals(id))).write( TripsCompanion(activity: Value(activity)), ); /// The most recently created trip, regardless of state — used to default a new ride's /// activity to whatever was last used, so there is no picker in front of Start. Future mostRecentTrip() async { final row = await (select(trips) ..orderBy([(t) => OrderingTerm.desc(t.id)]) ..limit(1)) .getSingleOrNull(); return row == null ? null : _toTrip(row); } Future renameTrip(int id, String? name) => (update( trips, )..where((t) => t.id.equals(id))).write(TripsCompanion(name: Value(name))); Future setTripState(int id, domain.TripState state) => (update( trips, )..where((t) => t.id.equals(id))).write(TripsCompanion(state: Value(state))); Future closeTrip( int id, int endedAt, { domain.TripState state = domain.TripState.completed, }) => (update(trips)..where((t) => t.id.equals(id))).write( TripsCompanion(endedAt: Value(endedAt), state: Value(state)), ); /// Persists the running totals. Called once per writer flush (~every 2 s), so it stays /// a narrow targeted update rather than a full row rewrite. Future updateAggregates({ required int id, required double distanceM, required int movingMillis, required double maxSpeedKmh, required double elevationGainM, required int pointCount, }) => (update(trips)..where((t) => t.id.equals(id))).write( TripsCompanion( distanceM: Value(distanceM), movingMillis: Value(movingMillis), maxSpeedKmh: Value(maxSpeedKmh), elevationGainM: Value(elevationGainM), pointCount: Value(pointCount), ), ); /// Segments and points go with it via CASCADE. Future deleteTrip(int id) => (delete(trips)..where((t) => t.id.equals(id))).go(); Future countTrips() async => (await select(trips).get()).length; /// Test/maintenance helper. Segments and points follow via CASCADE. Future deleteAllTrips() => delete(trips).go(); // --- Segments ------------------------------------------------------------ Future insertSegment(int tripId, int startedAt) => into(segments) .insert(SegmentsCompanion.insert(tripId: tripId, startedAt: startedAt)); Future> segmentsForTrip(int tripId) async { final rows = await (select(segments) ..where((s) => s.tripId.equals(tripId)) ..orderBy([(s) => OrderingTerm.asc(s.id)])) .get(); return rows.map(_toSegment).toList(); } /// Feeds the live map (V3-04): segment boundaries are what turn a pause into a visible /// gap instead of a straight line drawn across the gap. Stream> watchSegmentsForTrip(int tripId) => (select(segments) ..where((s) => s.tripId.equals(tripId)) ..orderBy([(s) => OrderingTerm.asc(s.id)])) .watch() .map((rows) => rows.map(_toSegment).toList()); /// The segment currently being recorded into, if any. Future openSegment(int tripId) async { final row = await (select(segments) ..where((s) => s.tripId.equals(tripId) & s.endedAt.isNull()) ..orderBy([(s) => OrderingTerm.desc(s.id)]) ..limit(1)) .getSingleOrNull(); return row == null ? null : _toSegment(row); } Future closeSegment(int id, int endedAt) => (update(segments)..where((s) => s.id.equals(id))).write( SegmentsCompanion(endedAt: Value(endedAt)), ); /// Used by merge: re-parents a trip's segments onto the surviving trip. Future reparentSegments(int oldTripId, int newTripId) => (update(segments)..where((s) => s.tripId.equals(oldTripId))).write( SegmentsCompanion(tripId: Value(newTripId)), ); /// Used by split (V3-10): unlike [reparentSegments], moves exactly one segment rather /// than every segment on a trip. Future reparentSegment(int segmentId, int newTripId) => (update(segments)..where((s) => s.id.equals(segmentId))).write( SegmentsCompanion(tripId: Value(newTripId)), ); Future countSegmentsForTrip(int tripId) async => (await (select( segments, )..where((s) => s.tripId.equals(tripId))).get()).length; // --- Track points -------------------------------------------------------- /// Batched insert — the recorder buffers points and flushes them in groups. Future insertPoints(List points) async { if (points.isEmpty) return; await batch((b) { b.insertAll( trackPoints, points.map( (p) => TrackPointsCompanion.insert( tripId: p.tripId, segmentId: p.segmentId, timestamp: p.timestamp, latitude: p.latitude, longitude: p.longitude, speedKmh: p.speedKmh, altitudeM: p.altitudeM, accuracyM: Value(p.accuracyM), bearingDeg: Value(p.bearingDeg), synced: Value(p.synced), ), ), ); }); } Future insertPoint(domain.TrackPoint p) => into(trackPoints).insert( TrackPointsCompanion.insert( tripId: p.tripId, segmentId: p.segmentId, timestamp: p.timestamp, latitude: p.latitude, longitude: p.longitude, speedKmh: p.speedKmh, altitudeM: p.altitudeM, accuracyM: Value(p.accuracyM), bearingDeg: Value(p.bearingDeg), synced: Value(p.synced), ), ); /// Ordered by segment then id so consumers walk the ride in recording order with pause /// boundaries intact. **Not** ordered by timestamp: that value is GPS-derived and can /// jump, whereas id is monotonic in write order. Future> pointsForTrip(int tripId) async { final rows = await (select(trackPoints) ..where((p) => p.tripId.equals(tripId)) ..orderBy([ (p) => OrderingTerm.asc(p.segmentId), (p) => OrderingTerm.asc(p.id), ])) .get(); return rows.map(_toPoint).toList(); } /// Feeds the live map (V3-04). Updates on each writer flush (~2 s), not per fix — /// the stream is backed by the same table the batched writer flushes into, so there is /// nothing extra to throttle. Stream> watchPointsForTrip(int tripId) => (select(trackPoints) ..where((p) => p.tripId.equals(tripId)) ..orderBy([ (p) => OrderingTerm.asc(p.segmentId), (p) => OrderingTerm.asc(p.id), ])) .watch() .map((rows) => rows.map(_toPoint).toList()); Future> pointsForSegment(int segmentId) async { final rows = await (select(trackPoints) ..where((p) => p.segmentId.equals(segmentId)) ..orderBy([(p) => OrderingTerm.asc(p.id)])) .get(); return rows.map(_toPoint).toList(); } /// The anchor a restarted recorder needs to continue accumulating distance. Future lastInSegment(int segmentId) async { final row = await (select(trackPoints) ..where((p) => p.segmentId.equals(segmentId)) ..orderBy([(p) => OrderingTerm.desc(p.id)]) ..limit(1)) .getSingleOrNull(); return row == null ? null : _toPoint(row); } Future countPointsForTrip(int tripId) async => (await (select( trackPoints, )..where((p) => p.tripId.equals(tripId))).get()).length; /// Live stats for one trip. /// /// The COALESCE guards in the Kotlin original existed because Room throws on null for /// non-null fields; they are kept because the semantics are what matter — a trip with /// no points yet must read as zeros, not as an error. Stream watchTripStats(int tripId) { final q = customSelect( ''' SELECT COUNT(*) AS pointCount, COALESCE(MAX(speedKmh), 0.0) AS maxSpeedKmh, COALESCE(AVG(speedKmh), 0.0) AS avgSpeedKmh, COALESCE(MIN(timestamp), 0) AS firstTimestamp, COALESCE(MAX(timestamp), 0) AS lastTimestamp, COALESCE(SUM(CASE WHEN synced = 0 THEN 1 ELSE 0 END), 0) AS pendingUpload FROM track_points WHERE tripId = ? ''', variables: [Variable.withInt(tripId)], readsFrom: {trackPoints}, ); return q.watchSingle().map( (row) => domain.RideStats( pointCount: row.read('pointCount'), maxSpeedKmh: row.read('maxSpeedKmh'), avgSpeedKmh: row.read('avgSpeedKmh'), firstTimestamp: row.read('firstTimestamp'), lastTimestamp: row.read('lastTimestamp'), pendingUpload: row.read('pendingUpload'), ), ); } // --- Upload backlog ------------------------------------------------------ // Batches are drawn by id and can straddle a segment or trip boundary, which is why // the payload carries trip/segment identity per point rather than per batch. Future> unsyncedPoints(int limit) async { final rows = await (select(trackPoints) ..where((p) => p.synced.equals(false)) ..orderBy([(p) => OrderingTerm.asc(p.id)]) ..limit(limit)) .get(); return rows.map(_toPoint).toList(); } Future markSynced(List ids) async { if (ids.isEmpty) return; await (update(trackPoints)..where((p) => p.id.isIn(ids))).write( const TrackPointsCompanion(synced: Value(true)), ); } Future countUnsynced() async => (await (select( trackPoints, )..where((p) => p.synced.equals(false))).get()).length; // --- Maintenance --------------------------------------------------------- /// Used by merge: points carry tripId directly, so they re-parent alongside segments. Future reparentPoints(int oldTripId, int newTripId) => (update(trackPoints)..where((p) => p.tripId.equals(oldTripId))).write( TrackPointsCompanion(tripId: Value(newTripId)), ); /// Used by split (V3-10): moves only the points belonging to the given segments, not /// every point on the trip -- the counterpart to [reparentSegment]. Future reparentPointsForSegments(List segmentIds, int newTripId) { if (segmentIds.isEmpty) return Future.value(); return (update( trackPoints, )..where((p) => p.segmentId.isIn(segmentIds))).write( TrackPointsCompanion(tripId: Value(newTripId)), ); } Future> allPoints() async { final rows = await (select( trackPoints, )..orderBy([(p) => OrderingTerm.asc(p.id)])).get(); return rows.map(_toPoint).toList(); } // --- Route plans (V3-07) -------------------------------------------------- // Deliberately separate from every trip query above: a plan never appears in ride // totals or the rides list. Stream> watchRoutePlans() => (select(routePlans)..orderBy([(r) => OrderingTerm.desc(r.createdAt)])) .watch() .map((rows) => rows.map(_toRoutePlan).toList()); Stream watchRoutePlan(int id) => (select(routePlans)..where((r) => r.id.equals(id))) .watchSingleOrNull() .map((r) => r == null ? null : _toRoutePlan(r)); Future getRoutePlan(int id) async { final row = await (select( routePlans, )..where((r) => r.id.equals(id))).getSingleOrNull(); return row == null ? null : _toRoutePlan(row); } Future insertRoutePlan(domain.RoutePlan route) => into(routePlans).insert( RoutePlansCompanion.insert( name: route.name, createdAt: route.createdAt, activity: Value(route.activity), distanceM: Value(route.distanceM), estimatedMillis: Value(route.estimatedMillis), geometry: Value(route.geometry), isClosedLoop: Value(route.isClosedLoop), ), ); Future renameRoutePlan(int id, String name) => (update( routePlans, )..where((r) => r.id.equals(id))).write(RoutePlansCompanion(name: Value(name))); Future setRoutePlanDistance(int id, double distanceM) => (update( routePlans, )..where((r) => r.id.equals(id))).write( RoutePlansCompanion(distanceM: Value(distanceM)), ); Future setRoutePlanClosedLoop(int id, bool value) => (update( routePlans, )..where((r) => r.id.equals(id))).write( RoutePlansCompanion(isClosedLoop: Value(value)), ); /// Waypoints cascade with it. Future deleteRoutePlan(int id) => (delete(routePlans)..where((r) => r.id.equals(id))).go(); Stream> watchWaypoints(int routeId) => (select(waypoints) ..where((w) => w.routeId.equals(routeId)) ..orderBy([(w) => OrderingTerm.asc(w.ordinal)])) .watch() .map((rows) => rows.map(_toWaypoint).toList()); Future> waypointsForRoute(int routeId) async { final rows = await (select(waypoints) ..where((w) => w.routeId.equals(routeId)) ..orderBy([(w) => OrderingTerm.asc(w.ordinal)])) .get(); return rows.map(_toWaypoint).toList(); } Future insertWaypoint(domain.Waypoint waypoint) => into(waypoints).insert( WaypointsCompanion.insert( routeId: waypoint.routeId, ordinal: waypoint.ordinal, latitude: waypoint.latitude, longitude: waypoint.longitude, name: Value(waypoint.name), ), ); Future moveWaypoint(int id, double latitude, double longitude) => (update(waypoints)..where((w) => w.id.equals(id))).write( WaypointsCompanion(latitude: Value(latitude), longitude: Value(longitude)), ); Future setWaypointOrdinal(int id, int ordinal) => (update( waypoints, )..where((w) => w.id.equals(id))).write(WaypointsCompanion(ordinal: Value(ordinal))); Future deleteWaypoint(int id) => (delete(waypoints)..where((w) => w.id.equals(id))).go(); } domain.RoutePlan _toRoutePlan(RoutePlanRow r) => domain.RoutePlan( id: r.id, name: r.name, createdAt: r.createdAt, activity: r.activity, distanceM: r.distanceM, estimatedMillis: r.estimatedMillis, geometry: r.geometry, isClosedLoop: r.isClosedLoop, ); domain.Waypoint _toWaypoint(WaypointRow r) => domain.Waypoint( id: r.id, routeId: r.routeId, ordinal: r.ordinal, latitude: r.latitude, longitude: r.longitude, name: r.name, ); // --- Row → domain mapping --------------------------------------------------- // Kept as free functions rather than extension getters so the domain layer stays // entirely unaware that Drift exists. domain.Trip _toTrip(TripRow r) => domain.Trip( id: r.id, startedAt: r.startedAt, endedAt: r.endedAt, name: r.name, state: r.state, activity: r.activity, distanceM: r.distanceM, movingMillis: r.movingMillis, maxSpeedKmh: r.maxSpeedKmh, elevationGainM: r.elevationGainM, pointCount: r.pointCount, ); domain.Segment _toSegment(SegmentRow r) => domain.Segment( id: r.id, tripId: r.tripId, startedAt: r.startedAt, endedAt: r.endedAt, ); domain.TrackPoint _toPoint(TrackPointRow r) => domain.TrackPoint( id: r.id, tripId: r.tripId, segmentId: r.segmentId, timestamp: r.timestamp, latitude: r.latitude, longitude: r.longitude, speedKmh: r.speedKmh, altitudeM: r.altitudeM, accuracyM: r.accuracyM, bearingDeg: r.bearingDeg, synced: r.synced, );