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

View file

@ -31,6 +31,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.da
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_files.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/routing_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
@ -46,6 +47,7 @@ import '../support/fake_firmware_relay.dart';
import '../support/fake_health_platform.dart';
import '../support/fake_loop_platform.dart';
import '../support/fake_recorder_platform.dart';
import '../support/fake_route_files.dart';
import '../support/fake_routing_gateway.dart';
import '../support/memory_content_key_gateway.dart';
import '../support/memory_day_gateway.dart';
@ -88,6 +90,7 @@ void main() {
RouteRepository? routes,
RoutingGateway? routing,
OfflineMaps? offlineMaps,
RouteFiles? routeFiles,
}) async {
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
@ -208,6 +211,7 @@ void main() {
// No tiles: widget tests reach no network.
mapTiles: (source) => const SizedBox.shrink(),
offlineMaps: offlineMaps,
routeFiles: routeFiles,
);
},
),
@ -1945,6 +1949,7 @@ void main() {
WidgetTester tester, {
({double lat, double lon})? at,
OfflineMaps? offlineMaps,
RouteFiles? routeFiles,
}) async {
await start(
tester,
@ -1952,6 +1957,7 @@ void main() {
routes: routes,
routing: routing,
offlineMaps: offlineMaps,
routeFiles: routeFiles,
recording: at == null
? null
: (
@ -2112,6 +2118,112 @@ void main() {
expect((await tester.runAsync(routes.all))!.single.id, id);
});
group('as GPX (D45)', () {
const gpx = '''
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<trk><name>Along the Isar</name><trkseg>
<trkpt lat="48.137" lon="11.575"><ele>519</ele></trkpt>
<trkpt lat="48.130" lon="11.578"><ele>530</ele></trkpt>
<trkpt lat="48.120" lon="11.583"><ele>512</ele></trkpt>
</trkseg></trk>
</gpx>''';
final file = (name: 'isar.gpx', bytes: utf8.encode(gpx));
testWidgets('a file picked is kept as drawn: its way, distance and '
"climb the file's, nothing planned, saved under its name", (
tester,
) async {
final files = FakeRouteFiles()..picked = file;
await openRoutes(tester, routeFiles: files);
await tester.tap(find.text('Bring in a GPX file'));
await tester.pumpAndSettle();
expect(find.text('Bring in Along the Isar'), findsOneWidget);
await tester.tap(find.text('Keep it as drawn'));
await tester.pumpAndSettle();
expect(find.text('New route'), findsOneWidget);
expect(find.text('11 m up'), findsOneWidget);
expect(routing.asked, isEmpty);
await tester.tap(find.widgetWithText(TextButton, 'Save'));
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
expect(find.text('Along the Isar'), findsOneWidget);
final [route] =
await tester.runAsync(routes.all) ?? const <StoredRoute>[];
expect(route.content?.planned?.points, hasLength(3));
expect(route.content?.planned?.elevations, [519, 530, 512]);
});
testWidgets('a file Android hands Train is planned along the paths '
'through its points', (tester) async {
final files = FakeRouteFiles();
await openRoutes(tester, routeFiles: files);
files.open(file);
await tester.pumpAndSettle();
await tester.tap(find.text('Plan it along the paths'));
await tester.pumpAndSettle();
expect(routing.asked.single.waypoints, const [
LatLng(48.137, 11.575),
LatLng(48.120, 11.583),
]);
expect(find.text('New route'), findsOneWidget);
});
testWidgets('a file without a route says so, and opens nothing', (
tester,
) async {
final files = FakeRouteFiles()
..picked = (name: 'notes.txt', bytes: utf8.encode('Buy milk'));
await openRoutes(tester, routeFiles: files);
await tester.tap(find.text('Bring in a GPX file'));
await tester.pumpAndSettle();
expect(
find.text('This file holds no route Train can read'),
findsOneWidget,
);
expect(find.text('New route'), findsOneWidget, reason: 'the tile');
expect(find.text('Keep it as drawn'), findsNothing);
});
testWidgets('a route is shared as GPX under its name, or saved where '
'the device has no share sheet', (tester) async {
await routes.save(
RouteContent(
name: 'Isar: loop',
profile: RouteProfile.run,
waypoints: const [LatLng(48.137, 11.575), LatLng(48.12, 11.58)],
planned: straightRoute(const [
LatLng(48.137, 11.575),
LatLng(48.12, 11.58),
]),
),
);
final files = FakeRouteFiles();
await openRoutes(tester, routeFiles: files);
await tester.tap(find.text('Isar: loop'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Share as GPX'));
await tester.pumpAndSettle();
final shared = files.shared.single;
expect(shared.name, 'Isar loop.gpx');
final text = utf8.decode(shared.bytes);
expect(text, contains('<name>Isar: loop</name>'));
expect('<trkpt'.allMatches(text), hasLength(2));
});
testWidgets('without a share sheet it says Save', (tester) async {
await openRoutes(tester, routeFiles: FakeRouteFiles(shares: false));
await tester.tap(find.text('New route'));
await tester.pumpAndSettle();
expect(find.byTooltip('Save as GPX'), findsOneWidget);
expect(find.byTooltip('Share as GPX'), findsNothing);
});
});
testWidgets("a saved route's map is kept offline on request, its tiles "
'counted, marked in the list, and let go again (D45)', (tester) async {
final id = await routes.save(