Files
PilotVault/Fly App/lib/fly/modes/mission_library.dart
T
tajniak81andClaude Opus 5.5 93f4a580e9 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>
2026-10-20 17:26:45 +02:00

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;
}
}