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.
19 KiB
FB-05 — Closed-loop routes in Route Planner
Depends on FB-04 (same file, heavily edited — sequence after it merges) · Size M · Status Done
Goal
Route planning currently only supports a one-way A→B→C path. Add the ability to close the route into a loop that returns to its starting pin, updating the drawn polyline and the distance stat to include the closing segment.
Context
Direct user feedback (docs/FEEDBACK.md, "Route" section):
...there is no way to create a loop with the pins, it only creates a unidirectional path.
Data model — no loop concept exists today
lib/src/domain/models.dart, RoutePlan (~lines 229-276):
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;
final int? estimatedMillis;
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,
);
}
Waypoint (~lines 278-309) has an ordinal for ordering, and waypoints are only ever
appended at the end (RoutePlanRepository.addWaypoint, below) — there is no "return to
start" field or concept anywhere in the model.
Polyline and distance — strictly open, no wraparound
lib/src/ui/routes/route_planner_screen.dart, the PolylineLayer (~lines 182-201):
if (waypoints.length >= 2)
PolylineLayer(
polylines: [
Polyline(
points: [
for (final w in waypoints) ll.LatLng(w.latitude, w.longitude),
],
strokeWidth: 4,
pattern: StrokePattern.dashed(segments: const [8, 6]),
color: colors.primary,
),
],
),
lib/src/data/route_plan_repository.dart, distance recomputation (full relevant
section):
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> _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);
}
geo.pathLengthMeters (lib/src/geo/geo.dart ~line 185) sums consecutive-pair
distances generically over whatever point list it's given — it needs no changes itself,
just a point list that includes the closing segment when appropriate.
FloatingPill's Distance/Est. Time/Pins stats read straight off route.distanceM/
route.estimatedMillis/waypoints.length (route_planner_screen.dart ~lines 280-292)
— Distance updates for free once _recomputeDistance accounts for the closing segment;
Pins and Est. Time are unaffected by this ticket.
Schema — current version and migration pattern to follow
lib/src/data/database.dart:
@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))();
IntColumn get estimatedMillis => integer().nullable()();
TextColumn get geometry => text().nullable()();
}
@DriftDatabase(tables: [Trips, Segments, TrackPoints, RoutePlans, Waypoints])
class AppDatabase extends _$AppDatabase {
@override
int get schemaVersion => 3;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
onUpgrade: (m, from, to) async {
if (from < 2) {
await m.addColumn(trips, trips.activity);
}
if (from < 3) {
await m.createTable(routePlans);
await m.createTable(waypoints);
}
},
...
The existing if (from < 2) { await m.addColumn(trips, trips.activity); } is the exact
pattern to follow for a new nullable/defaulted column.
RoutePlanRepository insert/conversion glue, in database.dart:
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> setRoutePlanDistance(int id, double distanceM) => (update(routePlans)
..where((r) => r.id.equals(id))).write(RoutePlansCompanion(distanceM: Value(distanceM)));
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,
);
Overflow menu — where the toggle belongs
route_planner_screen.dart, _OverflowMenu (~lines 493-530-ish) already hosts
Rename/Download/Delete as a PopupMenuButton inside a GlassPanel:
class _OverflowMenu extends StatelessWidget {
const _OverflowMenu({
required this.hasWaypoints, required this.onRename, required this.onDownload, required this.onDelete,
});
final bool hasWaypoints;
final VoidCallback onRename;
final VoidCallback? onDownload;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) => GlassPanel(
borderRadius: const BorderRadius.all(Radius.circular(999)),
child: PopupMenuButton<String>(
key: const Key('route-overflow-menu'),
icon: const Icon(Icons.more_vert),
onSelected: (value) => switch (value) {
'rename' => onRename(),
'download' => onDownload?.call(),
'delete' => onDelete(),
_ => null,
},
itemBuilder: (context) => [
const PopupMenuItem(key: Key('rename-route'), value: 'rename', child: Text('Rename')),
...
And it's constructed at the call site (~lines 298-310):
_OverflowMenu(
hasWaypoints: waypoints.isNotEmpty,
onRename: () => _rename(context, repo, route),
onDownload: waypoints.isEmpty ? null : () => _downloadOfflineTiles(context, waypoints),
onDelete: () async {
await repo.deleteRoutePlan(widget.routeId);
widget.onBack?.call();
},
),
Design
- Schema: add
BoolColumn get isClosedLoop => boolean().withDefault(const Constant(false))();toRoutePlansindatabase.dart. BumpschemaVersionfrom3to4; addif (from < 4) { await m.addColumn(routePlans, routePlans.isClosedLoop); }toonUpgrade. - Domain model: add
final bool isClosedLoop;toRoutePlan(defaultfalsein the constructor), thread it throughcopyWith. - DB glue: add
isClosedLoop: Value(route.isClosedLoop)toinsertRoutePlan'sRoutePlansCompanion.insert(...)call,isClosedLoop: r.isClosedLoopto_toRoutePlan, and a new method mirroringsetRoutePlanDistance:Future<void> setRoutePlanClosedLoop(int id, bool value) => (update(routePlans) ..where((r) => r.id.equals(id))).write(RoutePlansCompanion(isClosedLoop: Value(value))); - Repository: add to
RoutePlanRepository:UpdateFuture<void> setClosedLoop(int routeId, bool value) async { await _db.setRoutePlanClosedLoop(routeId, value); await _recomputeDistance(routeId); // the closing segment changes the total }_recomputeDistanceto fetch the route's own flag and append the first waypoint's coordinates when closed, before summing:(Future<void> _recomputeDistance(int routeId) async { final route = await _db.getRoutePlan(routeId); final waypoints = await _db.waypointsForRoute(routeId); final points = [for (final w in waypoints) geo.LatLon(w.latitude, w.longitude)]; if (route?.isClosedLoop == true && points.length >= 2) { points.add(points.first); } final distance = geo.pathLengthMeters(points); await _db.setRoutePlanDistance(routeId, distance); }_recomputeDistanceis already called from every waypoint-mutating method —addWaypoint,moveWaypoint,deleteWaypoint,reorderWaypoint— so the closing segment is kept correct automatically as pins are edited, with no other call site changes needed.) - UI toggle: add a 4th item to
_OverflowMenu, enabled only when there are enough waypoints for a loop to mean anything (hasWaypointsalone isn't enough — need at least 2 to form any segment at all; reuse the existinghasWaypointsbool but also thread through whether there are>= 2waypoints, or simplify by passing a newcanCloseLoop: waypoints.length >= 2param):Wire it at the call site:class _OverflowMenu extends StatelessWidget { const _OverflowMenu({ required this.hasWaypoints, required this.canCloseLoop, required this.isClosedLoop, required this.onRename, required this.onDownload, required this.onToggleLoop, required this.onDelete, }); ... final bool canCloseLoop; final bool isClosedLoop; final VoidCallback onToggleLoop; @override Widget build(BuildContext context) => GlassPanel( ... child: PopupMenuButton<String>( ... onSelected: (value) => switch (value) { 'rename' => onRename(), 'download' => onDownload?.call(), 'closeLoop' => onToggleLoop(), 'delete' => onDelete(), _ => null, }, itemBuilder: (context) => [ const PopupMenuItem(key: Key('rename-route'), value: 'rename', child: Text('Rename')), ... PopupMenuItem( key: const Key('toggle-closed-loop'), value: 'closeLoop', enabled: canCloseLoop, child: Text(isClosedLoop ? 'Open the loop' : 'Close the loop'), ), ... ], ), ); }_OverflowMenu( hasWaypoints: waypoints.isNotEmpty, canCloseLoop: waypoints.length >= 2, isClosedLoop: route.isClosedLoop, onRename: () => _rename(context, repo, route), onDownload: waypoints.isEmpty ? null : () => _downloadOfflineTiles(context, waypoints), onToggleLoop: () => repo.setClosedLoop(widget.routeId, !route.isClosedLoop), onDelete: () async { ... }, ), - Polyline: append the closing point when the route is closed and there are at
least 2 waypoints:
Same
if (waypoints.length >= 2) PolylineLayer( polylines: [ Polyline( points: [ for (final w in waypoints) ll.LatLng(w.latitude, w.longitude), if (route.isClosedLoop) ll.LatLng(waypoints.first.latitude, waypoints.first.longitude), ], strokeWidth: 4, pattern: StrokePattern.dashed(segments: const [8, 6]), color: colors.primary, ), ], ),Polylineobject, same styling — just one more point in the list, so the closing segment renders with identical dashed styling to the rest of the route. FloatingPill/_initialFit: no changes needed — Distance updates automatically via_recomputeDistance; Pins/Est. Time are correctly unaffected;_initialFit's bounds-fitting is based on waypoint positions regardless of whether the last segment loops back (the closing point is always one of the existing waypoints' own coordinates, already inside the fitted bounds).
Implementation
- Add
isClosedLoopcolumn toRoutePlansindatabase.dart, bumpschemaVersionto4, add theonUpgrademigration step. - Add
isClosedLoopfield toRoutePlandomain model +copyWith. - Add
isClosedLooptoinsertRoutePlan's companion and_toRoutePlanindatabase.dart; addsetRoutePlanClosedLoop. - Add
RoutePlanRepository.setClosedLoop; update_recomputeDistanceto append the closing point whenisClosedLoop. - Extend
_OverflowMenuwith the new toggle item and params; wire it at the call site inroute_planner_screen.dart. - Extend the
PolylineLayer's point list with the conditional closing point.
Acceptance criteria
- "Close the loop" appears in the overflow menu, disabled when there are fewer than 2 waypoints, enabled otherwise.
- Toggling it on redraws the polyline with a visible segment from the last waypoint back to the first, same dashed styling as the rest of the route.
- Toggling it on increases the Distance stat by the length of the new closing segment; toggling it back off returns Distance to the open-path total.
- The menu label reflects current state ("Close the loop" when open, "Open the loop" when already closed).
- Adding/moving/deleting a waypoint on a closed-loop route keeps the closing segment
correct automatically (it's recomputed via the existing
_recomputeDistancecall already present in every waypoint-mutating method). - Deleting waypoints down to fewer than 2 while closed doesn't crash — the
waypoints.length >= 2guards in both the polyline and distance code make the closing point simply disappear along with the rest of the route drawing, same as today's behavior for an open path with 0-1 waypoints. - A pre-existing (schema v3) database opens cleanly post-migration with
isClosedLoopdefaulting tofalseon every existing route. flutter analyzeclean,flutter testgreen, test count only goes up.
Tests
- Migration test (
test/migration_test.dart, following the exact pattern of the existing'a v2 database (V3-07) gains route_plans/waypoints and keeps its trips'test): seed a v3 database (v1 seed +activitycolumn +route_plans/waypointstables created via raw SQL,PRAGMA user_version = 3;), open it withAppDatabase, insert a route viadb.insertRoutePlan, and assertisClosedLoopreads backfalseby default, and that toggling it viasetRoutePlanClosedLooppersists. - Repository tests (
test/route_plan_repository_test.dart, alongside the existing'adding waypoints appends in order and updates distance live'etc.):setClosedLoop(true)on a route with 2+ waypoints increasesdistanceMby exactly the closing segment's length (compute the expected delta directly withgeo.pathLengthMeters/a manual haversine call between the last and first waypoint, matching how other distance tests in this file already assert exact values).setClosedLoop(false)aftertruereturnsdistanceMto the pre-toggle value.- Adding a waypoint to an already-closed route keeps the distance correct (recomputed including the new closing segment against the new last-added point, not the old one).
- Widget tests (
test/route_planner_screen_test.dart):- The "Close the loop" menu item is disabled with 0-1 waypoints, enabled with 2+
(mirrors the existing "offline-tiles download menu item is disabled with no pins"
test's structure — open the overflow menu, inspect the
PopupMenuItem.enabledproperty via its key). - Tapping it toggles
route.isClosedLoop(assert viarepo.routePlanById(id)?.isClosedLoop, the same pattern the existing rename test uses to verify writes through to the repository) and the label switches to "Open the loop". - With the loop closed,
find.byType(Polyline)(or inspecting thePolylineLayer'spolylineslist directly, matching whatever existing pattern this test file uses to inspect polyline data) has one more point than the waypoint count.
- The "Close the loop" menu item is disabled with 0-1 waypoints, enabled with 2+
(mirrors the existing "offline-tiles download menu item is disabled with no pins"
test's structure — open the overflow menu, inspect the
Risks
- None significant — this is additive (new column, new optional toggle) and every
touched call site (
_recomputeDistance) already runs on every mutation, so there's no new code path that could silently skip recomputation.
Out of scope
Turn-by-turn following of a closed loop (V3-09, already deferred). Any UI indication of loop direction/rotation. Road-snapped routing for the closing segment (V3-08) — it stays a straight line like every other segment in this ticket's scope.
Outcome
Implemented exactly as designed, with one small addition to the migration step beyond what the ticket spelled out:
- Added
isClosedLoop(BoolColumn, defaultfalse) toRoutePlansinlib/src/data/database.dart, bumpedschemaVersionfrom 3 to 4, and regenerateddatabase.g.dartviadart run build_runner build. - Deviation: the migration guard needed to be
if (from >= 3 && from < 4)rather than the ticket's plainif (from < 4). A v2 → v4 upgrade runs the existingif (from < 3)branch first, which callsm.createTable(routePlans)— and since that table definition already includesisClosedLoop(Drift generatesCREATE TABLEfrom the current Dart schema, not a historical snapshot), an unconditionalfrom < 4then tried toADD COLUMN isClosedLoopa second time and hit a duplicate-column SQLite error. Restricting theaddColumnstep to routes that already had the table before v4 (from >= 3) fixes it; a v1/v2 → v4+ upgrade gets the column for free from the freshcreateTableinstead. Added a v3-seed migration test (test/migration_test.dart,'a v3 database (FB-05) gains isClosedLoop defaulting to false') covering exactly this path, plus verifying a v2 → v4+ jump still works via the existing v2 test. - Added
isClosedLoopto theRoutePlandomain model,copyWith, theinsertRoutePlan/_toRoutePlanglue, and a newsetRoutePlanClosedLoopmethod indatabase.dart. - Added
RoutePlanRepository.setClosedLoopand updated_recomputeDistanceto append the first waypoint's coordinates as a closing point when the route is closed and has 2+ waypoints, exactly per the design. - Extended
_OverflowMenuinroute_planner_screen.dartwithcanCloseLoop/isClosedLoop/onToggleLoopand a "Close the loop" / "Open the loop" menu item, wired at the call site. - Extended the
PolylineLayer's point list with the conditional closing point. - FB-04 had already changed the surrounding
routeaccess to a guardedAsyncValuepattern (no more.valueOrNull) by the time this ticket landed; the new code readsroute.isClosedLoopoff the same already-resolvedroutevalue the rest of the build method uses, so no adjustment to that pattern was needed.
Added migration, repository, and widget tests per the ticket's Tests section (in
test/migration_test.dart, test/route_plan_repository_test.dart, and
test/route_planner_screen_test.dart).
flutter analyze: clean (only 4 pre-existing, unrelated info-level lints remain).
flutter test: all green, 411 tests passing (up from the 404 baseline).