The "•••" button now opens a right-side glass sheet over the live view with Safety, Vision, Remote, Video link, Battery, Gimbal and General tabs. Each row is a one-line spec bound to a KeyManager key: rows hide when the aircraft does not support their key, writes show at once and roll back if refused, and ground-only settings lock in flight. KeyBridge gains argument-less action keys and converts range and LED settings values. settings_menu_page.dart is retired. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
255 lines
11 KiB
Dart
255 lines
11 KiB
Dart
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/key_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<String, dynamic> 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();
|
||
|
||
/// KeyManager values the settings sheet listens to (plan P6).
|
||
final KeyState keys = KeyState();
|
||
|
||
late final List<ChangeNotifier> _subs = <ChangeNotifier>[
|
||
aircraft, battery, links, camera, gimbal, vision, alerts, mission, keys, //
|
||
];
|
||
|
||
/// 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();
|
||
keys.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<double>? 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;
|
||
|
||
// ── 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<MediaItem> media = <MediaItem>[];
|
||
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();
|
||
}
|
||
}
|