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:
1 parent
e49f318a0e
commit
93f4a580e9
18 files changed
+1525
-43
No files matched your search
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user