import 'package:flutter/foundation.dart'; import 'fly/product_profile.dart'; import 'fly/state/aircraft_state.dart'; import 'fly/state/alert_state.dart'; import 'fly/state/battery_state.dart'; import 'fly/state/camera_state.dart'; import 'fly/state/gimbal_state.dart'; import 'fly/state/link_state.dart'; import 'fly/state/mission_state.dart'; import 'fly/state/vision_state.dart'; import 'uploader.dart'; enum RegistrationState { idle, registering, success, failed } /// Camera capture family, mirrors the right-rail Photo/Video/Pano switch. enum CaptureMode { photo, video, pano } /// Exposure program — Auto (PROGRAM) vs Pro (MANUAL) tab on Camera settings. enum ExposureProgram { auto, pro } /// A media file listed off the aircraft's SD card by the native MediaManager. class MediaItem { MediaItem({ required this.index, required this.fileName, required this.isVideo, this.durationSeconds, this.sizeBytes, this.createdMs, this.thumbPath, this.localPath, }); final int index; final String fileName; final bool isVideo; final int? durationSeconds; final int? sizeBytes; final int? createdMs; /// On-disk path of the fetched thumbnail (cache dir), once available. String? thumbPath; /// On-disk path of the fully downloaded original, once available. String? localPath; /// `null` for anything that shouldn't carry a duration. The SDK reports /// `durationInSeconds` for *every* file, so stills come back with junk — /// a Mavic Pro's photos yielded -36:35, -136:23 and 545:26 on the SD card. String? get durationLabel { if (!isVideo) return null; final int? s = durationSeconds; if (s == null || s <= 0) return null; final String mm = (s ~/ 60).toString(); final String ss = (s % 60).toString().padLeft(2, '0'); return '$mm:$ss'; } factory MediaItem.fromMap(Map m) => MediaItem( index: (m['index'] as num).toInt(), fileName: (m['fileName'] as String?) ?? 'file', isVideo: (m['type'] as String?) == 'video', durationSeconds: (m['durationSeconds'] as num?)?.toInt(), sizeBytes: (m['sizeBytes'] as num?)?.toInt(), createdMs: (m['createdMs'] as num?)?.toInt(), ); } /// Live aircraft/session state, shared across every screen. `_HomePageState` /// owns the DJI/uploader plumbing and feeds bridge events through /// [TelemetryReducer]; the screens observe it. /// /// Telemetry lives in per-subsystem sub-models (plan P1) — [aircraft], /// [battery], [links], [camera], [gimbal], [vision], [alerts], [mission] — each /// its own [ChangeNotifier], so a HUD widget can listen to just the one it /// draws (`ListenableBuilder(listenable: model.battery, …)`) instead of /// rebuilding with every 10 Hz tick. The model itself re-notifies on any /// sub-model change — once per [batch], not once per sub-model — which keeps /// the pre-P2 screens (`AnimatedBuilder(animation: model)`) and their flat /// getters below working unchanged. class FlightModel extends ChangeNotifier { FlightModel() { for (final ChangeNotifier sub in _subs) { sub.addListener(_onSub); } } bool _batching = false; bool _dirty = false; void _onSub() { if (_batching) { _dirty = true; } else { notifyListeners(); } } /// Runs [update] (several sub-model writes) and notifies this model's own /// listeners once at the end, if anything changed. Sub-model listeners still /// hear their own change immediately. void batch(void Function() update) { if (_batching) return update(); _batching = true; try { update(); } finally { _batching = false; if (_dirty) { _dirty = false; notifyListeners(); } } } // ── Telemetry sub-models ─────────────────────────────────────────────────── final AircraftState aircraft = AircraftState(); final BatteryState battery = BatteryState(); final LinkState links = LinkState(); final CameraState camera = CameraState(); final GimbalState gimbal = GimbalState(); final VisionState vision = VisionState(); final AlertState alerts = AlertState(); final MissionState mission = MissionState(); late final List _subs = [ aircraft, battery, links, camera, gimbal, vision, alerts, mission, // ]; /// Wipes the aircraft-bound sub-models on disconnect. void clearTelemetry() => batch(() { aircraft.clear(); battery.clear(); links.clear(); camera.clear(); gimbal.clear(); vision.clear(); alerts.clear(); }); // ── SDK / registration / connection ──────────────────────────────────────── String sdkVersion = '…'; RegistrationState registration = RegistrationState.idle; String? registrationError; bool connected = false; String? model; String? firmwareVersion; /// The remote controller's own firmware — distinct from the aircraft's above. String? controllerFirmwareVersion; /// The *flight controller's* serial, which is the only serial MSDK v4 exposes — /// the airframe serial on the sticker (the one a drone is registered under) is /// unreadable over the SDK and is entered by hand in the Web App's fleet. String? flightControllerSerial; /// What the connected product can do (plan P0). [ProductProfile.disconnected] /// until the bridge's first `productProfile` event after connect. ProductProfile profile = ProductProfile.disconnected; // ── Flight controller telemetry (pre-P2 flat view of [aircraft]) ─────────── int? get satellites => aircraft.satellites; int? get gpsSignalLevel => aircraft.gpsSignalLevel; // 0..5 bool? get isFlying => aircraft.isFlying; bool? get motorsOn => aircraft.motorsOn; String? get flightMode => aircraft.flightMode; double? get altitude => aircraft.altitude; // m, relative to home double? get latitude => aircraft.latitude; double? get longitude => aircraft.longitude; double? get homeLatitude => aircraft.homeLatitude; double? get homeLongitude => aircraft.homeLongitude; double? get homeDistance => aircraft.homeDistance; // m, aircraft ↔ home double? get horizontalSpeed => aircraft.horizontalSpeed; // m/s double? get verticalSpeed => aircraft.verticalSpeed; // m/s (up positive) double? get heading => aircraft.heading; // deg int? get flightTimeSeconds => aircraft.flightTimeSeconds; double? get goHomeHeight => aircraft.goHomeHeight; // m // Phone's own GPS (independent of the drone's fix); streamed to the server as // a location fallback for the Web App's automatic bounding box. double? phoneLatitude; double? phoneLongitude; // ── Battery (flat view of [battery]) ──────────────────────────────────────── int? get batteryPercent => battery.percent; double? get batteryVoltage => battery.voltage; // V double? get batteryTemperature => battery.temperature; // °C // ── Camera ────────────────────────────────────────────────────────────────── CaptureMode captureMode = CaptureMode.video; String? shootPhotoMode; // SINGLE / HDR / BURST / AEB / INTERVAL / PANORAMA bool get isRecording => camera.isRecording; int get recordSeconds => camera.recordSeconds; bool get sdInserted => camera.storage?.inserted ?? false; int? get sdRemainingMB => camera.storage?.remainingMB; int? get sdPhotoCount => camera.storage?.availableCaptureCount; int? get sdRecordSeconds => camera.storage?.availableRecordingSeconds; // ── Exposure ──────────────────────────────────────────────────────────────── // Read back into [camera]; the camera settings page writes them optimistically // ahead of the next `exposure` event. ExposureProgram exposureProgram = ExposureProgram.auto; String? get iso => camera.iso; // "100", "AUTO", … set iso(String? v) => camera.iso = v; String? get shutter => camera.shutter; // "1/240" set shutter(String? v) => camera.shutter = v; String? get aperture => camera.aperture; // "f/2.8" set aperture(String? v) => camera.aperture = v; String? get ev => camera.ev; // "-0.3" set ev(String? v) => camera.ev = v; String? whiteBalance; // "5200K" / "AUTO" List? histogram; // 0..1 normalized bins // ── Gimbal (flat view of [gimbal]) ────────────────────────────────────────── double? get gimbalPitch => gimbal.pitch; // deg (−90..30-ish) double? get gimbalRoll => gimbal.roll; double? get gimbalYaw => gimbal.yaw; // ── Flight settings (Safety / Control) ────────────────────────────────────── int? maxHeight; // m int? maxRadius; // m bool? maxRadiusEnabled; int? rthHeight; // m String? obstacleAvoidance; // "On" / "Bypass" / "Off" bool? noviceMode; bool arHomePoint = true; // client-side toggle (AR overlay) // ── Missions (flat view of [mission]) ─────────────────────────────────────── String get missionState => mission.state; // idle / ready / uploading / executing / … bool get missionRunning => mission.running; bool get tracking => mission.tracking; // ActiveTrack engaged String? get missionError => mission.error; // ── On-drone media ────────────────────────────────────────────────────────── List media = []; bool mediaLoading = false; // ── DJI account (optional, in addition to PilotVault) ─────────────────────── String djiAccountState = 'unknown'; // notLoggedIn / tokenOutOfDate / authorized / … String? djiAccountUser; // ── Telemetry upload channel ──────────────────────────────────────────────── UploadStatus upload = UploadStatus.disabled; bool get registered => registration == RegistrationState.success; /// Notify observers after a batch of field writes. void bump() => notifyListeners(); @override void dispose() { for (final ChangeNotifier sub in _subs) { sub.removeListener(_onSub); sub.dispose(); } super.dispose(); } }