Give the Fly App a GO 4-style settings sheet

The "•••" button now opens a right-side glass sheet over the live view
with Safety, Vision, Remote, Video link, Battery, Gimbal and General
tabs. Each row is a one-line spec bound to a KeyManager key: rows hide
when the aircraft does not support their key, writes show at once and
roll back if refused, and ground-only settings lock in flight.

KeyBridge gains argument-less action keys and converts range and LED
settings values. settings_menu_page.dart is retired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-19 21:13:45 +02:00
1 parent 60c2fd7f64
commit 284de5442a
19 files changed
+2132 -342

No files matched your search

@@ -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<String>("id")
ctx.mainHandler.post {
@@ -79,6 +83,17 @@ class KeyBridge(private val ctx: BridgeCtx) : SubBridge {
}
})
"setKey" -> setKey(km, key, call.argument<Any>("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
+6
View File
@@ -115,6 +115,9 @@ class DjiService {
Future<String> listenKey(DjiKey key) async =>
await _methods.invokeMethod<String>('listenKey', key.args) ?? key.id;
/// Runs an action key that takes no arguments (`START_PAIRING`, …).
Future<void> performKeyAction(DjiKey key) => _methods.invokeMethod<void>('performKeyAction', key.args);
Future<void> cancelKey(String id) => _methods.invokeMethod<void>('cancelKey', <String, dynamic>{'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);
+6 -10
View File
@@ -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<ChangeNotifier> _subs = <ChangeNotifier>[
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;
+10
View File
@@ -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<bool> centerPoint = ValueNotifier<bool>(true);
static final ValueNotifier<LongPressAction> longPress = ValueNotifier<LongPressAction>(LongPressAction.meter);
/// Draw the flown trail on the map (settings → General).
static final ValueNotifier<bool> showRoute = ValueNotifier<bool>(true);
static SharedPreferences? _prefs;
static Future<void> 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<void> setShowRoute(bool on) async {
showRoute.value = on;
await _prefs?.setBool(_routeKey, on);
}
static T? _byName<T extends Enum>(List<T> values, String? name) {
for (final T v in values) {
if (v.name == name) return v;
+19
View File
@@ -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<HudFeature> modes = <HudFeature>[
for (final IntelligentMode m in IntelligentMode.values)
@@ -136,6 +150,11 @@ abstract final class HudFeatures {
virtualSticks,
precisionTakeoff,
simulator,
settingsVision,
settingsObstacle,
linkOcuSync,
linkLightbridge,
linkWifi,
...modes,
];
+302
View File
@@ -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<DjiKey> get watched => key == null ? const <DjiKey>[] : <DjiKey>[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<DjiKey> get watched => <DjiKey>[_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<DjiKey> get watched => <DjiKey>[_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<String, dynamic>? 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<DjiKey> get watched => const <DjiKey>[];
}
/// 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 <DjiKey>[], super.requires, super.visibleWhen});
final List<DjiKey> keys;
@override
Iterable<DjiKey> 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<SettingSpec> rows;
final HudFeature? requires;
Iterable<DjiKey> 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<Object?> 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';
}
+491
View File
@@ -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<SettingsTab> all(ProductProfile p) => <SettingsTab>[
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 <SettingSpec>[CustomSpec(id: gimbalList)],
),
SettingsTab(id: general, label: 'General', icon: 'settings', rows: _general),
];
// ── Safety / MC ─────────────────────────────────────────────────────────────
static final List<SettingSpec> _safety = <SettingSpec>[
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: <DjiKey>[SettingsKeys.leds]),
const KeySwitchSpec(id: 'escBeep', label: 'ESC beeping', key: SettingsKeys.escBeep),
const GroupSpec('SENSORS'),
const CustomSpec(id: sensors, keys: <DjiKey>[SettingsKeys.imuCount, SettingsKeys.compassCount]),
];
// ── Vision ──────────────────────────────────────────────────────────────────
static final List<SettingSpec> _vision = <SettingSpec>[
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<SettingSpec> _rc = <SettingSpec>[
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: <DjiKey>[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<SettingSpec> _transmission = <SettingSpec>[
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<SettingSpec> _battery(ProductProfile p) {
final int n = p.batteryCount.clamp(1, 6);
return <SettingSpec>[
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<SettingSpec> _batteryRows(int i, int n) {
DjiKey k(String key) => SettingsKeys.battery(key, i);
return <SettingSpec>[
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<SettingSpec> _general = <SettingSpec>[
const GroupSpec('MAP'),
const CustomSpec(id: showRoute),
const CustomSpec(id: clearRoute),
const GroupSpec('ABOUT'),
const CustomSpec(id: about, keys: <DjiKey>[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);
}
+112
View File
@@ -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<String, bool> _supported = <String, bool>{};
/// 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(<String, dynamic>{...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<DjiKey> _asked = <DjiKey>{};
final Set<String> _listening = <String>{};
bool _disposed = false;
Future<void> watch(Iterable<DjiKey> keys) async {
await Future.wait(<Future<void>>[
for (final DjiKey k in keys)
if (_asked.add(k)) _watchOne(k),
]);
}
Future<void> _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<void> rewatch(Iterable<DjiKey> keys) async {
await _cancelAll();
_asked.clear();
state.forgetSupport();
await watch(keys);
}
Future<void> _cancelAll() async {
final List<String> ids = _listening.toList();
_listening.clear();
for (final String id in ids) {
try {
await dji.cancelKey(id);
} catch (_) {}
}
}
void dispose() {
_disposed = true;
_cancelAll();
}
}
+4
View File
@@ -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;
+2 -4
View File
@@ -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<FlightControlPage> {
}
void _openSettingsMenu() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => SettingsMenuPage(model: _m, dji: _dji),
));
showFlySettings(context, model: _m, dji: _dji, onError: (String label, Object e) => _snack('$label failed: $e'));
}
void _openMap() {
+3 -2
View File
@@ -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<FlyMap> {
userAgentPackageName: 'com.pilotvault.flyapp',
),
ListenableBuilder(
listenable: widget.trail,
listenable: Listenable.merge(<Listenable>[widget.trail, FlyPrefs.showRoute]),
builder: (BuildContext context, _) {
final List<LatLng> 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<Object>(polylines: <Polyline<Object>>[
Polyline<Object>(points: pts, strokeWidth: 2.5, color: const Color(0xFFF4C542)),
]);
+9 -2
View File
@@ -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<FlyScreen> {
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)),
@@ -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<ChoiceTile> {
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
_RowFrame(
label: widget.label,
hint: widget.hint,
onTap: widget.options.isEmpty ? null : () => setState(() => _open = !_open),
trailing: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
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<bool> 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-<id>-minus` / `step-<id>-plus`.
@@ -308,6 +317,9 @@ class StepperTile extends StatelessWidget {
final String? hint;
final bool pending;
/// Tapping the value (keyed `value-<id>`) — 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: <Widget>[
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<String>('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-<id>`; a null [onTap] greys it out.
class ButtonTile extends StatelessWidget {
const ButtonTile({
@@ -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<void> 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: <String, Object?>{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<void> _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<String>('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<String>('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<String>('row-${s.id}'), label: s.label, hint: s.hint, value: s.shown(_keys)),
KeyActionSpec() => ButtonTile(
key: ValueKey<String>('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<void> _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<int> 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<String>('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<void> _type(BuildContext context, int current) async {
final (int lo, int hi) = spec.range(keys);
final int? picked = await showDialog<int>(
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<String>('number-input'),
controller: _text,
autofocus: true,
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[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: <Widget>[
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
FilledButton(key: const ValueKey<String>('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<String>('stick-diagram'),
padding: const EdgeInsets.fromLTRB(16, 2, 16, 10),
child: Row(children: <Widget>[
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: <Widget>[
const CustomPaint(size: Size(30, 30), painter: _StickPainter()),
const SizedBox(width: 8),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
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;
}
@@ -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<void> 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<void>(
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<double> a, _, Widget child) => SlideTransition(
position: Tween<Offset>(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<FlySettingsSheet> createState() => _FlySettingsSheetState();
}
class _FlySettingsSheetState extends State<FlySettingsSheet> {
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<SettingsTab> get _tabs => <SettingsTab>[
for (final SettingsTab t in SettingsTabs.all(_profile))
if (t.visible(_profile)) t,
];
SettingsTab get _current {
final List<SettingsTab> 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<DjiKey> 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: <Widget>[
_rail(tab),
const VerticalDivider(width: 1, color: Glass.hairline),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
_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: <Widget>[
for (final SettingsTab t in _tabs)
GestureDetector(
key: ValueKey<String>('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: <Widget>[
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: <Widget>[
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<String>('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<SettingSpec> rows = <SettingSpec>[for (final SettingSpec r in tab.rows) if (r.visible(c)) r];
return ListView(
key: ValueKey<String>('settings-list-${tab.id}'),
padding: const EdgeInsets.only(bottom: 16),
children: <Widget>[
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<String>('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<bool>(
valueListenable: FlyPrefs.showRoute,
builder: (BuildContext context, bool on, _) =>
SwitchTile(key: const ValueKey<String>('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<void> _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: <Widget>[
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<String, dynamic>? v = asMap(_keys.value(SettingsKeys.leds));
final String? locked = c.connected ? null : 'Connect the aircraft to change';
final Widget tile = SwitchTile(
key: const ValueKey<String>('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,
<String, dynamic>{...?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: <Widget>[
ValueTile(
key: const ValueKey<String>('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<String>('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: <Widget>[
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<void>(builder: (_) => page));
Widget _developer() {
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
ValueTile(
key: const ValueKey<String>('row-capabilityProfile'),
label: 'Capability profile',
value: 'View',
onTap: () => _push(ProductProfilePage(model: _m, dji: widget.dji)),
),
ValueTile(
key: const ValueKey<String>('row-telemetryInspector'),
label: 'Telemetry inspector',
value: 'View',
onTap: () => _push(TelemetryInspectorPage(model: _m, dji: widget.dji)),
),
ValueListenableBuilder<bool>(
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<bool>(
valueListenable: FlyPrefs.perfOverlay,
builder: (BuildContext context, bool on, _) =>
SwitchTile(label: 'HUD performance overlay', value: on, onChanged: FlyPrefs.setPerfOverlay),
),
]);
}
}
+1
View File
@@ -62,6 +62,7 @@ class StatusBar extends StatelessWidget {
_BatteryChip(model: model),
const SizedBox(width: 6),
GlassPill(
key: const ValueKey<String>('settings-button'),
height: height,
onTap: onMore,
padding: const EdgeInsets.symmetric(horizontal: 7),
-313
View File
@@ -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<SettingsMenuPage> createState() => _SettingsMenuPageState();
}
class _SettingsMenuPageState extends State<SettingsMenuPage> {
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<void>(
builder: (_) => ProductProfilePage(model: _m, dji: _dji),
));
}
void _openTelemetry() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => TelemetryInspectorPage(model: _m, dji: _dji),
));
}
void _snack(String msg) {
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(msg)));
}
Future<void> _run(String label, Future<void> 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: <Widget>[
Row(children: <Widget>[
_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: <Widget>[
Padding(
padding: const EdgeInsets.only(left: 6, bottom: 10),
child: Row(children: const <Widget>[
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: <Widget>[
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<Widget> 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', <Widget>[
..._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: <Widget>[
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<Widget> _safetyRows() => <Widget>[
_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', <String>['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<Widget> _controlRows() => <Widget>[
_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<Widget> _cameraRows() => <Widget>[
_infoRow('Exposure', _m.exposureProgram == ExposureProgram.pro ? 'Pro' : 'Auto'),
_infoRow('ISO', _m.iso ?? '—'),
_infoRow('Shutter', _m.shutter ?? '—'),
_infoRow('White Balance', _m.whiteBalance ?? 'Auto'),
];
List<Widget> _infoRows(List<(String, String)> items) =>
<Widget>[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: <Widget>[
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: <Widget>[
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<String> options, ValueChanged<String> onChanged) => _rowShell(
child: InkWell(
onTap: () {
final int i = (options.indexOf(value) + 1) % options.length;
onChanged(options[i]);
},
child: Row(children: <Widget>[
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<bool> onChanged) => _rowShell(
child: Row(children: <Widget>[
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<void> _editNumber(String title, int initial, int min, int max, int step, ValueChanged<int> onSet) async {
int value = initial;
await showDialog<void>(
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: <Widget>[
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: <Widget>[
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
FilledButton(onPressed: () {
Navigator.pop(ctx);
onSet(value);
}, child: const Text('Set')),
],
),
),
);
}
}
+31
View File
@@ -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<void> startCompassCalibration() => _record('startCompassCalibration');
@override
Future<void> formatStorage({String location = 'SDCARD'}) => _record('formatStorage');
@override
Future<void> setHomeToCurrent() => _record('setHomeToCurrent');
// ── KeyManager (plan P6) ───────────────────────────────────────────────────
/// Keys `isKeySupported` answers false for (by [DjiKey.key] name).
final Set<String> unsupportedKeys = <String>{};
/// When set, a successful `setKey` reports the new value back, as the
/// native listener would.
KeyState? echo;
@override
Future<bool> isKeySupported(DjiKey key) async => !unsupportedKeys.contains(key.key);
@override
Future<String> listenKey(DjiKey key) async {
calls.add('listenKey(${key.key})');
return key.id;
}
@override
Future<void> cancelKey(String id) async => calls.add('cancelKey');
@override
Future<void> setKey(DjiKey key, Object? value) async {
await _record('setKey(${key.key}, $value)');
echo?.apply(key.id, value);
}
@override
Future<void> performKeyAction(DjiKey key) => _record('performKeyAction(${key.key})');
@override
Future<Object?> getKey(DjiKey key) async => switch (key.key) {
'MAX_FLIGHT_HEIGHT' => 120,
+313
View File
@@ -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<String, dynamic> profile, {bool flying = false}) {
final FlightModel m = FlightModel()
..connected = true
..profile = ProductProfile.fromBridge(profile);
final Map<String, dynamic> t = telemetryTick();
t['isFlying'] = flying;
t['areMotorsOn'] = flying;
TelemetryReducer(m).apply(t);
return m;
}
void seed(KeyState s, Map<DjiKey, Object?> values) {
values.forEach((DjiKey k, Object? v) => s.apply(k.id, v));
}
T spec<T extends SettingSpec>(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<KeyNumberSpec>(SettingsTabs.safety, 'maxAltitude');
expect(alt.range(s), (20, 500), reason: 'fallback until the range key reports');
seed(s, <DjiKey, Object?>{
SettingsKeys.maxHeight: 120,
SettingsKeys.maxHeightRange: <String, dynamic>{'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, <DjiKey, Object?>{SettingsKeys.lowBattery: 25, SettingsKeys.criticalBattery: 20});
final KeyNumberSpec low = spec<KeyNumberSpec>(SettingsTabs.battery, 'lowBattery');
final KeyNumberSpec critical = spec<KeyNumberSpec>(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<KeyChoiceSpec>(SettingsTabs.transmission, 'osBand');
expect(band.optionsFor(s).length, 4);
seed(s, <DjiKey, Object?>{
SettingsKeys.osBands: <Object?>['FREQUENCY_BAND_2_DOT_4_GHZ', 'FREQUENCY_BAND_5_DOT_8_GHZ'],
SettingsKeys.osBand: 'FREQUENCY_BAND_2_DOT_4_GHZ',
});
expect(<String>[for (final (String v, _) in band.optionsFor(s)) v], <String>['FREQUENCY_BAND_2_DOT_4_GHZ', 'FREQUENCY_BAND_5_DOT_8_GHZ']);
final KeyChoiceSpec stick = spec<KeyChoiceSpec>(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<KeySwitchSpec>(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<KeyNumberSpec>(SettingsTabs.safety, 'maxDistance');
expect(distance.visible(c()), isFalse);
m.keys.apply(SettingsKeys.maxRadiusEnabled.id, true);
expect(distance.visible(c()), isTrue);
final KeySwitchSpec beginner = spec<KeySwitchSpec>(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<KeyChoiceSpec>(SettingsTabs.transmission, 'osChannelMode').visible(osc), isTrue);
expect(spec<KeyChoiceSpec>(SettingsTabs.transmission, 'osChannelMode').visible(lbc), isFalse);
expect(spec<KeyChoiceSpec>(SettingsTabs.transmission, 'lbBand').visible(lbc), isTrue);
expect(spec<KeyChoiceSpec>(SettingsTabs.transmission, 'wifiBand').visible(osc), isFalse);
List<String> groups(Map<String, dynamic> p) => <String>[
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), <String>['WARNINGS', 'BATTERY']);
expect(groups(inspire2X5s), <String>['WARNINGS', 'BATTERY 1', 'BATTERY 2']);
final KeyReadoutSpec cells = spec<KeyReadoutSpec>(SettingsTabs.battery, 'cells1', ProductProfile.fromBridge(inspire2X5s));
expect(cells.key.index, 1);
});
test('formatting', () {
expect(formatCells(<Object?>[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(<String, dynamic>{'type': 'key', 'id': SettingsKeys.novice.id, 'value': true}), isTrue);
expect(m.keys.value(SettingsKeys.novice), isTrue);
m.clearTelemetry();
m.connected = false;
r.apply(<String, dynamic>{'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(<DjiKey>[SettingsKeys.novice, SettingsKeys.escBeep, SettingsKeys.novice]);
expect(dji.calls, <String>['listenKey(NOVICE_MODE_ENABLED)']);
expect(s.supported(SettingsKeys.escBeep), isFalse);
expect(s.supported(SettingsKeys.novice), isTrue);
subs.dispose();
await Future<void>.delayed(Duration.zero);
expect(dji.calls.last, 'cancelKey');
});
});
group('Sheet', () {
Future<FakeDjiService> 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<String>('settings-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
return d;
}
Future<void> tab(WidgetTester tester, String id) async {
await tester.tap(find.byKey(ValueKey<String>('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 <String>['safety', 'vision', 'rc', 'transmission', 'battery', 'gimbal', 'general']) {
expect(find.byKey(ValueKey<String>('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<String>('settings-close')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey<String>('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, <DjiKey, Object?>{SettingsKeys.smartRth: false});
final FakeDjiService dji = await pump(tester, m);
await tester.tap(find.byKey(const ValueKey<String>('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<String>('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, <DjiKey, Object?>{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<String>('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<String>('spec-sensors')), 200,
scrollable: find.descendant(of: find.byKey(const ValueKey<String>('settings-list-safety')), matching: find.byType(Scrollable)));
expect(find.byKey(const ValueKey<String>('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, <DjiKey, Object?>{SettingsKeys.goHomeHeight: 100});
final FakeDjiService dji = await pump(tester, m);
await tester.tap(find.byKey(const ValueKey<String>('value-rthAltitude')));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey<String>('number-input')), '900');
await tester.tap(find.byKey(const ValueKey<String>('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, <DjiKey, Object?>{SettingsKeys.novice: false});
final FakeDjiService dji = await pump(tester, m);
await tester.scrollUntilVisible(find.byKey(const ValueKey<String>('row-beginner')), 200,
scrollable: find.descendant(of: find.byKey(const ValueKey<String>('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<String>('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, <DjiKey, Object?>{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<String>('row-stickMode')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('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<String>('action-startPairing')), 200,
scrollable: find.descendant(of: find.byKey(const ValueKey<String>('settings-list-rc')), matching: find.byType(Scrollable)));
await tester.ensureVisible(find.byKey(const ValueKey<String>('action-startPairing')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('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, <DjiKey, Object?>{SettingsKeys.osChannelMode: 'AUTO'});
final FakeDjiService dji = await pump(tester, m);
await tab(tester, 'vision');
expect(find.byKey(const ValueKey<String>('row-collision')), findsOneWidget);
await tab(tester, 'transmission');
expect(find.byKey(const ValueKey<String>('row-osChannelMode')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('row-osChannel')), findsNothing, reason: 'Auto picks the channel');
expect(find.byKey(const ValueKey<String>('row-lbBand')), findsNothing);
await tester.tap(find.byKey(const ValueKey<String>('row-osChannelMode')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('opt-MANUAL')));
await tester.pump();
expect(dji.calls, contains('setKey(CHANNEL_SELECTION_MODE, MANUAL)'));
expect(find.byKey(const ValueKey<String>('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<String>('row-showRoute')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('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);
});
});
}