From 284de5442a0d9ca17e24878aaf57e0f5658d0b5d Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:13:45 +0200 Subject: [PATCH] Give the Fly App a GO 4-style settings sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "•••" button now opens a right-side glass sheet over the live view with Safety, Vision, Remote, Video link, Battery, Gimbal and General tabs. Each row is a one-line spec bound to a KeyManager key: rows hide when the aircraft does not support their key, writes show at once and roll back if refused, and ground-only settings lock in flight. KeyBridge gains argument-less action keys and converts range and LED settings values. settings_menu_page.dart is retired. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/com/pilotvault/flyapp/KeyBridge.kt | 38 +- Fly App/lib/dji_service.dart | 6 + Fly App/lib/flight_model.dart | 16 +- Fly App/lib/fly/fly_prefs.dart | 10 + Fly App/lib/fly/hud_features.dart | 19 + Fly App/lib/fly/settings/settings_spec.dart | 302 +++++++++++ Fly App/lib/fly/settings/settings_tabs.dart | 491 ++++++++++++++++++ Fly App/lib/fly/state/key_state.dart | 112 ++++ Fly App/lib/fly/telemetry_reducer.dart | 4 + Fly App/lib/ui/flight_control_page.dart | 6 +- Fly App/lib/ui/fly/bottom/minimap.dart | 5 +- Fly App/lib/ui/fly/fly_screen.dart | 11 +- .../fly/panels/camera_menu/camera_rows.dart | 64 ++- .../ui/fly/panels/settings/setting_tiles.dart | 296 +++++++++++ .../fly/panels/settings/settings_sheet.dart | 436 ++++++++++++++++ Fly App/lib/ui/fly/top/status_bar.dart | 1 + Fly App/lib/ui/settings_menu_page.dart | 313 ----------- Fly App/test/fly/fake_dji.dart | 31 ++ Fly App/test/fly/settings_test.dart | 313 +++++++++++ 19 files changed, 2132 insertions(+), 342 deletions(-) create mode 100644 Fly App/lib/fly/settings/settings_spec.dart create mode 100644 Fly App/lib/fly/settings/settings_tabs.dart create mode 100644 Fly App/lib/fly/state/key_state.dart create mode 100644 Fly App/lib/ui/fly/panels/settings/setting_tiles.dart create mode 100644 Fly App/lib/ui/fly/panels/settings/settings_sheet.dart delete mode 100644 Fly App/lib/ui/settings_menu_page.dart create mode 100644 Fly App/test/fly/settings_test.dart diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/KeyBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/KeyBridge.kt index d416ba4..766ba43 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/KeyBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/KeyBridge.kt @@ -2,8 +2,10 @@ package com.pilotvault.flyapp import dji.common.camera.ResolutionAndFrameRate import dji.common.error.DJIError +import dji.common.flightcontroller.LEDsSettings import dji.common.flightcontroller.LocationCoordinate3D import dji.common.model.LocationCoordinate2D +import dji.common.util.DJIParamMinMaxCapability import dji.keysdk.AirLinkKey import dji.keysdk.BatteryKey import dji.keysdk.CameraKey @@ -13,6 +15,7 @@ import dji.keysdk.GimbalKey import dji.keysdk.KeyManager import dji.keysdk.ProductKey import dji.keysdk.RemoteControllerKey +import dji.keysdk.callback.ActionCallback import dji.keysdk.callback.GetCallback import dji.keysdk.callback.KeyListener import dji.keysdk.callback.SetCallback @@ -21,8 +24,9 @@ import io.flutter.plugin.common.MethodChannel /** * Generic MSDK V4 `KeyManager` access (plan §1.3): one `getKey` / `setKey` / - * `listenKey` / `cancelKey` / `isKeySupported` method set instead of a bespoke - * method per simple value, so a settings row (P6) is one key name, not new Kotlin. + * `listenKey` / `cancelKey` / `isKeySupported` / `performKeyAction` method set + * instead of a bespoke method per simple value, so a settings row (P6) is one + * key name, not new Kotlin. * * A key is addressed as `{component, key, index?, subIndex?}`: * - `component`: `FlightController`, `FlightAssistant`, `RTK`, `Camera`, `Gimbal`, @@ -44,7 +48,7 @@ class KeyBridge(private val ctx: BridgeCtx) : SubBridge { override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { when (call.method) { - "getKey", "setKey", "listenKey", "isKeySupported" -> Unit + "getKey", "setKey", "listenKey", "isKeySupported", "performKeyAction" -> Unit "cancelKey" -> { val id = call.argument("id") ctx.mainHandler.post { @@ -79,6 +83,17 @@ class KeyBridge(private val ctx: BridgeCtx) : SubBridge { } }) "setKey" -> setKey(km, key, call.argument("value"), result) + // Action keys (`START_PAIRING`, …) without arguments; ones that take SDK + // objects keep a bespoke method. + "performKeyAction" -> km.performAction(key, object : ActionCallback { + override fun onSuccess() { + ctx.mainHandler.post { result.success(null) } + } + + override fun onFailure(error: DJIError) { + ctx.mainHandler.post { result.error("DJI_ERROR", error.description, null) } + } + }) "listenKey" -> ctx.mainHandler.post { val id = idOf(call) if (!listeners.containsKey(id)) { @@ -226,12 +241,29 @@ object KeyValues { is ResolutionAndFrameRate -> mapOf("resolution" to v.resolution?.name, "frameRate" to v.frameRate?.name) is LocationCoordinate3D -> mapOf("latitude" to v.latitude, "longitude" to v.longitude, "altitude" to v.altitude.toDouble()) is LocationCoordinate2D -> mapOf("latitude" to v.latitude, "longitude" to v.longitude) + // `…_RANGE` keys (max flight height / radius, cinematic sensitivities). + is DJIParamMinMaxCapability -> mapOf("supported" to v.isSupported(), "min" to v.min?.toDouble(), "max" to v.max?.toDouble()) + is LEDsSettings -> mapOf( + "front" to v.areFrontLEDsOn(), + "rear" to v.areRearLEDsOn(), + "statusIndicator" to v.isStatusIndicatorOn(), + "beacons" to v.areBeaconsOn(), + ) else -> v.toString() } /** Converts a Dart value to the Java type of [sample], the key's current value. */ fun coerce(raw: Any, sample: Any?): Any { if (sample == null) return raw + if (sample is LEDsSettings && raw is Map<*, *>) { + fun on(k: String, was: Boolean) = raw[k] as? Boolean ?: was + return LEDsSettings.Builder() + .frontLEDsOn(on("front", sample.areFrontLEDsOn())) + .rearLEDsOn(on("rear", sample.areRearLEDsOn())) + .statusIndicatorOn(on("statusIndicator", sample.isStatusIndicatorOn())) + .beaconsOn(on("beacons", sample.areBeaconsOn())) + .build() + } if (sample is Enum<*>) { val cls = sample.javaClass.let { if (it.isEnum) it else it.superclass } return cls.enumConstants?.firstOrNull { (it as Enum<*>).name == raw.toString() } ?: raw diff --git a/Fly App/lib/dji_service.dart b/Fly App/lib/dji_service.dart index b0b2ea7..7fdf987 100644 --- a/Fly App/lib/dji_service.dart +++ b/Fly App/lib/dji_service.dart @@ -115,6 +115,9 @@ class DjiService { Future listenKey(DjiKey key) async => await _methods.invokeMethod('listenKey', key.args) ?? key.id; + /// Runs an action key that takes no arguments (`START_PAIRING`, …). + Future performKeyAction(DjiKey key) => _methods.invokeMethod('performKeyAction', key.args); + Future cancelKey(String id) => _methods.invokeMethod('cancelKey', {'id': id}); // ── Camera ────────────────────────────────────────────────────────────────── @@ -302,6 +305,9 @@ class DjiKey { const DjiKey.gimbal(String key, {int index = 0}) : this('Gimbal', key, index: index); const DjiKey.battery(String key, {int index = 0}) : this('Battery', key, index: index); const DjiKey.airLink(String key) : this('AirLink', key); + const DjiKey.ocuSync(String key) : this('OcuSyncLink', key); + const DjiKey.lightbridge(String key) : this('LightbridgeLink', key); + const DjiKey.wifi(String key) : this('WiFiLink', key); const DjiKey.remoteController(String key) : this('RemoteController', key); const DjiKey.product(String key) : this('Product', key); diff --git a/Fly App/lib/flight_model.dart b/Fly App/lib/flight_model.dart index 6bf1e76..5e75c10 100644 --- a/Fly App/lib/flight_model.dart +++ b/Fly App/lib/flight_model.dart @@ -6,6 +6,7 @@ import 'fly/state/alert_state.dart'; import 'fly/state/battery_state.dart'; import 'fly/state/camera_state.dart'; import 'fly/state/gimbal_state.dart'; +import 'fly/state/key_state.dart'; import 'fly/state/link_state.dart'; import 'fly/state/mission_state.dart'; import 'fly/state/vision_state.dart'; @@ -124,8 +125,11 @@ class FlightModel extends ChangeNotifier { final AlertState alerts = AlertState(); final MissionState mission = MissionState(); + /// KeyManager values the settings sheet listens to (plan P6). + final KeyState keys = KeyState(); + late final List _subs = [ - aircraft, battery, links, camera, gimbal, vision, alerts, mission, // + aircraft, battery, links, camera, gimbal, vision, alerts, mission, keys, // ]; /// Wipes the aircraft-bound sub-models on disconnect. @@ -137,6 +141,7 @@ class FlightModel extends ChangeNotifier { gimbal.clear(); vision.clear(); alerts.clear(); + keys.clear(); }); // ── SDK / registration / connection ──────────────────────────────────────── @@ -216,15 +221,6 @@ class FlightModel extends ChangeNotifier { double? get gimbalRoll => gimbal.roll; double? get gimbalYaw => gimbal.yaw; - // ── Flight settings (Safety / Control) ────────────────────────────────────── - int? maxHeight; // m - int? maxRadius; // m - bool? maxRadiusEnabled; - int? rthHeight; // m - String? obstacleAvoidance; // "On" / "Bypass" / "Off" - bool? noviceMode; - bool arHomePoint = true; // client-side toggle (AR overlay) - // ── Missions (flat view of [mission]) ─────────────────────────────────────── String get missionState => mission.state; // idle / ready / uploading / executing / … bool get missionRunning => mission.running; diff --git a/Fly App/lib/fly/fly_prefs.dart b/Fly App/lib/fly/fly_prefs.dart index 080c5b6..2bead54 100644 --- a/Fly App/lib/fly/fly_prefs.dart +++ b/Fly App/lib/fly/fly_prefs.dart @@ -29,6 +29,7 @@ abstract final class FlyPrefs { static const String _gridKey = 'fly.grid'; static const String _centerKey = 'fly.centerPoint'; static const String _longPressKey = 'fly.longPress'; + static const String _routeKey = 'fly.showRoute'; /// Open the pre-P2 `FlightControlPage` instead of the HUD v2 — kept /// reachable until P3's safety flows pass their simulator run (plan P2/P3). @@ -42,6 +43,9 @@ abstract final class FlyPrefs { static final ValueNotifier centerPoint = ValueNotifier(true); static final ValueNotifier longPress = ValueNotifier(LongPressAction.meter); + /// Draw the flown trail on the map (settings → General). + static final ValueNotifier showRoute = ValueNotifier(true); + static SharedPreferences? _prefs; static Future load() async { @@ -52,6 +56,7 @@ abstract final class FlyPrefs { grid.value = _byName(GridStyle.values, _prefs!.getString(_gridKey)) ?? GridStyle.off; centerPoint.value = _prefs!.getBool(_centerKey) ?? true; longPress.value = _byName(LongPressAction.values, _prefs!.getString(_longPressKey)) ?? LongPressAction.meter; + showRoute.value = _prefs!.getBool(_routeKey) ?? true; } catch (_) { // Storage unavailable: run on the defaults. } @@ -82,6 +87,11 @@ abstract final class FlyPrefs { await _prefs?.setString(_longPressKey, a.name); } + static Future setShowRoute(bool on) async { + showRoute.value = on; + await _prefs?.setBool(_routeKey, on); + } + static T? _byName(List values, String? name) { for (final T v in values) { if (v.name == name) return v; diff --git a/Fly App/lib/fly/hud_features.dart b/Fly App/lib/fly/hud_features.dart index 9d556c3..aea3896 100644 --- a/Fly App/lib/fly/hud_features.dart +++ b/Fly App/lib/fly/hud_features.dart @@ -98,6 +98,20 @@ abstract final class HudFeatures { static final HudFeature simulator = HudFeature( 'control.simulator', 'Simulator toggle', 'Control', (ProductProfile p) => p.hasSimulator); + // ── Settings sheet (plan P6) ────────────────────────────────────────────── + static final HudFeature settingsVision = HudFeature( + 'settings.vision', 'Vision settings tab', 'Settings', (ProductProfile p) => p.hasVps); + static final HudFeature settingsObstacle = HudFeature( + 'settings.obstacle', 'Obstacle avoidance settings', 'Settings', (ProductProfile p) => p.hasObstacleSensing); + /// Transmission rows address the link's own key class, so each set is gated + /// on the link type. + static final HudFeature linkOcuSync = HudFeature( + 'settings.ocusync', 'OcuSync transmission settings', 'Settings', (ProductProfile p) => p.link == LinkType.ocusync); + static final HudFeature linkLightbridge = HudFeature('settings.lightbridge', 'Lightbridge transmission settings', 'Settings', + (ProductProfile p) => p.link == LinkType.lightbridge); + static final HudFeature linkWifi = HudFeature( + 'settings.wifi', 'Wi-Fi transmission settings', 'Settings', (ProductProfile p) => p.link == LinkType.wifi); + // ── Intelligent flight modes ────────────────────────────────────────────── static final List modes = [ for (final IntelligentMode m in IntelligentMode.values) @@ -136,6 +150,11 @@ abstract final class HudFeatures { virtualSticks, precisionTakeoff, simulator, + settingsVision, + settingsObstacle, + linkOcuSync, + linkLightbridge, + linkWifi, ...modes, ]; diff --git a/Fly App/lib/fly/settings/settings_spec.dart b/Fly App/lib/fly/settings/settings_spec.dart new file mode 100644 index 0000000..d8309f5 --- /dev/null +++ b/Fly App/lib/fly/settings/settings_spec.dart @@ -0,0 +1,302 @@ +import '../../dji_service.dart'; +import '../hud_features.dart'; +import '../product_profile.dart'; +import '../state/aircraft_state.dart'; +import '../state/key_state.dart'; +import '../state/parse.dart'; + +/// What a row's predicates may look at. +class SettingsContext { + const SettingsContext({required this.profile, required this.keys, required this.aircraft}); + + final ProductProfile profile; + final KeyState keys; + final AircraftState aircraft; + + bool get connected => profile.connected; + + /// Airborne or motors running: the FC refuses some settings then. + bool get airborne => aircraft.isFlying == true || aircraft.motorsOn == true; +} + +/// One row of the settings sheet (plan P6, §1.4): data, not widgets, so a new +/// setting is one line in `settings_tabs.dart`. +/// +/// A row bound to a [key] hides once that key is known to be unsupported on +/// the connected product; [requires] gates it on the profile like any HUD +/// element; [visibleWhen] hides it on another setting's value (the distance +/// value only while the limit is on). [groundOnly] rows are locked while the +/// aircraft is airborne or the motors run. +sealed class SettingSpec { + const SettingSpec({required this.id, required this.label, this.hint, this.requires, this.visibleWhen, this.groundOnly = false}); + + final String id; + final String label; + final String? hint; + final HudFeature? requires; + final bool Function(SettingsContext c)? visibleWhen; + final bool groundOnly; + + /// The key the row reads, or null for app-side rows. + DjiKey? get key => null; + + /// Every key the row needs listened to. + Iterable get watched => key == null ? const [] : [key!]; + + bool visible(SettingsContext c) { + if (requires != null && c.connected && !requires!.enabledFor(c.profile)) return false; + final DjiKey? k = key; + if (k != null && c.keys.supported(k) == false) return false; + return visibleWhen?.call(c) ?? true; + } + + /// Why the row cannot be changed right now, or null. + String? lockedReason(SettingsContext c) { + if (key == null) return null; + if (!c.connected) return 'Connect the aircraft to change'; + if (groundOnly && c.airborne) return 'Land and stop the motors to change'; + return null; + } +} + +/// A section label. +class GroupSpec extends SettingSpec { + const GroupSpec(String label, {super.requires, super.visibleWhen}) : super(id: 'group-$label', label: label); +} + +class KeySwitchSpec extends SettingSpec { + const KeySwitchSpec({ + required super.id, + required super.label, + required DjiKey key, + super.hint, + super.requires, + super.visibleWhen, + super.groundOnly, + }) : _key = key; + + final DjiKey _key; + @override + DjiKey get key => _key; + + bool? current(KeyState s) => asBool(s.value(_key)); +} + +/// An SDK enum, offered as chips. [options] are (enum name, label); when +/// [supportedKey] reports a list of names, only those are offered. +class KeyChoiceSpec extends SettingSpec { + const KeyChoiceSpec({ + required super.id, + required super.label, + required DjiKey key, + required this.options, + this.supportedKey, + super.hint, + super.requires, + super.visibleWhen, + super.groundOnly, + }) : _key = key; + + final DjiKey _key; + final List<(String, String)> options; + final DjiKey? supportedKey; + + @override + DjiKey get key => _key; + + @override + Iterable get watched => [_key, if (supportedKey != null) supportedKey!]; + + String? current(KeyState s) => asString(s.value(_key)); + + List<(String, String)> optionsFor(KeyState s) { + final Object? names = supportedKey == null ? null : s.value(supportedKey!); + final List<(String, String)> offered = + names is List ? <(String, String)>[for (final (String, String) o in options) if (names.contains(o.$1)) o] : options; + // A value the table does not know (STYLE_CUSTOM set elsewhere) still shows. + final String? v = current(s); + if (v != null && !offered.any(((String, String) o) => o.$1 == v)) { + return <(String, String)>[...offered, (v, prettyEnum(v))]; + } + return offered; + } +} + +/// An integer stepped between bounds. The bounds come from [rangeKey] when the +/// aircraft reports one (`{min, max}` or `[min, max]`), else [min] / [max]; +/// [bounds] narrows them further from other settings. +class KeyNumberSpec extends SettingSpec { + const KeyNumberSpec({ + required super.id, + required super.label, + required DjiKey key, + required this.min, + required this.max, + this.step = 1, + this.unit = '', + this.rangeKey, + this.bounds, + this.feet = false, + super.hint, + super.requires, + super.visibleWhen, + super.groundOnly, + }) : _key = key; + + final DjiKey _key; + final int min; + final int max; + final int step; + final String unit; + final DjiKey? rangeKey; + final (int, int) Function(KeyState s, int min, int max)? bounds; + + /// Metres: show "≈ N ft" beside the value. + final bool feet; + + @override + DjiKey get key => _key; + + @override + Iterable get watched => [_key, if (rangeKey != null) rangeKey!]; + + int? current(KeyState s) => asInt(s.value(_key)); + + (int, int) range(KeyState s) { + int lo = min, hi = max; + final Object? r = rangeKey == null ? null : s.value(rangeKey!); + final Map? m = asMap(r); + if (m != null && m['supported'] != false && asInt(m['min']) != null && asInt(m['max']) != null) { + lo = asInt(m['min'])!; + hi = asInt(m['max'])!; + } else if (r is List && r.length == 2 && asInt(r[0]) != null && asInt(r[1]) != null) { + lo = asInt(r[0])!; + hi = asInt(r[1])!; + } + if (bounds != null) (lo, hi) = bounds!(s, lo, hi); + return (lo, hi); + } + + /// [v] moved by one step in [dir], clamped, or null when it cannot move. + int? stepped(KeyState s, int dir) { + final int? v = current(s); + if (v == null) return null; + final (int lo, int hi) = range(s); + final int next = (v + dir * step).clamp(lo, hi); + return next == v ? null : next; + } +} + +/// A read-only value. +class KeyReadoutSpec extends SettingSpec { + const KeyReadoutSpec({ + required super.id, + required super.label, + required DjiKey key, + this.format = formatValue, + super.hint, + super.requires, + super.visibleWhen, + }) : _key = key; + + final DjiKey _key; + final String Function(Object? v) format; + + @override + DjiKey get key => _key; + + @override + String? lockedReason(SettingsContext c) => null; + + String shown(KeyState s) { + final Object? v = s.value(_key); + return v == null ? '—' : format(v); + } +} + +/// A button that runs an argument-less action key (`START_PAIRING`). +class KeyActionSpec extends SettingSpec { + const KeyActionSpec({ + required super.id, + required super.label, + required DjiKey key, + required this.button, + this.confirmTitle, + this.confirmText, + super.hint, + super.requires, + super.visibleWhen, + super.groundOnly, + }) : _key = key; + + final DjiKey _key; + final String button; + final String? confirmTitle; + final String? confirmText; + + @override + DjiKey get key => _key; + + /// Action keys are not values: `isKeySupported` is the only gate. + @override + Iterable get watched => const []; +} + +/// A row the sheet draws itself (home point, stick-mode diagram, the gimbal +/// list, app preferences, About). [keys] are still watched and gate nothing. +class CustomSpec extends SettingSpec { + const CustomSpec({required super.id, super.label = '', this.keys = const [], super.requires, super.visibleWhen}); + + final List keys; + + @override + Iterable get watched => keys; +} + +/// A tab of the sheet. +class SettingsTab { + const SettingsTab({required this.id, required this.label, required this.icon, required this.rows, this.requires}); + + final String id; + final String label; + + /// A `PVIcon` name. + final String icon; + final List rows; + final HudFeature? requires; + + Iterable get watched sync* { + for (final SettingSpec r in rows) { + yield* r.watched; + if (r is KeyActionSpec) yield r.key; + } + } + + bool visible(ProductProfile p) => requires == null || !p.connected || requires!.enabledFor(p); +} + +// ── Formatting ──────────────────────────────────────────────────────────────── + +/// `FREQUENCY_BAND_2_DOT_4_GHZ` → `Frequency band 2.4 ghz`-ish fallback label. +String prettyEnum(String v) { + final String s = v.replaceAll('_DOT_', '.').replaceAll('_', ' ').toLowerCase(); + return s.isEmpty ? v : s[0].toUpperCase() + s.substring(1); +} + +String formatValue(Object? v) => switch (v) { + null => '—', + bool b => b ? 'On' : 'Off', + double d => d == d.roundToDouble() ? d.toInt().toString() : d.toStringAsFixed(1), + String s when s.toUpperCase() == s && s.contains('_') => prettyEnum(s), + List l => l.map(formatValue).join(', '), + _ => v.toString(), + }; + +/// `≈ 394 ft` for [m] metres. +String feetHint(int m) => '≈ ${(m * 3.28084).round()} ft'; + +/// Millivolt cell list → `3.85 3.86 3.84 V`. +String formatCells(Object? v) { + if (v is! List || v.isEmpty) return '—'; + return '${v.map((Object? mv) => mv is num ? (mv / 1000).toStringAsFixed(2) : '?').join(' ')} V'; +} diff --git a/Fly App/lib/fly/settings/settings_tabs.dart b/Fly App/lib/fly/settings/settings_tabs.dart new file mode 100644 index 0000000..9445958 --- /dev/null +++ b/Fly App/lib/fly/settings/settings_tabs.dart @@ -0,0 +1,491 @@ +import '../../dji_service.dart'; +import '../hud_features.dart'; +import '../product_profile.dart'; +import '../state/key_state.dart'; +import '../state/parse.dart'; +import 'settings_spec.dart'; + +/// The settings sheet's tabs (plan P6, GO 4's MC / Vision / RC / HD / Battery / +/// Gimbal / General). Each row is one spec bound to a KeyManager key; rows +/// whose key the aircraft does not support hide themselves, so the same list +/// serves every product. +/// +/// Out of scope here, with no public MSDK V4 endpoint (see the matrix): gain & +/// expo, max descent speed, the RC speed bar, RC type / factory reset. +abstract final class SettingsKeys { + // Flight controller + static const DjiKey goHomeHeight = DjiKey.flightController('GO_HOME_HEIGHT_IN_METERS'); + static const DjiKey smartRth = DjiKey.flightController('SMART_RETURN_TO_HOME_ENABLED'); + static const DjiKey failSafe = DjiKey.flightController('CONNECTION_FAIL_SAFE_BEHAVIOR'); + static const DjiKey maxHeight = DjiKey.flightController('MAX_FLIGHT_HEIGHT'); + static const DjiKey maxHeightRange = DjiKey.flightController('MAX_FLIGHT_HEIGHT_RANGE'); + static const DjiKey maxRadiusEnabled = DjiKey.flightController('MAX_FLIGHT_RADIUS_ENABLED'); + static const DjiKey maxRadius = DjiKey.flightController('MAX_FLIGHT_RADIUS'); + static const DjiKey maxRadiusRange = DjiKey.flightController('MAX_FLIGHT_RADIUS_RANGE'); + static const DjiKey novice = DjiKey.flightController('NOVICE_MODE_ENABLED'); + static const DjiKey multiMode = DjiKey.flightController('MULTI_MODE_OPEN'); + static const DjiKey leds = DjiKey.flightController('LEDS_ENABLED_SETTINGS'); + static const DjiKey escBeep = DjiKey.flightController('ESC_BEEPING_ENABLED'); + static const DjiKey imuCount = DjiKey.flightController('IMU_COUNT'); + static const DjiKey compassCount = DjiKey.flightController('COMPASS_COUNT'); + static const DjiKey lowBattery = DjiKey.flightController('LOW_BATTERY_WARNING_THRESHOLD'); + static const DjiKey criticalBattery = DjiKey.flightController('SERIOUS_LOW_BATTERY_WARNING_THRESHOLD'); + static const DjiKey fcSerial = DjiKey.flightController('SERIAL_NUMBER'); + + // Flight assistant (vision) + static const DjiKey collision = DjiKey.flightAssistant('COLLISION_AVOIDANCE_ENABLED'); + static const DjiKey activeAvoidance = DjiKey.flightAssistant('ACTIVE_OBSTACLE_AVOIDANCE_ENABLED'); + static const DjiKey upwardAvoidance = DjiKey.flightAssistant('UPWARDS_AVOIDANCE_ENABLED'); + static const DjiKey apas = DjiKey.flightAssistant('IS_ADVANCED_PILOT_ASSISTANT_SYSTEM_ENABLED'); + static const DjiKey rthAvoidance = DjiKey.flightAssistant('RTH_OBSTACLE_AVOIDANCE_ENABLED'); + static const DjiKey rthRemoteAvoidance = DjiKey.flightAssistant('RTH_REMOTE_OBSTACLE_AVOIDANCE_ENABLED'); + static const DjiKey vps = DjiKey.flightAssistant('VISION_ASSISTED_POSITIONING_ENABLED'); + static const DjiKey landingProtection = DjiKey.flightAssistant('LANDING_PROTECTION_ENABLED'); + static const DjiKey precisionLanding = DjiKey.flightAssistant('PRECISION_LANDING_ENABLED'); + static const DjiKey bottomLight = DjiKey.flightAssistant('BOTTOM_AUXILIARY_LIGHT_MODE'); + + // Remote controller + static const DjiKey stickMode = DjiKey.remoteController('AIRCRAFT_MAPPING_STYLE'); + static const DjiKey chargeMobile = DjiKey.remoteController('CHARGE_MOBILE_MODE'); + static const DjiKey gimbalDialSpeed = DjiKey.remoteController('GIMBAL_CONTROL_SPEED_COEFFICIENT'); + static const DjiKey pairingState = DjiKey.remoteController('PAIRING_STATE'); + static const DjiKey startPairing = DjiKey.remoteController('START_PAIRING'); + static const DjiKey stopPairing = DjiKey.remoteController('STOP_PAIRING'); + static const DjiKey rcName = DjiKey.remoteController('DISPLAY_NAME'); + static const DjiKey rcSerial = DjiKey.remoteController('SERIAL_NUMBER'); + static const DjiKey rcFirmware = DjiKey.remoteController('FIRMWARE_VERSION'); + + // Image transmission + static const DjiKey uplink = DjiKey.airLink('UPLINK_SIGNAL_QUALITY'); + static const DjiKey downlink = DjiKey.airLink('DOWNLINK_SIGNAL_QUALITY'); + static const DjiKey osChannelMode = DjiKey.ocuSync('CHANNEL_SELECTION_MODE'); + static const DjiKey osBand = DjiKey.ocuSync('OCUSYNC_FREQUENCY_BAND'); + static const DjiKey osBands = DjiKey.ocuSync('OCUSYNC_SUPPORTED_FREQUENCY_BANDS'); + static const DjiKey osBandwidth = DjiKey.ocuSync('BANDWIDTH'); + static const DjiKey osChannel = DjiKey.ocuSync('FREQUENCY_POINT_INDEX'); + static const DjiKey osChannelRange = DjiKey.ocuSync('FREQUENCY_POINT_INDEX_VALID_RANGE'); + static const DjiKey osDataRate = DjiKey.ocuSync('DYNAMIC_DATA_RATE'); + static const DjiKey lbChannelMode = DjiKey.lightbridge('CHANNEL_SELECTION_MODE'); + static const DjiKey lbBand = DjiKey.lightbridge('LB_FREQUENCY_BAND'); + static const DjiKey lbBands = DjiKey.lightbridge('LB_SUPPORTED_FREQUENCY_BANDS'); + static const DjiKey lbChannel = DjiKey.lightbridge('LB_CHANNEL'); + static const DjiKey lbChannelRange = DjiKey.lightbridge('CHANNEL_RANGE'); + static const DjiKey lbDataRate = DjiKey.lightbridge('LB_DATA_RATE'); + static const DjiKey wifiSsid = DjiKey.wifi('SSID'); + static const DjiKey wifiBand = DjiKey.wifi('WIFI_FREQUENCY_BAND'); + static const DjiKey wifiChannel = DjiKey.wifi('CHANNEL_NUMBER'); + + // Product + static const DjiKey firmwarePackage = DjiKey.product('FIRMWARE_PACKAGE_VERSION'); + + // Battery (per index) + static DjiKey battery(String key, int i) => DjiKey.battery(key, index: i); +} + +abstract final class SettingsTabs { + static const String safety = 'safety'; + static const String vision = 'vision'; + static const String rc = 'rc'; + static const String transmission = 'transmission'; + static const String battery = 'battery'; + static const String gimbal = 'gimbal'; + static const String general = 'general'; + + /// Custom row ids the sheet draws itself. + static const String homePoint = 'homePoint'; + static const String leds = 'leds'; + static const String sensors = 'sensors'; + static const String stickDiagram = 'stickDiagram'; + static const String gimbalList = 'gimbalList'; + static const String showRoute = 'showRoute'; + static const String clearRoute = 'clearRoute'; + static const String about = 'about'; + static const String developer = 'developer'; + + static bool _isOn(KeyState s, DjiKey k) => asBool(s.value(k)) == true; + static bool _manual(KeyState s, DjiKey k) => asString(s.value(k)) == 'MANUAL'; + + static const List<(String, String)> _channelModes = <(String, String)>[('AUTO', 'Auto'), ('MANUAL', 'Custom')]; + + /// The tabs for [p]; battery groups repeat per battery. + static List all(ProductProfile p) => [ + SettingsTab(id: safety, label: 'Safety', icon: 'shield', rows: _safety), + SettingsTab(id: vision, label: 'Vision', icon: 'obstacle', requires: HudFeatures.settingsVision, rows: _vision), + SettingsTab(id: rc, label: 'Remote', icon: 'rc', rows: _rc), + SettingsTab(id: transmission, label: 'Video link', icon: 'radio', rows: _transmission), + SettingsTab(id: battery, label: 'Battery', icon: 'battery', rows: _battery(p)), + SettingsTab( + id: gimbal, + label: 'Gimbal', + icon: 'gimbal', + requires: HudFeatures.gimbalSettings, + rows: const [CustomSpec(id: gimbalList)], + ), + SettingsTab(id: general, label: 'General', icon: 'settings', rows: _general), + ]; + + // ── Safety / MC ───────────────────────────────────────────────────────────── + static final List _safety = [ + const GroupSpec('RETURN TO HOME'), + const CustomSpec(id: homePoint), + const KeyNumberSpec( + id: 'rthAltitude', + label: 'RTH altitude', + key: SettingsKeys.goHomeHeight, + min: 20, + max: 500, + step: 10, + unit: 'm', + feet: true, + ), + const KeySwitchSpec( + id: 'smartRth', + label: 'Smart RTH', + hint: 'Return home on its own when the battery only just covers the way back', + key: SettingsKeys.smartRth, + ), + const KeyChoiceSpec( + id: 'failSafe', + label: 'RC signal lost', + key: SettingsKeys.failSafe, + options: <(String, String)>[('HOVER', 'Hover'), ('LANDING', 'Land'), ('GO_HOME', 'Return home')], + ), + const GroupSpec('LIMITS'), + const KeyNumberSpec( + id: 'maxAltitude', + label: 'Max altitude', + key: SettingsKeys.maxHeight, + rangeKey: SettingsKeys.maxHeightRange, + min: 20, + max: 500, + step: 10, + unit: 'm', + feet: true, + ), + const KeySwitchSpec(id: 'distanceLimit', label: 'Distance limit', key: SettingsKeys.maxRadiusEnabled), + KeyNumberSpec( + id: 'maxDistance', + label: 'Max distance', + key: SettingsKeys.maxRadius, + rangeKey: SettingsKeys.maxRadiusRange, + min: 15, + max: 8000, + step: 50, + unit: 'm', + feet: true, + visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.maxRadiusEnabled), + ), + const KeySwitchSpec( + id: 'beginner', + label: 'Beginner mode', + hint: 'Slow, and held within 30 m of home', + key: SettingsKeys.novice, + groundOnly: true, + ), + const GroupSpec('FLIGHT'), + const KeySwitchSpec( + id: 'multiMode', + label: 'Multiple flight modes', + hint: 'Lets the RC switch reach Sport / Atti / Tripod', + key: SettingsKeys.multiMode, + ), + const CustomSpec(id: leds, keys: [SettingsKeys.leds]), + const KeySwitchSpec(id: 'escBeep', label: 'ESC beeping', key: SettingsKeys.escBeep), + const GroupSpec('SENSORS'), + const CustomSpec(id: sensors, keys: [SettingsKeys.imuCount, SettingsKeys.compassCount]), + ]; + + // ── Vision ────────────────────────────────────────────────────────────────── + static final List _vision = [ + GroupSpec('OBSTACLE AVOIDANCE', requires: HudFeatures.settingsObstacle), + KeySwitchSpec( + id: 'collision', + label: 'Obstacle avoidance', + key: SettingsKeys.collision, + requires: HudFeatures.settingsObstacle, + ), + KeySwitchSpec( + id: 'activeAvoidance', + label: 'Active obstacle avoidance', + hint: 'Back away from obstacles that approach the aircraft', + key: SettingsKeys.activeAvoidance, + requires: HudFeatures.settingsObstacle, + ), + KeySwitchSpec( + id: 'upwardAvoidance', + label: 'Upward obstacle avoidance', + key: SettingsKeys.upwardAvoidance, + requires: HudFeatures.settingsObstacle, + ), + KeySwitchSpec( + id: 'apas', + label: 'APAS', + hint: 'Fly around obstacles instead of braking', + key: SettingsKeys.apas, + requires: HudFeatures.settingsObstacle, + ), + KeySwitchSpec( + id: 'rthAvoidance', + label: 'RTH obstacle check', + key: SettingsKeys.rthAvoidance, + requires: HudFeatures.settingsObstacle, + ), + KeySwitchSpec( + id: 'rthRemoteAvoidance', + label: 'RTH remote obstacle check', + key: SettingsKeys.rthRemoteAvoidance, + requires: HudFeatures.settingsObstacle, + ), + const GroupSpec('POSITIONING & LANDING'), + const KeySwitchSpec(id: 'vps', label: 'Vision positioning', key: SettingsKeys.vps), + const KeySwitchSpec( + id: 'landingProtection', + label: 'Landing protection', + hint: 'Checks the ground before landing', + key: SettingsKeys.landingProtection, + ), + const KeySwitchSpec( + id: 'precisionLanding', + label: 'Precision landing', + hint: 'RTH lands on the recorded take-off spot', + key: SettingsKeys.precisionLanding, + ), + const KeyChoiceSpec( + id: 'bottomLight', + label: 'Bottom auxiliary light', + key: SettingsKeys.bottomLight, + options: <(String, String)>[('AUTO', 'Auto'), ('ON', 'On'), ('OFF', 'Off')], + ), + ]; + + // ── Remote controller ─────────────────────────────────────────────────────── + static final List _rc = [ + const GroupSpec('STICKS'), + const KeyChoiceSpec( + id: 'stickMode', + label: 'Stick mode', + key: SettingsKeys.stickMode, + options: <(String, String)>[('STYLE_1', 'Mode 1'), ('STYLE_2', 'Mode 2'), ('STYLE_3', 'Mode 3')], + groundOnly: true, + ), + const CustomSpec(id: stickDiagram, keys: [SettingsKeys.stickMode]), + const KeyNumberSpec( + id: 'gimbalDialSpeed', + label: 'Gimbal dial speed', + key: SettingsKeys.gimbalDialSpeed, + min: 0, + max: 100, + step: 5, + ), + const GroupSpec('CONTROLLER'), + const KeyChoiceSpec( + id: 'chargeMobile', + label: 'Charge mobile device', + key: SettingsKeys.chargeMobile, + options: <(String, String)>[('NEVER', 'Never'), ('INTELLIGENT', 'Smart'), ('ALWAYS', 'Always')], + ), + const KeyReadoutSpec(id: 'pairingState', label: 'Linking', key: SettingsKeys.pairingState), + KeyActionSpec( + id: 'startPairing', + label: 'Link remote controller', + hint: 'Then press the link button on the aircraft', + key: SettingsKeys.startPairing, + button: 'Link', + groundOnly: true, + confirmTitle: 'Link remote controller', + confirmText: 'The current link drops until the aircraft is linked again. ' + 'After sliding, press the aircraft\'s link button.', + visibleWhen: (SettingsContext c) => asString(c.keys.value(SettingsKeys.pairingState)) != 'PAIRING', + ), + KeyActionSpec( + id: 'stopPairing', + label: 'Linking…', + key: SettingsKeys.stopPairing, + button: 'Stop', + visibleWhen: (SettingsContext c) => asString(c.keys.value(SettingsKeys.pairingState)) == 'PAIRING', + ), + const KeyReadoutSpec(id: 'rcName', label: 'Name', key: SettingsKeys.rcName), + const KeyReadoutSpec(id: 'rcSerial', label: 'Serial number', key: SettingsKeys.rcSerial), + const KeyReadoutSpec(id: 'rcFirmware', label: 'Firmware', key: SettingsKeys.rcFirmware), + ]; + + // ── Image transmission ────────────────────────────────────────────────────── + static final List _transmission = [ + const KeyReadoutSpec(id: 'uplink', label: 'Control signal', key: SettingsKeys.uplink, format: _percent), + const KeyReadoutSpec(id: 'downlink', label: 'Video signal', key: SettingsKeys.downlink, format: _percent), + // OcuSync + GroupSpec('OCUSYNC', requires: HudFeatures.linkOcuSync), + KeyReadoutSpec(id: 'osDataRate', label: 'Video bitrate', key: SettingsKeys.osDataRate, format: _mbps, requires: HudFeatures.linkOcuSync), + KeyChoiceSpec( + id: 'osChannelMode', + label: 'Channel mode', + key: SettingsKeys.osChannelMode, + options: _channelModes, + requires: HudFeatures.linkOcuSync, + ), + KeyChoiceSpec( + id: 'osBand', + label: 'Frequency band', + key: SettingsKeys.osBand, + supportedKey: SettingsKeys.osBands, + options: const <(String, String)>[ + ('FREQUENCY_BAND_DUAL', 'Dual'), + ('FREQUENCY_BAND_2_DOT_4_GHZ', '2.4 GHz'), + ('FREQUENCY_BAND_5_DOT_8_GHZ', '5.8 GHz'), + ('FREQUENCY_BAND_5_DOT_7_GHZ', '5.7 GHz'), + ], + requires: HudFeatures.linkOcuSync, + ), + KeyChoiceSpec( + id: 'osBandwidth', + label: 'Bandwidth', + key: SettingsKeys.osBandwidth, + options: const <(String, String)>[('Bandwidth10MHz', '10 MHz'), ('Bandwidth20MHz', '20 MHz'), ('Bandwidth40MHZ', '40 MHz')], + requires: HudFeatures.linkOcuSync, + visibleWhen: (SettingsContext c) => _manual(c.keys, SettingsKeys.osChannelMode), + ), + KeyNumberSpec( + id: 'osChannel', + label: 'Channel', + key: SettingsKeys.osChannel, + rangeKey: SettingsKeys.osChannelRange, + min: 0, + max: 100, + requires: HudFeatures.linkOcuSync, + visibleWhen: (SettingsContext c) => _manual(c.keys, SettingsKeys.osChannelMode), + ), + // Lightbridge + GroupSpec('LIGHTBRIDGE', requires: HudFeatures.linkLightbridge), + KeyChoiceSpec( + id: 'lbChannelMode', + label: 'Channel mode', + key: SettingsKeys.lbChannelMode, + options: _channelModes, + requires: HudFeatures.linkLightbridge, + ), + KeyChoiceSpec( + id: 'lbBand', + label: 'Frequency band', + key: SettingsKeys.lbBand, + supportedKey: SettingsKeys.lbBands, + options: const <(String, String)>[ + ('FREQUENCY_BAND_2_DOT_4_GHZ', '2.4 GHz'), + ('FREQUENCY_BAND_5_DOT_8_GHZ', '5.8 GHz'), + ('FREQUENCY_BAND_5_DOT_7_GHZ', '5.7 GHz'), + ], + requires: HudFeatures.linkLightbridge, + ), + KeyNumberSpec( + id: 'lbChannel', + label: 'Channel', + key: SettingsKeys.lbChannel, + rangeKey: SettingsKeys.lbChannelRange, + min: 0, + max: 40, + requires: HudFeatures.linkLightbridge, + visibleWhen: (SettingsContext c) => _manual(c.keys, SettingsKeys.lbChannelMode), + ), + KeyChoiceSpec( + id: 'lbDataRate', + label: 'Video bitrate', + key: SettingsKeys.lbDataRate, + options: const <(String, String)>[ + ('BANDWIDTH_4_MBPS', '4 Mbps'), + ('BANDWIDTH_6_MBPS', '6 Mbps'), + ('BANDWIDTH_8_MBPS', '8 Mbps'), + ('BANDWIDTH_10_MBPS', '10 Mbps'), + ], + requires: HudFeatures.linkLightbridge, + ), + // Wi-Fi + GroupSpec('WI-FI', requires: HudFeatures.linkWifi), + KeyReadoutSpec(id: 'wifiSsid', label: 'Network', key: SettingsKeys.wifiSsid, requires: HudFeatures.linkWifi), + KeyChoiceSpec( + id: 'wifiBand', + label: 'Frequency band', + key: SettingsKeys.wifiBand, + options: const <(String, String)>[ + ('FREQUENCY_BAND_2_DOT_4_GHZ', '2.4 GHz'), + ('FREQUENCY_BAND_5_GHZ', '5 GHz'), + ('FREQUENCY_BAND_DUAL', 'Dual'), + ], + requires: HudFeatures.linkWifi, + // 2.4-only hardware reports ONLY_2_DOT_4: nothing to choose. + visibleWhen: (SettingsContext c) => asString(c.keys.value(SettingsKeys.wifiBand)) != 'FREQUENCY_BAND_ONLY_2_DOT_4', + ), + KeyReadoutSpec(id: 'wifiChannel', label: 'Channel', key: SettingsKeys.wifiChannel, requires: HudFeatures.linkWifi), + ]; + + // ── Battery ───────────────────────────────────────────────────────────────── + static List _battery(ProductProfile p) { + final int n = p.batteryCount.clamp(1, 6); + return [ + const GroupSpec('WARNINGS'), + KeyNumberSpec( + id: 'lowBattery', + label: 'Low battery warning', + key: SettingsKeys.lowBattery, + min: 15, + max: 50, + unit: '%', + bounds: (KeyState s, int lo, int hi) { + final int? critical = asInt(s.value(SettingsKeys.criticalBattery)); + return (critical == null ? lo : (critical + 5).clamp(lo, hi), hi); + }, + ), + KeyNumberSpec( + id: 'criticalBattery', + label: 'Critical battery warning', + hint: 'The aircraft lands on its own at this level', + key: SettingsKeys.criticalBattery, + min: 10, + max: 45, + unit: '%', + bounds: (KeyState s, int lo, int hi) { + final int? low = asInt(s.value(SettingsKeys.lowBattery)); + return (lo, low == null ? hi : (low - 5).clamp(lo, hi)); + }, + ), + for (int i = 0; i < n; i++) ..._batteryRows(i, n), + ]; + } + + static List _batteryRows(int i, int n) { + DjiKey k(String key) => SettingsKeys.battery(key, i); + return [ + GroupSpec(n == 1 ? 'BATTERY' : 'BATTERY ${i + 1}'), + KeyReadoutSpec(id: 'cells$i', label: 'Cell voltages', key: k('CELL_VOLTAGES'), format: formatCells), + KeyReadoutSpec(id: 'temperature$i', label: 'Temperature', key: k('TEMPERATURE'), format: _celsius), + KeyReadoutSpec(id: 'discharges$i', label: 'Charge cycles', key: k('NUMBER_OF_DISCHARGES')), + KeyReadoutSpec(id: 'capacity$i', label: 'Full charge capacity', key: k('FULL_CHARGE_CAPACITY'), format: _mah), + KeyReadoutSpec(id: 'lifetime$i', label: 'Battery life', key: k('LIFETIME_REMAINING'), format: _percent), + KeyNumberSpec( + id: 'selfDischarge$i', + label: 'Self-discharge after', + hint: 'Storage: the battery drains to ~60 % after this many idle days', + key: k('SELF_DISCHARGE_IN_DAYS'), + min: 1, + max: 10, + unit: 'd', + ), + KeyReadoutSpec(id: 'batterySerial$i', label: 'Serial number', key: k('SERIAL_NUMBER')), + KeyReadoutSpec(id: 'batteryFirmware$i', label: 'Firmware', key: k('FIRMWARE_VERSION')), + ]; + } + + // ── General ───────────────────────────────────────────────────────────────── + static final List _general = [ + const GroupSpec('MAP'), + const CustomSpec(id: showRoute), + const CustomSpec(id: clearRoute), + const GroupSpec('ABOUT'), + const CustomSpec(id: about, keys: [SettingsKeys.firmwarePackage, SettingsKeys.fcSerial]), + const GroupSpec('DEVELOPER'), + const CustomSpec(id: developer), + ]; + + static String _percent(Object? v) => v is num ? '${v.round()} %' : formatValue(v); + static String _mbps(Object? v) => v is num ? '${v.toStringAsFixed(1)} Mbps' : formatValue(v); + static String _celsius(Object? v) => v is num ? '${v.toStringAsFixed(1)} °C' : formatValue(v); + static String _mah(Object? v) => v is num ? '${v.round()} mAh' : formatValue(v); +} diff --git a/Fly App/lib/fly/state/key_state.dart b/Fly App/lib/fly/state/key_state.dart new file mode 100644 index 0000000..b50cee8 --- /dev/null +++ b/Fly App/lib/fly/state/key_state.dart @@ -0,0 +1,112 @@ +import 'package:flutter/foundation.dart'; + +import '../../dji_service.dart'; +import 'settings_overlay.dart'; + +/// KeyManager values the settings sheet watches (plan P6), keyed by +/// [DjiKey.id], from the bridge's `key` events — overlaid with the sheet's +/// optimistic writes through [SettingsOverlay], like camera and gimbal settings. +/// +/// [supported] records `isKeySupported` per key: a row hides once its key is +/// known to be unsupported, and stays visible (as "—") while still unknown. +class KeyState extends ChangeNotifier with SettingsOverlay { + final Map _supported = {}; + + /// True / false once asked, null while unknown. + bool? supported(DjiKey key) => _supported[key.id]; + + Object? value(DjiKey key) => setting(key.id); + + bool pendingFor(DjiKey key) => isPending(key.id); + + void setSupported(DjiKey key, bool on) { + if (_supported[key.id] == on) return; + _supported[key.id] = on; + notifyListeners(); + } + + /// One `key` event: `{id, value}`. + void apply(String id, Object? value) { + replaceReported({...reportedSettings, id: value}); + notifyListeners(); + } + + /// A new aircraft answers `isKeySupported` afresh. + void forgetSupport() { + if (_supported.isEmpty) return; + _supported.clear(); + notifyListeners(); + } + + void clear() { + _supported.clear(); + clearSettings(); + notifyListeners(); + } +} + +/// The `listenKey` subscriptions one screen holds open — the settings sheet +/// while it is up. [watch] asks `isKeySupported` once per key and listens to +/// the supported ones; [dispose] cancels them all. +class KeySubscriptions { + KeySubscriptions(this.dji, this.state); + + final DjiService dji; + final KeyState state; + + final Set _asked = {}; + final Set _listening = {}; + bool _disposed = false; + + Future watch(Iterable keys) async { + await Future.wait(>[ + for (final DjiKey k in keys) + if (_asked.add(k)) _watchOne(k), + ]); + } + + Future _watchOne(DjiKey key) async { + bool ok; + try { + ok = await dji.isKeySupported(key); + } catch (_) { + ok = false; + } + if (_disposed) return; + state.setSupported(key, ok); + if (!ok) return; + try { + final String id = await dji.listenKey(key); + if (_disposed) { + await dji.cancelKey(id); + } else { + _listening.add(id); + } + } catch (_) { + // The row shows "—" until a value arrives some other way. + } + } + + /// Asks every key again — after a reconnect or product change. + Future rewatch(Iterable keys) async { + await _cancelAll(); + _asked.clear(); + state.forgetSupport(); + await watch(keys); + } + + Future _cancelAll() async { + final List ids = _listening.toList(); + _listening.clear(); + for (final String id in ids) { + try { + await dji.cancelKey(id); + } catch (_) {} + } + } + + void dispose() { + _disposed = true; + _cancelAll(); + } +} diff --git a/Fly App/lib/fly/telemetry_reducer.dart b/Fly App/lib/fly/telemetry_reducer.dart index 962c7e0..045fd87 100644 --- a/Fly App/lib/fly/telemetry_reducer.dart +++ b/Fly App/lib/fly/telemetry_reducer.dart @@ -41,6 +41,10 @@ class TelemetryReducer { case 'diagnostics': if (model.connected) model.alerts.apply(asMapList(event['items'])); return true; + case 'key': + final String? id = asString(event['id']); + if (model.connected && id != null) model.keys.apply(id, event['value']); + return true; case 'mission': model.mission.apply(event); return true; diff --git a/Fly App/lib/ui/flight_control_page.dart b/Fly App/lib/ui/flight_control_page.dart index ed3e867..9e64fee 100644 --- a/Fly App/lib/ui/flight_control_page.dart +++ b/Fly App/lib/ui/flight_control_page.dart @@ -11,9 +11,9 @@ import '../theme.dart'; import 'camera_settings_page.dart'; import 'capture_modes_page.dart'; import 'dji_video_view.dart'; +import 'fly/panels/settings/settings_sheet.dart'; import 'map_page.dart'; import 'pv_icons.dart'; -import 'settings_menu_page.dart'; /// Landscape live-flight overlay — mirrors the v2 "Main flight view" mockup. /// Live camera feed behind a glass HUD; real telemetry bound throughout, and @@ -167,9 +167,7 @@ class _FlightControlPageState extends State { } void _openSettingsMenu() { - Navigator.of(context).push(MaterialPageRoute( - builder: (_) => SettingsMenuPage(model: _m, dji: _dji), - )); + showFlySettings(context, model: _m, dji: _dji, onError: (String label, Object e) => _snack('$label failed: $e')); } void _openMap() { diff --git a/Fly App/lib/ui/fly/bottom/minimap.dart b/Fly App/lib/ui/fly/bottom/minimap.dart index 39d927a..f7bb1e2 100644 --- a/Fly App/lib/ui/fly/bottom/minimap.dart +++ b/Fly App/lib/ui/fly/bottom/minimap.dart @@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart' show LatLng; import '../../../flight_model.dart'; import '../../../fly/flight_trail.dart'; +import '../../../fly/fly_prefs.dart'; import '../../../fly/state/aircraft_state.dart'; import '../../../theme.dart'; import '../../pv_icons.dart'; @@ -127,10 +128,10 @@ class _FlyMapState extends State { userAgentPackageName: 'com.pilotvault.flyapp', ), ListenableBuilder( - listenable: widget.trail, + listenable: Listenable.merge([widget.trail, FlyPrefs.showRoute]), builder: (BuildContext context, _) { final List pts = widget.trail.points; - if (pts.length < 2) return const SizedBox.shrink(); + if (pts.length < 2 || !FlyPrefs.showRoute.value) return const SizedBox.shrink(); return PolylineLayer(polylines: >[ Polyline(points: pts, strokeWidth: 2.5, color: const Color(0xFFF4C542)), ]); diff --git a/Fly App/lib/ui/fly/fly_screen.dart b/Fly App/lib/ui/fly/fly_screen.dart index a948c98..5ea6747 100644 --- a/Fly App/lib/ui/fly/fly_screen.dart +++ b/Fly App/lib/ui/fly/fly_screen.dart @@ -11,7 +11,6 @@ import '../../theme.dart'; import '../album_page.dart'; import '../capture_modes_page.dart'; import '../pv_icons.dart'; -import '../settings_menu_page.dart'; import 'bottom/attitude_ball.dart'; import 'bottom/minimap.dart'; import 'bottom/telemetry_strip.dart'; @@ -29,6 +28,7 @@ import 'panels/aircraft_status_sheet.dart'; import 'panels/camera_menu/camera_menu_sheet.dart'; import 'panels/camera_quick_exposure.dart'; import 'panels/gimbal_sheet.dart'; +import 'panels/settings/settings_sheet.dart'; import 'right/camera_column.dart'; import 'right/zoom_column.dart'; import 'top/camera_info_strip.dart'; @@ -300,7 +300,14 @@ class _FlyScreenState extends State { model: _m, onBack: () => Navigator.of(context).maybePop(), onStatus: () => _openStatus(context), - onMore: () => _push(SettingsMenuPage(model: _m, dji: _dji)), + onMore: () => showFlySettings( + context, + model: _m, + dji: _dji, + onError: _error, + onOpenStatus: () => _openStatus(context), + onClearRoute: _trail.clear, + ), ), ), Positioned(left: 0, right: 0, top: StatusBar.height + 3, child: SmartBatteryBar(model: _m)), diff --git a/Fly App/lib/ui/fly/panels/camera_menu/camera_rows.dart b/Fly App/lib/ui/fly/panels/camera_menu/camera_rows.dart index 0ee226f..fae2bb2 100644 --- a/Fly App/lib/ui/fly/panels/camera_menu/camera_rows.dart +++ b/Fly App/lib/ui/fly/panels/camera_menu/camera_rows.dart @@ -85,9 +85,11 @@ class ChoiceTile extends StatefulWidget { required this.selected, required this.onSelect, this.pending = false, + this.hint, }); final String label; + final String? hint; /// (value, label) pairs. final List<(String, String)> options; @@ -113,6 +115,7 @@ class _ChoiceTileState extends State { return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _RowFrame( label: widget.label, + hint: widget.hint, onTap: widget.options.isEmpty ? null : () => setState(() => _open = !_open), trailing: Row(mainAxisSize: MainAxisSize.min, children: [ if (widget.pending) @@ -225,15 +228,20 @@ class _RowFrame extends StatelessWidget { /// An on / off row; a null [value] (not reported yet) shows as off. class SwitchTile extends StatelessWidget { - const SwitchTile({super.key, required this.label, required this.value, required this.onChanged}); + const SwitchTile({super.key, required this.label, required this.value, required this.onChanged, this.hint, this.pending = false}); final String label; final bool? value; final ValueChanged onChanged; + final String? hint; + + /// A write is waiting for the component's answer. + final bool pending; @override Widget build(BuildContext context) => _RowFrame( label: label, + hint: hint, onTap: () => onChanged(!(value ?? false)), trailing: SizedBox( height: 24, @@ -241,7 +249,7 @@ class SwitchTile extends StatelessWidget { value: value ?? false, onChanged: onChanged, activeThumbColor: Colors.white, - activeTrackColor: Glass.accent, + activeTrackColor: pending ? HudText.dim : Glass.accent, inactiveThumbColor: HudText.dim, inactiveTrackColor: HudColors.off, ), @@ -296,6 +304,7 @@ class StepperTile extends StatelessWidget { required this.onStep, this.hint, this.pending = false, + this.onValueTap, }); /// Keys the buttons as `step--minus` / `step--plus`. @@ -308,6 +317,9 @@ class StepperTile extends StatelessWidget { final String? hint; final bool pending; + /// Tapping the value (keyed `value-`) — to type a far-off number. + final VoidCallback? onValueTap; + @override Widget build(BuildContext context) { Widget step(String label, int dir) { @@ -335,12 +347,20 @@ class StepperTile extends StatelessWidget { hint: hint, trailing: Row(mainAxisSize: MainAxisSize.min, children: [ step('−', -1), - SizedBox( - width: 66, - child: Text( - shown, - textAlign: TextAlign.center, - style: HudText.mono.copyWith(color: pending ? HudText.dim : Glass.accent, fontWeight: FontWeight.w700), + GestureDetector( + key: ValueKey('value-$id'), + behavior: HitTestBehavior.opaque, + onTap: onValueTap, + child: SizedBox( + width: 66, + height: 28, + child: Center( + child: Text( + shown, + textAlign: TextAlign.center, + style: HudText.mono.copyWith(color: pending ? HudText.dim : Glass.accent, fontWeight: FontWeight.w700), + ), + ), ), ), step('+', 1), @@ -392,6 +412,34 @@ class _ActionTile extends StatelessWidget { } } +/// A read-only label + value row. +class ValueTile extends StatelessWidget { + const ValueTile({super.key, required this.label, required this.value, this.hint, this.onTap, this.valueColor}); + + final String label; + final String value; + final String? hint; + final VoidCallback? onTap; + final Color? valueColor; + + @override + Widget build(BuildContext context) => _RowFrame( + label: label, + hint: hint, + onTap: onTap, + trailing: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: Text( + value, + textAlign: TextAlign.right, + overflow: TextOverflow.ellipsis, + maxLines: 2, + style: HudText.mono.copyWith(color: valueColor ?? (onTap != null ? Glass.accent : HudText.dim)), + ), + ), + ); +} + /// A row with one outlined button, keyed `action-`; a null [onTap] greys it out. class ButtonTile extends StatelessWidget { const ButtonTile({ diff --git a/Fly App/lib/ui/fly/panels/settings/setting_tiles.dart b/Fly App/lib/ui/fly/panels/settings/setting_tiles.dart new file mode 100644 index 0000000..cf316b7 --- /dev/null +++ b/Fly App/lib/ui/fly/panels/settings/setting_tiles.dart @@ -0,0 +1,296 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../dji_service.dart'; +import '../../../../fly/settings/settings_spec.dart'; +import '../../../../fly/state/key_state.dart'; +import '../../../../theme.dart'; +import '../../dialogs/slide_to_confirm.dart'; +import '../../hud_kit.dart'; +import '../camera_menu/camera_rows.dart'; +import '../camera_write.dart'; + +/// Writes one KeyManager setting the way every camera / gimbal row does: show +/// it at once, send, roll back with [onError] if the aircraft refuses. +Future writeKeySetting( + KeyState keys, + DjiService dji, + DjiKey key, + Object value, { + required String label, + required void Function(String label, Object error) onError, +}) => + writeCameraSetting( + keys, + label: label, + pending: {key.id: value}, + send: () => dji.setKey(key, value), + onError: onError, + ); + +/// Renders one [SettingSpec] with the shared row kit. [custom] draws the +/// sheet's own rows ([CustomSpec]). +class SettingRowTile extends StatelessWidget { + const SettingRowTile({ + super.key, + required this.spec, + required this.settings, + required this.dji, + required this.onError, + required this.custom, + }); + + final SettingSpec spec; + final SettingsContext settings; + final DjiService dji; + final void Function(String label, Object error) onError; + final Widget Function(CustomSpec spec) custom; + + KeyState get _keys => settings.keys; + + Future _write(DjiKey key, Object value) => + writeKeySetting(_keys, dji, key, value, label: spec.label, onError: onError); + + @override + Widget build(BuildContext context) { + final String? locked = spec.lockedReason(settings); + final SettingSpec s = spec; + final Widget tile = switch (s) { + GroupSpec() => Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), + child: Text(s.label, style: HudText.label), + ), + KeySwitchSpec() => SwitchTile( + key: ValueKey('row-${s.id}'), + label: s.label, + hint: locked ?? s.hint, + value: s.current(_keys), + pending: _keys.pendingFor(s.key), + onChanged: (bool on) => _write(s.key, on), + ), + KeyChoiceSpec() => ChoiceTile( + key: ValueKey('row-${s.id}'), + label: s.label, + hint: locked ?? s.hint, + options: s.optionsFor(_keys), + selected: s.current(_keys), + pending: _keys.pendingFor(s.key), + onSelect: (String v) => _write(s.key, v), + ), + KeyNumberSpec() => _NumberTile(spec: s, keys: _keys, locked: locked, onSet: (int v) => _write(s.key, v)), + KeyReadoutSpec() => ValueTile(key: ValueKey('row-${s.id}'), label: s.label, hint: s.hint, value: s.shown(_keys)), + KeyActionSpec() => ButtonTile( + key: ValueKey('row-${s.id}'), + id: s.id, + label: s.label, + hint: locked ?? s.hint, + button: s.button, + onTap: locked == null ? () => _runAction(context, s) : null, + ), + CustomSpec() => custom(s), + }; + // Steppers and buttons grey their own controls; the rest take no input. + if (locked != null && (s is KeySwitchSpec || s is KeyChoiceSpec)) { + return IgnorePointer(child: Opacity(opacity: 0.5, child: tile)); + } + return tile; + } + + Future _runAction(BuildContext context, KeyActionSpec a) async { + if (a.confirmTitle != null) { + final SlideResult? ok = await showSlideToConfirm( + context, + SlideConfirmSpec(icon: 'link', title: a.confirmTitle!, description: a.confirmText ?? '', sliderLabel: 'Slide to confirm'), + ); + if (ok == null) return; + } + try { + await dji.performKeyAction(a.key); + } catch (e) { + onError(a.label, e); + } + } +} + +class _NumberTile extends StatelessWidget { + const _NumberTile({required this.spec, required this.keys, required this.locked, required this.onSet}); + + final KeyNumberSpec spec; + final KeyState keys; + final String? locked; + final ValueChanged onSet; + + String _shown(int? v) => v == null ? '—' : '$v${spec.unit.isEmpty ? '' : ' ${spec.unit}'}'; + + @override + Widget build(BuildContext context) { + final int? v = spec.current(keys); + final int? down = spec.stepped(keys, -1), up = spec.stepped(keys, 1); + final String? hint = locked ?? spec.hint ?? (spec.feet && v != null ? feetHint(v) : null); + return StepperTile( + key: ValueKey('row-${spec.id}'), + id: spec.id, + label: spec.label, + hint: hint, + shown: _shown(v), + pending: keys.pendingFor(spec.key), + canDown: locked == null && down != null, + canUp: locked == null && up != null, + onStep: (int dir) { + final int? next = dir > 0 ? up : down; + if (next != null) onSet(next); + }, + onValueTap: locked == null && v != null ? () => _type(context, v) : null, + ); + } + + /// Types a value — distance limits run to thousands of metres. + Future _type(BuildContext context, int current) async { + final (int lo, int hi) = spec.range(keys); + final int? picked = await showDialog( + context: context, + builder: (BuildContext ctx) => _NumberDialog(label: spec.label, unit: spec.unit, initial: current, min: lo, max: hi), + ); + if (picked != null && picked != current) onSet(picked); + } +} + +class _NumberDialog extends StatefulWidget { + const _NumberDialog({required this.label, required this.unit, required this.initial, required this.min, required this.max}); + + final String label; + final String unit; + final int initial; + final int min; + final int max; + + @override + State<_NumberDialog> createState() => _NumberDialogState(); +} + +class _NumberDialogState extends State<_NumberDialog> { + late final TextEditingController _text = TextEditingController(text: '${widget.initial}'); + + @override + void dispose() { + _text.dispose(); + super.dispose(); + } + + void _submit() { + final int? n = int.tryParse(_text.text.trim()); + Navigator.of(context).pop(n?.clamp(widget.min, widget.max)); + } + + @override + Widget build(BuildContext context) { + final String unit = widget.unit.isEmpty ? '' : ' ${widget.unit}'; + return AlertDialog( + backgroundColor: const Color(0xFF10203F), + title: Text(widget.label, style: HudText.sans.copyWith(fontSize: 15)), + content: TextField( + key: const ValueKey('number-input'), + controller: _text, + autofocus: true, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + style: HudText.value.copyWith(fontSize: 20), + decoration: InputDecoration( + suffixText: widget.unit, + helperText: '${widget.min} – ${widget.max}$unit', + helperStyle: HudText.label, + ), + onSubmitted: (_) => _submit(), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), + FilledButton(key: const ValueKey('number-set'), onPressed: _submit, child: const Text('Set')), + ], + ); + } +} + +/// GO 4's stick-mode picture: what each stick does in Mode 1 / 2 / 3. +class StickModeDiagram extends StatelessWidget { + const StickModeDiagram({super.key, required this.mode}); + + /// `STYLE_1` / `STYLE_2` / `STYLE_3`; anything else draws nothing. + final String? mode; + + static const (String, String) _pitchRoll = ('Forward / back', 'Left / right'); + static const (String, String) _throttleYaw = ('Up / down', 'Turn'); + static const (String, String) _pitchYaw = ('Forward / back', 'Turn'); + static const (String, String) _throttleRoll = ('Up / down', 'Left / right'); + + /// (left stick, right stick), each (vertical, horizontal). + static ((String, String), (String, String))? layoutFor(String? mode) => switch (mode) { + 'STYLE_1' => (_pitchYaw, _throttleRoll), + 'STYLE_2' => (_throttleYaw, _pitchRoll), + 'STYLE_3' => (_pitchRoll, _throttleYaw), + _ => null, + }; + + @override + Widget build(BuildContext context) { + final ((String, String), (String, String))? l = layoutFor(mode); + if (l == null) return const SizedBox.shrink(); + return Padding( + key: const ValueKey('stick-diagram'), + padding: const EdgeInsets.fromLTRB(16, 2, 16, 10), + child: Row(children: [ + Expanded(child: _Stick(title: 'LEFT STICK', axes: l.$1)), + const SizedBox(width: 10), + Expanded(child: _Stick(title: 'RIGHT STICK', axes: l.$2)), + ]), + ); + } +} + +class _Stick extends StatelessWidget { + const _Stick({required this.title, required this.axes}); + + final String title; + final (String, String) axes; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(8), border: Border.all(color: Glass.hairline)), + child: Row(children: [ + const CustomPaint(size: Size(30, 30), painter: _StickPainter()), + const SizedBox(width: 8), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(title, style: HudText.label), + const SizedBox(height: 3), + Text('Up-down: ${axes.$1}', style: HudText.sans.copyWith(fontSize: 10.5, fontWeight: FontWeight.w500)), + const SizedBox(height: 2), + Text('Sideways: ${axes.$2}', style: HudText.sans.copyWith(fontSize: 10.5, fontWeight: FontWeight.w500)), + ]), + ), + ]), + ); + } +} + +class _StickPainter extends CustomPainter { + const _StickPainter(); + + @override + void paint(Canvas canvas, Size size) { + final Offset c = size.center(Offset.zero); + final double r = size.shortestSide / 2; + final Paint line = Paint() + ..color = HudText.dim + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2; + canvas.drawCircle(c, r - 1, line); + canvas.drawLine(c.translate(0, -r + 4), c.translate(0, r - 4), line); + canvas.drawLine(c.translate(-r + 4, 0), c.translate(r - 4, 0), line); + canvas.drawCircle(c, 4, Paint()..color = Glass.accent); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/Fly App/lib/ui/fly/panels/settings/settings_sheet.dart b/Fly App/lib/ui/fly/panels/settings/settings_sheet.dart new file mode 100644 index 0000000..7551f3f --- /dev/null +++ b/Fly App/lib/ui/fly/panels/settings/settings_sheet.dart @@ -0,0 +1,436 @@ +import 'package:flutter/material.dart'; + +import '../../../../dji_service.dart'; +import '../../../../flight_model.dart'; +import '../../../../fly/fly_prefs.dart'; +import '../../../../fly/hud_features.dart'; +import '../../../../fly/product_profile.dart'; +import '../../../../fly/settings/settings_spec.dart'; +import '../../../../fly/settings/settings_tabs.dart'; +import '../../../../fly/state/key_state.dart'; +import '../../../../fly/state/parse.dart'; +import '../../../../theme.dart'; +import '../../../pv_icons.dart'; +import '../../debug/product_profile_page.dart'; +import '../../debug/telemetry_inspector_page.dart'; +import '../../dialogs/slide_to_confirm.dart'; +import '../../hud_kit.dart'; +import '../camera_menu/camera_rows.dart'; +import '../gimbal_sheet.dart'; +import 'setting_tiles.dart'; + +/// Opens the aircraft settings (plan P6, GO 4's "•••") as a right-side glass +/// sheet over the live view, so the video stays visible. +/// +/// [onOpenStatus] opens the Aircraft Status sheet (Sensors → calibrate); +/// [onClearRoute] clears the map's flown trail. Either may be null where the +/// host has no such thing. +Future showFlySettings( + BuildContext context, { + required FlightModel model, + required DjiService dji, + required void Function(String label, Object error) onError, + String initialTab = SettingsTabs.safety, + VoidCallback? onOpenStatus, + VoidCallback? onClearRoute, +}) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Settings', + barrierColor: const Color(0x55050A14), + transitionDuration: const Duration(milliseconds: 200), + pageBuilder: (BuildContext context, _, __) => Align( + alignment: Alignment.centerRight, + child: FlySettingsSheet( + model: model, + dji: dji, + onError: onError, + initialTab: initialTab, + onOpenStatus: onOpenStatus, + onClearRoute: onClearRoute, + ), + ), + transitionBuilder: (BuildContext context, Animation a, _, Widget child) => SlideTransition( + position: Tween(begin: const Offset(1, 0), end: Offset.zero).animate(CurvedAnimation(parent: a, curve: Curves.easeOut)), + child: child, + ), + ); +} + +class FlySettingsSheet extends StatefulWidget { + const FlySettingsSheet({ + super.key, + required this.model, + required this.dji, + required this.onError, + this.initialTab = SettingsTabs.safety, + this.onOpenStatus, + this.onClearRoute, + }); + + final FlightModel model; + final DjiService dji; + final void Function(String label, Object error) onError; + final String initialTab; + final VoidCallback? onOpenStatus; + final VoidCallback? onClearRoute; + + static const double maxWidth = 540; + static const double railWidth = 112; + + @override + State createState() => _FlySettingsSheetState(); +} + +class _FlySettingsSheetState extends State { + late final KeySubscriptions _subs = KeySubscriptions(widget.dji, widget.model.keys); + late String _tab = widget.initialTab; + + // What the sheet rebuilds on — not every 10 Hz telemetry tick. + late ProductProfile _profile = widget.model.profile; + late bool _connected = widget.model.connected; + late bool _airborne = _isAirborne; + + /// The profile the current subscriptions were made for. + ProductProfile? _watchedFor; + + FlightModel get _m => widget.model; + KeyState get _keys => _m.keys; + + bool get _isAirborne => _m.aircraft.isFlying == true || _m.aircraft.motorsOn == true; + + @override + void initState() { + super.initState(); + _m.addListener(_onModel); + _watch(); + } + + @override + void dispose() { + _m.removeListener(_onModel); + _subs.dispose(); + super.dispose(); + } + + void _onModel() { + final bool airborne = _isAirborne; + if (identical(_profile, _m.profile) && _connected == _m.connected && _airborne == airborne) return; + setState(() { + _profile = _m.profile; + _connected = _m.connected; + _airborne = airborne; + }); + _watch(); + } + + List get _tabs => [ + for (final SettingsTab t in SettingsTabs.all(_profile)) + if (t.visible(_profile)) t, + ]; + + SettingsTab get _current { + final List tabs = _tabs; + return tabs.firstWhere((SettingsTab t) => t.id == _tab, orElse: () => tabs.first); + } + + /// Subscribes to the open tab's keys; a new aircraft asks every key afresh. + /// Without a connection nothing is asked, so no row hides as "unsupported". + void _watch() { + if (!_connected) { + _watchedFor = null; + return; + } + final Iterable keys = _current.watched; + if (!identical(_watchedFor, _profile)) { + final bool fresh = _watchedFor == null; + _watchedFor = _profile; + fresh ? _subs.watch(keys) : _subs.rewatch(keys); + } else { + _subs.watch(keys); + } + } + + void _select(String id) { + setState(() => _tab = id); + _watch(); + } + + @override + Widget build(BuildContext context) { + final double width = (MediaQuery.sizeOf(context).width * 0.7).clamp(360.0, FlySettingsSheet.maxWidth); + final SettingsTab tab = _current; + return ProductProfileScope( + profile: _profile, + child: Material( + color: const Color(0xF20B1426), + child: SizedBox( + width: width, + height: MediaQuery.sizeOf(context).height, + child: SafeArea( + left: false, + child: Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + _rail(tab), + const VerticalDivider(width: 1, color: Glass.hairline), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + _header(tab), + const Divider(height: 1, color: Glass.hairline), + Expanded(child: _body(tab)), + ]), + ), + ]), + ), + ), + ), + ); + } + + Widget _rail(SettingsTab current) { + return SizedBox( + width: FlySettingsSheet.railWidth, + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6), + children: [ + for (final SettingsTab t in _tabs) + GestureDetector( + key: ValueKey('settings-tab-${t.id}'), + behavior: HitTestBehavior.opaque, + onTap: () => _select(t.id), + child: Container( + height: 36, + margin: const EdgeInsets.only(bottom: 4), + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: t.id == current.id ? Glass.accent : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row(children: [ + PVIcon(t.icon, size: 15, stroke: 1.7, color: Glass.ink), + const SizedBox(width: 7), + Flexible( + child: Text(t.label, overflow: TextOverflow.ellipsis, style: HudText.sans.copyWith(fontSize: 11.5)), + ), + ]), + ), + ), + ], + ), + ); + } + + Widget _header(SettingsTab tab) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 4, 4), + child: Row(children: [ + Expanded(child: Text(tab.label, style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))), + if (!_connected) Text('NOT CONNECTED', style: HudText.label.copyWith(color: HudColors.caution)), + IconButton( + key: const ValueKey('settings-close'), + onPressed: () => Navigator.of(context).pop(), + icon: const PVIcon('close', size: 18, color: HudText.dim), + ), + ]), + ); + } + + Widget _body(SettingsTab tab) { + if (tab.id == SettingsTabs.gimbal) { + return GimbalSettingsList(model: _m, dji: widget.dji, onError: widget.onError); + } + return ListenableBuilder( + listenable: _keys, + builder: (BuildContext context, _) { + final SettingsContext c = SettingsContext(profile: _profile, keys: _keys, aircraft: _m.aircraft); + final List rows = [for (final SettingSpec r in tab.rows) if (r.visible(c)) r]; + return ListView( + key: ValueKey('settings-list-${tab.id}'), + padding: const EdgeInsets.only(bottom: 16), + children: [ + for (int i = 0; i < rows.length; i++) + // A group header with nothing under it (all rows unsupported) drops out. + if (rows[i] is! GroupSpec || (i + 1 < rows.length && rows[i + 1] is! GroupSpec)) + SettingRowTile( + key: ValueKey('spec-${rows[i].id}'), + spec: rows[i], + settings: c, + dji: widget.dji, + onError: widget.onError, + custom: (CustomSpec s) => _custom(s, c), + ), + ], + ); + }, + ); + } + + // ── The sheet's own rows ──────────────────────────────────────────────────── + Widget _custom(CustomSpec s, SettingsContext c) => switch (s.id) { + SettingsTabs.homePoint => _homePoint(c), + SettingsTabs.leds => _leds(c), + SettingsTabs.sensors => _sensors(c), + SettingsTabs.stickDiagram => StickModeDiagram(mode: asString(_keys.value(SettingsKeys.stickMode))), + SettingsTabs.showRoute => ValueListenableBuilder( + valueListenable: FlyPrefs.showRoute, + builder: (BuildContext context, bool on, _) => + SwitchTile(key: const ValueKey('row-showRoute'), label: 'Show flight route', value: on, onChanged: FlyPrefs.setShowRoute), + ), + SettingsTabs.clearRoute => widget.onClearRoute == null + ? const SizedBox.shrink() + : ButtonTile(id: 'clearRoute', label: 'Clear flight route', button: 'Clear', onTap: widget.onClearRoute), + SettingsTabs.about => _about(), + SettingsTabs.developer => _developer(), + _ => const SizedBox.shrink(), + }; + + Future _setHome({required bool toPilot}) async { + final SlideResult? ok = await showSlideToConfirm( + context, + SlideConfirmSpec( + icon: 'home', + title: toPilot ? 'Home point to your position' : 'Home point to the aircraft', + description: toPilot + ? 'Return-to-home will fly to where this phone is now.' + : 'Return-to-home will fly to where the aircraft is now.', + sliderLabel: 'Slide to set', + ), + ); + if (ok == null) return; + try { + if (toPilot) { + await widget.dji.setHomeLocation(_m.phoneLatitude!, _m.phoneLongitude!); + } else { + await widget.dji.setHomeToCurrent(); + } + } catch (e) { + widget.onError('Home point', e); + } + } + + Widget _homePoint(SettingsContext c) { + final bool hasGps = _m.aircraft.latitude != null && _m.aircraft.longitude != null; + final bool hasPhone = _m.phoneLatitude != null && _m.phoneLongitude != null; + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + ButtonTile( + id: 'homeAircraft', + label: 'Home point: aircraft', + hint: !c.connected ? 'Connect the aircraft to change' : (hasGps ? null : 'Waiting for the aircraft\'s GPS'), + button: 'Set', + onTap: c.connected && hasGps ? () => _setHome(toPilot: false) : null, + ), + ButtonTile( + id: 'homePilot', + label: 'Home point: pilot', + hint: !c.connected ? 'Connect the aircraft to change' : (hasPhone ? null : 'This phone has no location fix'), + button: 'Set', + onTap: c.connected && hasPhone ? () => _setHome(toPilot: true) : null, + ), + ]); + } + + /// GO 4's "Turn on aircraft arm LEDs": the front LEDs, leaving the rest alone. + Widget _leds(SettingsContext c) { + if (c.keys.supported(SettingsKeys.leds) == false) return const SizedBox.shrink(); + final Map? v = asMap(_keys.value(SettingsKeys.leds)); + final String? locked = c.connected ? null : 'Connect the aircraft to change'; + final Widget tile = SwitchTile( + key: const ValueKey('row-leds'), + label: 'Front arm LEDs', + hint: locked ?? 'Off hides the aircraft at night — check local rules', + value: asBool(v?['front']), + pending: _keys.pendingFor(SettingsKeys.leds), + onChanged: (bool on) => writeKeySetting( + _keys, + widget.dji, + SettingsKeys.leds, + {...?v, 'front': on}, + label: 'Arm LEDs', + onError: widget.onError, + ), + ); + return locked == null ? tile : IgnorePointer(child: Opacity(opacity: 0.5, child: tile)); + } + + Widget _sensors(SettingsContext c) { + final int? imus = asInt(_keys.value(SettingsKeys.imuCount)) ?? _profile.flightController?.imuCount; + final int? compasses = asInt(_keys.value(SettingsKeys.compassCount)) ?? _profile.flightController?.compassCount; + String count(int? n, String one) => n == null ? '' : (n == 1 ? '1 $one · ' : '$n ${one}s · '); + final bool? compassError = _m.aircraft.compassHasError; + final bool? preheating = _m.aircraft.imuPreheating; + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + ValueTile( + key: const ValueKey('row-imu'), + label: 'IMU', + value: '${count(imus, 'unit')}${switch (preheating) { + true => 'Warming up', + false => 'Normal', + null => '—', + }}', + valueColor: preheating == true ? HudColors.caution : null, + ), + ValueTile( + key: const ValueKey('row-compass'), + label: 'Compass', + value: '${count(compasses, 'unit')}${switch (compassError) { + true => 'Error', + false => 'Normal', + null => '—', + }}', + valueColor: compassError == true ? HudColors.danger : null, + ), + if (widget.onOpenStatus != null) + ButtonTile( + id: 'openStatus', + label: 'Aircraft status & calibration', + hint: 'Compass and IMU calibration', + button: 'Open', + onTap: widget.onOpenStatus, + ), + ]); + } + + Widget _about() { + String or(String? a, Object? b) => a ?? (b == null ? '—' : formatValue(b)); + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + ValueTile(label: 'Aircraft', value: _m.model ?? '—'), + ValueTile(label: 'Flight controller SN', value: or(_m.flightControllerSerial, _keys.value(SettingsKeys.fcSerial))), + ValueTile(label: 'Aircraft firmware', value: or(_m.firmwareVersion, _keys.value(SettingsKeys.firmwarePackage))), + ValueTile(label: 'Remote firmware', value: _m.controllerFirmwareVersion ?? '—'), + ValueTile(label: 'DJI Mobile SDK', value: _m.sdkVersion), + ]); + } + + void _push(Widget page) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + + Widget _developer() { + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + ValueTile( + key: const ValueKey('row-capabilityProfile'), + label: 'Capability profile', + value: 'View', + onTap: () => _push(ProductProfilePage(model: _m, dji: widget.dji)), + ), + ValueTile( + key: const ValueKey('row-telemetryInspector'), + label: 'Telemetry inspector', + value: 'View', + onTap: () => _push(TelemetryInspectorPage(model: _m, dji: widget.dji)), + ), + ValueListenableBuilder( + valueListenable: FlyPrefs.classicHud, + builder: (BuildContext context, bool on, _) => SwitchTile( + label: 'Classic flight screen', + hint: 'Takes effect the next time the flight screen opens', + value: on, + onChanged: FlyPrefs.setClassicHud, + ), + ), + ValueListenableBuilder( + valueListenable: FlyPrefs.perfOverlay, + builder: (BuildContext context, bool on, _) => + SwitchTile(label: 'HUD performance overlay', value: on, onChanged: FlyPrefs.setPerfOverlay), + ), + ]); + } +} diff --git a/Fly App/lib/ui/fly/top/status_bar.dart b/Fly App/lib/ui/fly/top/status_bar.dart index 4be036c..7dfb4a0 100644 --- a/Fly App/lib/ui/fly/top/status_bar.dart +++ b/Fly App/lib/ui/fly/top/status_bar.dart @@ -62,6 +62,7 @@ class StatusBar extends StatelessWidget { _BatteryChip(model: model), const SizedBox(width: 6), GlassPill( + key: const ValueKey('settings-button'), height: height, onTap: onMore, padding: const EdgeInsets.symmetric(horizontal: 7), diff --git a/Fly App/lib/ui/settings_menu_page.dart b/Fly App/lib/ui/settings_menu_page.dart deleted file mode 100644 index ccb4425..0000000 --- a/Fly App/lib/ui/settings_menu_page.dart +++ /dev/null @@ -1,313 +0,0 @@ -import 'package:flutter/material.dart'; -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'; -import 'pv_icons.dart'; - -/// Aircraft settings — mirrors the v2 "Settings" mockup. Left tab column -/// (Safety / Control / Camera / Transmission / About); Safety & Control rows -/// drive real flight-controller setters. Values are held on the model and -/// updated as the user changes them (sensible defaults until read back). -class SettingsMenuPage extends StatefulWidget { - const SettingsMenuPage({super.key, required this.model, required this.dji}); - - final FlightModel model; - final DjiService dji; - - @override - State createState() => _SettingsMenuPageState(); -} - -class _SettingsMenuPageState extends State { - DjiService get _dji => widget.dji; - FlightModel get _m => widget.model; - - int _tab = 0; - static const List<(String, String)> _tabs = <(String, String)>[ - ('shield', 'Safety'), - ('rc', 'Control'), - ('aperture', 'Camera'), - ('radio', 'Transmission'), - ('drone', 'About'), - ]; - - @override - void initState() { - super.initState(); - // Seed defaults where nothing has been read back yet. - _m.maxHeight ??= 120; - _m.maxRadius ??= 500; - _m.maxRadiusEnabled ??= false; - _m.rthHeight ??= 100; - _m.obstacleAvoidance ??= 'On'; - _m.noviceMode ??= false; - } - - void _openProfile() { - Navigator.of(context).push(MaterialPageRoute( - builder: (_) => ProductProfilePage(model: _m, dji: _dji), - )); - } - - void _openTelemetry() { - Navigator.of(context).push(MaterialPageRoute( - builder: (_) => TelemetryInspectorPage(model: _m, dji: _dji), - )); - } - - void _snack(String msg) { - ScaffoldMessenger.of(context) - ..clearSnackBars() - ..showSnackBar(SnackBar(content: Text(msg))); - } - - Future _run(String label, Future Function() action) async { - try { - await action(); - } catch (e) { - _snack('$label: ${e is PlatformException ? (e.message ?? e.code) : e}'); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFF0A1120), - body: SafeArea( - child: AnimatedBuilder( - animation: _m, - builder: (BuildContext context, _) => Stack(children: [ - Row(children: [ - _tabColumn(), - Expanded(child: _rightPanel()), - ]), - Positioned( - top: 6, right: 8, - child: GestureDetector( - onTap: () => Navigator.of(context).maybePop(), - child: Container( - width: 30, height: 30, - decoration: BoxDecoration(color: const Color(0x1FFFFFFF), borderRadius: BorderRadius.circular(9)), - alignment: Alignment.center, - child: const PVIcon('close', size: 17, color: Glass.ink), - ), - ), - ), - ]), - ), - ), - ); - } - - Widget _tabColumn() { - return Container( - width: 160, - padding: const EdgeInsets.fromLTRB(12, 18, 12, 12), - color: const Color(0x8C081020), - child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.only(left: 6, bottom: 10), - child: Row(children: const [ - PVIcon('settings', size: 18, color: Glass.ink), - SizedBox(width: 8), - Text('Settings', style: TextStyle(fontFamily: PV.fontSans, fontSize: 15, fontWeight: FontWeight.w700, color: Glass.ink)), - ]), - ), - for (int i = 0; i < _tabs.length; i++) _tabBtn(i), - ]), - ); - } - - Widget _tabBtn(int i) { - final bool active = i == _tab; - return GestureDetector( - onTap: () => setState(() => _tab = i), - child: Container( - height: 38, - margin: const EdgeInsets.only(bottom: 6), - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration(color: active ? Glass.accent : Colors.transparent, borderRadius: BorderRadius.circular(10)), - child: Row(children: [ - PVIcon(_tabs[i].$1, size: 17, stroke: 1.7, color: Glass.ink), - const SizedBox(width: 10), - Text(_tabs[i].$2, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13, fontWeight: FontWeight.w600, color: Glass.ink)), - ]), - ), - ); - } - - Widget _rightPanel() { - final (String eyebrow, List rows) = switch (_tab) { - 0 => ('Flight Safety', _safetyRows()), - 1 => ('Flight Control', _controlRows()), - 2 => ('Camera', _cameraRows()), - 3 => ('Transmission', _infoRows(<(String, String)>[('Channel Mode', 'Auto'), ('Frequency', '2.4 / 5.8 GHz'), ('Signal', 'HD 1080p')])), - _ => ('About', [ - ..._infoRows(<(String, String)>[ - ('Model', _m.model ?? '—'), - ('Flight Controller SN', _m.flightControllerSerial ?? '—'), - ('Firmware', _m.firmwareVersion ?? '—'), - ('Controller Firmware', _m.controllerFirmwareVersion ?? '—'), - ('MSDK', _m.sdkVersion), - ]), - _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( - padding: const EdgeInsets.fromLTRB(22, 20, 22, 20), - child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text(eyebrow.toUpperCase(), - style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 1.4, color: Color(0x8CEAF0FA))), - ), - Expanded(child: ListView(children: rows)), - ]), - ); - } - - // ── Rows ──────────────────────────────────────────────────────────────── - List _safetyRows() => [ - _stepperRow('Max Altitude', '${_m.maxHeight} m', () => _editNumber('Max Altitude', _m.maxHeight ?? 120, 20, 500, 10, (int v) { - setState(() => _m.maxHeight = v); - _run('Max altitude', () => _dji.setMaxFlightHeight(v)); - })), - _toggleRow('Max Distance', _m.maxRadiusEnabled ?? false, (bool on) { - setState(() => _m.maxRadiusEnabled = on); - _run('Max distance', () => _dji.setMaxRadiusEnabled(on)); - }), - _stepperRow('Return-to-Home Alt.', '${_m.rthHeight} m', () => _editNumber('RTH Altitude', _m.rthHeight ?? 100, 20, 500, 10, (int v) { - setState(() => _m.rthHeight = v); - _run('RTH altitude', () => _dji.setGoHomeHeight(v)); - })), - _cycleRow('Obstacle Avoidance', _m.obstacleAvoidance ?? 'On', ['On', 'Off'], (String v) { - setState(() => _m.obstacleAvoidance = v); - _run('Obstacle avoidance', () => _dji.setObstacleAvoidance(v == 'On')); - }), - _toggleRow('Beginner Mode', _m.noviceMode ?? false, (bool on) { - setState(() => _m.noviceMode = on); - _run('Beginner mode', () => _dji.setNoviceMode(on)); - }), - _toggleRow('AR Home Point', _m.arHomePoint, (bool on) => setState(() => _m.arHomePoint = on)), - ]; - - List _controlRows() => [ - _stepperRow('Max Distance', '${_m.maxRadius} m', () => _editNumber('Max Distance', _m.maxRadius ?? 500, 50, 5000, 50, (int v) { - setState(() => _m.maxRadius = v); - _run('Max distance', () => _dji.setMaxFlightRadius(v)); - })), - _infoRow('Set Home to Current', 'Tap', onTap: () => _run('Set home', _dji.setHomeToCurrent)), - _infoRow('Cancel Return-to-Home', 'Tap', onTap: () => _run('Cancel RTH', _dji.cancelGoHome)), - ]; - - List _cameraRows() => [ - _infoRow('Exposure', _m.exposureProgram == ExposureProgram.pro ? 'Pro' : 'Auto'), - _infoRow('ISO', _m.iso ?? '—'), - _infoRow('Shutter', _m.shutter ?? '—'), - _infoRow('White Balance', _m.whiteBalance ?? 'Auto'), - ]; - - List _infoRows(List<(String, String)> items) => - [for (final (String l, String v) in items) _infoRow(l, v)]; - - Widget _rowShell({required Widget child}) => Container( - height: 44, - decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0x12FFFFFF)))), - child: child, - ); - - Widget _label(String l) => Text(l, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13.5, color: Glass.ink)); - - Widget _stepperRow(String label, String value, VoidCallback onTap) => _rowShell( - child: InkWell( - onTap: onTap, - child: Row(children: [ - Expanded(child: _label(label)), - Text(value, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF8FB4F6))), - const SizedBox(width: 4), - const PVIcon('chevronRight', size: 15, color: Color(0xFF8FB4F6)), - ]), - ), - ); - - Widget _infoRow(String label, String value, {VoidCallback? onTap}) => _rowShell( - child: InkWell( - onTap: onTap, - child: Row(children: [ - Expanded(child: _label(label)), - Text(value, style: TextStyle(fontFamily: PV.fontMono, fontSize: 13, color: onTap == null ? const Color(0x99EAF0FA) : const Color(0xFF8FB4F6))), - ]), - ), - ); - - Widget _cycleRow(String label, String value, List options, ValueChanged onChanged) => _rowShell( - child: InkWell( - onTap: () { - final int i = (options.indexOf(value) + 1) % options.length; - onChanged(options[i]); - }, - child: Row(children: [ - Expanded(child: _label(label)), - Text(value, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF8FB4F6))), - const SizedBox(width: 4), - const PVIcon('chevronRight', size: 15, color: Color(0xFF8FB4F6)), - ]), - ), - ); - - Widget _toggleRow(String label, bool value, ValueChanged onChanged) => _rowShell( - child: Row(children: [ - Expanded(child: _label(label)), - GestureDetector( - onTap: () => onChanged(!value), - child: Container( - width: 40, height: 22, - padding: const EdgeInsets.all(2), - decoration: BoxDecoration(color: value ? const Color(0xFF3D7BF0) : const Color(0x26FFFFFF), borderRadius: BorderRadius.circular(999)), - alignment: value ? Alignment.centerRight : Alignment.centerLeft, - child: Container(width: 18, height: 18, decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle)), - ), - ), - ]), - ); - - Future _editNumber(String title, int initial, int min, int max, int step, ValueChanged onSet) async { - int value = initial; - await showDialog( - context: context, - builder: (BuildContext ctx) => StatefulBuilder( - builder: (BuildContext ctx, StateSetter set) => AlertDialog( - backgroundColor: const Color(0xFF10203F), - title: Text(title, style: const TextStyle(color: Glass.ink, fontFamily: PV.fontSans, fontWeight: FontWeight.w700)), - content: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - IconButton(onPressed: () => set(() => value = (value - step).clamp(min, max)), icon: const Icon(Icons.remove, color: Glass.ink)), - Text('$value m', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 22, fontWeight: FontWeight.w700, color: Glass.ink)), - IconButton(onPressed: () => set(() => value = (value + step).clamp(min, max)), icon: const Icon(Icons.add, color: Glass.ink)), - ]), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')), - FilledButton(onPressed: () { - Navigator.pop(ctx); - onSet(value); - }, child: const Text('Set')), - ], - ), - ), - ); - } -} diff --git a/Fly App/test/fly/fake_dji.dart b/Fly App/test/fly/fake_dji.dart index 7402240..78b0527 100644 --- a/Fly App/test/fly/fake_dji.dart +++ b/Fly App/test/fly/fake_dji.dart @@ -1,4 +1,5 @@ import 'package:pilotvault_fly/dji_service.dart'; +import 'package:pilotvault_fly/fly/state/key_state.dart'; /// Records every bridge call instead of reaching the platform channel. class FakeDjiService extends DjiService { @@ -38,6 +39,36 @@ class FakeDjiService extends DjiService { Future startCompassCalibration() => _record('startCompassCalibration'); @override Future formatStorage({String location = 'SDCARD'}) => _record('formatStorage'); + @override + Future setHomeToCurrent() => _record('setHomeToCurrent'); + + // ── KeyManager (plan P6) ─────────────────────────────────────────────────── + /// Keys `isKeySupported` answers false for (by [DjiKey.key] name). + final Set unsupportedKeys = {}; + + /// When set, a successful `setKey` reports the new value back, as the + /// native listener would. + KeyState? echo; + + @override + Future isKeySupported(DjiKey key) async => !unsupportedKeys.contains(key.key); + @override + Future listenKey(DjiKey key) async { + calls.add('listenKey(${key.key})'); + return key.id; + } + + @override + Future cancelKey(String id) async => calls.add('cancelKey'); + @override + Future setKey(DjiKey key, Object? value) async { + await _record('setKey(${key.key}, $value)'); + echo?.apply(key.id, value); + } + + @override + Future performKeyAction(DjiKey key) => _record('performKeyAction(${key.key})'); + @override Future getKey(DjiKey key) async => switch (key.key) { 'MAX_FLIGHT_HEIGHT' => 120, diff --git a/Fly App/test/fly/settings_test.dart b/Fly App/test/fly/settings_test.dart new file mode 100644 index 0000000..e8737c9 --- /dev/null +++ b/Fly App/test/fly/settings_test.dart @@ -0,0 +1,313 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/product_profile.dart'; +import 'package:pilotvault_fly/fly/settings/settings_spec.dart'; +import 'package:pilotvault_fly/fly/settings/settings_tabs.dart'; +import 'package:pilotvault_fly/fly/state/key_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/panels/camera_write.dart'; +import 'package:pilotvault_fly/ui/fly/panels/gimbal_sheet.dart'; + +import 'fake_dji.dart'; +import 'profile_fixtures.dart'; +import 'telemetry_fixtures.dart'; + +FlightModel connectedModel(Map profile, {bool flying = false}) { + final FlightModel m = FlightModel() + ..connected = true + ..profile = ProductProfile.fromBridge(profile); + final Map t = telemetryTick(); + t['isFlying'] = flying; + t['areMotorsOn'] = flying; + TelemetryReducer(m).apply(t); + return m; +} + +void seed(KeyState s, Map values) { + values.forEach((DjiKey k, Object? v) => s.apply(k.id, v)); +} + +T spec(String tab, String id, [ProductProfile p = ProductProfile.disconnected]) => + SettingsTabs.all(p).firstWhere((SettingsTab t) => t.id == tab).rows.firstWhere((SettingSpec r) => r.id == id) as T; + +void main() { + group('Specs', () { + test('number rows take the aircraft\'s range and step within it', () { + final KeyState s = KeyState(); + final KeyNumberSpec alt = spec(SettingsTabs.safety, 'maxAltitude'); + expect(alt.range(s), (20, 500), reason: 'fallback until the range key reports'); + seed(s, { + SettingsKeys.maxHeight: 120, + SettingsKeys.maxHeightRange: {'supported': true, 'min': 20.0, 'max': 125.0}, + }); + expect(alt.range(s), (20, 125)); + expect(alt.stepped(s, 1), 125, reason: 'clamped to the max'); + expect(alt.stepped(s, -1), 110); + s.apply(SettingsKeys.maxHeight.id, 125); + expect(alt.stepped(s, 1), isNull, reason: 'nothing to step to'); + }); + + test('low and critical battery keep 5 % apart', () { + final KeyState s = KeyState(); + seed(s, {SettingsKeys.lowBattery: 25, SettingsKeys.criticalBattery: 20}); + final KeyNumberSpec low = spec(SettingsTabs.battery, 'lowBattery'); + final KeyNumberSpec critical = spec(SettingsTabs.battery, 'criticalBattery'); + expect(low.range(s), (25, 50)); + expect(low.stepped(s, -1), isNull); + expect(critical.range(s), (10, 20)); + expect(critical.stepped(s, 1), isNull); + expect(critical.stepped(s, -1), 19); + }); + + test('choices follow the supported list and keep an unknown current value', () { + final KeyState s = KeyState(); + final KeyChoiceSpec band = spec(SettingsTabs.transmission, 'osBand'); + expect(band.optionsFor(s).length, 4); + seed(s, { + SettingsKeys.osBands: ['FREQUENCY_BAND_2_DOT_4_GHZ', 'FREQUENCY_BAND_5_DOT_8_GHZ'], + SettingsKeys.osBand: 'FREQUENCY_BAND_2_DOT_4_GHZ', + }); + expect([for (final (String v, _) in band.optionsFor(s)) v], ['FREQUENCY_BAND_2_DOT_4_GHZ', 'FREQUENCY_BAND_5_DOT_8_GHZ']); + final KeyChoiceSpec stick = spec(SettingsTabs.rc, 'stickMode'); + s.apply(SettingsKeys.stickMode.id, 'STYLE_CUSTOM'); + expect(stick.optionsFor(s).last, ('STYLE_CUSTOM', 'Style custom')); + }); + + test('unsupported keys hide, dependent rows follow their switch, ground-only rows lock in flight', () { + final FlightModel m = connectedModel(mavic2Zoom); + SettingsContext c() => SettingsContext(profile: m.profile, keys: m.keys, aircraft: m.aircraft); + final KeySwitchSpec smartRth = spec(SettingsTabs.safety, 'smartRth'); + expect(smartRth.visible(c()), isTrue, reason: 'unknown support still shows'); + m.keys.setSupported(SettingsKeys.smartRth, false); + expect(smartRth.visible(c()), isFalse); + + final KeyNumberSpec distance = spec(SettingsTabs.safety, 'maxDistance'); + expect(distance.visible(c()), isFalse); + m.keys.apply(SettingsKeys.maxRadiusEnabled.id, true); + expect(distance.visible(c()), isTrue); + + final KeySwitchSpec beginner = spec(SettingsTabs.safety, 'beginner'); + expect(beginner.lockedReason(c()), isNull); + final FlightModel air = connectedModel(mavic2Zoom, flying: true); + expect(beginner.lockedReason(SettingsContext(profile: air.profile, keys: air.keys, aircraft: air.aircraft)), contains('Land')); + expect(beginner.lockedReason(SettingsContext(profile: ProductProfile.disconnected, keys: KeyState(), aircraft: air.aircraft)), + contains('Connect')); + }); + + test('transmission rows follow the link type; battery groups repeat per battery', () { + final ProductProfile os = ProductProfile.fromBridge(mavic2Zoom); + final ProductProfile lb = ProductProfile.fromBridge(phantom4); + final SettingsContext osc = SettingsContext(profile: os, keys: KeyState(), aircraft: connectedModel(mavic2Zoom).aircraft); + final SettingsContext lbc = SettingsContext(profile: lb, keys: KeyState(), aircraft: connectedModel(phantom4).aircraft); + expect(spec(SettingsTabs.transmission, 'osChannelMode').visible(osc), isTrue); + expect(spec(SettingsTabs.transmission, 'osChannelMode').visible(lbc), isFalse); + expect(spec(SettingsTabs.transmission, 'lbBand').visible(lbc), isTrue); + expect(spec(SettingsTabs.transmission, 'wifiBand').visible(osc), isFalse); + + List groups(Map p) => [ + for (final SettingSpec r in SettingsTabs.all(ProductProfile.fromBridge(p)).firstWhere((SettingsTab t) => t.id == 'battery').rows) + if (r is GroupSpec) r.label, + ]; + expect(groups(phantom4), ['WARNINGS', 'BATTERY']); + expect(groups(inspire2X5s), ['WARNINGS', 'BATTERY 1', 'BATTERY 2']); + final KeyReadoutSpec cells = spec(SettingsTabs.battery, 'cells1', ProductProfile.fromBridge(inspire2X5s)); + expect(cells.key.index, 1); + }); + + test('formatting', () { + expect(formatCells([3850, 3861]), '3.85 3.86 V'); + expect(formatValue('FREQUENCY_BAND_2_DOT_4_GHZ'), 'Frequency band 2.4 ghz'); + expect(formatValue(true), 'On'); + expect(feetHint(120), '≈ 394 ft'); + }); + }); + + group('Key state', () { + test('key events reach the model only while connected', () { + final FlightModel m = connectedModel(phantom4); + final TelemetryReducer r = TelemetryReducer(m); + expect(r.apply({'type': 'key', 'id': SettingsKeys.novice.id, 'value': true}), isTrue); + expect(m.keys.value(SettingsKeys.novice), isTrue); + m.clearTelemetry(); + m.connected = false; + r.apply({'type': 'key', 'id': SettingsKeys.novice.id, 'value': false}); + expect(m.keys.value(SettingsKeys.novice), isNull); + }); + + test('subscriptions listen only to supported keys and cancel on dispose', () async { + final FakeDjiService dji = FakeDjiService()..unsupportedKeys.add('ESC_BEEPING_ENABLED'); + final KeyState s = KeyState(); + final KeySubscriptions subs = KeySubscriptions(dji, s); + await subs.watch([SettingsKeys.novice, SettingsKeys.escBeep, SettingsKeys.novice]); + expect(dji.calls, ['listenKey(NOVICE_MODE_ENABLED)']); + expect(s.supported(SettingsKeys.escBeep), isFalse); + expect(s.supported(SettingsKeys.novice), isTrue); + subs.dispose(); + await Future.delayed(Duration.zero); + expect(dji.calls.last, 'cancelKey'); + }); + }); + + group('Sheet', () { + Future pump(WidgetTester tester, FlightModel m, {FakeDjiService? dji}) async { + tester.view.physicalSize = const Size(844, 390); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final FakeDjiService d = dji ?? FakeDjiService(); + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: d))); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(find.byKey(const ValueKey('settings-button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + return d; + } + + Future tab(WidgetTester tester, String id) async { + await tester.tap(find.byKey(ValueKey('settings-tab-$id'))); + await tester.pump(); + await tester.pump(); + } + + testWidgets('opens over the video with the product\'s tabs', (WidgetTester tester) async { + final FakeDjiService dji = await pump(tester, connectedModel(mavic2Zoom)); + for (final String t in ['safety', 'vision', 'rc', 'transmission', 'battery', 'gimbal', 'general']) { + expect(find.byKey(ValueKey('settings-tab-$t')), findsOneWidget, reason: t); + } + expect(find.byType(FlyScreen), findsOneWidget, reason: 'a sheet, not a route'); + expect(dji.calls, contains('listenKey(GO_HOME_HEIGHT_IN_METERS)')); + await tester.tap(find.byKey(const ValueKey('settings-close'))); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('settings-tab-safety')), findsNothing); + expect(dji.calls.last, 'cancelKey'); + }); + + testWidgets('a switch writes optimistically and rolls back on refusal', (WidgetTester tester) async { + final FlightModel m = connectedModel(mavic2Zoom); + seed(m.keys, {SettingsKeys.smartRth: false}); + final FakeDjiService dji = await pump(tester, m); + await tester.tap(find.byKey(const ValueKey('row-smartRth'))); + await tester.pump(); + expect(dji.calls, contains('setKey(SMART_RETURN_TO_HOME_ENABLED, true)')); + expect(m.keys.value(SettingsKeys.smartRth), isTrue); + await tester.pump(cameraSettle); + expect(m.keys.value(SettingsKeys.smartRth), isFalse, reason: 'never confirmed'); + + dji.failWith = PlatformException(code: 'DJI_ERROR', message: 'Not supported'); + await tester.tap(find.byKey(const ValueKey('row-smartRth'))); + await tester.pump(); + await tester.pump(); + expect(m.keys.value(SettingsKeys.smartRth), isFalse); + expect(find.textContaining('Not supported'), findsOneWidget); + }); + + testWidgets('a confirmed write stays; unsupported rows are gone', (WidgetTester tester) async { + final FlightModel m = connectedModel(mavic2Zoom); + seed(m.keys, {SettingsKeys.goHomeHeight: 100}); + final FakeDjiService dji = FakeDjiService() + ..echo = m.keys + ..unsupportedKeys.add('ESC_BEEPING_ENABLED'); + await pump(tester, m, dji: dji); + expect(find.text('≈ 328 ft'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('step-rthAltitude-plus'))); + await tester.pump(cameraSettle); + expect(dji.calls, contains('setKey(GO_HOME_HEIGHT_IN_METERS, 110)')); + expect(find.text('110 m'), findsOneWidget); + await tester.scrollUntilVisible(find.byKey(const ValueKey('spec-sensors')), 200, + scrollable: find.descendant(of: find.byKey(const ValueKey('settings-list-safety')), matching: find.byType(Scrollable))); + expect(find.byKey(const ValueKey('row-escBeep')), findsNothing); + }); + + testWidgets('a far-off number can be typed, clamped to the range', (WidgetTester tester) async { + final FlightModel m = connectedModel(mavic2Zoom); + seed(m.keys, {SettingsKeys.goHomeHeight: 100}); + final FakeDjiService dji = await pump(tester, m); + await tester.tap(find.byKey(const ValueKey('value-rthAltitude'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('number-input')), '900'); + await tester.tap(find.byKey(const ValueKey('number-set'))); + await tester.pumpAndSettle(); + expect(dji.calls, contains('setKey(GO_HOME_HEIGHT_IN_METERS, 500)')); + await tester.pump(cameraSettle); + }); + + testWidgets('ground-only rows lock in flight', (WidgetTester tester) async { + final FlightModel m = connectedModel(mavic2Zoom, flying: true); + seed(m.keys, {SettingsKeys.novice: false}); + final FakeDjiService dji = await pump(tester, m); + await tester.scrollUntilVisible(find.byKey(const ValueKey('row-beginner')), 200, + scrollable: find.descendant(of: find.byKey(const ValueKey('settings-list-safety')), matching: find.byType(Scrollable))); + expect(find.text('Land and stop the motors to change'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('row-beginner')), warnIfMissed: false); + await tester.pump(); + expect(dji.calls.where((String c) => c.startsWith('setKey')), isEmpty); + }); + + testWidgets('remote tab: stick mode with its diagram, linking is slide-confirmed', (WidgetTester tester) async { + final FlightModel m = connectedModel(mavic2Zoom); + seed(m.keys, {SettingsKeys.stickMode: 'STYLE_2', SettingsKeys.pairingState: 'PAIRED'}); + final FakeDjiService dji = await pump(tester, m); + await tab(tester, 'rc'); + expect(find.text('Mode 2'), findsOneWidget); + expect(find.text('Up-down: Up / down'), findsOneWidget, reason: 'Mode 2 left stick is throttle'); + await tester.tap(find.byKey(const ValueKey('row-stickMode'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('opt-STYLE_1'))); + await tester.pump(); + expect(dji.calls, contains('setKey(AIRCRAFT_MAPPING_STYLE, STYLE_1)')); + expect(find.text('Up-down: Forward / back'), findsOneWidget); + await tester.pump(cameraSettle); + await tester.scrollUntilVisible(find.byKey(const ValueKey('action-startPairing')), 200, + scrollable: find.descendant(of: find.byKey(const ValueKey('settings-list-rc')), matching: find.byType(Scrollable))); + await tester.ensureVisible(find.byKey(const ValueKey('action-startPairing'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('action-startPairing'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.textContaining("aircraft's link button"), findsOneWidget, reason: 'the slide-to-confirm dialog'); + expect(dji.calls.where((String c) => c.startsWith('performKeyAction')), isEmpty, reason: 'nothing runs before the slide'); + }); + + testWidgets('vision tab on an obstacle-sensing aircraft; transmission shows the link\'s rows', (WidgetTester tester) async { + final FlightModel m = connectedModel(mavic2Zoom); + seed(m.keys, {SettingsKeys.osChannelMode: 'AUTO'}); + final FakeDjiService dji = await pump(tester, m); + await tab(tester, 'vision'); + expect(find.byKey(const ValueKey('row-collision')), findsOneWidget); + await tab(tester, 'transmission'); + expect(find.byKey(const ValueKey('row-osChannelMode')), findsOneWidget); + expect(find.byKey(const ValueKey('row-osChannel')), findsNothing, reason: 'Auto picks the channel'); + expect(find.byKey(const ValueKey('row-lbBand')), findsNothing); + await tester.tap(find.byKey(const ValueKey('row-osChannelMode'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('opt-MANUAL'))); + await tester.pump(); + expect(dji.calls, contains('setKey(CHANNEL_SELECTION_MODE, MANUAL)')); + expect(find.byKey(const ValueKey('row-osChannel')), findsOneWidget); + await tester.pump(cameraSettle); + }); + + testWidgets('gimbal tab hosts the gimbal list; general clears the route', (WidgetTester tester) async { + final FlightModel m = connectedModel(mavic2Zoom); + await pump(tester, m); + await tab(tester, 'gimbal'); + expect(find.byType(GimbalSettingsList), findsOneWidget); + await tab(tester, 'general'); + expect(find.byKey(const ValueKey('row-showRoute')), findsOneWidget); + expect(find.byKey(const ValueKey('action-clearRoute')), findsOneWidget); + expect(find.text('Mavic 2 Zoom'), findsNothing, reason: 'About shows the model the connection reported'); + }); + + testWidgets('without a connection nothing is asked and rows show as previews', (WidgetTester tester) async { + final FlightModel m = FlightModel(); + final FakeDjiService dji = await pump(tester, m); + expect(find.text('NOT CONNECTED'), findsOneWidget); + expect(dji.calls.where((String c) => c.startsWith('listenKey')), isEmpty); + expect(find.text('Connect the aircraft to change'), findsWidgets); + }); + }); +}