feat(train): routes out and in as GPX (ADR 0030, D45)

A route's page shares it as GPX 1.1 (Android's share sheet, else saved
where the person chooses): its way a track with elevations, its
waypoints and turns as named points. A GPX file comes in from the Routes
tab or, on Android, opened with Train or shared to it; it is planned
along the paths through its points or kept as drawn, then named and
saved as a new route.

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-29 10:44:47 +01:00
commit 5ff9b2e053
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 1190 additions and 5 deletions

View file

@ -0,0 +1,173 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/gpx.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:latlong2/latlong.dart';
/// Routes as GPX (ADR 0030, D45): out with their way, waypoints and turns,
/// in from Train's own files and anyone's, kept as drawn or planned along
/// the paths.
void main() {
const route = RouteContent(
name: 'Isar & <Auen>',
profile: RouteProfile.run,
waypoints: [LatLng(48.137154, 11.576124), LatLng(48.1201, 11.5832)],
planned: RouteGeometry(
points: [
LatLng(48.137154, 11.576124),
LatLng(48.13, 11.58),
LatLng(48.1201, 11.5832),
],
elevations: [519.5, 524, 512],
distanceMetres: 2050,
hints: [
TurnHint(index: 1, kind: TurnKind.right, metres: 1200),
TurnHint(index: 2, kind: TurnKind.beeline, metres: 0),
],
),
);
String? name(TurnHint hint) =>
hint.kind == TurnKind.beeline ? null : 'Turn right';
test(
'a route goes out as GPX 1.1: its way a track with elevations, its '
'waypoints and its turns named, a beeline left out, its name escaped',
() {
final gpx = gpxOf(route, turnName: name);
expect(gpx, contains('xmlns="http://www.topografix.com/GPX/1/1"'));
expect(gpx, contains('<name>Isar &amp; &lt;Auen></name>'));
expect(gpx, contains('<trkpt lat="48.130000" lon="11.580000">'));
expect(gpx, contains('<ele>524.0</ele>'));
expect('<type>waypoint</type>'.allMatches(gpx), hasLength(2));
expect('<type>turn</type>'.allMatches(gpx), hasLength(1));
expect(gpx, contains('<name>Turn right</name>'));
},
);
test("Train's own file comes back as it went: its name, way, elevations "
'and waypoints', () {
final back = readGpx(gpxOf(route, turnName: name), fallbackName: 'File')!;
expect(back.name, route.name);
expect(back.points, route.planned!.points);
expect(back.elevations, route.planned!.elevations);
expect(back.waypoints, route.waypoints);
});
test("anyone's track comes in: its segments joined, GPX 1.0 too, without "
'elevations where some lack them, named by the file where it has no '
'name', () {
const text = '''
<?xml version="1.0"?>
<gpx version="1.0" xmlns="http://www.topografix.com/GPX/1/0">
<wpt lat="48.2" lon="11.6"><name>Café</name></wpt>
<trk>
<trkseg>
<trkpt lat="48.1" lon="11.5"><ele>500</ele></trkpt>
<trkpt lat="48.11" lon="11.5"/>
</trkseg>
<trkseg>
<trkpt lat="48.12" lon="11.5"><ele>510</ele></trkpt>
<trkpt lat="not" lon="11.5"/>
</trkseg>
</trk>
</gpx>''';
final gpx = readGpx(text, fallbackName: 'ride.gpx')!;
expect(gpx.name, 'ride.gpx');
expect(gpx.points, const [
LatLng(48.1, 11.5),
LatLng(48.11, 11.5),
LatLng(48.12, 11.5),
]);
expect(gpx.elevations, isEmpty);
expect(gpx.waypoints, isEmpty, reason: 'a café is no waypoint');
});
test('a route without a track is its route points', () {
const text = '''
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<rte><name>Loop</name>
<rtept lat="48.1" lon="11.5"/><rtept lat="48.2" lon="11.6"/>
</rte>
</gpx>''';
final gpx = readGpx(text, fallbackName: 'File')!;
expect(gpx.name, 'Loop');
expect(gpx.points, const [LatLng(48.1, 11.5), LatLng(48.2, 11.6)]);
});
test("what isn't GPX, or has no way of two points, is nothing", () {
expect(readGpx('not xml', fallbackName: 'f'), isNull);
expect(readGpx('<kml><trk/></kml>', fallbackName: 'f'), isNull);
expect(
readGpx(
'<gpx><trk><trkseg><trkpt lat="1" lon="2"/></trkseg></trk></gpx>',
fallbackName: 'f',
),
isNull,
);
expect(
readGpx(
'<gpx><trk><trkseg><trkpt lat="91" lon="2"/><trkpt lat="1" lon="200"/>'
'</trkseg></trk></gpx>',
fallbackName: 'f',
),
isNull,
);
});
test('kept as drawn: the way as it is, its distance and climb worked out, '
'no turns', () {
const gpx = GpxRoute(
name: 'Hill',
points: [LatLng(48, 11), LatLng(48.01, 11), LatLng(48.02, 11)],
elevations: [500, 540, 520],
);
final drawn = drawnRoute(gpx, RouteProfile.hike);
expect(drawn.profile, RouteProfile.hike);
expect(drawn.waypoints, const [LatLng(48, 11), LatLng(48.02, 11)]);
expect(drawn.planned!.points, gpx.points);
expect(drawn.planned!.distanceMetres, closeTo(2224, 5));
expect(drawn.planned!.ascentMetres, 40);
expect(drawn.planned!.descentMetres, 20);
expect(drawn.planned!.hints, isEmpty);
});
test('planned along the paths: through its own waypoints, else through at '
'most 50 points of its way, a kilometre apart or more', () {
const own = GpxRoute(
name: 'Own',
points: [LatLng(48, 11), LatLng(48.5, 11)],
waypoints: [LatLng(48, 11), LatLng(48.2, 11), LatLng(48.5, 11)],
);
expect(snappedRoute(own, RouteProfile.run).waypoints, own.waypoints);
expect(snappedRoute(own, RouteProfile.run).planned, isNull);
// 200 km north in 2,000 steps of 100 m.
final long = GpxRoute(
name: 'Long',
points: [for (var i = 0; i <= 2000; i++) LatLng(48 + i * 0.0009, 11)],
);
final spread = snappedRoute(long, RouteProfile.gravel).waypoints;
expect(spread.length, lessThanOrEqualTo(maxWaypoints));
expect(spread.first, long.points.first);
expect(spread.last, long.points.last);
// 3 km: a waypoint every kilometre.
final short = GpxRoute(
name: 'Short',
points: [for (var i = 0; i <= 30; i++) LatLng(48 + i * 0.0009, 11)],
);
const distance = Distance();
final few = snappedRoute(short, RouteProfile.run).waypoints;
expect(few.length, inInclusiveRange(3, 4));
for (var i = 1; i < few.length; i++) {
expect(
distance.as(LengthUnit.Meter, few[i - 1], few[i]),
greaterThanOrEqualTo(499),
);
}
});
}

View file

@ -110,6 +110,34 @@ void main() {
await cubit.close();
});
test('a new route brought in with waypoints and no way is planned as it '
'opens; one brought in as drawn is not planned again (D45)', () async {
const brought = RouteContent(
name: 'From a file',
profile: RouteProfile.run,
waypoints: [home, park, river],
);
final cubit = editor(route: brought, here: () async => river);
await cubit.open();
expect(routing.asked.single.waypoints, brought.waypoints);
expect(cubit.state.route.planned, isNotNull);
expect(cubit.state.route.name, 'From a file');
await cubit.close();
routing.asked.clear();
final drawn = RouteContent(
name: 'Drawn',
profile: RouteProfile.run,
waypoints: const [home, river],
planned: straightRoute(const [home, park, river]),
);
final kept = editor(route: drawn);
await kept.open();
expect(routing.asked, isEmpty);
expect(kept.state.route, drawn);
await kept.close();
});
test('a second waypoint plans the way, each change plans it again', () async {
final cubit = editor();
await cubit.open();