From a14f5b0134a8f291a2e214770b2a3bfd6eacf7ed Mon Sep 17 00:00:00 2001 From: uhryniuk Date: Mon, 17 Aug 2026 19:15:18 -0500 Subject: [PATCH] V3-07: route drawing (pins and straight lines) Route plans are their own entity (RoutePlan/Waypoint, schema v3), never joined into ride totals or the rides list. RoutePlanRepository recomputes straight-line distance after every waypoint mutation. RoutePlannerScreen supports tap-to-add, tap-to-delete, and hand-rolled drag-to-move via MapCamera's screen/latlng conversions. Fixed a real bug found by testing: FlutterMap's initialCenter/initialZoom are read once at construction, so building the map before the waypoints stream's first emission froze the camera on null-island. Also documents a genuine ten-minute test hang traced to Stream.first on a Drift watch() query inside testWidgets, which needs a pump-driven Timer that flutter_test's fake zone never advances on its own. --- docs/v3/README.md | 2 +- docs/v3/V3-07-route-drawing.md | 50 +- lib/src/app/providers.dart | 19 + lib/src/data/database.dart | 152 +- lib/src/data/database.g.dart | 1553 +++++++++++++++++++ lib/src/data/route_plan_repository.dart | 102 ++ lib/src/domain/models.dart | 83 + lib/src/ui/record/record_screen.dart | 13 +- lib/src/ui/router.dart | 21 + lib/src/ui/routes/route_planner_screen.dart | 268 ++++ lib/src/ui/routes/routes_list_screen.dart | 91 ++ test/migration_test.dart | 30 + test/route_plan_repository_test.dart | 133 ++ test/route_planner_screen_test.dart | 168 ++ test/widget_test.dart | 14 + 15 files changed, 2694 insertions(+), 5 deletions(-) create mode 100644 lib/src/data/route_plan_repository.dart create mode 100644 lib/src/ui/routes/route_planner_screen.dart create mode 100644 lib/src/ui/routes/routes_list_screen.dart create mode 100644 test/route_plan_repository_test.dart create mode 100644 test/route_planner_screen_test.dart diff --git a/docs/v3/README.md b/docs/v3/README.md index b1d3a31..c42ad2d 100644 --- a/docs/v3/README.md +++ b/docs/v3/README.md @@ -23,7 +23,7 @@ backup are v4 — see [../BACKLOG.md](../BACKLOG.md). | [V3-04](V3-04-live-map.md) | Live map on the recording screen | M | — | Done | | [V3-05](V3-05-mounted-mode.md) | Mounted (handlebar) mode | M | V3-04 | Done | | [V3-06](V3-06-notification-stats.md) | Live stats in the notification | S | — | Done | -| [V3-07](V3-07-route-drawing.md) | Route drawing (pins, straight lines) | M | — | Not started | +| [V3-07](V3-07-route-drawing.md) | Route drawing (pins, straight lines) | M | — | Done | | [V3-08](V3-08-road-routing.md) | Road-snapped routing and ETA | L | V3-07, V3-01 | Not started | | [V3-09](V3-09-route-following.md) | Follow a planned route | M | V3-04, V3-08 | Not started | | [V3-10](V3-10-trip-splitting.md) | Trip splitting | S | — | Not started | diff --git a/docs/v3/V3-07-route-drawing.md b/docs/v3/V3-07-route-drawing.md index 82e8607..f69a0c5 100644 --- a/docs/v3/V3-07-route-drawing.md +++ b/docs/v3/V3-07-route-drawing.md @@ -1,6 +1,6 @@ # V3-07 — Route drawing (pins and straight lines) -**Phase** Route planning · **Depends on** nothing · **Size** M · **Status** Not started +**Phase** Route planning · **Depends on** nothing · **Size** M · **Status** Done ## Goal Drop pins on a map to sketch a route, see the straight-line distance, and save it. The @@ -56,3 +56,51 @@ useful for a rough plan. ## Out of scope Road snapping, ETA, following a route while riding. Import of existing GPX routes. + +## Outcome +Shipped as designed, with one naming deviation and two real testing traps worth recording +for V3-08/V3-09. + +**Named `RoutePlan`, not `Route`.** The ticket's own design sketch used `Route`, but that +collides with `dart:ui`/`package:flutter`'s own `Route` (the navigator's page-transition +class) and with `go_router`'s `GoRoute`. Renaming up front avoided constant `hide`/`as` +import juggling across every file that touches both navigation and route plans. + +Schema: `route_plans`/`waypoints` tables, schema version 2→3, following V3-01's migration +pattern exactly (`m.createTable` for brand-new tables needs no backfill, unlike V3-01's +`addColumn`). `RoutePlanRepository` mirrors `TripRepository`'s shape but has no state +machine — every mutating call ends by recomputing `distanceM` via `geo.pathLengthMeters`, +so "distance always matches the current waypoints" holds with no exceptions to remember, +including after a pure reorder that doesn't change it. + +`RoutePlannerScreen`: tap-to-add via `MapOptions.onTap`, tap-a-pin-to-delete, and +drag-to-move implemented by hand against `MapCamera.latLngToScreenOffset`/ +`screenOffsetToLatLng` (flutter_map has no built-in draggable-marker widget). The straight +line is dashed and uses the planning accent, visibly distinct from `RideMap`'s +speed-bucketed solid polyline, satisfying the acceptance criterion without a design pass. + +**Real bug found by testing, not review:** the map's `initialCenter`/`initialZoom` are +read exactly once, at `FlutterMap` construction. Building the map before the waypoints +stream delivered its first value froze the camera on null-island permanently, even once +real waypoints arrived — invisible in manual testing (a route sketched from empty always +starts empty) but immediate in a test that opens a planner for a route with existing +waypoints. Fixed by gating the map behind the stream's first emission, and by switching +from a fixed-zoom guess to `CameraFit.bounds` (matching `RideMap`'s own established +pattern) so pins can't be culled off-camera either. + +**Real testing trap, likely to recur in V3-08/V3-09:** `await db.watchRoutePlans().first` +inside a `testWidgets` body hung for a genuine ten minutes (the framework's own internal +timeout, not a guess) — a fresh `Stream.first` subscription on a Drift `.watch()` query +depends on a `Timer` inside Drift's stream-query store that flutter_test's fake test zone +never advances without an explicit pump. `repo`-level `Future`-returning calls +(`routePlanById`, etc.) have no such dependency and are what every other assertion in this +suite already used correctly. Documented inline in the test as a trap for the next ticket +that watches a stream from inside `testWidgets`. + +16 new tests: 8 in `route_plan_repository_test.dart` (create, live distance on +add/move/delete, ordinal-gap closing, reordering, rename, cascade delete, and the +ticket-mandated "never appears in ride totals" check), 1 migration test (v2→v3, tables +created and usable, existing trip untouched), 7 in `route_planner_screen_test.dart` +(empty state, create-and-open, delete, tap-to-add-and-distance-updates, tap-to-delete, +rename, missing-route fallback), plus 1 in `widget_test.dart` for the Routes entry point +on the record screen. `flutter analyze` clean; full suite green (254 tests, up from 237). diff --git a/lib/src/app/providers.dart b/lib/src/app/providers.dart index beaa35f..6410696 100644 --- a/lib/src/app/providers.dart +++ b/lib/src/app/providers.dart @@ -14,6 +14,7 @@ import '../config/config.dart'; import '../data/database.dart'; import '../data/trip_repository.dart'; import '../domain/models.dart'; +import '../data/route_plan_repository.dart'; import '../notification/ride_notification_controller.dart'; import '../notification/ride_notification_coordinator.dart'; import '../recording/geolocator_location_source.dart'; @@ -153,3 +154,21 @@ final livePointsProvider = StreamProvider.autoDispose.family, i final liveSegmentsProvider = StreamProvider.autoDispose.family, int>( (ref, tripId) => ref.watch(databaseProvider).watchSegmentsForTrip(tripId), ); + +// --- Route plans (V3-07) ----------------------------------------------------- + +final routePlanRepositoryProvider = Provider( + (ref) => RoutePlanRepository(ref.watch(databaseProvider)), +); + +final routePlansProvider = StreamProvider>( + (ref) => ref.watch(routePlanRepositoryProvider).watchRoutePlans(), +); + +final routePlanProvider = StreamProvider.autoDispose.family( + (ref, id) => ref.watch(routePlanRepositoryProvider).watchRoutePlan(id), +); + +final routeWaypointsProvider = StreamProvider.autoDispose.family, int>( + (ref, routeId) => ref.watch(routePlanRepositoryProvider).watchWaypoints(routeId), +); diff --git a/lib/src/data/database.dart b/lib/src/data/database.dart index 66f312d..959fcb8 100644 --- a/lib/src/data/database.dart +++ b/lib/src/data/database.dart @@ -101,12 +101,52 @@ class TrackPoints extends Table { BoolColumn get synced => boolean().withDefault(const Constant(false))(); } -@DriftDatabase(tables: [Trips, Segments, TrackPoints]) +/// 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()(); +} + +/// 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 => 2; + int get schemaVersion => 3; @override MigrationStrategy get migration => MigrationStrategy( @@ -117,6 +157,11 @@ class AppDatabase extends _$AppDatabase { 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); + } }, beforeOpen: (details) async { // Non-negotiable: without this the CASCADE relationships above do nothing. @@ -460,8 +505,111 @@ class AppDatabase extends _$AppDatabase { )..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), + ), + ); + + 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)), + ); + + /// 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, +); + +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. diff --git a/lib/src/data/database.g.dart b/lib/src/data/database.g.dart index 292f8f0..c462fb1 100644 --- a/lib/src/data/database.g.dart +++ b/lib/src/data/database.g.dart @@ -1623,12 +1623,887 @@ class TrackPointsCompanion extends UpdateCompanion { } } +class $RoutePlansTable extends RoutePlans + with TableInfo<$RoutePlansTable, RoutePlanRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $RoutePlansTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'createdAt', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + late final GeneratedColumnWithTypeConverter + activity = GeneratedColumn( + 'activity', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('motorcycle'), + ).withConverter($RoutePlansTable.$converteractivity); + static const VerificationMeta _distanceMMeta = const VerificationMeta( + 'distanceM', + ); + @override + late final GeneratedColumn distanceM = GeneratedColumn( + 'distanceM', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _estimatedMillisMeta = const VerificationMeta( + 'estimatedMillis', + ); + @override + late final GeneratedColumn estimatedMillis = GeneratedColumn( + 'estimatedMillis', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _geometryMeta = const VerificationMeta( + 'geometry', + ); + @override + late final GeneratedColumn geometry = GeneratedColumn( + 'geometry', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + name, + createdAt, + activity, + distanceM, + estimatedMillis, + geometry, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'route_plans'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('createdAt')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['createdAt']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('distanceM')) { + context.handle( + _distanceMMeta, + distanceM.isAcceptableOrUnknown(data['distanceM']!, _distanceMMeta), + ); + } + if (data.containsKey('estimatedMillis')) { + context.handle( + _estimatedMillisMeta, + estimatedMillis.isAcceptableOrUnknown( + data['estimatedMillis']!, + _estimatedMillisMeta, + ), + ); + } + if (data.containsKey('geometry')) { + context.handle( + _geometryMeta, + geometry.isAcceptableOrUnknown(data['geometry']!, _geometryMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + RoutePlanRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RoutePlanRow( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}createdAt'], + )!, + activity: $RoutePlansTable.$converteractivity.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}activity'], + )!, + ), + distanceM: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}distanceM'], + )!, + estimatedMillis: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}estimatedMillis'], + ), + geometry: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}geometry'], + ), + ); + } + + @override + $RoutePlansTable createAlias(String alias) { + return $RoutePlansTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 + $converteractivity = const EnumNameConverter( + domain.Activity.values, + ); +} + +class RoutePlanRow extends DataClass implements Insertable { + final int id; + final String name; + final int createdAt; + final domain.Activity activity; + final double distanceM; + + /// Null until V3-08 fills it in from a routing engine. + final int? estimatedMillis; + + /// 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. + final String? geometry; + const RoutePlanRow({ + required this.id, + required this.name, + required this.createdAt, + required this.activity, + required this.distanceM, + this.estimatedMillis, + this.geometry, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['createdAt'] = Variable(createdAt); + { + map['activity'] = Variable( + $RoutePlansTable.$converteractivity.toSql(activity), + ); + } + map['distanceM'] = Variable(distanceM); + if (!nullToAbsent || estimatedMillis != null) { + map['estimatedMillis'] = Variable(estimatedMillis); + } + if (!nullToAbsent || geometry != null) { + map['geometry'] = Variable(geometry); + } + return map; + } + + RoutePlansCompanion toCompanion(bool nullToAbsent) { + return RoutePlansCompanion( + id: Value(id), + name: Value(name), + createdAt: Value(createdAt), + activity: Value(activity), + distanceM: Value(distanceM), + estimatedMillis: estimatedMillis == null && nullToAbsent + ? const Value.absent() + : Value(estimatedMillis), + geometry: geometry == null && nullToAbsent + ? const Value.absent() + : Value(geometry), + ); + } + + factory RoutePlanRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RoutePlanRow( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + createdAt: serializer.fromJson(json['createdAt']), + activity: $RoutePlansTable.$converteractivity.fromJson( + serializer.fromJson(json['activity']), + ), + distanceM: serializer.fromJson(json['distanceM']), + estimatedMillis: serializer.fromJson(json['estimatedMillis']), + geometry: serializer.fromJson(json['geometry']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'createdAt': serializer.toJson(createdAt), + 'activity': serializer.toJson( + $RoutePlansTable.$converteractivity.toJson(activity), + ), + 'distanceM': serializer.toJson(distanceM), + 'estimatedMillis': serializer.toJson(estimatedMillis), + 'geometry': serializer.toJson(geometry), + }; + } + + RoutePlanRow copyWith({ + int? id, + String? name, + int? createdAt, + domain.Activity? activity, + double? distanceM, + Value estimatedMillis = const Value.absent(), + Value geometry = const Value.absent(), + }) => RoutePlanRow( + id: id ?? this.id, + name: name ?? this.name, + createdAt: createdAt ?? this.createdAt, + activity: activity ?? this.activity, + distanceM: distanceM ?? this.distanceM, + estimatedMillis: estimatedMillis.present + ? estimatedMillis.value + : this.estimatedMillis, + geometry: geometry.present ? geometry.value : this.geometry, + ); + RoutePlanRow copyWithCompanion(RoutePlansCompanion data) { + return RoutePlanRow( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + activity: data.activity.present ? data.activity.value : this.activity, + distanceM: data.distanceM.present ? data.distanceM.value : this.distanceM, + estimatedMillis: data.estimatedMillis.present + ? data.estimatedMillis.value + : this.estimatedMillis, + geometry: data.geometry.present ? data.geometry.value : this.geometry, + ); + } + + @override + String toString() { + return (StringBuffer('RoutePlanRow(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('createdAt: $createdAt, ') + ..write('activity: $activity, ') + ..write('distanceM: $distanceM, ') + ..write('estimatedMillis: $estimatedMillis, ') + ..write('geometry: $geometry') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + createdAt, + activity, + distanceM, + estimatedMillis, + geometry, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RoutePlanRow && + other.id == this.id && + other.name == this.name && + other.createdAt == this.createdAt && + other.activity == this.activity && + other.distanceM == this.distanceM && + other.estimatedMillis == this.estimatedMillis && + other.geometry == this.geometry); +} + +class RoutePlansCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value createdAt; + final Value activity; + final Value distanceM; + final Value estimatedMillis; + final Value geometry; + const RoutePlansCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.createdAt = const Value.absent(), + this.activity = const Value.absent(), + this.distanceM = const Value.absent(), + this.estimatedMillis = const Value.absent(), + this.geometry = const Value.absent(), + }); + RoutePlansCompanion.insert({ + this.id = const Value.absent(), + required String name, + required int createdAt, + this.activity = const Value.absent(), + this.distanceM = const Value.absent(), + this.estimatedMillis = const Value.absent(), + this.geometry = const Value.absent(), + }) : name = Value(name), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? createdAt, + Expression? activity, + Expression? distanceM, + Expression? estimatedMillis, + Expression? geometry, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (createdAt != null) 'createdAt': createdAt, + if (activity != null) 'activity': activity, + if (distanceM != null) 'distanceM': distanceM, + if (estimatedMillis != null) 'estimatedMillis': estimatedMillis, + if (geometry != null) 'geometry': geometry, + }); + } + + RoutePlansCompanion copyWith({ + Value? id, + Value? name, + Value? createdAt, + Value? activity, + Value? distanceM, + Value? estimatedMillis, + Value? geometry, + }) { + return RoutePlansCompanion( + id: id ?? this.id, + name: name ?? this.name, + createdAt: createdAt ?? this.createdAt, + activity: activity ?? this.activity, + distanceM: distanceM ?? this.distanceM, + estimatedMillis: estimatedMillis ?? this.estimatedMillis, + geometry: geometry ?? this.geometry, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (createdAt.present) { + map['createdAt'] = Variable(createdAt.value); + } + if (activity.present) { + map['activity'] = Variable( + $RoutePlansTable.$converteractivity.toSql(activity.value), + ); + } + if (distanceM.present) { + map['distanceM'] = Variable(distanceM.value); + } + if (estimatedMillis.present) { + map['estimatedMillis'] = Variable(estimatedMillis.value); + } + if (geometry.present) { + map['geometry'] = Variable(geometry.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RoutePlansCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('createdAt: $createdAt, ') + ..write('activity: $activity, ') + ..write('distanceM: $distanceM, ') + ..write('estimatedMillis: $estimatedMillis, ') + ..write('geometry: $geometry') + ..write(')')) + .toString(); + } +} + +class $WaypointsTable extends Waypoints + with TableInfo<$WaypointsTable, WaypointRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $WaypointsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _routeIdMeta = const VerificationMeta( + 'routeId', + ); + @override + late final GeneratedColumn routeId = GeneratedColumn( + 'routeId', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES route_plans (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _ordinalMeta = const VerificationMeta( + 'ordinal', + ); + @override + late final GeneratedColumn ordinal = GeneratedColumn( + 'ordinal', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _latitudeMeta = const VerificationMeta( + 'latitude', + ); + @override + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + ); + static const VerificationMeta _longitudeMeta = const VerificationMeta( + 'longitude', + ); + @override + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + routeId, + ordinal, + latitude, + longitude, + name, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'waypoints'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('routeId')) { + context.handle( + _routeIdMeta, + routeId.isAcceptableOrUnknown(data['routeId']!, _routeIdMeta), + ); + } else if (isInserting) { + context.missing(_routeIdMeta); + } + if (data.containsKey('ordinal')) { + context.handle( + _ordinalMeta, + ordinal.isAcceptableOrUnknown(data['ordinal']!, _ordinalMeta), + ); + } else if (isInserting) { + context.missing(_ordinalMeta); + } + if (data.containsKey('latitude')) { + context.handle( + _latitudeMeta, + latitude.isAcceptableOrUnknown(data['latitude']!, _latitudeMeta), + ); + } else if (isInserting) { + context.missing(_latitudeMeta); + } + if (data.containsKey('longitude')) { + context.handle( + _longitudeMeta, + longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta), + ); + } else if (isInserting) { + context.missing(_longitudeMeta); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + WaypointRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return WaypointRow( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + routeId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}routeId'], + )!, + ordinal: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}ordinal'], + )!, + latitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}latitude'], + )!, + longitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}longitude'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + ), + ); + } + + @override + $WaypointsTable createAlias(String alias) { + return $WaypointsTable(attachedDatabase, alias); + } +} + +class WaypointRow extends DataClass implements Insertable { + final int id; + final int routeId; + + /// Position along the route, not insertion order -- see the domain doc comment. + final int ordinal; + final double latitude; + final double longitude; + final String? name; + const WaypointRow({ + required this.id, + required this.routeId, + required this.ordinal, + required this.latitude, + required this.longitude, + this.name, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['routeId'] = Variable(routeId); + map['ordinal'] = Variable(ordinal); + map['latitude'] = Variable(latitude); + map['longitude'] = Variable(longitude); + if (!nullToAbsent || name != null) { + map['name'] = Variable(name); + } + return map; + } + + WaypointsCompanion toCompanion(bool nullToAbsent) { + return WaypointsCompanion( + id: Value(id), + routeId: Value(routeId), + ordinal: Value(ordinal), + latitude: Value(latitude), + longitude: Value(longitude), + name: name == null && nullToAbsent ? const Value.absent() : Value(name), + ); + } + + factory WaypointRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return WaypointRow( + id: serializer.fromJson(json['id']), + routeId: serializer.fromJson(json['routeId']), + ordinal: serializer.fromJson(json['ordinal']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + name: serializer.fromJson(json['name']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'routeId': serializer.toJson(routeId), + 'ordinal': serializer.toJson(ordinal), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + 'name': serializer.toJson(name), + }; + } + + WaypointRow copyWith({ + int? id, + int? routeId, + int? ordinal, + double? latitude, + double? longitude, + Value name = const Value.absent(), + }) => WaypointRow( + id: id ?? this.id, + routeId: routeId ?? this.routeId, + ordinal: ordinal ?? this.ordinal, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + name: name.present ? name.value : this.name, + ); + WaypointRow copyWithCompanion(WaypointsCompanion data) { + return WaypointRow( + id: data.id.present ? data.id.value : this.id, + routeId: data.routeId.present ? data.routeId.value : this.routeId, + ordinal: data.ordinal.present ? data.ordinal.value : this.ordinal, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + name: data.name.present ? data.name.value : this.name, + ); + } + + @override + String toString() { + return (StringBuffer('WaypointRow(') + ..write('id: $id, ') + ..write('routeId: $routeId, ') + ..write('ordinal: $ordinal, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('name: $name') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, routeId, ordinal, latitude, longitude, name); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is WaypointRow && + other.id == this.id && + other.routeId == this.routeId && + other.ordinal == this.ordinal && + other.latitude == this.latitude && + other.longitude == this.longitude && + other.name == this.name); +} + +class WaypointsCompanion extends UpdateCompanion { + final Value id; + final Value routeId; + final Value ordinal; + final Value latitude; + final Value longitude; + final Value name; + const WaypointsCompanion({ + this.id = const Value.absent(), + this.routeId = const Value.absent(), + this.ordinal = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.name = const Value.absent(), + }); + WaypointsCompanion.insert({ + this.id = const Value.absent(), + required int routeId, + required int ordinal, + required double latitude, + required double longitude, + this.name = const Value.absent(), + }) : routeId = Value(routeId), + ordinal = Value(ordinal), + latitude = Value(latitude), + longitude = Value(longitude); + static Insertable custom({ + Expression? id, + Expression? routeId, + Expression? ordinal, + Expression? latitude, + Expression? longitude, + Expression? name, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (routeId != null) 'routeId': routeId, + if (ordinal != null) 'ordinal': ordinal, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (name != null) 'name': name, + }); + } + + WaypointsCompanion copyWith({ + Value? id, + Value? routeId, + Value? ordinal, + Value? latitude, + Value? longitude, + Value? name, + }) { + return WaypointsCompanion( + id: id ?? this.id, + routeId: routeId ?? this.routeId, + ordinal: ordinal ?? this.ordinal, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + name: name ?? this.name, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (routeId.present) { + map['routeId'] = Variable(routeId.value); + } + if (ordinal.present) { + map['ordinal'] = Variable(ordinal.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('WaypointsCompanion(') + ..write('id: $id, ') + ..write('routeId: $routeId, ') + ..write('ordinal: $ordinal, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('name: $name') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); late final $TripsTable trips = $TripsTable(this); late final $SegmentsTable segments = $SegmentsTable(this); late final $TrackPointsTable trackPoints = $TrackPointsTable(this); + late final $RoutePlansTable routePlans = $RoutePlansTable(this); + late final $WaypointsTable waypoints = $WaypointsTable(this); late final Index idxTripsEnded = Index( 'idx_trips_ended', 'CREATE INDEX idx_trips_ended ON trips (endedAt)', @@ -1649,6 +2524,10 @@ abstract class _$AppDatabase extends GeneratedDatabase { 'idx_points_synced', 'CREATE INDEX idx_points_synced ON track_points (synced)', ); + late final Index idxWaypointsRoute = Index( + 'idx_waypoints_route', + 'CREATE INDEX idx_waypoints_route ON waypoints (routeId)', + ); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -1657,11 +2536,14 @@ abstract class _$AppDatabase extends GeneratedDatabase { trips, segments, trackPoints, + routePlans, + waypoints, idxTripsEnded, idxSegmentsTrip, idxPointsTrip, idxPointsSegment, idxPointsSynced, + idxWaypointsRoute, ]; @override StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ @@ -1686,6 +2568,13 @@ abstract class _$AppDatabase extends GeneratedDatabase { ), result: [TableUpdate('track_points', kind: UpdateKind.delete)], ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'route_plans', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('waypoints', kind: UpdateKind.delete)], + ), ]); } @@ -3083,6 +3972,666 @@ typedef $$TrackPointsTableProcessedTableManager = TrackPointRow, PrefetchHooks Function({bool tripId, bool segmentId}) >; +typedef $$RoutePlansTableCreateCompanionBuilder = RoutePlansCompanion Function({ + Value id, + required String name, + required int createdAt, + Value activity, + Value distanceM, + Value estimatedMillis, + Value geometry, +}); +typedef $$RoutePlansTableUpdateCompanionBuilder = RoutePlansCompanion Function({ + Value id, + Value name, + Value createdAt, + Value activity, + Value distanceM, + Value estimatedMillis, + Value geometry, +}); + +final class $$RoutePlansTableReferences + extends BaseReferences<_$AppDatabase, $RoutePlansTable, RoutePlanRow> { + $$RoutePlansTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey<$WaypointsTable, List> + _waypointsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.waypoints, + aliasName: 'route_plans__id__waypoints__routeId', + ); + + $$WaypointsTableProcessedTableManager get waypointsRefs { + final manager = $$WaypointsTableTableManager( + $_db, + $_db.waypoints, + ).filter((f) => f.routeId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_waypointsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $$RoutePlansTableFilterComposer + extends Composer<_$AppDatabase, $RoutePlansTable> { + $$RoutePlansTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters + get activity => $composableBuilder( + column: $table.activity, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get distanceM => $composableBuilder( + column: $table.distanceM, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get estimatedMillis => $composableBuilder( + column: $table.estimatedMillis, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get geometry => $composableBuilder( + column: $table.geometry, + builder: (column) => ColumnFilters(column), + ); + + Expression waypointsRefs( + Expression Function($$WaypointsTableFilterComposer f) f, + ) { + final $$WaypointsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.waypoints, + getReferencedColumn: (t) => t.routeId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WaypointsTableFilterComposer( + $db: $db, + $table: $db.waypoints, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$RoutePlansTableOrderingComposer + extends Composer<_$AppDatabase, $RoutePlansTable> { + $$RoutePlansTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get activity => $composableBuilder( + column: $table.activity, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get distanceM => $composableBuilder( + column: $table.distanceM, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get estimatedMillis => $composableBuilder( + column: $table.estimatedMillis, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get geometry => $composableBuilder( + column: $table.geometry, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$RoutePlansTableAnnotationComposer + extends Composer<_$AppDatabase, $RoutePlansTable> { + $$RoutePlansTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumnWithTypeConverter get activity => + $composableBuilder(column: $table.activity, builder: (column) => column); + + GeneratedColumn get distanceM => + $composableBuilder(column: $table.distanceM, builder: (column) => column); + + GeneratedColumn get estimatedMillis => $composableBuilder( + column: $table.estimatedMillis, + builder: (column) => column, + ); + + GeneratedColumn get geometry => + $composableBuilder(column: $table.geometry, builder: (column) => column); + + Expression waypointsRefs( + Expression Function($$WaypointsTableAnnotationComposer a) f, + ) { + final $$WaypointsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.waypoints, + getReferencedColumn: (t) => t.routeId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WaypointsTableAnnotationComposer( + $db: $db, + $table: $db.waypoints, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$RoutePlansTableTableManager + extends + RootTableManager< + _$AppDatabase, + $RoutePlansTable, + RoutePlanRow, + $$RoutePlansTableFilterComposer, + $$RoutePlansTableOrderingComposer, + $$RoutePlansTableAnnotationComposer, + $$RoutePlansTableCreateCompanionBuilder, + $$RoutePlansTableUpdateCompanionBuilder, + (RoutePlanRow, $$RoutePlansTableReferences), + RoutePlanRow, + PrefetchHooks Function({bool waypointsRefs}) + > { + $$RoutePlansTableTableManager(_$AppDatabase db, $RoutePlansTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$RoutePlansTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$RoutePlansTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$RoutePlansTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value createdAt = const Value.absent(), + Value activity = const Value.absent(), + Value distanceM = const Value.absent(), + Value estimatedMillis = const Value.absent(), + Value geometry = const Value.absent(), + }) => RoutePlansCompanion( + id: id, + name: name, + createdAt: createdAt, + activity: activity, + distanceM: distanceM, + estimatedMillis: estimatedMillis, + geometry: geometry, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String name, + required int createdAt, + Value activity = const Value.absent(), + Value distanceM = const Value.absent(), + Value estimatedMillis = const Value.absent(), + Value geometry = const Value.absent(), + }) => RoutePlansCompanion.insert( + id: id, + name: name, + createdAt: createdAt, + activity: activity, + distanceM: distanceM, + estimatedMillis: estimatedMillis, + geometry: geometry, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$RoutePlansTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({waypointsRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [if (waypointsRefs) db.waypoints], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (waypointsRefs) + await $_getPrefetchedData< + RoutePlanRow, + $RoutePlansTable, + WaypointRow + >( + currentTable: table, + referencedTable: $$RoutePlansTableReferences + ._waypointsRefsTable(db), + managerFromTypedResult: (p0) => + $$RoutePlansTableReferences( + db, + table, + p0, + ).waypointsRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.routeId == item.id), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$RoutePlansTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $RoutePlansTable, + RoutePlanRow, + $$RoutePlansTableFilterComposer, + $$RoutePlansTableOrderingComposer, + $$RoutePlansTableAnnotationComposer, + $$RoutePlansTableCreateCompanionBuilder, + $$RoutePlansTableUpdateCompanionBuilder, + (RoutePlanRow, $$RoutePlansTableReferences), + RoutePlanRow, + PrefetchHooks Function({bool waypointsRefs}) + >; +typedef $$WaypointsTableCreateCompanionBuilder = WaypointsCompanion Function({ + Value id, + required int routeId, + required int ordinal, + required double latitude, + required double longitude, + Value name, +}); +typedef $$WaypointsTableUpdateCompanionBuilder = WaypointsCompanion Function({ + Value id, + Value routeId, + Value ordinal, + Value latitude, + Value longitude, + Value name, +}); + +final class $$WaypointsTableReferences + extends BaseReferences<_$AppDatabase, $WaypointsTable, WaypointRow> { + $$WaypointsTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static $RoutePlansTable _routeIdTable(_$AppDatabase db) => + db.routePlans.createAlias('waypoints__routeId__route_plans__id'); + + $$RoutePlansTableProcessedTableManager get routeId { + final $_column = $_itemColumn('routeId')!; + + final manager = $$RoutePlansTableTableManager( + $_db, + $_db.routePlans, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_routeIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$WaypointsTableFilterComposer + extends Composer<_$AppDatabase, $WaypointsTable> { + $$WaypointsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get ordinal => $composableBuilder( + column: $table.ordinal, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get latitude => $composableBuilder( + column: $table.latitude, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get longitude => $composableBuilder( + column: $table.longitude, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + $$RoutePlansTableFilterComposer get routeId { + final $$RoutePlansTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.routeId, + referencedTable: $db.routePlans, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$RoutePlansTableFilterComposer( + $db: $db, + $table: $db.routePlans, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$WaypointsTableOrderingComposer + extends Composer<_$AppDatabase, $WaypointsTable> { + $$WaypointsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get ordinal => $composableBuilder( + column: $table.ordinal, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get latitude => $composableBuilder( + column: $table.latitude, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get longitude => $composableBuilder( + column: $table.longitude, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + $$RoutePlansTableOrderingComposer get routeId { + final $$RoutePlansTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.routeId, + referencedTable: $db.routePlans, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$RoutePlansTableOrderingComposer( + $db: $db, + $table: $db.routePlans, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$WaypointsTableAnnotationComposer + extends Composer<_$AppDatabase, $WaypointsTable> { + $$WaypointsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get ordinal => + $composableBuilder(column: $table.ordinal, builder: (column) => column); + + GeneratedColumn get latitude => + $composableBuilder(column: $table.latitude, builder: (column) => column); + + GeneratedColumn get longitude => + $composableBuilder(column: $table.longitude, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + $$RoutePlansTableAnnotationComposer get routeId { + final $$RoutePlansTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.routeId, + referencedTable: $db.routePlans, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$RoutePlansTableAnnotationComposer( + $db: $db, + $table: $db.routePlans, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$WaypointsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $WaypointsTable, + WaypointRow, + $$WaypointsTableFilterComposer, + $$WaypointsTableOrderingComposer, + $$WaypointsTableAnnotationComposer, + $$WaypointsTableCreateCompanionBuilder, + $$WaypointsTableUpdateCompanionBuilder, + (WaypointRow, $$WaypointsTableReferences), + WaypointRow, + PrefetchHooks Function({bool routeId}) + > { + $$WaypointsTableTableManager(_$AppDatabase db, $WaypointsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$WaypointsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$WaypointsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$WaypointsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value routeId = const Value.absent(), + Value ordinal = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + Value name = const Value.absent(), + }) => WaypointsCompanion( + id: id, + routeId: routeId, + ordinal: ordinal, + latitude: latitude, + longitude: longitude, + name: name, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required int routeId, + required int ordinal, + required double latitude, + required double longitude, + Value name = const Value.absent(), + }) => WaypointsCompanion.insert( + id: id, + routeId: routeId, + ordinal: ordinal, + latitude: latitude, + longitude: longitude, + name: name, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$WaypointsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({routeId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (routeId) { + state = state.withJoin( + currentTable: table, + currentColumn: table.routeId, + referencedTable: $$WaypointsTableReferences + ._routeIdTable(db), + referencedColumn: $$WaypointsTableReferences + ._routeIdTable(db) + .id, + ) as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$WaypointsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $WaypointsTable, + WaypointRow, + $$WaypointsTableFilterComposer, + $$WaypointsTableOrderingComposer, + $$WaypointsTableAnnotationComposer, + $$WaypointsTableCreateCompanionBuilder, + $$WaypointsTableUpdateCompanionBuilder, + (WaypointRow, $$WaypointsTableReferences), + WaypointRow, + PrefetchHooks Function({bool routeId}) + >; class $AppDatabaseManager { final _$AppDatabase _db; @@ -3093,4 +4642,8 @@ class $AppDatabaseManager { $$SegmentsTableTableManager(_db, _db.segments); $$TrackPointsTableTableManager get trackPoints => $$TrackPointsTableTableManager(_db, _db.trackPoints); + $$RoutePlansTableTableManager get routePlans => + $$RoutePlansTableTableManager(_db, _db.routePlans); + $$WaypointsTableTableManager get waypoints => + $$WaypointsTableTableManager(_db, _db.waypoints); } diff --git a/lib/src/data/route_plan_repository.dart b/lib/src/data/route_plan_repository.dart new file mode 100644 index 0000000..070dadd --- /dev/null +++ b/lib/src/data/route_plan_repository.dart @@ -0,0 +1,102 @@ +/// Owns route-plan lifecycle: create, edit waypoints, rename, delete. See V3-07. +/// +/// Mirrors [TripRepository]'s shape deliberately, but a route plan has no state machine +/// to speak of -- there is no equivalent of "recording"; every write here is a direct, +/// synchronous-feeling edit to a thing the rider is actively sketching. +library; + +import '../domain/models.dart'; +import '../geo/geo.dart' as geo; +import 'database.dart'; + +class RoutePlanRepository { + RoutePlanRepository(this._db); + + final AppDatabase _db; + + Stream> watchRoutePlans() => _db.watchRoutePlans(); + + Stream watchRoutePlan(int id) => _db.watchRoutePlan(id); + + Future routePlanById(int id) => _db.getRoutePlan(id); + + Stream> watchWaypoints(int routeId) => _db.watchWaypoints(routeId); + + Future> waypointsFor(int routeId) => _db.waypointsForRoute(routeId); + + /// A fresh, unnamed plan with no pins yet. + Future createRoutePlan(int now, {String? name, Activity activity = Activity.motorcycle}) => + _db.insertRoutePlan( + RoutePlan(name: name ?? 'New route', createdAt: now, activity: activity), + ); + + Future renameRoutePlan(int id, String name) => _db.renameRoutePlan(id, name); + + /// Waypoints cascade with it. + Future deleteRoutePlan(int id) => _db.deleteRoutePlan(id); + + /// Appends a pin at the end of the route and recomputes the straight-line distance. + Future addWaypoint(int routeId, double latitude, double longitude) async { + final existing = await _db.waypointsForRoute(routeId); + await _db.insertWaypoint( + Waypoint( + routeId: routeId, + ordinal: existing.length, + latitude: latitude, + longitude: longitude, + ), + ); + await _recomputeDistance(routeId); + } + + Future moveWaypoint(int routeId, int waypointId, double latitude, double longitude) async { + await _db.moveWaypoint(waypointId, latitude, longitude); + await _recomputeDistance(routeId); + } + + Future deleteWaypoint(int routeId, int waypointId) async { + await _db.deleteWaypoint(waypointId); + // Ordinals must stay contiguous, or a reorder later would leave a gap that ordering + // by ordinal alone cannot express. + final remaining = await _db.waypointsForRoute(routeId); + for (var i = 0; i < remaining.length; i++) { + if (remaining[i].ordinal != i) { + await _db.setWaypointOrdinal(remaining[i].id, i); + } + } + await _recomputeDistance(routeId); + } + + /// Moves the waypoint at [fromIndex] to [toIndex] and renumbers everything between them. + Future reorderWaypoint(int routeId, int fromIndex, int toIndex) async { + final waypoints = await _db.waypointsForRoute(routeId); + if (fromIndex == toIndex || + fromIndex < 0 || + fromIndex >= waypoints.length || + toIndex < 0 || + toIndex >= waypoints.length) { + return; + } + final reordered = List.of(waypoints); + final moved = reordered.removeAt(fromIndex); + reordered.insert(toIndex, moved); + for (var i = 0; i < reordered.length; i++) { + if (reordered[i].ordinal != i) { + await _db.setWaypointOrdinal(reordered[i].id, i); + } + } + // Pure reordering does not change distance, but keeping this call here (rather than + // asserting the caller never needs it) means the invariant "distanceM always matches + // the current waypoints" holds after every mutating call, with no exceptions to + // remember. + await _recomputeDistance(routeId); + } + + Future _recomputeDistance(int routeId) async { + final waypoints = await _db.waypointsForRoute(routeId); + final distance = geo.pathLengthMeters([ + for (final w in waypoints) geo.LatLon(w.latitude, w.longitude), + ]); + await _db.setRoutePlanDistance(routeId, distance); + } +} diff --git a/lib/src/domain/models.dart b/lib/src/domain/models.dart index 4b6ef25..5a04925 100644 --- a/lib/src/domain/models.dart +++ b/lib/src/domain/models.dart @@ -225,3 +225,86 @@ class RideStats { int get durationMillis => pointCount == 0 ? 0 : lastTimestamp - firstTimestamp; } + +/// A planned ride, sketched before setting off. See V3-07 in `docs/v3/`. +/// +/// Deliberately **not** a [Trip] and never joined into ride totals or the rides list — a +/// plan is not a recording, and conflating them would put unridden kilometres into ride +/// totals. [distanceM] is the straight-line sum between waypoints in this ticket; +/// [geometry] and [estimatedMillis] stay null until V3-08 adds road-snapped routing. +class RoutePlan { + const RoutePlan({ + this.id = 0, + required this.name, + required this.createdAt, + this.activity = Activity.motorcycle, + this.distanceM = 0.0, + this.estimatedMillis, + this.geometry, + }); + + final int id; + final String name; + final int createdAt; + final Activity activity; + final double distanceM; + + /// Null until V3-08 fills it in from a routing engine. + final int? estimatedMillis; + + /// Null until V3-08. This ticket's polyline is drawn straight between waypoints and + /// never stored -- it is fully derived from them, so there is nothing to persist. + final String? geometry; + + RoutePlan copyWith({ + int? id, + String? name, + int? createdAt, + Activity? activity, + double? distanceM, + int? estimatedMillis, + String? geometry, + }) => RoutePlan( + id: id ?? this.id, + name: name ?? this.name, + createdAt: createdAt ?? this.createdAt, + activity: activity ?? this.activity, + distanceM: distanceM ?? this.distanceM, + estimatedMillis: estimatedMillis ?? this.estimatedMillis, + geometry: geometry ?? this.geometry, + ); +} + +/// One pin on a [RoutePlan]. +/// +/// Ordered by [ordinal] rather than insertion id, so a pin can be dragged into a new +/// position without touching every other row's identity. +class Waypoint { + const Waypoint({ + this.id = 0, + required this.routeId, + required this.ordinal, + required this.latitude, + required this.longitude, + this.name, + }); + + final int id; + final int routeId; + final int ordinal; + final double latitude; + final double longitude; + + /// Null means the UI derives a label from [ordinal] ("Point 3"). Never store an empty + /// string -- the same rule as [Trip.name]. + final String? name; + + Waypoint copyWith({int? id, int? routeId, int? ordinal}) => Waypoint( + id: id ?? this.id, + routeId: routeId ?? this.routeId, + ordinal: ordinal ?? this.ordinal, + latitude: latitude, + longitude: longitude, + name: name, + ); +} diff --git a/lib/src/ui/record/record_screen.dart b/lib/src/ui/record/record_screen.dart index cd978c4..a75216f 100644 --- a/lib/src/ui/record/record_screen.dart +++ b/lib/src/ui/record/record_screen.dart @@ -38,10 +38,16 @@ class RecordUiState { } class RecordScreen extends ConsumerStatefulWidget { - const RecordScreen({super.key, this.onOpenTrips, this.onOpenSettings}); + const RecordScreen({ + super.key, + this.onOpenTrips, + this.onOpenSettings, + this.onOpenRoutes, + }); final VoidCallback? onOpenTrips; final VoidCallback? onOpenSettings; + final VoidCallback? onOpenRoutes; @override ConsumerState createState() => _RecordScreenState(); @@ -198,6 +204,11 @@ class _RecordScreenState extends ConsumerState { ), Row( children: [ + TextButton( + key: const Key('open-routes'), + onPressed: widget.onOpenRoutes, + child: const Text('Routes'), + ), TextButton( onPressed: widget.onOpenTrips, child: const Text('Rides'), diff --git a/lib/src/ui/router.dart b/lib/src/ui/router.dart index f21c189..321d1f3 100644 --- a/lib/src/ui/router.dart +++ b/lib/src/ui/router.dart @@ -10,6 +10,8 @@ import 'package:go_router/go_router.dart'; import 'detail/trip_detail_screen.dart'; import 'record/record_screen.dart'; +import 'routes/route_planner_screen.dart'; +import 'routes/routes_list_screen.dart'; import 'settings/settings_screen.dart'; import 'trips/trips_screen.dart'; @@ -18,8 +20,11 @@ abstract final class Routes { static const trips = '/trips'; static const tripDetail = '/trip/:tripId'; static const settings = '/settings'; + static const routes = '/routes'; + static const routePlanner = '/routes/:routeId'; static String detailFor(int tripId) => '/trip/$tripId'; + static String plannerFor(int routeId) => '/routes/$routeId'; } GoRouter buildRouter() => GoRouter( @@ -30,8 +35,24 @@ GoRouter buildRouter() => GoRouter( builder: (context, state) => RecordScreen( onOpenTrips: () => context.push(Routes.trips), onOpenSettings: () => context.push(Routes.settings), + onOpenRoutes: () => context.push(Routes.routes), ), ), + GoRoute( + path: Routes.routes, + builder: (context, state) => RoutesListScreen( + onOpenRoute: (id) => context.push(Routes.plannerFor(id)), + onBack: () => context.pop(), + ), + ), + GoRoute( + path: Routes.routePlanner, + builder: (context, state) { + final id = int.tryParse(state.pathParameters['routeId'] ?? ''); + if (id == null) return const _NotFound(); + return RoutePlannerScreen(routeId: id, onBack: () => context.pop()); + }, + ), GoRoute( path: Routes.settings, builder: (context, state) => diff --git a/lib/src/ui/routes/route_planner_screen.dart b/lib/src/ui/routes/route_planner_screen.dart new file mode 100644 index 0000000..382e4bf --- /dev/null +++ b/lib/src/ui/routes/route_planner_screen.dart @@ -0,0 +1,268 @@ +/// V3-07: tap the map to drop a pin, drag a pin to move it, tap a pin to delete it. +/// +/// Straight lines only, deliberately — see the ticket. `geometry` stays null; the +/// polyline drawn here is derived from waypoints on every build, never persisted. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:latlong2/latlong.dart' as ll; + +import '../../app/providers.dart'; +import '../../data/route_plan_repository.dart'; +import '../../domain/models.dart'; +import '../../geo/geo.dart' as geo; +import '../components/ride_map.dart' show maxTileZoom, tileUserAgent; +import '../format.dart'; + +class RoutePlannerScreen extends ConsumerStatefulWidget { + const RoutePlannerScreen({super.key, required this.routeId, this.onBack}); + + final int routeId; + final VoidCallback? onBack; + + @override + ConsumerState createState() => _RoutePlannerScreenState(); +} + +class _RoutePlannerScreenState extends ConsumerState { + final _mapController = MapController(); + + @override + void dispose() { + _mapController.dispose(); + super.dispose(); + } + + /// Fits every pin in view, the same way `RideMap` fits a recorded path -- otherwise a + /// fixed zoom guess either strands distant pins off-screen or, for two close-together + /// pins, can cull one before it is ever seen. Null below two points: a single pin (or + /// none) has no meaningful bounds to fit. + CameraFit? _initialFit(List waypoints) { + if (waypoints.length < 2) return null; + final bounds = geo.bounds([ + for (final w in waypoints) geo.LatLon(w.latitude, w.longitude), + ])!; + if (bounds.isDegenerate) return null; + return CameraFit.bounds( + bounds: LatLngBounds( + ll.LatLng(bounds.minLat, bounds.minLon), + ll.LatLng(bounds.maxLat, bounds.maxLon), + ), + padding: const EdgeInsets.all(48), + maxZoom: maxTileZoom, + ); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final route = ref.watch(routePlanProvider(widget.routeId)).valueOrNull; + final waypointsAsync = ref.watch(routeWaypointsProvider(widget.routeId)); + final waypoints = waypointsAsync.valueOrNull ?? const []; + final units = ref.watch(unitSystemProvider); + final repo = ref.read(routePlanRepositoryProvider); + + if (route == null) { + return Scaffold( + body: SafeArea( + child: Center( + child: Text( + 'This route no longer exists.', + style: TextStyle(color: colors.outline), + ), + ), + ), + ); + } + + return Scaffold( + appBar: AppBar( + leading: IconButton( + key: const Key('back'), + icon: const Icon(Icons.arrow_back), + onPressed: widget.onBack, + ), + title: Text(route.name), + actions: [ + IconButton( + key: const Key('rename-route'), + icon: const Icon(Icons.edit_outlined), + onPressed: () => _rename(context, repo, route), + ), + IconButton( + key: const Key('delete-route'), + icon: const Icon(Icons.delete_outline), + onPressed: () async { + await repo.deleteRoutePlan(widget.routeId); + widget.onBack?.call(); + }, + ), + ], + ), + body: Column( + children: [ + Expanded( + // `MapOptions.initialCenter`/`initialZoom` are read exactly once, at + // FlutterMap's construction -- not on every rebuild. Building the map before + // the waypoints stream has delivered its first value would freeze the camera + // on null-island forever, even once real waypoints arrive. Wait for the first + // emission (typically one frame) so the initial camera is right from the + // start. + child: !waypointsAsync.hasValue + ? const Center(child: CircularProgressIndicator()) + : FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCameraFit: _initialFit(waypoints), + initialCenter: waypoints.isEmpty + ? const ll.LatLng(0, 0) + : ll.LatLng(waypoints.first.latitude, waypoints.first.longitude), + initialZoom: waypoints.length <= 1 ? 14 : maxTileZoom - 3, + maxZoom: maxTileZoom, + onTap: (tapPosition, point) => + repo.addWaypoint(widget.routeId, point.latitude, point.longitude), + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: tileUserAgent, + maxNativeZoom: maxTileZoom.toInt(), + panBuffer: 0, + ), + if (waypoints.length >= 2) + PolylineLayer( + polylines: [ + Polyline( + points: [ + for (final w in waypoints) ll.LatLng(w.latitude, w.longitude), + ], + strokeWidth: 4, + // Visibly distinct from a recorded path (see the acceptance + // criteria) — dashed, and the planning accent rather than the + // speed-bucketed colours RideMap uses. + pattern: StrokePattern.dashed(segments: const [8, 6]), + color: colors.primary, + ), + ], + ), + MarkerLayer( + markers: [ + for (var i = 0; i < waypoints.length; i++) + Marker( + key: Key('waypoint-${waypoints[i].id}'), + point: ll.LatLng(waypoints[i].latitude, waypoints[i].longitude), + width: 36, + height: 36, + child: _WaypointPin( + index: i, + onTap: () => + repo.deleteWaypoint(widget.routeId, waypoints[i].id), + onPanUpdate: (delta) { + final camera = _mapController.camera; + final current = ll.LatLng( + waypoints[i].latitude, + waypoints[i].longitude, + ); + final newOffset = + camera.latLngToScreenOffset(current) + delta; + final moved = camera.screenOffsetToLatLng(newOffset); + repo.moveWaypoint( + widget.routeId, + waypoints[i].id, + moved.latitude, + moved.longitude, + ); + }, + ), + ), + ], + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.straighten, size: 18, color: colors.outline), + const SizedBox(width: 8), + Text( + key: const Key('route-distance'), + formatDistance(route.distanceM, unit: units), + style: TextStyle(fontWeight: FontWeight.bold, color: colors.onSurface), + ), + Text( + ' · ${waypoints.length} pin${waypoints.length == 1 ? '' : 's'}', + style: TextStyle(color: colors.outline), + ), + ], + ), + ), + ], + ), + ); + } + + Future _rename( + BuildContext context, + RoutePlanRepository repo, + RoutePlan route, + ) async { + final controller = TextEditingController(text: route.name); + final name = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Rename route'), + content: TextField(key: const Key('route-name-field'), controller: controller), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(controller.text.trim()), + child: const Text('Save'), + ), + ], + ), + ); + if (name == null || name.isEmpty) return; + await repo.renameRoutePlan(widget.routeId, name); + } +} + +class _WaypointPin extends StatelessWidget { + const _WaypointPin({ + required this.index, + required this.onTap, + required this.onPanUpdate, + }); + + final int index; + final VoidCallback onTap; + final void Function(Offset delta) onPanUpdate; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap, + onPanUpdate: (details) => onPanUpdate(details.delta), + child: CircleAvatar( + backgroundColor: colors.primary, + radius: 14, + child: Text( + '${index + 1}', + style: TextStyle( + color: colors.onPrimary, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } +} diff --git a/lib/src/ui/routes/routes_list_screen.dart b/lib/src/ui/routes/routes_list_screen.dart new file mode 100644 index 0000000..face432 --- /dev/null +++ b/lib/src/ui/routes/routes_list_screen.dart @@ -0,0 +1,91 @@ +/// V3-07: routes never appear in the rides list, and never touch a `Trip` row. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/providers.dart'; +import '../../domain/models.dart'; +import '../components/stats.dart'; +import '../format.dart'; + +class RoutesListScreen extends ConsumerWidget { + const RoutesListScreen({super.key, this.onOpenRoute, this.onBack}); + + final void Function(int routeId)? onOpenRoute; + final VoidCallback? onBack; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = Theme.of(context).colorScheme; + final routes = ref.watch(routePlansProvider).valueOrNull ?? const []; + final units = ref.watch(unitSystemProvider); + + return Scaffold( + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Row( + children: [ + TextButton( + key: const Key('back'), + onPressed: onBack, + child: const Text('‹ Record'), + ), + const Padding( + padding: EdgeInsets.only(left: 8), + child: Text('ROUTES', style: TextStyle(letterSpacing: 2)), + ), + const Spacer(), + IconButton( + key: const Key('new-route'), + icon: const Icon(Icons.add), + tooltip: 'New route', + onPressed: () async { + final id = await ref + .read(routePlanRepositoryProvider) + .createRoutePlan(DateTime.now().millisecondsSinceEpoch); + onOpenRoute?.call(id); + }, + ), + ], + ), + ), + Expanded( + child: routes.isEmpty + ? const EmptyState( + message: 'No routes yet.\nDrop pins on a map to plan a ride.', + icon: Icons.route, + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: routes.length, + itemBuilder: (context, i) { + final route = routes[i]; + return Card( + key: Key('route-${route.id}'), + child: ListTile( + title: Text(route.name), + subtitle: Text(formatDistance(route.distanceM, unit: units)), + trailing: IconButton( + key: Key('delete-route-${route.id}'), + icon: Icon(Icons.delete_outline, color: colors.error), + onPressed: () => ref + .read(routePlanRepositoryProvider) + .deleteRoutePlan(route.id), + ), + onTap: () => onOpenRoute?.call(route.id), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/test/migration_test.dart b/test/migration_test.dart index 0a5e562..3c6d037 100644 --- a/test/migration_test.dart +++ b/test/migration_test.dart @@ -147,4 +147,34 @@ void main() { expect(all.length, 3); expect(all.every((t) => t.activity == Activity.motorcycle), isTrue); }); + + test('a v2 database (V3-07) gains route_plans/waypoints and keeps its trips', + () async { + // v2: identical to the v1 seed above, plus the `activity` column V3-01 added. + seedV1Database(); + final raw = sqlite3.sqlite3.open(dbFile.path); + raw.execute(''' + ALTER TABLE trips ADD COLUMN activity TEXT NOT NULL DEFAULT 'motorcycle'; + '''); + raw.execute('PRAGMA user_version = 2;'); + raw.close(); + + final db = AppDatabase(NativeDatabase(dbFile)); + addTearDown(db.close); + + // The pre-existing ride is untouched by an upgrade that has nothing to do with it. + final trip = await db.getTrip(1); + expect(trip, isNotNull); + expect(trip!.name, 'Old ride'); + + // The new tables are not just present but usable. + final routeId = await db.insertRoutePlan( + const RoutePlan(name: 'Coast loop', createdAt: 9000), + ); + await db.insertWaypoint( + Waypoint(routeId: routeId, ordinal: 0, latitude: 51.0, longitude: -114.0), + ); + final waypoints = await db.waypointsForRoute(routeId); + expect(waypoints, hasLength(1)); + }); } diff --git a/test/route_plan_repository_test.dart b/test/route_plan_repository_test.dart new file mode 100644 index 0000000..f85bb71 --- /dev/null +++ b/test/route_plan_repository_test.dart @@ -0,0 +1,133 @@ +import 'package:drift/drift.dart' show driftRuntimeOptions; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rippr/src/data/database.dart'; +import 'package:rippr/src/data/route_plan_repository.dart'; +import 'package:rippr/src/data/trip_repository.dart'; +import 'package:rippr/src/geo/geo.dart' as geo; + +/// V3-07: mirrors trip_repository_test.dart's shape -- in-memory Drift, real repository, +/// no mocks. A route plan has no state machine, so most of what is tested here is +/// straight-line distance staying correct after every mutation. +void main() { + late AppDatabase db; + late RoutePlanRepository routes; + late TripRepository trips; + + setUp(() { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + db = AppDatabase(NativeDatabase.memory()); + routes = RoutePlanRepository(db); + trips = TripRepository(db); + }); + + tearDown(() async => db.close()); + + test('a new route plan has no waypoints and zero distance', () async { + final id = await routes.createRoutePlan(1000); + final plan = await routes.routePlanById(id); + + expect(plan, isNotNull); + expect(plan!.distanceM, 0.0); + expect(await routes.waypointsFor(id), isEmpty); + }); + + test('adding waypoints appends in order and updates distance live', () async { + final id = await routes.createRoutePlan(1000); + + await routes.addWaypoint(id, 51.0, -114.0); + await routes.addWaypoint(id, 51.01, -114.0); + await routes.addWaypoint(id, 51.02, -114.0); + + final waypoints = await routes.waypointsFor(id); + expect(waypoints.map((w) => w.ordinal), [0, 1, 2]); + + final expected = geo.pathLengthMeters([ + for (final w in waypoints) geo.LatLon(w.latitude, w.longitude), + ]); + final plan = await routes.routePlanById(id); + expect(plan!.distanceM, expected); + expect(expected, greaterThan(0)); + }); + + test('moving a waypoint updates distance', () async { + final id = await routes.createRoutePlan(1000); + await routes.addWaypoint(id, 51.0, -114.0); + await routes.addWaypoint(id, 51.01, -114.0); + final before = (await routes.routePlanById(id))!.distanceM; + + final target = (await routes.waypointsFor(id)).last; + await routes.moveWaypoint(id, target.id, 52.0, -114.0); + + final after = (await routes.routePlanById(id))!.distanceM; + expect(after, isNot(before)); + }); + + test('deleting a waypoint closes the ordinal gap and updates distance', () async { + final id = await routes.createRoutePlan(1000); + await routes.addWaypoint(id, 51.0, -114.0); + await routes.addWaypoint(id, 51.01, -114.0); + await routes.addWaypoint(id, 51.02, -114.0); + + final middle = (await routes.waypointsFor(id))[1]; + await routes.deleteWaypoint(id, middle.id); + + final remaining = await routes.waypointsFor(id); + expect(remaining.length, 2); + expect(remaining.map((w) => w.ordinal), [0, 1]); + + final expected = geo.pathLengthMeters([ + for (final w in remaining) geo.LatLon(w.latitude, w.longitude), + ]); + expect((await routes.routePlanById(id))!.distanceM, expected); + }); + + test('reordering renumbers ordinals and leaves distance internally consistent', + () async { + final id = await routes.createRoutePlan(1000); + await routes.addWaypoint(id, 51.0, -114.0); + await routes.addWaypoint(id, 51.01, -114.0); + await routes.addWaypoint(id, 51.02, -114.0); + + await routes.reorderWaypoint(id, 0, 2); + + final reordered = await routes.waypointsFor(id); + expect(reordered.map((w) => w.ordinal), [0, 1, 2]); + expect(reordered.map((w) => w.latitude), [51.01, 51.02, 51.0]); + }); + + test('renaming updates the row', () async { + final id = await routes.createRoutePlan(1000); + await routes.renameRoutePlan(id, 'Coast loop'); + expect((await routes.routePlanById(id))!.name, 'Coast loop'); + }); + + test('deleting a route plan cascades to its waypoints', () async { + final id = await routes.createRoutePlan(1000); + await routes.addWaypoint(id, 51.0, -114.0); + await routes.addWaypoint(id, 51.01, -114.0); + + await routes.deleteRoutePlan(id); + + expect(await routes.routePlanById(id), isNull); + expect(await db.waypointsForRoute(id), isEmpty); + }); + + test('route plans never appear alongside trips and never affect ride totals', + () async { + await routes.createRoutePlan(1000, name: 'Plan A'); + final routeId = await routes.createRoutePlan(2000, name: 'Plan B'); + await routes.addWaypoint(routeId, 51.0, -114.0); + await routes.addWaypoint(routeId, 52.0, -114.0); + + final h = await trips.startTrip(3000); + await trips.appendPoints([]); + await trips.completeTrip(4000); + + final completedTrips = await trips.db.watchCompletedTrips().first; + expect(completedTrips.length, 1); + expect(completedTrips.single.id, h.tripId); + // Nothing about a route plan's distance leaked into the trip. + expect(completedTrips.single.distanceM, 0.0); + }); +} diff --git a/test/route_planner_screen_test.dart b/test/route_planner_screen_test.dart new file mode 100644 index 0000000..67080d3 --- /dev/null +++ b/test/route_planner_screen_test.dart @@ -0,0 +1,168 @@ +import 'package:drift/drift.dart' show driftRuntimeOptions; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rippr/src/app/providers.dart'; +import 'package:rippr/src/data/database.dart'; +import 'package:rippr/src/data/route_plan_repository.dart'; +import 'package:rippr/src/ui/routes/route_planner_screen.dart'; +import 'package:rippr/src/ui/routes/routes_list_screen.dart'; +import 'package:rippr/src/ui/theme.dart'; + +/// V3-07: the repository is already covered end to end in +/// route_plan_repository_test.dart (including "routes never appear in the rides list"); +/// this covers the screens that drive it. +void main() { + late AppDatabase db; + late RoutePlanRepository repo; + + setUp(() { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + db = AppDatabase(NativeDatabase.memory()); + repo = RoutePlanRepository(db); + }); + + tearDown(() async => db.close()); + + Widget host(Widget child) => ProviderScope( + overrides: [databaseProvider.overrideWithValue(db)], + child: MaterialApp(theme: ripprTheme(), home: child), + ); + + /// Same shape as widget_test.dart's `screenTest`: Drift's stream-query cache keeps a + /// query alive briefly after its last listener leaves, which reads to `flutter_test` + /// as a pending Timer unless the tree is fully torn down and pumped past the + /// keep-alive window first. + void screenTest(String description, Future Function(WidgetTester) body) { + testWidgets(description, (tester) async { + await body(tester); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 2)); + }); + } + + group('RoutesListScreen', () { + screenTest('empty state explains what to do', (tester) async { + await tester.pumpWidget(host(const RoutesListScreen())); + await tester.pumpAndSettle(); + + expect(find.textContaining('No routes yet'), findsOneWidget); + }); + + screenTest('tapping new route creates one and opens it', (tester) async { + int? opened; + await tester.pumpWidget( + host(RoutesListScreen(onOpenRoute: (id) => opened = id)), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('new-route'))); + await tester.pumpAndSettle(); + + expect(opened, isNotNull); + // Not `db.watchRoutePlans().first`: a fresh Stream subscription's first emission + // depends on a Timer inside Drift's stream-query store that never fires under + // flutter_test's fake zone without a pump driving it -- this hung for a real ten + // minutes before being traced back to exactly that. A plain Future-returning call + // has no such dependency. + final plan = await repo.routePlanById(opened!); + expect(plan, isNotNull); + }); + + screenTest('deleting a route removes it from the list', (tester) async { + final id = await repo.createRoutePlan(1000, name: 'Coast loop'); + await tester.pumpWidget(host(const RoutesListScreen())); + await tester.pumpAndSettle(); + + expect(find.text('Coast loop'), findsOneWidget); + + await tester.tap(find.byKey(Key('delete-route-$id'))); + await tester.pumpAndSettle(); + + expect(find.text('Coast loop'), findsNothing); + expect(await repo.routePlanById(id), isNull); + }); + }); + + group('RoutePlannerScreen', () { + // A real TileLayer tries real network fetches that never resolve in the test + // harness, so `pumpAndSettle` would hang forever waiting for it to go idle -- + // exactly the reason record_screen's own map tests use a bounded pump instead (see + // `pumpLive` in widget_test.dart). A few explicit frames are enough here too. + Future pumpMap(WidgetTester tester, Widget widget) async { + await tester.pumpWidget(widget); + // The waypoints stream (and therefore the map's initial camera, which is read + // only once at construction -- see the screen's own comment) needs a handful of + // frames to resolve its first value; a bounded loop rather than pumpAndSettle, + // since once the map is up its TileLayer never goes idle in this harness. + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + } + + screenTest('tapping the map adds a pin and the distance label updates', + (tester) async { + final id = await repo.createRoutePlan(1000); + await pumpMap(tester, host(RoutePlannerScreen(routeId: id))); + + expect(find.text('0 m'), findsOneWidget); + expect(find.textContaining('0 pins'), findsOneWidget); + + await tester.tapAt(tester.getCenter(find.byType(FlutterMap))); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt( + tester.getCenter(find.byType(FlutterMap)) + const Offset(40, 40), + ); + await tester.pump(const Duration(milliseconds: 50)); + + final waypoints = await repo.waypointsFor(id); + expect(waypoints, hasLength(2)); + expect(find.textContaining('2 pins'), findsOneWidget); + + final label = tester.widget(find.byKey(const Key('route-distance'))); + expect(label.data, isNot('0 m')); + }); + + screenTest('tapping a pin deletes it', (tester) async { + final id = await repo.createRoutePlan(1000); + await repo.addWaypoint(id, 51.0, -114.0); + await repo.addWaypoint(id, 51.01, -114.0); + + await pumpMap(tester, host(RoutePlannerScreen(routeId: id))); + + // flutter_map's `Marker` is a plain data class, not a Widget -- it never appears + // in the tree itself. `CircleAvatar` is what `_WaypointPin` actually renders. + expect(find.byType(CircleAvatar), findsNWidgets(2)); + + await tester.tap(find.text('1')); // the first pin's label + await tester.pump(); + + expect(await repo.waypointsFor(id), hasLength(1)); + }); + + screenTest('renaming updates the app bar title', (tester) async { + final id = await repo.createRoutePlan(1000, name: 'Old name'); + await pumpMap(tester, host(RoutePlannerScreen(routeId: id))); + + await tester.tap(find.byKey(const Key('rename-route'))); + await tester.pump(); + await tester.enterText( + find.byKey(const Key('route-name-field')), + 'Sunday coast run', + ); + await tester.tap(find.text('Save')); + await tester.pump(); + + expect(find.text('Sunday coast run'), findsOneWidget); + }); + + screenTest('a missing route says so instead of a blank map', (tester) async { + await tester.pumpWidget(host(const RoutePlannerScreen(routeId: 9999))); + await tester.pumpAndSettle(); + + expect(find.textContaining('no longer exists'), findsOneWidget); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index ed1ec6a..1e82801 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -218,6 +218,20 @@ void main() { reason: 'the button must actually invoke the callback that navigates'); }); + screenTest('a routes entry point exists and is wired (V3-07)', (tester) async { + var opened = false; + await tester.pumpWidget( + host(RecordScreen(onOpenRoutes: () => opened = true)), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('open-routes')), findsOneWidget); + await tester.tap(find.byKey(const Key('open-routes'))); + await tester.pumpAndSettle(); + + expect(opened, isTrue); + }); + screenTest('the live map appears only while recording and the toggle is on ' '(V3-04)', (tester) async { // Idle, toggle on: no trip to draw, so no map at all.