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

546 lines
22 KiB
Dart

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/safety_gate.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 '../../../../fly/virtual_sticks.dart' show StickMode;
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 '../../overlays/airsense.dart';
import '../../overlays/fly_zone_licences.dart';
import '../../overlays/fly_zones.dart';
import '../../overlays/live_stream.dart';
import '../camera_menu/camera_rows.dart';
import '../gimbal_sheet.dart';
import 'calibration/calibration_hub.dart';
import 'link_rows.dart';
import 'sensing_rows.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,
VoidCallback? onOpenFlightLogs,
}) {
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,
onOpenFlightLogs: onOpenFlightLogs,
),
),
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,
this.onOpenFlightLogs,
});
final FlightModel model;
final DjiService dji;
final void Function(String label, Object error) onError;
final String initialTab;
final VoidCallback? onOpenStatus;
final VoidCallback? onClearRoute;
/// Opens the flight records (General → Flight logs); the row hides without it.
final VoidCallback? onOpenFlightLogs;
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.radarChart => ValueListenableBuilder<bool>(
valueListenable: FlyPrefs.showRadar,
builder: (BuildContext context, bool on, _) => SwitchTile(
key: const ValueKey<String>('row-radarChart'),
label: 'Display radar chart',
hint: 'Obstacle distances around the centre of the video',
value: on,
onChanged: FlyPrefs.setShowRadar,
),
),
SettingsTabs.stickPads => ValueListenableBuilder<StickMode>(
valueListenable: FlyPrefs.stickMode,
builder: (BuildContext context, StickMode m, _) => ChoiceTile(
key: const ValueKey<String>('row-stickPads'),
label: 'Stick mode',
hint: 'What each on-screen stick does; match your remote',
options: <(String, String)>[for (final StickMode s in StickMode.values) (s.name, s.label)],
selected: m.name,
onSelect: (String v) => FlyPrefs.setStickMode(StickMode.values.byName(v)),
),
),
SettingsTabs.sensingDistances =>
SensingDistanceRows(key: const ValueKey<String>('row-sensingDistances'), dji: widget.dji, connected: c.connected, onError: widget.onError),
SettingsTabs.flyZoneList => FlyZoneList(
key: const ValueKey<String>('row-flyZones'),
model: _m,
gate: SafetyGate(model: _m, dji: widget.dji),
onError: widget.onError,
shrinkWrap: true,
),
SettingsTabs.flyZoneLicences => FlyZoneLicences(
key: const ValueKey<String>('row-flyZoneLicences'),
model: _m,
gate: SafetyGate(model: _m, dji: widget.dji),
onError: widget.onError,
),
SettingsTabs.flyZoneDatabase => FlyZoneDatabaseRow(
key: const ValueKey<String>('row-flyZoneDatabase'),
zones: _m.flyZones,
gate: SafetyGate(model: _m, dji: widget.dji),
onError: widget.onError,
),
SettingsTabs.liveStream => LiveStreamRows(
key: const ValueKey<String>('row-liveStream'),
live: _m.liveStream,
dji: widget.dji,
connected: c.connected,
onError: widget.onError,
),
SettingsTabs.airSenseList => AirSenseList(key: const ValueKey<String>('row-airSense'), airSense: _m.airSense, shrinkWrap: true),
SettingsTabs.units => ValueListenableBuilder<UnitSystem>(
valueListenable: FlyPrefs.units,
builder: (BuildContext context, UnitSystem u, _) => ChoiceTile(
key: const ValueKey<String>('row-units'),
label: 'Units',
hint: 'What the flight screen shows; settings stay in metres',
options: <(String, String)>[for (final UnitSystem s in UnitSystem.values) (s.name, s.label)],
selected: u.name,
onSelect: (String v) => FlyPrefs.setUnits(UnitSystem.values.byName(v)),
),
),
SettingsTabs.customButtons => const CustomButtonRows(key: ValueKey<String>('row-customButtons')),
SettingsTabs.masterSearch =>
MasterSearchRows(key: const ValueKey<String>('row-masterSearch'), model: _m, dji: widget.dji, onError: widget.onError),
SettingsTabs.slaveList => SlaveListRow(key: const ValueKey<String>('row-slaveList'), dji: widget.dji, onError: widget.onError),
SettingsTabs.spectrum => SpectrumChart(key: const ValueKey<String>('spectrum'), model: _m, dji: widget.dji, connected: c.connected),
SettingsTabs.videoCache => VideoCacheRows(key: const ValueKey<String>('row-videoCacheGroup'), dji: widget.dji, onError: widget.onError),
SettingsTabs.flightLogs => widget.onOpenFlightLogs == null
? const SizedBox.shrink()
: ValueTile(
key: const ValueKey<String>('row-flightLogs'),
label: 'Flight logs',
hint: 'Every recorded flight, its track and its logbook upload',
value: 'Open',
onTap: widget.onOpenFlightLogs,
),
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,
),
ButtonTile(
id: 'openCalibration',
label: 'Calibration & maintenance',
hint: 'Compass, IMU, remote, gimbal and more',
button: 'Open',
onTap: () => showCalibrationHub(context, model: _m, dji: widget.dji, onError: widget.onError),
),
if (widget.onOpenStatus != null)
ButtonTile(
id: 'openStatus',
label: 'Aircraft status',
hint: 'Every subsystem at a glance',
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),
),
if (HudFeatures.virtualSticks.enabledFor(_profile) || !_profile.connected)
ValueListenableBuilder<bool>(
valueListenable: FlyPrefs.sticksInRealFlight,
builder: (BuildContext context, bool on, _) => SwitchTile(
key: const ValueKey<String>('row-sticksRealFlight'),
label: 'On-screen sticks in real flight',
hint: 'Off: the sticks only fly the simulator on aircraft with a remote',
value: on,
onChanged: FlyPrefs.setSticksInRealFlight,
),
),
]);
}
}