FB-05: closed-loop routes in Route Planner

Add an isClosedLoop toggle to route plans (schema v3 -> v4 migration),
threaded through the domain model, repository distance recomputation,
the overflow menu, and the drawn polyline, so a route can loop back to
its first waypoint.
This commit is contained in:
2026-08-24 16:22:51 -05:00
parent 57bc3c9233
commit 9ac0be6ee5
9 changed files with 359 additions and 8 deletions

View File

@@ -177,4 +177,57 @@ void main() {
final waypoints = await db.waypointsForRoute(routeId);
expect(waypoints, hasLength(1));
});
test('a v3 database (FB-05) gains isClosedLoop defaulting to false', () async {
// v3: identical to the v2 seed above, plus the route_plans/waypoints tables V3-07
// added.
seedV1Database();
final raw = sqlite3.sqlite3.open(dbFile.path);
raw.execute('''
ALTER TABLE trips ADD COLUMN activity TEXT NOT NULL DEFAULT 'motorcycle';
''');
raw.execute('''
CREATE TABLE route_plans (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
createdAt INTEGER NOT NULL,
activity TEXT NOT NULL DEFAULT 'motorcycle',
distanceM REAL NOT NULL DEFAULT 0,
estimatedMillis INTEGER NULL,
geometry TEXT NULL
);
CREATE TABLE waypoints (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
routeId INTEGER NOT NULL REFERENCES route_plans (id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
name TEXT NULL
);
CREATE INDEX idx_waypoints_route ON waypoints (routeId);
''');
raw.execute('''
INSERT INTO route_plans (id, name, createdAt) VALUES (1, 'Existing route', 8000);
''');
raw.execute('PRAGMA user_version = 3;');
raw.close();
final db = AppDatabase(NativeDatabase(dbFile));
addTearDown(db.close);
// The pre-existing route survives with the new column defaulting to false.
final existing = await db.getRoutePlan(1);
expect(existing, isNotNull);
expect(existing!.isClosedLoop, isFalse);
// A freshly inserted route also reads back false by default.
final newId = await db.insertRoutePlan(
const RoutePlan(name: 'New route', createdAt: 9000),
);
expect((await db.getRoutePlan(newId))!.isClosedLoop, isFalse);
// Toggling persists.
await db.setRoutePlanClosedLoop(1, true);
expect((await db.getRoutePlan(1))!.isClosedLoop, isTrue);
});
}

View File

@@ -113,6 +113,61 @@ void main() {
expect(await db.waypointsForRoute(id), isEmpty);
});
test('setClosedLoop(true) adds the closing segment to 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 open = (await routes.routePlanById(id))!.distanceM;
final waypoints = await routes.waypointsFor(id);
final closingSegment = geo.pathLengthMeters([
geo.LatLon(waypoints.last.latitude, waypoints.last.longitude),
geo.LatLon(waypoints.first.latitude, waypoints.first.longitude),
]);
await routes.setClosedLoop(id, true);
final closed = (await routes.routePlanById(id))!.distanceM;
expect(closed, closeTo(open + closingSegment, 1e-6));
expect((await routes.routePlanById(id))!.isClosedLoop, isTrue);
});
test('setClosedLoop(false) after true returns distance to the open total', () 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 open = (await routes.routePlanById(id))!.distanceM;
await routes.setClosedLoop(id, true);
expect((await routes.routePlanById(id))!.distanceM, isNot(open));
await routes.setClosedLoop(id, false);
expect((await routes.routePlanById(id))!.distanceM, closeTo(open, 1e-6));
expect((await routes.routePlanById(id))!.isClosedLoop, isFalse);
});
test('adding a waypoint to a closed-loop route keeps the closing segment correct',
() 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.setClosedLoop(id, true);
// Adding a third waypoint should recompute the closing segment against the new
// last waypoint, not the old one.
await routes.addWaypoint(id, 51.02, -114.0);
final waypoints = await routes.waypointsFor(id);
final expected = geo.pathLengthMeters([
for (final w in waypoints) geo.LatLon(w.latitude, w.longitude),
geo.LatLon(waypoints.first.latitude, waypoints.first.longitude),
]);
expect((await routes.routePlanById(id))!.distanceM, closeTo(expected, 1e-6));
});
test('route plans never appear alongside trips and never affect ride totals',
() async {
await routes.createRoutePlan(1000, name: 'Plan A');

View File

@@ -274,6 +274,78 @@ void main() {
expect(find.textContaining('MB'), findsOneWidget);
});
screenTest('the "Close the loop" menu item is disabled with 0-1 waypoints, '
'enabled with 2+ (FB-05)', (tester) async {
final id = await repo.createRoutePlan(1000);
await repo.addWaypoint(id, 51.0, -114.0);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
await tester.tap(find.byKey(const Key('route-overflow-menu')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
var item = tester.widget<PopupMenuItem<String>>(
find.byKey(const Key('toggle-closed-loop')),
);
expect(item.enabled, isFalse);
// Close the (still-open) menu and add a second waypoint.
await tester.tapAt(const Offset(10, 10));
await tester.pump(const Duration(milliseconds: 300));
await repo.addWaypoint(id, 51.01, -114.0);
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(find.byKey(const Key('route-overflow-menu')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
item = tester.widget<PopupMenuItem<String>>(
find.byKey(const Key('toggle-closed-loop')),
);
expect(item.enabled, isTrue);
});
screenTest('tapping "Close the loop" toggles isClosedLoop and flips the label '
'(FB-05)', (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)));
await tester.tap(find.byKey(const Key('route-overflow-menu')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
expect(find.text('Close the loop'), findsOneWidget);
await tester.tap(find.byKey(const Key('toggle-closed-loop')));
await tester.pump(const Duration(milliseconds: 300));
final route = await repo.routePlanById(id);
expect(route?.isClosedLoop, isTrue);
await tester.tap(find.byKey(const Key('route-overflow-menu')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
expect(find.text('Open the loop'), findsOneWidget);
});
screenTest('a closed loop draws one more polyline point than there are waypoints '
'(FB-05)', (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 repo.addWaypoint(id, 51.02, -114.0);
await repo.setClosedLoop(id, true);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
final layer = tester.widget<PolylineLayer>(find.byType(PolylineLayer));
expect(layer.polylines.single.points, hasLength(4));
});
screenTest('the shell nav bar correctly shows Plan active on this screen '
'(UI-06)', (tester) async {
final id = await repo.createRoutePlan(1000);