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
3 changed files with 24035 additions and 12 deletions

View file

@ -131,6 +131,13 @@ class FakeRecorderPlatform({
@override
Future<bool> isRecording() async => _file != null;
/// Where the phone is; null: no fix.
({double lat, double lon})? at;
@override
Future<({double lat, double lon})?> position() async =>
allows.contains(RecorderPermission.location) ? at : null;
/// A recording that runs on, as when the app comes back to one.
void runOn(String path) => _file = File(path);

View file

@ -0,0 +1,70 @@
import 'dart:async';
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:latlong2/latlong.dart';
/// [RoutingGateway] planning straight lines between the waypoints, 100 m of
/// climb a waypoint after the first; or refusing with [refusal], or holding
/// its answers until [release] where [held].
class FakeRoutingGateway({
var bool isOffered = true,
var RoutingRefusal? refusal,
var bool held = false,
}) implements RoutingGateway {
/// What was asked for, in order.
final asked = <({List<LatLng> waypoints, RouteProfile profile})>[];
final _held = <Completer<void>>[];
/// Answers every plan held so far.
void release() {
for (final answer in _held) {
answer.complete();
}
_held.clear();
}
@override
Future<bool> offered() async => isOffered;
@override
Future<RouteGeometry> plan(
List<LatLng> waypoints,
RouteProfile profile,
) async {
asked.add((waypoints: [...waypoints], profile: profile));
final refused = refusal;
if (held) {
final answer = Completer<void>();
_held.add(answer);
await answer.future;
}
if (refused != null) {
throw RoutingRefused(refused);
}
return straightRoute(waypoints);
}
}
/// The straight lines through [waypoints], as [FakeRoutingGateway] plans.
RouteGeometry straightRoute(List<LatLng> waypoints) {
const distance = Distance();
var metres = 0.0;
for (var i = 1; i < waypoints.length; i++) {
metres += distance(waypoints[i - 1], waypoints[i]);
}
return RouteGeometry(
points: waypoints,
elevations: [for (var i = 0; i < waypoints.length; i++) 500.0 + 100 * i],
distanceMetres: metres.round(),
ascentMetres: 100 * (waypoints.length - 1),
hints: [
for (var i = 1; i < waypoints.length - 1; i++)
TurnHint(index: i, kind: TurnKind.left, metres: 0),
],
stretches: [
RouteStretch(metres: metres.round(), surface: 'asphalt'),
],
);
}

View file

@ -0,0 +1,35 @@
import 'dart:async';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_gateway.dart';
/// [RouteGateway] in memory, whose rows a test changes as an instance could.
class MemoryRouteGateway() implements RouteGateway {
final rows = <String, Envelope>{};
final _remote = StreamController<void>.broadcast();
/// As a sync bringing others' changes.
void bringRemoteChanges() => _remote.add(null);
@override
Future<List<RouteRow>> routes() async => [
for (final MapEntry(:key, :value) in rows.entries)
(id: key, envelope: value),
];
@override
Future<void> insert(RouteRow route) async => rows[route.id] = route.envelope;
@override
Future<void> update(String id, Envelope envelope) async {
if (rows.containsKey(id)) {
rows[id] = envelope;
}
}
@override
Future<void> delete(String id) async => rows.remove(id);
@override
Stream<void> get remoteChanges => _remote.stream;
}