A Flutter app that runs next to DJI Fly without the DJI SDK. It receives the aircraft's Remote ID (Bluetooth, BT5, Wi-Fi NaN and Beacon) in a foreground service, streams the paired aircraft to the hub over /ws/device, and turns take-off to landing into a logbook entry filed through /api/flights, queued when offline. Covers phases A0-A3 of the Fly Agent plan; needs no server change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
5.8 KiB
Dart
209 lines
5.8 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
|
||
import '../api/api.dart';
|
||
import '../rid/rid_aircraft.dart';
|
||
import '../rid/rid_service.dart';
|
||
import 'flight_session.dart';
|
||
|
||
/// A logbook entry that could not be filed (offline): kept until it uploads.
|
||
class PendingFlight {
|
||
const PendingFlight(this.id, this.body, this.error);
|
||
final String id;
|
||
final Map<String, dynamic> body;
|
||
final String error;
|
||
|
||
String get summary => '${body['operationDate']} ${body['startTime']}–${body['endTime']}';
|
||
}
|
||
|
||
/// Owns the current [FlightSession]: feeds it the paired aircraft's fixes,
|
||
/// persists it, and files it to the logbook (queueing when offline).
|
||
class SessionController extends ChangeNotifier {
|
||
SessionController({required this.api, required this.rid});
|
||
|
||
final Api api;
|
||
final RidService rid;
|
||
|
||
FlightSession session = FlightSession();
|
||
final List<PendingFlight> pending = <PendingFlight>[];
|
||
StreamSubscription<RidAircraft>? _sub;
|
||
Timer? _tick;
|
||
DateTime _lastSave = DateTime.fromMillisecondsSinceEpoch(0);
|
||
SharedPreferences? _prefs;
|
||
bool filing = false;
|
||
|
||
Future<void> init() async {
|
||
_prefs = await SharedPreferences.getInstance();
|
||
final String? raw = _prefs!.getString('session');
|
||
if (raw != null) {
|
||
try {
|
||
session = FlightSession.decode(raw);
|
||
} catch (_) {
|
||
session = FlightSession();
|
||
}
|
||
}
|
||
final String? q = _prefs!.getString('pending_flights');
|
||
if (q != null) {
|
||
try {
|
||
for (final Object? e in jsonDecode(q) as List<Object?>) {
|
||
final Map<String, dynamic> m = (e! as Map).cast<String, dynamic>();
|
||
pending.add(PendingFlight('${m['id']}', (m['body'] as Map).cast<String, dynamic>(), '${m['error'] ?? ''}'));
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
_sub = rid.pairedLocations.listen(_onFix);
|
||
_tick = Timer.periodic(const Duration(seconds: 1), (_) {
|
||
if (session.onTick(DateTime.now())) _save(force: true);
|
||
// Elapsed time on screen.
|
||
if (session.phase == SessionPhase.active || session.phase == SessionPhase.armed) notifyListeners();
|
||
});
|
||
notifyListeners();
|
||
}
|
||
|
||
void _onFix(RidAircraft a) {
|
||
if (session.ridSerial.isNotEmpty && a.serial != session.ridSerial) return;
|
||
final bool changed = session.onFix(FixSample(
|
||
at: a.lastLocationAt ?? DateTime.now(),
|
||
airborne: a.isAirborne,
|
||
lat: a.latitude,
|
||
lon: a.longitude,
|
||
height: a.height,
|
||
speed: a.speedHorizontal,
|
||
));
|
||
_save(force: changed);
|
||
if (changed) notifyListeners();
|
||
}
|
||
|
||
/// Briefing done: wait for take-off.
|
||
void arm({
|
||
required String droneId,
|
||
required String droneName,
|
||
required String purpose,
|
||
required String category,
|
||
required String areaRoute,
|
||
required String weather,
|
||
}) {
|
||
session = FlightSession()
|
||
..droneId = droneId
|
||
..droneName = droneName
|
||
..ridSerial = rid.pairing?.serial ?? ''
|
||
..purpose = purpose
|
||
..category = category
|
||
..areaRoute = areaRoute
|
||
..weather = weather;
|
||
session.arm(DateTime.now());
|
||
_save(force: true);
|
||
notifyListeners();
|
||
}
|
||
|
||
void startNow() {
|
||
session.start(DateTime.now());
|
||
_save(force: true);
|
||
notifyListeners();
|
||
}
|
||
|
||
void stopNow() {
|
||
session.stop(DateTime.now());
|
||
_save(force: true);
|
||
notifyListeners();
|
||
}
|
||
|
||
void resume() {
|
||
session.resume();
|
||
_save(force: true);
|
||
notifyListeners();
|
||
}
|
||
|
||
void discard() {
|
||
session = FlightSession();
|
||
_save(force: true);
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Saves review edits made on the landed screen.
|
||
void edited() {
|
||
_save(force: true);
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Files the landed session. Returns null on success, else a message; a
|
||
/// network failure queues the entry instead of losing it.
|
||
Future<String?> file() async {
|
||
final Map<String, dynamic> body = session.toFlightInput();
|
||
filing = true;
|
||
notifyListeners();
|
||
try {
|
||
await api.createFlight(body);
|
||
session = FlightSession();
|
||
return null;
|
||
} on ApiException catch (e) {
|
||
if (!e.isNetwork) return e.message; // the pilot has to fix the entry
|
||
pending.add(PendingFlight(DateTime.now().millisecondsSinceEpoch.toString(), body, e.message));
|
||
_savePending();
|
||
session = FlightSession();
|
||
return 'Offline: the entry is queued and will be filed when the server is reachable.';
|
||
} finally {
|
||
filing = false;
|
||
_save(force: true);
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Retries queued entries. Returns how many were filed.
|
||
Future<int> retryPending() async {
|
||
int ok = 0;
|
||
for (final PendingFlight p in List<PendingFlight>.of(pending)) {
|
||
try {
|
||
await api.createFlight(p.body);
|
||
pending.remove(p);
|
||
ok++;
|
||
} on ApiException catch (e) {
|
||
final int i = pending.indexOf(p);
|
||
if (i >= 0) pending[i] = PendingFlight(p.id, p.body, e.message);
|
||
if (e.isNetwork) break;
|
||
}
|
||
}
|
||
_savePending();
|
||
notifyListeners();
|
||
return ok;
|
||
}
|
||
|
||
void dropPending(PendingFlight p) {
|
||
pending.remove(p);
|
||
_savePending();
|
||
notifyListeners();
|
||
}
|
||
|
||
void _save({bool force = false}) {
|
||
final DateTime now = DateTime.now();
|
||
// A fix a second would otherwise rewrite the whole track every second.
|
||
if (!force && now.difference(_lastSave) < const Duration(seconds: 10)) return;
|
||
_lastSave = now;
|
||
if (session.phase == SessionPhase.idle) {
|
||
_prefs?.remove('session');
|
||
} else {
|
||
_prefs?.setString('session', session.encode());
|
||
}
|
||
}
|
||
|
||
void _savePending() {
|
||
_prefs?.setString(
|
||
'pending_flights',
|
||
jsonEncode(pending
|
||
.map((PendingFlight p) => <String, dynamic>{'id': p.id, 'body': p.body, 'error': p.error})
|
||
.toList()),
|
||
);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_save(force: true);
|
||
_sub?.cancel();
|
||
_tick?.cancel();
|
||
super.dispose();
|
||
}
|
||
}
|