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:
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
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)),
|
||||
]);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user