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:
@@ -1,6 +1,6 @@
|
|||||||
# FB-05 — Closed-loop routes in Route Planner
|
# FB-05 — Closed-loop routes in Route Planner
|
||||||
|
|
||||||
**Depends on** FB-04 (same file, heavily edited — sequence after it merges) · **Size** M · **Status** Not started
|
**Depends on** FB-04 (same file, heavily edited — sequence after it merges) · **Size** M · **Status** Done
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Route planning currently only supports a one-way A→B→C path. Add the ability to close
|
Route planning currently only supports a one-way A→B→C path. Add the ability to close
|
||||||
@@ -409,3 +409,44 @@ _OverflowMenu(
|
|||||||
Turn-by-turn following of a closed loop (V3-09, already deferred). Any UI indication of
|
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
|
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.
|
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`, default `false`) to `RoutePlans` in
|
||||||
|
`lib/src/data/database.dart`, bumped `schemaVersion` from 3 to 4, and regenerated
|
||||||
|
`database.g.dart` via `dart run build_runner build`.
|
||||||
|
- **Deviation**: the migration guard needed to be `if (from >= 3 && from < 4)` rather
|
||||||
|
than the ticket's plain `if (from < 4)`. A v2 → v4 upgrade runs the existing
|
||||||
|
`if (from < 3)` branch first, which calls `m.createTable(routePlans)` — and since that
|
||||||
|
table definition already includes `isClosedLoop` (Drift generates `CREATE TABLE` from
|
||||||
|
the *current* Dart schema, not a historical snapshot), an unconditional `from < 4`
|
||||||
|
then tried to `ADD COLUMN isClosedLoop` a second time and hit a duplicate-column
|
||||||
|
SQLite error. Restricting the `addColumn` step 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 fresh `createTable` instead. 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 `isClosedLoop` to the `RoutePlan` domain model, `copyWith`, the
|
||||||
|
`insertRoutePlan`/`_toRoutePlan` glue, and a new `setRoutePlanClosedLoop` method in
|
||||||
|
`database.dart`.
|
||||||
|
- Added `RoutePlanRepository.setClosedLoop` and updated `_recomputeDistance` to append
|
||||||
|
the first waypoint's coordinates as a closing point when the route is closed and has
|
||||||
|
2+ waypoints, exactly per the design.
|
||||||
|
- Extended `_OverflowMenu` in `route_planner_screen.dart` with `canCloseLoop`/
|
||||||
|
`isClosedLoop`/`onToggleLoop` and 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 `route` access to a guarded `AsyncValue`
|
||||||
|
pattern (no more `.valueOrNull`) by the time this ticket landed; the new code reads
|
||||||
|
`route.isClosedLoop` off the same already-resolved `route` value 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).
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ class RoutePlans extends Table {
|
|||||||
/// Null in this ticket -- the polyline is derived from waypoints, not stored. V3-08
|
/// 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.
|
/// fills it with the road-snapped geometry, which is not cheaply re-derivable.
|
||||||
TextColumn get geometry => text().nullable()();
|
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.
|
/// One pin on a [RoutePlans] row.
|
||||||
@@ -146,7 +149,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase(super.e);
|
AppDatabase(super.e);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 3;
|
int get schemaVersion => 4;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -162,6 +165,13 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
await m.createTable(routePlans);
|
await m.createTable(routePlans);
|
||||||
await m.createTable(waypoints);
|
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 {
|
beforeOpen: (details) async {
|
||||||
// Non-negotiable: without this the CASCADE relationships above do nothing.
|
// Non-negotiable: without this the CASCADE relationships above do nothing.
|
||||||
@@ -553,6 +563,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
distanceM: Value(route.distanceM),
|
distanceM: Value(route.distanceM),
|
||||||
estimatedMillis: Value(route.estimatedMillis),
|
estimatedMillis: Value(route.estimatedMillis),
|
||||||
geometry: Value(route.geometry),
|
geometry: Value(route.geometry),
|
||||||
|
isClosedLoop: Value(route.isClosedLoop),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -566,6 +577,12 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
RoutePlansCompanion(distanceM: Value(distanceM)),
|
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.
|
/// Waypoints cascade with it.
|
||||||
Future<void> deleteRoutePlan(int id) =>
|
Future<void> deleteRoutePlan(int id) =>
|
||||||
(delete(routePlans)..where((r) => r.id.equals(id))).go();
|
(delete(routePlans)..where((r) => r.id.equals(id))).go();
|
||||||
@@ -617,6 +634,7 @@ domain.RoutePlan _toRoutePlan(RoutePlanRow r) => domain.RoutePlan(
|
|||||||
distanceM: r.distanceM,
|
distanceM: r.distanceM,
|
||||||
estimatedMillis: r.estimatedMillis,
|
estimatedMillis: r.estimatedMillis,
|
||||||
geometry: r.geometry,
|
geometry: r.geometry,
|
||||||
|
isClosedLoop: r.isClosedLoop,
|
||||||
);
|
);
|
||||||
|
|
||||||
domain.Waypoint _toWaypoint(WaypointRow r) => domain.Waypoint(
|
domain.Waypoint _toWaypoint(WaypointRow r) => domain.Waypoint(
|
||||||
|
|||||||
@@ -1706,6 +1706,21 @@ class $RoutePlansTable extends RoutePlans
|
|||||||
type: DriftSqlType.string,
|
type: DriftSqlType.string,
|
||||||
requiredDuringInsert: false,
|
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
|
@override
|
||||||
List<GeneratedColumn> get $columns => [
|
List<GeneratedColumn> get $columns => [
|
||||||
id,
|
id,
|
||||||
@@ -1715,6 +1730,7 @@ class $RoutePlansTable extends RoutePlans
|
|||||||
distanceM,
|
distanceM,
|
||||||
estimatedMillis,
|
estimatedMillis,
|
||||||
geometry,
|
geometry,
|
||||||
|
isClosedLoop,
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
@@ -1768,6 +1784,15 @@ class $RoutePlansTable extends RoutePlans
|
|||||||
geometry.isAcceptableOrUnknown(data['geometry']!, _geometryMeta),
|
geometry.isAcceptableOrUnknown(data['geometry']!, _geometryMeta),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('isClosedLoop')) {
|
||||||
|
context.handle(
|
||||||
|
_isClosedLoopMeta,
|
||||||
|
isClosedLoop.isAcceptableOrUnknown(
|
||||||
|
data['isClosedLoop']!,
|
||||||
|
_isClosedLoopMeta,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1807,6 +1832,10 @@ class $RoutePlansTable extends RoutePlans
|
|||||||
DriftSqlType.string,
|
DriftSqlType.string,
|
||||||
data['${effectivePrefix}geometry'],
|
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
|
/// 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.
|
/// fills it with the road-snapped geometry, which is not cheaply re-derivable.
|
||||||
final String? geometry;
|
final String? geometry;
|
||||||
|
|
||||||
|
/// Whether the route loops back to its first waypoint after the last one. See FB-05.
|
||||||
|
final bool isClosedLoop;
|
||||||
const RoutePlanRow({
|
const RoutePlanRow({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -1842,6 +1874,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
required this.distanceM,
|
required this.distanceM,
|
||||||
this.estimatedMillis,
|
this.estimatedMillis,
|
||||||
this.geometry,
|
this.geometry,
|
||||||
|
required this.isClosedLoop,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||||
@@ -1861,6 +1894,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
if (!nullToAbsent || geometry != null) {
|
if (!nullToAbsent || geometry != null) {
|
||||||
map['geometry'] = Variable<String>(geometry);
|
map['geometry'] = Variable<String>(geometry);
|
||||||
}
|
}
|
||||||
|
map['isClosedLoop'] = Variable<bool>(isClosedLoop);
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1877,6 +1911,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
geometry: geometry == null && nullToAbsent
|
geometry: geometry == null && nullToAbsent
|
||||||
? const Value.absent()
|
? const Value.absent()
|
||||||
: Value(geometry),
|
: Value(geometry),
|
||||||
|
isClosedLoop: Value(isClosedLoop),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1895,6 +1930,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
distanceM: serializer.fromJson<double>(json['distanceM']),
|
distanceM: serializer.fromJson<double>(json['distanceM']),
|
||||||
estimatedMillis: serializer.fromJson<int?>(json['estimatedMillis']),
|
estimatedMillis: serializer.fromJson<int?>(json['estimatedMillis']),
|
||||||
geometry: serializer.fromJson<String?>(json['geometry']),
|
geometry: serializer.fromJson<String?>(json['geometry']),
|
||||||
|
isClosedLoop: serializer.fromJson<bool>(json['isClosedLoop']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@override
|
@override
|
||||||
@@ -1910,6 +1946,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
'distanceM': serializer.toJson<double>(distanceM),
|
'distanceM': serializer.toJson<double>(distanceM),
|
||||||
'estimatedMillis': serializer.toJson<int?>(estimatedMillis),
|
'estimatedMillis': serializer.toJson<int?>(estimatedMillis),
|
||||||
'geometry': serializer.toJson<String?>(geometry),
|
'geometry': serializer.toJson<String?>(geometry),
|
||||||
|
'isClosedLoop': serializer.toJson<bool>(isClosedLoop),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1921,6 +1958,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
double? distanceM,
|
double? distanceM,
|
||||||
Value<int?> estimatedMillis = const Value.absent(),
|
Value<int?> estimatedMillis = const Value.absent(),
|
||||||
Value<String?> geometry = const Value.absent(),
|
Value<String?> geometry = const Value.absent(),
|
||||||
|
bool? isClosedLoop,
|
||||||
}) => RoutePlanRow(
|
}) => RoutePlanRow(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
@@ -1931,6 +1969,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
? estimatedMillis.value
|
? estimatedMillis.value
|
||||||
: this.estimatedMillis,
|
: this.estimatedMillis,
|
||||||
geometry: geometry.present ? geometry.value : this.geometry,
|
geometry: geometry.present ? geometry.value : this.geometry,
|
||||||
|
isClosedLoop: isClosedLoop ?? this.isClosedLoop,
|
||||||
);
|
);
|
||||||
RoutePlanRow copyWithCompanion(RoutePlansCompanion data) {
|
RoutePlanRow copyWithCompanion(RoutePlansCompanion data) {
|
||||||
return RoutePlanRow(
|
return RoutePlanRow(
|
||||||
@@ -1943,6 +1982,9 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
? data.estimatedMillis.value
|
? data.estimatedMillis.value
|
||||||
: this.estimatedMillis,
|
: this.estimatedMillis,
|
||||||
geometry: data.geometry.present ? data.geometry.value : this.geometry,
|
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('activity: $activity, ')
|
||||||
..write('distanceM: $distanceM, ')
|
..write('distanceM: $distanceM, ')
|
||||||
..write('estimatedMillis: $estimatedMillis, ')
|
..write('estimatedMillis: $estimatedMillis, ')
|
||||||
..write('geometry: $geometry')
|
..write('geometry: $geometry, ')
|
||||||
|
..write('isClosedLoop: $isClosedLoop')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
@@ -1969,6 +2012,7 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
distanceM,
|
distanceM,
|
||||||
estimatedMillis,
|
estimatedMillis,
|
||||||
geometry,
|
geometry,
|
||||||
|
isClosedLoop,
|
||||||
);
|
);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
@@ -1980,7 +2024,8 @@ class RoutePlanRow extends DataClass implements Insertable<RoutePlanRow> {
|
|||||||
other.activity == this.activity &&
|
other.activity == this.activity &&
|
||||||
other.distanceM == this.distanceM &&
|
other.distanceM == this.distanceM &&
|
||||||
other.estimatedMillis == this.estimatedMillis &&
|
other.estimatedMillis == this.estimatedMillis &&
|
||||||
other.geometry == this.geometry);
|
other.geometry == this.geometry &&
|
||||||
|
other.isClosedLoop == this.isClosedLoop);
|
||||||
}
|
}
|
||||||
|
|
||||||
class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
||||||
@@ -1991,6 +2036,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
final Value<double> distanceM;
|
final Value<double> distanceM;
|
||||||
final Value<int?> estimatedMillis;
|
final Value<int?> estimatedMillis;
|
||||||
final Value<String?> geometry;
|
final Value<String?> geometry;
|
||||||
|
final Value<bool> isClosedLoop;
|
||||||
const RoutePlansCompanion({
|
const RoutePlansCompanion({
|
||||||
this.id = const Value.absent(),
|
this.id = const Value.absent(),
|
||||||
this.name = const Value.absent(),
|
this.name = const Value.absent(),
|
||||||
@@ -1999,6 +2045,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
this.distanceM = const Value.absent(),
|
this.distanceM = const Value.absent(),
|
||||||
this.estimatedMillis = const Value.absent(),
|
this.estimatedMillis = const Value.absent(),
|
||||||
this.geometry = const Value.absent(),
|
this.geometry = const Value.absent(),
|
||||||
|
this.isClosedLoop = const Value.absent(),
|
||||||
});
|
});
|
||||||
RoutePlansCompanion.insert({
|
RoutePlansCompanion.insert({
|
||||||
this.id = const Value.absent(),
|
this.id = const Value.absent(),
|
||||||
@@ -2008,6 +2055,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
this.distanceM = const Value.absent(),
|
this.distanceM = const Value.absent(),
|
||||||
this.estimatedMillis = const Value.absent(),
|
this.estimatedMillis = const Value.absent(),
|
||||||
this.geometry = const Value.absent(),
|
this.geometry = const Value.absent(),
|
||||||
|
this.isClosedLoop = const Value.absent(),
|
||||||
}) : name = Value(name),
|
}) : name = Value(name),
|
||||||
createdAt = Value(createdAt);
|
createdAt = Value(createdAt);
|
||||||
static Insertable<RoutePlanRow> custom({
|
static Insertable<RoutePlanRow> custom({
|
||||||
@@ -2018,6 +2066,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
Expression<double>? distanceM,
|
Expression<double>? distanceM,
|
||||||
Expression<int>? estimatedMillis,
|
Expression<int>? estimatedMillis,
|
||||||
Expression<String>? geometry,
|
Expression<String>? geometry,
|
||||||
|
Expression<bool>? isClosedLoop,
|
||||||
}) {
|
}) {
|
||||||
return RawValuesInsertable({
|
return RawValuesInsertable({
|
||||||
if (id != null) 'id': id,
|
if (id != null) 'id': id,
|
||||||
@@ -2027,6 +2076,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
if (distanceM != null) 'distanceM': distanceM,
|
if (distanceM != null) 'distanceM': distanceM,
|
||||||
if (estimatedMillis != null) 'estimatedMillis': estimatedMillis,
|
if (estimatedMillis != null) 'estimatedMillis': estimatedMillis,
|
||||||
if (geometry != null) 'geometry': geometry,
|
if (geometry != null) 'geometry': geometry,
|
||||||
|
if (isClosedLoop != null) 'isClosedLoop': isClosedLoop,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2038,6 +2088,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
Value<double>? distanceM,
|
Value<double>? distanceM,
|
||||||
Value<int?>? estimatedMillis,
|
Value<int?>? estimatedMillis,
|
||||||
Value<String?>? geometry,
|
Value<String?>? geometry,
|
||||||
|
Value<bool>? isClosedLoop,
|
||||||
}) {
|
}) {
|
||||||
return RoutePlansCompanion(
|
return RoutePlansCompanion(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -2047,6 +2098,7 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
distanceM: distanceM ?? this.distanceM,
|
distanceM: distanceM ?? this.distanceM,
|
||||||
estimatedMillis: estimatedMillis ?? this.estimatedMillis,
|
estimatedMillis: estimatedMillis ?? this.estimatedMillis,
|
||||||
geometry: geometry ?? this.geometry,
|
geometry: geometry ?? this.geometry,
|
||||||
|
isClosedLoop: isClosedLoop ?? this.isClosedLoop,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2076,6 +2128,9 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
if (geometry.present) {
|
if (geometry.present) {
|
||||||
map['geometry'] = Variable<String>(geometry.value);
|
map['geometry'] = Variable<String>(geometry.value);
|
||||||
}
|
}
|
||||||
|
if (isClosedLoop.present) {
|
||||||
|
map['isClosedLoop'] = Variable<bool>(isClosedLoop.value);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2088,7 +2143,8 @@ class RoutePlansCompanion extends UpdateCompanion<RoutePlanRow> {
|
|||||||
..write('activity: $activity, ')
|
..write('activity: $activity, ')
|
||||||
..write('distanceM: $distanceM, ')
|
..write('distanceM: $distanceM, ')
|
||||||
..write('estimatedMillis: $estimatedMillis, ')
|
..write('estimatedMillis: $estimatedMillis, ')
|
||||||
..write('geometry: $geometry')
|
..write('geometry: $geometry, ')
|
||||||
|
..write('isClosedLoop: $isClosedLoop')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
@@ -3980,6 +4036,7 @@ typedef $$RoutePlansTableCreateCompanionBuilder = RoutePlansCompanion Function({
|
|||||||
Value<double> distanceM,
|
Value<double> distanceM,
|
||||||
Value<int?> estimatedMillis,
|
Value<int?> estimatedMillis,
|
||||||
Value<String?> geometry,
|
Value<String?> geometry,
|
||||||
|
Value<bool> isClosedLoop,
|
||||||
});
|
});
|
||||||
typedef $$RoutePlansTableUpdateCompanionBuilder = RoutePlansCompanion Function({
|
typedef $$RoutePlansTableUpdateCompanionBuilder = RoutePlansCompanion Function({
|
||||||
Value<int> id,
|
Value<int> id,
|
||||||
@@ -3989,6 +4046,7 @@ typedef $$RoutePlansTableUpdateCompanionBuilder = RoutePlansCompanion Function({
|
|||||||
Value<double> distanceM,
|
Value<double> distanceM,
|
||||||
Value<int?> estimatedMillis,
|
Value<int?> estimatedMillis,
|
||||||
Value<String?> geometry,
|
Value<String?> geometry,
|
||||||
|
Value<bool> isClosedLoop,
|
||||||
});
|
});
|
||||||
|
|
||||||
final class $$RoutePlansTableReferences
|
final class $$RoutePlansTableReferences
|
||||||
@@ -4059,6 +4117,11 @@ class $$RoutePlansTableFilterComposer
|
|||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnFilters<bool> get isClosedLoop => $composableBuilder(
|
||||||
|
column: $table.isClosedLoop,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
Expression<bool> waypointsRefs(
|
Expression<bool> waypointsRefs(
|
||||||
Expression<bool> Function($$WaypointsTableFilterComposer f) f,
|
Expression<bool> Function($$WaypointsTableFilterComposer f) f,
|
||||||
) {
|
) {
|
||||||
@@ -4128,6 +4191,11 @@ class $$RoutePlansTableOrderingComposer
|
|||||||
column: $table.geometry,
|
column: $table.geometry,
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<bool> get isClosedLoop => $composableBuilder(
|
||||||
|
column: $table.isClosedLoop,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$RoutePlansTableAnnotationComposer
|
class $$RoutePlansTableAnnotationComposer
|
||||||
@@ -4162,6 +4230,11 @@ class $$RoutePlansTableAnnotationComposer
|
|||||||
GeneratedColumn<String> get geometry =>
|
GeneratedColumn<String> get geometry =>
|
||||||
$composableBuilder(column: $table.geometry, builder: (column) => column);
|
$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> waypointsRefs<T extends Object>(
|
||||||
Expression<T> Function($$WaypointsTableAnnotationComposer a) f,
|
Expression<T> Function($$WaypointsTableAnnotationComposer a) f,
|
||||||
) {
|
) {
|
||||||
@@ -4223,6 +4296,7 @@ class $$RoutePlansTableTableManager
|
|||||||
Value<double> distanceM = const Value.absent(),
|
Value<double> distanceM = const Value.absent(),
|
||||||
Value<int?> estimatedMillis = const Value.absent(),
|
Value<int?> estimatedMillis = const Value.absent(),
|
||||||
Value<String?> geometry = const Value.absent(),
|
Value<String?> geometry = const Value.absent(),
|
||||||
|
Value<bool> isClosedLoop = const Value.absent(),
|
||||||
}) => RoutePlansCompanion(
|
}) => RoutePlansCompanion(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
@@ -4231,6 +4305,7 @@ class $$RoutePlansTableTableManager
|
|||||||
distanceM: distanceM,
|
distanceM: distanceM,
|
||||||
estimatedMillis: estimatedMillis,
|
estimatedMillis: estimatedMillis,
|
||||||
geometry: geometry,
|
geometry: geometry,
|
||||||
|
isClosedLoop: isClosedLoop,
|
||||||
),
|
),
|
||||||
createCompanionCallback:
|
createCompanionCallback:
|
||||||
({
|
({
|
||||||
@@ -4241,6 +4316,7 @@ class $$RoutePlansTableTableManager
|
|||||||
Value<double> distanceM = const Value.absent(),
|
Value<double> distanceM = const Value.absent(),
|
||||||
Value<int?> estimatedMillis = const Value.absent(),
|
Value<int?> estimatedMillis = const Value.absent(),
|
||||||
Value<String?> geometry = const Value.absent(),
|
Value<String?> geometry = const Value.absent(),
|
||||||
|
Value<bool> isClosedLoop = const Value.absent(),
|
||||||
}) => RoutePlansCompanion.insert(
|
}) => RoutePlansCompanion.insert(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
@@ -4249,6 +4325,7 @@ class $$RoutePlansTableTableManager
|
|||||||
distanceM: distanceM,
|
distanceM: distanceM,
|
||||||
estimatedMillis: estimatedMillis,
|
estimatedMillis: estimatedMillis,
|
||||||
geometry: geometry,
|
geometry: geometry,
|
||||||
|
isClosedLoop: isClosedLoop,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ class RoutePlanRepository {
|
|||||||
|
|
||||||
Future<void> renameRoutePlan(int id, String name) => _db.renameRoutePlan(id, name);
|
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.
|
/// Waypoints cascade with it.
|
||||||
Future<void> deleteRoutePlan(int id) => _db.deleteRoutePlan(id);
|
Future<void> deleteRoutePlan(int id) => _db.deleteRoutePlan(id);
|
||||||
|
|
||||||
@@ -93,10 +100,13 @@ class RoutePlanRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _recomputeDistance(int routeId) async {
|
Future<void> _recomputeDistance(int routeId) async {
|
||||||
|
final route = await _db.getRoutePlan(routeId);
|
||||||
final waypoints = await _db.waypointsForRoute(routeId);
|
final waypoints = await _db.waypointsForRoute(routeId);
|
||||||
final distance = geo.pathLengthMeters([
|
final points = [for (final w in waypoints) geo.LatLon(w.latitude, w.longitude)];
|
||||||
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);
|
await _db.setRoutePlanDistance(routeId, distance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ class RoutePlan {
|
|||||||
this.distanceM = 0.0,
|
this.distanceM = 0.0,
|
||||||
this.estimatedMillis,
|
this.estimatedMillis,
|
||||||
this.geometry,
|
this.geometry,
|
||||||
|
this.isClosedLoop = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int id;
|
final int id;
|
||||||
@@ -256,6 +257,9 @@ class RoutePlan {
|
|||||||
/// never stored -- it is fully derived from them, so there is nothing to persist.
|
/// never stored -- it is fully derived from them, so there is nothing to persist.
|
||||||
final String? geometry;
|
final String? geometry;
|
||||||
|
|
||||||
|
/// Whether the route loops back to its first waypoint after the last one.
|
||||||
|
final bool isClosedLoop;
|
||||||
|
|
||||||
RoutePlan copyWith({
|
RoutePlan copyWith({
|
||||||
int? id,
|
int? id,
|
||||||
String? name,
|
String? name,
|
||||||
@@ -264,6 +268,7 @@ class RoutePlan {
|
|||||||
double? distanceM,
|
double? distanceM,
|
||||||
int? estimatedMillis,
|
int? estimatedMillis,
|
||||||
String? geometry,
|
String? geometry,
|
||||||
|
bool? isClosedLoop,
|
||||||
}) => RoutePlan(
|
}) => RoutePlan(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
@@ -272,6 +277,7 @@ class RoutePlan {
|
|||||||
distanceM: distanceM ?? this.distanceM,
|
distanceM: distanceM ?? this.distanceM,
|
||||||
estimatedMillis: estimatedMillis ?? this.estimatedMillis,
|
estimatedMillis: estimatedMillis ?? this.estimatedMillis,
|
||||||
geometry: geometry ?? this.geometry,
|
geometry: geometry ?? this.geometry,
|
||||||
|
isClosedLoop: isClosedLoop ?? this.isClosedLoop,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen>
|
|||||||
Polyline(
|
Polyline(
|
||||||
points: [
|
points: [
|
||||||
for (final w in waypoints) ll.LatLng(w.latitude, w.longitude),
|
for (final w in waypoints) ll.LatLng(w.latitude, w.longitude),
|
||||||
|
if (route.isClosedLoop)
|
||||||
|
ll.LatLng(waypoints.first.latitude, waypoints.first.longitude),
|
||||||
],
|
],
|
||||||
strokeWidth: 4,
|
strokeWidth: 4,
|
||||||
// Visibly distinct from a recorded path (see the
|
// Visibly distinct from a recorded path (see the
|
||||||
@@ -315,10 +317,14 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen>
|
|||||||
right: 8,
|
right: 8,
|
||||||
child: _OverflowMenu(
|
child: _OverflowMenu(
|
||||||
hasWaypoints: waypoints.isNotEmpty,
|
hasWaypoints: waypoints.isNotEmpty,
|
||||||
|
canCloseLoop: waypoints.length >= 2,
|
||||||
|
isClosedLoop: route.isClosedLoop,
|
||||||
onRename: () => _rename(context, repo, route),
|
onRename: () => _rename(context, repo, route),
|
||||||
onDownload: waypoints.isEmpty
|
onDownload: waypoints.isEmpty
|
||||||
? null
|
? null
|
||||||
: () => _downloadOfflineTiles(context, waypoints),
|
: () => _downloadOfflineTiles(context, waypoints),
|
||||||
|
onToggleLoop: () =>
|
||||||
|
repo.setClosedLoop(widget.routeId, !route.isClosedLoop),
|
||||||
onDelete: () async {
|
onDelete: () async {
|
||||||
await repo.deleteRoutePlan(widget.routeId);
|
await repo.deleteRoutePlan(widget.routeId);
|
||||||
widget.onBack?.call();
|
widget.onBack?.call();
|
||||||
@@ -509,14 +515,20 @@ class _DropPinTooltipState extends State<_DropPinTooltip>
|
|||||||
class _OverflowMenu extends StatelessWidget {
|
class _OverflowMenu extends StatelessWidget {
|
||||||
const _OverflowMenu({
|
const _OverflowMenu({
|
||||||
required this.hasWaypoints,
|
required this.hasWaypoints,
|
||||||
|
required this.canCloseLoop,
|
||||||
|
required this.isClosedLoop,
|
||||||
required this.onRename,
|
required this.onRename,
|
||||||
required this.onDownload,
|
required this.onDownload,
|
||||||
|
required this.onToggleLoop,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool hasWaypoints;
|
final bool hasWaypoints;
|
||||||
|
final bool canCloseLoop;
|
||||||
|
final bool isClosedLoop;
|
||||||
final VoidCallback onRename;
|
final VoidCallback onRename;
|
||||||
final VoidCallback? onDownload;
|
final VoidCallback? onDownload;
|
||||||
|
final VoidCallback onToggleLoop;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -528,6 +540,7 @@ class _OverflowMenu extends StatelessWidget {
|
|||||||
onSelected: (value) => switch (value) {
|
onSelected: (value) => switch (value) {
|
||||||
'rename' => onRename(),
|
'rename' => onRename(),
|
||||||
'download' => onDownload?.call(),
|
'download' => onDownload?.call(),
|
||||||
|
'closeLoop' => onToggleLoop(),
|
||||||
'delete' => onDelete(),
|
'delete' => onDelete(),
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
@@ -543,6 +556,12 @@ class _OverflowMenu extends StatelessWidget {
|
|||||||
enabled: hasWaypoints,
|
enabled: hasWaypoints,
|
||||||
child: const Text('Download offline tiles'),
|
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(
|
const PopupMenuItem(
|
||||||
key: Key('delete-route'),
|
key: Key('delete-route'),
|
||||||
value: 'delete',
|
value: 'delete',
|
||||||
|
|||||||
@@ -177,4 +177,57 @@ void main() {
|
|||||||
final waypoints = await db.waypointsForRoute(routeId);
|
final waypoints = await db.waypointsForRoute(routeId);
|
||||||
expect(waypoints, hasLength(1));
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,61 @@ void main() {
|
|||||||
expect(await db.waypointsForRoute(id), isEmpty);
|
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',
|
test('route plans never appear alongside trips and never affect ride totals',
|
||||||
() async {
|
() async {
|
||||||
await routes.createRoutePlan(1000, name: 'Plan A');
|
await routes.createRoutePlan(1000, name: 'Plan A');
|
||||||
|
|||||||
@@ -274,6 +274,78 @@ void main() {
|
|||||||
expect(find.textContaining('MB'), findsOneWidget);
|
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 '
|
screenTest('the shell nav bar correctly shows Plan active on this screen '
|
||||||
'(UI-06)', (tester) async {
|
'(UI-06)', (tester) async {
|
||||||
final id = await repo.createRoutePlan(1000);
|
final id = await repo.createRoutePlan(1000);
|
||||||
|
|||||||
Reference in New Issue
Block a user