feat(train): routes kept as sealed rows, planned on the map (ADR 0030, D44)

Routes are synced `route` rows (the model `SavedRoute`, as Flutter has a
`Route`), sealed as plans are; the migration `routes` only creates the
table, its space index and its cascade to the sync engine's spaces. A
route's payload holds its name, activity, profile, waypoints and the way
planned, points and elevations delta-coded and gzipped.

Home gets a fourth tab, Routes, and the planner: waypoints tapped on the
map, held and dragged, the last undone, a new route's first where the
phone is (GPS through the recorder's channel), the way planned by the
instance's BRouter after each change, an earlier answer dropped; its
distance, climb, descent, elevation and surfaces under the map.

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 00:37:44 +01:00
commit aea99c68ca
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -30,19 +30,25 @@ 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_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';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:latlong2/latlong.dart';
import 'package:material_ui/material_ui.dart';
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_routing_gateway.dart';
import '../support/memory_content_key_gateway.dart';
import '../support/memory_day_gateway.dart';
import '../support/memory_plan_gateway.dart';
import '../support/memory_profile_gateway.dart';
import '../support/memory_route_gateway.dart';
import '../support/memory_workout_gateway.dart';
void main() {
@ -76,6 +82,8 @@ void main() {
DayRepository? days,
PolarLoop? loop,
PlanRepository? plans,
RouteRepository? routes,
RoutingGateway? routing,
}) async {
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
@ -176,6 +184,17 @@ void main() {
() async => FakeAccountCrypto().keysNumbered(1),
),
),
routes:
routes ??
RouteRepository(
MemoryRouteGateway(),
TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
),
),
routing: routing ?? FakeRoutingGateway(),
recording: recording,
health: health,
loop: loop,
@ -1808,6 +1827,200 @@ void main() {
});
});
group('routes (ADR 0030, D44)', () {
late TrainKeys keys;
late RouteRepository routes;
late FakeRoutingGateway routing;
late Directory folder;
setUp(() {
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
routes = RouteRepository(MemoryRouteGateway(), keys);
routing = FakeRoutingGateway();
folder = Directory.systemTemp.createTempSync('train');
});
tearDown(() async {
await routes.dispose();
keys.dispose();
folder.deleteSync(recursive: true);
});
/// Home's *Routes*, on a phone at [at] (null: a device that can't tell).
Future<void> openRoutes(
WidgetTester tester, {
({double lat, double lon})? at,
}) async {
await start(
tester,
usedBefore: true,
routes: routes,
routing: routing,
recording: at == null
? null
: (
platform: FakeRecorderPlatform()..at = at,
file: File('${folder.path}/recording.txt'),
straps: DeviceStore(File('${folder.path}/strap.json')),
rowers: DeviceStore(File('${folder.path}/rower.json')),
),
);
await tester.tap(find.text('Routes'));
await tester.pumpAndSettle();
}
/// Taps the map [offset] from its centre.
Future<void> tapMap(WidgetTester tester, Offset offset) async {
await tester.tapAt(tester.getCenter(find.byType(FlutterMap)) + offset);
// Past the wait for a second tap, which would zoom.
await tester.pump(const Duration(milliseconds: 500));
await tester.pumpAndSettle();
}
testWidgets('a route starts where the phone is, is planned by tapping the '
'map, saved once named and listed with its distance and climb', (
tester,
) async {
await openRoutes(tester, at: (lat: 48.137, lon: 11.575));
expect(find.text('No routes yet'), findsOneWidget);
await tester.tap(find.text('New route'));
await tester.pumpAndSettle();
expect(find.text('1'), findsOneWidget);
expect(
find.text(
'Tap the map to add a waypoint; hold one and drag it to '
'move it.',
),
findsOneWidget,
);
await tapMap(tester, const Offset(60, 40));
expect(
routing.asked.single.waypoints.first,
const LatLng(48.137, 11.575),
);
expect(routing.asked.single.profile, RouteProfile.run);
expect(find.text('2'), findsOneWidget);
expect(find.text('100 m up'), findsOneWidget);
expect(find.text('0 m down'), findsOneWidget);
expect(find.text('Elevation'), findsOneWidget);
final save = find.widgetWithText(TextButton, 'Save');
expect(tester.widget<TextButton>(save).onPressed, isNull);
await tester.enterText(
find.widgetWithText(TextFormField, 'Name'),
'To the park',
);
await tester.pump();
await tester.scrollUntilVisible(
find.text('Paved'),
100,
scrollable: find
.descendant(
of: find.byType(ListView),
matching: find.byType(Scrollable),
)
.first,
);
expect(find.text('Surfaces'), findsOneWidget);
await tester.tap(save);
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
expect(find.text('To the park'), findsOneWidget);
expect(find.textContaining(' km · 100 m up'), findsOneWidget);
final [route] =
await tester.runAsync(routes.all) ?? const <StoredRoute>[];
expect(route.content?.waypoints, hasLength(2));
expect(route.content?.planned, isNotNull);
});
testWidgets('the activity picks the profile, which plans the way again', (
tester,
) async {
await openRoutes(tester);
await tester.tap(find.text('New route'));
await tester.pumpAndSettle();
await tapMap(tester, Offset.zero);
await tapMap(tester, const Offset(60, 40));
expect(routing.asked.last.profile, RouteProfile.run);
await tester.tap(find.text('Any activity'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cycling').last);
await tester.pumpAndSettle();
expect(routing.asked.last.profile, RouteProfile.trekkingBike);
expect(find.text('Trekking bike'), findsOneWidget);
});
testWidgets('a refusal says why; undo takes the last waypoint off', (
tester,
) async {
routing.refusal = RoutingRefusal.noRoute;
await openRoutes(tester);
await tester.tap(find.text('New route'));
await tester.pumpAndSettle();
// A device that can't tell where it is starts with none.
expect(find.text('1'), findsNothing);
await tapMap(tester, Offset.zero);
await tapMap(tester, const Offset(60, 40));
expect(find.textContaining('No way found there'), findsOneWidget);
await tester.tap(find.byTooltip('Take the last waypoint off'));
await tester.pumpAndSettle();
expect(find.text('2'), findsNothing);
expect(find.textContaining('No way found there'), findsNothing);
});
testWidgets('an instance that plans no routes says so', (tester) async {
routing.isOffered = false;
await openRoutes(tester);
await tester.tap(find.text('New route'));
await tester.pumpAndSettle();
expect(find.text("Your instance doesn't plan routes."), findsOneWidget);
});
testWidgets('a route is opened, deleted after asking and brought back', (
tester,
) async {
final id = await routes.save(
RouteContent(
name: 'Isar',
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),
]),
),
);
await openRoutes(tester);
await tester.tap(find.text('Isar'));
await tester.pumpAndSettle();
expect(find.text('Route'), findsOneWidget);
expect(routing.asked, isEmpty);
await tester.tap(find.byTooltip('Delete'));
await tester.pumpAndSettle();
expect(find.text('Delete this route?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
expect(find.text('Isar'), findsNothing);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
expect(find.text('Isar'), findsOneWidget);
expect((await tester.runAsync(routes.all))!.single.id, id);
});
});
group('workouts (ADR 0030, D24)', () {
late MemoryWorkoutGateway rows;
late TrainKeys keys;