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