Files
PilotVault/Fly App/lib/fly/modes/mission_sync.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

212 lines
7.9 KiB
Dart

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