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>
This commit is contained in:
1 parent
91ca150a35
commit
636da1ac2c
57 files changed
+5363
-87
No files matched your search
@@ -1,10 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../fly/fly_prefs.dart';
|
||||
import '../../../fly/hud_features.dart';
|
||||
import '../../../fly/state/aircraft_state.dart';
|
||||
import '../../../fly/units.dart';
|
||||
import '../hud_kit.dart';
|
||||
|
||||
/// The bottom telemetry strip (plan P2): H · D · VS (with arrow) · HS · VPS.
|
||||
/// The bottom telemetry strip (plan P2): H · D · VS (with arrow) · HS · VPS,
|
||||
/// in metres or feet as Settings → General → Units says.
|
||||
class TelemetryStrip extends StatelessWidget {
|
||||
const TelemetryStrip({super.key, required this.aircraft});
|
||||
|
||||
@@ -13,21 +16,25 @@ class TelemetryStrip extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: aircraft,
|
||||
listenable: Listenable.merge(<Listenable>[aircraft, FlyPrefs.units]),
|
||||
builder: (BuildContext context, _) {
|
||||
final AircraftState a = aircraft;
|
||||
final UnitSystem u = FlyPrefs.units.value;
|
||||
final double? vs = a.verticalSpeed;
|
||||
// Under 0.1 m/s is hover noise; no arrow for it.
|
||||
final int dir = vs == null || vs.abs() < 0.1 ? 0 : vs.sign.toInt();
|
||||
double? len(double? m) => m == null ? null : Units.lengthValue(m, u);
|
||||
double? spd(double? ms) => ms == null ? null : Units.speedValue(ms, u);
|
||||
final String l = Units.lengthUnit(u), s = Units.speedUnit(u);
|
||||
return GlassPill(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
_Field('H', fmtNum(a.altitude), 'm'),
|
||||
_Field('D', fmtNum(a.homeDistance, digits: 0), 'm'),
|
||||
_Field('VS', fmtNum(vs?.abs()), 'm/s', arrow: dir),
|
||||
_Field('HS', fmtNum(a.horizontalSpeed), 'm/s'),
|
||||
Requires(HudFeatures.vpsReadout, child: _Field('VPS', fmtNum(a.vpsHeight), 'm')),
|
||||
_Field('H', fmtNum(len(a.altitude)), l),
|
||||
_Field('D', fmtNum(len(a.homeDistance), digits: 0), l),
|
||||
_Field('VS', fmtNum(spd(vs?.abs())), s, arrow: dir),
|
||||
_Field('HS', fmtNum(spd(a.horizontalSpeed)), s),
|
||||
Requires(HudFeatures.vpsReadout, child: _Field('VPS', fmtNum(len(a.vpsHeight)), l)),
|
||||
]),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
import '../../dji_service.dart';
|
||||
import '../../flight_model.dart';
|
||||
import '../../fly/calibration.dart' show CalibrationKind;
|
||||
import '../../fly/flight_trail.dart';
|
||||
import '../../fly/fly_zone_loader.dart';
|
||||
import '../../fly/fly_prefs.dart';
|
||||
import '../../fly/gimbal_control.dart' show GimbalActions;
|
||||
import '../../fly/hud_features.dart';
|
||||
import '../../fly/modes/mission_library.dart';
|
||||
import '../../fly/modes/mission_sync.dart' show ApiMissionRemote;
|
||||
import '../../fly/modes/orbit_modes.dart';
|
||||
import '../../fly/modes/vision_modes.dart';
|
||||
import '../../fly/state/link_extras.dart' show RcButtonPress;
|
||||
import '../../fly/state/parse.dart' show asString;
|
||||
import '../../fly/modes/waypoint_editor.dart';
|
||||
import '../../fly/modes/waypoint_mission.dart' show MissionLimits;
|
||||
import '../../fly/product_profile.dart';
|
||||
@@ -30,6 +37,7 @@ import 'hud_kit.dart';
|
||||
import 'layers/video_layer.dart';
|
||||
import 'left/action_rail.dart';
|
||||
import 'left/gimbal_slider.dart';
|
||||
import 'modes/mode_panels.dart';
|
||||
import 'modes/mode_picker.dart';
|
||||
import 'modes/vision/vision_layers.dart';
|
||||
import 'modes/vision/vision_panel.dart';
|
||||
@@ -51,7 +59,9 @@ import 'overlays/vision_radar.dart';
|
||||
import 'panels/aircraft_status_sheet.dart';
|
||||
import 'panels/camera_menu/camera_menu_sheet.dart';
|
||||
import 'panels/camera_quick_exposure.dart';
|
||||
import 'panels/camera_write.dart';
|
||||
import 'panels/gimbal_sheet.dart';
|
||||
import 'panels/settings/calibration/calibration_hub.dart' show openCalibration;
|
||||
import 'panels/settings/settings_sheet.dart';
|
||||
import 'right/camera_column.dart';
|
||||
import 'right/zoom_column.dart';
|
||||
@@ -100,7 +110,7 @@ import 'top/status_bar.dart';
|
||||
/// album over the live view (videos play on the aircraft into the feed), and
|
||||
/// a LIVE pill heads the left column while an RTMP stream runs.
|
||||
class FlyScreen extends StatefulWidget {
|
||||
const FlyScreen({super.key, required this.model, required this.dji, this.waypoints});
|
||||
const FlyScreen({super.key, required this.model, required this.dji, this.waypoints, this.onOpenFlightLogs});
|
||||
|
||||
final FlightModel model;
|
||||
final DjiService dji;
|
||||
@@ -108,6 +118,9 @@ class FlyScreen extends StatefulWidget {
|
||||
/// The Waypoints editor to use; the screen makes (and disposes) its own when null.
|
||||
final WaypointEditor? waypoints;
|
||||
|
||||
/// Opens the flight records (Settings → General → Flight logs).
|
||||
final VoidCallback? onOpenFlightLogs;
|
||||
|
||||
@override
|
||||
State<FlyScreen> createState() => _FlyScreenState();
|
||||
}
|
||||
@@ -140,6 +153,23 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
/// Keeps the GEO zones around the aircraft loaded (plan P8).
|
||||
late final FlyZoneLoader _zones = FlyZoneLoader(model: widget.model, dji: widget.dji);
|
||||
|
||||
/// POI / Follow Me / Hyperlapse set-up (plan P7).
|
||||
final ModeSetup _setup = ModeSetup();
|
||||
|
||||
/// Sends the phone's position to Follow Me while it runs.
|
||||
Timer? _followTimer;
|
||||
|
||||
/// The waypoint the current route was interrupted before, if it was.
|
||||
int? _resumeFrom;
|
||||
int _seenModeFinishes = 0;
|
||||
|
||||
/// The live-feed cache is recording (plan P6): the aircraft flies and the pref is on.
|
||||
bool _caching = false;
|
||||
|
||||
/// The last focus mode seen, for the first-manual-focus lens prompt.
|
||||
String? _focusMode;
|
||||
bool _lensPromptShown = false;
|
||||
|
||||
/// Waypoints is open on the big map and nothing is flying: taps edit.
|
||||
bool get _editing {
|
||||
final WaypointProgress run = _m.mission.waypoint;
|
||||
@@ -166,13 +196,100 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
_wasConnected = _m.connected;
|
||||
_m.addListener(_onModel);
|
||||
_seenFinishes = _m.mission.waypointFinishes;
|
||||
_seenModeFinishes = _m.mission.modeFinishes;
|
||||
_m.mission.addListener(_onMission);
|
||||
_m.aircraft.addListener(_onSticksAircraft);
|
||||
_m.aircraft.addListener(_onCacheAircraft);
|
||||
FlyPrefs.videoCache.addListener(_onCacheAircraft);
|
||||
_m.rcButton.addListener(_onRcButton);
|
||||
_focusMode = asString(_m.camera.setting('focusMode'));
|
||||
_m.camera.addListener(_onCamera);
|
||||
_sticks.addListener(_onSticks);
|
||||
FlyPrefs.stickMode.addListener(_onStickMode);
|
||||
_zones.start();
|
||||
}
|
||||
|
||||
// ── Video cache (plan P6) ───────────────────────────────────────────────────
|
||||
|
||||
/// Records the live feed while the aircraft flies, when the pref is on.
|
||||
void _onCacheAircraft() {
|
||||
final bool want = FlyPrefs.videoCache.value && _m.connected && _m.aircraft.isFlying == true;
|
||||
if (want == _caching) return;
|
||||
_caching = want;
|
||||
_dji.setVideoCacheRecording(want, limitMb: FlyPrefs.videoCacheLimitMb.value).catchError((Object _) {});
|
||||
}
|
||||
|
||||
// ── C1 / C2 (plan P6) ───────────────────────────────────────────────────────
|
||||
|
||||
void _onRcButton() {
|
||||
final RcButtonPress? p = _m.rcButton.value;
|
||||
if (p == null || !mounted) return;
|
||||
_runButtonAction(FlyPrefs.rcButton(p.button));
|
||||
}
|
||||
|
||||
Future<void> _runButtonAction(RcButtonAction a) async {
|
||||
try {
|
||||
switch (a) {
|
||||
case RcButtonAction.none:
|
||||
break;
|
||||
case RcButtonAction.gimbalRecenter:
|
||||
await _dji.resetGimbal();
|
||||
case RcButtonAction.gimbalToggleDown:
|
||||
// Down when roughly level, level when pointing down.
|
||||
final double pitch = _m.gimbal.pitch ?? 0;
|
||||
await _dji.rotateGimbalPitch(pitch < -45 ? 0 : GimbalActions.faceDownPitch(_m.profile.gimbal?.pitch));
|
||||
case RcButtonAction.mapSwap:
|
||||
setState(() => _mapBig = !_mapBig);
|
||||
case RcButtonAction.cameraSettings:
|
||||
await showCameraMenu(context, model: _m, dji: _dji, onError: _error);
|
||||
case RcButtonAction.aircraftStatus:
|
||||
await _openStatus(context);
|
||||
case RcButtonAction.aeLock:
|
||||
final bool on = _m.camera.aeLock != true;
|
||||
await writeCameraSetting(_m.camera, label: 'AE lock', pending: <String, Object?>{'aeLock': on}, send: () => _dji.setCameraValue('aeLock', on), onError: _error);
|
||||
case RcButtonAction.radar:
|
||||
await FlyPrefs.setShowRadar(!FlyPrefs.showRadar.value);
|
||||
}
|
||||
} catch (e) {
|
||||
_error(a.label, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── First manual focus: offer the lens calibration (plan P6b) ──────────────
|
||||
|
||||
void _onCamera() {
|
||||
final String? mode = asString(_m.camera.setting('focusMode'));
|
||||
if (mode == _focusMode) return;
|
||||
final bool toManual = mode == 'MANUAL' && _focusMode != null;
|
||||
_focusMode = mode;
|
||||
if (!toManual || _lensPromptShown || !mounted) return;
|
||||
final String camera = _m.profile.camera?.displayName ?? _m.model ?? 'camera';
|
||||
if (!HudFeatures.calibrationLens.enabledFor(_m.profile) || FlyPrefs.lensCalibrated.value.contains(camera)) return;
|
||||
_lensPromptShown = true;
|
||||
_offerLensCalibration(camera);
|
||||
}
|
||||
|
||||
Future<void> _offerLensCalibration(String camera) async {
|
||||
final bool? go = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF10203F),
|
||||
title: Text('Calibrate the lens?', style: HudText.sans.copyWith(fontSize: 15)),
|
||||
content: Text(
|
||||
'Manual focus is only accurate once the lens has been calibrated on this camera. It takes a minute, on the ground.',
|
||||
style: HudText.sans.copyWith(fontWeight: FontWeight.w400),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Not now')),
|
||||
FilledButton(key: const ValueKey<String>('lens-prompt-go'), onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Calibrate')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (go != true || !mounted) return;
|
||||
await openCalibration(context, CalibrationKind.lens, model: _m, dji: _dji, onError: _error);
|
||||
if (_m.calibration.lensResult == 'COMPLETED') await FlyPrefs.markLensCalibrated(camera);
|
||||
}
|
||||
|
||||
void _onStickMode() => _sticks.mode = FlyPrefs.stickMode.value;
|
||||
|
||||
/// The flight controller can end virtual-stick mode on its own (the remote's
|
||||
@@ -235,6 +352,25 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
..clearSnackBars()
|
||||
..showSnackBar(SnackBar(content: Text(r.isEmpty ? 'Waypoint mission complete' : 'Waypoint mission ended: $r')));
|
||||
}
|
||||
// A mission cut short may be resumed from where it stopped.
|
||||
_checkResume();
|
||||
}
|
||||
if (ms.modeFinishes != _seenModeFinishes) {
|
||||
_seenModeFinishes = ms.modeFinishes;
|
||||
final String? r = ms.modeResult;
|
||||
if (mounted && r != null && r.isNotEmpty) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..clearSnackBars()
|
||||
..showSnackBar(SnackBar(content: Text('Mode ended: $r')));
|
||||
}
|
||||
}
|
||||
// Follow Me needs the phone's position for as long as it follows.
|
||||
final bool following = ms.followMe.active;
|
||||
if (following && _followTimer == null) {
|
||||
_followTimer = Timer.periodic(FollowMeSpec.updateEvery, (_) => _gate.updateFollowMe().catchError((Object _) {}));
|
||||
} else if (!following && _followTimer != null) {
|
||||
_followTimer!.cancel();
|
||||
_followTimer = null;
|
||||
}
|
||||
// Execution events arrive at ~10 Hz; rebuild the screen only when editing turns on or off.
|
||||
final bool editing = _editing;
|
||||
@@ -248,15 +384,18 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
if (m == null || !mounted) return;
|
||||
switch (m) {
|
||||
case IntelligentMode.waypoint:
|
||||
case IntelligentMode.waypointV2:
|
||||
setState(() {
|
||||
_mode = m;
|
||||
_mode = IntelligentMode.waypoint;
|
||||
_mapBig = true;
|
||||
});
|
||||
_minimap.setFollow(_waypoints.mission.points.isEmpty);
|
||||
final MissionLimits limits = await _gate.missionLimits();
|
||||
if (mounted) setState(() => _limits = limits);
|
||||
_checkResume();
|
||||
case IntelligentMode.activeTrack:
|
||||
case IntelligentMode.tapFly:
|
||||
case IntelligentMode.quickShots:
|
||||
// Vision modes work on the live video.
|
||||
_vision.clearPending();
|
||||
setState(() {
|
||||
@@ -270,18 +409,46 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
_vision.setRetreat(null); // not reported: the chip starts off
|
||||
}
|
||||
}
|
||||
default:
|
||||
break; // the picker only returns built modes
|
||||
case IntelligentMode.pointOfInterest:
|
||||
// The centre is picked on the map; it stays big until GO.
|
||||
setState(() {
|
||||
_mode = m;
|
||||
_mapBig = !_m.mission.hotpoint.active;
|
||||
});
|
||||
if (_setup.poi.center == null && _m.aircraft.latitude != null && _m.aircraft.longitude != null) {
|
||||
_minimap.setFollow(true);
|
||||
}
|
||||
case IntelligentMode.followMe:
|
||||
case IntelligentMode.courseLock:
|
||||
case IntelligentMode.homeLock:
|
||||
case IntelligentMode.tripod:
|
||||
case IntelligentMode.hyperlapse:
|
||||
setState(() {
|
||||
_mode = m;
|
||||
_mapBig = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks whether the route on screen was cut short, to offer RESUME.
|
||||
Future<void> _checkResume() async {
|
||||
final int? i = await _gate.interruptionFor(_waypoints.mission);
|
||||
if (mounted && i != _resumeFrom) setState(() => _resumeFrom = i);
|
||||
}
|
||||
|
||||
void _resume(BuildContext context) {
|
||||
final int? i = _resumeFrom;
|
||||
if (i == null) return;
|
||||
startWaypointFlow(context, gate: _gate, spec: _waypoints.mission.from(i), onError: _error);
|
||||
}
|
||||
|
||||
void _exitMode() => setState(() {
|
||||
_mode = null;
|
||||
_mapBig = false;
|
||||
_waypoints.select(null);
|
||||
});
|
||||
|
||||
bool get _visionMode => _mode == IntelligentMode.activeTrack || _mode == IntelligentMode.tapFly;
|
||||
bool get _visionMode => _mode == IntelligentMode.activeTrack || _mode == IntelligentMode.tapFly || _mode == IntelligentMode.quickShots;
|
||||
|
||||
/// Leaving a vision mode while it flies stops it first (GO 4's Exit).
|
||||
Future<void> _exitVision(BuildContext context) async {
|
||||
@@ -292,6 +459,36 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
if (mounted) setState(() => _mode = null);
|
||||
}
|
||||
|
||||
/// Leaving POI or Follow Me while it flies stops it first, like the vision modes.
|
||||
Future<void> _exitAuto(BuildContext context) async {
|
||||
final MissionState ms = _m.mission;
|
||||
final bool running = (_mode == IntelligentMode.pointOfInterest && ms.hotpoint.active) || (_mode == IntelligentMode.followMe && ms.followMe.active);
|
||||
if (running) await _flow.request(context, FlightCommand.stopMode);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_mode = null;
|
||||
_mapBig = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _goQuickShot(BuildContext context, Rect box) {
|
||||
final QuickShot shot = _vision.quickShot;
|
||||
startVisionMode(
|
||||
context,
|
||||
title: 'QuickShot · ${shot.label}',
|
||||
icon: 'dronie',
|
||||
description: '${shot.blurb}. The aircraft records, flies the shot and comes back. Stop, the sticks or the remote\'s pause button end it.',
|
||||
// QuickShots always get the slider: the aircraft flies far on its own.
|
||||
checks: <GateCheck>[..._gate.checkQuickShot(shot), if (_gate.checkQuickShot(shot).isEmpty) const GateCheck.warn('The aircraft flies the whole shot on its own.')],
|
||||
start: () async {
|
||||
await _gate.startQuickShot(box, shot);
|
||||
_vision.setPendingBox(null);
|
||||
},
|
||||
onError: _error,
|
||||
);
|
||||
}
|
||||
|
||||
void _goTrack(BuildContext context, Rect box, {int? subject}) {
|
||||
final TrackMode mode = _vision.trackMode;
|
||||
startVisionMode(
|
||||
@@ -354,7 +551,15 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
_m.removeListener(_onModel);
|
||||
_m.mission.removeListener(_onMission);
|
||||
_m.aircraft.removeListener(_onSticksAircraft);
|
||||
_m.aircraft.removeListener(_onCacheAircraft);
|
||||
FlyPrefs.videoCache.removeListener(_onCacheAircraft);
|
||||
_m.rcButton.removeListener(_onRcButton);
|
||||
_m.camera.removeListener(_onCamera);
|
||||
FlyPrefs.stickMode.removeListener(_onStickMode);
|
||||
_followTimer?.cancel();
|
||||
// The cache records only while this screen flies.
|
||||
if (_caching) _dji.setVideoCacheRecording(false).catchError((Object _) {});
|
||||
_setup.dispose();
|
||||
_zones.dispose();
|
||||
// Leaving the screen hands the aircraft back to the remote.
|
||||
if (_sticks.open) _gate.disableVirtualSticks().catchError((Object _) {});
|
||||
@@ -406,10 +611,15 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
flyZoneLicenceLayer(_m.flyZones),
|
||||
flyZoneHeightLayer(_m.flyZones),
|
||||
...waypointMapLayers(editor: _waypoints, mission: _m.mission, editable: _editing),
|
||||
if (_mode == IntelligentMode.pointOfInterest || _m.mission.hotpoint.active) ...poiMapLayers(_setup, _m.mission),
|
||||
airSenseMapLayer(_m.airSense),
|
||||
],
|
||||
onTap: (LatLng at) {
|
||||
if (_editing) _waypoints.tapMap(at);
|
||||
if (_editing) {
|
||||
_waypoints.tapMap(at);
|
||||
} else if (_mode == IntelligentMode.pointOfInterest && !_m.mission.hotpoint.active) {
|
||||
_setup.setPoi(_setup.poi.copyWith(center: at));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -420,13 +630,14 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
Positioned.fromRect(key: const ValueKey<String>('video-slot'), rect: full, child: video),
|
||||
Positioned.fill(child: VisualOverlays(video: _m.videoSize)),
|
||||
// A vision mode owns every touch on the video: a box or a point.
|
||||
if (_mode == IntelligentMode.activeTrack)
|
||||
if (_mode == IntelligentMode.activeTrack || _mode == IntelligentMode.quickShots)
|
||||
Positioned.fill(
|
||||
child: ActiveTrackLayer(
|
||||
mission: _m.mission,
|
||||
session: _vision,
|
||||
video: _m.videoSize,
|
||||
onGo: (Rect box, int? subject) => _goTrack(context, box, subject: subject),
|
||||
onGo: (Rect box, int? subject) =>
|
||||
_mode == IntelligentMode.quickShots ? _goQuickShot(context, box) : _goTrack(context, box, subject: subject),
|
||||
onConfirm: _confirmTrack,
|
||||
),
|
||||
)
|
||||
@@ -502,6 +713,28 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// The open mode's panel at the head of the left column, for the modes
|
||||
/// that are neither Waypoints nor a vision mode.
|
||||
Widget? _modePanel(BuildContext context) {
|
||||
final MissionState ms = _m.mission;
|
||||
// A POI or Follow Me flying keeps its panel even after the mode was left.
|
||||
final IntelligentMode? m = _mode ??
|
||||
(ms.hotpoint.active
|
||||
? IntelligentMode.pointOfInterest
|
||||
: ms.followMe.active
|
||||
? IntelligentMode.followMe
|
||||
: null);
|
||||
return switch (m) {
|
||||
IntelligentMode.pointOfInterest =>
|
||||
PoiPanel(model: _m, setup: _setup, gate: _gate, flow: _flow, dji: _dji, onExit: () => _exitAuto(context), onError: _error),
|
||||
IntelligentMode.followMe => FollowMePanel(model: _m, setup: _setup, gate: _gate, flow: _flow, onExit: () => _exitAuto(context), onError: _error),
|
||||
IntelligentMode.courseLock || IntelligentMode.homeLock => OrientationPanel(mode: m!, model: _m, gate: _gate, onExit: _exitMode, onError: _error),
|
||||
IntelligentMode.tripod => SlowModePanel(model: _m, dji: _dji, gate: _gate, onExit: _exitMode, onError: _error),
|
||||
IntelligentMode.hyperlapse => HyperlapsePanel(model: _m, setup: _setup, gate: _gate, onExit: _exitMode, onError: _error),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
Widget _hud(BuildContext context, Size canvas, double scale) {
|
||||
return Stack(clipBehavior: Clip.none, children: <Widget>[
|
||||
// ── Camera column (right) ─────────────────────────────────────────────
|
||||
@@ -592,6 +825,7 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
onError: _error,
|
||||
onOpenStatus: () => _openStatus(context),
|
||||
onClearRoute: _trail.clear,
|
||||
onOpenFlightLogs: widget.onOpenFlightLogs,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -688,6 +922,7 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: VisionModePanel(
|
||||
tapFly: _mode == IntelligentMode.tapFly,
|
||||
quickShots: _mode == IntelligentMode.quickShots,
|
||||
model: _m,
|
||||
session: _vision,
|
||||
gate: _gate,
|
||||
@@ -696,6 +931,7 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
onError: _error,
|
||||
),
|
||||
),
|
||||
if (_modePanel(context) case final Widget panel) Padding(padding: const EdgeInsets.only(bottom: 6), child: panel),
|
||||
if (_editing)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
@@ -703,6 +939,8 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
editor: _waypoints,
|
||||
limits: _limits,
|
||||
onTap: () => showRouteSettings(context, editor: _waypoints, limits: _limits),
|
||||
resumeFrom: _resumeFrom,
|
||||
onResume: () => _resume(context),
|
||||
),
|
||||
),
|
||||
Requires(
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
import '../../../dji_service.dart';
|
||||
import '../../../flight_model.dart';
|
||||
import '../../../fly/hud_geometry.dart' show formatDuration;
|
||||
import '../../../fly/modes/intelligent_modes.dart';
|
||||
import '../../../fly/modes/orbit_modes.dart';
|
||||
import '../../../fly/product_profile.dart' show IntelligentMode;
|
||||
import '../../../fly/safety_gate.dart';
|
||||
import '../../../fly/settings/settings_spec.dart';
|
||||
import '../../../fly/state/key_state.dart';
|
||||
import '../../../fly/state/mission_state.dart';
|
||||
import '../../../fly/state/parse.dart';
|
||||
import '../../../theme.dart';
|
||||
import '../../pv_icons.dart';
|
||||
import '../dialogs/slide_to_confirm.dart';
|
||||
import '../flight_command_flow.dart';
|
||||
import '../hud_kit.dart';
|
||||
import '../panels/settings/setting_tiles.dart';
|
||||
import 'mode_picker.dart' show ensureModeTutorial;
|
||||
import 'waypoints/point_editor.dart' show HudChip;
|
||||
|
||||
/// Confirms a mission-type mode with its gate checks and starts it: always
|
||||
/// slide-to-confirm, since the aircraft goes off on its own; blockers show
|
||||
/// their reasons and no slider.
|
||||
Future<void> confirmAndStart(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String icon,
|
||||
required String description,
|
||||
required List<GateCheck> checks,
|
||||
required Future<void> Function() start,
|
||||
required void Function(String label, Object error) onError,
|
||||
String slider = 'Slide to start',
|
||||
}) async {
|
||||
final SlideResult? r = await showSlideToConfirm(
|
||||
context,
|
||||
SlideConfirmSpec(icon: icon, title: title, description: description, sliderLabel: slider, checks: checks),
|
||||
);
|
||||
if (r == null) return;
|
||||
try {
|
||||
await start();
|
||||
} catch (e) {
|
||||
onError(title, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame every mode panel shares: icon, title, status, Stop while it
|
||||
/// flies, a "?" for the tutorial, Exit — then the panel's own rows.
|
||||
class ModePanelFrame extends StatelessWidget {
|
||||
const ModePanelFrame({
|
||||
super.key,
|
||||
required this.mode,
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.status,
|
||||
required this.statusColor,
|
||||
required this.children,
|
||||
required this.onExit,
|
||||
this.onStop,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final IntelligentMode mode;
|
||||
final String title;
|
||||
final String icon;
|
||||
final String status;
|
||||
final Color statusColor;
|
||||
final List<Widget> children;
|
||||
final VoidCallback onExit;
|
||||
|
||||
/// Shown while the mode flies the aircraft.
|
||||
final VoidCallback? onStop;
|
||||
final String? note;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
key: ValueKey<String>('mode-panel-${mode.name}'),
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
padding: const EdgeInsets.fromLTRB(10, 6, 6, 6),
|
||||
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(10), border: Border.all(color: Glass.hairline)),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||||
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
PVIcon(icon, size: 13, color: Glass.ink),
|
||||
const SizedBox(width: 6),
|
||||
Text(title, style: HudText.label.copyWith(color: Glass.ink, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(child: Text(status, overflow: TextOverflow.ellipsis, style: HudText.label.copyWith(color: statusColor, fontWeight: FontWeight.w700))),
|
||||
const SizedBox(width: 8),
|
||||
if (onStop != null) ...<Widget>[
|
||||
ModeButton(id: 'stop', icon: 'stop', label: 'STOP', color: HudColors.danger, onTap: onStop),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
ModeButton(
|
||||
id: 'help',
|
||||
icon: 'info',
|
||||
label: '?',
|
||||
color: Glass.pill,
|
||||
onTap: () => ensureModeTutorial(context, ModeCatalog.of(mode), force: true),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ModeButton(id: 'exit', icon: 'close', label: 'EXIT', color: Glass.pill, onTap: onExit),
|
||||
]),
|
||||
if (note != null) Padding(padding: const EdgeInsets.only(top: 3), child: Text(note!, style: HudText.label.copyWith(color: HudColors.caution))),
|
||||
const SizedBox(height: 5),
|
||||
...children,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ModeButton extends StatelessWidget {
|
||||
const ModeButton({super.key, required this.id, required this.icon, required this.label, required this.color, required this.onTap});
|
||||
|
||||
final String id;
|
||||
final String icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Opacity(
|
||||
opacity: onTap == null ? 0.4 : 1,
|
||||
child: GestureDetector(
|
||||
key: ValueKey<String>('mode-$id'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 24,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(7), border: Border.all(color: Glass.hairline)),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
PVIcon(icon, size: 11, color: Colors.white),
|
||||
const SizedBox(width: 3),
|
||||
Text(label, style: HudText.label.copyWith(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A compact − value + for the mode panels.
|
||||
class ModeStepper extends StatelessWidget {
|
||||
const ModeStepper({super.key, required this.id, required this.label, required this.value, required this.onStep, this.canDown = true, this.canUp = true});
|
||||
|
||||
final String id;
|
||||
final String label;
|
||||
final String value;
|
||||
final ValueChanged<int> onStep;
|
||||
final bool canDown;
|
||||
final bool canUp;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget b(String l, int d, bool on) => Opacity(
|
||||
opacity: on ? 1 : 0.35,
|
||||
child: GestureDetector(
|
||||
key: ValueKey<String>('$id-${d > 0 ? 'plus' : 'minus'}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: on ? () => onStep(d) : null,
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6), border: Border.all(color: Glass.hairline)),
|
||||
alignment: Alignment.center,
|
||||
child: Text(l, style: HudText.mono.copyWith(fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
);
|
||||
return Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
Text(label, style: HudText.label),
|
||||
const SizedBox(width: 4),
|
||||
b('−', -1, canDown),
|
||||
SizedBox(width: 58, child: Text(value, key: ValueKey<String>(id), textAlign: TextAlign.center, style: HudText.mono)),
|
||||
b('+', 1, canUp),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _guard(String label, Future<void> Function() f, void Function(String, Object) onError) async {
|
||||
try {
|
||||
await f();
|
||||
} catch (e) {
|
||||
onError(label, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Point of Interest ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Point of Interest (plan P7): set the centre (tap the map, or the
|
||||
/// aircraft's position), radius, altitude, speed, direction and heading; GO;
|
||||
/// then radius and speed live, Pause / Resume and Stop.
|
||||
class PoiPanel extends StatelessWidget {
|
||||
const PoiPanel({super.key, required this.model, required this.setup, required this.gate, required this.flow, required this.dji, required this.onExit, required this.onError});
|
||||
|
||||
final FlightModel model;
|
||||
final ModeSetup setup;
|
||||
final SafetyGate gate;
|
||||
final FlightCommandFlow flow;
|
||||
final DjiService dji;
|
||||
final VoidCallback onExit;
|
||||
final void Function(String label, Object error) onError;
|
||||
|
||||
Future<void> _go(BuildContext context) async {
|
||||
final PoiSpec p = setup.poi;
|
||||
double? max = setup.poiMaxAngular;
|
||||
if (max == null && model.connected) {
|
||||
try {
|
||||
max = await dji.getHotpointMaxAngularVelocity(p.radius);
|
||||
setup.setPoiMaxAngular(max);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
await confirmAndStart(
|
||||
context,
|
||||
title: 'Point of Interest',
|
||||
icon: 'circle',
|
||||
description: '${p.clockwise ? 'Clockwise' : 'Anticlockwise'} at ${p.radius.round()} m, ${p.altitude.round()} m up, '
|
||||
'${p.speed.toStringAsFixed(1)} m/s (a lap in about ${formatDuration(p.lapSeconds)}). Facing: ${p.heading.label.toLowerCase()}.',
|
||||
checks: gate.checkPoi(p, maxAngular: max),
|
||||
start: () => gate.startPoi(p, maxAngular: max),
|
||||
onError: onError,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge(<Listenable>[model.mission, setup]),
|
||||
builder: (BuildContext context, _) {
|
||||
final HotpointProgress run = model.mission.hotpoint;
|
||||
final PoiSpec p = setup.poi;
|
||||
final bool active = run.active;
|
||||
void set(PoiSpec n) => setup.setPoi(n);
|
||||
void live({double? radius, double? speed}) => _guard(
|
||||
'Point of Interest',
|
||||
() => gate.adjustPoi(radius: radius, speed: speed, clockwise: p.clockwise, currentRadius: run.radius ?? p.radius),
|
||||
onError,
|
||||
);
|
||||
return ModePanelFrame(
|
||||
mode: IntelligentMode.pointOfInterest,
|
||||
title: 'POINT OF INTEREST',
|
||||
icon: 'circle',
|
||||
status: active ? run.label.toUpperCase() : (p.center == null ? 'TAP THE MAP ON THE SUBJECT' : 'READY'),
|
||||
statusColor: active ? (run.paused ? HudColors.caution : HudColors.ready) : HudText.dim,
|
||||
onStop: active ? () => flow.request(context, FlightCommand.stopMode) : null,
|
||||
onExit: onExit,
|
||||
children: <Widget>[
|
||||
Wrap(spacing: 6, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
|
||||
if (!active)
|
||||
HudChip(
|
||||
key: const ValueKey<String>('poi-aircraft'),
|
||||
label: 'Centre: aircraft',
|
||||
selected: false,
|
||||
onTap: model.aircraft.latitude == null
|
||||
? null
|
||||
: () => set(p.copyWith(center: LatLng(model.aircraft.latitude!, model.aircraft.longitude!))),
|
||||
),
|
||||
ModeStepper(
|
||||
id: 'poi-radius',
|
||||
label: 'R',
|
||||
value: '${(active ? (run.radius ?? p.radius) : p.radius).round()} m',
|
||||
canDown: p.radius > PoiSpec.minRadius,
|
||||
canUp: p.radius < PoiSpec.maxRadius,
|
||||
onStep: (int d) {
|
||||
final double r = (p.radius + d * 5).clamp(PoiSpec.minRadius, PoiSpec.maxRadius);
|
||||
set(p.copyWith(radius: r));
|
||||
if (active) live(radius: r);
|
||||
},
|
||||
),
|
||||
if (!active)
|
||||
ModeStepper(
|
||||
id: 'poi-altitude',
|
||||
label: 'H',
|
||||
value: '${p.altitude.round()} m',
|
||||
canDown: p.altitude > PoiSpec.minAltitude,
|
||||
canUp: p.altitude < PoiSpec.maxAltitude,
|
||||
onStep: (int d) => set(p.copyWith(altitude: (p.altitude + d * 5).clamp(PoiSpec.minAltitude, PoiSpec.maxAltitude))),
|
||||
),
|
||||
ModeStepper(
|
||||
id: 'poi-speed',
|
||||
label: 'V',
|
||||
value: '${p.speed.toStringAsFixed(1)} m/s',
|
||||
canDown: p.speed > PoiSpec.minSpeed,
|
||||
canUp: p.speed < PoiSpec.maxSpeed,
|
||||
onStep: (int d) {
|
||||
final double v = (p.speed + d * 0.5).clamp(PoiSpec.minSpeed, PoiSpec.maxSpeed);
|
||||
set(p.copyWith(speed: v));
|
||||
if (active) live(speed: v);
|
||||
},
|
||||
),
|
||||
]),
|
||||
if (!active) ...<Widget>[
|
||||
const SizedBox(height: 4),
|
||||
Wrap(spacing: 4, runSpacing: 4, children: <Widget>[
|
||||
HudChip(key: const ValueKey<String>('poi-cw'), label: 'Clockwise', selected: p.clockwise, onTap: () => set(p.copyWith(clockwise: true))),
|
||||
HudChip(key: const ValueKey<String>('poi-ccw'), label: 'Anticlockwise', selected: !p.clockwise, onTap: () => set(p.copyWith(clockwise: false))),
|
||||
for (final PoiHeading h in PoiHeading.values)
|
||||
HudChip(key: ValueKey<String>('poi-heading-${h.name}'), label: h.label, selected: p.heading == h, onTap: () => set(p.copyWith(heading: h))),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
|
||||
Text('JOIN', style: HudText.label),
|
||||
for (final PoiStart s in PoiStart.values)
|
||||
HudChip(key: ValueKey<String>('poi-start-${s.name}'), label: s.label, selected: p.start == s, onTap: () => set(p.copyWith(start: s))),
|
||||
const SizedBox(width: 2),
|
||||
ModeButton(id: 'poi-go', icon: 'play2', label: 'GO', color: Glass.accent, onTap: p.center == null ? null : () => _go(context)),
|
||||
]),
|
||||
] else ...<Widget>[
|
||||
const SizedBox(height: 4),
|
||||
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
ModeButton(
|
||||
id: run.paused ? 'poi-resume' : 'poi-pause',
|
||||
icon: run.paused ? 'play2' : 'pause',
|
||||
label: run.paused ? 'RESUME' : 'PAUSE',
|
||||
color: Glass.accent,
|
||||
onTap: () => _guard(run.paused ? 'Resume' : 'Pause', run.paused ? gate.resumePoi : gate.pausePoi, onError),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ModeButton(
|
||||
id: 'poi-reset-heading',
|
||||
icon: 'compass',
|
||||
label: 'FACE POI',
|
||||
color: Glass.pill,
|
||||
onTap: () => _guard('Reset heading', dji.resetHotpointHeading, onError),
|
||||
),
|
||||
]),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The orbit on the map: the circle and its centre, editable before GO.
|
||||
List<Widget> poiMapLayers(ModeSetup setup, MissionState mission) => <Widget>[
|
||||
ListenableBuilder(
|
||||
listenable: Listenable.merge(<Listenable>[setup, mission]),
|
||||
builder: (BuildContext context, _) {
|
||||
final LatLng? c = setup.poi.center;
|
||||
if (c == null) return const SizedBox.shrink();
|
||||
final double r = mission.hotpoint.active ? (mission.hotpoint.radius ?? setup.poi.radius) : setup.poi.radius;
|
||||
return PolylineLayer<Object>(polylines: <Polyline<Object>>[
|
||||
Polyline<Object>(points: _circle(c, r), color: const Color(0xCC8E6CFF), strokeWidth: 2.5),
|
||||
]);
|
||||
},
|
||||
),
|
||||
ListenableBuilder(
|
||||
listenable: setup,
|
||||
builder: (BuildContext context, _) {
|
||||
final LatLng? c = setup.poi.center;
|
||||
if (c == null) return const SizedBox.shrink();
|
||||
return MarkerLayer(markers: <Marker>[
|
||||
Marker(
|
||||
point: c,
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: Container(
|
||||
key: const ValueKey<String>('poi-center'),
|
||||
decoration: BoxDecoration(color: const Color(0xFF8E6CFF), shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2)),
|
||||
),
|
||||
),
|
||||
]);
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
/// The orbit as a 72-sided polygon; flat-earth offsets are exact enough at 500 m.
|
||||
List<LatLng> _circle(LatLng c, double r) {
|
||||
const double metresPerDegree = 111320;
|
||||
final double cosLat = math.cos(c.latitude * math.pi / 180);
|
||||
return <LatLng>[
|
||||
for (int i = 0; i <= 72; i++)
|
||||
LatLng(
|
||||
c.latitude + r * math.cos(i * 5 * math.pi / 180) / metresPerDegree,
|
||||
c.longitude + r * math.sin(i * 5 * math.pi / 180) / (metresPerDegree * cosLat),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Follow Me ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class FollowMePanel extends StatelessWidget {
|
||||
const FollowMePanel({super.key, required this.model, required this.setup, required this.gate, required this.flow, required this.onExit, required this.onError});
|
||||
|
||||
final FlightModel model;
|
||||
final ModeSetup setup;
|
||||
final SafetyGate gate;
|
||||
final FlightCommandFlow flow;
|
||||
final VoidCallback onExit;
|
||||
final void Function(String label, Object error) onError;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge(<Listenable>[model.mission, setup]),
|
||||
builder: (BuildContext context, _) {
|
||||
final FollowMeProgress run = model.mission.followMe;
|
||||
final FollowMeSpec f = setup.followMe;
|
||||
final bool active = run.active;
|
||||
return ModePanelFrame(
|
||||
mode: IntelligentMode.followMe,
|
||||
title: 'FOLLOW ME',
|
||||
icon: 'user',
|
||||
status: active
|
||||
? (run.stuck ? 'STUCK — STEER WITH THE STICKS' : 'FOLLOWING${run.distance == null ? '' : ' · ${run.distance!.round()} m'}')
|
||||
: (model.phoneLatitude == null ? 'WAITING FOR THE PHONE\'S GPS' : 'READY'),
|
||||
statusColor: active ? (run.stuck ? HudColors.caution : HudColors.ready) : HudText.dim,
|
||||
onStop: active ? () => flow.request(context, FlightCommand.stopMode) : null,
|
||||
onExit: onExit,
|
||||
children: <Widget>[
|
||||
if (!active)
|
||||
Wrap(spacing: 6, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
|
||||
ModeStepper(
|
||||
id: 'follow-altitude',
|
||||
label: 'H',
|
||||
value: '${f.altitude.round()} m',
|
||||
canDown: f.altitude > FollowMeSpec.minAltitude,
|
||||
canUp: f.altitude < FollowMeSpec.maxAltitude,
|
||||
onStep: (int d) => setup.setFollowMe(f.copyWith(altitude: (f.altitude + d * 5).clamp(FollowMeSpec.minAltitude, FollowMeSpec.maxAltitude))),
|
||||
),
|
||||
for (final FollowMeHeading h in FollowMeHeading.values)
|
||||
HudChip(key: ValueKey<String>('follow-${h.name}'), label: h.label, selected: f.heading == h, onTap: () => setup.setFollowMe(f.copyWith(heading: h))),
|
||||
ModeButton(
|
||||
id: 'follow-go',
|
||||
icon: 'play2',
|
||||
label: 'GO',
|
||||
color: Glass.accent,
|
||||
onTap: () => confirmAndStart(
|
||||
context,
|
||||
title: 'Follow Me',
|
||||
icon: 'user',
|
||||
description: 'The aircraft follows this phone at ${f.altitude.round()} m. Keep the app open; STOP or the remote\'s pause button ends it.',
|
||||
checks: gate.checkFollowMe(f),
|
||||
start: () => gate.startFollowMe(f),
|
||||
onError: onError,
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Course Lock / Home Lock ───────────────────────────────────────────────────
|
||||
|
||||
class OrientationPanel extends StatelessWidget {
|
||||
const OrientationPanel({super.key, required this.mode, required this.model, required this.gate, required this.onExit, required this.onError});
|
||||
|
||||
/// The picker tile it was opened from (Course Lock or Home Lock).
|
||||
final IntelligentMode mode;
|
||||
final FlightModel model;
|
||||
final SafetyGate gate;
|
||||
final VoidCallback onExit;
|
||||
final void Function(String label, Object error) onError;
|
||||
|
||||
Future<void> _set(BuildContext context, OrientationMode m) async {
|
||||
final List<GateCheck> checks = gate.checkOrientation(m);
|
||||
if (m == OrientationMode.normal) {
|
||||
await _guard('Orientation', () => gate.setOrientation(m), onError);
|
||||
return;
|
||||
}
|
||||
await confirmAndStart(
|
||||
context,
|
||||
title: m.label,
|
||||
icon: m == OrientationMode.homeLock ? 'home' : 'compass',
|
||||
description: m == OrientationMode.homeLock
|
||||
? 'Back on the stick flies towards home; turn it off to fly normally again.'
|
||||
: 'Forward on the stick is the current heading; turn it off to fly normally again.',
|
||||
checks: checks,
|
||||
start: () => gate.setOrientation(m),
|
||||
onError: onError,
|
||||
slider: 'Slide to turn on',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: model.aircraft,
|
||||
builder: (BuildContext context, _) {
|
||||
final OrientationMode now = OrientationMode.fromSdk(model.aircraft.orientationMode);
|
||||
return ModePanelFrame(
|
||||
mode: mode,
|
||||
title: 'ORIENTATION',
|
||||
icon: mode == IntelligentMode.homeLock ? 'home' : 'compass',
|
||||
status: now.label.toUpperCase(),
|
||||
statusColor: now == OrientationMode.normal ? HudText.dim : HudColors.caution,
|
||||
onExit: onExit,
|
||||
children: <Widget>[
|
||||
Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
|
||||
for (final OrientationMode m in OrientationMode.values)
|
||||
HudChip(key: ValueKey<String>('ioc-${m.name}'), label: m.label, selected: now == m, onTap: now == m ? null : () => _set(context, m)),
|
||||
if (now == OrientationMode.courseLock)
|
||||
ModeButton(
|
||||
id: 'ioc-lock',
|
||||
icon: 'compass',
|
||||
label: 'LOCK HEADING',
|
||||
color: Glass.accent,
|
||||
onTap: () => _guard('Lock heading', gate.lockCourse, onError),
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tripod / Cinematic ────────────────────────────────────────────────────────
|
||||
|
||||
/// Tripod and Cinematic (plan P7): switches plus Cinematic's brake and yaw
|
||||
/// tuning, each row hidden where the flight controller says it has no such key.
|
||||
class SlowModePanel extends StatefulWidget {
|
||||
const SlowModePanel({super.key, required this.model, required this.dji, required this.gate, required this.onExit, required this.onError});
|
||||
|
||||
final FlightModel model;
|
||||
final DjiService dji;
|
||||
final SafetyGate gate;
|
||||
final VoidCallback onExit;
|
||||
final void Function(String label, Object error) onError;
|
||||
|
||||
static const DjiKey tripod = DjiKey.flightController('TRIPOD_MODE_ENABLED');
|
||||
static const DjiKey cinematic = DjiKey.flightController('CINEMATIC_MODE_ENABLED');
|
||||
static const DjiKey brake = DjiKey.flightController('CINEMATIC_BRAKE_SENSITIVITY');
|
||||
static const DjiKey brakeRange = DjiKey.flightController('CINEMATIC_BRAKE_SENSITIVITY_RANGE');
|
||||
static const DjiKey yaw = DjiKey.flightController('CINEMATIC_YAW_SPEED');
|
||||
static const DjiKey yawRange = DjiKey.flightController('CINEMATIC_YAW_SPEED_RANGE');
|
||||
|
||||
static const List<SettingSpec> tuning = <SettingSpec>[
|
||||
KeyNumberSpec(id: 'cinematicBrake', label: 'Brake sensitivity', key: brake, rangeKey: brakeRange, min: 10, max: 100, step: 10),
|
||||
KeyNumberSpec(id: 'cinematicYaw', label: 'Yaw speed', key: yaw, rangeKey: yawRange, min: 10, max: 100, step: 5, unit: '°/s'),
|
||||
];
|
||||
|
||||
@override
|
||||
State<SlowModePanel> createState() => _SlowModePanelState();
|
||||
}
|
||||
|
||||
class _SlowModePanelState extends State<SlowModePanel> {
|
||||
late final KeySubscriptions _subs = KeySubscriptions(widget.dji, widget.model.keys);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.model.connected) {
|
||||
_subs.watch(<DjiKey>[SlowModePanel.tripod, SlowModePanel.cinematic, SlowModePanel.brake, SlowModePanel.brakeRange, SlowModePanel.yaw, SlowModePanel.yawRange]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subs.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _toggle(BuildContext context, {required bool cinematic, required bool on}) async {
|
||||
final KeyState k = widget.model.keys;
|
||||
final DjiKey key = cinematic ? SlowModePanel.cinematic : SlowModePanel.tripod;
|
||||
Future<void> send() async {
|
||||
k.setPending(<String, Object?>{key.id: on});
|
||||
try {
|
||||
await widget.gate.setSlowMode(cinematic: cinematic, on: on);
|
||||
} finally {
|
||||
k.dropPending(<String>[key.id]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!on) return _guard(cinematic ? 'Cinematic' : 'Tripod', send, widget.onError);
|
||||
await confirmAndStart(
|
||||
context,
|
||||
title: cinematic ? 'Cinematic mode' : 'Tripod mode',
|
||||
icon: 'gauge',
|
||||
description: cinematic ? 'Smooth, slow braking and turns for steady video.' : 'About 1 m/s and very gentle sticks, for precise framing.',
|
||||
checks: widget.gate.checkSlowMode(cinematic: cinematic, on: true),
|
||||
start: send,
|
||||
onError: widget.onError,
|
||||
slider: 'Slide to turn on',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final FlightModel m = widget.model;
|
||||
return ListenableBuilder(
|
||||
listenable: m.keys,
|
||||
builder: (BuildContext context, _) {
|
||||
final KeyState k = m.keys;
|
||||
final bool tripod = asBool(k.value(SlowModePanel.tripod)) == true;
|
||||
final bool cinematic = asBool(k.value(SlowModePanel.cinematic)) == true;
|
||||
final SettingsContext c = SettingsContext(profile: m.profile, keys: k, aircraft: m.aircraft);
|
||||
return ModePanelFrame(
|
||||
mode: IntelligentMode.tripod,
|
||||
title: 'TRIPOD · CINEMATIC',
|
||||
icon: 'gauge',
|
||||
status: tripod ? 'TRIPOD ON' : (cinematic ? 'CINEMATIC ON' : 'OFF'),
|
||||
statusColor: tripod || cinematic ? HudColors.caution : HudText.dim,
|
||||
onExit: widget.onExit,
|
||||
children: <Widget>[
|
||||
Wrap(spacing: 4, runSpacing: 4, children: <Widget>[
|
||||
if (k.supported(SlowModePanel.tripod) != false)
|
||||
HudChip(
|
||||
key: const ValueKey<String>('slow-tripod'),
|
||||
label: tripod ? 'Tripod on' : 'Tripod off',
|
||||
selected: tripod,
|
||||
onTap: () => _toggle(context, cinematic: false, on: !tripod),
|
||||
),
|
||||
if (k.supported(SlowModePanel.cinematic) != false)
|
||||
HudChip(
|
||||
key: const ValueKey<String>('slow-cinematic'),
|
||||
label: cinematic ? 'Cinematic on' : 'Cinematic off',
|
||||
selected: cinematic,
|
||||
onTap: () => _toggle(context, cinematic: true, on: !cinematic),
|
||||
),
|
||||
]),
|
||||
if (cinematic)
|
||||
SizedBox(
|
||||
width: 380,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
for (final SettingSpec s in SlowModePanel.tuning)
|
||||
if (s.visible(c))
|
||||
SettingRowTile(spec: s, settings: c, dji: widget.dji, onError: widget.onError, custom: (CustomSpec _) => const SizedBox.shrink()),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hyperlapse ────────────────────────────────────────────────────────────────
|
||||
|
||||
class HyperlapsePanel extends StatelessWidget {
|
||||
const HyperlapsePanel({super.key, required this.model, required this.setup, required this.gate, required this.onExit, required this.onError});
|
||||
|
||||
final FlightModel model;
|
||||
final ModeSetup setup;
|
||||
final SafetyGate gate;
|
||||
final VoidCallback onExit;
|
||||
final void Function(String label, Object error) onError;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge(<Listenable>[model.camera, setup]),
|
||||
builder: (BuildContext context, _) {
|
||||
final HyperlapseSpec h = setup.hyperlapse;
|
||||
final bool shooting = model.camera.shootingInterval;
|
||||
return ModePanelFrame(
|
||||
mode: IntelligentMode.hyperlapse,
|
||||
title: 'HYPERLAPSE · FREE',
|
||||
icon: 'hyperlapse',
|
||||
status: shooting ? 'SHOOTING — FLY SLOWLY' : '${h.frames} FRAMES · ${formatDuration(h.shootingSeconds)}',
|
||||
statusColor: shooting ? HudColors.ready : HudText.dim,
|
||||
onStop: shooting ? () => _guard('Hyperlapse', gate.stopHyperlapse, onError) : null,
|
||||
onExit: onExit,
|
||||
children: <Widget>[
|
||||
if (!shooting)
|
||||
Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
|
||||
Text('EVERY', style: HudText.label),
|
||||
for (final int s in HyperlapseSpec.intervals)
|
||||
HudChip(key: ValueKey<String>('hl-interval-$s'), label: '$s s', selected: h.intervalSeconds == s, onTap: () => setup.setHyperlapse(h.copyWith(intervalSeconds: s))),
|
||||
const SizedBox(width: 6),
|
||||
Text('VIDEO', style: HudText.label),
|
||||
for (final int s in HyperlapseSpec.durations)
|
||||
HudChip(key: ValueKey<String>('hl-length-$s'), label: '$s s', selected: h.videoSeconds == s, onTap: () => setup.setHyperlapse(h.copyWith(videoSeconds: s))),
|
||||
ModeButton(
|
||||
id: 'hl-start',
|
||||
icon: 'play2',
|
||||
label: 'START',
|
||||
color: Glass.accent,
|
||||
onTap: () => confirmAndStart(
|
||||
context,
|
||||
title: 'Hyperlapse',
|
||||
icon: 'hyperlapse',
|
||||
description: '${h.frames} photos, one every ${h.intervalSeconds} s (${formatDuration(h.shootingSeconds)}), '
|
||||
'for ${h.videoSeconds} s of video at ${h.fps} fps. You fly; keep it slow and steady.',
|
||||
checks: gate.checkHyperlapse(h),
|
||||
start: () => gate.startHyperlapse(h),
|
||||
onError: onError,
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,87 @@ Future<IntelligentMode?> showModePicker(BuildContext context, {required ProductP
|
||||
),
|
||||
);
|
||||
if (picked == null || !context.mounted) return null;
|
||||
final bool ok = await ensureModeTerms(context, ModeCatalog.of(picked));
|
||||
return ok ? picked : null;
|
||||
final ModeInfo info = ModeCatalog.of(picked);
|
||||
final bool ok = await ensureModeTerms(context, info);
|
||||
if (!ok || !context.mounted) return null;
|
||||
await ensureModeTutorial(context, info);
|
||||
return picked;
|
||||
}
|
||||
|
||||
/// GO 4's first-use tutorial, once per mode (and on demand with [force]).
|
||||
Future<void> ensureModeTutorial(BuildContext context, ModeInfo info, {bool force = false}) async {
|
||||
if (info.tutorial.isEmpty) return;
|
||||
if (!force && FlyPrefs.seenTutorials.value.contains(info.mode.name)) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierColor: const Color(0x99050A14),
|
||||
builder: (BuildContext context) => ModeTutorialDialog(info: info),
|
||||
);
|
||||
await FlyPrefs.markTutorialSeen(info.mode.name);
|
||||
}
|
||||
|
||||
/// The tutorial: one step per page, Back / Next, Done on the last.
|
||||
class ModeTutorialDialog extends StatefulWidget {
|
||||
const ModeTutorialDialog({super.key, required this.info});
|
||||
|
||||
final ModeInfo info;
|
||||
|
||||
@override
|
||||
State<ModeTutorialDialog> createState() => _ModeTutorialDialogState();
|
||||
}
|
||||
|
||||
class _ModeTutorialDialogState extends State<ModeTutorialDialog> {
|
||||
int _page = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<String> steps = widget.info.tutorial;
|
||||
final bool last = _page >= steps.length - 1;
|
||||
return Dialog(
|
||||
backgroundColor: const Color(0xF20B1426),
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
Row(children: <Widget>[
|
||||
PVIcon(widget.info.icon, size: 18, color: Glass.ink),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text('How to fly ${widget.info.label}', style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))),
|
||||
Text('${_page + 1}/${steps.length}', style: HudText.label),
|
||||
]),
|
||||
const SizedBox(height: 14),
|
||||
Text(steps[_page], key: const ValueKey<String>('tutorial-step'), style: HudText.sans.copyWith(fontSize: 13, fontWeight: FontWeight.w400, height: 1.4)),
|
||||
const SizedBox(height: 14),
|
||||
Row(children: <Widget>[
|
||||
for (int i = 0; i < steps.length; i++)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.only(right: 4),
|
||||
decoration: BoxDecoration(color: i == _page ? Glass.accent : HudText.dim, shape: BoxShape.circle),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_page > 0)
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _page--),
|
||||
child: Text('Back', style: HudText.sans.copyWith(color: HudText.dim)),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
FilledButton(
|
||||
key: const ValueKey<String>('tutorial-next'),
|
||||
style: FilledButton.styleFrom(backgroundColor: PV.accent),
|
||||
onPressed: last ? () => Navigator.of(context).pop() : () => setState(() => _page++),
|
||||
child: Text(last ? 'Done' : 'Next', style: HudText.sans.copyWith(color: Colors.white)),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the mode's terms the first time it is used; true once accepted.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../flight_model.dart';
|
||||
import '../../../../fly/modes/orbit_modes.dart' show QuickShot;
|
||||
import '../../../../fly/modes/vision_modes.dart';
|
||||
import '../../../../fly/product_profile.dart' show VisionSensors;
|
||||
import '../../../../fly/safety_gate.dart';
|
||||
import '../../../../fly/state/mission_state.dart';
|
||||
import '../../../../theme.dart';
|
||||
@@ -50,10 +52,14 @@ class VisionModePanel extends StatelessWidget {
|
||||
required this.flow,
|
||||
required this.onExit,
|
||||
required this.onError,
|
||||
this.quickShots = false,
|
||||
});
|
||||
|
||||
/// TapFly's panel, or ActiveTrack's.
|
||||
final bool tapFly;
|
||||
|
||||
/// QuickShots' panel: ActiveTrack's operator, the shot instead of the follow style.
|
||||
final bool quickShots;
|
||||
final FlightModel model;
|
||||
final VisionSession session;
|
||||
final SafetyGate gate;
|
||||
@@ -76,11 +82,23 @@ class VisionModePanel extends StatelessWidget {
|
||||
builder: (BuildContext context, _) {
|
||||
final MissionState ms = model.mission;
|
||||
final bool active = tapFly ? ms.tapFly.active : ms.activeTrack.active;
|
||||
final String title = tapFly ? 'TAPFLY' : 'ACTIVETRACK';
|
||||
final String title = tapFly ? 'TAPFLY' : (quickShots ? 'QUICKSHOTS' : 'ACTIVETRACK');
|
||||
final String status;
|
||||
final Color statusColor;
|
||||
String? note;
|
||||
if (tapFly) {
|
||||
if (quickShots) {
|
||||
final ActiveTrackProgress t = ms.activeTrack;
|
||||
final bool filming = t.state == 'PERFORMING_QUICK_SHOT';
|
||||
status = filming
|
||||
? 'FILMING${t.percent == null ? '' : ' ${t.percent}%'}'
|
||||
: t.active
|
||||
? t.stateLabel.toUpperCase()
|
||||
: session.pendingBox != null
|
||||
? 'TAP GO'
|
||||
: 'DRAW A BOX ROUND THE SUBJECT';
|
||||
statusColor = t.cannotConfirm ? HudColors.danger : (filming ? HudColors.ready : (t.active ? HudColors.caution : HudText.dim));
|
||||
note = t.cannotConfirm ? t.reasonLabel : (active ? null : session.quickShot.blurb);
|
||||
} else if (tapFly) {
|
||||
final TapFlyProgress f = ms.tapFly;
|
||||
status = f.active ? (f.paused ? 'PAUSED' : 'FLYING') : (session.pendingPoint != null ? 'TAP GO' : 'TAP WHERE TO FLY');
|
||||
statusColor = f.active ? (f.paused ? HudColors.caution : HudColors.ready) : HudText.dim;
|
||||
@@ -98,7 +116,21 @@ class VisionModePanel extends StatelessWidget {
|
||||
note = t.cannotConfirm ? t.reasonLabel : null;
|
||||
}
|
||||
|
||||
final List<Widget> chips = tapFly
|
||||
final List<Widget> chips = quickShots
|
||||
? <Widget>[
|
||||
for (final QuickShot s in QuickShot.offered(
|
||||
omni: model.profile.vision == VisionSensors.omni || !model.profile.connected,
|
||||
zoom: model.profile.hasZoom || !model.profile.connected,
|
||||
))
|
||||
HudChip(
|
||||
key: ValueKey<String>('quickshot-${s.name}'),
|
||||
label: s.label,
|
||||
selected: session.quickShot == s,
|
||||
// A shot cannot change once it flies.
|
||||
onTap: active ? null : () => session.setQuickShot(s),
|
||||
),
|
||||
]
|
||||
: tapFly
|
||||
? <Widget>[
|
||||
for (final TapFlyDirection d in gate.tapFlyDirections())
|
||||
HudChip(
|
||||
@@ -110,7 +142,7 @@ class VisionModePanel extends StatelessWidget {
|
||||
),
|
||||
]
|
||||
: <Widget>[
|
||||
for (final TrackMode m in TrackMode.values)
|
||||
for (final TrackMode m in TrackMode.offered(omni: model.profile.vision == VisionSensors.omni || !model.profile.connected))
|
||||
HudChip(
|
||||
key: ValueKey<String>('track-${m.name}'),
|
||||
label: m.label,
|
||||
@@ -134,6 +166,19 @@ class VisionModePanel extends StatelessWidget {
|
||||
});
|
||||
},
|
||||
),
|
||||
// Circle: the orbit speed, live while circling (negative = anticlockwise).
|
||||
if (session.trackMode == TrackMode.circle)
|
||||
_SpeedStepper(
|
||||
speed: session.circleSpeed,
|
||||
min: -10,
|
||||
max: 10,
|
||||
keyPrefix: 'track-circle',
|
||||
onStep: (int d) {
|
||||
final double v = (session.circleSpeed + d).clamp(-10.0, 10.0);
|
||||
session.setCircleSpeed(v);
|
||||
_guard('Circle speed', () => gate.setTrackCircleSpeed(v));
|
||||
},
|
||||
),
|
||||
// The aircraft finds subjects itself; tap one instead of drawing.
|
||||
if (ms.activeTrack.autoSensingSupported && !active)
|
||||
HudChip(
|
||||
@@ -145,13 +190,13 @@ class VisionModePanel extends StatelessWidget {
|
||||
];
|
||||
|
||||
return Container(
|
||||
key: ValueKey<String>(tapFly ? 'tapfly-panel' : 'track-panel'),
|
||||
key: ValueKey<String>(tapFly ? 'tapfly-panel' : (quickShots ? 'quickshot-panel' : 'track-panel')),
|
||||
constraints: const BoxConstraints(maxWidth: 390),
|
||||
padding: const EdgeInsets.fromLTRB(10, 6, 6, 6),
|
||||
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(10), border: Border.all(color: Glass.hairline)),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||||
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
PVIcon(tapFly ? 'chevronsRight' : 'crosshair', size: 13, color: Glass.ink),
|
||||
PVIcon(tapFly ? 'chevronsRight' : (quickShots ? 'dronie' : 'crosshair'), size: 13, color: Glass.ink),
|
||||
const SizedBox(width: 6),
|
||||
Text(title, style: HudText.label.copyWith(color: Glass.ink, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(width: 8),
|
||||
@@ -202,17 +247,26 @@ class VisionModePanel extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _SpeedStepper extends StatelessWidget {
|
||||
const _SpeedStepper({required this.speed, required this.onStep});
|
||||
const _SpeedStepper({
|
||||
required this.speed,
|
||||
required this.onStep,
|
||||
this.min = SafetyGate.tapFlyMinSpeed,
|
||||
this.max = SafetyGate.tapFlyMaxSpeed,
|
||||
this.keyPrefix = 'tapfly-speed',
|
||||
});
|
||||
|
||||
final double speed;
|
||||
final ValueChanged<int> onStep;
|
||||
final double min;
|
||||
final double max;
|
||||
final String keyPrefix;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget b(String l, int d, bool on) => Opacity(
|
||||
opacity: on ? 1 : 0.35,
|
||||
child: GestureDetector(
|
||||
key: ValueKey<String>('tapfly-speed-${d > 0 ? 'plus' : 'minus'}'),
|
||||
key: ValueKey<String>('$keyPrefix-${d > 0 ? 'plus' : 'minus'}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: on ? () => onStep(d) : null,
|
||||
child: Container(
|
||||
@@ -225,9 +279,9 @@ class _SpeedStepper extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
return Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
b('−', -1, speed > SafetyGate.tapFlyMinSpeed),
|
||||
b('−', -1, speed > min),
|
||||
SizedBox(width: 54, child: Text('${speed.toStringAsFixed(0)} m/s', textAlign: TextAlign.center, style: HudText.mono)),
|
||||
b('+', 1, speed < SafetyGate.tapFlyMaxSpeed),
|
||||
b('+', 1, speed < max),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import '../../hud_kit.dart';
|
||||
/// The route summary across the top of the map while editing (GO 4's top
|
||||
/// board): name, waypoints, length, time, cruise speed, and the issue count.
|
||||
class WaypointBoard extends StatelessWidget {
|
||||
const WaypointBoard({super.key, required this.editor, required this.limits, required this.onTap});
|
||||
const WaypointBoard({super.key, required this.editor, required this.limits, required this.onTap, this.resumeFrom, this.onResume});
|
||||
|
||||
final WaypointEditor editor;
|
||||
final MissionLimits limits;
|
||||
@@ -23,6 +23,11 @@ class WaypointBoard extends StatelessWidget {
|
||||
/// Opens the route settings (where the issues are listed).
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// The waypoint (0-based) this route was interrupted before, when it was:
|
||||
/// a RESUME chip flies the rest of it.
|
||||
final int? resumeFrom;
|
||||
final VoidCallback? onResume;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
@@ -67,6 +72,19 @@ class WaypointBoard extends StatelessWidget {
|
||||
_sep(),
|
||||
Text(hint, style: HudText.label.copyWith(color: Glass.ink)),
|
||||
],
|
||||
if (resumeFrom != null && onResume != null && resumeFrom! < m.points.length) ...<Widget>[
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
key: const ValueKey<String>('wp-resume'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onResume,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: Glass.accent, borderRadius: BorderRadius.circular(6)),
|
||||
child: Text('RESUME FROM ${resumeFrom! + 1}', style: HudText.label.copyWith(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
);
|
||||
},
|
||||
@@ -254,6 +272,8 @@ class WaypointRunPanel extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
Text(left >= 1000 ? '${(left / 1000).toStringAsFixed(2)} km left' : '${left.round()} m left', style: HudText.label),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
_CruiseSpeed(initial: editor.mission.cruiseSpeed, gate: gate, onError: onError),
|
||||
const SizedBox(width: 10),
|
||||
_RunButton(
|
||||
id: run.paused ? 'resume' : 'pause',
|
||||
@@ -290,6 +310,59 @@ class WaypointRunPanel extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The mission's cruise speed, changed in flight (`setAutoFlightSpeed`): − / +
|
||||
/// by 1 m/s. Starts at the route's speed; the operator does not report it back.
|
||||
class _CruiseSpeed extends StatefulWidget {
|
||||
const _CruiseSpeed({required this.initial, required this.gate, required this.onError});
|
||||
|
||||
final double initial;
|
||||
final SafetyGate gate;
|
||||
final void Function(String label, Object error) onError;
|
||||
|
||||
@override
|
||||
State<_CruiseSpeed> createState() => _CruiseSpeedState();
|
||||
}
|
||||
|
||||
class _CruiseSpeedState extends State<_CruiseSpeed> {
|
||||
late double _speed = widget.initial;
|
||||
|
||||
Future<void> _step(int d) async {
|
||||
final double before = _speed;
|
||||
final double next = (_speed + d).clamp(WaypointMissionSpec.minSpeed, WaypointMissionSpec.maxSpeed);
|
||||
setState(() => _speed = next);
|
||||
try {
|
||||
await widget.gate.setWaypointCruiseSpeed(next);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _speed = before);
|
||||
widget.onError('Cruise speed', e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget b(String l, int d, bool on) => Opacity(
|
||||
opacity: on ? 1 : 0.35,
|
||||
child: GestureDetector(
|
||||
key: ValueKey<String>('wp-speed-${d > 0 ? 'plus' : 'minus'}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: on ? () => _step(d) : null,
|
||||
child: Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6), border: Border.all(color: Glass.hairline)),
|
||||
alignment: Alignment.center,
|
||||
child: Text(l, style: HudText.mono.copyWith(fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
);
|
||||
return Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
b('−', -1, _speed > WaypointMissionSpec.minSpeed),
|
||||
SizedBox(width: 50, child: Text('${_speed.toStringAsFixed(0)} m/s', key: const ValueKey<String>('wp-speed'), textAlign: TextAlign.center, style: HudText.mono)),
|
||||
b('+', 1, _speed < WaypointMissionSpec.maxSpeed),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _RunButton extends StatelessWidget {
|
||||
const _RunButton({required this.id, required this.icon, required this.label, required this.onTap, this.color = Glass.accent});
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
import '../../../../fly/hud_geometry.dart';
|
||||
import '../../../../fly/modes/mission_library.dart';
|
||||
import '../../../../fly/modes/terrain.dart';
|
||||
import '../../../../fly/modes/waypoint_editor.dart';
|
||||
import '../../../../fly/modes/waypoint_mission.dart';
|
||||
import '../../../../theme.dart';
|
||||
@@ -139,6 +141,7 @@ class RouteSettingsList extends StatelessWidget {
|
||||
onStep: (int d) => _set((WaypointMissionSpec m) => m.copyWith(repeatTimes: (m.repeatTimes + d).clamp(1, 10))),
|
||||
),
|
||||
ValueTile(label: 'Length', value: '${m.length.round()} m · ≈${formatDuration(m.estimatedSeconds)}'),
|
||||
TerrainCheckRow(editor: editor, home: limits.home),
|
||||
if (issues.isNotEmpty) ...<Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
|
||||
@@ -166,6 +169,76 @@ class RouteSettingsList extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Check terrain": samples the ground along the route online and lists where
|
||||
/// it comes within 15 m. Runs only on tap — it sends the route's coordinates
|
||||
/// to the elevation service — and from the home point, or waypoint 1 when the
|
||||
/// aircraft has none yet (then altitudes are taken as above waypoint 1's ground).
|
||||
class TerrainCheckRow extends StatefulWidget {
|
||||
const TerrainCheckRow({super.key, required this.editor, this.home, this.source = const OpenMeteoElevation()});
|
||||
|
||||
final WaypointEditor editor;
|
||||
final LatLng? home;
|
||||
final ElevationSource source;
|
||||
|
||||
@override
|
||||
State<TerrainCheckRow> createState() => _TerrainCheckRowState();
|
||||
}
|
||||
|
||||
class _TerrainCheckRowState extends State<TerrainCheckRow> {
|
||||
bool _running = false;
|
||||
List<TerrainIssue>? _found;
|
||||
String? _error;
|
||||
|
||||
/// The route the result belongs to; editing it makes the result stale.
|
||||
WaypointMissionSpec? _checked;
|
||||
|
||||
Future<void> _run() async {
|
||||
final WaypointMissionSpec m = widget.editor.mission;
|
||||
setState(() {
|
||||
_running = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final List<TerrainIssue> found = await TerrainCheck(widget.source).run(m, widget.home ?? m.points.first.at);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_found = found;
|
||||
_checked = m;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _error = 'Could not reach the elevation service');
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final WaypointMissionSpec m = widget.editor.mission;
|
||||
final bool stale = _checked != null && !identical(_checked, m);
|
||||
final List<TerrainIssue>? f = stale ? null : _found;
|
||||
final String hint = _running
|
||||
? 'Checking…'
|
||||
: _error ??
|
||||
(f == null
|
||||
? 'Sends the route to open-meteo.com for ground heights${widget.home == null ? ' (no home point yet: measured from waypoint 1)' : ''}'
|
||||
: f.isEmpty
|
||||
? 'At least 15 m over the ground all the way'
|
||||
: '${f.length} leg${f.length == 1 ? '' : 's'} too close to the ground');
|
||||
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
ButtonTile(
|
||||
id: 'terrain-check',
|
||||
label: 'Check terrain',
|
||||
hint: hint,
|
||||
button: 'Check',
|
||||
onTap: _running || m.points.length < 2 ? null : _run,
|
||||
),
|
||||
for (final TerrainIssue t in f ?? const <TerrainIssue>[]) IssueRow(issue: t.toIssue(), onTap: () => widget.editor.select(t.leg + 1)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class IssueRow extends StatelessWidget {
|
||||
const IssueRow({super.key, required this.issue, this.onTap});
|
||||
|
||||
|
||||
@@ -230,7 +230,8 @@ class FlyZoneDatabaseRow extends StatelessWidget {
|
||||
child: Text(
|
||||
db == null
|
||||
? 'Not reported yet'
|
||||
: '${_line('Basic', db.basicVersion, db.basicState)}\n${_line('Precise', db.preciseVersion, db.preciseState)}',
|
||||
: '${_line('Basic', db.basicVersion, db.basicState)}\n${_line('Precise', db.preciseVersion, db.preciseState)}'
|
||||
'${db.preciseBehind ? '\nPrecise ${db.preciseRemoteVersion} available: it comes with the aircraft firmware' : ''}',
|
||||
style: HudText.monoSmall.copyWith(color: db?.outOfDate == true ? HudColors.caution : Glass.ink),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,7 +6,9 @@ import '../../../fly/calibration.dart';
|
||||
import '../../../fly/gimbal_control.dart';
|
||||
import '../../../fly/hud_features.dart';
|
||||
import '../../../fly/product_profile.dart';
|
||||
import '../../../fly/settings/settings_spec.dart';
|
||||
import '../../../fly/state/gimbal_state.dart';
|
||||
import '../../../fly/state/key_state.dart';
|
||||
import '../../../fly/state/parse.dart';
|
||||
import '../../../theme.dart';
|
||||
import '../../pv_icons.dart';
|
||||
@@ -15,6 +17,87 @@ import '../hud_kit.dart';
|
||||
import 'camera_menu/camera_rows.dart';
|
||||
import 'camera_write.dart';
|
||||
import 'settings/calibration/calibration_hub.dart';
|
||||
import 'settings/setting_tiles.dart';
|
||||
|
||||
/// Roll-axis (and pitch / yaw) motor tuning on gimbals that expose it
|
||||
/// (`GimbalKey.*_MOTOR_CONTROL_*`, Ronin-class and some Zenmuse). Each row
|
||||
/// asks `isKeySupported` and hides when the gimbal says no, so on most
|
||||
/// aircraft the whole section stays empty.
|
||||
class GimbalMotorRows extends StatefulWidget {
|
||||
const GimbalMotorRows({super.key, required this.model, required this.dji, required this.onError});
|
||||
|
||||
final FlightModel model;
|
||||
final DjiService dji;
|
||||
final void Function(String label, Object error) onError;
|
||||
|
||||
static const List<(String, String)> _axes = <(String, String)>[('ROLL', 'Roll'), ('PITCH', 'Pitch'), ('YAW', 'Yaw')];
|
||||
static const List<(String, String)> _values = <(String, String)>[
|
||||
('MOTOR_CONTROL_STIFFNESS', 'stiffness'),
|
||||
('MOTOR_CONTROL_STRENGTH', 'strength'),
|
||||
('MOTOR_CONTROL_GYRO_FILTERING_FACTOR', 'gyro filtering'),
|
||||
('MOTOR_CONTROL_PRE_CONTROL', 'pre-control'),
|
||||
];
|
||||
|
||||
static final List<SettingSpec> rows = <SettingSpec>[
|
||||
for (final (String axis, String axisLabel) in _axes)
|
||||
for (final (String key, String label) in _values)
|
||||
KeyNumberSpec(
|
||||
id: 'gimbal${axis}_$key',
|
||||
label: '$axisLabel $label',
|
||||
key: DjiKey.gimbal('${axis}_$key'),
|
||||
min: 0,
|
||||
max: 100,
|
||||
groundOnly: true,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
State<GimbalMotorRows> createState() => _GimbalMotorRowsState();
|
||||
}
|
||||
|
||||
class _GimbalMotorRowsState extends State<GimbalMotorRows> {
|
||||
late final KeySubscriptions _subs = KeySubscriptions(widget.dji, widget.model.keys);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_subs.watch(<DjiKey>[for (final SettingSpec r in GimbalMotorRows.rows) ...r.watched]);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subs.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final FlightModel m = widget.model;
|
||||
return ListenableBuilder(
|
||||
listenable: m.keys,
|
||||
builder: (BuildContext context, _) {
|
||||
final SettingsContext c = SettingsContext(profile: m.profile, keys: m.keys, aircraft: m.aircraft);
|
||||
// Only rows the gimbal confirmed: unknown support would show a column of dashes.
|
||||
final List<SettingSpec> shown = <SettingSpec>[
|
||||
for (final SettingSpec r in GimbalMotorRows.rows)
|
||||
if (m.keys.supported(r.key!) == true) r,
|
||||
];
|
||||
if (shown.isEmpty) return const SizedBox.shrink();
|
||||
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
GimbalSettingsList._section('MOTOR TUNING'),
|
||||
for (final SettingSpec r in shown)
|
||||
SettingRowTile(
|
||||
spec: r,
|
||||
settings: c,
|
||||
dji: widget.dji,
|
||||
onError: widget.onError,
|
||||
custom: (CustomSpec _) => const SizedBox.shrink(),
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the gimbal settings (plan P5) as a right-side glass sheet over the
|
||||
/// live view, like the camera menu.
|
||||
@@ -257,6 +340,7 @@ class GimbalSettingsList extends StatelessWidget {
|
||||
whenDisconnected: false,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
_section('ADVANCED'),
|
||||
_presetRow(p),
|
||||
for (final (GimbalTuning t, AxisRange r) in GimbalTuning.rowsFor(p))
|
||||
if (_g.reportedSettings[t.id] != null) _tuningRow(t, r),
|
||||
ButtonTile(
|
||||
@@ -268,6 +352,8 @@ class GimbalSettingsList extends StatelessWidget {
|
||||
),
|
||||
]),
|
||||
),
|
||||
// ── Roll motor (gimbals that expose motor tuning; hidden elsewhere) ──
|
||||
if (p.connected) GimbalMotorRows(model: model, dji: dji, onError: onError),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -317,6 +403,30 @@ class GimbalSettingsList extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Fast / Medium / Slow: max speed and smoothness on both axes at once.
|
||||
Widget _presetRow(ProductProfile p) {
|
||||
final Map<String, AxisRange> ranges = p.gimbal?.controller ?? const <String, AxisRange>{};
|
||||
final Map<String, AxisRange> reported = <String, AxisRange>{
|
||||
for (final MapEntry<String, AxisRange> e in ranges.entries)
|
||||
if (_g.reportedSettings[e.key] != null) e.key: e.value,
|
||||
};
|
||||
if (p.connected && GimbalPreset.fast.valuesFor(reported).isEmpty) return const SizedBox.shrink();
|
||||
final GimbalPreset? current = GimbalPreset.matching(reported, _g.setting);
|
||||
return ChoiceTile(
|
||||
key: const ValueKey<String>('row-gimbalPreset'),
|
||||
label: 'Movement',
|
||||
hint: current == null && p.connected ? 'Custom' : null,
|
||||
options: <(String, String)>[for (final GimbalPreset g in GimbalPreset.values) (g.name, g.label)],
|
||||
selected: current?.name,
|
||||
onSelect: (String name) {
|
||||
final GimbalPreset g = GimbalPreset.values.byName(name);
|
||||
for (final MapEntry<String, int> e in g.valuesFor(reported).entries) {
|
||||
_write('Gimbal movement', e.key, e.value);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tuningRow(GimbalTuning t, AxisRange r) {
|
||||
final int? v = asInt(_g.setting(t.id));
|
||||
final int step = GimbalTuning.stepFor(r);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../../fly/calibration.dart';
|
||||
import '../../../../../fly/fly_prefs.dart';
|
||||
import '../../../../../fly/state/aircraft_state.dart' show CompassSensor;
|
||||
import '../../../hud_kit.dart';
|
||||
import 'calibration_scaffold.dart';
|
||||
import 'illustrations.dart';
|
||||
@@ -108,6 +110,7 @@ class _CompassWizardState extends WizardState<CompassWizard> with _Pages<Compass
|
||||
'in your hand. Take the aircraft off any metal surface. You will turn it a full circle level, '
|
||||
'then a full circle nose down.${count > 1 ? ' Its $count compasses calibrate together.' : ''}',
|
||||
onStart: _start,
|
||||
detail: CompassInterferenceMeter(sensors: model.aircraft.compassSensors),
|
||||
),
|
||||
RunPhase.starting => starting(const CompassIllustration(step: CompassStep.horizontal, animate: false)),
|
||||
RunPhase.running => CalibrationScaffold(
|
||||
@@ -120,6 +123,7 @@ class _CompassWizardState extends WizardState<CompassWizard> with _Pages<Compass
|
||||
? 'Turn slowly on the spot about 360° until the next step appears.'
|
||||
: 'Point the nose at the ground and turn about 360° again.',
|
||||
progress: step == CompassStep.horizontal ? 0.25 : 0.75,
|
||||
detail: CompassInterferenceMeter(sensors: model.aircraft.compassSensors),
|
||||
actions: <WizardAction>[stopAction],
|
||||
),
|
||||
RunPhase.succeeded => succeeded(
|
||||
@@ -151,7 +155,10 @@ class _ImuWizardState extends WizardState<ImuWizard> with _Pages<ImuWizard> {
|
||||
@override
|
||||
bool get canAbort => false;
|
||||
|
||||
Future<void> _start() => start(dji.startIMUCalibration);
|
||||
/// One IMU of several (external IMU1 / IMU2 on the M-series), or all when null.
|
||||
int? _unit;
|
||||
|
||||
Future<void> _start() => start(() => dji.startIMUCalibration(index: _unit));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -163,8 +170,10 @@ class _ImuWizardState extends WizardState<ImuWizard> with _Pages<ImuWizard> {
|
||||
heading: 'Calibrate the IMU',
|
||||
body: 'Take the propellers off and put the aircraft on a flat, level, steady surface with the motors off and '
|
||||
'the gimbal clamp removed. Do not touch it while it calibrates — about 5 to 10 minutes. Some aircraft ask '
|
||||
'to be turned onto each side; this screen shows which.${count > 1 ? ' All $count IMUs calibrate together.' : ''}',
|
||||
'to be turned onto each side; this screen shows which.'
|
||||
'${count > 1 ? (_unit == null ? ' All $count IMUs calibrate together.' : ' Only IMU ${_unit! + 1} calibrates.') : ''}',
|
||||
onStart: _start,
|
||||
detail: count > 1 ? _unitChooser(count) : null,
|
||||
),
|
||||
RunPhase.starting => starting(const ImuIllustration()),
|
||||
RunPhase.running => CalibrationScaffold(
|
||||
@@ -191,6 +200,67 @@ class _ImuWizardState extends WizardState<ImuWizard> with _Pages<ImuWizard> {
|
||||
RunPhase.failed => failed(illustration: const IconIllustration('gauge', color: HudColors.danger), onRetry: _start),
|
||||
};
|
||||
}
|
||||
|
||||
/// All IMUs, or one: GO 4's IMU1 / IMU2 choice on aircraft with external units.
|
||||
Widget _unitChooser(int count) => Wrap(spacing: 6, runSpacing: 6, children: <Widget>[
|
||||
ChoiceChip(
|
||||
key: const ValueKey<String>('imu-all'),
|
||||
label: const Text('All IMUs'),
|
||||
selected: _unit == null,
|
||||
onSelected: (_) => setState(() => _unit = null),
|
||||
),
|
||||
for (int i = 0; i < count; i++)
|
||||
ChoiceChip(
|
||||
key: ValueKey<String>('imu-$i'),
|
||||
label: Text('IMU ${i + 1}'),
|
||||
selected: _unit == i,
|
||||
onSelected: (_) => setState(() => _unit = i),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/// The compass's magnetic interference, per compass (GO 4's interference
|
||||
/// meter): a bar graded by the flight controller's own verdict. Move until
|
||||
/// every bar is green before calibrating.
|
||||
class CompassInterferenceMeter extends StatelessWidget {
|
||||
const CompassInterferenceMeter({super.key, required this.sensors});
|
||||
|
||||
final List<CompassSensor> sensors;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (sensors.isEmpty) return const SizedBox.shrink();
|
||||
return Column(key: const ValueKey<String>('compass-interference'), crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||||
Text('MAGNETIC INTERFERENCE', style: HudText.label),
|
||||
const SizedBox(height: 4),
|
||||
for (final CompassSensor s in sensors)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(children: <Widget>[
|
||||
SizedBox(width: 70, child: Text(sensors.length > 1 ? 'Compass ${s.index + 1}' : 'Compass', style: HudText.monoSmall)),
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: switch (s.level) { 0 => 0.25, 1 => 0.6, 2 => 1.0, _ => 0.0 },
|
||||
minHeight: 6,
|
||||
backgroundColor: HudColors.off,
|
||||
color: switch (s.level) { 0 => HudColors.ready, 1 => HudColors.caution, _ => HudColors.danger },
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(
|
||||
switch (s.level) { 0 => 'Low', 1 => 'Moderate', 2 => 'High', _ => 'No reading' },
|
||||
style: HudText.monoSmall.copyWith(color: switch (s.level) { 0 => HudColors.ready, 1 => HudColors.caution, 2 => HudColors.danger, _ => HudText.dim }),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Remote controller ───────────────────────────────────────────────────────
|
||||
@@ -372,6 +442,7 @@ class _LensWizardState extends WizardState<LensWizard> with _Pages<LensWizard> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_remember();
|
||||
return switch (run.phase) {
|
||||
RunPhase.idle => intro(
|
||||
illustration: const IconIllustration('focus'),
|
||||
@@ -393,11 +464,30 @@ class _LensWizardState extends WizardState<LensWizard> with _Pages<LensWizard> {
|
||||
RunPhase.succeeded => succeeded(
|
||||
illustration: const IconIllustration('focus', progress: 1, color: HudColors.ready),
|
||||
heading: 'Lens calibrated',
|
||||
body: 'Manual focus now follows the lens\'s distance scale.',
|
||||
body: 'Manual focus now follows the lens\'s distance scale. For a sharp infinity, focus by hand on '
|
||||
'something far away (a horizon, a building over 100 m off) and record it as infinity.',
|
||||
more: <WizardAction>[WizardAction('Set infinity', _infinity, id: 'infinity')],
|
||||
),
|
||||
RunPhase.failed => failed(illustration: const IconIllustration('focus', color: HudColors.danger), onRetry: _start),
|
||||
};
|
||||
}
|
||||
|
||||
/// `calibratedInfinityFocus`: the lens's current focus is its infinity.
|
||||
Future<void> _infinity() async {
|
||||
final bool ok = await send('Infinity focus', dji.calibrateInfinityFocus);
|
||||
if (ok && mounted) {
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(const SnackBar(content: Text('Infinity focus recorded')));
|
||||
}
|
||||
}
|
||||
|
||||
/// Once calibrated here, the first-manual-focus prompt stops asking for this camera.
|
||||
bool _remembered = false;
|
||||
|
||||
void _remember() {
|
||||
if (_remembered || run.phase != RunPhase.succeeded) return;
|
||||
_remembered = true;
|
||||
FlyPrefs.markLensCalibrated(model.profile.camera?.displayName ?? model.model ?? 'camera');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sensor cleaning ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../../dji_service.dart';
|
||||
import '../../../../flight_model.dart';
|
||||
import '../../../../fly/fly_prefs.dart';
|
||||
import '../../../../fly/state/link_extras.dart';
|
||||
import '../../../../theme.dart';
|
||||
import '../../hud_kit.dart';
|
||||
import '../camera_menu/camera_rows.dart';
|
||||
import 'setting_tiles.dart' show TextSettingDialog;
|
||||
|
||||
/// GO 4's RC → Button Customization (plan P6): what C1 and C2 do. Kept on
|
||||
/// the phone; the flight screen acts on the remote's presses.
|
||||
class CustomButtonRows extends StatelessWidget {
|
||||
const CustomButtonRows({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<(String, String)> options = <(String, String)>[for (final RcButtonAction a in RcButtonAction.values) (a.name, a.label)];
|
||||
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
for (final RcButton b in RcButton.values)
|
||||
ValueListenableBuilder<RcButtonAction>(
|
||||
valueListenable: b == RcButton.c1 ? FlyPrefs.c1 : FlyPrefs.c2,
|
||||
builder: (BuildContext context, RcButtonAction a, _) => ChoiceTile(
|
||||
key: ValueKey<String>('row-rc${b.label}'),
|
||||
label: '${b.label} button',
|
||||
options: options,
|
||||
selected: a.name,
|
||||
onSelect: (String v) => FlyPrefs.setRcButton(b, RcButtonAction.values.byName(v)),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A slave remote looks for masters in range and joins one with its password.
|
||||
class MasterSearchRows extends StatefulWidget {
|
||||
const MasterSearchRows({super.key, required this.model, required this.dji, required this.onError});
|
||||
|
||||
final FlightModel model;
|
||||
final DjiService dji;
|
||||
final void Function(String label, Object error) onError;
|
||||
|
||||
@override
|
||||
State<MasterSearchRows> createState() => _MasterSearchRowsState();
|
||||
}
|
||||
|
||||
class _MasterSearchRowsState extends State<MasterSearchRows> {
|
||||
bool _searching = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_searching) widget.dji.searchMasters(false).catchError((Object _) {});
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _toggle() async {
|
||||
final bool on = !_searching;
|
||||
setState(() => _searching = on);
|
||||
if (on) widget.model.rcMasters.value = const <RcPeer>[];
|
||||
try {
|
||||
await widget.dji.searchMasters(on);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _searching = false);
|
||||
widget.onError('Search for master', e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _join(RcPeer m) async {
|
||||
final String? password = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) => TextSettingDialog(label: 'Password for ${m.name ?? 'master'}', initial: '', secret: true, maxLength: 4),
|
||||
);
|
||||
if (password == null) return;
|
||||
try {
|
||||
final String? r = await widget.dji.connectToMaster(id: m.id, name: m.name ?? '', password: password);
|
||||
if (!mounted) return;
|
||||
final String msg = switch (r) {
|
||||
'ACCEPTED' => 'Joined ${m.name ?? 'the master'}',
|
||||
'PASSWORD_ERROR' => 'Wrong password',
|
||||
'REJECTED' => 'The master refused',
|
||||
'MAXIMUM_CAPACITY' => 'The master has no room for another slave',
|
||||
'TIMEOUT' => 'The master did not answer',
|
||||
_ => 'Could not join ($r)',
|
||||
};
|
||||
ScaffoldMessenger.of(context)
|
||||
..clearSnackBars()
|
||||
..showSnackBar(SnackBar(content: Text(msg)));
|
||||
} catch (e) {
|
||||
widget.onError('Join master', e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
ButtonTile(
|
||||
id: 'searchMasters',
|
||||
label: _searching ? 'Searching for masters…' : 'Find a master remote',
|
||||
button: _searching ? 'Stop' : 'Search',
|
||||
onTap: _toggle,
|
||||
),
|
||||
ValueListenableBuilder<List<RcPeer>>(
|
||||
valueListenable: widget.model.rcMasters,
|
||||
builder: (BuildContext context, List<RcPeer> masters, _) => Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
for (final RcPeer m in masters)
|
||||
ValueTile(
|
||||
key: ValueKey<String>('master-${m.id}'),
|
||||
label: m.name ?? 'Remote ${m.id}',
|
||||
hint: m.signal == null ? null : 'Signal ${m.signal} %',
|
||||
value: 'Join',
|
||||
onTap: () => _join(m),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A master remote lists the slaves that joined it.
|
||||
class SlaveListRow extends StatefulWidget {
|
||||
const SlaveListRow({super.key, required this.dji, required this.onError});
|
||||
|
||||
final DjiService dji;
|
||||
final void Function(String label, Object error) onError;
|
||||
|
||||
@override
|
||||
State<SlaveListRow> createState() => _SlaveListRowState();
|
||||
}
|
||||
|
||||
class _SlaveListRowState extends State<SlaveListRow> {
|
||||
List<RcPeer>? _slaves;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final List<Map<String, dynamic>> l = await widget.dji.getSlaves();
|
||||
if (mounted) setState(() => _slaves = <RcPeer>[for (final Map<String, dynamic> m in l) RcPeer.fromMap(m)]);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _slaves = const <RcPeer>[]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<RcPeer>? s = _slaves;
|
||||
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
ValueTile(
|
||||
key: const ValueKey<String>('row-slaves'),
|
||||
label: 'Slaves',
|
||||
value: s == null ? '…' : (s.isEmpty ? 'None' : '${s.length}'),
|
||||
onTap: _load,
|
||||
),
|
||||
for (final RcPeer p in s ?? const <RcPeer>[])
|
||||
ValueTile(label: p.name ?? 'Remote ${p.id}', value: p.signal == null ? '—' : '${p.signal} %'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The video link's interference as a bar chart (GO 4's transmission
|
||||
/// spectrum): taller bars are noisier channels; the quietest is marked, the
|
||||
/// one to pick in Custom mode. Listens only while it is on screen.
|
||||
class SpectrumChart extends StatefulWidget {
|
||||
const SpectrumChart({super.key, required this.model, required this.dji, required this.connected});
|
||||
|
||||
final FlightModel model;
|
||||
final DjiService dji;
|
||||
final bool connected;
|
||||
|
||||
@override
|
||||
State<SpectrumChart> createState() => _SpectrumChartState();
|
||||
}
|
||||
|
||||
class _SpectrumChartState extends State<SpectrumChart> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.connected) widget.dji.watchSpectrum(true).catchError((Object _) {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.dji.watchSpectrum(false).catchError((Object _) {});
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Spectrum?>(
|
||||
valueListenable: widget.model.spectrum,
|
||||
builder: (BuildContext context, Spectrum? s, _) {
|
||||
if (s == null || s.bars.isEmpty) {
|
||||
return ValueTile(
|
||||
key: const ValueKey<String>('row-spectrum'),
|
||||
label: 'Interference',
|
||||
value: widget.connected ? 'Listening…' : '—',
|
||||
);
|
||||
}
|
||||
final SpectrumBar? q = s.quietest;
|
||||
return Padding(
|
||||
key: const ValueKey<String>('row-spectrum'),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||||
Row(children: <Widget>[
|
||||
Expanded(child: Text('INTERFERENCE', style: HudText.label)),
|
||||
if (q != null) Text('Quietest: ${q.label}', style: HudText.monoSmall.copyWith(color: HudColors.ready)),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(height: 70, child: CustomPaint(size: Size.infinite, painter: SpectrumPainter(s))),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SpectrumPainter extends CustomPainter {
|
||||
SpectrumPainter(this.spectrum);
|
||||
|
||||
final Spectrum spectrum;
|
||||
|
||||
/// dBm drawn as an empty bar (quiet) and a full one (loud).
|
||||
static const double floor = -110, ceiling = -60;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final List<SpectrumBar> bars = spectrum.bars;
|
||||
if (bars.isEmpty) return;
|
||||
final double w = size.width / bars.length;
|
||||
final SpectrumBar? q = spectrum.quietest;
|
||||
for (int i = 0; i < bars.length; i++) {
|
||||
final double t = ((bars[i].power - floor) / (ceiling - floor)).clamp(0.05, 1.0);
|
||||
final Color c = identical(bars[i], q)
|
||||
? HudColors.ready
|
||||
: t > 0.7
|
||||
? HudColors.danger
|
||||
: t > 0.4
|
||||
? HudColors.caution
|
||||
: Glass.sat;
|
||||
final double h = size.height * t;
|
||||
canvas.drawRect(Rect.fromLTWH(i * w + w * 0.15, size.height - h, w * 0.7, h), Paint()..color = c);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(SpectrumPainter old) => old.spectrum != spectrum;
|
||||
}
|
||||
|
||||
/// GO 4's video cache: record the live feed to the phone while flying, with a
|
||||
/// size cap and Clear.
|
||||
class VideoCacheRows extends StatefulWidget {
|
||||
const VideoCacheRows({super.key, required this.dji, required this.onError});
|
||||
|
||||
final DjiService dji;
|
||||
final void Function(String label, Object error) onError;
|
||||
|
||||
static const List<int> limits = <int>[1024, 2048, 4096, 8192, 16384];
|
||||
|
||||
@override
|
||||
State<VideoCacheRows> createState() => _VideoCacheRowsState();
|
||||
}
|
||||
|
||||
class _VideoCacheRowsState extends State<VideoCacheRows> {
|
||||
Map<String, dynamic>? _info;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refresh();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
try {
|
||||
final Map<String, dynamic> i = await widget.dji.getVideoCacheInfo();
|
||||
if (mounted) setState(() => _info = i);
|
||||
} on MissingPluginException {
|
||||
// No native side (tests).
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static String _size(num? bytes) {
|
||||
final double b = (bytes ?? 0).toDouble();
|
||||
if (b >= 1 << 30) return '${(b / (1 << 30)).toStringAsFixed(1)} GB';
|
||||
return '${(b / (1 << 20)).toStringAsFixed(0)} MB';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: FlyPrefs.videoCache,
|
||||
builder: (BuildContext context, bool on, _) => SwitchTile(
|
||||
key: const ValueKey<String>('row-videoCache'),
|
||||
label: 'Cache the live video',
|
||||
hint: 'Records the feed to this phone while flying (raw H.264, plays in VLC)',
|
||||
value: on,
|
||||
onChanged: FlyPrefs.setVideoCache,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder<int>(
|
||||
valueListenable: FlyPrefs.videoCacheLimitMb,
|
||||
builder: (BuildContext context, int mb, _) => ChoiceTile(
|
||||
key: const ValueKey<String>('row-videoCacheLimit'),
|
||||
label: 'Cache size',
|
||||
hint: 'The oldest recordings go first',
|
||||
options: <(String, String)>[for (final int l in VideoCacheRows.limits) ('$l', '${l ~/ 1024} GB')],
|
||||
selected: '$mb',
|
||||
onSelect: (String v) => FlyPrefs.setVideoCacheLimitMb(int.parse(v)),
|
||||
),
|
||||
),
|
||||
ButtonTile(
|
||||
id: 'clearVideoCache',
|
||||
label: 'Clear video cache',
|
||||
hint: _info == null ? null : '${_info!['files'] ?? 0} recordings · ${_size(_info!['bytes'] as num?)}',
|
||||
button: 'Clear',
|
||||
danger: true,
|
||||
onTap: () async {
|
||||
try {
|
||||
await widget.dji.clearVideoCache();
|
||||
} catch (e) {
|
||||
widget.onError('Clear video cache', e);
|
||||
}
|
||||
_refresh();
|
||||
},
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,13 @@ class SettingRowTile extends StatelessWidget {
|
||||
),
|
||||
KeyNumberSpec() => _NumberTile(spec: s, keys: _keys, locked: locked, onSet: (int v) => _write(s.key, v)),
|
||||
KeyReadoutSpec() => ValueTile(key: ValueKey<String>('row-${s.id}'), label: s.label, hint: s.hint, value: s.shown(_keys)),
|
||||
KeyTextSpec() => ValueTile(
|
||||
key: ValueKey<String>('row-${s.id}'),
|
||||
label: s.label,
|
||||
hint: locked ?? s.hint,
|
||||
value: s.shown(_keys),
|
||||
onTap: locked == null ? () => _editText(context, s) : null,
|
||||
),
|
||||
KeyActionSpec() => ButtonTile(
|
||||
key: ValueKey<String>('row-${s.id}'),
|
||||
id: s.id,
|
||||
@@ -96,6 +103,28 @@ class SettingRowTile extends StatelessWidget {
|
||||
return tile;
|
||||
}
|
||||
|
||||
Future<void> _editText(BuildContext context, KeyTextSpec t) async {
|
||||
final String? typed = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) => TextSettingDialog(
|
||||
label: t.label,
|
||||
initial: t.secret ? '' : (t.current(_keys) ?? ''),
|
||||
secret: t.secret,
|
||||
maxLength: t.maxLength,
|
||||
),
|
||||
);
|
||||
if (typed == null || typed == t.current(_keys)) return;
|
||||
if (t.send != null) {
|
||||
try {
|
||||
await t.send!(dji, typed);
|
||||
} catch (e) {
|
||||
onError(t.label, e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await _write(t.key, typed);
|
||||
}
|
||||
|
||||
Future<void> _runAction(BuildContext context, KeyActionSpec a) async {
|
||||
if (a.confirmTitle != null) {
|
||||
final SlideResult? ok = await showSlideToConfirm(
|
||||
@@ -210,6 +239,65 @@ class _NumberDialogState extends State<_NumberDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Types a string setting. The dialog's own State owns the controller (one
|
||||
/// disposed right after `await showDialog` crashes the exit animation).
|
||||
class TextSettingDialog extends StatefulWidget {
|
||||
const TextSettingDialog({super.key, required this.label, required this.initial, this.secret = false, this.maxLength = 32});
|
||||
|
||||
final String label;
|
||||
final String initial;
|
||||
final bool secret;
|
||||
final int maxLength;
|
||||
|
||||
@override
|
||||
State<TextSettingDialog> createState() => _TextSettingDialogState();
|
||||
}
|
||||
|
||||
class _TextSettingDialogState extends State<TextSettingDialog> {
|
||||
late final TextEditingController _text = TextEditingController(text: widget.initial);
|
||||
bool _show = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_text.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final String v = _text.text.trim();
|
||||
Navigator.of(context).pop(v.isEmpty ? null : v);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
backgroundColor: const Color(0xFF10203F),
|
||||
title: Text(widget.label, style: HudText.sans.copyWith(fontSize: 15)),
|
||||
content: TextField(
|
||||
key: const ValueKey<String>('text-input'),
|
||||
controller: _text,
|
||||
autofocus: true,
|
||||
obscureText: widget.secret && !_show,
|
||||
maxLength: widget.maxLength,
|
||||
style: HudText.value.copyWith(fontSize: 16),
|
||||
decoration: InputDecoration(
|
||||
suffixIcon: widget.secret
|
||||
? IconButton(
|
||||
onPressed: () => setState(() => _show = !_show),
|
||||
icon: Icon(_show ? Icons.visibility_off : Icons.visibility, size: 18),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
|
||||
FilledButton(key: const ValueKey<String>('text-set'), onPressed: _submit, child: const Text('Set')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// GO 4's stick-mode picture: what each stick does in Mode 1 / 2 / 3.
|
||||
class StickModeDiagram extends StatelessWidget {
|
||||
const StickModeDiagram({super.key, required this.mode});
|
||||
|
||||
@@ -24,6 +24,7 @@ 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';
|
||||
|
||||
@@ -41,6 +42,7 @@ Future<void> showFlySettings(
|
||||
String initialTab = SettingsTabs.safety,
|
||||
VoidCallback? onOpenStatus,
|
||||
VoidCallback? onClearRoute,
|
||||
VoidCallback? onOpenFlightLogs,
|
||||
}) {
|
||||
return showGeneralDialog<void>(
|
||||
context: context,
|
||||
@@ -57,6 +59,7 @@ Future<void> showFlySettings(
|
||||
initialTab: initialTab,
|
||||
onOpenStatus: onOpenStatus,
|
||||
onClearRoute: onClearRoute,
|
||||
onOpenFlightLogs: onOpenFlightLogs,
|
||||
),
|
||||
),
|
||||
transitionBuilder: (BuildContext context, Animation<double> a, _, Widget child) => SlideTransition(
|
||||
@@ -75,6 +78,7 @@ class FlySettingsSheet extends StatefulWidget {
|
||||
this.initialTab = SettingsTabs.safety,
|
||||
this.onOpenStatus,
|
||||
this.onClearRoute,
|
||||
this.onOpenFlightLogs,
|
||||
});
|
||||
|
||||
final FlightModel model;
|
||||
@@ -84,6 +88,9 @@ class FlySettingsSheet extends StatefulWidget {
|
||||
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;
|
||||
|
||||
@@ -337,6 +344,32 @@ class _FlySettingsSheetState extends State<FlySettingsSheet> {
|
||||
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(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../../flight_model.dart';
|
||||
import '../../../fly/flight_status.dart';
|
||||
import '../../../fly/hud_features.dart';
|
||||
import '../../../fly/state/aircraft_state.dart' show RtkStatus;
|
||||
import '../../../fly/state/alert_state.dart';
|
||||
import '../../../fly/state/link_state.dart';
|
||||
import '../../../fly/state/vision_state.dart';
|
||||
@@ -42,6 +43,11 @@ class StatusBar extends StatelessWidget {
|
||||
_StatusChip(model: model, onTap: onStatus),
|
||||
const SizedBox(width: 6),
|
||||
_GpsCluster(model: model),
|
||||
Requires(
|
||||
HudFeatures.rtkStatus,
|
||||
whenDisconnected: false,
|
||||
child: Padding(padding: const EdgeInsets.only(left: 6), child: _RtkChip(model: model)),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_ModeText(model: model),
|
||||
Requires(
|
||||
@@ -130,6 +136,43 @@ class _GpsCluster extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// GO 4's RTK chip: the module's fix (FIX green, FLOAT amber, else dim) and
|
||||
/// its satellites. Hidden until the module has reported once.
|
||||
class _RtkChip extends StatelessWidget {
|
||||
const _RtkChip({required this.model});
|
||||
|
||||
final FlightModel model;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: model.aircraft,
|
||||
builder: (BuildContext context, _) {
|
||||
final RtkStatus? r = model.aircraft.rtk;
|
||||
if (r == null) return const SizedBox.shrink();
|
||||
final Color c = r.fixed
|
||||
? HudColors.ready
|
||||
: r.solution == 'FLOAT'
|
||||
? HudColors.caution
|
||||
: HudColors.off;
|
||||
return GlassPill(
|
||||
key: const ValueKey<String>('rtk-chip'),
|
||||
height: StatusBar.height,
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
Text('RTK', style: HudText.label.copyWith(color: c, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(width: 4),
|
||||
Text(r.label, style: HudText.mono.copyWith(color: c, fontWeight: FontWeight.w700)),
|
||||
if (r.satellites != null && r.satellites! > 0) ...<Widget>[
|
||||
const SizedBox(width: 4),
|
||||
Text('${r.satellites}', style: HudText.monoSmall.copyWith(color: HudText.dim)),
|
||||
],
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModeText extends StatelessWidget {
|
||||
const _ModeText({required this.model});
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ class PvPaths {
|
||||
'land': 'M12 4v12m0 0-4-4m4 4 4-4M4 20h16',
|
||||
'stop': 'M7 7h10v10H7z',
|
||||
'alert': 'M12 3 2 20h20L12 3zM12 10v4M12 17h.01',
|
||||
'info': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 11v6M12 7h.01',
|
||||
'chevronsRight': 'M6 6l6 6-6 6M13 6l6 6-6 6',
|
||||
'rth': 'M12 3a9 9 0 1 0 9 9M12 3v6l4 2M20 4l-3 1 1-3',
|
||||
'map': 'M9 4 3 6v14l6-2 6 2 6-2V4l-6 2-6-2zM9 4v14M15 6v14',
|
||||
|
||||
Reference in new issue
Block a user