Files
PilotVault/Fly App/lib/fly/settings/settings_tabs.dart
T
tajniak81andClaude Opus 5.5 284de5442a 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>
2026-10-19 21:13:45 +02:00

492 lines
21 KiB
Dart

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);
}