Files
PilotVault/Fly App/lib/fly/settings/settings_spec.dart
T
tajniak81andClaude Opus 5.5 636da1ac2c Give the Fly App every remaining GO 4 flight mode and the missing settings
Intelligent modes: Point of Interest (orbit on the map, live radius and
speed), Follow Me (follows the phone), QuickShots on a boxed subject,
Course Lock / Home Lock, Tripod and Cinematic, a Free hyperlapse, and
Waypoints 2.0 for V2-only aircraft. ActiveTrack gains Spotlight Pro and
Circle. Every mode has a first-use tutorial and stops from the rail.
Waypoints can resume an interrupted mission, change cruise speed in
flight and check terrain clearance (Open-Meteo, on tap).

Settings: units, C1/C2 actions, dual-operator master/slave, HDMI/SDI
output, a link spectrum chart, Wi-Fi name and password, aircraft name,
video cache, battery warning history and pairing, non-smart cell
voltages. Calibration: compass interference meter, per-IMU calibration,
lens infinity focus and a first-manual-focus prompt. Also an RTK chip,
gimbal movement presets and motor tuning, Precision Takeoff gated on the
flight controller, and the precise GEO database's server version.

Built and unit/widget-tested only; nothing has flown yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:06:16 +02:00

367 lines
11 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 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<void> 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<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';
/// One battery `WarningRecord` (as `KeyBridge.kt` maps it) → what went wrong.
String formatWarningRecord(Object? v) {
final Map<String, dynamic>? m = asMap(v);
if (m == null) return formatValue(v);
final List<String> found = <String>[
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<String> bad = <String>[
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';
}