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:
parent
a2997e87af
commit
5ff9b2e053
4 changed files with 1190 additions and 5 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 & <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),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/route_files.dart';
|
||||
|
||||
/// [RouteFiles] in memory: the file [picked] next, files handed over with
|
||||
/// [open], and the files shared kept in [shared].
|
||||
class FakeRouteFiles({@override final bool shares = true})
|
||||
implements RouteFiles {
|
||||
final _opened = StreamController<OpenedFile>.broadcast();
|
||||
|
||||
/// What [pick] gives next; null: the person cancels.
|
||||
OpenedFile? picked;
|
||||
|
||||
final shared = <OpenedFile>[];
|
||||
|
||||
/// A file opened with Train or shared to it.
|
||||
void open(OpenedFile file) => _opened.add(file);
|
||||
|
||||
@override
|
||||
Stream<OpenedFile> get opened => _opened.stream;
|
||||
|
||||
@override
|
||||
Future<OpenedFile?> pick() async => picked;
|
||||
|
||||
@override
|
||||
Future<bool> share({required String name, required Uint8List bytes}) async {
|
||||
shared.add((name: name, bytes: bytes));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue