Files
PilotVault/Fly App/lib/fly/flight_record.dart
T
tajniak81andClaude Opus 5.5 91ca150a35 Give the Fly App GO 4-style playback, flight records, Find My Drone and live streaming
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>
2026-10-20 22:07:10 +02:00

267 lines
8.8 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import '../flight_model.dart';
import 'hud_geometry.dart';
import 'state/aircraft_state.dart';
import 'state/parse.dart';
/// One sample of a recorded flight: where the aircraft was [t] seconds after take-off.
@immutable
class TrackPoint {
const TrackPoint(this.latitude, this.longitude, this.altitude, this.t);
/// Stored compactly as `[lat, lon, alt, t]`.
factory TrackPoint.fromJson(List<Object?> j) =>
TrackPoint(finite(j[0]) ?? 0, finite(j[1]) ?? 0, finite(j[2]) ?? 0, asInt(j[3]) ?? 0);
final double latitude;
final double longitude;
final double altitude; // m, relative to take-off
final int t; // s since take-off
LatLng get latLng => LatLng(latitude, longitude);
List<Object> toJson() => <Object>[
double.parse(latitude.toStringAsFixed(7)),
double.parse(longitude.toStringAsFixed(7)),
double.parse(altitude.toStringAsFixed(1)),
t,
];
}
/// One flight, take-off to landing, as the Fly App recorded it from telemetry
/// (plan P9 "flight records"). Kept on the phone by `FlightRecordStore` and
/// uploaded to the API Server's logbook by `FlightLogSync`.
class FlightRecord {
FlightRecord({
required this.id,
required this.start,
required this.end,
this.aircraftModel,
this.flightControllerSerial,
this.firmware,
this.homeLatitude,
this.homeLongitude,
List<TrackPoint>? track,
this.maxAltitude = 0,
this.maxDistance = 0,
this.maxSpeed = 0,
this.distanceFlown = 0,
this.batteryStart,
this.batteryEnd,
this.simulated = false,
this.logbookId,
this.uploadError,
}) : track = track ?? <TrackPoint>[];
factory FlightRecord.fromJson(Map<String, dynamic> j) => FlightRecord(
id: asString(j['id']) ?? '',
start: DateTime.fromMillisecondsSinceEpoch(asInt(j['start']) ?? 0),
end: DateTime.fromMillisecondsSinceEpoch(asInt(j['end']) ?? 0),
aircraftModel: asString(j['model']),
flightControllerSerial: asString(j['fcSerial']),
firmware: asString(j['firmware']),
homeLatitude: finite(j['homeLat']),
homeLongitude: finite(j['homeLon']),
track: <TrackPoint>[
for (final Object? p in (j['track'] as List<Object?>? ?? const <Object?>[]))
if (p is List && p.length >= 4) TrackPoint.fromJson(p),
],
maxAltitude: finite(j['maxAlt']) ?? 0,
maxDistance: finite(j['maxDist']) ?? 0,
maxSpeed: finite(j['maxSpeed']) ?? 0,
distanceFlown: finite(j['distance']) ?? 0,
batteryStart: asInt(j['batteryStart']),
batteryEnd: asInt(j['batteryEnd']),
simulated: asBool(j['simulated']) ?? false,
logbookId: asString(j['logbookId']),
uploadError: asString(j['uploadError']),
);
/// `fr-<take-off ms>`, also the file name.
final String id;
final DateTime start;
DateTime end;
final String? aircraftModel;
final String? flightControllerSerial;
final String? firmware;
double? homeLatitude;
double? homeLongitude;
final List<TrackPoint> track;
double maxAltitude; // m above take-off
double maxDistance; // m from home
double maxSpeed; // m/s horizontal
double distanceFlown; // m along the track
int? batteryStart; // %
int? batteryEnd; // %
/// Flown in the flight controller's simulator: never a logbook flight.
bool simulated;
/// The API Server's flight id once uploaded.
String? logbookId;
/// Why the last upload failed, until one succeeds.
String? uploadError;
bool get uploaded => logbookId != null;
Duration get duration => end.difference(start);
/// The last place the aircraft was seen on this flight.
TrackPoint? get lastPoint => track.isEmpty ? null : track.last;
Map<String, Object?> toJson() => <String, Object?>{
'id': id,
'start': start.millisecondsSinceEpoch,
'end': end.millisecondsSinceEpoch,
'model': aircraftModel,
'fcSerial': flightControllerSerial,
'firmware': firmware,
'homeLat': homeLatitude,
'homeLon': homeLongitude,
'track': <Object>[for (final TrackPoint p in track) p.toJson()],
'maxAlt': maxAltitude,
'maxDist': maxDistance,
'maxSpeed': maxSpeed,
'distance': distanceFlown,
'batteryStart': batteryStart,
'batteryEnd': batteryEnd,
'simulated': simulated,
'logbookId': logbookId,
'uploadError': uploadError,
};
/// `12 min 05 s`, `45 s`.
String get durationLabel {
final int s = duration.inSeconds;
if (s < 60) return '$s s';
final int h = s ~/ 3600, m = (s % 3600) ~/ 60;
final String ss = (s % 60).toString().padLeft(2, '0');
return h > 0 ? '$h h ${m.toString().padLeft(2, '0')} min' : '$m min $ss s';
}
}
/// A usable GPS fix: present and not the SDK's (0, 0) "no fix".
bool validFix(double? lat, double? lon) => lat != null && lon != null && !(lat == 0 && lon == 0) && lat.abs() <= 90 && lon.abs() <= 180;
/// Records every flight from telemetry (plan P9): a flight starts when the
/// flight controller reports `isFlying`, and ends once it has reported landed
/// for [landedGrace], or when the aircraft disconnects mid-air. Each finished
/// flight goes to [onFinished]; flights shorter than [minDuration] (a hop, a
/// glitch) are dropped.
///
/// The track keeps a point every [sampleEvery] with a valid fix; maxima are
/// taken on every tick.
class FlightRecorder extends ChangeNotifier {
FlightRecorder({
required this.model,
required this.onFinished,
DateTime Function()? clock,
this.sampleEvery = const Duration(seconds: 1),
this.landedGrace = const Duration(seconds: 5),
this.minDuration = const Duration(seconds: 5),
}) : _clock = clock ?? DateTime.now {
model.aircraft.addListener(_onAircraft);
model.addListener(_onModel);
}
final FlightModel model;
final void Function(FlightRecord record) onFinished;
final DateTime Function() _clock;
final Duration sampleEvery;
final Duration landedGrace;
final Duration minDuration;
FlightRecord? _current;
DateTime? _lastSample;
DateTime? _landedAt;
DateTime? _lastSeen;
/// The flight being recorded, or null on the ground.
FlightRecord? get current => _current;
bool get recording => _current != null;
void _onModel() {
// Gone mid-air: the flight ends where it was last seen.
if (!model.connected && _current != null) _finish(_lastSeen ?? _clock());
}
void _onAircraft() {
final DateTime now = _clock();
final bool? flying = model.aircraft.isFlying;
final FlightRecord? r = _current;
if (r == null) {
if (flying == true && model.connected) _begin(now);
return;
}
if (flying == null) return; // a cleared snapshot; the disconnect ends it
_lastSeen = now;
_sample(r, now);
if (flying) {
_landedAt = null;
} else {
_landedAt ??= now;
if (now.difference(_landedAt!) >= landedGrace) _finish(_landedAt!);
}
}
void _begin(DateTime now) {
final FlightRecord r = FlightRecord(
id: 'fr-${now.millisecondsSinceEpoch}',
start: now,
end: now,
aircraftModel: model.model,
flightControllerSerial: model.flightControllerSerial,
firmware: model.firmwareVersion,
batteryStart: model.battery.percent,
simulated: model.aircraft.simulatorActive == true,
);
_current = r;
_lastSample = null;
_landedAt = null;
_lastSeen = now;
_sample(r, now);
notifyListeners();
}
void _sample(FlightRecord r, DateTime now) {
final AircraftState a = model.aircraft;
r.end = now;
if (a.simulatorActive == true) r.simulated = true;
if (validFix(a.homeLatitude, a.homeLongitude)) {
r.homeLatitude = a.homeLatitude;
r.homeLongitude = a.homeLongitude;
}
final double alt = a.altitude ?? 0;
if (alt > r.maxAltitude) r.maxAltitude = alt;
if ((a.homeDistance ?? 0) > r.maxDistance) r.maxDistance = a.homeDistance!;
if ((a.horizontalSpeed ?? 0) > r.maxSpeed) r.maxSpeed = a.horizontalSpeed!;
if (model.battery.percent != null) r.batteryEnd = model.battery.percent;
if (!validFix(a.latitude, a.longitude)) return;
if (_lastSample != null && now.difference(_lastSample!) < sampleEvery) return;
_lastSample = now;
final TrackPoint p = TrackPoint(a.latitude!, a.longitude!, alt, now.difference(r.start).inSeconds);
final TrackPoint? last = r.lastPoint;
if (last != null) r.distanceFlown += HudGeometry.distance(last.latitude, last.longitude, p.latitude, p.longitude);
r.track.add(p);
}
void _finish(DateTime end) {
final FlightRecord r = _current!;
_current = null;
r.end = end.isBefore(r.start) ? r.start : end;
notifyListeners();
if (r.duration >= minDuration) onFinished(r);
}
@override
void dispose() {
model.aircraft.removeListener(_onAircraft);
model.removeListener(_onModel);
super.dispose();
}
}