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

@@ -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-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-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-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-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-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 | | [V3-10](V3-10-trip-splitting.md) | Trip splitting | S | — | Not started |

View File

@@ -1,6 +1,6 @@
# V3-07 — Route drawing (pins and straight lines) # 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 ## Goal
Drop pins on a map to sketch a route, see the straight-line distance, and save it. The 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 ## Out of scope
Road snapping, ETA, following a route while riding. Import of existing GPX routes. 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<T>` (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).

View File

@@ -14,6 +14,7 @@ import '../config/config.dart';
import '../data/database.dart'; import '../data/database.dart';
import '../data/trip_repository.dart'; import '../data/trip_repository.dart';
import '../domain/models.dart'; import '../domain/models.dart';
import '../data/route_plan_repository.dart';
import '../notification/ride_notification_controller.dart'; import '../notification/ride_notification_controller.dart';
import '../notification/ride_notification_coordinator.dart'; import '../notification/ride_notification_coordinator.dart';
import '../recording/geolocator_location_source.dart'; import '../recording/geolocator_location_source.dart';
@@ -153,3 +154,21 @@ final livePointsProvider = StreamProvider.autoDispose.family<List<TrackPoint>, i
final liveSegmentsProvider = StreamProvider.autoDispose.family<List<Segment>, int>( final liveSegmentsProvider = StreamProvider.autoDispose.family<List<Segment>, int>(
(ref, tripId) => ref.watch(databaseProvider).watchSegmentsForTrip(tripId), (ref, tripId) => ref.watch(databaseProvider).watchSegmentsForTrip(tripId),
); );
// --- Route plans (V3-07) -----------------------------------------------------
final routePlanRepositoryProvider = Provider<RoutePlanRepository>(
(ref) => RoutePlanRepository(ref.watch(databaseProvider)),
);
final routePlansProvider = StreamProvider<List<RoutePlan>>(
(ref) => ref.watch(routePlanRepositoryProvider).watchRoutePlans(),
);
final routePlanProvider = StreamProvider.autoDispose.family<RoutePlan?, int>(
(ref, id) => ref.watch(routePlanRepositoryProvider).watchRoutePlan(id),
);
final routeWaypointsProvider = StreamProvider.autoDispose.family<List<Waypoint>, int>(
(ref, routeId) => ref.watch(routePlanRepositoryProvider).watchWaypoints(routeId),
);

View File

@@ -101,12 +101,52 @@ class TrackPoints extends Table {
BoolColumn get synced => boolean().withDefault(const Constant(false))(); 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 { class AppDatabase extends _$AppDatabase {
AppDatabase(super.e); AppDatabase(super.e);
@override @override
int get schemaVersion => 2; int get schemaVersion => 3;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -117,6 +157,11 @@ class AppDatabase extends _$AppDatabase {
if (from < 2) { if (from < 2) {
await m.addColumn(trips, trips.activity); 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 { beforeOpen: (details) async {
// Non-negotiable: without this the CASCADE relationships above do nothing. // 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(); )..orderBy([(p) => OrderingTerm.asc(p.id)])).get();
return rows.map(_toPoint).toList(); 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 --------------------------------------------------- // --- Row → domain mapping ---------------------------------------------------
// Kept as free functions rather than extension getters so the domain layer stays // Kept as free functions rather than extension getters so the domain layer stays
// entirely unaware that Drift exists. // entirely unaware that Drift exists.

File diff suppressed because it is too large Load Diff

View File

@@ -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<List<RoutePlan>> watchRoutePlans() => _db.watchRoutePlans();
Stream<RoutePlan?> watchRoutePlan(int id) => _db.watchRoutePlan(id);
Future<RoutePlan?> routePlanById(int id) => _db.getRoutePlan(id);
Stream<List<Waypoint>> watchWaypoints(int routeId) => _db.watchWaypoints(routeId);
Future<List<Waypoint>> waypointsFor(int routeId) => _db.waypointsForRoute(routeId);
/// A fresh, unnamed plan with no pins yet.
Future<int> createRoutePlan(int now, {String? name, Activity activity = Activity.motorcycle}) =>
_db.insertRoutePlan(
RoutePlan(name: name ?? 'New route', createdAt: now, activity: activity),
);
Future<void> renameRoutePlan(int id, String name) => _db.renameRoutePlan(id, name);
/// Waypoints cascade with it.
Future<void> deleteRoutePlan(int id) => _db.deleteRoutePlan(id);
/// Appends a pin at the end of the route and recomputes the straight-line distance.
Future<void> 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<void> moveWaypoint(int routeId, int waypointId, double latitude, double longitude) async {
await _db.moveWaypoint(waypointId, latitude, longitude);
await _recomputeDistance(routeId);
}
Future<void> 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<void> 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<Waypoint>.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<void> _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);
}
}

View File

@@ -225,3 +225,86 @@ class RideStats {
int get durationMillis => int get durationMillis =>
pointCount == 0 ? 0 : lastTimestamp - firstTimestamp; 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,
);
}

View File

@@ -38,10 +38,16 @@ class RecordUiState {
} }
class RecordScreen extends ConsumerStatefulWidget { 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? onOpenTrips;
final VoidCallback? onOpenSettings; final VoidCallback? onOpenSettings;
final VoidCallback? onOpenRoutes;
@override @override
ConsumerState<RecordScreen> createState() => _RecordScreenState(); ConsumerState<RecordScreen> createState() => _RecordScreenState();
@@ -198,6 +204,11 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
), ),
Row( Row(
children: [ children: [
TextButton(
key: const Key('open-routes'),
onPressed: widget.onOpenRoutes,
child: const Text('Routes'),
),
TextButton( TextButton(
onPressed: widget.onOpenTrips, onPressed: widget.onOpenTrips,
child: const Text('Rides'), child: const Text('Rides'),

View File

@@ -10,6 +10,8 @@ import 'package:go_router/go_router.dart';
import 'detail/trip_detail_screen.dart'; import 'detail/trip_detail_screen.dart';
import 'record/record_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 'settings/settings_screen.dart';
import 'trips/trips_screen.dart'; import 'trips/trips_screen.dart';
@@ -18,8 +20,11 @@ abstract final class Routes {
static const trips = '/trips'; static const trips = '/trips';
static const tripDetail = '/trip/:tripId'; static const tripDetail = '/trip/:tripId';
static const settings = '/settings'; static const settings = '/settings';
static const routes = '/routes';
static const routePlanner = '/routes/:routeId';
static String detailFor(int tripId) => '/trip/$tripId'; static String detailFor(int tripId) => '/trip/$tripId';
static String plannerFor(int routeId) => '/routes/$routeId';
} }
GoRouter buildRouter() => GoRouter( GoRouter buildRouter() => GoRouter(
@@ -30,8 +35,24 @@ GoRouter buildRouter() => GoRouter(
builder: (context, state) => RecordScreen( builder: (context, state) => RecordScreen(
onOpenTrips: () => context.push(Routes.trips), onOpenTrips: () => context.push(Routes.trips),
onOpenSettings: () => context.push(Routes.settings), 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( GoRoute(
path: Routes.settings, path: Routes.settings,
builder: (context, state) => builder: (context, state) =>

View File

@@ -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<RoutePlannerScreen> createState() => _RoutePlannerScreenState();
}
class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
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<Waypoint> 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<void> _rename(
BuildContext context,
RoutePlanRepository repo,
RoutePlan route,
) async {
final controller = TextEditingController(text: route.name);
final name = await showDialog<String>(
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,
),
),
),
);
}
}

View File

@@ -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 <RoutePlan>[];
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),
),
);
},
),
),
],
),
),
);
}
}

View File

@@ -147,4 +147,34 @@ void main() {
expect(all.length, 3); expect(all.length, 3);
expect(all.every((t) => t.activity == Activity.motorcycle), isTrue); 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));
});
} }

View File

@@ -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);
});
}

View File

@@ -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<void> 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<void> 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<Text>(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);
});
});
}

View File

@@ -218,6 +218,20 @@ void main() {
reason: 'the button must actually invoke the callback that navigates'); 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 ' screenTest('the live map appears only while recording and the toggle is on '
'(V3-04)', (tester) async { '(V3-04)', (tester) async {
// Idle, toggle on: no trip to draw, so no map at all. // Idle, toggle on: no trip to draw, so no map at all.