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 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 toJson() => [ 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? 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 ?? []; factory FlightRecord.fromJson(Map 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: [ for (final Object? p in (j['track'] as List? ?? const [])) 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-`, 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 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 toJson() => { 'id': id, 'start': start.millisecondsSinceEpoch, 'end': end.millisecondsSinceEpoch, 'model': aircraftModel, 'fcSerial': flightControllerSerial, 'firmware': firmware, 'homeLat': homeLatitude, 'homeLon': homeLongitude, 'track': [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(); } }