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:
parent
a8f7c26e69
commit
aea99c68ca
1 changed files with 24035 additions and 12 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue