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

+184 -14
View File
@@ -1,24 +1,56 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'mission_sync.dart';
import 'waypoint_mission.dart';
/// How the library stands with the API Server.
enum MissionSyncStatus {
/// Signed out (or no server): routes live on this device only.
local,
syncing,
synced,
/// The last sync failed (offline, server down); routes are safe locally and
/// go up on the next sync.
failed,
}
/// The pilot's saved waypoint routes (GO 4's mission library, plan P7).
///
/// Kept on this device as JSON in shared preferences, in the
/// [WaypointMissionSpec.toJson] format the API Server will store once route
/// sync lands. Newest first.
/// [WaypointMissionSpec.toJson] format the API Server stores. Newest first.
///
/// When signed in, the library syncs with the server through [MissionRemote]:
/// [load] pulls, every [save] and [delete] syncs again, and the two sides are
/// merged last-writer-wins by `updatedAt` ([mergeMissions]). Deletes the server
/// has not acknowledged are kept as local tombstones. Offline, everything still
/// works on the device and goes up on the next successful sync.
///
/// Each account keeps its own copy on the device, so pilots sharing a tablet
/// never see (or upload) each other's routes. Routes saved while signed out
/// are claimed by the first account that signs in.
class MissionLibrary extends ChangeNotifier {
MissionLibrary({SharedPreferences? prefs}) : _prefs = prefs;
MissionLibrary({SharedPreferences? prefs, MissionRemote? remote})
: _prefs = prefs,
_remote = remote;
static const String _key = 'missions.waypoint';
SharedPreferences? _prefs;
final MissionRemote? _remote;
List<WaypointMissionSpec> _missions = <WaypointMissionSpec>[];
Map<String, DateTime> _tombstones = <String, DateTime>{};
bool _loaded = false;
/// The account whose copy is loaded ('' = signed out).
String _account = '';
MissionSyncStatus _status = MissionSyncStatus.local;
Future<void> _chain = Future<void>.value();
/// The flight screen can close while [load] still waits for storage.
bool _disposed = false;
@@ -35,21 +67,69 @@ class MissionLibrary extends ChangeNotifier {
List<WaypointMissionSpec> get missions => List<WaypointMissionSpec>.unmodifiable(_missions);
bool get loaded => _loaded;
MissionSyncStatus get syncStatus => _status;
/// Completes once every sync started so far has finished.
Future<void> get idle => _chain;
static String _keyFor(String account) => account.isEmpty ? _key : '$_key@$account';
Future<void> load() async {
try {
_prefs ??= await SharedPreferences.getInstance();
final String? raw = _prefs!.getString(_key);
_missions = raw == null ? <WaypointMissionSpec>[] : decode(raw);
} catch (_) {
_missions = <WaypointMissionSpec>[];
}
_account = _remote?.account ?? '';
await _loadLocal();
_loaded = true;
notifyListeners();
await sync();
}
Future<void> _loadLocal() async {
try {
_prefs ??= await SharedPreferences.getInstance();
_missions = _read(_keyFor(_account));
_tombstones = _readTombstones(_keyFor(_account));
if (_account.isNotEmpty) {
// Routes saved while signed out now belong to this account.
final List<WaypointMissionSpec> unclaimed = _read(_key);
if (unclaimed.isNotEmpty) {
final Set<String> have = <String>{for (final WaypointMissionSpec m in _missions) m.id};
_missions = <WaypointMissionSpec>[
..._missions,
for (final WaypointMissionSpec m in unclaimed) if (!have.contains(m.id)) m,
];
await _store();
await _prefs!.remove(_key);
await _prefs!.remove('$_key.deleted');
}
}
} catch (_) {
_missions = <WaypointMissionSpec>[];
_tombstones = <String, DateTime>{};
}
}
List<WaypointMissionSpec> _read(String key) {
final String? raw = _prefs!.getString(key);
return raw == null ? <WaypointMissionSpec>[] : decode(raw);
}
Map<String, DateTime> _readTombstones(String key) {
final String? raw = _prefs!.getString('$key.deleted');
if (raw == null) return <String, DateTime>{};
try {
final Object? m = jsonDecode(raw);
if (m is! Map) return <String, DateTime>{};
return <String, DateTime>{
for (final MapEntry<Object?, Object?> e in m.entries)
if (e.key is String && DateTime.tryParse('${e.value}') != null) e.key! as String: DateTime.parse('${e.value}'),
};
} catch (_) {
return <String, DateTime>{};
}
}
/// Saves [m] (replacing the one with the same id) and returns it as stored:
/// with an id and a fresh [WaypointMissionSpec.updatedAt].
/// with an id and a fresh [WaypointMissionSpec.updatedAt]. Syncs in the
/// background.
Future<WaypointMissionSpec> save(WaypointMissionSpec m, {DateTime? now}) async {
final WaypointMissionSpec stored = m.copyWith(
id: m.id.isEmpty ? newId(now) : m.id,
@@ -59,25 +139,115 @@ class MissionLibrary extends ChangeNotifier {
stored,
for (final WaypointMissionSpec o in _missions) if (o.id != stored.id) o,
];
_tombstones.remove(stored.id);
await _persist();
unawaited(sync());
return stored;
}
Future<void> delete(String id) async {
Future<void> delete(String id, {DateTime? now}) async {
_missions = <WaypointMissionSpec>[for (final WaypointMissionSpec o in _missions) if (o.id != id) o];
// Only a signed-in library has a server copy to tell.
if (_account.isNotEmpty) _tombstones[id] = (now ?? DateTime.now()).toUtc();
await _persist();
unawaited(sync());
}
/// Pulls the server's routes, merges, and pushes what the device has newer.
/// Runs one at a time; a no-op while signed out. Never throws.
Future<void> sync() => _chain = _chain.then((_) => _sync()).catchError((Object _) {});
Future<void> _sync() async {
final MissionRemote? remote = _remote;
if (remote == null || _disposed) return;
final String account = remote.account;
if (account != _account) {
// Signed in, out, or as someone else since the last load.
_account = account;
await _loadLocal();
notifyListeners();
}
if (account.isEmpty) {
_setStatus(MissionSyncStatus.local);
return;
}
_setStatus(MissionSyncStatus.syncing);
try {
final List<RemoteRoute> theirs = await remote.pull();
if (remote.account != _account) return; // signed out mid-pull
// Merged against the library as it is now, not as it was before the
// pull: a save made meanwhile is newer and gets pushed.
final MissionMerge merge = mergeMissions(local: _missions, tombstones: _tombstones, remote: theirs);
_missions = merge.missions;
_tombstones = Map<String, DateTime>.of(merge.tombstones);
await _persist();
bool ok = true;
for (final WaypointMissionSpec m in merge.toPush) {
try {
final RemoteRoute? newer = await remote.push(m);
if (newer != null) _adopt(newer);
} catch (_) {
ok = false;
}
}
for (final MapEntry<String, DateTime> d in merge.toDelete.entries) {
try {
final RemoteRoute? newer = await remote.pushDelete(d.key, d.value);
if (_tombstones[d.key] == d.value) _tombstones.remove(d.key);
if (newer != null) _adopt(newer);
} catch (_) {
ok = false;
}
}
await _persist();
_setStatus(ok ? MissionSyncStatus.synced : MissionSyncStatus.failed);
} catch (_) {
_setStatus(MissionSyncStatus.failed);
}
}
/// Takes the server's copy of a route it refused to overwrite — unless the
/// pilot changed the route again while the push was in flight.
void _adopt(RemoteRoute r) {
final WaypointMissionSpec? mine = _missions.where((WaypointMissionSpec m) => m.id == r.id).firstOrNull;
final DateTime? mineAt = mine?.updatedAt ?? _tombstones[r.id];
if (mineAt != null && mineAt.isAfter(r.updatedAt)) return;
_tombstones.remove(r.id);
_missions = <WaypointMissionSpec>[
if (r.spec != null) r.spec!,
for (final WaypointMissionSpec o in _missions) if (o.id != r.id) o,
]..sort((WaypointMissionSpec a, WaypointMissionSpec b) =>
(b.updatedAt ?? DateTime(0)).compareTo(a.updatedAt ?? DateTime(0)));
}
void _setStatus(MissionSyncStatus s) {
if (_status == s) return;
_status = s;
notifyListeners();
}
Future<void> _persist() async {
notifyListeners();
try {
_prefs ??= await SharedPreferences.getInstance();
await _prefs!.setString(_key, encode(_missions));
await _store();
} catch (_) {
// Storage unavailable: the library lives for this session only.
}
}
Future<void> _store() async {
_prefs ??= await SharedPreferences.getInstance();
final String key = _keyFor(_account);
await _prefs!.setString(key, encode(_missions));
if (_tombstones.isEmpty) {
await _prefs!.remove('$key.deleted');
} else {
await _prefs!.setString('$key.deleted',
jsonEncode(<String, String>{for (final MapEntry<String, DateTime> e in _tombstones.entries) e.key: e.value.toUtc().toIso8601String()}));
}
}
static String newId([DateTime? now]) => 'wp-${(now ?? DateTime.now()).microsecondsSinceEpoch.toRadixString(36)}';
static String encode(List<WaypointMissionSpec> missions) =>
+211
View File
@@ -0,0 +1,211 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../../pb_auth.dart';
import 'waypoint_mission.dart';
/// One route as the API Server reports it: a [spec], or a tombstone (null
/// [spec]) left by a delete on some device. [updatedAt] is the
/// last-writer-wins clock either way.
class RemoteRoute {
const RemoteRoute(this.id, this.updatedAt, this.spec);
final String id;
final DateTime updatedAt;
final WaypointMissionSpec? spec;
bool get deleted => spec == null;
/// Reads one entry of the server's `/api/routes` view. Null when unusable.
static RemoteRoute? fromView(Map<String, dynamic> v) {
final String id = v['id'] as String? ?? '';
final DateTime? at = DateTime.tryParse(v['updatedAt'] as String? ?? '');
if (id.isEmpty || at == null) return null;
if (v['deleted'] == true) return RemoteRoute(id, at, null);
final Object? spec = v['spec'];
if (spec is! Map) return null;
try {
final WaypointMissionSpec m = WaypointMissionSpec.fromJson(Map<String, dynamic>.from(spec));
return RemoteRoute(id, at, m.copyWith(id: id, updatedAt: m.updatedAt ?? at));
} catch (_) {
return null;
}
}
}
/// The server half of the mission library. [ApiMissionRemote] is the real one.
abstract class MissionRemote {
/// Who the routes belong to; empty while signed out (nothing syncs).
String get account;
/// Every route the account owns, tombstones included.
Future<List<RemoteRoute>> pull();
/// Stores [m]. Returns the server's copy when it holds a newer one (the
/// write was refused), else null.
Future<RemoteRoute?> push(WaypointMissionSpec m);
/// Tombstones route [id] as deleted at [at]. Returns the server's copy when
/// the route was changed after [at] (the delete was refused), else null.
Future<RemoteRoute?> pushDelete(String id, DateTime at);
}
/// What [mergeMissions] decided.
class MissionMerge {
const MissionMerge({required this.missions, required this.tombstones, required this.toPush, required this.toDelete});
/// The library after the merge, newest first.
final List<WaypointMissionSpec> missions;
/// Local deletes the server has not heard of yet.
final Map<String, DateTime> tombstones;
/// Local routes newer than the server's copy (or unknown to it).
final List<WaypointMissionSpec> toPush;
/// Local deletes newer than the server's live copy.
final Map<String, DateTime> toDelete;
}
final DateTime _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
/// Last-writer-wins merge of the device's library with the server's, per
/// route id, by `updatedAt`. A delete counts as a write at the time it was
/// made, on either side. On a tie the server wins.
///
/// A route saved before it had an `updatedAt` is treated as the oldest
/// possible write (and stamped so it can be pushed).
MissionMerge mergeMissions({
required List<WaypointMissionSpec> local,
required Map<String, DateTime> tombstones,
required List<RemoteRoute> remote,
}) {
final Map<String, WaypointMissionSpec> mine = <String, WaypointMissionSpec>{
for (final WaypointMissionSpec m in local) m.id: m.updatedAt == null ? m.copyWith(updatedAt: _epoch) : m,
};
final Map<String, RemoteRoute> theirs = <String, RemoteRoute>{for (final RemoteRoute r in remote) r.id: r};
final Map<String, WaypointMissionSpec> out = <String, WaypointMissionSpec>{};
final Map<String, DateTime> keepTombstones = <String, DateTime>{};
final List<WaypointMissionSpec> toPush = <WaypointMissionSpec>[];
final Map<String, DateTime> toDelete = <String, DateTime>{};
final Set<String> ids = <String>{...mine.keys, ...tombstones.keys, ...theirs.keys};
for (final String id in ids) {
final WaypointMissionSpec? l = mine[id];
final DateTime? t = tombstones[id];
final RemoteRoute? r = theirs[id];
// The device's own latest word on this route: a save, or a delete.
final bool localDeleted = t != null && (l == null || !t.isBefore(l.updatedAt!));
final DateTime? localAt = localDeleted ? t : l?.updatedAt;
if (r == null) {
// The server never had it: push a save; a delete has nothing to undo.
if (!localDeleted && l != null) {
out[id] = l;
toPush.add(l);
}
continue;
}
if (localAt == null || !localAt.isAfter(r.updatedAt)) {
// The server's copy (or its delete) is the newest word.
if (!r.deleted) out[id] = r.spec!;
continue;
}
if (localDeleted) {
if (!r.deleted) {
keepTombstones[id] = t;
toDelete[id] = t;
}
} else {
out[id] = l!;
toPush.add(l);
}
}
final List<WaypointMissionSpec> missions = out.values.toList()
..sort((WaypointMissionSpec a, WaypointMissionSpec b) => b.updatedAt!.compareTo(a.updatedAt!));
return MissionMerge(missions: missions, tombstones: keepTombstones, toPush: toPush, toDelete: toDelete);
}
/// [MissionRemote] over the API Server's `/api/routes`, as the signed-in
/// pilot ([auth]).
class ApiMissionRemote implements MissionRemote {
ApiMissionRemote({PbAuth? auth}) : _auth = auth;
final PbAuth? _auth;
PbAuth get _a => _auth ?? auth;
static const Duration _timeout = Duration(seconds: 10);
@override
String get account => _a.isAuthed ? (_a.userEmail.isEmpty ? '_' : _a.userEmail) : '';
@override
Future<List<RemoteRoute>> pull() async {
final (int status, Map<String, dynamic> body) = await _send('GET', '/api/routes?mine=1&includeDeleted=1');
if (status != 200) throw MissionSyncException(status, body);
return <RemoteRoute>[
for (final Object? v in (body['routes'] as List<Object?>? ?? const <Object?>[]))
if (v is Map)
if (RemoteRoute.fromView(Map<String, dynamic>.from(v)) case final RemoteRoute r) r,
];
}
@override
Future<RemoteRoute?> push(WaypointMissionSpec m) async {
final (int status, Map<String, dynamic> body) =
await _send('PUT', '/api/routes/${Uri.encodeComponent(m.id)}', body: m.toJson());
if (status == 200 || status == 201) return null;
return _conflict(status, body);
}
@override
Future<RemoteRoute?> pushDelete(String id, DateTime at) async {
final String when = Uri.encodeQueryComponent(at.toUtc().toIso8601String());
final (int status, Map<String, dynamic> body) = await _send('DELETE', '/api/routes/${Uri.encodeComponent(id)}?at=$when');
// 404: the server never had it, which is as deleted as it gets.
if (status == 200 || status == 404) return null;
return _conflict(status, body);
}
RemoteRoute _conflict(int status, Map<String, dynamic> body) {
final Object? route = body['route'];
final RemoteRoute? r = status == 409 && route is Map ? RemoteRoute.fromView(Map<String, dynamic>.from(route)) : null;
if (r == null) throw MissionSyncException(status, body);
return r;
}
Future<(int, Map<String, dynamic>)> _send(String method, String path, {Object? body}) async {
final HttpClient http = HttpClient()..connectionTimeout = _timeout;
try {
final HttpClientRequest req = await http.openUrl(method, Uri.parse('${_a.serverUrl}$path'));
req.headers.set(HttpHeaders.authorizationHeader, _a.token);
if (body != null) {
req.headers.contentType = ContentType.json;
req.add(utf8.encode(jsonEncode(body)));
}
final HttpClientResponse resp = await req.close().timeout(_timeout);
final String text = await resp.transform(utf8.decoder).join().timeout(_timeout);
Map<String, dynamic> json = <String, dynamic>{};
try {
final Object? decoded = text.isEmpty ? null : jsonDecode(text);
if (decoded is Map) json = Map<String, dynamic>.from(decoded);
} catch (_) {}
return (resp.statusCode, json);
} finally {
http.close(force: true);
}
}
}
class MissionSyncException implements Exception {
const MissionSyncException(this.status, this.body);
final int status;
final Map<String, dynamic> body;
@override
String toString() => (body['error'] ?? 'Route sync failed ($status)').toString();
}
+2 -1
View File
@@ -8,6 +8,7 @@ import '../../fly/flight_trail.dart';
import '../../fly/fly_prefs.dart';
import '../../fly/hud_features.dart';
import '../../fly/modes/mission_library.dart';
import '../../fly/modes/mission_sync.dart' show ApiMissionRemote;
import '../../fly/modes/vision_modes.dart';
import '../../fly/modes/waypoint_editor.dart';
import '../../fly/modes/waypoint_mission.dart' show MissionLimits;
@@ -110,7 +111,7 @@ class _FlyScreenState extends State<FlyScreen> {
IntelligentMode? _mode;
late final WaypointEditor _waypoints = widget.waypoints ?? WaypointEditor();
final VisionSession _vision = VisionSession();
late final MissionLibrary _library = MissionLibrary()..load();
late final MissionLibrary _library = MissionLibrary(remote: ApiMissionRemote())..load();
MissionLimits _limits = const MissionLimits();
int _seenFinishes = 0;
@@ -190,8 +190,11 @@ class IssueRow extends StatelessWidget {
// ── Mission library ────────────────────────────────────────────────────────
/// The saved routes: save the current one, start a new one, load or delete.
Future<void> showMissionLibrary(BuildContext context, {required WaypointEditor editor, required MissionLibrary library}) =>
_showSheet(context, title: 'Mission library', icon: 'album', body: MissionLibraryList(editor: editor, library: library));
/// Opening it also syncs, so routes saved on another device show up.
Future<void> showMissionLibrary(BuildContext context, {required WaypointEditor editor, required MissionLibrary library}) {
library.sync();
return _showSheet(context, title: 'Mission library', icon: 'album', body: MissionLibraryList(editor: editor, library: library));
}
class MissionLibraryList extends StatelessWidget {
const MissionLibraryList({super.key, required this.editor, required this.library});
@@ -209,6 +212,13 @@ class MissionLibraryList extends StatelessWidget {
editor.markSaved(await library.save(m));
}
static String _heading(MissionSyncStatus s) => switch (s) {
MissionSyncStatus.local => 'SAVED ON THIS DEVICE',
MissionSyncStatus.syncing => 'SAVED ROUTES · SYNCING…',
MissionSyncStatus.synced => 'SAVED ROUTES · SYNCED',
MissionSyncStatus.failed => 'SAVED ROUTES · OFFLINE, SYNCS LATER',
};
Future<bool> _discardOk(BuildContext context) async {
if (!editor.dirty || editor.mission.points.isEmpty) return true;
return confirmHud(context, title: 'Discard changes?', body: 'The route on the map has unsaved changes.', action: 'Discard');
@@ -239,7 +249,7 @@ class MissionLibraryList extends StatelessWidget {
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
child: Text('SAVED ON THIS DEVICE', style: HudText.label),
child: Text(_heading(library.syncStatus), style: HudText.label),
),
if (library.missions.isEmpty)
Padding(
@@ -256,7 +266,9 @@ class MissionLibraryList extends StatelessWidget {
if (context.mounted) Navigator.of(context).pop();
},
onDelete: () async {
if (await confirmHud(context, title: 'Delete "${m.name}"?', body: 'The saved route is removed from this device.', action: 'Delete')) {
if (await confirmHud(context, title: 'Delete "${m.name}"?', body: library.syncStatus == MissionSyncStatus.local
? 'The saved route is removed from this device.'
: 'The saved route is removed from this device and your account.', action: 'Delete')) {
await library.delete(m.id);
}
},
+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;
}
}