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:
Tizian 「ティツィアーン」 2026-10-30 23:01:04 +01:00
commit 57262ebf73
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
13 changed files with 371 additions and 36 deletions

File diff suppressed because one or more lines are too long

View file

@ -395,7 +395,8 @@
"routeProfileTrekkingBike": "Trekkingrad",
"routeProfileGravel": "Gravelbike",
"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",
"routeSearchHint": "Nach einem Ort suchen",
"routeSearchClear": "Suche leeren",

View file

@ -501,7 +501,9 @@
"routeProfileTrekkingBike": "Trekking bike",
"routeProfileGravel": "Gravel 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",
"routeSearchHint": "Search for a place",
"@routeSearchHint": {"description": "The planner's search field over the map (D50)."},

View file

@ -2395,9 +2395,15 @@ abstract class AppLocalizations {
/// No description provided for @routeTapHint.
///
/// 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;
/// 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.
///
/// In en, this message translates to:

View file

@ -1427,7 +1427,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get routeUndo => 'Letzten Wegpunkt entfernen';

View file

@ -1420,7 +1420,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get routeUndo => 'Take the last waypoint off';

View file

@ -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:latlong2/latlong.dart';
/// A route being planned or changed (ADR 0030, D44): waypoints tapped,
/// dragged and the last undone, a new route's first where the phone is
/// A route being planned or changed (ADR 0030, D44, D59): waypoints
/// 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
/// 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
@ -74,6 +74,16 @@ class RouteEditorCubit(
Future<void> move(int index, LatLng 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.
Future<void> undo() async {
final waypoints = state.route.waypoints;

View file

@ -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;
}

View file

@ -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/route_analysis.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_texts.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
/// waypoints, a waypoint held and dragged to move it, the last undone;
/// A route being planned or changed (ADR 0030, D44, D59): the map, tapped
/// 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
/// planned: its distance, climb and descent, its elevation and its
/// 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
/// 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
/// above takes the last off, and a place searched for and chosen is the
/// last red; a tap adds one, one held and dragged moves it, the line held
/// 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
/// goes to a new route's first waypoint once it is known.
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.
(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.
late final List<LatLng> _fit;
@ -219,6 +229,7 @@ class _RouteMapState() extends State<_RouteMap> {
@override
void dispose() {
_lineHit.dispose();
_map.dispose();
super.dispose();
}
@ -244,11 +255,13 @@ class _RouteMapState() extends State<_RouteMap> {
(search) => search.state.offered ?? false,
);
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)
at
else
point,
],
];
return Stack(
key: _area,
@ -273,25 +286,48 @@ class _RouteMapState() extends State<_RouteMap> {
),
children: [
train.mapTiles(widget.source),
PolylineLayer(
polylines: [
if (route.planned case final planned? when _dragged == null)
Polyline(
points: planned.points,
// Cobalt in either theme: the map's colours are the
// source's, not the app's.
color: PaletteColor.cobalt.background,
strokeWidth: 5,
)
else if (waypoints.length > 1)
Polyline(
points: waypoints,
color: PaletteColor.cobalt.background.withValues(
alpha: 0.5,
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: [
if (route.planned case final planned?
when _dragged == null && _pulled == null)
Polyline(
points: planned.points,
// Cobalt in either theme: the map's colours are the
// source's, not the app's.
color: PaletteColor.cobalt.background,
strokeWidth: 5,
)
else if (waypoints.length > 1)
Polyline(
points: waypoints,
color: PaletteColor.cobalt.background.withValues(
alpha: 0.5,
),
strokeWidth: 3,
),
strokeWidth: 3,
),
],
],
),
),
MarkerLayer(
markers: [
@ -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}) {
final color = switch (index) {
0 => PaletteColor.forest.background,
@ -365,6 +444,7 @@ class _RouteMapState() extends State<_RouteMap> {
height: 28,
child: Builder(
builder: (context) => GestureDetector(
onTapUp: (details) => _waypointMenu(index, details.globalPosition),
// Held first, so that dragging the map never moves a waypoint.
onLongPressMoveUpdate: (details) {
final area = _area.currentContext?.findRenderObject();

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:flutter/gestures.dart' show kLongPressTimeout;
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
@ -2138,8 +2139,8 @@ void main() {
await tester.pumpAndSettle();
expect(
find.text(
'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.',
),
findsOneWidget,
);
@ -2183,8 +2184,8 @@ void main() {
expect(find.text('1'), findsOneWidget);
expect(
find.text(
'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.',
),
findsOneWidget,
);
@ -2269,6 +2270,39 @@ void main() {
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', (
tester,
) async {

View file

@ -168,6 +168,29 @@ void main() {
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 '
'which plans again', () async {
final cubit = editor();

View file

@ -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,
);
});
}

View file

@ -1457,6 +1457,18 @@ Strava. What was found, 27 Oct:
(`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
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