import '../../dji_service.dart'; import '../hud_features.dart'; import '../product_profile.dart'; import '../state/aircraft_state.dart'; import '../state/key_state.dart'; import '../state/parse.dart'; /// What a row's predicates may look at. class SettingsContext { const SettingsContext({required this.profile, required this.keys, required this.aircraft}); final ProductProfile profile; final KeyState keys; final AircraftState aircraft; bool get connected => profile.connected; /// Airborne or motors running: the FC refuses some settings then. bool get airborne => aircraft.isFlying == true || aircraft.motorsOn == true; } /// One row of the settings sheet (plan P6, §1.4): data, not widgets, so a new /// setting is one line in `settings_tabs.dart`. /// /// A row bound to a [key] hides once that key is known to be unsupported on /// the connected product; [requires] gates it on the profile like any HUD /// element; [visibleWhen] hides it on another setting's value (the distance /// value only while the limit is on). [groundOnly] rows are locked while the /// aircraft is airborne or the motors run. sealed class SettingSpec { const SettingSpec({required this.id, required this.label, this.hint, this.requires, this.visibleWhen, this.groundOnly = false}); final String id; final String label; final String? hint; final HudFeature? requires; final bool Function(SettingsContext c)? visibleWhen; final bool groundOnly; /// The key the row reads, or null for app-side rows. DjiKey? get key => null; /// Every key the row needs listened to. Iterable get watched => key == null ? const [] : [key!]; bool visible(SettingsContext c) { if (requires != null && c.connected && !requires!.enabledFor(c.profile)) return false; final DjiKey? k = key; if (k != null && c.keys.supported(k) == false) return false; return visibleWhen?.call(c) ?? true; } /// Why the row cannot be changed right now, or null. String? lockedReason(SettingsContext c) { if (key == null) return null; if (!c.connected) return 'Connect the aircraft to change'; if (groundOnly && c.airborne) return 'Land and stop the motors to change'; return null; } } /// A section label. class GroupSpec extends SettingSpec { const GroupSpec(String label, {super.requires, super.visibleWhen}) : super(id: 'group-$label', label: label); } class KeySwitchSpec extends SettingSpec { const KeySwitchSpec({ required super.id, required super.label, required DjiKey key, super.hint, super.requires, super.visibleWhen, super.groundOnly, }) : _key = key; final DjiKey _key; @override DjiKey get key => _key; bool? current(KeyState s) => asBool(s.value(_key)); } /// An SDK enum, offered as chips. [options] are (enum name, label); when /// [supportedKey] reports a list of names, only those are offered. class KeyChoiceSpec extends SettingSpec { const KeyChoiceSpec({ required super.id, required super.label, required DjiKey key, required this.options, this.supportedKey, super.hint, super.requires, super.visibleWhen, super.groundOnly, }) : _key = key; final DjiKey _key; final List<(String, String)> options; final DjiKey? supportedKey; @override DjiKey get key => _key; @override Iterable get watched => [_key, if (supportedKey != null) supportedKey!]; String? current(KeyState s) => asString(s.value(_key)); List<(String, String)> optionsFor(KeyState s) { final Object? names = supportedKey == null ? null : s.value(supportedKey!); final List<(String, String)> offered = names is List ? <(String, String)>[for (final (String, String) o in options) if (names.contains(o.$1)) o] : options; // A value the table does not know (STYLE_CUSTOM set elsewhere) still shows. final String? v = current(s); if (v != null && !offered.any(((String, String) o) => o.$1 == v)) { return <(String, String)>[...offered, (v, prettyEnum(v))]; } return offered; } } /// An integer stepped between bounds. The bounds come from [rangeKey] when the /// aircraft reports one (`{min, max}` or `[min, max]`), else [min] / [max]; /// [bounds] narrows them further from other settings. class KeyNumberSpec extends SettingSpec { const KeyNumberSpec({ required super.id, required super.label, required DjiKey key, required this.min, required this.max, this.step = 1, this.unit = '', this.rangeKey, this.bounds, this.feet = false, super.hint, super.requires, super.visibleWhen, super.groundOnly, }) : _key = key; final DjiKey _key; final int min; final int max; final int step; final String unit; final DjiKey? rangeKey; final (int, int) Function(KeyState s, int min, int max)? bounds; /// Metres: show "≈ N ft" beside the value. final bool feet; @override DjiKey get key => _key; @override Iterable get watched => [_key, if (rangeKey != null) rangeKey!]; int? current(KeyState s) => asInt(s.value(_key)); (int, int) range(KeyState s) { int lo = min, hi = max; final Object? r = rangeKey == null ? null : s.value(rangeKey!); final Map? m = asMap(r); if (m != null && m['supported'] != false && asInt(m['min']) != null && asInt(m['max']) != null) { lo = asInt(m['min'])!; hi = asInt(m['max'])!; } else if (r is List && r.length == 2 && asInt(r[0]) != null && asInt(r[1]) != null) { lo = asInt(r[0])!; hi = asInt(r[1])!; } if (bounds != null) (lo, hi) = bounds!(s, lo, hi); return (lo, hi); } /// [v] moved by one step in [dir], clamped, or null when it cannot move. int? stepped(KeyState s, int dir) { final int? v = current(s); if (v == null) return null; final (int lo, int hi) = range(s); final int next = (v + dir * step).clamp(lo, hi); return next == v ? null : next; } } /// A read-only value. class KeyReadoutSpec extends SettingSpec { const KeyReadoutSpec({ required super.id, required super.label, required DjiKey key, this.format = formatValue, super.hint, super.requires, super.visibleWhen, }) : _key = key; final DjiKey _key; final String Function(Object? v) format; @override DjiKey get key => _key; @override String? lockedReason(SettingsContext c) => null; String shown(KeyState s) { final Object? v = s.value(_key); return v == null ? '—' : format(v); } } /// A string the pilot types (aircraft name, a remote's name). [secret] masks /// it and never shows the stored value; [maxLength] caps what is typed; /// [send] replaces the plain `setKey` for values the SDK only takes through a /// bespoke method. class KeyTextSpec extends SettingSpec { const KeyTextSpec({ required super.id, required super.label, required DjiKey key, this.secret = false, this.maxLength = 32, this.send, super.hint, super.requires, super.visibleWhen, super.groundOnly, }) : _key = key; final DjiKey _key; final bool secret; final int maxLength; final Future Function(DjiService dji, String value)? send; @override DjiKey get key => _key; String? current(KeyState s) => asString(s.value(_key)); String shown(KeyState s) { final String? v = current(s); if (secret) return v == null || v.isEmpty ? '—' : '••••••'; return v == null || v.isEmpty ? '—' : v; } } /// A button that runs an argument-less action key (`START_PAIRING`). class KeyActionSpec extends SettingSpec { const KeyActionSpec({ required super.id, required super.label, required DjiKey key, required this.button, this.confirmTitle, this.confirmText, super.hint, super.requires, super.visibleWhen, super.groundOnly, }) : _key = key; final DjiKey _key; final String button; final String? confirmTitle; final String? confirmText; @override DjiKey get key => _key; /// Action keys are not values: `isKeySupported` is the only gate. @override Iterable get watched => const []; } /// A row the sheet draws itself (home point, stick-mode diagram, the gimbal /// list, app preferences, About). [keys] are still watched and gate nothing. class CustomSpec extends SettingSpec { const CustomSpec({required super.id, super.label = '', this.keys = const [], super.requires, super.visibleWhen}); final List keys; @override Iterable get watched => keys; } /// A tab of the sheet. class SettingsTab { const SettingsTab({required this.id, required this.label, required this.icon, required this.rows, this.requires}); final String id; final String label; /// A `PVIcon` name. final String icon; final List rows; final HudFeature? requires; Iterable get watched sync* { for (final SettingSpec r in rows) { yield* r.watched; if (r is KeyActionSpec) yield r.key; } } bool visible(ProductProfile p) => requires == null || !p.connected || requires!.enabledFor(p); } // ── Formatting ──────────────────────────────────────────────────────────────── /// `FREQUENCY_BAND_2_DOT_4_GHZ` → `Frequency band 2.4 ghz`-ish fallback label. String prettyEnum(String v) { final String s = v.replaceAll('_DOT_', '.').replaceAll('_', ' ').toLowerCase(); return s.isEmpty ? v : s[0].toUpperCase() + s.substring(1); } String formatValue(Object? v) => switch (v) { null => '—', bool b => b ? 'On' : 'Off', double d => d == d.roundToDouble() ? d.toInt().toString() : d.toStringAsFixed(1), String s when s.toUpperCase() == s && s.contains('_') => prettyEnum(s), List l => l.map(formatValue).join(', '), _ => v.toString(), }; /// `≈ 394 ft` for [m] metres. String feetHint(int m) => '≈ ${(m * 3.28084).round()} ft'; /// One battery `WarningRecord` (as `KeyBridge.kt` maps it) → what went wrong. String formatWarningRecord(Object? v) { final Map? m = asMap(v); if (m == null) return formatValue(v); final List found = [ if (asBool(m['overCurrent']) == true) 'over-current', if (asBool(m['overHeated']) == true) 'overheated', if (asBool(m['lowTemperature']) == true) 'too cold', if (asBool(m['shortCircuit']) == true) 'short circuit', if (asBool(m['underVoltage']) == true) 'under-voltage', if ((asInt(m['lowVoltageCell']) ?? 0) > 0) 'low cell ${asInt(m['lowVoltageCell'])}', if ((asInt(m['damagedCell']) ?? 0) > 0) 'damaged cell ${asInt(m['damagedCell'])}', ]; if (found.isEmpty) return asBool(m['error']) == true ? 'Error' : 'None'; return found.join(', '); } /// A list of `WarningRecord`s → how many flights had a warning, and the worst. String formatWarningHistory(Object? v) { if (v is! List) return formatValue(v); if (v.isEmpty) return 'None'; final List bad = [ for (final Object? r in v) if (formatWarningRecord(r) case final String s when s != 'None') s, ]; if (bad.isEmpty) return 'None in ${v.length} records'; return '${bad.length} of ${v.length}: ${bad.first}'; } /// 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'; }