Vision radar: graded obstacle sectors around the centre of the video, the nearest distance per side, the obstacle overhead, and BLOCKED on a side whose sensors cannot see once the motors run. On aircraft with 360° sensing, sides without their own readings are filled from the perception ring. The same sectors sit on the attitude ball's rim, turned by the heading. Settings > Vision gains Display radar chart and the braking / sensing distances per direction. On-screen sticks: the rail's STICKS button hands the aircraft to two stick pads after a slide-to-confirm. The 25 Hz stream runs natively and sends zeros once Dart's last command is 300 ms old, so a stalled UI leaves the aircraft hovering. The sticks close when the remote takes over, on RTH or on landing. They are simulator-only on aircraft with a remote unless Settings > General > Developer allows real flights; Spark and Mavic Air on Wi-Fi may fly them for real. Which MSDK axis is forward in velocity + body mode is coded from DJI's documentation and must be checked in the simulator first. AirSense: a banner for manned aircraft at warning level 2 and up, with distance, direction and ICAO code; the planes on the map; the full list in a sheet and Settings > Safety; and the aircraft's own avoidance action with its countdown. Fly zones: DJI GEO zones around the aircraft (or the phone) on the map with their height limits, a banner for FlySafe notices and in/near-zone states, take-off refused in restricted and locked authorization zones, self-unlock of authorization zones, the aircraft's licences (enable, sync from the DJI account, remove all; ground only), and the GEO database version with an update button. Tested with unit and widget tests and screenshot renders only; nothing has run on an aircraft or the simulator yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
505 lines
19 KiB
Dart
505 lines
19 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 '../camera_menu/camera_rows.dart';
|
|
import '../gimbal_sheet.dart';
|
|
import 'calibration/calibration_hub.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,
|
|
}) {
|
|
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,
|
|
),
|
|
),
|
|
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,
|
|
});
|
|
|
|
final FlightModel model;
|
|
final DjiService dji;
|
|
final void Function(String label, Object error) onError;
|
|
final String initialTab;
|
|
final VoidCallback? onOpenStatus;
|
|
final VoidCallback? onClearRoute;
|
|
|
|
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.airSenseList => AirSenseList(key: const ValueKey<String>('row-airSense'), airSense: _m.airSense, shrinkWrap: true),
|
|
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,
|
|
),
|
|
),
|
|
]);
|
|
}
|
|
}
|