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

@@ -121,6 +121,9 @@ class RoutePlans extends Table {
/// 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()();
/// Whether the route loops back to its first waypoint after the last one. See FB-05.
BoolColumn get isClosedLoop => boolean().withDefault(const Constant(false))();
}
/// One pin on a [RoutePlans] row.
@@ -146,7 +149,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
int get schemaVersion => 3;
int get schemaVersion => 4;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -162,6 +165,13 @@ class AppDatabase extends _$AppDatabase {
await m.createTable(routePlans);
await m.createTable(waypoints);
}
// FB-05: closed-loop toggle, defaults to false for every pre-existing route.
// Guarded to `from >= 3` because a v2 -> v4+ upgrade just created `routePlans`
// fresh (above) with every current column, `isClosedLoop` included -- adding it
// again here would be a duplicate-column error.
if (from >= 3 && from < 4) {
await m.addColumn(routePlans, routePlans.isClosedLoop);
}
},
beforeOpen: (details) async {
// Non-negotiable: without this the CASCADE relationships above do nothing.
@@ -553,6 +563,7 @@ class AppDatabase extends _$AppDatabase {
distanceM: Value(route.distanceM),
estimatedMillis: Value(route.estimatedMillis),
geometry: Value(route.geometry),
isClosedLoop: Value(route.isClosedLoop),
),
);
@@ -566,6 +577,12 @@ class AppDatabase extends _$AppDatabase {
RoutePlansCompanion(distanceM: Value(distanceM)),
);
Future<void> setRoutePlanClosedLoop(int id, bool value) => (update(
routePlans,
)..where((r) => r.id.equals(id))).write(
RoutePlansCompanion(isClosedLoop: Value(value)),
);
/// Waypoints cascade with it.
Future<void> deleteRoutePlan(int id) =>
(delete(routePlans)..where((r) => r.id.equals(id))).go();
@@ -617,6 +634,7 @@ domain.RoutePlan _toRoutePlan(RoutePlanRow r) => domain.RoutePlan(
distanceM: r.distanceM,
estimatedMillis: r.estimatedMillis,
geometry: r.geometry,
isClosedLoop: r.isClosedLoop,
);
domain.Waypoint _toWaypoint(WaypointRow r) => domain.Waypoint(

View File

@@ -1706,6 +1706,21 @@ class $RoutePlansTable extends RoutePlans
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _isClosedLoopMeta = const VerificationMeta(
'isClosedLoop',
);
@override
late final GeneratedColumn<bool> isClosedLoop = GeneratedColumn<bool>(
'isClosedLoop',
aliasedName,
false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("isClosedLoop" IN (0, 1))',
),
defaultValue: const Constant(false),
);
@override
List<GeneratedColumn> get $columns => [
id,
@@ -1715,6 +1730,7 @@ class $RoutePlansTable extends RoutePlans
distanceM,
estimatedMillis,
geometry,
isClosedLoop,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -1768,6 +1784,15 @@ class $RoutePlansTable extends RoutePlans
geometry.isAcceptableOrUnknown(data['geometry']!, _geometryMeta),
);
}
if (data.containsKey('isClosedLoop')) {
context.handle(
_isClosedLoopMeta,
isClosedLoop.isAcceptableOrUnknown(
data['isClosedLoop']!,
_isClosedLoopMeta,
),
);
}
return context;
}
@@ -1807,6 +1832,10 @@ class $RoutePlansTable extends RoutePlans
DriftSqlType.string,
data['${effectivePrefix}geometry'],
),
isClosedLoop: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}isClosedLoop'],
)!,
);
}
@@ -1834,6 +1863,9 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
/// 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.
final String? geometry;
/// Whether the route loops back to its first waypoint after the last one. See FB-05.
final bool isClosedLoop;
const RoutePlanRow({
required this.id,
required this.name,
@@ -1842,6 +1874,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
required this.distanceM,
this.estimatedMillis,
this.geometry,
required this.isClosedLoop,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -1861,6 +1894,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
if (!nullToAbsent || geometry != null) {
map['geometry'] = Variable<String>(geometry);
}
map['isClosedLoop'] = Variable<bool>(isClosedLoop);
return map;
}
@@ -1877,6 +1911,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
geometry: geometry == null && nullToAbsent
? const Value.absent()
: Value(geometry),
isClosedLoop: Value(isClosedLoop),
);
}
@@ -1895,6 +1930,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
distanceM: serializer.fromJson<double>(json['distanceM']),
estimatedMillis: serializer.fromJson<int?>(json['estimatedMillis']),
geometry: serializer.fromJson<String?>(json['geometry']),
isClosedLoop: serializer.fromJson<bool>(json['isClosedLoop']),
);
}
@override
@@ -1910,6 +1946,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
'distanceM': serializer.toJson<double>(distanceM),
'estimatedMillis': serializer.toJson<int?>(estimatedMillis),
'geometry': serializer.toJson<String?>(geometry),
'isClosedLoop': serializer.toJson<bool>(isClosedLoop),
};
}
@@ -1921,6 +1958,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
double? distanceM,
Value<int?> estimatedMillis = const Value.absent(),
Value<String?> geometry = const Value.absent(),
bool? isClosedLoop,
}) => RoutePlanRow(
id: id ?? this.id,
name: name ?? this.name,
@@ -1931,6 +1969,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
? estimatedMillis.value
: this.estimatedMillis,
geometry: geometry.present ? geometry.value : this.geometry,
isClosedLoop: isClosedLoop ?? this.isClosedLoop,
);
RoutePlanRow copyWithCompanion(RoutePlansCompanion data) {
return RoutePlanRow(
@@ -1943,6 +1982,9 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
? data.estimatedMillis.value
: this.estimatedMillis,
geometry: data.geometry.present ? data.geometry.value : this.geometry,
isClosedLoop: data.isClosedLoop.present
? data.isClosedLoop.value
: this.isClosedLoop,
);
}
@@ -1955,7 +1997,8 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
..write('activity: $activity, ')
..write('distanceM: $distanceM, ')
..write('estimatedMillis: $estimatedMillis, ')
..write('geometry: $geometry')
..write('geometry: $geometry, ')
..write('isClosedLoop: $isClosedLoop')
..write(')'))
.toString();
}
@@ -1969,6 +2012,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
distanceM,
estimatedMillis,
geometry,
isClosedLoop,
);
@override
bool operator ==(Object other) =>
@@ -1980,7 +2024,8 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
other.activity == this.activity &&
other.distanceM == this.distanceM &&
other.estimatedMillis == this.estimatedMillis &&
other.geometry == this.geometry);
other.geometry == this.geometry &&
other.isClosedLoop == this.isClosedLoop);
}
class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
@@ -1991,6 +2036,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
final Value<double> distanceM;
final Value<int?> estimatedMillis;
final Value<String?> geometry;
final Value<bool> isClosedLoop;
const RoutePlansCompanion({
this.id = const Value.absent(),
this.name = const Value.absent(),
@@ -1999,6 +2045,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
this.distanceM = const Value.absent(),
this.estimatedMillis = const Value.absent(),
this.geometry = const Value.absent(),
this.isClosedLoop = const Value.absent(),
});
RoutePlansCompanion.insert({
this.id = const Value.absent(),
@@ -2008,6 +2055,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
this.distanceM = const Value.absent(),
this.estimatedMillis = const Value.absent(),
this.geometry = const Value.absent(),
this.isClosedLoop = const Value.absent(),
}) : name = Value(name),
createdAt = Value(createdAt);
static Insertable<RoutePlanRow> custom({
@@ -2018,6 +2066,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
Expression<double>? distanceM,
Expression<int>? estimatedMillis,
Expression<String>? geometry,
Expression<bool>? isClosedLoop,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
@@ -2027,6 +2076,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
if (distanceM != null) 'distanceM': distanceM,
if (estimatedMillis != null) 'estimatedMillis': estimatedMillis,
if (geometry != null) 'geometry': geometry,
if (isClosedLoop != null) 'isClosedLoop': isClosedLoop,
});
}
@@ -2038,6 +2088,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
Value<double>? distanceM,
Value<int?>? estimatedMillis,
Value<String?>? geometry,
Value<bool>? isClosedLoop,
}) {
return RoutePlansCompanion(
id: id ?? this.id,
@@ -2047,6 +2098,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
distanceM: distanceM ?? this.distanceM,
estimatedMillis: estimatedMillis ?? this.estimatedMillis,
geometry: geometry ?? this.geometry,
isClosedLoop: isClosedLoop ?? this.isClosedLoop,
);
}
@@ -2076,6 +2128,9 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
if (geometry.present) {
map['geometry'] = Variable<String>(geometry.value);
}
if (isClosedLoop.present) {
map['isClosedLoop'] = Variable<bool>(isClosedLoop.value);
}
return map;
}
@@ -2088,7 +2143,8 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
..write('activity: $activity, ')
..write('distanceM: $distanceM, ')
..write('estimatedMillis: $estimatedMillis, ')
..write('geometry: $geometry')
..write('geometry: $geometry, ')
..write('isClosedLoop: $isClosedLoop')
..write(')'))
.toString();
}
@@ -3980,6 +4036,7 @@ typedef $$RoutePlansTableCreateCompanionBuilder = RoutePlansCompanion Function({
Value<double> distanceM,
Value<int?> estimatedMillis,
Value<String?> geometry,
Value<bool> isClosedLoop,
});
typedef $$RoutePlansTableUpdateCompanionBuilder = RoutePlansCompanion Function({
Value<int> id,
@@ -3989,6 +4046,7 @@ typedef $$RoutePlansTableUpdateCompanionBuilder = RoutePlansCompanion Function({
Value<double> distanceM,
Value<int?> estimatedMillis,
Value<String?> geometry,
Value<bool> isClosedLoop,
});
final class $$RoutePlansTableReferences
@@ -4059,6 +4117,11 @@ class $$RoutePlansTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<bool> get isClosedLoop => $composableBuilder(
column: $table.isClosedLoop,
builder: (column) => ColumnFilters(column),
);
Expression<bool> waypointsRefs(
Expression<bool> Function($$WaypointsTableFilterComposer f) f,
) {
@@ -4128,6 +4191,11 @@ class $$RoutePlansTableOrderingComposer
column: $table.geometry,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<bool> get isClosedLoop => $composableBuilder(
column: $table.isClosedLoop,
builder: (column) => ColumnOrderings(column),
);
}
class $$RoutePlansTableAnnotationComposer
@@ -4162,6 +4230,11 @@ class $$RoutePlansTableAnnotationComposer
GeneratedColumn<String> get geometry =>
$composableBuilder(column: $table.geometry, builder: (column) => column);
GeneratedColumn<bool> get isClosedLoop => $composableBuilder(
column: $table.isClosedLoop,
builder: (column) => column,
);
Expression<T> waypointsRefs<T extends Object>(
Expression<T> Function($$WaypointsTableAnnotationComposer a) f,
) {
@@ -4223,6 +4296,7 @@ class $$RoutePlansTableTableManager
Value<double> distanceM = const Value.absent(),
Value<int?> estimatedMillis = const Value.absent(),
Value<String?> geometry = const Value.absent(),
Value<bool> isClosedLoop = const Value.absent(),
}) => RoutePlansCompanion(
id: id,
name: name,
@@ -4231,6 +4305,7 @@ class $$RoutePlansTableTableManager
distanceM: distanceM,
estimatedMillis: estimatedMillis,
geometry: geometry,
isClosedLoop: isClosedLoop,
),
createCompanionCallback:
({
@@ -4241,6 +4316,7 @@ class $$RoutePlansTableTableManager
Value<double> distanceM = const Value.absent(),
Value<int?> estimatedMillis = const Value.absent(),
Value<String?> geometry = const Value.absent(),
Value<bool> isClosedLoop = const Value.absent(),
}) => RoutePlansCompanion.insert(
id: id,
name: name,
@@ -4249,6 +4325,7 @@ class $$RoutePlansTableTableManager
distanceM: distanceM,
estimatedMillis: estimatedMillis,
geometry: geometry,
isClosedLoop: isClosedLoop,
),
withReferenceMapper: (p0) => p0
.map(

View File

@@ -32,6 +32,13 @@ class RoutePlanRepository {
Future<void> renameRoutePlan(int id, String name) => _db.renameRoutePlan(id, name);
/// Toggles whether the route loops back to its first waypoint. The closing segment
/// changes the total distance, so this recomputes it same as any waypoint edit.
Future<void> setClosedLoop(int routeId, bool value) async {
await _db.setRoutePlanClosedLoop(routeId, value);
await _recomputeDistance(routeId);
}
/// Waypoints cascade with it.
Future<void> deleteRoutePlan(int id) => _db.deleteRoutePlan(id);
@@ -93,10 +100,13 @@ class RoutePlanRepository {
}
Future<void> _recomputeDistance(int routeId) async {
final route = await _db.getRoutePlan(routeId);
final waypoints = await _db.waypointsForRoute(routeId);
final distance = geo.pathLengthMeters([
for (final w in waypoints) geo.LatLon(w.latitude, w.longitude),
]);
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);
}
}

View File

@@ -241,6 +241,7 @@ class RoutePlan {
this.distanceM = 0.0,
this.estimatedMillis,
this.geometry,
this.isClosedLoop = false,
});
final int id;
@@ -256,6 +257,9 @@ class RoutePlan {
/// never stored -- it is fully derived from them, so there is nothing to persist.
final String? geometry;
/// Whether the route loops back to its first waypoint after the last one.
final bool isClosedLoop;
RoutePlan copyWith({
int? id,
String? name,
@@ -264,6 +268,7 @@ class RoutePlan {
double? distanceM,
int? estimatedMillis,
String? geometry,
bool? isClosedLoop,
}) => RoutePlan(
id: id ?? this.id,
name: name ?? this.name,
@@ -272,6 +277,7 @@ class RoutePlan {
distanceM: distanceM ?? this.distanceM,
estimatedMillis: estimatedMillis ?? this.estimatedMillis,
geometry: geometry ?? this.geometry,
isClosedLoop: isClosedLoop ?? this.isClosedLoop,
);
}

View File

@@ -202,6 +202,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen>
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,
// Visibly distinct from a recorded path (see the
@@ -315,10 +317,14 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen>
right: 8,
child: _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 {
await repo.deleteRoutePlan(widget.routeId);
widget.onBack?.call();
@@ -509,14 +515,20 @@ class _DropPinTooltipState extends State<_DropPinTooltip>
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 hasWaypoints;
final bool canCloseLoop;
final bool isClosedLoop;
final VoidCallback onRename;
final VoidCallback? onDownload;
final VoidCallback onToggleLoop;
final VoidCallback onDelete;
@override
@@ -528,6 +540,7 @@ class _OverflowMenu extends StatelessWidget {
onSelected: (value) => switch (value) {
'rename' => onRename(),
'download' => onDownload?.call(),
'closeLoop' => onToggleLoop(),
'delete' => onDelete(),
_ => null,
},
@@ -543,6 +556,12 @@ class _OverflowMenu extends StatelessWidget {
enabled: hasWaypoints,
child: const Text('Download offline tiles'),
),
PopupMenuItem(
key: const Key('toggle-closed-loop'),
value: 'closeLoop',
enabled: canCloseLoop,
child: Text(isClosedLoop ? 'Open the loop' : 'Close the loop'),
),
const PopupMenuItem(
key: Key('delete-route'),
value: 'delete',