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:
2026-08-17 19:15:18 -05:00
parent 5013b7002f
commit a14f5b0134
15 changed files with 2694 additions and 5 deletions

View File

@@ -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.