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.
This commit is contained in:
@@ -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<domain.Activity>().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<List<domain.RoutePlan>> watchRoutePlans() =>
|
||||
(select(routePlans)..orderBy([(r) => OrderingTerm.desc(r.createdAt)]))
|
||||
.watch()
|
||||
.map((rows) => rows.map(_toRoutePlan).toList());
|
||||
|
||||
Stream<domain.RoutePlan?> watchRoutePlan(int id) =>
|
||||
(select(routePlans)..where((r) => r.id.equals(id)))
|
||||
.watchSingleOrNull()
|
||||
.map((r) => r == null ? null : _toRoutePlan(r));
|
||||
|
||||
Future<domain.RoutePlan?> getRoutePlan(int id) async {
|
||||
final row = await (select(
|
||||
routePlans,
|
||||
)..where((r) => r.id.equals(id))).getSingleOrNull();
|
||||
return row == null ? null : _toRoutePlan(row);
|
||||
}
|
||||
|
||||
Future<int> 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<void> renameRoutePlan(int id, String name) => (update(
|
||||
routePlans,
|
||||
)..where((r) => r.id.equals(id))).write(RoutePlansCompanion(name: Value(name)));
|
||||
|
||||
Future<void> setRoutePlanDistance(int id, double distanceM) => (update(
|
||||
routePlans,
|
||||
)..where((r) => r.id.equals(id))).write(
|
||||
RoutePlansCompanion(distanceM: Value(distanceM)),
|
||||
);
|
||||
|
||||
/// Waypoints cascade with it.
|
||||
Future<void> deleteRoutePlan(int id) =>
|
||||
(delete(routePlans)..where((r) => r.id.equals(id))).go();
|
||||
|
||||
Stream<List<domain.Waypoint>> 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<List<domain.Waypoint>> 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<int> 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<void> moveWaypoint(int id, double latitude, double longitude) =>
|
||||
(update(waypoints)..where((w) => w.id.equals(id))).write(
|
||||
WaypointsCompanion(latitude: Value(latitude), longitude: Value(longitude)),
|
||||
);
|
||||
|
||||
Future<void> setWaypointOrdinal(int id, int ordinal) => (update(
|
||||
waypoints,
|
||||
)..where((w) => w.id.equals(id))).write(WaypointsCompanion(ordinal: Value(ordinal)));
|
||||
|
||||
Future<void> 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.
|
||||
|
||||
Reference in New Issue
Block a user