feat(train): a route's line pulled out into a new waypoint, and any waypoint taken off (ADR 0030, D59)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
32c94aa601
commit
57262ebf73
13 changed files with 371 additions and 36 deletions
File diff suppressed because one or more lines are too long
|
|
@ -395,7 +395,8 @@
|
||||||
"routeProfileTrekkingBike": "Trekkingrad",
|
"routeProfileTrekkingBike": "Trekkingrad",
|
||||||
"routeProfileGravel": "Gravelbike",
|
"routeProfileGravel": "Gravelbike",
|
||||||
"routeProfileMtb": "Mountainbike",
|
"routeProfileMtb": "Mountainbike",
|
||||||
"routeTapHint": "Tipp auf die Karte, um einen Wegpunkt hinzuzufügen; halt einen gedrückt und zieh ihn, um ihn zu verschieben.",
|
"routeTapHint": "Tipp auf die Karte, um einen Wegpunkt hinzuzufügen, und auf einen, um ihn zu entfernen. Halt einen gedrückt, um ihn zu ziehen, oder die Linie, um einen weiteren herauszuziehen.",
|
||||||
|
"routeWaypointRemove": "Diesen Wegpunkt entfernen",
|
||||||
"routeUndo": "Letzten Wegpunkt entfernen",
|
"routeUndo": "Letzten Wegpunkt entfernen",
|
||||||
"routeSearchHint": "Nach einem Ort suchen",
|
"routeSearchHint": "Nach einem Ort suchen",
|
||||||
"routeSearchClear": "Suche leeren",
|
"routeSearchClear": "Suche leeren",
|
||||||
|
|
|
||||||
|
|
@ -501,7 +501,9 @@
|
||||||
"routeProfileTrekkingBike": "Trekking bike",
|
"routeProfileTrekkingBike": "Trekking bike",
|
||||||
"routeProfileGravel": "Gravel bike",
|
"routeProfileGravel": "Gravel bike",
|
||||||
"routeProfileMtb": "Mountain bike",
|
"routeProfileMtb": "Mountain bike",
|
||||||
"routeTapHint": "Tap the map to add a waypoint; hold one and drag it to move it.",
|
"routeTapHint": "Tap the map to add a waypoint, tap one to take it off. Hold one to drag it, or hold the line to pull out another.",
|
||||||
|
"routeWaypointRemove": "Take this waypoint off",
|
||||||
|
"@routeWaypointRemove": {"description": "The menu of a waypoint tapped on the planner's map (D59)."},
|
||||||
"routeUndo": "Take the last waypoint off",
|
"routeUndo": "Take the last waypoint off",
|
||||||
"routeSearchHint": "Search for a place",
|
"routeSearchHint": "Search for a place",
|
||||||
"@routeSearchHint": {"description": "The planner's search field over the map (D50)."},
|
"@routeSearchHint": {"description": "The planner's search field over the map (D50)."},
|
||||||
|
|
|
||||||
|
|
@ -2395,9 +2395,15 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @routeTapHint.
|
/// No description provided for @routeTapHint.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Tap the map to add a waypoint; hold one and drag it to move it.'**
|
/// **'Tap the map to add a waypoint, tap one to take it off. Hold one to drag it, or hold the line to pull out another.'**
|
||||||
String get routeTapHint;
|
String get routeTapHint;
|
||||||
|
|
||||||
|
/// The menu of a waypoint tapped on the planner's map (D59).
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Take this waypoint off'**
|
||||||
|
String get routeWaypointRemove;
|
||||||
|
|
||||||
/// No description provided for @routeUndo.
|
/// No description provided for @routeUndo.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1427,7 +1427,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get routeTapHint =>
|
String get routeTapHint =>
|
||||||
'Tipp auf die Karte, um einen Wegpunkt hinzuzufügen; halt einen gedrückt und zieh ihn, um ihn zu verschieben.';
|
'Tipp auf die Karte, um einen Wegpunkt hinzuzufügen, und auf einen, um ihn zu entfernen. Halt einen gedrückt, um ihn zu ziehen, oder die Linie, um einen weiteren herauszuziehen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get routeWaypointRemove => 'Diesen Wegpunkt entfernen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get routeUndo => 'Letzten Wegpunkt entfernen';
|
String get routeUndo => 'Letzten Wegpunkt entfernen';
|
||||||
|
|
|
||||||
|
|
@ -1420,7 +1420,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get routeTapHint =>
|
String get routeTapHint =>
|
||||||
'Tap the map to add a waypoint; hold one and drag it to move it.';
|
'Tap the map to add a waypoint, tap one to take it off. Hold one to drag it, or hold the line to pull out another.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get routeWaypointRemove => 'Take this waypoint off';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get routeUndo => 'Take the last waypoint off';
|
String get routeUndo => 'Take the last waypoint off';
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// A route being planned or changed (ADR 0030, D44): waypoints tapped,
|
/// A route being planned or changed (ADR 0030, D44, D59): waypoints
|
||||||
/// dragged and the last undone, a new route's first where the phone is
|
/// tapped, dragged, pulled out of the line, taken off and the last undone, a new route's first where the phone is
|
||||||
/// (`here`, null where the device can't tell); the instance plans the way
|
/// (`here`, null where the device can't tell); the instance plans the way
|
||||||
/// through them after each change, an answer to an earlier change dropped
|
/// through them after each change, an answer to an earlier change dropped
|
||||||
/// once a later one is asked. Kept once named and planned; [id] null for a
|
/// once a later one is asked. Kept once named and planned; [id] null for a
|
||||||
|
|
@ -74,6 +74,16 @@ class RouteEditorCubit(
|
||||||
Future<void> move(int index, LatLng point) =>
|
Future<void> move(int index, LatLng point) =>
|
||||||
_replan(_route(waypoints: [...state.route.waypoints]..[index] = point));
|
_replan(_route(waypoints: [...state.route.waypoints]..[index] = point));
|
||||||
|
|
||||||
|
/// Inserts a waypoint at [index], pulled out of the line between the two
|
||||||
|
/// around it (D59).
|
||||||
|
Future<void> insert(int index, LatLng point) => _replan(
|
||||||
|
_route(waypoints: [...state.route.waypoints]..insert(index, point)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Takes waypoint [index] off (D59).
|
||||||
|
Future<void> remove(int index) =>
|
||||||
|
_replan(_route(waypoints: [...state.route.waypoints]..removeAt(index)));
|
||||||
|
|
||||||
/// Takes the last waypoint off.
|
/// Takes the last waypoint off.
|
||||||
Future<void> undo() async {
|
Future<void> undo() async {
|
||||||
final waypoints = state.route.waypoints;
|
final waypoints = state.route.waypoints;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
/// Where a waypoint pulled out of a route's [line] at [at] goes among its
|
||||||
|
/// [waypoints] (ADR 0030, D59): the index it is inserted at, between the
|
||||||
|
/// two waypoints of the stretch nearest [at], from 1 to one before the
|
||||||
|
/// last. [line] is the way planned through the waypoints, or the waypoints
|
||||||
|
/// themselves while none is.
|
||||||
|
int insertionIndex(List<LatLng> waypoints, List<LatLng> line, LatLng at) {
|
||||||
|
assert(waypoints.length > 1 && line.isNotEmpty, 'A route with a line');
|
||||||
|
final grabbed = _nearestStretch(line, at);
|
||||||
|
// Each waypoint's place on the line, found from the one before's on, so
|
||||||
|
// that a way passing a waypoint twice keeps its stretches in order.
|
||||||
|
var place = 0;
|
||||||
|
for (var index = 1; index < waypoints.length - 1; index++) {
|
||||||
|
place = _nearest(line, waypoints[index], from: place);
|
||||||
|
if (grabbed < place) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return waypoints.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The index of the stretch of [line] nearest [point], from its point of
|
||||||
|
/// that index to the next; the first of those as near.
|
||||||
|
int _nearestStretch(List<LatLng> line, LatLng point) {
|
||||||
|
var best = 0;
|
||||||
|
var bestDistance = double.infinity;
|
||||||
|
for (var index = 0; index < line.length - 1; index++) {
|
||||||
|
final distance = _toStretch(line[index], line[index + 1], point);
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
best = index;
|
||||||
|
bestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The index of [line]'s point nearest [point], from [from] on; the first
|
||||||
|
/// of those as near.
|
||||||
|
int _nearest(List<LatLng> line, LatLng point, {required int from}) {
|
||||||
|
var best = from;
|
||||||
|
var bestDistance = double.infinity;
|
||||||
|
for (var index = from; index < line.length; index++) {
|
||||||
|
final distance = _toStretch(line[index], line[index], point);
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
best = index;
|
||||||
|
bestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The squared distance from [point] to the stretch from [a] to [b], in
|
||||||
|
/// degrees of latitude, longitude scaled by the latitude: enough to compare
|
||||||
|
/// points a route's length apart.
|
||||||
|
double _toStretch(LatLng a, LatLng b, LatLng point) {
|
||||||
|
final scale = cos(point.latitude * pi / 180);
|
||||||
|
final (ax, ay) = (a.longitude * scale, a.latitude);
|
||||||
|
final (bx, by) = (b.longitude * scale, b.latitude);
|
||||||
|
final (px, py) = (point.longitude * scale, point.latitude);
|
||||||
|
final (dx, dy) = (bx - ax, by - ay);
|
||||||
|
final length = dx * dx + dy * dy;
|
||||||
|
final along = length == 0
|
||||||
|
? 0.0
|
||||||
|
: (((px - ax) * dx + (py - ay) * dy) / length).clamp(0.0, 1.0);
|
||||||
|
final (x, y) = (ax + along * dx - px, ay + along * dy - py);
|
||||||
|
return x * x + y * y;
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/route/data/place_search_gateway.dart';
|
import 'package:halkyon_train_flutter/src/feature/route/data/place_search_gateway.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/route/data/route_analysis.dart';
|
import 'package:halkyon_train_flutter/src/feature/route/data/route_analysis.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
|
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/route/model/route_legs.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart';
|
import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/route/widget/route_texts.dart';
|
import 'package:halkyon_train_flutter/src/feature/route/widget/route_texts.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart'
|
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart'
|
||||||
|
|
@ -82,8 +83,9 @@ Future<LatLng?> _here(RecorderPlatform platform) async {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A route being planned or changed (ADR 0030, D44): the map, tapped for
|
/// A route being planned or changed (ADR 0030, D44, D59): the map, tapped
|
||||||
/// waypoints, a waypoint held and dragged to move it, the last undone;
|
/// for waypoints, a waypoint held and dragged to move it, the line held and
|
||||||
|
/// dragged to pull one out, one tapped to take it off, the last undone;
|
||||||
/// under it the route's name, what it is for and its profile, and the way
|
/// under it the route's name, what it is for and its profile, and the way
|
||||||
/// planned: its distance, climb and descent, its elevation and its
|
/// planned: its distance, climb and descent, its elevation and its
|
||||||
/// surfaces. Saved with the button, deleted after asking.
|
/// surfaces. Saved with the button, deleted after asking.
|
||||||
|
|
@ -190,8 +192,9 @@ class const RouteScreen({required final Future<MapSource?> maps, super.key})
|
||||||
|
|
||||||
/// The map: the way planned as a line (straight lines between the
|
/// The map: the way planned as a line (straight lines between the
|
||||||
/// waypoints until it is), the waypoints numbered, the first green and the
|
/// waypoints until it is), the waypoints numbered, the first green and the
|
||||||
/// last red; a tap adds one, one held and dragged moves it, the button
|
/// last red; a tap adds one, one held and dragged moves it, the line held
|
||||||
/// above takes the last off, and a place searched for and chosen is the
|
/// and dragged pulls one out between the two around it (D59), one tapped
|
||||||
|
/// offers to take it off, the button above takes the last off, and a place searched for and chosen is the
|
||||||
/// next (D50). The camera fits the route as it opens, and
|
/// next (D50). The camera fits the route as it opens, and
|
||||||
/// goes to a new route's first waypoint once it is known.
|
/// goes to a new route's first waypoint once it is known.
|
||||||
class const _RouteMap({required final MapSource source})
|
class const _RouteMap({required final MapSource source})
|
||||||
|
|
@ -207,6 +210,13 @@ class _RouteMapState() extends State<_RouteMap> {
|
||||||
/// The waypoint being dragged and where it is now.
|
/// The waypoint being dragged and where it is now.
|
||||||
(int, LatLng)? _dragged;
|
(int, LatLng)? _dragged;
|
||||||
|
|
||||||
|
/// The waypoint being pulled out of the line: where it goes among the
|
||||||
|
/// waypoints and where it is now (D59).
|
||||||
|
(int, LatLng)? _pulled;
|
||||||
|
|
||||||
|
/// Where the line was last hit, so that a hold on it knows where.
|
||||||
|
final _lineHit = LayerHitNotifier<Object>(null);
|
||||||
|
|
||||||
/// What the camera fits as the map opens: the route as it was opened.
|
/// What the camera fits as the map opens: the route as it was opened.
|
||||||
late final List<LatLng> _fit;
|
late final List<LatLng> _fit;
|
||||||
|
|
||||||
|
|
@ -219,6 +229,7 @@ class _RouteMapState() extends State<_RouteMap> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_lineHit.dispose();
|
||||||
_map.dispose();
|
_map.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -244,11 +255,13 @@ class _RouteMapState() extends State<_RouteMap> {
|
||||||
(search) => search.state.offered ?? false,
|
(search) => search.state.offered ?? false,
|
||||||
);
|
);
|
||||||
final waypoints = [
|
final waypoints = [
|
||||||
for (final (index, point) in route.waypoints.indexed)
|
for (final (index, point) in route.waypoints.indexed) ...[
|
||||||
|
if (_pulled case (final pulled, final at) when pulled == index) at,
|
||||||
if (_dragged case (final dragged, final at) when dragged == index)
|
if (_dragged case (final dragged, final at) when dragged == index)
|
||||||
at
|
at
|
||||||
else
|
else
|
||||||
point,
|
point,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
return Stack(
|
return Stack(
|
||||||
key: _area,
|
key: _area,
|
||||||
|
|
@ -273,9 +286,31 @@ class _RouteMapState() extends State<_RouteMap> {
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
train.mapTiles(widget.source),
|
train.mapTiles(widget.source),
|
||||||
PolylineLayer(
|
GestureDetector(
|
||||||
|
// Held first, as a waypoint is, so that dragging the map
|
||||||
|
// never pulls the line.
|
||||||
|
onLongPressStart: (_) => _pull(),
|
||||||
|
onLongPressMoveUpdate: (details) {
|
||||||
|
if (_pulled case (final index, _)) {
|
||||||
|
setState(
|
||||||
|
() => _pulled = (index, _at(details.globalPosition)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPressEnd: (_) {
|
||||||
|
final pulled = _pulled;
|
||||||
|
setState(() => _pulled = null);
|
||||||
|
if (pulled != null) {
|
||||||
|
cubit().insert(pulled.$1, pulled.$2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: PolylineLayer(
|
||||||
|
hitNotifier: _lineHit,
|
||||||
|
// A finger's width: the line is 5 wide.
|
||||||
|
minimumHitbox: 20,
|
||||||
polylines: [
|
polylines: [
|
||||||
if (route.planned case final planned? when _dragged == null)
|
if (route.planned case final planned?
|
||||||
|
when _dragged == null && _pulled == null)
|
||||||
Polyline(
|
Polyline(
|
||||||
points: planned.points,
|
points: planned.points,
|
||||||
// Cobalt in either theme: the map's colours are the
|
// Cobalt in either theme: the map's colours are the
|
||||||
|
|
@ -293,6 +328,7 @@ class _RouteMapState() extends State<_RouteMap> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
MarkerLayer(
|
MarkerLayer(
|
||||||
markers: [
|
markers: [
|
||||||
for (final (index, point) in waypoints.indexed)
|
for (final (index, point) in waypoints.indexed)
|
||||||
|
|
@ -353,6 +389,49 @@ class _RouteMapState() extends State<_RouteMap> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Starts pulling a waypoint out of the line where it was held.
|
||||||
|
void _pull() {
|
||||||
|
final hit = _lineHit.value;
|
||||||
|
final route = context.read<RouteEditorCubit>().state.route;
|
||||||
|
if (hit == null || route.waypoints.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final line = route.planned?.points ?? route.waypoints;
|
||||||
|
setState(
|
||||||
|
() => _pulled = (
|
||||||
|
insertionIndex(route.waypoints, line, hit.coordinate),
|
||||||
|
hit.coordinate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where on the map a pointer at [global] is.
|
||||||
|
LatLng _at(Offset global) {
|
||||||
|
final area = _area.currentContext!.findRenderObject()! as RenderBox;
|
||||||
|
return _map.camera.screenOffsetToLatLng(area.globalToLocal(global));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The menu of waypoint [index], tapped at [at]: Take it off (D59).
|
||||||
|
Future<void> _waypointMenu(int index, Offset at) async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final cubit = context.read<RouteEditorCubit>();
|
||||||
|
final overlay =
|
||||||
|
Overlay.of(context).context.findRenderObject()! as RenderBox;
|
||||||
|
final remove = await showMenu<bool>(
|
||||||
|
context: context,
|
||||||
|
position: RelativeRect.fromRect(
|
||||||
|
at & Size.zero,
|
||||||
|
Offset.zero & overlay.size,
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
PopupMenuItem(value: true, child: Text(l10n.routeWaypointRemove)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (remove ?? false) {
|
||||||
|
await cubit.remove(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Marker _waypoint(int index, LatLng point, {required bool last}) {
|
Marker _waypoint(int index, LatLng point, {required bool last}) {
|
||||||
final color = switch (index) {
|
final color = switch (index) {
|
||||||
0 => PaletteColor.forest.background,
|
0 => PaletteColor.forest.background,
|
||||||
|
|
@ -365,6 +444,7 @@ class _RouteMapState() extends State<_RouteMap> {
|
||||||
height: 28,
|
height: 28,
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) => GestureDetector(
|
builder: (context) => GestureDetector(
|
||||||
|
onTapUp: (details) => _waypointMenu(index, details.globalPosition),
|
||||||
// Held first, so that dragging the map never moves a waypoint.
|
// Held first, so that dragging the map never moves a waypoint.
|
||||||
onLongPressMoveUpdate: (details) {
|
onLongPressMoveUpdate: (details) {
|
||||||
final area = _area.currentContext?.findRenderObject();
|
final area = _area.currentContext?.findRenderObject();
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
|
import 'package:flutter/gestures.dart' show kLongPressTimeout;
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
|
|
@ -2138,8 +2139,8 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(
|
expect(
|
||||||
find.text(
|
find.text(
|
||||||
'Tap the map to add a waypoint; hold one and drag it to '
|
'Tap the map to add a waypoint, tap one to take it off. Hold '
|
||||||
'move it.',
|
'one to drag it, or hold the line to pull out another.',
|
||||||
),
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
|
|
@ -2183,8 +2184,8 @@ void main() {
|
||||||
expect(find.text('1'), findsOneWidget);
|
expect(find.text('1'), findsOneWidget);
|
||||||
expect(
|
expect(
|
||||||
find.text(
|
find.text(
|
||||||
'Tap the map to add a waypoint; hold one and drag it to '
|
'Tap the map to add a waypoint, tap one to take it off. Hold '
|
||||||
'move it.',
|
'one to drag it, or hold the line to pull out another.',
|
||||||
),
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
|
|
@ -2269,6 +2270,39 @@ void main() {
|
||||||
expect(find.textContaining('No way found there'), findsNothing);
|
expect(find.textContaining('No way found there'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('the line held and dragged pulls out a waypoint between the '
|
||||||
|
'two around it, and a waypoint tapped is taken off (D59)', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await openRoutes(tester, at: (lat: 48.137, lon: 11.575));
|
||||||
|
await tester.tap(find.text('New route'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tapMap(tester, const Offset(120, 80));
|
||||||
|
final [start, end] = routing.asked.last.waypoints;
|
||||||
|
|
||||||
|
final centre = tester.getCenter(find.byType(FlutterMap));
|
||||||
|
final gesture = await tester.startGesture(
|
||||||
|
centre + const Offset(60, 40),
|
||||||
|
);
|
||||||
|
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 100));
|
||||||
|
await gesture.moveTo(centre + const Offset(60, -60));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final [first, pulled, last] = routing.asked.last.waypoints;
|
||||||
|
expect((first, last), (start, end));
|
||||||
|
expect(pulled.latitude, greaterThan(start.latitude));
|
||||||
|
expect(find.text('3'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('1'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Take this waypoint off'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(routing.asked.last.waypoints, [pulled, end]);
|
||||||
|
expect(find.text('3'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('the maps stop where their tiles stop being drawn', (
|
testWidgets('the maps stop where their tiles stop being drawn', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,29 @@ void main() {
|
||||||
await cubit.close();
|
await cubit.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a waypoint pulled out of the line goes between the two around it, '
|
||||||
|
'and any one is taken off (D59)', () async {
|
||||||
|
final cubit = editor();
|
||||||
|
await cubit.open();
|
||||||
|
await cubit.add(home);
|
||||||
|
await cubit.add(river);
|
||||||
|
|
||||||
|
await cubit.insert(1, park);
|
||||||
|
expect(cubit.state.route.waypoints, [home, park, river]);
|
||||||
|
expect(routing.asked.last.waypoints, [home, park, river]);
|
||||||
|
expect(cubit.state.route.planned, straightRoute(const [home, park, river]));
|
||||||
|
|
||||||
|
await cubit.remove(0);
|
||||||
|
expect(cubit.state.route.waypoints, [park, river]);
|
||||||
|
expect(routing.asked.last.waypoints, [park, river]);
|
||||||
|
|
||||||
|
await cubit.remove(1);
|
||||||
|
expect(cubit.state.route.waypoints, [park]);
|
||||||
|
expect(cubit.state.route.planned, isNull);
|
||||||
|
expect(routing.asked, hasLength(3));
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('the activity picks its profile, which the person can change and '
|
test('the activity picks its profile, which the person can change and '
|
||||||
'which plans again', () async {
|
'which plans again', () async {
|
||||||
final cubit = editor();
|
final cubit = editor();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/route/model/route_legs.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
/// Where a waypoint pulled out of the route's line goes (ADR 0030, D59):
|
||||||
|
/// between the two waypoints of the stretch it was grabbed on.
|
||||||
|
void main() {
|
||||||
|
const start = LatLng(47.55, 9.68);
|
||||||
|
const middle = LatLng(47.53, 9.71);
|
||||||
|
const end = LatLng(47.50, 9.74);
|
||||||
|
|
||||||
|
/// The way planned through start, middle and end: points between each.
|
||||||
|
const line = [
|
||||||
|
start,
|
||||||
|
LatLng(47.545, 9.69),
|
||||||
|
LatLng(47.54, 9.70),
|
||||||
|
middle,
|
||||||
|
LatLng(47.52, 9.72),
|
||||||
|
LatLng(47.51, 9.73),
|
||||||
|
end,
|
||||||
|
];
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a point grabbed on the first stretch goes after the first waypoint',
|
||||||
|
() {
|
||||||
|
expect(
|
||||||
|
insertionIndex(
|
||||||
|
const [start, middle, end],
|
||||||
|
line,
|
||||||
|
const LatLng(47.54, 9.70),
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a point grabbed on the last stretch goes before the last waypoint', () {
|
||||||
|
expect(
|
||||||
|
insertionIndex(
|
||||||
|
const [start, middle, end],
|
||||||
|
line,
|
||||||
|
const LatLng(47.515, 9.725),
|
||||||
|
),
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a point grabbed beside the line goes on the stretch nearest it', () {
|
||||||
|
expect(
|
||||||
|
insertionIndex(
|
||||||
|
const [start, middle, end],
|
||||||
|
line,
|
||||||
|
const LatLng(47.50, 9.70),
|
||||||
|
),
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a route of two waypoints takes it between them, wherever grabbed', () {
|
||||||
|
expect(insertionIndex(const [start, end], line, start), 1);
|
||||||
|
expect(insertionIndex(const [start, end], line, end), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a route that comes back past a waypoint keeps its stretches in '
|
||||||
|
'order', () {
|
||||||
|
// Out to the turn and back the same way: the way back passes the
|
||||||
|
// first stretch's points again.
|
||||||
|
const turn = LatLng(47.53, 9.71);
|
||||||
|
const back = [start, LatLng(47.54, 9.70), turn, LatLng(47.54, 9.70), start];
|
||||||
|
expect(
|
||||||
|
insertionIndex(
|
||||||
|
const [start, turn, start],
|
||||||
|
back,
|
||||||
|
const LatLng(47.5401, 9.7001),
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without a way planned, the straight lines between the waypoints are '
|
||||||
|
'the line', () {
|
||||||
|
expect(
|
||||||
|
insertionIndex(
|
||||||
|
const [start, middle, end],
|
||||||
|
const [start, middle, end],
|
||||||
|
const LatLng(47.52, 9.725),
|
||||||
|
),
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1457,6 +1457,18 @@ Strava. What was found, 27 Oct:
|
||||||
(`tool/release/fdroid/metadata/app.halkyon.train.yml`) declares `NonFreeDep` (Polar's
|
(`tool/release/fdroid/metadata/app.halkyon.train.yml`) declares `NonFreeDep` (Polar's
|
||||||
SDK, D36) and `NonFreeNet` (Polar's firmware service, the direct way of D40, and the
|
SDK, D36) and `NonFreeNet` (Polar's firmware service, the direct way of D40, and the
|
||||||
server's SSPL, as Learn's says).
|
server's SSPL, as Learn's says).
|
||||||
|
- **D59 Waypoints pulled out of the line, and any one taken off** (recommended, the
|
||||||
|
developer's; 30 Oct; D44): the planned line held and dragged pulls a waypoint out of
|
||||||
|
it, inserted between the two waypoints of the stretch it was held on, and the way is
|
||||||
|
planned again through it, as Komoot, brouter-web and Google Maps do; a waypoint tapped
|
||||||
|
offers to take it off, where undo took only the last. Held first, as a waypoint is
|
||||||
|
moved, so that dragging the map never pulls the line. Over a tap on the line
|
||||||
|
inserting a waypoint to be dragged after (two steps, and a line easily hit by a
|
||||||
|
finger meaning the map) and over a list of waypoints under the map (good for long
|
||||||
|
routes, clumsy for "a little closer to the lake"). The stretch is the one of the line
|
||||||
|
nearest where it was held, each waypoint's place on the line found from the one
|
||||||
|
before's on, so a way out and back the same streets keeps its stretches in order
|
||||||
|
(`insertionIndex`, `route_legs.dart`).
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue