Sync the Fly App's waypoint routes through the API Server

Routes saved in the Fly App's mission library now sync to a new per-user
routes collection on the API Server, in the WaypointMissionSpec JSON
format. The library pulls on load and pushes on every save and delete,
merges last-writer-wins by updatedAt, keeps unacknowledged deletes as
tombstones, and keeps working offline. Each signed-in account has its
own copy on the device; routes saved while signed out go to the first
account that signs in.

The API Server gets list / get / upsert / delete route endpoints with a
PocketBase migration, and the Web App a read-only Routes view on the
dashboard (rebuilt dist).

Tested against a fake PocketBase and a fake server only; not yet tried
with a real sign-in on the tablet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-20 17:26:45 +02:00
1 parent e49f318a0e
commit 93f4a580e9
18 files changed
+1525 -43

No files matched your search

+194
View File
@@ -6,6 +6,7 @@ import 'package:pilotvault_fly/fly/action_rail_state.dart';
import 'package:pilotvault_fly/fly/fly_prefs.dart';
import 'package:pilotvault_fly/fly/modes/intelligent_modes.dart';
import 'package:pilotvault_fly/fly/modes/mission_library.dart';
import 'package:pilotvault_fly/fly/modes/mission_sync.dart';
import 'package:pilotvault_fly/fly/modes/waypoint_editor.dart';
import 'package:pilotvault_fly/fly/modes/waypoint_mission.dart';
import 'package:pilotvault_fly/fly/product_profile.dart';
@@ -290,6 +291,156 @@ void main() {
final List<WaypointMissionSpec> out = MissionLibrary.decode('[{"name":"ok","points":[]}, {"points": 7}, 3]');
expect(out.map((WaypointMissionSpec m) => m.name), <String>['ok']);
});
group('sync', () {
WaypointMissionSpec named(String id, String name, int day) =>
route(<LatLng>[base, at(0, 100)]).copyWith(id: id, name: name, updatedAt: DateTime.utc(2026, 1, day));
RemoteRoute live(WaypointMissionSpec m) => RemoteRoute(m.id, m.updatedAt!, m);
RemoteRoute gone(String id, int day) => RemoteRoute(id, DateTime.utc(2026, 1, day), null);
List<String> names(Iterable<WaypointMissionSpec> ms) => <String>[for (final WaypointMissionSpec m in ms) m.name];
test('merge: newest write wins per route, either side', () {
final MissionMerge m = mergeMissions(
local: <WaypointMissionSpec>[named('a', 'A local', 5), named('b', 'B local', 2), named('c', 'C only here', 3)],
tombstones: const <String, DateTime>{},
remote: <RemoteRoute>[live(named('a', 'A server', 4)), live(named('b', 'B server', 6)), live(named('d', 'D only there', 1))],
);
expect(names(m.missions), <String>['B server', 'A local', 'C only here', 'D only there'], reason: 'newest first');
expect(names(m.toPush), unorderedEquals(<String>['A local', 'C only here']));
expect(m.toDelete, isEmpty);
});
test('merge: a tie goes to the server and pushes nothing', () {
final MissionMerge m = mergeMissions(
local: <WaypointMissionSpec>[named('a', 'mine', 3)],
tombstones: const <String, DateTime>{},
remote: <RemoteRoute>[live(named('a', 'theirs', 3))],
);
expect(names(m.missions), <String>['theirs']);
expect(m.toPush, isEmpty);
});
test('merge: deletes are writes too', () {
final MissionMerge m = mergeMissions(
local: <WaypointMissionSpec>[named('kept', 'edited after their delete', 5), named('dropped', 'stale', 2)],
tombstones: <String, DateTime>{
'del': DateTime.utc(2026, 1, 5), // deleted here after their edit
'lost': DateTime.utc(2026, 1, 1), // deleted here, but edited there since
'never': DateTime.utc(2026, 1, 1), // deleted before it ever reached the server
'both': DateTime.utc(2026, 1, 4), // deleted on both sides
},
remote: <RemoteRoute>[
gone('kept', 4),
gone('dropped', 3),
live(named('del', 'old', 4)),
live(named('lost', 'edited there', 2)),
gone('both', 2),
],
);
expect(names(m.missions), <String>['edited after their delete', 'edited there']);
expect(names(m.toPush), <String>['edited after their delete'], reason: 'resurrects it on the server');
expect(m.toDelete, <String, DateTime>{'del': DateTime.utc(2026, 1, 5)});
expect(m.tombstones.keys, <String>['del'], reason: 'only unacknowledged deletes stay');
});
test('merge: a route with no updatedAt is the oldest write, and gets one to push', () {
final MissionMerge m = mergeMissions(
local: <WaypointMissionSpec>[route(<LatLng>[base]).copyWith(id: 'legacy', name: 'legacy')],
tombstones: const <String, DateTime>{},
remote: const <RemoteRoute>[],
);
expect(m.toPush.single.updatedAt, isNotNull);
});
test('the server view parses, tombstones included', () {
final WaypointMissionSpec spec = named('wp-1', 'R', 3);
final RemoteRoute? r = RemoteRoute.fromView(<String, dynamic>{
'id': 'wp-1',
'updatedAt': '2026-01-03T00:00:00.000000Z',
'deleted': false,
'spec': spec.toJson(),
});
expect(r!.spec!.toJson(), spec.toJson());
final RemoteRoute? t = RemoteRoute.fromView(<String, dynamic>{'id': 'wp-1', 'updatedAt': '2026-01-04T00:00:00Z', 'deleted': true});
expect(t!.deleted, isTrue);
expect(RemoteRoute.fromView(<String, dynamic>{'id': 'wp-1', 'deleted': false, 'spec': spec.toJson()}), isNull);
});
test('a route saved on one tablet is there after a reinstall + sign-in', () async {
final _FakeRemote server = _FakeRemote('pilot@x');
final MissionLibrary tablet = MissionLibrary(remote: server);
await tablet.load();
final WaypointMissionSpec saved = await tablet.save(route(<LatLng>[base, at(0, 100)]).copyWith(name: 'Survey'));
await tablet.idle;
expect(server.routes.keys, <String>[saved.id]);
expect(tablet.syncStatus, MissionSyncStatus.synced);
SharedPreferences.setMockInitialValues(<String, Object>{}); // reinstall
final MissionLibrary fresh = MissionLibrary(remote: server);
await fresh.load();
expect(fresh.missions.single.toJson(), saved.toJson());
await fresh.delete(saved.id);
await fresh.idle;
expect(server.routes[saved.id]!.deleted, isTrue);
await tablet.sync();
expect(tablet.missions, isEmpty, reason: 'the other tablet learns of the delete');
});
test('offline: saves and deletes stay local and go up on the next sync', () async {
final _FakeRemote server = _FakeRemote('pilot@x')..offline = true;
final MissionLibrary lib = MissionLibrary(remote: server);
await lib.load();
expect(lib.syncStatus, MissionSyncStatus.failed);
final WaypointMissionSpec a = await lib.save(route(<LatLng>[base]).copyWith(name: 'A'), now: DateTime.utc(2026, 1, 1));
final WaypointMissionSpec b = await lib.save(route(<LatLng>[base]).copyWith(name: 'B'), now: DateTime.utc(2026, 1, 2));
server.routes[b.id] = RemoteRoute(b.id, DateTime.utc(2026, 1, 2), b); // reached the server earlier
await lib.delete(b.id, now: DateTime.utc(2026, 1, 3));
await lib.idle;
expect(names(lib.missions), <String>['A'], reason: 'works offline');
final MissionLibrary reopened = MissionLibrary(remote: server);
await reopened.load();
expect(names(reopened.missions), <String>['A'], reason: 'the pending delete survives a restart');
server.offline = false;
await reopened.sync();
expect(server.routes[a.id]!.spec!.name, 'A');
expect(server.routes[b.id]!.deleted, isTrue);
expect(reopened.syncStatus, MissionSyncStatus.synced);
});
test('a refused push adopts the newer server copy', () async {
final _FakeRemote server = _FakeRemote('pilot@x');
final MissionLibrary lib = MissionLibrary(remote: server);
await lib.load();
final WaypointMissionSpec a = await lib.save(route(<LatLng>[base]).copyWith(name: 'mine'), now: DateTime.utc(2026, 1, 1));
// Someone else's newer save lands between our pull and our push.
server.beforePush = () => server.routes[a.id] = live(a.copyWith(name: 'theirs', updatedAt: DateTime.utc(2026, 1, 9)));
await lib.idle;
expect(names(lib.missions), <String>['theirs']);
});
test('signed-out routes are claimed by the first account; accounts stay apart', () async {
final _FakeRemote server = _FakeRemote('');
final MissionLibrary lib = MissionLibrary(remote: server);
await lib.load();
await lib.save(route(<LatLng>[base]).copyWith(name: 'before sign-in'));
await lib.idle;
expect(lib.syncStatus, MissionSyncStatus.local);
expect(server.routes, isEmpty);
server.who = 'a@x';
await lib.sync();
expect(names(lib.missions), <String>['before sign-in']);
expect(server.routes.values.single.spec!.name, 'before sign-in');
server.who = 'b@x';
server.routes.clear(); // b's own (empty) server library
await lib.sync();
expect(lib.missions, isEmpty, reason: "b never sees a's routes");
});
});
});
group('WaypointEditor', () {
@@ -647,3 +798,46 @@ void main() {
});
});
}
/// An in-memory API Server route store, with the server's last-writer-wins rule.
class _FakeRemote implements MissionRemote {
_FakeRemote(this.who);
String who;
bool offline = false;
void Function()? beforePush;
final Map<String, RemoteRoute> routes = <String, RemoteRoute>{};
@override
String get account => who;
void _check() {
if (offline) throw const MissionSyncException(0, <String, dynamic>{'error': 'offline'});
}
@override
Future<List<RemoteRoute>> pull() async {
_check();
return routes.values.toList();
}
@override
Future<RemoteRoute?> push(WaypointMissionSpec m) async {
_check();
beforePush?.call();
final RemoteRoute? have = routes[m.id];
if (have != null && have.updatedAt.isAfter(m.updatedAt!)) return have;
routes[m.id] = RemoteRoute(m.id, m.updatedAt!, m);
return null;
}
@override
Future<RemoteRoute?> pushDelete(String id, DateTime at) async {
_check();
final RemoteRoute? have = routes[id];
if (have == null) return null;
if (have.updatedAt.isAfter(at)) return have;
routes[id] = RemoteRoute(id, at, null);
return null;
}
}