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>
272 lines
9.4 KiB
Dart
272 lines
9.4 KiB
Dart
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 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, 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;
|
|
|
|
@override
|
|
void dispose() {
|
|
_disposed = true;
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void notifyListeners() {
|
|
if (!_disposed) super.notifyListeners();
|
|
}
|
|
|
|
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 {
|
|
_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]. 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,
|
|
updatedAt: now ?? DateTime.now(),
|
|
);
|
|
_missions = <WaypointMissionSpec>[
|
|
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, {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 {
|
|
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) =>
|
|
jsonEncode(<Map<String, Object?>>[for (final WaypointMissionSpec m in missions) m.toJson()]);
|
|
|
|
/// Unreadable entries are skipped rather than losing the whole library.
|
|
static List<WaypointMissionSpec> decode(String raw) {
|
|
final Object? list = jsonDecode(raw);
|
|
if (list is! List) return <WaypointMissionSpec>[];
|
|
final List<WaypointMissionSpec> out = <WaypointMissionSpec>[];
|
|
for (final Object? e in list) {
|
|
if (e is! Map) continue;
|
|
try {
|
|
out.add(WaypointMissionSpec.fromJson(Map<String, dynamic>.from(e)));
|
|
} catch (_) {
|
|
continue;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
}
|