From 590f2a6152d825b9c36f371d60f71138668f02eb Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:54:09 +0200 Subject: [PATCH] Give the Fly App a GO 4-style flight HUD The new flight screen (lib/ui/fly/fly_screen.dart) is built from layers. Each readout listens to its own telemetry sub-model, so a 10 Hz tick only repaints what changed. It adds a status bar with a status chip and tip marquee, a smart battery bar with land/RTH markers, a camera info strip, an attitude ball, an H/D/VS/HS/VPS strip, and a minimap that swaps places with the video when tapped. The HUD is laid out in design pixels and scaled to phone or tablet inside the safe area. What the HUD shows depends on the product: dual or multi battery bars, SSD status, a thermal spot reading, a master/slave RC badge, and Wi-Fi bars in place of RC/HD. The bridges now report the data for these: the camera's ssd/thermal groups and the RC's master/slave mode. The old FlightControlPage can still be opened from Settings > About > Classic flight screen until the P3 safety flows land. The same tab also gets a toggle for the performance overlay. Co-Authored-By: Claude Opus 5.5 --- .../com/pilotvault/flyapp/AirLinkBridge.kt | 16 +- .../com/pilotvault/flyapp/CameraBridge.kt | 36 ++ .../com/pilotvault/flyapp/TelemetryHub.kt | 3 +- Fly App/lib/fly/flight_status.dart | 67 ++++ Fly App/lib/fly/flight_trail.dart | 47 +++ Fly App/lib/fly/fly_prefs.dart | 38 ++ Fly App/lib/fly/hud_geometry.dart | 111 ++++++ Fly App/lib/fly/state/camera_state.dart | 65 +++- Fly App/lib/fly/state/link_state.dart | 17 +- Fly App/lib/fly/telemetry_reducer.dart | 14 +- Fly App/lib/main.dart | 7 +- Fly App/lib/ui/flight_control_page.dart | 3 + Fly App/lib/ui/fly/bottom/attitude_ball.dart | 214 +++++++++++ Fly App/lib/ui/fly/bottom/minimap.dart | 256 +++++++++++++ .../lib/ui/fly/bottom/telemetry_strip.dart | 93 +++++ Fly App/lib/ui/fly/fly_screen.dart | 260 ++++++++++++++ Fly App/lib/ui/fly/hud_kit.dart | 248 +++++++++++++ Fly App/lib/ui/fly/layers/video_layer.dart | 112 ++++++ Fly App/lib/ui/fly/left/action_rail.dart | 121 +++++++ Fly App/lib/ui/fly/left/gimbal_slider.dart | 107 ++++++ Fly App/lib/ui/fly/right/camera_column.dart | 132 +++++++ Fly App/lib/ui/fly/top/camera_info_strip.dart | 111 ++++++ Fly App/lib/ui/fly/top/smart_battery_bar.dart | 151 ++++++++ Fly App/lib/ui/fly/top/status_bar.dart | 338 ++++++++++++++++++ Fly App/lib/ui/settings_menu_page.dart | 10 + Fly App/test/fly/hud_test.dart | 256 +++++++++++++ 26 files changed, 2822 insertions(+), 11 deletions(-) create mode 100644 Fly App/lib/fly/flight_status.dart create mode 100644 Fly App/lib/fly/flight_trail.dart create mode 100644 Fly App/lib/fly/fly_prefs.dart create mode 100644 Fly App/lib/fly/hud_geometry.dart create mode 100644 Fly App/lib/ui/fly/bottom/attitude_ball.dart create mode 100644 Fly App/lib/ui/fly/bottom/minimap.dart create mode 100644 Fly App/lib/ui/fly/bottom/telemetry_strip.dart create mode 100644 Fly App/lib/ui/fly/fly_screen.dart create mode 100644 Fly App/lib/ui/fly/hud_kit.dart create mode 100644 Fly App/lib/ui/fly/layers/video_layer.dart create mode 100644 Fly App/lib/ui/fly/left/action_rail.dart create mode 100644 Fly App/lib/ui/fly/left/gimbal_slider.dart create mode 100644 Fly App/lib/ui/fly/right/camera_column.dart create mode 100644 Fly App/lib/ui/fly/top/camera_info_strip.dart create mode 100644 Fly App/lib/ui/fly/top/smart_battery_bar.dart create mode 100644 Fly App/lib/ui/fly/top/status_bar.dart create mode 100644 Fly App/test/fly/hud_test.dart 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); + }); + }); +}