Playback: the flight screen's playback button opens the album over the live view, so a video tapped there plays on the aircraft into the feed with a transport bar. Long press selects files to download or delete (delete asks first); one file opens in a viewer with the camera's preview. Leaving the album puts the camera back in its capture mode. Flight records: every flight is recorded from take-off to landing (1 Hz track, max height / distance / speed, distance flown, battery used) and kept on the phone. When it lands while signed in, it is uploaded to the PilotVault logbook through the existing /api/drones/auto and /api/flights endpoints as an automatic-path entry with the track as GeoJSON. Simulator flights are never uploaded; flights recorded while signed out retry on sign-in. Flight logs lists them with their upload state and a track map, and the profile stats come from real flights. Find My Drone: the aircraft's last fix is kept across lost links and restarts, and shown against the phone's own GPS with distance, direction and a hand-off to the maps app. Live streaming: a new LiveStreamBridge streams the main camera to an RTMP address set in Settings > General, with a LIVE pill on the flight screen that turns amber when the uplink falls behind and stops on tap. Pre-flight checklist (manual checks plus the aircraft's live status) and an in-app Academy with six short guides that work offline. None of this has run on hardware yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
4.7 KiB
Dart
154 lines
4.7 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import 'flight_record.dart';
|
|
import 'flight_record_sync.dart';
|
|
import 'fly_prefs.dart';
|
|
|
|
/// Where recorded flights live on the phone.
|
|
abstract class FlightRecordStore {
|
|
Future<List<FlightRecord>> loadAll();
|
|
Future<void> save(FlightRecord r);
|
|
Future<void> delete(String id);
|
|
}
|
|
|
|
/// One JSON file per flight under the app's documents directory
|
|
/// (`flight_records/<id>.json`); an unreadable file is skipped, not fatal.
|
|
class FileFlightRecordStore implements FlightRecordStore {
|
|
FileFlightRecordStore({Future<Directory> Function()? root}) : _root = root ?? getApplicationDocumentsDirectory;
|
|
|
|
final Future<Directory> Function() _root;
|
|
Directory? _dir;
|
|
|
|
Future<Directory> _folder() async {
|
|
if (_dir != null) return _dir!;
|
|
final Directory d = Directory('${(await _root()).path}${Platform.pathSeparator}flight_records');
|
|
if (!await d.exists()) await d.create(recursive: true);
|
|
return _dir = d;
|
|
}
|
|
|
|
File _file(Directory d, String id) => File('${d.path}${Platform.pathSeparator}$id.json');
|
|
|
|
@override
|
|
Future<List<FlightRecord>> loadAll() async {
|
|
final Directory d = await _folder();
|
|
final List<FlightRecord> out = <FlightRecord>[];
|
|
await for (final FileSystemEntity f in d.list()) {
|
|
if (f is! File || !f.path.endsWith('.json')) continue;
|
|
try {
|
|
final Object? j = jsonDecode(await f.readAsString());
|
|
if (j is Map) out.add(FlightRecord.fromJson(Map<String, dynamic>.from(j)));
|
|
} catch (_) {
|
|
// A half-written file from a crash: skip it.
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
@override
|
|
Future<void> save(FlightRecord r) async {
|
|
final File f = _file(await _folder(), r.id);
|
|
// Write then rename, so a crash never leaves a truncated record.
|
|
final File tmp = File('${f.path}.tmp');
|
|
await tmp.writeAsString(jsonEncode(r.toJson()), flush: true);
|
|
await tmp.rename(f.path);
|
|
}
|
|
|
|
@override
|
|
Future<void> delete(String id) async {
|
|
final File f = _file(await _folder(), id);
|
|
if (await f.exists()) await f.delete();
|
|
}
|
|
}
|
|
|
|
/// The phone's flight records (plan P9): every flight the [FlightRecorder]
|
|
/// finishes is saved here and, while signed in and
|
|
/// [FlyPrefs.autoUploadFlights] is on, uploaded to the API Server's logbook.
|
|
/// Flights that could not be uploaded stay pending and retry on [uploadPending].
|
|
class FlightLog extends ChangeNotifier {
|
|
FlightLog({required this.store, required this.remote});
|
|
|
|
final FlightRecordStore store;
|
|
final LogbookRemote remote;
|
|
|
|
List<FlightRecord> _records = <FlightRecord>[];
|
|
bool _loaded = false;
|
|
final Set<String> _uploading = <String>{};
|
|
|
|
/// Newest first.
|
|
List<FlightRecord> get records => List<FlightRecord>.unmodifiable(_records);
|
|
bool get loaded => _loaded;
|
|
bool isUploading(FlightRecord r) => _uploading.contains(r.id);
|
|
|
|
/// Real flights not in the logbook yet.
|
|
List<FlightRecord> get pending => <FlightRecord>[for (final FlightRecord r in _records) if (!r.uploaded && !r.simulated) r];
|
|
|
|
/// The latest flight, which is where Find My Drone looks first.
|
|
FlightRecord? get latest => _records.isEmpty ? null : _records.first;
|
|
|
|
Future<void> load() async {
|
|
try {
|
|
_records = await store.loadAll();
|
|
} catch (_) {
|
|
_records = <FlightRecord>[];
|
|
}
|
|
_sort();
|
|
_loaded = true;
|
|
notifyListeners();
|
|
}
|
|
|
|
void _sort() => _records.sort((FlightRecord a, FlightRecord b) => b.start.compareTo(a.start));
|
|
|
|
/// A flight the recorder just finished.
|
|
Future<void> add(FlightRecord r) async {
|
|
_records.removeWhere((FlightRecord x) => x.id == r.id);
|
|
_records.add(r);
|
|
_sort();
|
|
notifyListeners();
|
|
await _save(r);
|
|
if (FlyPrefs.autoUploadFlights.value && remote.available && !r.simulated) await upload(r);
|
|
}
|
|
|
|
Future<void> _save(FlightRecord r) async {
|
|
try {
|
|
await store.save(r);
|
|
} catch (_) {
|
|
// Storage full or gone: the record lives in memory until the app closes.
|
|
}
|
|
}
|
|
|
|
Future<FlightRecord> upload(FlightRecord r) async {
|
|
if (r.uploaded || !_uploading.add(r.id)) return r;
|
|
notifyListeners();
|
|
try {
|
|
await uploadFlightRecord(r, remote);
|
|
await _save(r);
|
|
} finally {
|
|
_uploading.remove(r.id);
|
|
notifyListeners();
|
|
}
|
|
return r;
|
|
}
|
|
|
|
/// Retries every pending flight; answers how many made it.
|
|
Future<int> uploadPending() async {
|
|
if (!remote.available) return 0;
|
|
int ok = 0;
|
|
for (final FlightRecord r in pending) {
|
|
if ((await upload(r)).uploaded) ok++;
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
Future<void> delete(FlightRecord r) async {
|
|
_records.removeWhere((FlightRecord x) => x.id == r.id);
|
|
notifyListeners();
|
|
try {
|
|
await store.delete(r.id);
|
|
} catch (_) {}
|
|
}
|
|
}
|