diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/AirLinkBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/AirLinkBridge.kt index 3241725..fbd9137 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/AirLinkBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/AirLinkBridge.kt @@ -13,11 +13,15 @@ import io.flutter.plugin.common.MethodChannel * * ``` * links: {kind: ocusync|lightbridge|wifi, uplinkQuality, downlinkQuality} - * rc: {batteryPercent, batteryMah, isCharging} + * rc: {batteryPercent, batteryMah, isCharging, mode, masterSlaveConnected} * ``` * * Wi-Fi products (Spark, Mavic Air on Wi-Fi) report quality through their * `WiFiLink`; the RC links report through `AirLink` itself. + * + * `rc.mode` is the `RCMode` name (`MASTER` / `SLAVE` / `NORMAL` …) and only + * appears on remotes that support dual-operator flying, where the HUD shows a + * master / slave badge. */ class AirLinkBridge(private val ctx: BridgeCtx) : SubBridge { @@ -62,7 +66,15 @@ class AirLinkBridge(private val ctx: BridgeCtx) : SubBridge { "batteryMah" to s.remainingChargeInmAh, "isCharging" to s.isCharging, ) - ctx.mainHandler.post { ctx.hub.putSlow("rc", values) } + ctx.mainHandler.post { ctx.hub.patchSlow("rc", values) } + } + } + if (safe { rc.isMasterSlaveModeSupported } == true) { + safe { + rc.setMasterSlaveStateCallback { s -> + val values = mapOf("mode" to s.rcMode?.name, "masterSlaveConnected" to s.isConnected) + ctx.mainHandler.post { ctx.hub.patchSlow("rc", values) } + } } } } diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt index 65492a5..d1e6d8e 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt @@ -2,6 +2,7 @@ package com.pilotvault.flyapp import dji.common.camera.ExposureSettings import dji.common.camera.ResolutionAndFrameRate +import dji.common.camera.SSDState import dji.common.camera.SettingsDefinitions import dji.common.camera.StorageState import dji.common.camera.SystemState @@ -26,7 +27,12 @@ import io.flutter.plugin.common.MethodChannel * availableCaptureCount, availableRecordingSeconds} * cameraSettings: {aeLock, focusMode, photoAspectRatio, photoFileFormat, * videoResolution, videoFrameRate, videoFileFormat, colorProfile} + * ssd: {connected, state, capacity, remainingMB, availableRecordingSeconds} + * thermal: {spotTemperature} * ``` + * `ssd` only on cameras with an SSD slot (X5S / X7 on Inspire 2), `thermal` only + * on thermal cameras (XT / XT2 / Mavic 2 Enterprise Dual); the HUD's SSD and + * thermal readouts (plan P2) are hidden on everything else. * The settings are watched keys, so a change made from the RC's dial or the * settings sheet shows up without a re-read. */ @@ -112,6 +118,22 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge { } catch (_: Throwable) { // A camera without removable or internal storage reports nothing. } + if (safe { camera.isSSDSupported } == true) { + safe { + camera.setSSDStateCallback { s: SSDState -> + val values = ssdMap(s) + ctx.mainHandler.post { ctx.hub.putSlow("ssd", values) } + } + } + } + if (safe { camera.isThermalCamera } == true) { + safe { + camera.setThermalTemperatureCallback { celsius: Float -> + val t = if (celsius.isNaN()) null else celsius.toDouble() + ctx.mainHandler.post { ctx.hub.putSlow("thermal", mapOf("spotTemperature" to t)) } + } + } + } val index = try { camera.index @@ -156,6 +178,14 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge { "availableRecordingSeconds" to s.availableRecordingTimeInSeconds, ) + private fun ssdMap(s: SSDState): Map = mapOf( + "connected" to s.isConnected, + "state" to s.ssdOperationState?.name, + "capacity" to s.capacity?.name, + "remainingMB" to s.remainingSpaceInMB, + "availableRecordingSeconds" to s.availableRecordingTimeInSeconds, + ) + private fun emitSystem(state: SystemState) { ctx.emit( mapOf( @@ -241,4 +271,10 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge { null } } + + private inline fun safe(block: () -> T): T? = try { + block() + } catch (_: Throwable) { + null + } } diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/TelemetryHub.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/TelemetryHub.kt index 3c7ffa3..b28db5c 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/TelemetryHub.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/TelemetryHub.kt @@ -13,7 +13,8 @@ import android.os.SystemClock * * - **fast** sources ([putFast]: flight-controller state, vision) at up to * [FAST_MS] (10 Hz), only when one of them changed; - * - **slow** sources ([putSlow]: battery, links, RC, storage, camera settings) + * - **slow** sources ([putSlow]: battery, links, RC, storage, SSD, thermal, + * camera settings) * folded in at most every [SLOW_MS] (2 Hz), so a chatty battery callback * cannot drive the tick rate on its own. * diff --git a/Fly App/lib/fly/flight_status.dart b/Fly App/lib/fly/flight_status.dart new file mode 100644 index 0000000..5e28070 --- /dev/null +++ b/Fly App/lib/fly/flight_status.dart @@ -0,0 +1,67 @@ +import 'state/aircraft_state.dart'; +import 'state/alert_state.dart'; + +/// How the status chip is coloured, worst last. +enum StatusLevel { neutral, ready, caution, danger } + +/// The status chip at the left of the HUD's top bar (GO 4's "Ready to Go (GPS)"). +class FlightStatus { + const FlightStatus(this.text, this.level); + + final String text; + final StatusLevel level; + + /// GPS level the flight controller needs before it will record a home point. + static const int minGpsLevel = 3; + + /// Reads the chip off the flight controller state and the live diagnostics. + /// Ordered by what the pilot most needs to know: an automatic manoeuvre in + /// progress first, then battery, then faults, then the plain flight state. + static FlightStatus of({required bool connected, required AircraftState a, AlertState? alerts}) { + if (!connected) return const FlightStatus('Disconnected', StatusLevel.neutral); + if (a.updatedAt == null) return const FlightStatus('Connecting…', StatusLevel.neutral); + + final String mode = a.flightModeEnum ?? ''; + final bool flying = a.isFlying == true; + final AlertSeverity? worst = alerts?.worst; + + if (a.landingConfirmationNeeded == true) return const FlightStatus('Confirm landing', StatusLevel.caution); + if (a.goingHome == true || mode == 'GO_HOME') { + return const FlightStatus('Returning to Home', StatusLevel.caution); + } + if (mode == 'AUTO_LANDING' || mode == 'CONFIRM_LANDING') return const FlightStatus('Landing', StatusLevel.caution); + if (mode == 'AUTO_TAKEOFF' || mode == 'ASSISTED_TAKEOFF') return const FlightStatus('Taking off', StatusLevel.ready); + if (a.seriousLowBattery == true) return const FlightStatus('Critically low battery', StatusLevel.danger); + if (a.lowBattery == true) return const FlightStatus('Low battery', StatusLevel.caution); + if (worst == AlertSeverity.critical) { + return FlightStatus(flying ? 'Warning' : 'Cannot take off', StatusLevel.danger); + } + if (a.imuPreheating == true) return const FlightStatus('IMU preheating', StatusLevel.caution); + + final String fix = _positioning(a); + if (flying) { + return FlightStatus('In Flight ($fix)', worst == AlertSeverity.warning ? StatusLevel.caution : StatusLevel.ready); + } + final bool noFix = fix == 'ATTI'; + return FlightStatus( + 'Ready to Go ($fix)', + noFix || worst == AlertSeverity.warning ? StatusLevel.caution : StatusLevel.ready, + ); + } + + /// What the aircraft is holding position with: GPS, the vision system, or nothing. + static String _positioning(AircraftState a) { + if ((a.gpsSignalLevel ?? 0) >= minGpsLevel) return 'GPS'; + if (a.visionPositioningBeingUsed == true) return 'Vision'; + return 'ATTI'; + } + + @override + bool operator ==(Object other) => other is FlightStatus && other.text == text && other.level == level; + + @override + int get hashCode => Object.hash(text, level); + + @override + String toString() => 'FlightStatus($text, $level)'; +} diff --git a/Fly App/lib/fly/flight_trail.dart b/Fly App/lib/fly/flight_trail.dart new file mode 100644 index 0000000..3c3eaa1 --- /dev/null +++ b/Fly App/lib/fly/flight_trail.dart @@ -0,0 +1,47 @@ +import 'package:flutter/foundation.dart'; +import 'package:latlong2/latlong.dart'; + +import 'hud_geometry.dart'; +import 'state/aircraft_state.dart'; + +/// The path the aircraft has flown this session, for the minimap. Follows an +/// [AircraftState] and keeps a point every [minSpacing] metres, so a 10 Hz +/// hover does not grow the list. +class FlightTrail extends ChangeNotifier { + FlightTrail(this.aircraft, {this.minSpacing = 2, this.maxPoints = 5000}) { + aircraft.addListener(_onAircraft); + _onAircraft(); + } + + final AircraftState aircraft; + final double minSpacing; // m + final int maxPoints; + + final List _points = []; + + List get points => List.unmodifiable(_points); + + void _onAircraft() { + final double? lat = aircraft.latitude, lon = aircraft.longitude; + if (lat == null || lon == null || (lat == 0 && lon == 0)) return; + if (_points.isNotEmpty) { + final LatLng last = _points.last; + if (HudGeometry.distance(last.latitude, last.longitude, lat, lon) < minSpacing) return; + } + _points.add(LatLng(lat, lon)); + if (_points.length > maxPoints) _points.removeRange(0, _points.length - maxPoints); + notifyListeners(); + } + + void clear() { + if (_points.isEmpty) return; + _points.clear(); + notifyListeners(); + } + + @override + void dispose() { + aircraft.removeListener(_onAircraft); + super.dispose(); + } +} diff --git a/Fly App/lib/fly/fly_prefs.dart b/Fly App/lib/fly/fly_prefs.dart new file mode 100644 index 0000000..cab9f94 --- /dev/null +++ b/Fly App/lib/fly/fly_prefs.dart @@ -0,0 +1,38 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Device-local switches for the flight screen, persisted across launches. +abstract final class FlyPrefs { + static const String _classicKey = 'fly.classicHud'; + static const String _perfKey = 'fly.perfOverlay'; + + /// Open the pre-P2 `FlightControlPage` instead of the HUD v2 — kept + /// reachable until P3's safety flows land (plan P2). + static final ValueNotifier classicHud = ValueNotifier(false); + + /// Flutter's performance overlay on the flight screen, for the P2 "no jank at + /// 10 Hz" check. + static final ValueNotifier perfOverlay = ValueNotifier(false); + + static SharedPreferences? _prefs; + + static Future load() async { + try { + _prefs = await SharedPreferences.getInstance(); + classicHud.value = _prefs!.getBool(_classicKey) ?? false; + perfOverlay.value = _prefs!.getBool(_perfKey) ?? false; + } catch (_) { + // Storage unavailable: run on the defaults. + } + } + + static Future setClassicHud(bool on) async { + classicHud.value = on; + await _prefs?.setBool(_classicKey, on); + } + + static Future setPerfOverlay(bool on) async { + perfOverlay.value = on; + await _prefs?.setBool(_perfKey, on); + } +} diff --git a/Fly App/lib/fly/hud_geometry.dart b/Fly App/lib/fly/hud_geometry.dart new file mode 100644 index 0000000..c1e56f4 --- /dev/null +++ b/Fly App/lib/fly/hud_geometry.dart @@ -0,0 +1,111 @@ +import 'dart:math' as math; +import 'dart:ui' show Offset; + +/// The HUD's pure geometry, kept out of the painters so it can be unit-tested +/// (plan §4: "attitude-ball geometry (bearing/distance → dot position)"). +abstract final class HudGeometry { + static const double _earthRadius = 6371000; // m + + static double _rad(double deg) => deg * math.pi / 180; + + /// Great-circle distance in metres. + static double distance(double lat1, double lon1, double lat2, double lon2) { + final double dLat = _rad(lat2 - lat1), dLon = _rad(lon2 - lon1); + final double h = math.pow(math.sin(dLat / 2), 2) + + math.cos(_rad(lat1)) * math.cos(_rad(lat2)) * math.pow(math.sin(dLon / 2), 2); + return 2 * _earthRadius * math.asin(math.min(1, math.sqrt(h))); + } + + /// Initial bearing from point 1 to point 2, degrees clockwise from north, 0…360. + static double bearing(double lat1, double lon1, double lat2, double lon2) { + final double p1 = _rad(lat1), p2 = _rad(lat2), dLon = _rad(lon2 - lon1); + final double y = math.sin(dLon) * math.cos(p2); + final double x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dLon); + return (math.atan2(y, x) * 180 / math.pi + 360) % 360; + } + + /// The attitude ball's distance scales, in metres from centre to rim. + static const List ballRanges = [25, 50, 100, 200, 500, 1000, 2000, 5000, 10000]; + + /// The smallest scale that fits [farthest] on the ball; the largest when nothing does. + static double ballRange(double farthest) { + for (final double r in ballRanges) { + if (farthest <= r) return r; + } + return ballRanges.last; + } + + /// Where a point [distanceM] away at [bearingDeg] lands on a ball of [radius] + /// px scaled to [rangeM], relative to the centre, with screen y pointing down. + /// [upDeg] is the bearing drawn at the top: 0 for north-up, the aircraft's + /// heading for heading-up. Points past the range are pinned to the rim, so + /// the direction still reads. + static Offset ballOffset({ + required double bearingDeg, + required double distanceM, + required double radius, + required double rangeM, + double upDeg = 0, + }) { + final double r = radius * (distanceM / rangeM).clamp(0.0, 1.0); + final double a = _rad(bearingDeg - upDeg); + return Offset(r * math.sin(a), -r * math.cos(a)); + } + + /// Where the horizon crosses the ball's vertical axis, as a fraction of the + /// radius from the centre (positive = below centre). As on an artificial + /// horizon, a nose-down pitch (negative in the SDK) shows more ground, so the + /// horizon moves *up*; ±[fullScaleDeg] reaches the rim. + static double horizonShift(double pitchDeg, {double fullScaleDeg = 45}) => + (pitchDeg / fullScaleDeg).clamp(-1.0, 1.0); +} + +/// Where the smart battery bar draws its fill and markers, as 0…1 fractions of +/// its width. A null marker is one the flight controller has not reported. +class BatteryBarLayout { + const BatteryBarLayout({required this.fill, this.landMarker, this.homeMarker, this.lowMarker, this.criticalMarker}); + + factory BatteryBarLayout.from({ + int? percent, + int? batteryNeededToLand, + int? batteryNeededToGoHome, + int? lowThreshold, + int? criticalThreshold, + }) { + double? f(int? v) => v == null || v <= 0 ? null : (v.clamp(0, 100) / 100.0); + return BatteryBarLayout( + fill: (percent ?? 0).clamp(0, 100) / 100.0, + landMarker: f(batteryNeededToLand), + homeMarker: f(batteryNeededToGoHome), + lowMarker: f(lowThreshold), + criticalMarker: f(criticalThreshold), + ); + } + + final double fill; + + /// Charge needed to land from the current height. + final double? landMarker; + + /// Charge needed to fly home and land — GO 4's "H" marker. + final double? homeMarker; + final double? lowMarker; + final double? criticalMarker; + + /// The bar's colour band: 0 = fine, 1 = only enough to go home, 2 = only enough to land. + int get band { + if (landMarker != null && fill <= landMarker!) return 2; + if (criticalMarker != null && fill <= criticalMarker!) return 2; + if (homeMarker != null && fill <= homeMarker!) return 1; + if (lowMarker != null && fill <= lowMarker!) return 1; + return 0; + } +} + +/// `mm:ss`, or `h:mm:ss` past the hour; `--:--` when unknown. +String formatDuration(int? seconds) { + if (seconds == null || seconds < 0) return '--:--'; + final int h = seconds ~/ 3600, m = (seconds % 3600) ~/ 60, s = seconds % 60; + final String mm = m.toString().padLeft(2, '0'), ss = s.toString().padLeft(2, '0'); + return h > 0 ? '$h:$mm:$ss' : '$mm:$ss'; +} diff --git a/Fly App/lib/fly/state/camera_state.dart b/Fly App/lib/fly/state/camera_state.dart index 31a7cd4..6d8d287 100644 --- a/Fly App/lib/fly/state/camera_state.dart +++ b/Fly App/lib/fly/state/camera_state.dart @@ -69,6 +69,44 @@ class StorageInfo { bool get usable => inserted && !error && !invalidFormat && !formatting && !initializing && !full; } +/// The SSD on cameras that record to one (X5S / X7 on Inspire 2). +@immutable +class SsdInfo { + const SsdInfo({this.connected = false, this.state, this.capacity, this.remainingMB, this.availableRecordingSeconds}); + + factory SsdInfo.fromMap(Map m) => SsdInfo( + connected: asBool(m['connected']) ?? false, + state: asString(m['state']), + capacity: asString(m['capacity']), + remainingMB: asInt(m['remainingMB']), + availableRecordingSeconds: asInt(m['availableRecordingSeconds']), + ); + + final bool connected; + + /// `SSDOperationState`: `NOT_FOUND` / `IDLE` / `SAVING` / `FORMATTING` / + /// `INITIALIZING` / `ERROR` / `FULL` / `POOR_CONNECTION` … + final String? state; + final String? capacity; // CAPACITY_512_GB … + final int? remainingMB; + final int? availableRecordingSeconds; + + /// One word for the HUD's SSD chip, like [StorageInfo.status]. + String get status { + if (!connected || state == 'NOT_FOUND') return 'No SSD'; + return switch (state) { + 'ERROR' => 'SSD error', + 'FULL' => 'SSD full', + 'FORMATTING' => 'Formatting', + 'INITIALIZING' => 'Initializing', + 'POOR_CONNECTION' => 'SSD loose', + _ => 'OK', + }; + } + + bool get usable => status == 'OK'; +} + /// Camera state from the bridge: recording, exposure read-back, storage and /// the watched camera settings. Recording and exposure arrive as their own /// `camera` / `exposure` events; storage and settings as the `storage` and @@ -88,6 +126,12 @@ class CameraState extends ChangeNotifier { StorageInfo? storage; + /// Only on cameras with an SSD slot; null elsewhere. + SsdInfo? ssd; + + /// Spot-meter temperature in °C, only from thermal cameras. + double? thermalSpot; + bool? aeLock; String? focusMode; // MANUAL / AUTO / AFC String? photoAspectRatio; // RATIO_4_3 / RATIO_16_9 / RATIO_3_2 @@ -106,6 +150,16 @@ class CameraState extends ChangeNotifier { notifyListeners(); } + /// Sets [mode] ahead of the camera's own `camera` event, so the HUD follows + /// the photo / video switch at once. + void setModeOptimistic(String mode) { + if (this.mode == mode) return; + this.mode = mode; + notifyListeners(); + } + + bool get isVideoMode => mode == 'RECORD_VIDEO'; + void applyExposure(Map e) { iso = asString(e['iso']); shutter = asString(e['shutter']); @@ -115,8 +169,15 @@ class CameraState extends ChangeNotifier { } /// From the telemetry tick; an absent group means the camera never reported it. - void applyTelemetry({Map? storage, Map? settings}) { + void applyTelemetry({ + Map? storage, + Map? ssd, + Map? thermal, + Map? settings, + }) { this.storage = storage == null ? null : StorageInfo.fromMap(storage); + this.ssd = ssd == null ? null : SsdInfo.fromMap(ssd); + thermalSpot = finite(thermal?['spotTemperature']); aeLock = asBool(settings?['aeLock']); focusMode = asString(settings?['focusMode']); photoAspectRatio = asString(settings?['photoAspectRatio']); @@ -134,6 +195,8 @@ class CameraState extends ChangeNotifier { recordSeconds = 0; mode = iso = shutter = aperture = ev = null; storage = null; + ssd = null; + thermalSpot = null; aeLock = null; focusMode = photoAspectRatio = photoFileFormat = videoResolution = videoFrameRate = videoFileFormat = colorProfile = null; updatedAt = null; diff --git a/Fly App/lib/fly/state/link_state.dart b/Fly App/lib/fly/state/link_state.dart index c5073dd..7357faf 100644 --- a/Fly App/lib/fly/state/link_state.dart +++ b/Fly App/lib/fly/state/link_state.dart @@ -18,6 +18,17 @@ class LinkState extends ChangeNotifier { int? rcBatteryMah; bool? rcCharging; + /// `RCMode` name on dual-operator remotes: `MASTER` / `SLAVE` / `NORMAL` … + String? rcMode; + bool? masterSlaveConnected; + + /// "M" / "S" for the HUD's dual-operator badge, null when flying solo. + String? get rcRoleBadge => switch (rcMode) { + 'MASTER' || 'MASTER_SUB' => 'M', + 'SLAVE' || 'SLAVE_SUB' => 'S', + _ => null, + }; + DateTime? updatedAt; /// 0–5 bars, the way the top bar draws a 0–100 quality. @@ -30,14 +41,16 @@ class LinkState extends ChangeNotifier { rcBatteryPercent = asInt(rc?['batteryPercent']); rcBatteryMah = asInt(rc?['batteryMah']); rcCharging = asBool(rc?['isCharging']); + rcMode = asString(rc?['mode']); + masterSlaveConnected = asBool(rc?['masterSlaveConnected']); updatedAt = DateTime.now(); notifyListeners(); } void clear() { - kind = null; + kind = rcMode = null; uplinkQuality = downlinkQuality = rcBatteryPercent = rcBatteryMah = null; - rcCharging = null; + rcCharging = masterSlaveConnected = null; updatedAt = null; notifyListeners(); } diff --git a/Fly App/lib/fly/telemetry_reducer.dart b/Fly App/lib/fly/telemetry_reducer.dart index 4e09c4b..a1319ee 100644 --- a/Fly App/lib/fly/telemetry_reducer.dart +++ b/Fly App/lib/fly/telemetry_reducer.dart @@ -7,7 +7,8 @@ import 'state/parse.dart'; /// /// A `telemetry` event is the native [TelemetryHub]'s full snapshot: the flight /// controller's flat keys plus one nested group per subsystem (`battery`, -/// `links`, `rc`, `storage`, `cameraSettings`, `gimbal`, `vision`). It arrives at +/// `links`, `rc`, `storage`, `ssd`, `thermal`, `cameraSettings`, `gimbal`, +/// `vision`). It arrives at /// up to 10 Hz with the slow groups repeated unchanged between their 2 Hz /// refreshes, so each group is compared with the last one seen and its /// sub-model only notified when it actually moved. A group missing from the @@ -19,7 +20,7 @@ class TelemetryReducer { final Map _lastGroups = {}; - static const List _groups = ['battery', 'links', 'rc', 'storage', 'cameraSettings', 'gimbal', 'vision']; + static const List _groups = ['battery', 'links', 'rc', 'storage', 'ssd', 'thermal', 'cameraSettings', 'gimbal', 'vision']; /// Applies [event] if it is one of the telemetry types; returns whether it was. bool apply(Map event) { @@ -67,8 +68,13 @@ class TelemetryReducer { if (changed.contains('links') || changed.contains('rc')) { model.links.apply(links: asMap(t['links']), rc: asMap(t['rc'])); } - if (changed.contains('storage') || changed.contains('cameraSettings')) { - model.camera.applyTelemetry(storage: asMap(t['storage']), settings: asMap(t['cameraSettings'])); + if (const ['storage', 'ssd', 'thermal', 'cameraSettings'].any(changed.contains)) { + model.camera.applyTelemetry( + storage: asMap(t['storage']), + ssd: asMap(t['ssd']), + thermal: asMap(t['thermal']), + settings: asMap(t['cameraSettings']), + ); } if (changed.contains('gimbal')) { final Map? g = asMap(t['gimbal']); diff --git a/Fly App/lib/main.dart b/Fly App/lib/main.dart index 0962dd5..2247d92 100644 --- a/Fly App/lib/main.dart +++ b/Fly App/lib/main.dart @@ -6,6 +6,7 @@ import 'package:geolocator/geolocator.dart'; import 'dji_service.dart'; import 'flight_model.dart'; +import 'fly/fly_prefs.dart'; import 'fly/product_profile.dart'; import 'fly/telemetry_reducer.dart'; import 'login_page.dart'; @@ -15,6 +16,7 @@ import 'ui/academy_page.dart'; import 'ui/album_page.dart'; import 'ui/flight_control_page.dart'; import 'ui/flight_logs_page.dart'; +import 'ui/fly/fly_screen.dart'; import 'ui/home_page.dart'; import 'ui/profile_page.dart'; import 'ui/routes_page.dart'; @@ -24,6 +26,7 @@ Future main() async { WidgetsFlutterBinding.ensureInitialized(); await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); await auth.init(); + await FlyPrefs.load(); runApp(const DjiSampleApp()); } @@ -340,7 +343,9 @@ class _HomePageState extends State { void _goFly() { Navigator.of(context).push(MaterialPageRoute( - builder: (_) => FlightControlPage(model: _model, dji: _dji), + builder: (_) => FlyPrefs.classicHud.value + ? FlightControlPage(model: _model, dji: _dji) + : FlyScreen(model: _model, dji: _dji), )); } diff --git a/Fly App/lib/ui/flight_control_page.dart b/Fly App/lib/ui/flight_control_page.dart index b58c782..ed3e867 100644 --- a/Fly App/lib/ui/flight_control_page.dart +++ b/Fly App/lib/ui/flight_control_page.dart @@ -19,6 +19,9 @@ import 'settings_menu_page.dart'; /// Live camera feed behind a glass HUD; real telemetry bound throughout, and /// the shutter / mode switch / gimbal slider / RTH / take-off controls issue /// real SDK commands via [DjiService]. +/// +/// Retired by the HUD v2 (`fly/fly_screen.dart`, plan P2); still reachable via +/// Settings → About → Classic flight screen until the P3 safety flows land. class FlightControlPage extends StatefulWidget { const FlightControlPage({super.key, required this.model, required this.dji}); diff --git a/Fly App/lib/ui/fly/bottom/attitude_ball.dart b/Fly App/lib/ui/fly/bottom/attitude_ball.dart new file mode 100644 index 0000000..d33830c --- /dev/null +++ b/Fly App/lib/ui/fly/bottom/attitude_ball.dart @@ -0,0 +1,214 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../flight_model.dart'; +import '../../../fly/hud_geometry.dart'; +import '../../../fly/state/aircraft_state.dart'; +import '../../../theme.dart'; +import '../hud_kit.dart'; + +/// GO 4's attitude ball (plan P2): the horizon from the aircraft's pitch and +/// roll, with a north-up radar on top — the aircraft arrow in the middle +/// pointing along its heading, the home point and the pilot (phone GPS) +/// plotted by bearing and distance, and a tick on the rim for the gimbal's yaw. +class AttitudeBall extends StatelessWidget { + const AttitudeBall({super.key, required this.model, this.size = 96}); + + final FlightModel model; + final double size; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([model.aircraft, model.gimbal]), + builder: (BuildContext context, _) { + final AttitudeBallData d = AttitudeBallData.from( + model.aircraft, + gimbalYaw: model.gimbal.yaw, + phoneLat: model.phoneLatitude, + phoneLon: model.phoneLongitude, + ); + return SizedBox( + width: size, + height: size + 12, + child: Column(children: [ + SizedBox.square(dimension: size, child: CustomPaint(painter: AttitudeBallPainter(d))), + const SizedBox(height: 1), + Text(d.hasPoints ? '${d.rangeM.toStringAsFixed(0)} m' : '', style: HudText.label), + ]), + ); + }, + ); + } +} + +/// Everything the painter needs, resolved from the models so it can be compared +/// for [CustomPainter.shouldRepaint] and built in tests. +@immutable +class AttitudeBallData { + const AttitudeBallData({ + this.pitch, + this.roll, + this.heading, + this.gimbalYaw, + this.homeBearing, + this.homeDistance, + this.pilotBearing, + this.pilotDistance, + this.rangeM = 100, + }); + + /// Bearings and distances are taken from the aircraft to each point. + factory AttitudeBallData.from(AircraftState a, {double? gimbalYaw, double? phoneLat, double? phoneLon}) { + final double? lat = a.latitude, lon = a.longitude; + final bool fix = lat != null && lon != null && !(lat == 0 && lon == 0); + double? hb, hd, pb, pd; + if (fix && a.homeLatitude != null && a.homeLongitude != null && a.homeLocationSet != false) { + hb = HudGeometry.bearing(lat, lon, a.homeLatitude!, a.homeLongitude!); + hd = HudGeometry.distance(lat, lon, a.homeLatitude!, a.homeLongitude!); + } + if (fix && phoneLat != null && phoneLon != null) { + pb = HudGeometry.bearing(lat, lon, phoneLat, phoneLon); + pd = HudGeometry.distance(lat, lon, phoneLat, phoneLon); + } + return AttitudeBallData( + pitch: a.pitch, + roll: a.roll, + heading: a.heading, + gimbalYaw: gimbalYaw, + homeBearing: hb, + homeDistance: hd, + pilotBearing: pb, + pilotDistance: pd, + rangeM: HudGeometry.ballRange(math.max(hd ?? 0, pd ?? 0)), + ); + } + + final double? pitch; + final double? roll; + final double? heading; + final double? gimbalYaw; + final double? homeBearing; + final double? homeDistance; + final double? pilotBearing; + final double? pilotDistance; + final double rangeM; + + bool get hasPoints => homeDistance != null || pilotDistance != null; + + @override + bool operator ==(Object other) => + other is AttitudeBallData && + other.pitch == pitch && + other.roll == roll && + other.heading == heading && + other.gimbalYaw == gimbalYaw && + other.homeBearing == homeBearing && + other.homeDistance == homeDistance && + other.pilotBearing == pilotBearing && + other.pilotDistance == pilotDistance && + other.rangeM == rangeM; + + @override + int get hashCode => + Object.hash(pitch, roll, heading, gimbalYaw, homeBearing, homeDistance, pilotBearing, pilotDistance, rangeM); +} + +class AttitudeBallPainter extends CustomPainter { + AttitudeBallPainter(this.d); + + final AttitudeBallData d; + + static const Color _sky = Color(0x803D7BF0); + static const Color _ground = Color(0x806B4E2E); + static const Color _pilot = Color(0xFF5B93F5); + + @override + void paint(Canvas canvas, Size size) { + final double r = size.shortestSide / 2; + final Offset c = size.center(Offset.zero); + final Rect ball = Rect.fromCircle(center: c, radius: r); + + // ── Horizon ──────────────────────────────────────────────────────────── + canvas.save(); + canvas.clipPath(Path()..addOval(ball)); + canvas.drawRect(ball, Paint()..color = _sky); + canvas.translate(c.dx, c.dy); + canvas.rotate(-(d.roll ?? 0) * math.pi / 180); + final double hy = HudGeometry.horizonShift(d.pitch ?? 0) * r; + canvas.drawRect(Rect.fromLTRB(-2 * r, hy, 2 * r, 2 * r), Paint()..color = _ground); + canvas.drawLine(Offset(-r, hy), Offset(r, hy), Paint() + ..color = const Color(0xCCEAF0FA) + ..strokeWidth = 1); + canvas.restore(); + + // ── Rim, range ring and north tick ───────────────────────────────────── + canvas.drawCircle(c, r - 0.5, Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = const Color(0x66EAF0FA)); + canvas.drawCircle(c, r / 2, Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 0.6 + ..color = const Color(0x33EAF0FA)); + _label(canvas, 'N', c + Offset(0, -r + 7), Glass.ink, 8); + + // ── Gimbal yaw tick (on the rim) ─────────────────────────────────────── + final double? gy = d.gimbalYaw; + if (gy != null) { + final double a = gy * math.pi / 180; + final Offset dir = Offset(math.sin(a), -math.cos(a)); + canvas.drawLine(c + dir * (r - 7), c + dir * (r - 1), Paint() + ..color = Glass.subject + ..strokeWidth = 2.5 + ..strokeCap = StrokeCap.round); + } + + // ── Home and pilot ───────────────────────────────────────────────────── + final double plot = r - 6; + if (d.homeBearing != null && d.homeDistance != null) { + final Offset p = c + + HudGeometry.ballOffset(bearingDeg: d.homeBearing!, distanceM: d.homeDistance!, radius: plot, rangeM: d.rangeM); + canvas.drawCircle(p, 5.5, Paint()..color = Glass.sat); + _label(canvas, 'H', p, const Color(0xFF0A1120), 7); + } + if (d.pilotBearing != null && d.pilotDistance != null) { + final Offset p = c + + HudGeometry.ballOffset(bearingDeg: d.pilotBearing!, distanceM: d.pilotDistance!, radius: plot, rangeM: d.rangeM); + canvas.drawCircle(p, 3.5, Paint()..color = _pilot); + canvas.drawCircle(p, 3.5, Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = Colors.white); + } + + // ── Aircraft arrow (heading, north-up) ───────────────────────────────── + canvas.save(); + canvas.translate(c.dx, c.dy); + canvas.rotate((d.heading ?? 0) * math.pi / 180); + final Path arrow = Path() + ..moveTo(0, -9) + ..lineTo(6, 6) + ..lineTo(0, 3) + ..lineTo(-6, 6) + ..close(); + canvas.drawPath(arrow, Paint()..color = d.heading == null ? HudColors.off : Colors.white); + canvas.drawPath(arrow, Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 0.8 + ..color = const Color(0xFF0A1120)); + canvas.restore(); + } + + void _label(Canvas canvas, String t, Offset centre, Color color, double size) { + final TextPainter tp = TextPainter( + text: TextSpan(text: t, style: TextStyle(fontFamily: PV.fontMono, fontSize: size, fontWeight: FontWeight.w700, color: color)), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, centre - Offset(tp.width / 2, tp.height / 2)); + } + + @override + bool shouldRepaint(AttitudeBallPainter old) => old.d != d; +} diff --git a/Fly App/lib/ui/fly/bottom/minimap.dart b/Fly App/lib/ui/fly/bottom/minimap.dart new file mode 100644 index 0000000..39d927a --- /dev/null +++ b/Fly App/lib/ui/fly/bottom/minimap.dart @@ -0,0 +1,256 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart' show LatLng; + +import '../../../flight_model.dart'; +import '../../../fly/flight_trail.dart'; +import '../../../fly/state/aircraft_state.dart'; +import '../../../theme.dart'; +import '../../pv_icons.dart'; +import '../hud_kit.dart'; + +/// What the minimap shows and how it follows the aircraft. Shared between the +/// map layer and its HUD controls, which live in different parts of the tree. +class MinimapController extends ChangeNotifier { + /// Keep the aircraft centred. A pan by hand turns it off; recentring turns it back on. + bool follow = true; + + /// Rotate the map so the aircraft's heading points up, instead of north. + bool headingUp = false; + + void setFollow(bool on) { + if (follow == on) return; + follow = on; + notifyListeners(); + } + + void toggleHeadingUp() { + headingUp = !headingUp; + follow = true; + notifyListeners(); + } +} + +/// The map layer: OpenStreetMap tiles (like `map_page.dart`), the flown trail, +/// home, pilot and the aircraft. Used both as the small picture-in-picture and, +/// after the swap, full-bleed behind the HUD — where it is also interactive. +class FlyMap extends StatefulWidget { + const FlyMap({super.key, required this.model, required this.trail, required this.controller, required this.interactive}); + + final FlightModel model; + final FlightTrail trail; + final MinimapController controller; + final bool interactive; + + @override + State createState() => _FlyMapState(); +} + +class _FlyMapState extends State { + final MapController _map = MapController(); + bool _ready = false; + + AircraftState get _a => widget.model.aircraft; + + LatLng? get _aircraft => _point(_a.latitude, _a.longitude); + LatLng? get _home => _a.homeLocationSet == false ? null : _point(_a.homeLatitude, _a.homeLongitude); + LatLng? get _pilot => _point(widget.model.phoneLatitude, widget.model.phoneLongitude); + + static LatLng? _point(double? lat, double? lon) => + lat == null || lon == null || (lat == 0 && lon == 0) ? null : LatLng(lat, lon); + + @override + void initState() { + super.initState(); + _a.addListener(_follow); + widget.controller.addListener(_follow); + } + + @override + void didUpdateWidget(FlyMap old) { + super.didUpdateWidget(old); + if (old.controller != widget.controller) { + old.controller.removeListener(_follow); + widget.controller.addListener(_follow); + } + } + + @override + void dispose() { + _a.removeListener(_follow); + widget.controller.removeListener(_follow); + super.dispose(); + } + + /// Moves the camera with the aircraft — outside build, as a listener, so a + /// 10 Hz tick does not rebuild the tile layer. + void _follow() { + if (!_ready || !widget.controller.follow) return; + final LatLng? target = _aircraft ?? _pilot ?? _home; + if (target == null) return; + final double rotation = widget.controller.headingUp ? -(_a.heading ?? 0) : 0; + _map.moveAndRotate(target, _map.camera.zoom, rotation); + } + + @override + Widget build(BuildContext context) { + final LatLng? start = _aircraft ?? _pilot ?? _home; + if (start == null) { + return Container( + color: const Color(0xFF0E1726), + alignment: Alignment.center, + child: const Text('No position', style: HudText.label), + ); + } + return FlutterMap( + mapController: _map, + options: MapOptions( + initialCenter: start, + initialZoom: 17, + backgroundColor: const Color(0xFF0E1726), + interactionOptions: InteractionOptions( + flags: widget.interactive ? InteractiveFlag.all & ~InteractiveFlag.rotate : InteractiveFlag.none, + ), + onMapReady: () { + _ready = true; + _follow(); + }, + onPositionChanged: (MapCamera _, bool hasGesture) { + if (hasGesture) widget.controller.setFollow(false); + }, + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.pilotvault.flyapp', + ), + ListenableBuilder( + listenable: widget.trail, + builder: (BuildContext context, _) { + final List pts = widget.trail.points; + if (pts.length < 2) return const SizedBox.shrink(); + return PolylineLayer(polylines: >[ + Polyline(points: pts, strokeWidth: 2.5, color: const Color(0xFFF4C542)), + ]); + }, + ), + ListenableBuilder( + listenable: _a, + builder: (BuildContext context, _) => MarkerLayer(markers: [ + if (_home case final LatLng h) Marker(point: h, width: 18, height: 18, child: const _HomeMarker()), + if (_pilot case final LatLng p) + Marker( + point: p, + width: 12, + height: 12, + child: Container( + decoration: BoxDecoration( + color: const Color(0xFF5B93F5), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + ), + ), + if (_aircraft case final LatLng ac) + Marker( + point: ac, + width: 22, + height: 22, + // Markers turn with the map (Marker.rotate is off), so the + // true heading is right in both north-up and heading-up. + child: Transform.rotate( + angle: (_a.heading ?? 0) * math.pi / 180, + child: const _AircraftMarker(), + ), + ), + ]), + ), + ], + ); + } +} + +class _HomeMarker extends StatelessWidget { + const _HomeMarker(); + + @override + Widget build(BuildContext context) => Container( + decoration: const BoxDecoration(color: Glass.sat, shape: BoxShape.circle), + alignment: Alignment.center, + child: const Text('H', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF0A1120))), + ); +} + +class _AircraftMarker extends StatelessWidget { + const _AircraftMarker(); + + @override + Widget build(BuildContext context) => const CustomPaint(painter: _ArrowPainter()); +} + +class _ArrowPainter extends CustomPainter { + const _ArrowPainter(); + + @override + void paint(Canvas canvas, Size size) { + final double w = size.width, h = size.height; + final Path p = Path() + ..moveTo(w / 2, 0) + ..lineTo(w * 0.85, h * 0.9) + ..lineTo(w / 2, h * 0.7) + ..lineTo(w * 0.15, h * 0.9) + ..close(); + canvas.drawPath(p, Paint()..color = Colors.white); + canvas.drawPath(p, Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2 + ..color = const Color(0xFF0A1120)); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// The minimap's buttons — north / heading lock, recentre, clear path — shown +/// in the HUD while the map is the big view. +class MinimapControls extends StatelessWidget { + const MinimapControls({super.key, required this.controller, required this.trail}); + + final MinimapController controller; + final FlightTrail trail; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: controller, + builder: (BuildContext context, _) => Column(mainAxisSize: MainAxisSize.min, children: [ + _btn(controller.headingUp ? 'compass' : 'chevronDown', controller.headingUp ? 'HDG' : 'N', controller.toggleHeadingUp, + active: controller.headingUp), + const SizedBox(height: 8), + _btn('crosshair', 'CTR', () => controller.setFollow(true), active: controller.follow), + const SizedBox(height: 8), + _btn('route', 'CLR', trail.clear), + ]), + ); + } + + Widget _btn(String icon, String label, VoidCallback onTap, {bool active = false}) => GestureDetector( + onTap: onTap, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: active ? Glass.accent : Glass.pillStrong, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Glass.hairline), + ), + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + PVIcon(icon, size: 14, color: Glass.ink), + const SizedBox(height: 1), + Text(label, style: HudText.label.copyWith(color: Glass.ink, fontSize: 8)), + ]), + ), + ); +} diff --git a/Fly App/lib/ui/fly/bottom/telemetry_strip.dart b/Fly App/lib/ui/fly/bottom/telemetry_strip.dart new file mode 100644 index 0000000..08ae585 --- /dev/null +++ b/Fly App/lib/ui/fly/bottom/telemetry_strip.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +import '../../../fly/hud_features.dart'; +import '../../../fly/state/aircraft_state.dart'; +import '../hud_kit.dart'; + +/// The bottom telemetry strip (plan P2): H · D · VS (with arrow) · HS · VPS. +class TelemetryStrip extends StatelessWidget { + const TelemetryStrip({super.key, required this.aircraft}); + + final AircraftState aircraft; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: aircraft, + builder: (BuildContext context, _) { + final AircraftState a = aircraft; + final double? vs = a.verticalSpeed; + // Under 0.1 m/s is hover noise; no arrow for it. + final int dir = vs == null || vs.abs() < 0.1 ? 0 : vs.sign.toInt(); + return GlassPill( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + _Field('H', fmtNum(a.altitude), 'm'), + _Field('D', fmtNum(a.homeDistance, digits: 0), 'm'), + _Field('VS', fmtNum(vs?.abs()), 'm/s', arrow: dir), + _Field('HS', fmtNum(a.horizontalSpeed), 'm/s'), + Requires(HudFeatures.vpsReadout, child: _Field('VPS', fmtNum(a.vpsHeight), 'm')), + ]), + ); + }, + ); + } +} + +class _Field extends StatelessWidget { + const _Field(this.label, this.value, this.unit, {this.arrow = 0}); + + final String label; + final String value; + final String unit; + + /// 1 climbing, −1 descending, 0 no arrow. Painted: Space Mono has no ▲ / ▼. + final int arrow; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: HudText.label), + const SizedBox(height: 2), + Row(mainAxisSize: MainAxisSize.min, children: [ + if (arrow != 0) + Padding( + padding: const EdgeInsets.only(right: 2), + child: CustomPaint(size: const Size(7, 6), painter: _ArrowPainter(up: arrow > 0)), + ), + Text.rich(TextSpan(children: [ + TextSpan(text: value, style: HudText.value), + TextSpan(text: ' $unit', style: HudText.unit), + ])), + ]), + ]), + ); + } +} + +class _ArrowPainter extends CustomPainter { + const _ArrowPainter({required this.up}); + + final bool up; + + @override + void paint(Canvas canvas, Size size) { + final double w = size.width, h = size.height; + final Path p = up + ? (Path() + ..moveTo(w / 2, 0) + ..lineTo(w, h) + ..lineTo(0, h)) + : (Path() + ..moveTo(0, 0) + ..lineTo(w, 0) + ..lineTo(w / 2, h)); + canvas.drawPath(p..close(), Paint()..color = HudColors.ready); + } + + @override + bool shouldRepaint(_ArrowPainter old) => old.up != up; +} diff --git a/Fly App/lib/ui/fly/fly_screen.dart b/Fly App/lib/ui/fly/fly_screen.dart new file mode 100644 index 0000000..a2a5ec2 --- /dev/null +++ b/Fly App/lib/ui/fly/fly_screen.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../dji_service.dart'; +import '../../flight_model.dart'; +import '../../fly/flight_trail.dart'; +import '../../fly/fly_prefs.dart'; +import '../../fly/hud_features.dart'; +import '../../theme.dart'; +import '../camera_settings_page.dart'; +import '../capture_modes_page.dart'; +import '../settings_menu_page.dart'; +import 'bottom/attitude_ball.dart'; +import 'bottom/minimap.dart'; +import 'bottom/telemetry_strip.dart'; +import 'hud_kit.dart'; +import 'layers/video_layer.dart'; +import 'left/action_rail.dart'; +import 'left/gimbal_slider.dart'; +import 'right/camera_column.dart'; +import 'top/camera_info_strip.dart'; +import 'top/smart_battery_bar.dart'; +import 'top/status_bar.dart'; + +/// The flight HUD v2 (plan P2), replacing [FlightControlPage]. +/// +/// A stack of layers in `fpv_litchi`'s order — video → visual overlays → map → +/// camera column → gimbal → top bar → bottom attitude → left rail → minimap — +/// where every readout listens to its own telemetry sub-model, so a 10 Hz tick +/// rebuilds only what moved. The HUD is laid out once in design pixels and +/// scaled to phone or tablet by [HudCanvas]; capability-gated parts use +/// [Requires] against the connected [ProductProfile]. +/// +/// Tapping the minimap swaps it with the video: the map goes full-bleed (and +/// becomes interactive) while the feed shrinks into the corner, and back. +class FlyScreen extends StatefulWidget { + const FlyScreen({super.key, required this.model, required this.dji}); + + final FlightModel model; + final DjiService dji; + + @override + State createState() => _FlyScreenState(); +} + +class _FlyScreenState extends State { + late final FlightTrail _trail = FlightTrail(widget.model.aircraft); + final MinimapController _minimap = MinimapController(); + + bool _mapBig = false; + bool _grid = false; + + /// The picture-in-picture box, in HUD canvas pixels from the bottom-left corner. + static const Size _pipSize = Size(150, 90); + + FlightModel get _m => widget.model; + DjiService get _dji => widget.dji; + + @override + void initState() { + super.initState(); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } + + @override + void dispose() { + _trail.dispose(); + _minimap.dispose(); + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + super.dispose(); + } + + void _error(String label, Object e) { + if (!mounted) return; + final String reason = e is PlatformException ? (e.message ?? e.code) : e.toString(); + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar(SnackBar(content: Text('$label failed: $reason'))); + } + + void _push(Widget page) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + + @override + Widget build(BuildContext context) { + final MediaQueryData mq = MediaQuery.of(context); + final double s = HudScale.scaleFor(mq.size.shortestSide); + final Rect area = HudCanvas.screenRect(mq); + final double canvasHeight = area.height / s; + final Rect pip = HudCanvas.toScreen(mq, Rect.fromLTWH(0, canvasHeight - _pipSize.height, _pipSize.width, _pipSize.height)); + final Rect full = Offset.zero & mq.size; + + final Widget video = ListenableBuilder( + key: const ValueKey('video'), + listenable: _m, + builder: (BuildContext context, _) => VideoLayer(connected: _m.connected), + ); + final Widget map = FlyMap( + key: const ValueKey('map'), + model: _m, + trail: _trail, + controller: _minimap, + interactive: _mapBig, + ); + + final Widget body = Stack(fit: StackFit.expand, children: [ + // The background view, then whatever sits in the corner box. Keyed, so + // the swap moves the native video view instead of recreating it. + if (!_mapBig) ...[ + Positioned.fromRect(key: const ValueKey('video-slot'), rect: full, child: video), + Positioned.fill(child: VisualOverlays(grid: _grid)), + Positioned.fromRect(key: const ValueKey('map-slot'), rect: pip, child: map), + ] else ...[ + Positioned.fromRect(key: const ValueKey('map-slot'), rect: full, child: map), + Positioned.fromRect(key: const ValueKey('video-slot'), rect: pip, child: video), + ], + Positioned.fill(child: HudCanvas(builder: _hud)), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: IgnorePointer( + child: ValueListenableBuilder( + valueListenable: FlyPrefs.perfOverlay, + builder: (BuildContext context, bool on, _) => on ? PerformanceOverlay.allEnabled() : const SizedBox.shrink(), + ), + ), + ), + ]); + + return Scaffold( + backgroundColor: const Color(0xFF0A1120), + body: ListenableBuilder( + listenable: _m, + // Only the scope is rebuilt per tick; it notifies [Requires] below it + // when the profile object itself changes. + builder: (BuildContext context, Widget? child) => ProductProfileScope(profile: _m.profile, child: child!), + child: body, + ), + ); + } + + Widget _hud(BuildContext context, Size canvas, double scale) { + return Stack(clipBehavior: Clip.none, children: [ + // ── Camera column (right) ───────────────────────────────────────────── + Positioned( + right: 0, + top: 76, + bottom: 0, + child: Center( + child: Requires( + HudFeatures.cameraControls, + child: CameraColumn( + model: _m, + dji: _dji, + onError: _error, + onCameraMenu: () => _push(CameraSettingsPage(model: _m, dji: _dji)), + onCaptureModes: () => _push(CaptureModesPage(model: _m, dji: _dji)), + ), + ), + ), + ), + // ── Gimbal ──────────────────────────────────────────────────────────── + Positioned( + left: 54, + top: 64, + bottom: _pipSize.height + 24, + child: Requires( + HudFeatures.gimbalPitch, + child: Builder( + builder: (BuildContext context) => GimbalSlider( + gimbal: _m.gimbal, + dji: _dji, + range: ProductProfileScope.of(context).gimbal?.pitch, + onError: _error, + ), + ), + ), + ), + // ── Top bar, battery bar, camera info ───────────────────────────────── + Positioned( + left: 0, + right: 0, + top: 0, + child: StatusBar( + model: _m, + onBack: () => Navigator.of(context).maybePop(), + onMore: () => _push(SettingsMenuPage(model: _m, dji: _dji)), + ), + ), + Positioned(left: 0, right: 0, top: StatusBar.height + 3, child: SmartBatteryBar(model: _m)), + Positioned(right: 0, top: StatusBar.height + SmartBatteryBar.height + 5, child: CameraInfoStrip(camera: _m.camera)), + // ── Bottom: attitude ball + telemetry ───────────────────────────────── + Positioned( + left: _pipSize.width + 10, + bottom: 0, + child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + AttitudeBall(model: _m, size: 84), + const SizedBox(width: 6), + TelemetryStrip(aircraft: _m.aircraft), + ]), + ), + // ── Left rail ───────────────────────────────────────────────────────── + Positioned( + left: 0, + top: 60, + child: ActionRail( + aircraft: _m.aircraft, + dji: _dji, + grid: _grid, + onGrid: () => setState(() => _grid = !_grid), + onError: _error, + ), + ), + if (_mapBig) Positioned(right: 70, top: 80, child: MinimapControls(controller: _minimap, trail: _trail)), + // ── Minimap / video corner box ──────────────────────────────────────── + Positioned( + left: 0, + bottom: 0, + width: _pipSize.width, + height: _pipSize.height, + child: _PipFrame(label: _mapBig ? 'VIDEO' : 'MAP', onTap: () => setState(() => _mapBig = !_mapBig)), + ), + ]); + } +} + +/// The corner box's frame and tap target. The content underneath is the real +/// map or video layer, placed at the same spot by [FlyScreen]. +class _PipFrame extends StatelessWidget { + const _PipFrame({required this.label, required this.onTap}); + + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all(color: const Color(0x66EAF0FA)), + ), + alignment: Alignment.topLeft, + padding: const EdgeInsets.all(4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(3)), + child: Text(label, style: HudText.label.copyWith(color: Glass.ink)), + ), + ), + ); + } +} diff --git a/Fly App/lib/ui/fly/hud_kit.dart b/Fly App/lib/ui/fly/hud_kit.dart new file mode 100644 index 0000000..b81fb8d --- /dev/null +++ b/Fly App/lib/ui/fly/hud_kit.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; + +import '../../fly/flight_status.dart'; +import '../../theme.dart'; + +/// Shared pieces of the flight HUD v2 (plan P2): the scaling canvas every HUD +/// element is laid out in, and the small glass widgets they are built from. +/// +/// The HUD is designed once for a ~390 dp-tall landscape phone. [HudCanvas] +/// scales that layout to the device ([HudScale.scaleFor]) inside the safe-area +/// insets, so phone and tablet get the same arrangement at a readable size — +/// GO 4 ships `layout-w1024dp` / `w1280dp` variants for the same reason. +class HudScale extends InheritedWidget { + const HudScale({super.key, required this.scale, required this.tablet, required super.child}); + + final double scale; + final bool tablet; + + /// Shortest side at which the tablet breakpoint starts. + static const double tabletBreakpoint = 600; + + /// Scale factor from the device's shortest side: phones shrink a little below + /// the design height, tablets grow. + static double scaleFor(double shortestSide) => shortestSide >= tabletBreakpoint + ? (shortestSide / tabletBreakpoint * 1.2).clamp(1.2, 1.6) + : (shortestSide / 390).clamp(0.8, 1.0); + + static HudScale? maybeOf(BuildContext context) => context.dependOnInheritedWidgetOfExactType(); + + @override + bool updateShouldNotify(HudScale oldWidget) => scale != oldWidget.scale || tablet != oldWidget.tablet; +} + +/// Lays [builder]'s HUD out in design pixels inside the safe area and scales it +/// to the screen. [builder] receives the canvas's logical size and the scale, +/// for callers that must line up full-screen layers with HUD positions. +class HudCanvas extends StatelessWidget { + const HudCanvas({super.key, required this.builder}); + + final Widget Function(BuildContext context, Size canvas, double scale) builder; + + /// The safe-area-inset rect, in screen pixels, the canvas occupies. + static Rect screenRect(MediaQueryData mq) { + final EdgeInsets pad = _insets(mq); + return Rect.fromLTWH(pad.left, pad.top, mq.size.width - pad.horizontal, mq.size.height - pad.vertical); + } + + /// Maps a rect in canvas (design) pixels to screen pixels. + static Rect toScreen(MediaQueryData mq, Rect canvasRect) { + final double s = HudScale.scaleFor(mq.size.shortestSide); + final Rect area = screenRect(mq); + return Rect.fromLTWH(area.left + canvasRect.left * s, area.top + canvasRect.top * s, canvasRect.width * s, canvasRect.height * s); + } + + /// Safe-area insets with a minimum gutter, so nothing touches a rounded corner. + static EdgeInsets _insets(MediaQueryData mq) { + const double gutter = 6; + final EdgeInsets p = mq.padding; + return EdgeInsets.fromLTRB( + p.left < gutter ? gutter : p.left, + p.top < gutter ? gutter : p.top, + p.right < gutter ? gutter : p.right, + p.bottom < gutter ? gutter : p.bottom, + ); + } + + @override + Widget build(BuildContext context) { + final MediaQueryData mq = MediaQuery.of(context); + final double s = HudScale.scaleFor(mq.size.shortestSide); + final Rect area = screenRect(mq); + final Size canvas = Size(area.width / s, area.height / s); + return Stack(children: [ + Positioned( + left: area.left, + top: area.top, + width: canvas.width, + height: canvas.height, + child: Transform.scale( + scale: s, + alignment: Alignment.topLeft, + child: HudScale( + scale: s, + tablet: mq.size.shortestSide >= HudScale.tabletBreakpoint, + child: MediaQuery.removePadding( + context: context, + removeLeft: true, + removeTop: true, + removeRight: true, + removeBottom: true, + child: Builder(builder: (BuildContext ctx) => builder(ctx, canvas, s)), + ), + ), + ), + ), + ]); + } +} + +/// Text styles of the HUD. Numbers are always Space Mono so they don't jitter. +abstract final class HudText { + static const Color dim = Color(0x99EAF0FA); + + static const TextStyle mono = TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink, height: 1.1); + static const TextStyle monoSmall = TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Glass.ink, height: 1.1); + static const TextStyle label = TextStyle(fontFamily: PV.fontMono, fontSize: 9, letterSpacing: 0.8, color: dim, height: 1.1); + static const TextStyle value = TextStyle(fontFamily: PV.fontMono, fontSize: 14, fontWeight: FontWeight.w700, color: Glass.ink, height: 1.1); + static const TextStyle unit = TextStyle(fontFamily: PV.fontMono, fontSize: 9, color: dim, height: 1.1); + static const TextStyle sans = TextStyle(fontFamily: PV.fontSans, fontSize: 12, fontWeight: FontWeight.w600, color: Glass.ink, height: 1.1); +} + +/// HUD colours for the status levels. +abstract final class HudColors { + static const Color ready = Color(0xFF2FA36F); + static const Color caution = Color(0xFFE09A3E); + static const Color danger = Color(0xFFE05252); + static const Color neutral = Color(0xB3253552); + static const Color off = Color(0x40EAF0FA); + + static Color forLevel(StatusLevel l) => switch (l) { + StatusLevel.ready => ready, + StatusLevel.caution => caution, + StatusLevel.danger => danger, + StatusLevel.neutral => neutral, + }; +} + +/// The rounded translucent pill most HUD readouts sit on. +class GlassPill extends StatelessWidget { + const GlassPill({super.key, required this.child, this.height = 26, this.color = Glass.pill, this.padding, this.onTap}); + + final Widget child; + final double height; + final Color color; + final EdgeInsetsGeometry? padding; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final Widget pill = Container( + height: height, + padding: padding ?? const EdgeInsets.symmetric(horizontal: 9), + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(8)), + alignment: Alignment.center, + child: child, + ); + return onTap == null ? pill : GestureDetector(behavior: HitTestBehavior.opaque, onTap: onTap, child: pill); + } +} + +/// Five rising bars for a 0–5 level (GPS, RC, HD, Wi-Fi). +class SignalBars extends StatelessWidget { + const SignalBars({super.key, required this.level, this.color = Glass.ink, this.height = 12}); + + final int level; + final Color color; + final double height; + + @override + Widget build(BuildContext context) { + final Color on = level <= 1 ? HudColors.danger : (level == 2 ? HudColors.caution : color); + return SizedBox( + height: height, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (int i = 1; i <= 5; i++) + Container( + width: 2.5, + height: height * (0.2 + 0.16 * i), + margin: EdgeInsets.only(left: i == 1 ? 0 : 1.5), + decoration: BoxDecoration(color: i <= level ? on : HudColors.off, borderRadius: BorderRadius.circular(1)), + ), + ], + ), + ); + } +} + +/// A one-line message that scrolls when it does not fit — GO 4's tip marquee. +class Marquee extends StatefulWidget { + const Marquee({super.key, required this.text, required this.style, this.pixelsPerSecond = 28}); + + final String text; + final TextStyle style; + final double pixelsPerSecond; + + @override + State createState() => _MarqueeState(); +} + +class _MarqueeState extends State with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController(vsync: this); + double _overflow = 0; + static const double _gap = 40; + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + void _configure(double textWidth, double boxWidth) { + final double overflow = textWidth > boxWidth ? textWidth + _gap : 0; + if (overflow == _overflow) return; + _overflow = overflow; + if (overflow == 0) { + _c.stop(); + _c.value = 0; + } else { + _c.duration = Duration(milliseconds: (overflow / widget.pixelsPerSecond * 1000).round()); + _c.repeat(); + } + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (BuildContext context, BoxConstraints c) { + final TextPainter tp = TextPainter( + text: TextSpan(text: widget.text, style: widget.style), + maxLines: 1, + textDirection: TextDirection.ltr, + )..layout(); + final double w = tp.width; + // Starting the animation is a side effect; keep it out of the build itself. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _configure(w, c.maxWidth); + }); + final Widget line = Text(widget.text, style: widget.style, maxLines: 1, softWrap: false); + if (w <= c.maxWidth) return Align(alignment: Alignment.centerLeft, child: line); + return ClipRect( + child: AnimatedBuilder( + animation: _c, + builder: (BuildContext context, Widget? child) => OverflowBox( + alignment: Alignment.centerLeft, + maxWidth: double.infinity, + child: Transform.translate(offset: Offset(-_c.value * _overflow, 0), child: child), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [line, const SizedBox(width: _gap), line]), + ), + ); + }); + } +} + +/// `12.3`, or `—` when unknown. +String fmtNum(double? v, {int digits = 1}) => v == null ? '—' : v.toStringAsFixed(digits); diff --git a/Fly App/lib/ui/fly/layers/video_layer.dart b/Fly App/lib/ui/fly/layers/video_layer.dart new file mode 100644 index 0000000..7a1bed0 --- /dev/null +++ b/Fly App/lib/ui/fly/layers/video_layer.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; + +import '../../dji_video_view.dart'; +import '../../pv_icons.dart'; + +/// The live feed — or, with nothing connected, a painted stand-in so the HUD +/// can still be laid out and reviewed. +class VideoLayer extends StatelessWidget { + const VideoLayer({super.key, required this.connected}); + + final bool connected; + + @override + Widget build(BuildContext context) => + connected ? const DjiVideoView() : const CustomPaint(painter: FeedPlaceholderPainter()); +} + +/// The visual overlays drawn straight on the video: rule-of-thirds grid and the +/// centre point. Never takes touches. +class VisualOverlays extends StatelessWidget { + const VisualOverlays({super.key, required this.grid}); + + final bool grid; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Stack(fit: StackFit.expand, children: [ + if (grid) const CustomPaint(painter: GridPainter()), + const Center(child: PVIcon('crosshair', size: 30, stroke: 1.1, color: Color(0xB3FFFFFF))), + ]), + ); + } +} + +/// Rule-of-thirds grid. +class GridPainter extends CustomPainter { + const GridPainter(); + + @override + void paint(Canvas canvas, Size size) { + final Paint p = Paint() + ..color = const Color(0x33FFFFFF) + ..strokeWidth = 1; + for (int i = 1; i < 3; i++) { + final double x = size.width * i / 3, y = size.height * i / 3; + canvas.drawLine(Offset(x, 0), Offset(x, size.height), p); + canvas.drawLine(Offset(0, y), Offset(size.width, y), p); + } + } + + @override + bool shouldRepaint(covariant GridPainter oldDelegate) => false; +} + +/// Painted placeholder feed: graded sky → ground, haze, a perspective grid and +/// a few distant buildings. +class FeedPlaceholderPainter extends CustomPainter { + const FeedPlaceholderPainter(); + + @override + void paint(Canvas canvas, Size size) { + final double w = size.width, h = size.height; + final double horizon = h * 0.52; + + final Rect full = Offset.zero & size; + canvas.drawRect( + full, + Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF2A4E86), Color(0xFF3E6199), Color(0xFF1C2A1E), Color(0xFF0E1710)], + stops: [0.0, 0.51, 0.54, 1.0], + ).createShader(full), + ); + + final Rect hazeRect = Rect.fromLTWH(0, horizon - 30, w, 60); + canvas.drawRect( + hazeRect, + Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x808FB4D8), Color(0x008FB4D8)], + ).createShader(hazeRect), + ); + + final Paint bld = Paint()..color = const Color(0xE612201A); + void building(double x, double y, double bw, double bh) => canvas.drawRect(Rect.fromLTWH(x * w, horizon + y, bw, bh), bld); + building(0.10, -40, 46, 40); + building(0.17, -52, 30, 52); + building(0.74, -46, 54, 46); + building(0.83, -34, 34, 34); + + final Paint grid = Paint() + ..color = const Color(0x297FE0B0) + ..strokeWidth = 1; + for (int i = 1; i <= 5; i++) { + final double t = i / 5.0; + final double y = horizon + (h - horizon) * t * t; + canvas.drawLine(Offset(0, y), Offset(w, y), grid); + } + final double vx = w / 2; + for (int k = -6; k <= 6; k += 2) { + canvas.drawLine(Offset(vx + k * 10, horizon), Offset(vx + k * (w * 0.16), h), grid); + } + } + + @override + bool shouldRepaint(covariant FeedPlaceholderPainter oldDelegate) => false; +} diff --git a/Fly App/lib/ui/fly/left/action_rail.dart b/Fly App/lib/ui/fly/left/action_rail.dart new file mode 100644 index 0000000..85b9a58 --- /dev/null +++ b/Fly App/lib/ui/fly/left/action_rail.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; + +import '../../../dji_service.dart'; +import '../../../fly/state/aircraft_state.dart'; +import '../../../theme.dart'; +import '../../pv_icons.dart'; +import '../hud_kit.dart'; + +/// The left rail: take-off / land and RTH, then the grid toggle. +/// +/// Interim for P2 — the same two commands and plain confirm dialog as the +/// classic screen. P3 replaces this with the state machine (Take-off ⇄ Land ⇄ +/// Cancel landing, RTH ⇄ Cancel RTH, Stop mode) behind the SafetyGate and the +/// slide-to-confirm dialog. +class ActionRail extends StatelessWidget { + const ActionRail({ + super.key, + required this.aircraft, + required this.dji, + required this.grid, + required this.onGrid, + required this.onError, + }); + + final AircraftState aircraft; + final DjiService dji; + final bool grid; + final VoidCallback onGrid; + final void Function(String label, Object error) onError; + + Future _run(String label, Future Function() action) async { + try { + await action(); + } catch (e) { + onError(label, e); + } + } + + Future _takeoffOrLand(BuildContext context) async { + if (aircraft.isFlying == true) { + if (await _confirm(context, 'Land now', 'The aircraft will descend and land at its current position.')) { + await _run('Land', dji.land); + } + } else { + if (await _confirm(context, 'Take off', 'The aircraft will take off and hover at ~1.2 m.')) { + await _run('Take off', dji.takeOff); + } + } + } + + Future _rth(BuildContext context) async { + if (await _confirm(context, 'Return to Home', 'The aircraft will fly back to its recorded home point and land.')) { + await _run('Return to Home', dji.startGoHome); + } + } + + Future _confirm(BuildContext context, String title, String body) async { + final bool? r = await showDialog( + context: context, + builder: (BuildContext ctx) => AlertDialog( + backgroundColor: const Color(0xFF10203F), + title: Text(title, style: const TextStyle(color: Glass.ink, fontFamily: PV.fontSans, fontWeight: FontWeight.w700)), + content: Text(body, style: const TextStyle(color: Color(0xFF8FA0BE), fontFamily: PV.fontSans)), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(title)), + ], + ), + ); + return r ?? false; + } + + @override + Widget build(BuildContext context) { + return Column(mainAxisSize: MainAxisSize.min, children: [ + ListenableBuilder( + listenable: aircraft, + builder: (BuildContext context, _) { + final bool flying = aircraft.isFlying == true; + return _RailButton( + icon: flying ? 'home' : 'takeoff', + label: flying ? 'LAND' : 'TAKE OFF', + color: flying ? const Color(0xE6D64545) : Glass.accent, + onTap: () => _takeoffOrLand(context), + ); + }, + ), + const SizedBox(height: 10), + _RailButton(icon: 'rth', label: 'RTH', onTap: () => _rth(context)), + const SizedBox(height: 10), + _RailButton(icon: 'grid', label: 'GRID', color: grid ? Glass.accent : Glass.pillStrong, onTap: onGrid), + ]); + } +} + +class _RailButton extends StatelessWidget { + const _RailButton({required this.icon, required this.label, required this.onTap, this.color = Glass.pillStrong}); + + final String icon; + final String label; + final VoidCallback onTap; + final Color color; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration(color: color, shape: BoxShape.circle, border: Border.all(color: const Color(0x2EFFFFFF))), + alignment: Alignment.center, + child: PVIcon(icon, size: 20, color: Colors.white), + ), + const SizedBox(height: 2), + Text(label, style: HudText.label.copyWith(fontSize: 7.5)), + ]), + ); + } +} diff --git a/Fly App/lib/ui/fly/left/gimbal_slider.dart b/Fly App/lib/ui/fly/left/gimbal_slider.dart new file mode 100644 index 0000000..505c1db --- /dev/null +++ b/Fly App/lib/ui/fly/left/gimbal_slider.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; + +import '../../../dji_service.dart'; +import '../../../fly/product_profile.dart'; +import '../../../fly/state/gimbal_state.dart'; +import '../../../theme.dart'; +import '../hud_kit.dart'; + +/// Vertical gimbal pitch slider over the profile's pitch range, with the live +/// pitch read back from the gimbal. P5 adds drag-on-video and the mode settings. +class GimbalSlider extends StatefulWidget { + const GimbalSlider({super.key, required this.gimbal, required this.dji, required this.range, required this.onError}); + + final GimbalState gimbal; + final DjiService dji; + + /// The gimbal's pitch range from the profile; +30° … −90° until it reports. + final AxisRange? range; + final void Function(String label, Object error) onError; + + static const AxisRange defaultPitch = AxisRange(-90, 30); + + @override + State createState() => _GimbalSliderState(); +} + +class _GimbalSliderState extends State { + /// The pitch the pilot is dragging to; null while the thumb follows the gimbal. + double? _dragPitch; + + AxisRange get _r => widget.range ?? GimbalSlider.defaultPitch; + + double _pitchAt(double frac) => _r.max - frac.clamp(0.0, 1.0) * (_r.max - _r.min); + + double _fracOf(double pitch) => ((_r.max - pitch) / (_r.max - _r.min)).clamp(0.0, 1.0); + + DateTime _lastSent = DateTime.fromMillisecondsSinceEpoch(0); + + /// Moves the thumb at once and commands the gimbal at most 10 times a second + /// while dragging; [force] sends the final position when the finger lifts. + Future _drag(double frac, {bool force = false}) async { + final double pitch = _pitchAt(frac); + setState(() => _dragPitch = pitch); + final DateTime now = DateTime.now(); + if (!force && now.difference(_lastSent) < const Duration(milliseconds: 100)) return; + _lastSent = now; + try { + await widget.dji.rotateGimbalPitch(pitch); + } catch (e) { + widget.onError('Gimbal', e); + } + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (BuildContext context, BoxConstraints c) { + final double h = c.maxHeight; + return ListenableBuilder( + listenable: widget.gimbal, + builder: (BuildContext context, _) { + final double pitch = _dragPitch ?? widget.gimbal.pitch ?? 0; + final double frac = _fracOf(pitch); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: (DragUpdateDetails d) => _drag(d.localPosition.dy / h), + onVerticalDragEnd: (_) { + if (_dragPitch case final double p) _drag(_fracOf(p), force: true); + setState(() => _dragPitch = null); + }, + onTapDown: (TapDownDetails d) => _drag(d.localPosition.dy / h, force: true), + onTapUp: (_) => setState(() => _dragPitch = null), + child: SizedBox( + width: 44, + child: Stack(clipBehavior: Clip.none, children: [ + Positioned( + left: 5, + top: 0, + bottom: 0, + child: Container(width: 6, decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6))), + ), + // The 0° mark, where the camera looks at the horizon. + Positioned(left: 2, top: _fracOf(0) * h - 0.5, child: Container(width: 12, height: 1, color: HudText.dim)), + Positioned( + top: (frac * h - 8).clamp(0.0, h - 16), + left: 0, + child: Row(children: [ + Container( + width: 16, + height: 16, + decoration: const BoxDecoration( + color: Glass.ink, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Color(0x80000000), blurRadius: 3, offset: Offset(0, 1))], + ), + ), + const SizedBox(width: 3), + Text('${pitch.round()}°', style: HudText.monoSmall), + ]), + ), + ]), + ), + ); + }, + ); + }); + } +} diff --git a/Fly App/lib/ui/fly/right/camera_column.dart b/Fly App/lib/ui/fly/right/camera_column.dart new file mode 100644 index 0000000..7ef6839 --- /dev/null +++ b/Fly App/lib/ui/fly/right/camera_column.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; + +import '../../../dji_service.dart'; +import '../../../flight_model.dart'; +import '../../../fly/state/camera_state.dart'; +import '../../../theme.dart'; +import '../../pv_icons.dart'; + +/// The right camera column: camera menu · shutter / record · photo / video / +/// pano switch · capture modes. P4 rebuilds it around the quick exposure panel +/// and the tabbed camera menu. +class CameraColumn extends StatefulWidget { + const CameraColumn({ + super.key, + required this.model, + required this.dji, + required this.onCameraMenu, + required this.onCaptureModes, + required this.onError, + }); + + final FlightModel model; + final DjiService dji; + final VoidCallback onCameraMenu; + final VoidCallback onCaptureModes; + final void Function(String label, Object error) onError; + + @override + State createState() => _CameraColumnState(); +} + +class _CameraColumnState extends State { + FlightModel get _m => widget.model; + CameraState get _cam => _m.camera; + + /// The camera's own mode once it has reported one; the switch's choice before that. + bool get _video => _cam.mode == null ? _m.captureMode == CaptureMode.video : _cam.isVideoMode; + + Future _run(String label, Future Function() action) async { + try { + await action(); + } catch (e) { + widget.onError(label, e); + } + } + + Future _shutter() async { + if (_video) { + await _run(_cam.isRecording ? 'Stop recording' : 'Start recording', + _cam.isRecording ? widget.dji.stopRecordVideo : widget.dji.startRecordVideo); + } else { + await _run('Shoot photo', widget.dji.startShootPhoto); + } + } + + Future _setMode(CaptureMode mode) async { + final String wire = mode == CaptureMode.video ? 'video' : 'photo'; + setState(() => _m.captureMode = mode); + _cam.setModeOptimistic(mode == CaptureMode.video ? 'RECORD_VIDEO' : 'SHOOT_PHOTO'); + await _run('Set camera mode', () => widget.dji.setCameraMode(wire)); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _cam, + builder: (BuildContext context, _) { + final bool video = _video; + final bool recording = video && _cam.isRecording; + return Column(mainAxisSize: MainAxisSize.min, children: [ + GestureDetector( + onTap: widget.onCameraMenu, + child: Container( + width: 44, + height: 30, + decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(8), border: Border.all(color: Glass.hairline)), + alignment: Alignment.center, + child: const Text('MENU', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10, fontWeight: FontWeight.w700, color: Glass.ink)), + ), + ), + const SizedBox(height: 12), + GestureDetector( + onTap: _shutter, + child: Container( + width: 60, + height: 60, + decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: const Color(0xD9FFFFFF), width: 4)), + child: Center( + child: recording + ? Container(width: 22, height: 22, decoration: BoxDecoration(color: Glass.rec, borderRadius: BorderRadius.circular(5))) + : Container(width: 44, height: 44, decoration: BoxDecoration(color: video ? Glass.rec : Colors.white, shape: BoxShape.circle)), + ), + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)), + child: Column(children: [ + _modeBtn('image', CaptureMode.photo, active: !video && _m.captureMode != CaptureMode.pano), + _modeBtn('video', CaptureMode.video, active: video), + _modeBtn('pano', CaptureMode.pano, active: !video && _m.captureMode == CaptureMode.pano), + ]), + ), + const SizedBox(height: 12), + GestureDetector( + onTap: widget.onCaptureModes, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration(color: Glass.pillStrong, shape: BoxShape.circle, border: Border.all(color: Glass.hairline)), + alignment: Alignment.center, + child: const PVIcon('aperture', size: 18, stroke: 1.8, color: Glass.ink), + ), + ), + ]); + }, + ); + } + + Widget _modeBtn(String icon, CaptureMode mode, {required bool active}) => GestureDetector( + onTap: () => _setMode(mode), + child: Container( + width: 38, + height: 28, + margin: const EdgeInsets.symmetric(vertical: 1), + decoration: BoxDecoration(color: active ? Glass.accent : Colors.transparent, borderRadius: BorderRadius.circular(7)), + alignment: Alignment.center, + child: PVIcon(icon, size: 16, stroke: 1.8, color: Glass.ink), + ), + ); +} diff --git a/Fly App/lib/ui/fly/top/camera_info_strip.dart b/Fly App/lib/ui/fly/top/camera_info_strip.dart new file mode 100644 index 0000000..ac83b14 --- /dev/null +++ b/Fly App/lib/ui/fly/top/camera_info_strip.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; + +import '../../../fly/hud_features.dart'; +import '../../../fly/hud_geometry.dart'; +import '../../../fly/state/camera_state.dart'; +import '../../../theme.dart'; +import '../hud_kit.dart'; + +/// Row 2 of the flight HUD (plan P2): storage · ISO · shutter · aperture · EV · +/// REC time or what is left to shoot · colour profile · AE lock — plus the SSD +/// chip and the thermal spot reading on the cameras that have them. +class CameraInfoStrip extends StatelessWidget { + const CameraInfoStrip({super.key, required this.camera}); + + final CameraState camera; + + @override + Widget build(BuildContext context) { + return Requires( + HudFeatures.cameraControls, + child: ListenableBuilder( + listenable: camera, + builder: (BuildContext context, _) { + final CameraState c = camera; + final StorageInfo? sd = c.storage; + final SsdInfo? ssd = c.ssd; + final String? colour = _colourLabel(c.colorProfile); + return GlassPill( + height: 22, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + _sd(sd), + Requires(HudFeatures.ssdStatus, whenDisconnected: false, child: _ssd(ssd)), + _field('ISO', c.iso), + _field('SHUTTER', c.shutter), + Requires(HudFeatures.apertureReadout, child: _field('F', c.aperture?.replaceFirst('f/', ''))), + _field('EV', c.ev), + Requires(HudFeatures.thermalReadout, whenDisconnected: false, child: _field('SPOT', _celsius(c.thermalSpot))), + _divider(), + _capture(c), + if (colour != null) ...[_divider(), Text(colour, style: HudText.monoSmall)], + if (c.aeLock == true) ...[ + _divider(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration(color: Glass.accent, borderRadius: BorderRadius.circular(4)), + child: Text('AE-L', style: HudText.monoSmall.copyWith(color: Colors.white, fontWeight: FontWeight.w700)), + ), + ], + ]), + ); + }, + ), + ); + } + + Widget _sd(StorageInfo? sd) { + final String text = sd == null ? 'SD —' : (sd.status == 'OK' ? 'SD' : sd.status); + final Color color = sd == null ? HudText.dim : (sd.usable ? Glass.ink : HudColors.danger); + return Text(text, style: HudText.monoSmall.copyWith(color: color, fontWeight: FontWeight.w700)); + } + + Widget _ssd(SsdInfo? ssd) { + final String text = ssd == null ? 'SSD —' : (ssd.status == 'OK' ? 'SSD' : ssd.status); + final Color color = ssd == null ? HudText.dim : (ssd.usable ? Glass.ink : HudColors.danger); + return Padding( + padding: const EdgeInsets.only(left: 8), + child: Text(text, style: HudText.monoSmall.copyWith(color: color, fontWeight: FontWeight.w700)), + ); + } + + /// REC dot + elapsed time while recording; otherwise what the card still + /// holds — shots in photo mode, recording time in video mode. + Widget _capture(CameraState c) { + if (c.isRecording) { + return Row(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 6, height: 6, decoration: const BoxDecoration(color: Glass.rec, shape: BoxShape.circle)), + const SizedBox(width: 4), + Text(formatDuration(c.recordSeconds), style: HudText.monoSmall.copyWith(color: Glass.rec, fontWeight: FontWeight.w700)), + ]); + } + final StorageInfo? sd = c.storage; + // On SSD cameras the video goes to the SSD, so that is the time that counts. + final int? recordLeft = c.ssd?.availableRecordingSeconds ?? sd?.availableRecordingSeconds; + final String left = c.isVideoMode + ? formatDuration(recordLeft) + : (sd?.availableCaptureCount == null ? '—' : '${sd!.availableCaptureCount}'); + return Text(left, style: HudText.monoSmall); + } + + Widget _field(String label, String? value) => Padding( + padding: const EdgeInsets.only(left: 8), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Text(label, style: HudText.label), + const SizedBox(width: 3), + Text(value ?? '—', style: HudText.monoSmall), + ]), + ); + + Widget _divider() => Container(width: 1, height: 10, margin: const EdgeInsets.symmetric(horizontal: 7), color: HudColors.off); + + static String? _celsius(double? t) => t == null ? null : '${t.toStringAsFixed(1)}°C'; + + /// `NONE` is the default look and not worth a chip; the rest read as GO 4 names them. + static String? _colourLabel(String? p) => switch (p) { + null || 'NONE' || 'UNKNOWN' => null, + 'D_LOG' => 'D-Log', + 'D_CINELIKE' => 'D-Cinelike', + 'HLG' => 'HLG', + _ => p.replaceAll('_', ' '), + }; +} diff --git a/Fly App/lib/ui/fly/top/smart_battery_bar.dart b/Fly App/lib/ui/fly/top/smart_battery_bar.dart new file mode 100644 index 0000000..95c7e98 --- /dev/null +++ b/Fly App/lib/ui/fly/top/smart_battery_bar.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; + +import '../../../flight_model.dart'; +import '../../../fly/hud_features.dart'; +import '../../../fly/hud_geometry.dart'; +import '../../../fly/state/aircraft_state.dart'; +import '../../../fly/state/battery_state.dart'; +import '../../../theme.dart'; +import '../hud_kit.dart'; + +/// GO 4's smart battery bar (plan P2): a thin full-width charge bar with markers +/// at the charge needed to land and to fly home ("H"), and the flight time +/// left on the right. +/// +/// Per product: one bar normally, one bar per pack on dual-battery aircraft +/// (Inspire 2, M200), and the weakest pack plus a pack count on six-battery +/// ones (M600) — the weakest pack is what ends the flight. +class SmartBatteryBar extends StatelessWidget { + const SmartBatteryBar({super.key, required this.model}); + + final FlightModel model; + + /// Tall enough for two stacked pack bars and the "H" label. + static const double height = 16; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([model.battery, model.aircraft]), + builder: (BuildContext context, _) { + final BatteryState b = model.battery; + final GoHomeAssessment? gh = model.aircraft.goHome; + BatteryBarLayout layout(int? percent) => BatteryBarLayout.from( + percent: percent, + batteryNeededToLand: gh?.batteryNeededToLand, + batteryNeededToGoHome: gh?.batteryNeededToGoHome, + lowThreshold: b.lowWarningThreshold, + criticalThreshold: b.seriousLowWarningThreshold, + ); + + final Widget single = _Bar(layout: layout(b.percent)); + final Widget dual = b.packs.length >= 2 + ? Column(mainAxisSize: MainAxisSize.min, children: [ + _Bar(layout: layout(b.packs[0].percent)), + const SizedBox(height: 2), + _Bar(layout: layout(b.packs[1].percent), showLabels: false), + ]) + : single; + + return SizedBox( + height: SmartBatteryBar.height, + child: Row(children: [ + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Requires( + HudFeatures.dualBattery, + whenDisconnected: false, + fallback: single, + child: dual, + ), + ), + ), + Requires( + HudFeatures.multiBattery, + whenDisconnected: false, + child: Padding( + padding: const EdgeInsets.only(left: 6), + child: Text('${b.packs.length}×', style: HudText.monoSmall), + ), + ), + const SizedBox(width: 6), + Text(formatDuration(gh?.remainingFlightTime), style: HudText.monoSmall.copyWith(fontWeight: FontWeight.w700)), + ]), + ); + }, + ); + } +} + +class _Bar extends StatelessWidget { + const _Bar({required this.layout, this.showLabels = true}); + + final BatteryBarLayout layout; + final bool showLabels; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: showLabels ? 10 : 3, + width: double.infinity, + child: CustomPaint(painter: SmartBatteryPainter(layout, showLabels: showLabels)), + ); + } +} + +class SmartBatteryPainter extends CustomPainter { + SmartBatteryPainter(this.layout, {this.showLabels = true}); + + final BatteryBarLayout layout; + final bool showLabels; + + static const double barHeight = 2.5; + + static const List _bands = [HudColors.ready, HudColors.caution, HudColors.danger]; + + @override + void paint(Canvas canvas, Size size) { + final double w = size.width; + final double y = size.height - barHeight; + final RRect track = RRect.fromRectAndRadius(Rect.fromLTWH(0, y, w, barHeight), const Radius.circular(2)); + canvas.drawRRect(track, Paint()..color = const Color(0x40EAF0FA)); + + final Color c = _bands[layout.band]; + final double fw = w * layout.fill; + if (fw > 0) { + final Rect fill = Rect.fromLTWH(0, y, fw, barHeight); + canvas.drawRRect( + RRect.fromRectAndRadius(fill, const Radius.circular(2)), + Paint()..shader = LinearGradient(colors: [c.withValues(alpha: 0.75), c]).createShader(fill), + ); + } + + void tick(double? at, Color color, {String? label}) { + if (at == null) return; + final double x = (w * at).clamp(1.0, w - 1); + canvas.drawRect(Rect.fromLTWH(x - 1, y - 1.5, 2, barHeight + 3), Paint()..color = color); + if (label != null && showLabels) { + final TextPainter tp = TextPainter( + text: TextSpan(text: label, style: TextStyle(fontFamily: PV.fontMono, fontSize: 7, fontWeight: FontWeight.w700, color: color)), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset((x - tp.width / 2).clamp(0, w - tp.width), y - tp.height - 1)); + } + } + + tick(layout.criticalMarker, const Color(0x99E05252)); + tick(layout.lowMarker, const Color(0x99E09A3E)); + tick(layout.landMarker, HudColors.danger); + tick(layout.homeMarker, HudColors.caution, label: 'H'); + } + + @override + bool shouldRepaint(SmartBatteryPainter old) => + old.showLabels != showLabels || + old.layout.fill != layout.fill || + old.layout.landMarker != layout.landMarker || + old.layout.homeMarker != layout.homeMarker || + old.layout.lowMarker != layout.lowMarker || + old.layout.criticalMarker != layout.criticalMarker; +} diff --git a/Fly App/lib/ui/fly/top/status_bar.dart b/Fly App/lib/ui/fly/top/status_bar.dart new file mode 100644 index 0000000..336d213 --- /dev/null +++ b/Fly App/lib/ui/fly/top/status_bar.dart @@ -0,0 +1,338 @@ +import 'package:flutter/material.dart'; + +import '../../../flight_model.dart'; +import '../../../fly/flight_status.dart'; +import '../../../fly/hud_features.dart'; +import '../../../fly/state/alert_state.dart'; +import '../../../fly/state/link_state.dart'; +import '../../../fly/state/vision_state.dart'; +import '../../../theme.dart'; +import '../../pv_icons.dart'; +import '../hud_kit.dart'; + +/// Row 1 of the flight HUD (plan P2): back · status chip · GPS · flight mode · +/// vision · tip marquee · RC / HD (or Wi-Fi) signal · battery · "•••". +/// +/// Every cluster listens to the one sub-model it draws, so a 10 Hz flight +/// controller tick repaints the GPS and mode text, not the whole bar. +class StatusBar extends StatelessWidget { + const StatusBar({super.key, required this.model, this.onBack, this.onStatus, this.onMore}); + + final FlightModel model; + final VoidCallback? onBack; + + /// Tapping the status chip — the Aircraft Status sheet, from P3. + final VoidCallback? onStatus; + final VoidCallback? onMore; + + static const double height = 28; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: height, + child: Row(children: [ + GlassPill( + height: height, + onTap: onBack, + padding: const EdgeInsets.symmetric(horizontal: 6), + child: const PVIcon('chevronLeft', size: 16, color: Glass.ink), + ), + const SizedBox(width: 6), + _StatusChip(model: model, onTap: onStatus), + const SizedBox(width: 6), + _GpsCluster(model: model), + const SizedBox(width: 6), + _ModeText(model: model), + Requires( + HudFeatures.visionIndicator, + child: Padding(padding: const EdgeInsets.only(left: 6), child: _VisionIcon(vision: model.vision)), + ), + const SizedBox(width: 8), + Expanded(child: _TipMarquee(alerts: model.alerts)), + const SizedBox(width: 8), + _SimBadge(model: model), + Requires( + HudFeatures.masterSlaveBadge, + whenDisconnected: false, + child: _RoleBadge(links: model.links), + ), + _SignalCluster(links: model.links), + const SizedBox(width: 6), + _BatteryChip(model: model), + const SizedBox(width: 6), + GlassPill( + height: height, + onTap: onMore, + padding: const EdgeInsets.symmetric(horizontal: 7), + child: const PVIcon('more', size: 16, color: Glass.ink), + ), + ]), + ); + } +} + +class _StatusChip extends StatelessWidget { + const _StatusChip({required this.model, this.onTap}); + + final FlightModel model; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([model.aircraft, model.alerts]), + builder: (BuildContext context, _) { + final FlightStatus s = FlightStatus.of(connected: model.connected, a: model.aircraft, alerts: model.alerts); + return GlassPill( + height: StatusBar.height, + color: HudColors.forLevel(s.level), + onTap: onTap, + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text(s.text, style: HudText.sans.copyWith(color: Colors.white, fontWeight: FontWeight.w700)), + ); + }, + ); + } +} + +class _GpsCluster extends StatelessWidget { + const _GpsCluster({required this.model}); + + final FlightModel model; + + @override + Widget build(BuildContext context) { + return GlassPill( + height: StatusBar.height, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const PVIcon('satellite', size: 14, color: Glass.ink), + const SizedBox(width: 4), + ListenableBuilder( + listenable: model.aircraft, + builder: (BuildContext context, _) => Row(mainAxisSize: MainAxisSize.min, children: [ + SignalBars(level: model.aircraft.gpsSignalLevel ?? 0), + const SizedBox(width: 4), + Text('${model.aircraft.satellites ?? 0}', style: HudText.mono), + ]), + ), + ]), + ); + } +} + +class _ModeText extends StatelessWidget { + const _ModeText({required this.model}); + + final FlightModel model; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: model.aircraft, + builder: (BuildContext context, _) { + final String? mode = model.aircraft.flightMode; + final String? orientation = switch (model.aircraft.orientationMode) { + 'COURSE_LOCK' => 'CL', + 'HOME_LOCK' => 'HL', + _ => null, + }; + return GlassPill( + height: StatusBar.height, + child: Text( + [if (mode != null && mode.isNotEmpty) mode else '—', if (orientation != null) orientation].join(' · '), + style: HudText.mono.copyWith(fontWeight: FontWeight.w700), + ), + ); + }, + ); + } +} + +class _VisionIcon extends StatelessWidget { + const _VisionIcon({required this.vision}); + + final VisionState vision; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: vision, + builder: (BuildContext context, _) { + final Iterable r = vision.sensors.values; + final Color c = r.any((VisionReading s) => s.dangerous) + ? HudColors.danger + : r.any((VisionReading s) => s.isSensorBeingUsed) + ? Glass.sat + : HudColors.off; + return GlassPill(height: StatusBar.height, child: PVIcon('obstacle', size: 14, color: c)); + }, + ); + } +} + +class _TipMarquee extends StatelessWidget { + const _TipMarquee({required this.alerts}); + + final AlertState alerts; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: alerts, + builder: (BuildContext context, _) { + final DiagnosticAlert? top = alerts.top; + if (top == null) return const SizedBox.shrink(); + final Color c = switch (top.severity) { + AlertSeverity.critical => HudColors.danger, + AlertSeverity.warning => HudColors.caution, + AlertSeverity.info => Glass.ink, + }; + final int more = alerts.all.length - 1; + return GlassPill( + height: StatusBar.height, + child: Row(children: [ + Container(width: 6, height: 6, decoration: BoxDecoration(color: c, shape: BoxShape.circle)), + const SizedBox(width: 6), + Expanded(child: Marquee(text: top.reason, style: HudText.sans.copyWith(color: c))), + if (more > 0) Padding(padding: const EdgeInsets.only(left: 6), child: Text('+$more', style: HudText.monoSmall)), + ]), + ); + }, + ); + } +} + +class _SimBadge extends StatelessWidget { + const _SimBadge({required this.model}); + + final FlightModel model; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: model.aircraft, + builder: (BuildContext context, _) => model.aircraft.simulatorActive == true + ? Padding( + padding: const EdgeInsets.only(right: 6), + child: GlassPill( + height: StatusBar.height, + color: const Color(0xCC7A4DD8), + child: Text('SIM', style: HudText.mono.copyWith(fontWeight: FontWeight.w700)), + ), + ) + : const SizedBox.shrink(), + ); + } +} + +class _RoleBadge extends StatelessWidget { + const _RoleBadge({required this.links}); + + final LinkState links; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: links, + builder: (BuildContext context, _) { + final String? role = links.rcRoleBadge; + if (role == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(right: 6), + child: GlassPill( + height: StatusBar.height, + color: links.masterSlaveConnected == false ? HudColors.caution : Glass.accent, + child: Text(role, style: HudText.mono.copyWith(fontWeight: FontWeight.w700, color: Colors.white)), + ), + ); + }, + ); + } +} + +/// RC + HD bars on Lightbridge / OcuSync products, one Wi-Fi cluster on Wi-Fi ones. +class _SignalCluster extends StatelessWidget { + const _SignalCluster({required this.links}); + + final LinkState links; + + @override + Widget build(BuildContext context) { + final Widget rcHd = GlassPill( + height: StatusBar.height, + child: ListenableBuilder( + listenable: links, + builder: (BuildContext context, _) => Row(mainAxisSize: MainAxisSize.min, children: [ + const Text('RC', style: HudText.label), + const SizedBox(width: 3), + SignalBars(level: LinkState.bars(links.uplinkQuality)), + const SizedBox(width: 8), + const Text('HD', style: HudText.label), + const SizedBox(width: 3), + SignalBars(level: LinkState.bars(links.downlinkQuality)), + ]), + ), + ); + final Widget wifi = GlassPill( + height: StatusBar.height, + child: ListenableBuilder( + listenable: links, + builder: (BuildContext context, _) => Row(mainAxisSize: MainAxisSize.min, children: [ + const Text('WI-FI', style: HudText.label), + const SizedBox(width: 3), + // A Wi-Fi link is only as good as its weaker direction. + SignalBars(level: LinkState.bars(_min(links.uplinkQuality, links.downlinkQuality))), + ]), + ), + ); + return Requires( + HudFeatures.wifiSignal, + whenDisconnected: false, + fallback: rcHd, + child: wifi, + ); + } + + static int? _min(int? a, int? b) => a == null ? b : (b == null ? a : (a < b ? a : b)); +} + +class _BatteryChip extends StatelessWidget { + const _BatteryChip({required this.model}); + + final FlightModel model; + + @override + Widget build(BuildContext context) { + return GlassPill( + height: StatusBar.height, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const PVIcon('battery', size: 16, color: Glass.ink), + const SizedBox(width: 4), + ListenableBuilder( + listenable: model.battery, + builder: (BuildContext context, _) { + final int? p = model.battery.percent; + final double? v = model.battery.voltage; + final int low = model.battery.lowWarningThreshold ?? 30; + final int critical = model.battery.seriousLowWarningThreshold ?? 15; + final Color c = p == null + ? Glass.ink + : p <= critical + ? HudColors.danger + : p <= low + ? HudColors.caution + : Glass.sat; + return Row(mainAxisSize: MainAxisSize.min, children: [ + Text(p == null ? '—' : '$p%', style: HudText.mono.copyWith(color: c, fontWeight: FontWeight.w700)), + if (v != null) ...[ + const SizedBox(width: 5), + Text('${v.toStringAsFixed(1)}V', style: HudText.monoSmall.copyWith(color: HudText.dim)), + ], + ]); + }, + ), + ]), + ); + } +} diff --git a/Fly App/lib/ui/settings_menu_page.dart b/Fly App/lib/ui/settings_menu_page.dart index df05252..ccb4425 100644 --- a/Fly App/lib/ui/settings_menu_page.dart +++ b/Fly App/lib/ui/settings_menu_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import '../dji_service.dart'; import '../flight_model.dart'; +import '../fly/fly_prefs.dart'; import '../theme.dart'; import 'fly/debug/product_profile_page.dart'; import 'fly/debug/telemetry_inspector_page.dart'; @@ -156,6 +157,15 @@ class _SettingsMenuPageState extends State { ]), _infoRow('Capability profile', _m.profile.connected ? 'View' : '—', onTap: _openProfile), _infoRow('Telemetry inspector', 'View', onTap: _openTelemetry), + _toggleRow('Classic flight screen', FlyPrefs.classicHud.value, (bool on) { + FlyPrefs.setClassicHud(on); + setState(() {}); + _snack('Takes effect the next time the flight screen opens'); + }), + _toggleRow('HUD performance overlay', FlyPrefs.perfOverlay.value, (bool on) { + FlyPrefs.setPerfOverlay(on); + setState(() {}); + }), ]), }; return Padding( diff --git a/Fly App/test/fly/hud_test.dart b/Fly App/test/fly/hud_test.dart new file mode 100644 index 0000000..d10d496 --- /dev/null +++ b/Fly App/test/fly/hud_test.dart @@ -0,0 +1,256 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pilotvault_fly/dji_service.dart'; +import 'package:pilotvault_fly/flight_model.dart'; +import 'package:pilotvault_fly/fly/flight_status.dart'; +import 'package:pilotvault_fly/fly/flight_trail.dart'; +import 'package:pilotvault_fly/fly/hud_geometry.dart'; +import 'package:pilotvault_fly/fly/product_profile.dart'; +import 'package:pilotvault_fly/fly/state/aircraft_state.dart'; +import 'package:pilotvault_fly/fly/state/alert_state.dart'; +import 'package:pilotvault_fly/fly/telemetry_reducer.dart'; +import 'package:pilotvault_fly/ui/fly/fly_screen.dart'; +import 'package:pilotvault_fly/ui/fly/hud_kit.dart'; +import 'package:pilotvault_fly/ui/fly/top/smart_battery_bar.dart'; + +import 'profile_fixtures.dart'; +import 'telemetry_fixtures.dart'; + +AircraftState aircraftFrom(Map tick) => AircraftState()..apply(tick); + +void main() { + group('FlightStatus', () { + test('disconnected and not-yet-reported', () { + expect(FlightStatus.of(connected: false, a: AircraftState()).text, 'Disconnected'); + expect(FlightStatus.of(connected: true, a: AircraftState()).text, 'Connecting…'); + }); + + test('in flight on GPS is ready', () { + expect(FlightStatus.of(connected: true, a: aircraftFrom(telemetryTick())), const FlightStatus('In Flight (GPS)', StatusLevel.ready)); + }); + + test('on the ground with a weak GPS fix asks for caution', () { + final AircraftState a = aircraftFrom(telemetryTick() + ..['isFlying'] = false + ..['gpsSignalLevel'] = 1); + expect(FlightStatus.of(connected: true, a: a), const FlightStatus('Ready to Go (ATTI)', StatusLevel.caution)); + a.visionPositioningBeingUsed = true; + expect(FlightStatus.of(connected: true, a: a).text, 'Ready to Go (Vision)'); + }); + + test('automatic manoeuvres outrank battery and faults', () { + final AlertState alerts = AlertState()..apply(>[motorBlocked]); + final AircraftState a = aircraftFrom(telemetryTick() + ..['isGoingHome'] = true + ..['isLowBattery'] = true); + expect(FlightStatus.of(connected: true, a: a, alerts: alerts).text, 'Returning to Home'); + a.goingHome = false; + expect(FlightStatus.of(connected: true, a: a, alerts: alerts), const FlightStatus('Low battery', StatusLevel.caution)); + a.lowBattery = false; + expect(FlightStatus.of(connected: true, a: a, alerts: alerts), const FlightStatus('Warning', StatusLevel.danger)); + a.isFlying = false; + expect(FlightStatus.of(connected: true, a: a, alerts: alerts).text, 'Cannot take off'); + }); + + test('a warning-level alert tints a ready state amber', () { + final AlertState alerts = AlertState()..apply(>[sdSlow]); + expect(alerts.worst, AlertSeverity.warning); + expect(FlightStatus.of(connected: true, a: aircraftFrom(telemetryTick()), alerts: alerts).level, StatusLevel.caution); + }); + }); + + group('HudGeometry', () { + test('bearing and distance', () { + expect(HudGeometry.bearing(55, 12, 55.001, 12), closeTo(0, 0.01)); + expect(HudGeometry.bearing(55, 12, 55, 12.001), closeTo(90, 0.1)); + expect(HudGeometry.bearing(55, 12, 54.999, 12), closeTo(180, 0.01)); + expect(HudGeometry.bearing(55, 12, 55, 11.999), closeTo(270, 0.1)); + // 0.001° of latitude ≈ 111 m. + expect(HudGeometry.distance(55, 12, 55.001, 12), closeTo(111.2, 0.5)); + }); + + test('ball offset: north is up, east is right, beyond range pins to the rim', () { + Offset at(double b, double d, {double up = 0}) => + HudGeometry.ballOffset(bearingDeg: b, distanceM: d, radius: 40, rangeM: 100, upDeg: up); + expect(at(0, 50).dy, closeTo(-20, 1e-9)); + expect(at(0, 50).dx, closeTo(0, 1e-9)); + expect(at(90, 50).dx, closeTo(20, 1e-9)); + expect(at(180, 1000).dy, closeTo(40, 1e-9)); + // Heading-up: a point dead ahead of an east-facing aircraft is drawn up. + expect(at(90, 100, up: 90).dy, closeTo(-40, 1e-9)); + }); + + test('ball range steps up to fit the farthest point', () { + expect(HudGeometry.ballRange(0), 25); + expect(HudGeometry.ballRange(26), 50); + expect(HudGeometry.ballRange(450), 500); + expect(HudGeometry.ballRange(1e6), HudGeometry.ballRanges.last); + }); + + test('horizon moves up when the nose drops', () { + expect(HudGeometry.horizonShift(-9), lessThan(0)); + expect(HudGeometry.horizonShift(9), greaterThan(0)); + expect(HudGeometry.horizonShift(-90), -1); + }); + + test('formatDuration', () { + expect(formatDuration(null), '--:--'); + expect(formatDuration(65), '01:05'); + expect(formatDuration(3725), '1:02:05'); + }); + }); + + group('BatteryBarLayout', () { + BatteryBarLayout layout(int p) => BatteryBarLayout.from( + percent: p, + batteryNeededToLand: 6, + batteryNeededToGoHome: 12, + lowThreshold: 30, + criticalThreshold: 15, + ); + + test('markers sit at the reported percentages', () { + final BatteryBarLayout l = layout(64); + expect(l.fill, 0.64); + expect(l.landMarker, 0.06); + expect(l.homeMarker, 0.12); + expect(l.band, 0); + }); + + test('band follows the thresholds', () { + expect(layout(25).band, 1, reason: 'below the low warning'); + expect(layout(14).band, 2, reason: 'below the critical warning'); + expect(BatteryBarLayout.from(percent: 10, batteryNeededToGoHome: 12).band, 1); + expect(BatteryBarLayout.from(percent: 5, batteryNeededToLand: 6).band, 2); + }); + + test('unreported markers are absent', () { + final BatteryBarLayout l = BatteryBarLayout.from(percent: 50, batteryNeededToLand: 0); + expect(l.landMarker, isNull); + expect(l.homeMarker, isNull); + }); + }); + + group('FlightTrail', () { + test('keeps a point every few metres and ignores no-fix zeros', () { + final AircraftState a = AircraftState(); + final FlightTrail t = FlightTrail(a, minSpacing: 5); + a.apply(telemetryTick() + ..['latitude'] = 0.0 + ..['longitude'] = 0.0); + expect(t.points, isEmpty); + a.apply(telemetryTick()); + a.apply(telemetryTick()..['latitude'] = 55.67611); // ~1 m: dropped + a.apply(telemetryTick()..['latitude'] = 55.6762); // ~11 m: kept + expect(t.points, hasLength(2)); + t.clear(); + expect(t.points, isEmpty); + t.dispose(); + }); + }); + + group('P2 telemetry groups', () { + test('ssd, thermal and the RC role reach their sub-models', () { + final FlightModel m = FlightModel()..connected = true; + TelemetryReducer(m).apply(telemetryTick() + ..['ssd'] = {'connected': true, 'state': 'IDLE', 'remainingMB': 400000, 'availableRecordingSeconds': 3600} + ..['thermal'] = {'spotTemperature': 36.6} + ..['rc'] = {'batteryPercent': 80, 'mode': 'SLAVE', 'masterSlaveConnected': true}); + expect(m.camera.ssd!.status, 'OK'); + expect(m.camera.ssd!.availableRecordingSeconds, 3600); + expect(m.camera.thermalSpot, 36.6); + expect(m.links.rcRoleBadge, 'S'); + }); + }); + + group('HudScale', () { + test('phones shrink slightly, tablets grow', () { + expect(HudScale.scaleFor(390), 1.0); + expect(HudScale.scaleFor(340), lessThan(1.0)); + expect(HudScale.scaleFor(800), greaterThan(1.2)); + }); + }); + + group('FlyScreen', () { + FlightModel modelFor(Map profile, {Map? tick}) { + final FlightModel m = FlightModel() + ..connected = true + ..profile = ProductProfile.fromBridge(profile); + TelemetryReducer(m).apply(tick ?? telemetryTick()); + return m; + } + + Map dualPackTick() { + final Map t = telemetryTick(); + final Map b = Map.from(t['battery'] as Map); + final List packs = List.from(b['packs'] as List); + packs.add({...(packs.first as Map), 'index': 1, 'percent': 61}); + b['packs'] = packs; + t['battery'] = b; + t['ssd'] = {'connected': true, 'state': 'IDLE', 'remainingMB': 400000, 'availableRecordingSeconds': 3600}; + t['rc'] = {'batteryPercent': 80, 'mode': 'MASTER', 'masterSlaveConnected': true}; + return t; + } + + Future pump(WidgetTester tester, FlightModel m, {Size size = const Size(844, 390)}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: DjiService()))); + await tester.pump(const Duration(milliseconds: 50)); + } + + int batteryBars(WidgetTester tester) => + tester.widgetList(find.byWidgetPredicate((Widget w) => w is CustomPaint && w.painter is SmartBatteryPainter)).length; + + testWidgets('Spark on Wi-Fi: Wi-Fi bars, one battery bar, no SSD', (WidgetTester tester) async { + await pump(tester, modelFor(sparkWifi)); + expect(find.text('WI-FI'), findsOneWidget); + expect(find.text('RC'), findsNothing); + expect(batteryBars(tester), 1); + expect(find.textContaining('SSD'), findsNothing); + expect(find.text('In Flight (GPS)'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('Phantom 4: RC + HD bars and the telemetry strip', (WidgetTester tester) async { + await pump(tester, modelFor(phantom4)); + expect(find.text('RC'), findsOneWidget); + expect(find.text('HD'), findsOneWidget); + expect(find.text('WI-FI'), findsNothing); + expect(batteryBars(tester), 1); + expect(find.text('32.4 m', findRichText: true), findsOneWidget, reason: 'altitude'); + expect(find.text('13:00'), findsOneWidget, reason: 'remaining flight time'); + expect(find.text('64%'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('Inspire 2 + X5S: dual battery, SSD, aperture and master badge', (WidgetTester tester) async { + await pump(tester, modelFor(inspire2X5s, tick: dualPackTick())); + expect(batteryBars(tester), 2); + expect(find.text('SSD'), findsOneWidget); + expect(find.text('F'), findsOneWidget); + expect(find.text('M'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('lays out without overflow on a tablet and a small phone', (WidgetTester tester) async { + for (final Size s in const [Size(1280, 800), Size(740, 340)]) { + await pump(tester, modelFor(inspire2X5s, tick: dualPackTick()), size: s); + expect(tester.takeException(), isNull, reason: '$s'); + } + }); + + testWidgets('tapping the corner box swaps map and video', (WidgetTester tester) async { + await pump(tester, modelFor(phantom4)); + expect(find.text('MAP'), findsOneWidget); + await tester.tap(find.text('MAP')); + await tester.pump(); + expect(find.text('VIDEO'), findsOneWidget); + expect(find.text('CLR'), findsOneWidget, reason: 'minimap controls while the map is big'); + await tester.tap(find.text('VIDEO')); + await tester.pump(); + expect(find.text('MAP'), findsOneWidget); + }); + }); +}