Files
PilotVault/Fly App/lib/fly/settings/settings_spec.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

303 lines
9.1 KiB
Dart

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