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:
tajniak81andClaude Opus 5.5 committed 2026-10-20 23:06:16 +02:00
1 parent 91ca150a35
commit 636da1ac2c
57 files changed
+5363 -87

No files matched your search

+126 -8
View File
@@ -12,7 +12,8 @@ import 'package:flutter/services.dart';
/// carries maps keyed by `type`: `registration`, `connection`, `identity`,
/// `telemetry` (the batched snapshot — see `TelemetryReducer`), `camera`,
/// `exposure`, `histogram`, `diagnostics`, `mission`, `key`, `mediaList`, `mediaDownload`,
/// `djiAccount`, `productProfile`, `database`, `init`.
/// `djiAccount`, `productProfile`, `database`, `init`, `rcButton`, `rcMasters`,
/// `spectrum`, `flyZoneDbRemote`.
class DjiService {
static const MethodChannel _methods = MethodChannel('dji_msdk/methods');
static const EventChannel _events = EventChannel('dji_msdk/events');
@@ -266,8 +267,11 @@ class DjiService {
/// Loads and uploads a waypoint mission — `WaypointMissionSpec.toBridge()`'s
/// map (see `MissionBridge.kt`). Resolves once every waypoint is on the
/// aircraft; progress arrives as `mission` events with `operator: 'waypoint'`.
Future<void> uploadWaypointMission(Map<String, Object?> mission) =>
_methods.invokeMethod<void>('uploadWaypointMission', mission);
///
/// [v2] sends it to the Waypoints 2.0 operator (Matrice 300) instead; the
/// start / pause / resume / stop below then address that one.
Future<void> uploadWaypointMission(Map<String, Object?> mission, {bool v2 = false}) =>
_methods.invokeMethod<void>('uploadWaypointMission', <String, Object?>{...mission, if (v2) 'v2': true});
Future<void> cancelWaypointUpload() => _methods.invokeMethod<void>('cancelWaypointUpload');
/// The operator's `WaypointMissionState` name; also re-published as an event.
@@ -405,11 +409,125 @@ class DjiService {
Future<void> startFlyZoneDatabaseUpgrade() => _methods.invokeMethod<void>('startFlyZoneDatabaseUpgrade');
Future<void> stopFlyZoneDatabaseUpgrade() => _methods.invokeMethod<void>('stopFlyZoneDatabaseUpgrade');
/// A DJI-Fly QuickShot by name (Dronie/Rocket/Circle/Helix/Boomerang/Asteroid).
/// The native side maps each to the closest real operator or returns a typed
/// "unsupported on this aircraft" error.
Future<void> startQuickShot(String name) =>
_methods.invokeMethod<void>('startQuickShot', <String, dynamic>{'name': name});
/// A QuickShot by `QuickShotMode` name (DRONIE / ROCKET / CIRCLE / HELIX /
/// BOOMERANG / ASTEROID / DOLLY_ZOOM) on the subject boxed at [x], [y], [w],
/// [h] (0..1 of the live view); an aircraft without that shot answers a
/// typed error.
Future<void> startQuickShot(String name, {double? x, double? y, double? w, double? h}) =>
_methods.invokeMethod<void>('startQuickShot', <String, dynamic>{
'name': name,
if (x != null) 'x': x,
if (y != null) 'y': y,
if (w != null) 'w': w,
if (h != null) 'h': h,
});
/// ActiveTrack Circle's orbit speed (m/s, negative anticlockwise).
Future<void> setActiveTrackCircularSpeed(double speed) =>
_methods.invokeMethod<void>('setActiveTrackCircularSpeed', <String, dynamic>{'speed': speed});
Future<double?> getActiveTrackCircularSpeed() => _methods.invokeMethod<double>('getActiveTrackCircularSpeed');
/// Where the last waypoint mission was cut short: `{missionId, index, lat,
/// lon, altitude}`, or null when it was not.
Future<Map<String, dynamic>?> getWaypointInterruption() async {
final dynamic m = await _methods.invokeMethod('getWaypointInterruption');
return m == null ? null : Map<String, dynamic>.from(m as Map);
}
// ── Point of Interest (plan P7, `HotpointBridge.kt`) ────────────────────────
/// Circles [lat]/[lon] at [radius] m and [altitude] m; [angularVelocity] °/s.
/// startPoint: NORTH / SOUTH / WEST / EAST / NEAREST; heading:
/// TOWARDS_HOT_POINT / AWAY_FROM_HOT_POINT / ALONG_CIRCLE_LOOKING_FORWARDS /
/// ALONG_CIRCLE_LOOKING_BACKWARDS / CONTROLLED_BY_REMOTE_CONTROLLER / USING_INITIAL_HEADING.
Future<void> startHotpoint({
required double lat,
required double lon,
required double altitude,
required double radius,
required double angularVelocity,
required bool clockwise,
String startPoint = 'NEAREST',
String heading = 'TOWARDS_HOT_POINT',
}) =>
_methods.invokeMethod<void>('startHotpoint', <String, dynamic>{
'lat': lat,
'lon': lon,
'altitude': altitude,
'radius': radius,
'angularVelocity': angularVelocity,
'clockwise': clockwise,
'startPoint': startPoint,
'heading': heading,
});
Future<void> pauseHotpoint() => _methods.invokeMethod<void>('pauseHotpoint');
Future<void> resumeHotpoint() => _methods.invokeMethod<void>('resumeHotpoint');
Future<void> stopHotpoint() => _methods.invokeMethod<void>('stopHotpoint');
Future<void> setHotpointRadius(double radius) =>
_methods.invokeMethod<void>('setHotpointRadius', <String, dynamic>{'radius': radius});
/// °/s; the sign is the direction (positive clockwise).
Future<void> setHotpointAngularVelocity(double value) =>
_methods.invokeMethod<void>('setHotpointAngularVelocity', <String, dynamic>{'value': value});
Future<void> resetHotpointHeading() => _methods.invokeMethod<void>('resetHotpointHeading');
/// The fastest the aircraft circles at [radius] m, °/s.
Future<double?> getHotpointMaxAngularVelocity(double radius) =>
_methods.invokeMethod<double>('getHotpointMaxAngularVelocity', <String, dynamic>{'radius': radius});
// ── Follow Me (plan P7, `FollowMeBridge.kt`) ────────────────────────────────
/// heading: TOWARD_FOLLOW_POSITION | CONTROLLED_BY_REMOTE_CONTROLLER.
Future<void> startFollowMe({required double lat, required double lon, required double altitude, String heading = 'TOWARD_FOLLOW_POSITION'}) =>
_methods.invokeMethod<void>('startFollowMe', <String, dynamic>{'lat': lat, 'lon': lon, 'altitude': altitude, 'heading': heading});
Future<void> updateFollowMeTarget(double lat, double lon) =>
_methods.invokeMethod<void>('updateFollowMeTarget', <String, dynamic>{'lat': lat, 'lon': lon});
Future<void> stopFollowMe() => _methods.invokeMethod<void>('stopFollowMe');
// ── Orientation modes (plan P7) ─────────────────────────────────────────────
/// mode: AIRCRAFT_HEADING | COURSE_LOCK | HOME_LOCK.
Future<void> setFlightOrientationMode(String mode) =>
_methods.invokeMethod<void>('setFlightOrientationMode', <String, dynamic>{'mode': mode});
/// Course Lock's "forward" becomes the aircraft's current heading.
Future<void> lockCourseUsingCurrentHeading() => _methods.invokeMethod<void>('lockCourseUsingCurrentHeading');
// ── Remote controller extras (plan P6, `RemoteControllerBridge.kt`) ─────────
/// Looks for master remotes in range; they arrive as `rcMasters` events.
Future<void> searchMasters(bool on) => _methods.invokeMethod<void>('searchMasters', <String, dynamic>{'on': on});
/// Joins a master remote; answers `ConnectToMasterResult` (ACCEPTED, PASSWORD_ERROR, …).
Future<String?> connectToMaster({required int id, required String name, required String password}) =>
_methods.invokeMethod<String>('connectToMaster', <String, dynamic>{'id': id, 'name': name, 'password': password});
/// The slaves linked to this (master) remote: `[{id, name, signal}]`.
Future<List<Map<String, dynamic>>> getSlaves() async {
final List<Object?>? l = await _methods.invokeMethod<List<Object?>>('getSlaves');
return <Map<String, dynamic>>[for (final Object? s in l ?? const <Object?>[]) if (s is Map) Map<String, dynamic>.from(s)];
}
/// The video link's interference while on, as `spectrum` events.
Future<void> watchSpectrum(bool on) => _methods.invokeMethod<void>('watchSpectrum', <String, dynamic>{'on': on});
/// The Wi-Fi link's network name and/or password; [rebootWifi] applies them.
Future<void> setWifiCredentials({String? ssid, String? password}) => _methods.invokeMethod<void>('setWifiCredentials', <String, dynamic>{
if (ssid != null) 'ssid': ssid,
if (password != null) 'password': password,
});
Future<void> rebootWifi() => _methods.invokeMethod<void>('rebootWifi');
// ── Video cache (plan P6, `VideoCacheBridge.kt`) ────────────────────────────
Future<void> setVideoCacheRecording(bool on, {int? limitMb}) =>
_methods.invokeMethod<void>('setVideoCacheRecording', <String, dynamic>{'on': on, if (limitMb != null) 'limitMb': limitMb});
/// `{bytes, files, recording}`.
Future<Map<String, dynamic>> getVideoCacheInfo() async {
final dynamic m = await _methods.invokeMethod('getVideoCacheInfo');
return Map<String, dynamic>.from(m as Map);
}
Future<void> clearVideoCache() => _methods.invokeMethod<void>('clearVideoCache');
/// Records the lens's current focus as infinity (focus on a far subject first).
Future<void> calibrateInfinityFocus() => _methods.invokeMethod<void>('calibrateInfinityFocus');
// ── Media (MediaManager) ────────────────────────────────────────────────────
Future<void> refreshMediaList() => _methods.invokeMethod<void>('refreshMediaList');
+13
View File
@@ -12,6 +12,7 @@ import 'fly/state/camera_state.dart';
import 'fly/state/fly_zone_state.dart';
import 'fly/state/gimbal_state.dart';
import 'fly/state/key_state.dart';
import 'fly/state/link_extras.dart';
import 'fly/state/link_state.dart';
import 'fly/state/live_stream_state.dart';
import 'fly/state/mission_state.dart';
@@ -177,6 +178,15 @@ class FlightModel extends ChangeNotifier {
/// native view letterboxes to it, and the touch layers map taps into it.
final ValueNotifier<Size?> videoSize = ValueNotifier<Size?>(null);
/// The remote's latest C1 / C2 press (plan P6); the flight screen acts on it.
final ValueNotifier<RcButtonPress?> rcButton = ValueNotifier<RcButtonPress?>(null);
/// Master remotes in range while the dual-operator search runs.
final ValueNotifier<List<RcPeer>> rcMasters = ValueNotifier<List<RcPeer>>(const <RcPeer>[]);
/// The video link's interference while the spectrum chart watches it.
final ValueNotifier<Spectrum?> spectrum = ValueNotifier<Spectrum?>(null);
late final List<ChangeNotifier> _subs = <ChangeNotifier>[
aircraft, battery, links, camera, gimbal, vision, alerts, mission, keys, calibration, airSense, flyZones, liveStream, //
];
@@ -306,6 +316,9 @@ class FlightModel extends ChangeNotifier {
}
videoSize.dispose();
mediaPlayback.dispose();
rcButton.dispose();
rcMasters.dispose();
spectrum.dispose();
super.dispose();
}
}
+8 -2
View File
@@ -7,7 +7,7 @@ enum FlightCommand { takeOff, land, cancelLanding, confirmLanding, goHome, cance
/// What is running when the rail offers [FlightCommand.stopMode], i.e. which
/// operator "Stop" has to address.
enum ActiveMode { none, waypoint, activeTrack, tapFly, other }
enum ActiveMode { none, waypoint, activeTrack, tapFly, hotpoint, followMe, other }
/// The left rail's slots for the current flight state (plan P3, GO 4's
/// `fpv_leftmenu`): one primary button that walks Take-off → Land → Cancel
@@ -36,7 +36,7 @@ class RailState {
/// Flight-controller modes in which an intelligent mode is flying the aircraft.
static const Set<String> _waypointModes = <String>{'GPS_WAYPOINT'};
static const Set<String> _trackModes = <String>{'ACTIVE_TRACK', 'ACTIVE_TRACK_SPOTLIGHT'};
static const Set<String> _otherModes = <String>{'GPS_HOT_POINT', 'GPS_FOLLOW_ME', 'DRAW', 'TERRAIN_FOLLOW'};
static const Set<String> _otherModes = <String>{'DRAW', 'TERRAIN_FOLLOW'};
static RailState of({required bool connected, required AircraftState a, MissionState? mission}) {
final bool reported = connected && a.updatedAt != null;
@@ -74,11 +74,17 @@ class RailState {
return ActiveMode.activeTrack;
case 'tapfly':
return ActiveMode.tapFly;
case 'hotpoint':
return ActiveMode.hotpoint;
case 'followme':
return ActiveMode.followMe;
}
final String mode = a.flightModeEnum ?? '';
if (_waypointModes.contains(mode)) return ActiveMode.waypoint;
if (_trackModes.contains(mode)) return ActiveMode.activeTrack;
if (mode == 'TAP_FLY') return ActiveMode.tapFly;
if (mode == 'GPS_HOT_POINT') return ActiveMode.hotpoint;
if (mode == 'GPS_FOLLOW_ME') return ActiveMode.followMe;
if (_otherModes.contains(mode)) return ActiveMode.other;
return ActiveMode.none;
}
+73
View File
@@ -1,8 +1,13 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'rc_buttons.dart';
import 'units.dart';
import 'virtual_sticks.dart' show StickMode;
export 'rc_buttons.dart' show RcButton, RcButtonAction;
export 'units.dart' show UnitSystem;
/// The video overlay grid (camera menu → Other, and the rail's GRID button).
enum GridStyle {
off('Off'),
@@ -39,6 +44,13 @@ abstract final class FlyPrefs {
static const String _autoUploadKey = 'fly.autoUploadFlights';
static const String _liveUrlKey = 'fly.liveStreamUrl';
static const String _liveAudioKey = 'fly.liveStreamAudio';
static const String _unitsKey = 'fly.units';
static const String _c1Key = 'fly.rcC1';
static const String _c2Key = 'fly.rcC2';
static const String _videoCacheKey = 'fly.videoCache';
static const String _videoCacheLimitKey = 'fly.videoCacheLimitMb';
static const String _lensCalibratedKey = 'fly.lensCalibrated';
static const String _tutorialsKey = 'fly.modeTutorials';
/// Open the pre-P2 `FlightControlPage` instead of the HUD v2 — kept
/// reachable until P3's safety flows pass their simulator run (plan P2/P3).
@@ -77,6 +89,26 @@ abstract final class FlyPrefs {
/// Send the phone's microphone with the live stream.
static final ValueNotifier<bool> liveStreamAudio = ValueNotifier<bool>(false);
/// Metric or imperial readouts on the HUD and in settings (plan P6).
static final ValueNotifier<UnitSystem> units = ValueNotifier<UnitSystem>(UnitSystem.metric);
/// What the remote's C1 / C2 buttons do (plan P6); GO 4's defaults.
static final ValueNotifier<RcButtonAction> c1 = ValueNotifier<RcButtonAction>(RcButtonAction.gimbalRecenter);
static final ValueNotifier<RcButtonAction> c2 = ValueNotifier<RcButtonAction>(RcButtonAction.gimbalToggleDown);
/// Record the live feed to the phone while flying (GO 4's video cache, plan P6).
static final ValueNotifier<bool> videoCache = ValueNotifier<bool>(false);
/// The cache's size cap; the oldest recordings go first.
static final ValueNotifier<int> videoCacheLimitMb = ValueNotifier<int>(2048);
/// Cameras (by display name) whose manual-focus lens was calibrated here, so
/// the first-MF prompt is not repeated (plan P6b).
static final ValueNotifier<Set<String>> lensCalibrated = ValueNotifier<Set<String>>(<String>{});
/// Intelligent modes whose tutorial the pilot has seen (plan P7), by name.
static final ValueNotifier<Set<String>> seenTutorials = ValueNotifier<Set<String>>(<String>{});
static SharedPreferences? _prefs;
static Future<void> load() async {
@@ -95,6 +127,13 @@ abstract final class FlyPrefs {
autoUploadFlights.value = _prefs!.getBool(_autoUploadKey) ?? true;
liveStreamUrl.value = _prefs!.getString(_liveUrlKey) ?? '';
liveStreamAudio.value = _prefs!.getBool(_liveAudioKey) ?? false;
units.value = _byName(UnitSystem.values, _prefs!.getString(_unitsKey)) ?? UnitSystem.metric;
c1.value = _byName(RcButtonAction.values, _prefs!.getString(_c1Key)) ?? RcButtonAction.gimbalRecenter;
c2.value = _byName(RcButtonAction.values, _prefs!.getString(_c2Key)) ?? RcButtonAction.gimbalToggleDown;
videoCache.value = _prefs!.getBool(_videoCacheKey) ?? false;
videoCacheLimitMb.value = _prefs!.getInt(_videoCacheLimitKey) ?? 2048;
lensCalibrated.value = (_prefs!.getStringList(_lensCalibratedKey) ?? const <String>[]).toSet();
seenTutorials.value = (_prefs!.getStringList(_tutorialsKey) ?? const <String>[]).toSet();
} catch (_) {
// Storage unavailable: run on the defaults.
}
@@ -166,6 +205,40 @@ abstract final class FlyPrefs {
await _prefs?.setBool(_liveAudioKey, on);
}
static Future<void> setUnits(UnitSystem u) async {
units.value = u;
await _prefs?.setString(_unitsKey, u.name);
}
static Future<void> setRcButton(RcButton b, RcButtonAction a) async {
(b == RcButton.c1 ? c1 : c2).value = a;
await _prefs?.setString(b == RcButton.c1 ? _c1Key : _c2Key, a.name);
}
static RcButtonAction rcButton(RcButton b) => (b == RcButton.c1 ? c1 : c2).value;
static Future<void> setVideoCache(bool on) async {
videoCache.value = on;
await _prefs?.setBool(_videoCacheKey, on);
}
static Future<void> setVideoCacheLimitMb(int mb) async {
videoCacheLimitMb.value = mb;
await _prefs?.setInt(_videoCacheLimitKey, mb);
}
static Future<void> markLensCalibrated(String camera) async {
if (lensCalibrated.value.contains(camera)) return;
lensCalibrated.value = <String>{...lensCalibrated.value, camera};
await _prefs?.setStringList(_lensCalibratedKey, lensCalibrated.value.toList()..sort());
}
static Future<void> markTutorialSeen(String mode) async {
if (seenTutorials.value.contains(mode)) return;
seenTutorials.value = <String>{...seenTutorials.value, mode};
await _prefs?.setStringList(_tutorialsKey, seenTutorials.value.toList()..sort());
}
static T? _byName<T extends Enum>(List<T> values, String? name) {
for (final T v in values) {
if (v.name == name) return v;
+43
View File
@@ -120,6 +120,49 @@ class GimbalTuning {
];
}
/// GO 4's gimbal movement presets: one tap sets max speed and smoothness on
/// both axes, as fractions of each value's range on this gimbal (MSDK V4 has
/// no preset setter for aircraft gimbals, only the per-value ones).
enum GimbalPreset {
fast('Fast', 0.8, 0.2),
medium('Medium', 0.5, 0.5),
slow('Slow', 0.2, 0.8);
const GimbalPreset(this.label, this.speed, this.smoothing);
final String label;
/// Fraction of the max-speed range.
final double speed;
/// Fraction of the smoothing range.
final double smoothing;
static const List<String> speedIds = <String>['pitchMaxSpeed', 'yawMaxSpeed'];
static const List<String> smoothingIds = <String>['pitchSmoothing', 'yawSmoothing'];
/// The values this preset writes, for the tuning ids [ranges] covers.
Map<String, int> valuesFor(Map<String, AxisRange> ranges) => <String, int>{
for (final String id in speedIds)
if (ranges[id] case final AxisRange r) id: (r.min + (r.max - r.min) * speed).round(),
for (final String id in smoothingIds)
if (ranges[id] case final AxisRange r) id: (r.min + (r.max - r.min) * smoothing).round(),
};
/// The preset the current [settings] match (within a step), or null for a custom mix.
static GimbalPreset? matching(Map<String, AxisRange> ranges, Object? Function(String id) setting) {
for (final GimbalPreset p in values) {
final Map<String, int> want = p.valuesFor(ranges);
if (want.isEmpty) return null;
final bool all = want.entries.every((MapEntry<String, int> e) {
final Object? v = setting(e.key);
return v is num && (v - e.value).abs() <= GimbalTuning.stepFor(ranges[e.key]!);
});
if (all) return p;
}
return null;
}
}
/// The one-tap gimbal actions of the sheet and when they are refused.
abstract final class GimbalActions {
/// Fine-tune nudge per tap, degrees (GO 4's Adjust Gimbal step).
+9 -3
View File
@@ -95,9 +95,11 @@ abstract final class HudFeatures {
'overlay.radar', 'Vision radar', 'Overlays', (ProductProfile p) => p.hasObstacleSensing);
static final HudFeature virtualSticks = HudFeature(
'control.virtualSticks', 'On-screen sticks', 'Control', (ProductProfile p) => p.supportsVirtualStick);
/// Precision take-off records the spot with the downward cameras.
static final HudFeature precisionTakeoff = HudFeature(
'control.precisionTakeoff', 'Precision Takeoff option', 'Control', (ProductProfile p) => p.hasVps);
/// Precision take-off records the spot with the downward cameras. The flight
/// controller's own `IS_PRECISION_TAKE_OFF_SUPPORTED` decides once it has
/// answered; until then downward vision is the stand-in.
static final HudFeature precisionTakeoff = HudFeature('control.precisionTakeoff', 'Precision Takeoff option', 'Control',
(ProductProfile p) => p.flightController?.precisionTakeOffSupported ?? p.hasVps);
/// ADS-B In: the receiver's own reports decide at runtime; the profile only rules out non-aircraft.
static final HudFeature airSense = HudFeature(
'overlay.airSense', 'AirSense (ADS-B) warnings', 'Overlays', (ProductProfile p) => p.isAircraft);
@@ -123,6 +125,9 @@ abstract final class HudFeatures {
(ProductProfile p) => p.link == LinkType.lightbridge);
static final HudFeature linkWifi = HudFeature(
'settings.wifi', 'Wi-Fi transmission settings', 'Settings', (ProductProfile p) => p.link == LinkType.wifi);
/// C1 / C2: every remote with custom buttons; Wi-Fi-only flying has none.
static final HudFeature customButtons = HudFeature(
'settings.customButtons', 'C1 / C2 button actions', 'Settings', (ProductProfile p) => p.remoteController != null && p.hasRcLink);
// ── Calibration & maintenance wizards (plan P6b) ──────────────────────────
// Runtime support (`CalibrationState`) can still drop a wizard the profile allows.
@@ -194,6 +199,7 @@ abstract final class HudFeatures {
linkOcuSync,
linkLightbridge,
linkWifi,
customButtons,
calibrationCompass,
calibrationImu,
calibrationRc,
+99 -9
View File
@@ -5,7 +5,7 @@ import '../product_profile.dart';
///
/// Which modes a product has comes from [ProductProfile.modes] (the product
/// matrix until runtime capture confirms it); which of those this app can fly
/// yet is [ModeInfo.built]. A mode that is supported but not built is listed
/// is [ModeInfo.built]. A mode that is supported but not built is listed
/// greyed out rather than hidden, so the pilot sees what is coming.
class ModeInfo {
const ModeInfo({
@@ -15,6 +15,7 @@ class ModeInfo {
required this.group,
required this.blurb,
required this.terms,
this.tutorial = const <String>[],
this.built = false,
});
@@ -33,6 +34,10 @@ class ModeInfo {
/// The first-use disclaimer: what the pilot stays responsible for.
final List<String> terms;
/// GO 4's first-use tutorial: how to fly the mode, step by step. Shown once
/// after the terms, and again from the mode's panel.
final List<String> tutorial;
/// This app can fly the mode.
final bool built;
}
@@ -43,6 +48,13 @@ abstract final class ModeCatalog {
'The aircraft flies itself in this mode. Push the remote\'s pause button, or tap Stop, to make it hover.',
];
static const List<String> _waypointTutorial = <String>[
'Tap the map to drop waypoints; tap a waypoint to edit its altitude, speed, heading, gimbal and camera action.',
'Long-press and drag a waypoint to move it. The + between two waypoints inserts one there.',
'ROUTE sets the cruise speed, the heading and what happens at the end; LIBRARY saves the route.',
'START checks the route against the aircraft, uploads it and flies it. PAUSE holds the aircraft where it is.',
];
static const List<ModeInfo> all = <ModeInfo>[
ModeInfo(
mode: IntelligentMode.waypoint,
@@ -56,6 +68,7 @@ abstract final class ModeCatalog {
'The route flies straight between waypoints at the altitudes you set. It does not avoid terrain, trees or buildings: check the route and its altitudes before you start.',
'If the remote loses signal the aircraft follows the route\'s signal-lost setting.',
],
tutorial: _waypointTutorial,
),
ModeInfo(
mode: IntelligentMode.waypointV2,
@@ -63,7 +76,12 @@ abstract final class ModeCatalog {
icon: 'route',
group: 'Map',
blurb: 'Enterprise waypoint missions.',
terms: _common,
built: true,
terms: <String>[
..._common,
'Waypoints 2.0 flies the route\'s positions, altitudes, speeds and headings. Camera actions are not sent to the aircraft yet: run the camera yourself.',
],
tutorial: _waypointTutorial,
),
ModeInfo(
mode: IntelligentMode.pointOfInterest,
@@ -71,7 +89,17 @@ abstract final class ModeCatalog {
icon: 'circle',
group: 'Map',
blurb: 'Circle a subject at a set radius.',
terms: _common,
built: true,
terms: <String>[
..._common,
'The aircraft circles at a fixed height and does not brake for obstacles on the circle: check the whole circle is clear.',
],
tutorial: <String>[
'Tap the map on the subject, or AIRCRAFT to use where the aircraft is now.',
'Set the radius, the altitude and the speed; pick the direction and which way the aircraft faces.',
'GO flies to the circle and starts circling. Radius and speed can change while it circles.',
'The remote\'s sticks nudge the orbit; PAUSE holds position, STOP ends it.',
],
),
ModeInfo(
mode: IntelligentMode.followMe,
@@ -79,7 +107,18 @@ abstract final class ModeCatalog {
icon: 'user',
group: 'Map',
blurb: 'The aircraft follows this phone.',
terms: _common,
built: true,
terms: <String>[
..._common,
'The aircraft follows the phone\'s GPS, which can be off by several metres, and it does not avoid obstacles while it follows.',
'Keep the phone with you and its screen on: if the app stops sending your position the aircraft stops following and hovers.',
],
tutorial: <String>[
'Take off and climb to at least 10 m with open space around you.',
'Set the height to follow at and whether the aircraft faces you or the remote steers its heading.',
'GO: the aircraft keeps its distance as you walk. Keep the app open.',
'STOP, or the remote\'s pause button, ends it.',
],
),
ModeInfo(
mode: IntelligentMode.activeTrack,
@@ -93,6 +132,12 @@ abstract final class ModeCatalog {
'ActiveTrack follows what the camera sees. It can lose the subject behind trees or in crowds, and it does not see obstacles to the sides or behind on every aircraft.',
'Do not use it to follow cars or boats at speed, or over water and roads you cannot see the end of.',
],
tutorial: <String>[
'Drag a box round the subject on the video (or tap a person-sized one), then GO.',
'Trace follows behind, Profile alongside, Spotlight keeps the camera on it while you fly.',
'A green box is locked on, amber is unsure; confirm with ✓ when the aircraft asks.',
'Gestures: with Gesture mode on (Settings → Vision), raise your arms to be tracked, frame your fingers for a photo.',
],
),
ModeInfo(
mode: IntelligentMode.tapFly,
@@ -105,6 +150,11 @@ abstract final class ModeCatalog {
..._common,
'TapFly keeps flying towards the point until you stop it. Obstacle avoidance only works in the direction the sensors face, and not at all in Free.',
],
tutorial: <String>[
'Tap where to fly on the video, then GO.',
'Forward flies towards the point; Backward away from it; Free lets you turn the aircraft while it flies.',
'Set the speed with − / +; tap a new point to change course.',
],
),
ModeInfo(
mode: IntelligentMode.quickShots,
@@ -112,7 +162,16 @@ abstract final class ModeCatalog {
icon: 'dronie',
group: 'Shots',
blurb: 'Dronie, Rocket, Circle, Helix…',
terms: _common,
built: true,
terms: <String>[
..._common,
'A QuickShot flies a set path around the subject, up to 120 m away and high: keep that space clear.',
],
tutorial: <String>[
'Pick the shot, draw a box round the subject on the video, then GO.',
'The aircraft records the video, flies the path and comes back to where it started.',
'STOP, the sticks or the remote\'s pause button end it early.',
],
),
ModeInfo(
mode: IntelligentMode.hyperlapse,
@@ -120,7 +179,16 @@ abstract final class ModeCatalog {
icon: 'hyperlapse',
group: 'Shots',
blurb: 'A moving timelapse.',
terms: _common,
built: true,
terms: <String>[
'You fly the aircraft during a Free hyperlapse; the camera only shoots the frames. Fly slowly and steadily.',
'Stop the sequence before landing or flying home.',
],
tutorial: <String>[
'Set the interval between frames and how long the finished video should be.',
'START shoots the frames; fly a slow, steady line meanwhile.',
'The frames are on the SD card; assemble them into the video on a computer.',
],
),
ModeInfo(
mode: IntelligentMode.courseLock,
@@ -128,7 +196,15 @@ abstract final class ModeCatalog {
icon: 'compass',
group: 'Orientation',
blurb: 'Forward stays the current heading.',
terms: _common,
built: true,
terms: <String>[
'Course Lock changes what the sticks do: forward is the locked course, whichever way the aircraft faces. Turn it off before flying normally again.',
],
tutorial: <String>[
'Point the aircraft where "forward" should be, then turn Course Lock on.',
'Turn the aircraft freely: the sticks keep flying the locked course.',
'LOCK HEADING takes the current heading as the new course.',
],
),
ModeInfo(
mode: IntelligentMode.homeLock,
@@ -136,7 +212,14 @@ abstract final class ModeCatalog {
icon: 'home',
group: 'Orientation',
blurb: 'Pulling back always flies home.',
terms: _common,
built: true,
terms: <String>[
'Home Lock changes what the sticks do: back is always towards home, whichever way the aircraft faces. Turn it off before flying normally again.',
],
tutorial: <String>[
'Fly at least 10 m from home, then turn Home Lock on.',
'Pull the stick back to come home, push it to fly away, whichever way the aircraft points.',
],
),
ModeInfo(
mode: IntelligentMode.tripod,
@@ -144,7 +227,14 @@ abstract final class ModeCatalog {
icon: 'gauge',
group: 'Orientation',
blurb: 'Slow, precise control for framing.',
terms: _common,
built: true,
terms: <String>[
'Tripod mode limits the speed to about 1 m/s and makes the sticks very gentle. Turn it off to fly away from danger quickly.',
],
tutorial: <String>[
'Tripod: about 1 m/s and gentle sticks, for framing and indoor-tight spaces.',
'Cinematic: smooth braking and slow turns for steady video; set how gently it brakes and turns.',
],
),
];
+283
View File
@@ -0,0 +1,283 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import '../hud_geometry.dart';
/// The remaining Intelligent Flight Modes (plan P7) as pure data: Point of
/// Interest, Follow Me, QuickShots, Hyperlapse and the orientation /
/// Tripod / Cinematic switches. The SafetyGate checks them and the bridge
/// flies them; nothing here talks to the aircraft.
// ── Point of Interest ─────────────────────────────────────────────────────────
/// Which way the aircraft faces while circling (`HotpointHeading`).
enum PoiHeading {
toCenter('Towards POI', 'TOWARDS_HOT_POINT'),
away('Away from POI', 'AWAY_FROM_HOT_POINT'),
forward('Along the circle', 'ALONG_CIRCLE_LOOKING_FORWARDS'),
backward('Along, looking back', 'ALONG_CIRCLE_LOOKING_BACKWARDS'),
remote('Remote controls it', 'CONTROLLED_BY_REMOTE_CONTROLLER');
const PoiHeading(this.label, this.sdk);
final String label;
final String sdk;
}
/// Where on the circle the aircraft joins it (`HotpointStartPoint`).
enum PoiStart {
nearest('Nearest', 'NEAREST'),
north('North', 'NORTH'),
east('East', 'EAST'),
south('South', 'SOUTH'),
west('West', 'WEST');
const PoiStart(this.label, this.sdk);
final String label;
final String sdk;
}
/// A Point of Interest orbit as the pilot sets it up (GO 4's POI panel).
@immutable
class PoiSpec {
const PoiSpec({
this.center,
this.radius = 30,
this.altitude = 30,
this.speed = 3,
this.clockwise = true,
this.heading = PoiHeading.toCenter,
this.start = PoiStart.nearest,
});
// `HotpointMission` limits (MSDK 4.18 constants).
static const double minRadius = 5;
static const double maxRadius = 500;
static const double minAltitude = 5;
static const double maxAltitude = 500;
/// The operator's ceiling for the orbit, whatever the radius allows.
static const double maxSpeed = 10;
static const double minSpeed = 0.5;
/// Below this the pre-start check warns about trees and buildings.
static const double lowAltitude = 20;
final LatLng? center;
final double radius;
final double altitude;
/// Tangential speed, m/s; the bridge wants °/s ([angularVelocity]).
final double speed;
final bool clockwise;
final PoiHeading heading;
final PoiStart start;
static const Object _keep = Object();
PoiSpec copyWith({
Object? center = _keep,
double? radius,
double? altitude,
double? speed,
bool? clockwise,
PoiHeading? heading,
PoiStart? start,
}) =>
PoiSpec(
center: identical(center, _keep) ? this.center : center as LatLng?,
radius: radius ?? this.radius,
altitude: altitude ?? this.altitude,
speed: speed ?? this.speed,
clockwise: clockwise ?? this.clockwise,
heading: heading ?? this.heading,
start: start ?? this.start,
);
/// Degrees per second for a tangential [speed] on a circle of [radius].
static double angularFor(double speed, double radius) => speed / radius * 180 / math.pi;
/// The tangential speed of [deg] °/s on [radius].
static double speedFor(double deg, double radius) => deg * math.pi / 180 * radius;
double get angularVelocity => angularFor(speed, radius);
/// Seconds per lap.
int get lapSeconds => (2 * math.pi * radius / speed.clamp(minSpeed, maxSpeed)).round();
/// What would make the operator refuse the orbit (blocking) or what the
/// pilot should know (not).
List<(bool, String)> issues({double? maxAngular, LatLng? aircraft}) => <(bool, String)>[
if (center == null) (true, 'Set the point of interest: tap the map, or use the aircraft\'s position.'),
if (radius < minRadius || radius > maxRadius) (true, 'The radius must be between 5 and 500 m.'),
if (altitude < minAltitude || altitude > maxAltitude) (true, 'The altitude must be between 5 and 500 m.'),
if (maxAngular != null && maxAngular > 0 && angularVelocity > maxAngular)
(false, 'At ${radius.round()} m the aircraft circles at most ${speedFor(maxAngular, radius).toStringAsFixed(1)} m/s; it flies that speed.'),
if (altitude < lowAltitude) (false, 'The orbit is only ${altitude.round()} m up: check for trees and buildings on the circle.'),
if (center != null && aircraft != null && HudGeometry.distance(aircraft.latitude, aircraft.longitude, center!.latitude, center!.longitude) > 1000)
(false, 'The point of interest is more than 1 km from the aircraft.'),
];
/// `startHotpoint`'s arguments; [maxAngular] caps the speed the operator would refuse.
Map<String, Object?> toBridge({double? maxAngular}) {
double w = angularVelocity;
if (maxAngular != null && maxAngular > 0) w = math.min(w, maxAngular);
return <String, Object?>{
'lat': center!.latitude,
'lon': center!.longitude,
'altitude': altitude.clamp(minAltitude, maxAltitude),
'radius': radius.clamp(minRadius, maxRadius),
'angularVelocity': w,
'clockwise': clockwise,
'startPoint': start.sdk,
'heading': heading.sdk,
};
}
}
// ── Follow Me ─────────────────────────────────────────────────────────────────
enum FollowMeHeading {
toPilot('Face me', 'TOWARD_FOLLOW_POSITION'),
remote('Remote controls it', 'CONTROLLED_BY_REMOTE_CONTROLLER');
const FollowMeHeading(this.label, this.sdk);
final String label;
final String sdk;
}
@immutable
class FollowMeSpec {
const FollowMeSpec({this.altitude = 15, this.heading = FollowMeHeading.toPilot});
static const double minAltitude = 10;
static const double maxAltitude = 120;
/// How often the phone's fix is sent while following.
static const Duration updateEvery = Duration(milliseconds: 500);
/// A phone fix worse than this (m) is not worth following.
static const double maxAccuracy = 15;
final double altitude;
final FollowMeHeading heading;
FollowMeSpec copyWith({double? altitude, FollowMeHeading? heading}) =>
FollowMeSpec(altitude: altitude ?? this.altitude, heading: heading ?? this.heading);
}
// ── QuickShots ────────────────────────────────────────────────────────────────
/// GO 4's QuickShots (`QuickShotMode` names); which ones an aircraft flies is
/// its firmware's call — an unsupported one is refused by the operator.
enum QuickShot {
dronie('Dronie', 'DRONIE', 'Flies up and back, camera on the subject'),
rocket('Rocket', 'ROCKET', 'Climbs straight up, camera looking down'),
circle('Circle', 'CIRCLE', 'Circles the subject at its height'),
helix('Helix', 'HELIX', 'Spirals up and out around the subject'),
boomerang('Boomerang', 'BOOMERANG', 'Swings out and back in an oval'),
asteroid('Asteroid', 'ASTEROID', 'Flies up and back, then a spherical pano'),
dollyZoom('Dolly zoom', 'DOLLY_ZOOM', 'Backs away while zooming in (zoom cameras)');
const QuickShot(this.label, this.sdk, this.blurb);
final String label;
final String sdk;
final String blurb;
/// The shots GO 4 offers on this product: Boomerang and Asteroid came with
/// the omni-sensing aircraft, Dolly zoom needs a zoom lens.
static List<QuickShot> offered({required bool omni, required bool zoom}) => <QuickShot>[
dronie,
rocket,
circle,
helix,
if (omni) boomerang,
if (omni) asteroid,
if (zoom) dollyZoom,
];
}
// ── Hyperlapse ────────────────────────────────────────────────────────────────
/// A Free hyperlapse: the camera shoots an interval sequence while the pilot
/// flies, for a video of [videoSeconds] at [fps] (GO 4's Hyperlapse → Free).
/// MSDK V4 has no hyperlapse operator, so the flight is the pilot's and the
/// frames are assembled afterwards.
@immutable
class HyperlapseSpec {
const HyperlapseSpec({this.intervalSeconds = 2, this.videoSeconds = 10, this.fps = 25});
static const List<int> intervals = <int>[2, 3, 5, 10];
static const List<int> durations = <int>[5, 10, 15, 20, 30];
final int intervalSeconds;
final int videoSeconds;
final int fps;
int get frames => (videoSeconds * fps).clamp(2, 10000);
/// The camera's interval count: at most 254 frames, else 255 = "until
/// stopped", and the gate stops it after [shootingSeconds].
int get cameraCount => frames > 254 ? 255 : frames;
/// How long the shooting takes, s.
int get shootingSeconds => frames * intervalSeconds;
HyperlapseSpec copyWith({int? intervalSeconds, int? videoSeconds}) =>
HyperlapseSpec(intervalSeconds: intervalSeconds ?? this.intervalSeconds, videoSeconds: videoSeconds ?? this.videoSeconds, fps: fps);
}
// ── Orientation / Tripod / Cinematic ──────────────────────────────────────────
/// Intelligent Orientation Control (`FlightOrientationMode`).
enum OrientationMode {
normal('Normal', 'AIRCRAFT_HEADING'),
courseLock('Course Lock', 'COURSE_LOCK'),
homeLock('Home Lock', 'HOME_LOCK');
const OrientationMode(this.label, this.sdk);
final String label;
final String sdk;
static OrientationMode fromSdk(String? s) => switch (s) {
'COURSE_LOCK' => courseLock,
'HOME_LOCK' => homeLock,
_ => normal,
};
}
/// Home Lock refuses within this distance of home: "pull back" has no direction.
const double homeLockMinDistance = 10;
/// What the pilot has set up in the POI / Follow Me / Hyperlapse panels,
/// shared by the panel, the map layer and the flight screen.
class ModeSetup extends ChangeNotifier {
PoiSpec poi = const PoiSpec();
FollowMeSpec followMe = const FollowMeSpec();
HyperlapseSpec hyperlapse = const HyperlapseSpec();
/// The aircraft's fastest orbit at [poi]'s radius (°/s), once asked.
double? poiMaxAngular;
void setPoi(PoiSpec p) {
if (p.radius != poi.radius) poiMaxAngular = null;
poi = p;
notifyListeners();
}
void setPoiMaxAngular(double? v) {
poiMaxAngular = v;
notifyListeners();
}
void setFollowMe(FollowMeSpec f) {
followMe = f;
notifyListeners();
}
void setHyperlapse(HyperlapseSpec h) {
hyperlapse = h;
notifyListeners();
}
}
+139
View File
@@ -0,0 +1,139 @@
import 'dart:convert';
import 'dart:io';
import 'package:latlong2/latlong.dart' show LatLng;
import '../hud_geometry.dart';
import 'waypoint_mission.dart';
/// The route's clearance over the ground (plan P7's terrain check).
///
/// Waypoint altitudes are relative to the take-off point, so a route that
/// climbs a hillside can fly into it while every waypoint reads "50 m". This
/// samples the ground along every leg (an elevation service, [ElevationSource])
/// and compares it with the aircraft's planned height above sea level: take-off
/// elevation + the altitude interpolated along the leg.
///
/// Pure apart from the source, so the maths is unit-tested with a fake.
abstract class ElevationSource {
/// Ground elevation in metres above sea level for each point, in order.
Future<List<double>> elevations(List<LatLng> points);
}
/// One sample along the route that comes too close to the ground.
class TerrainIssue {
const TerrainIssue({required this.leg, required this.clearance, required this.at});
/// The leg it is on: from waypoint [leg] to [leg] + 1 (0-based).
final int leg;
/// Metres between the aircraft and the ground there (negative = underground).
final double clearance;
final LatLng at;
MissionIssue toIssue() => clearance < 0
? MissionIssue.error(
'Between waypoints ${leg + 1} and ${leg + 2} the ground rises ${(-clearance).round()} m above the route.',
point: leg + 1,
)
: MissionIssue.warning(
'Between waypoints ${leg + 1} and ${leg + 2} the route passes only ${clearance.round()} m over the ground.',
point: leg + 1,
);
}
class TerrainCheck {
const TerrainCheck(this.source, {this.spacing = 50, this.minClearance = 15, this.maxSamples = 100});
final ElevationSource source;
/// Metres between samples along a leg.
final double spacing;
/// Less clearance than this is reported.
final double minClearance;
/// The service takes at most this many points per request.
final int maxSamples;
/// Samples along the route: every waypoint, and points every [spacing] m
/// between them, each with its leg and planned altitude (relative to take-off).
List<(int leg, LatLng at, double altitude)> samples(WaypointMissionSpec spec) {
final List<(int, LatLng, double)> out = <(int, LatLng, double)>[];
final List<WaypointSpec> p = spec.points;
for (int i = 0; i + 1 < p.length; i++) {
final LatLng a = p[i].at, b = p[i + 1].at;
final double d = HudGeometry.distance(a.latitude, a.longitude, b.latitude, b.longitude);
final int n = (d / spacing).ceil().clamp(1, 1000);
for (int k = 0; k < n; k++) {
final double t = k / n;
out.add((
i,
LatLng(a.latitude + (b.latitude - a.latitude) * t, a.longitude + (b.longitude - a.longitude) * t),
p[i].altitude + (p[i + 1].altitude - p[i].altitude) * t,
));
}
}
if (p.isNotEmpty) out.add((p.length - 2 < 0 ? 0 : p.length - 2, p.last.at, p.last.altitude));
// Evenly thinned to the service's limit.
if (out.length > maxSamples) {
final double step = out.length / maxSamples;
return <(int, LatLng, double)>[for (int k = 0; k < maxSamples; k++) out[(k * step).floor()]];
}
return out;
}
/// The places the route comes within [minClearance] of the ground; [home]
/// is the take-off point the altitudes are measured from. The worst per leg.
Future<List<TerrainIssue>> run(WaypointMissionSpec spec, LatLng home) async {
final List<(int, LatLng, double)> s = samples(spec);
if (s.isEmpty) return const <TerrainIssue>[];
final List<double> ground = await source.elevations(<LatLng>[home, for (final (int, LatLng, double) x in s) x.$2]);
if (ground.length != s.length + 1) throw const FormatException('The elevation service answered for a different number of points');
final double base = ground.first;
final Map<int, TerrainIssue> worst = <int, TerrainIssue>{};
for (int k = 0; k < s.length; k++) {
final (int leg, LatLng at, double alt) = s[k];
final double clearance = base + alt - ground[k + 1];
if (clearance >= minClearance) continue;
final TerrainIssue? w = worst[leg];
if (w == null || clearance < w.clearance) worst[leg] = TerrainIssue(leg: leg, clearance: clearance, at: at);
}
final List<TerrainIssue> out = worst.values.toList()..sort((TerrainIssue a, TerrainIssue b) => a.leg.compareTo(b.leg));
return out;
}
}
/// Open-Meteo's free elevation API (Copernicus 90 m DEM, no key). Sends the
/// sampled coordinates to api.open-meteo.com, so the check only runs when the
/// pilot asks for it.
class OpenMeteoElevation implements ElevationSource {
const OpenMeteoElevation();
@override
Future<List<double>> elevations(List<LatLng> points) async {
final List<double> out = <double>[];
// 100 coordinates per request is the API's limit.
for (int i = 0; i < points.length; i += 100) {
final List<LatLng> chunk = points.sublist(i, (i + 100).clamp(0, points.length));
final Uri uri = Uri.https('api.open-meteo.com', '/v1/elevation', <String, String>{
'latitude': chunk.map((LatLng p) => p.latitude.toStringAsFixed(5)).join(','),
'longitude': chunk.map((LatLng p) => p.longitude.toStringAsFixed(5)).join(','),
});
final HttpClient client = HttpClient()..connectionTimeout = const Duration(seconds: 10);
try {
final HttpClientRequest req = await client.getUrl(uri);
final HttpClientResponse res = await req.close().timeout(const Duration(seconds: 15));
final String body = await res.transform(utf8.decoder).join();
if (res.statusCode != 200) throw HttpException('Elevation service answered ${res.statusCode}', uri: uri);
final Object? json = jsonDecode(body);
final Object? list = json is Map ? json['elevation'] : null;
if (list is! List) throw const FormatException('No elevation in the answer');
out.addAll(<double>[for (final Object? e in list) (e as num).toDouble()]);
} finally {
client.close();
}
}
return out;
}
}
+31 -1
View File
@@ -2,6 +2,8 @@ import 'dart:ui' show Offset, Rect, Size;
import 'package:flutter/foundation.dart';
import 'orbit_modes.dart' show QuickShot;
/// What the pilot has chosen in an ActiveTrack or TapFly session, before and
/// while the operator runs: shared by the video overlay and the mode panel.
class VisionSession extends ChangeNotifier {
@@ -9,6 +11,22 @@ class VisionSession extends ChangeNotifier {
TapFlyDirection direction = TapFlyDirection.forward;
double speed = 5;
/// ActiveTrack Circle's orbit, m/s; negative goes anticlockwise.
double circleSpeed = 3;
/// The QuickShot the next box flies.
QuickShot quickShot = QuickShot.dronie;
void setCircleSpeed(double v) {
circleSpeed = v;
notifyListeners();
}
void setQuickShot(QuickShot s) {
quickShot = s;
notifyListeners();
}
/// A box drawn (or tapped) on the video, 0..1, waiting for GO.
Rect? pendingBox;
@@ -77,12 +95,24 @@ enum TrackMode {
profile('Profile', 'PROFILE'),
/// Stays put and keeps the camera on it; the pilot flies.
spotlight('Spotlight', 'SPOTLIGHT');
spotlight('Spotlight', 'SPOTLIGHT'),
/// Spotlight that also turns the aircraft (the gimbal alone cannot keep
/// up): Mavic 2-class aircraft.
spotlightPro('Spotlight Pro', 'SPOTLIGHT_PRO'),
/// Orbits the subject at [VisionSession.circleSpeed].
circle('Circle', 'CIRCLE');
const TrackMode(this.label, this.sdk);
final String label;
final String sdk;
/// The follow styles GO 4 offers: Spotlight Pro and Circle came with the
/// omni-sensing aircraft (Mavic 2); everything else has the first three.
static List<TrackMode> offered({required bool omni}) =>
<TrackMode>[trace, profile, spotlight, if (omni) spotlightPro, if (omni) circle];
static TrackMode? fromSdk(String? s) {
for (final TrackMode m in values) {
if (m.sdk == s) return m;
@@ -365,6 +365,21 @@ class WaypointMissionSpec {
updatedAt: updatedAt ?? this.updatedAt,
);
/// The rest of the route from waypoint [index] on (0-based), to resume an
/// interrupted mission there. Keeps the route settings and POIs.
WaypointMissionSpec from(int index) => copyWith(points: points.sublist(index.clamp(0, points.length)));
/// Whether an interruption at [at] (waypoint [index]) belongs to this route:
/// the route has that waypoint and it lies within 50 m of where it stopped.
bool matchesInterruption(int index, LatLng at) {
if (index < 0 || index >= points.length) return false;
// Interrupted between index−1 and index: near either end of that leg counts.
final LatLng p = points[index].at;
final LatLng q = index > 0 ? points[index - 1].at : p;
final double leg = _dist2(q, p);
return _dist2(at, p) <= leg + 50 && _dist2(at, q) <= leg + 50;
}
// ── Geometry and estimates ────────────────────────────────────────────────
/// 3-D length of each leg, m: leg i runs from point i to point i+1.
+5
View File
@@ -226,9 +226,11 @@ class FlightControllerCaps {
this.accessLockerSupported,
this.onboardSdkAvailable,
this.simulatorAvailable,
this.precisionTakeOffSupported,
});
factory FlightControllerCaps.fromMap(Map<String, dynamic> m) => FlightControllerCaps(
precisionTakeOffSupported: m['precisionTakeOffSupported'] as bool?,
flightAssistantSupported: m['flightAssistantSupported'] as bool?,
virtualStickAvailable: m['virtualStickAvailable'] as bool?,
rtkSupported: m['rtkSupported'] as bool?,
@@ -251,6 +253,9 @@ class FlightControllerCaps {
final bool? accessLockerSupported;
final bool? onboardSdkAvailable;
final bool? simulatorAvailable;
/// `IS_PRECISION_TAKE_OFF_SUPPORTED`; null until the flight controller answered.
final bool? precisionTakeOffSupported;
}
class RemoteControllerCaps {
+35
View File
@@ -0,0 +1,35 @@
/// What a remote's custom button (C1 / C2) does (GO 4's RC → Button
/// Customization, plan P6).
///
/// On the consumer remotes MSDK V4 only reports that C1 / C2 was pressed
/// (`RemoteControllerKey.CUSTOM_BUTTON_1/2`): the action is the app's, as it is
/// in GO 4. The flight screen performs it; nothing is written to the remote.
enum RcButtonAction {
none('Not set'),
gimbalRecenter('Recenter gimbal'),
gimbalToggleDown('Gimbal forward / down'),
mapSwap('Map / camera view'),
cameraSettings('Camera settings'),
aircraftStatus('Aircraft status'),
aeLock('AE lock on / off'),
radar('Radar chart on / off');
const RcButtonAction(this.label);
final String label;
}
/// A press of one of the remote's custom buttons.
enum RcButton {
c1('C1'),
c2('C2');
const RcButton(this.label);
final String label;
/// The `rcButton` event's `button` value.
static RcButton? fromEvent(Object? v) => switch (v) {
'C1' => RcButton.c1,
'C2' => RcButton.c2,
_ => null,
};
}
+327 -1
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:ui' show Offset, Rect;
import 'package:flutter/foundation.dart';
@@ -10,6 +11,7 @@ import 'calibration.dart';
import 'flight_status.dart';
import 'fly_prefs.dart';
import 'hud_geometry.dart';
import 'modes/orbit_modes.dart';
import 'modes/vision_modes.dart';
import 'modes/waypoint_mission.dart';
import 'product_profile.dart';
@@ -158,6 +160,8 @@ class SafetyGate {
case ActiveMode.waypoint:
case ActiveMode.activeTrack:
case ActiveMode.tapFly:
case ActiveMode.hotpoint:
case ActiveMode.followMe:
break;
}
}
@@ -421,6 +425,8 @@ class SafetyGate {
checks.add(const GateCheck.block('The mission system is not ready yet. Wait a moment.'));
}
if (model.mission.tracking) checks.add(const GateCheck.block('Stop ActiveTrack first.'));
if (model.mission.hotpoint.active) checks.add(const GateCheck.block('Stop Point of Interest first.'));
if (model.mission.followMe.active) checks.add(const GateCheck.block('Stop Follow Me first.'));
for (final MissionIssue i in spec.validate(limits)) {
checks.add(i.blocks ? GateCheck.block(i.message) : GateCheck.warn(i.message));
@@ -472,6 +478,9 @@ class SafetyGate {
if (shoots && model.camera.storage != null && !model.camera.storage!.usable) {
checks.add(GateCheck.warn("${model.camera.storage!.status}: the route's photos and video will not be saved."));
}
if (usesWaypointsV2 && (shoots || spec.points.any((WaypointSpec p) => p.hoverSeconds > 0 || p.gimbalPitch != null))) {
checks.add(const GateCheck.warn('Waypoints 2.0: the route flies, but its camera actions, hovers and gimbal angles are not sent yet.'));
}
return checks;
}
@@ -489,11 +498,42 @@ class SafetyGate {
final int h = rthHeight.clamp(minRthHeight, maxRthHeight);
if (h != _a.goHomeHeight?.round()) await dji.setGoHomeHeight(h);
}
await dji.uploadWaypointMission(spec.toBridge(gimbalMin: limits.gimbalMin, gimbalMax: limits.gimbalMax));
await dji.uploadWaypointMission(spec.toBridge(gimbalMin: limits.gimbalMin, gimbalMax: limits.gimbalMax), v2: usesWaypointsV2);
onUploaded?.call();
await dji.startWaypointMission();
}
/// The connected aircraft flies Waypoints 2.0 only (Matrice 300): the same
/// route goes to the V2 operator.
bool get usesWaypointsV2 =>
model.profile.supportsMode(IntelligentMode.waypointV2) && !model.profile.supportsMode(IntelligentMode.waypoint);
/// The cruise speed of the mission in flight, from the run panel.
Future<void> setWaypointCruiseSpeed(double speed) async {
if (!model.mission.waypoint.active) {
throw const MissionRefused(<GateCheck>[GateCheck.block('No waypoint mission is flying.')]);
}
await dji.setWaypointAutoFlightSpeed(speed.clamp(WaypointMissionSpec.minSpeed, WaypointMissionSpec.maxSpeed));
}
/// Where the last mission was cut short, if it belongs to [spec]: the
/// waypoint (0-based) to resume from, else null.
Future<int?> interruptionFor(WaypointMissionSpec spec) async {
if (!model.connected || spec.points.length < 2) return null;
try {
final Map<String, dynamic>? i = await dji.getWaypointInterruption();
if (i == null) return null;
final int? index = (i['index'] as num?)?.toInt();
final double? lat = (i['lat'] as num?)?.toDouble(), lon = (i['lon'] as num?)?.toDouble();
if (index == null || lat == null || lon == null || index <= 0) return null;
// A resume needs two waypoints left to fly.
if (index > spec.points.length - 2) return null;
return spec.matchesInterruption(index, LatLng(lat, lon)) ? index : null;
} catch (_) {
return null;
}
}
/// Holds the aircraft where it is; the mission can resume from there.
Future<void> pauseWaypointMission() async {
if (!model.mission.waypoint.executing) {
@@ -544,6 +584,8 @@ class SafetyGate {
if (_a.goingHome == true || mode == 'GO_HOME') checks.add(const GateCheck.block('Cancel Return to Home first.'));
if (mode == 'AUTO_LANDING' || mode == 'CONFIRM_LANDING') checks.add(const GateCheck.block('The aircraft is landing.'));
if (model.mission.waypoint.active) checks.add(const GateCheck.block('Stop the waypoint mission first.'));
if (model.mission.hotpoint.active) checks.add(const GateCheck.block('Stop Point of Interest first.'));
if (model.mission.followMe.active) checks.add(const GateCheck.block('Stop Follow Me first.'));
if (mode == 'ATTI' || mode == 'GPS_SPORT' || mode == 'MANUAL') {
checks.add(const GateCheck.block('Switch the remote to P mode first.'));
}
@@ -667,6 +709,282 @@ class SafetyGate {
await dji.setTapFlySpeed(speed.clamp(tapFlyMinSpeed, tapFlyMaxSpeed));
}
/// ActiveTrack Circle's orbit speed, m/s (negative anticlockwise).
Future<void> setTrackCircleSpeed(double speed) async {
if (!model.connected) throw const MissionRefused(<GateCheck>[GateCheck.block('No aircraft connected.')]);
await dji.setActiveTrackCircularSpeed(speed.clamp(-10.0, 10.0));
}
// ── QuickShots (plan P7) ────────────────────────────────────────────────────
/// Preconditions for flying [shot] around a boxed subject: ActiveTrack's,
/// plus room for the aircraft to climb and fly away.
List<GateCheck> checkQuickShot(QuickShot shot) {
final List<GateCheck> checks = <GateCheck>[];
if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.'));
if (_a.updatedAt == null) return checks..add(const GateCheck.block('Waiting for the flight controller to report.'));
if (model.mission.activeTrack.unsupported) return checks..add(const GateCheck.block('This aircraft cannot fly QuickShots.'));
_visionCommon(checks);
if (model.mission.tapFly.active) checks.add(const GateCheck.block('Stop TapFly first.'));
if (model.mission.activeTrack.active) checks.add(const GateCheck.block('Stop ActiveTrack first.'));
if (model.camera.storage != null && !model.camera.storage!.usable) {
checks.add(GateCheck.block('${model.camera.storage!.status}: the QuickShot video would not be saved.'));
}
final int? left = _a.goHome?.remainingFlightTime;
if (left != null && left > 0 && left < 120) checks.add(const GateCheck.warn('Under 2 minutes of flight left: the shot may be cut short.'));
switch (shot) {
case QuickShot.rocket:
case QuickShot.dronie:
case QuickShot.asteroid:
case QuickShot.helix:
checks.add(const GateCheck.warn('The aircraft climbs up to 120 m and flies away from the subject: keep the space above and behind clear.'));
case QuickShot.circle:
case QuickShot.boomerang:
checks.add(const GateCheck.warn('The aircraft flies around the subject: keep the circle clear.'));
case QuickShot.dollyZoom:
checks.add(const GateCheck.warn('The aircraft backs away from the subject: keep the space behind it clear.'));
}
if (!model.profile.hasObstacleSensing) {
checks.add(const GateCheck.warn('This aircraft cannot see obstacles on the way.'));
}
return checks;
}
/// Re-checks and flies [shot] around the subject in [rect] (0..1 of the live view).
Future<void> startQuickShot(Rect rect, QuickShot shot) async {
final List<GateCheck> checks = checkQuickShot(shot);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
final Rect r = Rect.fromLTRB(rect.left.clamp(0, 1), rect.top.clamp(0, 1), rect.right.clamp(0, 1), rect.bottom.clamp(0, 1));
await dji.startQuickShot(shot.sdk, x: r.left, y: r.top, w: r.width, h: r.height);
}
// ── Point of Interest (plan P7) ─────────────────────────────────────────────
/// What a mission-type mode needs before it may fly the aircraft on its own:
/// connected, airborne in P mode with GPS, nothing else in charge, battery.
void _autoCommon(List<GateCheck> checks, String name) {
final String mode = _a.flightModeEnum ?? '';
if (_a.isFlying != true) checks.add(GateCheck.block('Take off before starting $name.'));
if (_a.goingHome == true || mode == 'GO_HOME') checks.add(const GateCheck.block('Cancel Return to Home first.'));
if (mode == 'AUTO_LANDING' || mode == 'CONFIRM_LANDING') checks.add(const GateCheck.block('The aircraft is landing.'));
if (mode == 'ATTI' || mode == 'GPS_SPORT' || mode == 'MANUAL') checks.add(const GateCheck.block('Switch the remote to P mode first.'));
final ActiveMode running = RailState.activeMode(_a, model.mission);
if (running != ActiveMode.none) checks.add(const GateCheck.block('Stop the intelligent flight mode that is running first.'));
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) checks.add(GateCheck.block('GPS too weak for $name.'));
final int? percent = model.battery.percent;
final int critical = model.battery.seriousLowWarningThreshold ?? 15;
final int low = model.battery.lowWarningThreshold ?? 30;
if (_a.seriousLowBattery == true || (percent != null && percent <= critical)) {
checks.add(GateCheck.block('Battery critically low${percent == null ? '' : ' ($percent%)'}.'));
} else if (_a.lowBattery == true || (percent != null && percent <= low)) {
checks.add(GateCheck.warn('Battery low${percent == null ? '' : ' ($percent%)'}.'));
}
if (_a.compassHasError == true) checks.add(const GateCheck.block('Compass error. Calibrate the compass or move away from metal.'));
}
/// Preconditions for circling [spec]; [maxAngular] is the aircraft's limit at its radius.
List<GateCheck> checkPoi(PoiSpec spec, {double? maxAngular}) {
final List<GateCheck> checks = <GateCheck>[];
if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.'));
if (_a.updatedAt == null) return checks..add(const GateCheck.block('Waiting for the flight controller to report.'));
if (model.mission.hotpoint.unsupported) return checks..add(const GateCheck.block('This aircraft cannot fly Point of Interest.'));
_autoCommon(checks, 'Point of Interest');
final double? lat = _a.latitude, lon = _a.longitude;
for (final (bool blocks, String m) in spec.issues(maxAngular: maxAngular, aircraft: lat == null || lon == null ? null : LatLng(lat, lon))) {
checks.add(blocks ? GateCheck.block(m) : GateCheck.warn(m));
}
// POI flies sideways round the circle, where most aircraft have no sensors.
checks.add(const GateCheck.warn('The aircraft does not brake for obstacles on the circle: check it is clear.'));
final int? left = _a.goHome?.remainingFlightTime;
if (left != null && left > 0 && left < spec.lapSeconds + (_a.goHome?.timeNeededToGoHome ?? 0)) {
checks.add(GateCheck.warn('One lap takes about ${formatDuration(spec.lapSeconds)}: more than the battery has to spare.'));
}
return checks;
}
Future<void> startPoi(PoiSpec spec, {double? maxAngular}) async {
final List<GateCheck> checks = checkPoi(spec, maxAngular: maxAngular);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
final Map<String, Object?> m = spec.toBridge(maxAngular: maxAngular);
await dji.startHotpoint(
lat: m['lat']! as double,
lon: m['lon']! as double,
altitude: (m['altitude']! as num).toDouble(),
radius: (m['radius']! as num).toDouble(),
angularVelocity: (m['angularVelocity']! as num).toDouble(),
clockwise: m['clockwise']! as bool,
startPoint: m['startPoint']! as String,
heading: m['heading']! as String,
);
}
Future<void> pausePoi() async {
if (!model.mission.hotpoint.active || model.mission.hotpoint.paused) {
throw const MissionRefused(<GateCheck>[GateCheck.block('Point of Interest is not circling.')]);
}
await dji.pauseHotpoint();
}
Future<void> resumePoi() async {
final List<GateCheck> checks = <GateCheck>[
if (!model.mission.hotpoint.paused) const GateCheck.block('Point of Interest is not paused.'),
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) const GateCheck.block('GPS too weak to carry on.'),
];
if (checks.isNotEmpty) throw MissionRefused(checks);
await dji.resumeHotpoint();
}
/// Changes the orbit in flight; [speed] m/s becomes °/s on the current radius.
Future<void> adjustPoi({double? radius, double? speed, required bool clockwise, required double currentRadius}) async {
if (!model.mission.hotpoint.active) throw const MissionRefused(<GateCheck>[GateCheck.block('Point of Interest is not running.')]);
if (radius != null) await dji.setHotpointRadius(radius.clamp(PoiSpec.minRadius, PoiSpec.maxRadius));
if (speed != null) {
final double r = radius ?? currentRadius;
final double w = PoiSpec.angularFor(speed.clamp(PoiSpec.minSpeed, PoiSpec.maxSpeed), r);
await dji.setHotpointAngularVelocity(clockwise ? w : -w);
}
}
// ── Follow Me (plan P7) ─────────────────────────────────────────────────────
/// Preconditions for following this phone. [phoneAccuracy] is its fix, m.
List<GateCheck> checkFollowMe(FollowMeSpec spec, {double? phoneAccuracy}) {
final List<GateCheck> checks = <GateCheck>[];
if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.'));
if (_a.updatedAt == null) return checks..add(const GateCheck.block('Waiting for the flight controller to report.'));
if (model.mission.followMe.unsupported) return checks..add(const GateCheck.block('This aircraft cannot run Follow Me.'));
_autoCommon(checks, 'Follow Me');
final double? pLat = model.phoneLatitude, pLon = model.phoneLongitude;
if (pLat == null || pLon == null) {
checks.add(const GateCheck.block('This phone has no location fix: Follow Me follows the phone.'));
} else {
if (phoneAccuracy != null && phoneAccuracy > FollowMeSpec.maxAccuracy) {
checks.add(GateCheck.warn('The phone\'s fix is only good to ${phoneAccuracy.round()} m: the aircraft will wander.'));
}
final double? lat = _a.latitude, lon = _a.longitude;
if (lat != null && lon != null) {
final double d = HudGeometry.distance(lat, lon, pLat, pLon);
if (d > 200) checks.add(GateCheck.warn('The aircraft is ${d.round()} m from you: it flies to you first.'));
}
}
if (spec.altitude < FollowMeSpec.minAltitude) checks.add(const GateCheck.block('Follow at 10 m or higher.'));
if (spec.altitude < 20) checks.add(const GateCheck.warn('Low: trees and lamp posts along your way are not avoided.'));
checks.add(const GateCheck.warn('The aircraft does not avoid obstacles while it follows you.'));
return checks;
}
Future<void> startFollowMe(FollowMeSpec spec) async {
final List<GateCheck> checks = checkFollowMe(spec);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await dji.startFollowMe(lat: model.phoneLatitude!, lon: model.phoneLongitude!, altitude: spec.altitude, heading: spec.heading.sdk);
}
/// Sends the phone's fix while following; ignored once it is not.
Future<void> updateFollowMe() async {
final double? lat = model.phoneLatitude, lon = model.phoneLongitude;
if (!model.mission.followMe.active || lat == null || lon == null) return;
await dji.updateFollowMeTarget(lat, lon);
}
// ── Orientation, Tripod, Cinematic (plan P7) ────────────────────────────────
/// Preconditions for switching Intelligent Orientation Control to [mode].
List<GateCheck> checkOrientation(OrientationMode mode) {
final List<GateCheck> checks = <GateCheck>[];
if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.'));
if (_a.updatedAt == null) return checks..add(const GateCheck.block('Waiting for the flight controller to report.'));
if (mode == OrientationMode.normal) return checks;
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) checks.add(GateCheck.block('GPS too weak for ${mode.label}.'));
if (mode == OrientationMode.homeLock) {
if (_a.homeLocationSet != true) checks.add(const GateCheck.block('No home point recorded yet.'));
final double? d = _a.homeDistance;
if (d != null && d < homeLockMinDistance) checks.add(const GateCheck.block('Fly 10 m from home first: Home Lock needs a direction to pull back along.'));
}
final String fm = _a.flightModeEnum ?? '';
if (fm == 'GPS_SPORT' || fm == 'ATTI') checks.add(const GateCheck.block('Switch the remote to P mode first.'));
checks.add(GateCheck.warn(mode == OrientationMode.courseLock
? 'Forward on the stick is now the locked course, whichever way the aircraft faces.'
: 'Pulling back now always flies towards home, whichever way the aircraft faces.'));
return checks;
}
Future<void> setOrientation(OrientationMode mode) async {
final List<GateCheck> checks = checkOrientation(mode);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await dji.setFlightOrientationMode(mode.sdk);
}
/// Course Lock takes the current heading as "forward".
Future<void> lockCourse() async {
if (_a.orientationMode != 'COURSE_LOCK') throw const MissionRefused(<GateCheck>[GateCheck.block('Course Lock is not on.')]);
await dji.lockCourseUsingCurrentHeading();
}
/// Tripod (slow, fine control) or Cinematic (smooth braking) on or off.
/// Turning one on is a speed change the pilot feels at once, so it warns.
List<GateCheck> checkSlowMode({required bool cinematic, required bool on}) => <GateCheck>[
if (!model.connected) const GateCheck.block('No aircraft connected.'),
if (model.connected && on && RailState.activeMode(_a, model.mission) != ActiveMode.none)
const GateCheck.block('Stop the intelligent flight mode that is running first.'),
if (model.connected && on)
GateCheck.warn(cinematic
? 'The aircraft brakes and turns slowly: give it room to stop.'
: 'Speed drops to about 1 m/s and the sticks become very gentle.'),
];
Future<void> setSlowMode({required bool cinematic, required bool on}) async {
final List<GateCheck> checks = checkSlowMode(cinematic: cinematic, on: on);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await dji.setKey(DjiKey.flightController(cinematic ? 'CINEMATIC_MODE_ENABLED' : 'TRIPOD_MODE_ENABLED'), on);
}
// ── Hyperlapse (plan P7) ────────────────────────────────────────────────────
/// Preconditions for a Free hyperlapse: a camera that shoots intervals, a
/// card with room, and nothing else shooting.
List<GateCheck> checkHyperlapse(HyperlapseSpec spec) {
final List<GateCheck> checks = <GateCheck>[];
if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.'));
if (!model.profile.hasCamera) return checks..add(const GateCheck.block('This aircraft has no camera.'));
if (model.camera.isRecording) checks.add(const GateCheck.block('Stop recording first.'));
if (model.camera.shootingContinuous) checks.add(const GateCheck.block('The camera is already shooting a sequence.'));
final int? shots = model.camera.storage?.availableCaptureCount;
if (model.camera.storage != null && !model.camera.storage!.usable) {
checks.add(GateCheck.block('${model.camera.storage!.status}: the frames would not be saved.'));
} else if (shots != null && shots < spec.frames) {
checks.add(GateCheck.block('The card has room for $shots photos; this hyperlapse needs ${spec.frames}.'));
}
final int? left = _a.goHome?.remainingFlightTime;
if (left != null && left > 0 && left < spec.shootingSeconds + (_a.goHome?.timeNeededToGoHome ?? 0)) {
checks.add(GateCheck.warn('Shooting takes ${formatDuration(spec.shootingSeconds)}: more than the battery has to spare.'));
}
if (_a.isFlying != true) checks.add(const GateCheck.warn('The aircraft is on the ground: take off, then fly slowly and steadily.'));
return checks;
}
/// Ends an open-ended (255) interval sequence once it has its frames.
Timer? _hyperlapseStop;
/// Starts the interval sequence; the camera's own interval mode runs it.
Future<void> startHyperlapse(HyperlapseSpec spec) async {
final List<GateCheck> checks = checkHyperlapse(spec);
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
await dji.setCameraMode('photo');
await dji.setShootPhotoMode('INTERVAL');
await dji.setPhotoTimeInterval(count: spec.cameraCount, seconds: spec.intervalSeconds);
await dji.startShootPhoto();
_hyperlapseStop?.cancel();
if (spec.cameraCount == 255) {
_hyperlapseStop = Timer(Duration(seconds: spec.shootingSeconds), () => dji.stopShootPhoto().catchError((Object _) {}));
}
}
Future<void> stopHyperlapse() {
_hyperlapseStop?.cancel();
_hyperlapseStop = null;
return dji.stopShootPhoto();
}
// ── Virtual sticks (plan P8) ────────────────────────────────────────────────
/// Preconditions for handing the aircraft to the on-screen sticks.
@@ -691,6 +1009,10 @@ class SafetyGate {
checks.add(const GateCheck.block('Stop ActiveTrack first.'));
case ActiveMode.tapFly:
checks.add(const GateCheck.block('Stop TapFly first.'));
case ActiveMode.hotpoint:
checks.add(const GateCheck.block('Stop Point of Interest first.'));
case ActiveMode.followMe:
checks.add(const GateCheck.block('Stop Follow Me first.'));
case ActiveMode.other:
checks.add(const GateCheck.block('Stop the intelligent flight mode first.'));
case ActiveMode.none:
@@ -791,6 +1113,10 @@ class SafetyGate {
await dji.stopActiveTrack();
case ActiveMode.tapFly:
await dji.stopTapFly();
case ActiveMode.hotpoint:
await dji.stopHotpoint();
case ActiveMode.followMe:
await dji.stopFollowMe();
case ActiveMode.none:
case ActiveMode.other:
break; // refused by check()
@@ -214,6 +214,41 @@ class KeyReadoutSpec extends SettingSpec {
}
}
/// A string the pilot types (aircraft name, a remote's name). [secret] masks
/// it and never shows the stored value; [maxLength] caps what is typed;
/// [send] replaces the plain `setKey` for values the SDK only takes through a
/// bespoke method.
class KeyTextSpec extends SettingSpec {
const KeyTextSpec({
required super.id,
required super.label,
required DjiKey key,
this.secret = false,
this.maxLength = 32,
this.send,
super.hint,
super.requires,
super.visibleWhen,
super.groundOnly,
}) : _key = key;
final DjiKey _key;
final bool secret;
final int maxLength;
final Future<void> Function(DjiService dji, String value)? send;
@override
DjiKey get key => _key;
String? current(KeyState s) => asString(s.value(_key));
String shown(KeyState s) {
final String? v = current(s);
if (secret) return v == null || v.isEmpty ? '—' : '••••••';
return v == null || v.isEmpty ? '—' : v;
}
}
/// A button that runs an argument-less action key (`START_PAIRING`).
class KeyActionSpec extends SettingSpec {
const KeyActionSpec({
@@ -295,6 +330,35 @@ String formatValue(Object? v) => switch (v) {
/// `≈ 394 ft` for [m] metres.
String feetHint(int m) => '≈ ${(m * 3.28084).round()} ft';
/// One battery `WarningRecord` (as `KeyBridge.kt` maps it) → what went wrong.
String formatWarningRecord(Object? v) {
final Map<String, dynamic>? m = asMap(v);
if (m == null) return formatValue(v);
final List<String> found = <String>[
if (asBool(m['overCurrent']) == true) 'over-current',
if (asBool(m['overHeated']) == true) 'overheated',
if (asBool(m['lowTemperature']) == true) 'too cold',
if (asBool(m['shortCircuit']) == true) 'short circuit',
if (asBool(m['underVoltage']) == true) 'under-voltage',
if ((asInt(m['lowVoltageCell']) ?? 0) > 0) 'low cell ${asInt(m['lowVoltageCell'])}',
if ((asInt(m['damagedCell']) ?? 0) > 0) 'damaged cell ${asInt(m['damagedCell'])}',
];
if (found.isEmpty) return asBool(m['error']) == true ? 'Error' : 'None';
return found.join(', ');
}
/// A list of `WarningRecord`s → how many flights had a warning, and the worst.
String formatWarningHistory(Object? v) {
if (v is! List) return formatValue(v);
if (v.isEmpty) return 'None';
final List<String> bad = <String>[
for (final Object? r in v)
if (formatWarningRecord(r) case final String s when s != 'None') s,
];
if (bad.isEmpty) return 'None in ${v.length} records';
return '${bad.length} of ${v.length}: ${bad.first}';
}
/// Millivolt cell list → `3.85 3.86 3.84 V`.
String formatCells(Object? v) {
if (v is! List || v.isEmpty) return '—';
+295 -1
View File
@@ -31,6 +31,7 @@ abstract final class SettingsKeys {
static const DjiKey lowBattery = DjiKey.flightController('LOW_BATTERY_WARNING_THRESHOLD');
static const DjiKey criticalBattery = DjiKey.flightController('SERIOUS_LOW_BATTERY_WARNING_THRESHOLD');
static const DjiKey fcSerial = DjiKey.flightController('SERIAL_NUMBER');
static const DjiKey aircraftName = DjiKey.flightController('AIRCRAFT_NAME');
// Flight assistant (vision)
static const DjiKey collision = DjiKey.flightAssistant('COLLISION_AVOIDANCE_ENABLED');
@@ -56,6 +57,25 @@ abstract final class SettingsKeys {
static const DjiKey rcName = DjiKey.remoteController('DISPLAY_NAME');
static const DjiKey rcSerial = DjiKey.remoteController('SERIAL_NUMBER');
static const DjiKey rcFirmware = DjiKey.remoteController('FIRMWARE_VERSION');
static const DjiKey customButton1 = DjiKey.remoteController('CUSTOM_BUTTON_1');
// Dual operator (master / slave)
static const DjiKey rcRole = DjiKey.remoteController('MODE');
static const DjiKey rcLinkName = DjiKey.remoteController('NAME');
static const DjiKey rcLinkPassword = DjiKey.remoteController('PASSWORD');
static const DjiKey masterSlaveSupported = DjiKey.remoteController('IS_MASTER_SLAVE_MODE_SUPPORTED');
static const DjiKey masterSlaveState = DjiKey.remoteController('MASTER_SLAVE_STATE');
// Secondary video output (Lightbridge remotes / CrystalSky HDMI, Cendence SDI)
static const DjiKey secondaryOut = DjiKey.remoteController('SECONDARY_VIDEO_OUTPUT_ENABLED');
static const DjiKey secondaryPort = DjiKey.remoteController('SECONDARY_VIDEO_OUTPUT_PORT');
static const DjiKey secondaryDisplay = DjiKey.remoteController('SECONDARY_VIDEO_DISPLAY_MODE');
static const DjiKey secondaryOsd = DjiKey.remoteController('SECONDARY_VIDEO_OSD_ENABLED');
static const DjiKey secondaryOsdUnit = DjiKey.remoteController('SECONDARY_VIDEO_OSD_UNIT');
static const DjiKey hdmiFormat = DjiKey.remoteController('HDMI_OUTPUT_FORMAT');
static const DjiKey sdiFormat = DjiKey.remoteController('SDI_OUTPUT_FORMAT');
static const DjiKey pipPosition = DjiKey.remoteController('PIP_POSITION');
static DjiKey osdMargin(String side) => DjiKey.remoteController('SECONDARY_VIDEO_OSD_${side}_MARGIN');
// Image transmission
static const DjiKey uplink = DjiKey.airLink('UPLINK_SIGNAL_QUALITY');
@@ -76,6 +96,8 @@ abstract final class SettingsKeys {
static const DjiKey wifiSsid = DjiKey.wifi('SSID');
static const DjiKey wifiBand = DjiKey.wifi('WIFI_FREQUENCY_BAND');
static const DjiKey wifiChannel = DjiKey.wifi('CHANNEL_NUMBER');
static const DjiKey wifiPassword = DjiKey.wifi('PASSWORD');
static const DjiKey wifiReboot = DjiKey.wifi('REBOOT');
// Product
static const DjiKey firmwarePackage = DjiKey.product('FIRMWARE_PACKAGE_VERSION');
@@ -111,12 +133,38 @@ abstract final class SettingsTabs {
static const String liveStream = 'liveStream';
static const String about = 'about';
static const String developer = 'developer';
static const String units = 'units';
static const String customButtons = 'customButtons';
static const String masterSearch = 'masterSearch';
static const String slaveList = 'slaveList';
static const String spectrum = 'spectrum';
static const String videoCache = 'videoCache';
static const String flightLogs = 'flightLogs';
static const String batteryHistory = 'batteryHistory';
static bool _isOn(KeyState s, DjiKey k) => asBool(s.value(k)) == true;
static bool _manual(KeyState s, DjiKey k) => asString(s.value(k)) == 'MANUAL';
/// The remote's dual-operator role; `NORMAL` until it says otherwise.
static String _role(KeyState s) => asString(s.value(SettingsKeys.rcRole)) ?? 'NORMAL';
static const List<(String, String)> _channelModes = <(String, String)>[('AUTO', 'Auto'), ('MANUAL', 'Custom')];
static const List<(String, String)> _videoFormats = <(String, String)>[
('RESOLUTION_1080P_60FPS', '1080p60'),
('RESOLUTION_1080P_50FPS', '1080p50'),
('RESOLUTION_1080P_30FPS', '1080p30'),
('RESOLUTION_1080P_25FPS', '1080p25'),
('RESOLUTION_1080P_24FPS', '1080p24'),
('RESOLUTION_1080I_60FPS', '1080i60'),
('RESOLUTION_1080I_50FPS', '1080i50'),
('RESOLUTION_720P_60FPS', '720p60'),
('RESOLUTION_720P_50FPS', '720p50'),
('RESOLUTION_720P_30FPS', '720p30'),
('RESOLUTION_720P_25FPS', '720p25'),
('RESOLUTION_720P_24FPS', '720p24'),
];
/// The tabs for [p]; battery groups repeat per battery.
static List<SettingsTab> all(ProductProfile p) => <SettingsTab>[
SettingsTab(id: safety, label: 'Safety', icon: 'shield', rows: _safety),
@@ -311,6 +359,135 @@ abstract final class SettingsTabs {
max: 100,
step: 5,
),
GroupSpec('BUTTONS', requires: HudFeatures.customButtons),
CustomSpec(id: customButtons, requires: HudFeatures.customButtons, keys: const <DjiKey>[SettingsKeys.customButton1]),
// ── Dual operator ─────────────────────────────────────────────────────────
GroupSpec('DUAL OPERATOR', requires: HudFeatures.masterSlaveBadge),
KeyChoiceSpec(
id: 'rcRole',
label: 'Remote role',
hint: 'Master flies; slave runs the gimbal and camera',
key: SettingsKeys.rcRole,
options: const <(String, String)>[('NORMAL', 'Single'), ('MASTER', 'Master'), ('SLAVE', 'Slave')],
requires: HudFeatures.masterSlaveBadge,
groundOnly: true,
),
KeyTextSpec(
id: 'rcLinkName',
label: 'Remote name',
hint: 'How the other remote finds this one',
key: SettingsKeys.rcLinkName,
maxLength: 6,
requires: HudFeatures.masterSlaveBadge,
visibleWhen: (SettingsContext c) => _role(c.keys) != 'NORMAL',
),
KeyTextSpec(
id: 'rcLinkPassword',
label: 'Connection password',
hint: 'Four digits the slave types to join',
key: SettingsKeys.rcLinkPassword,
secret: true,
maxLength: 4,
requires: HudFeatures.masterSlaveBadge,
visibleWhen: (SettingsContext c) => _role(c.keys) == 'MASTER',
),
CustomSpec(
id: masterSearch,
requires: HudFeatures.masterSlaveBadge,
visibleWhen: (SettingsContext c) => _role(c.keys) == 'SLAVE',
),
CustomSpec(
id: slaveList,
requires: HudFeatures.masterSlaveBadge,
visibleWhen: (SettingsContext c) => _role(c.keys) == 'MASTER',
),
// ── Secondary video output (HDMI / SDI) ───────────────────────────────────
GroupSpec('VIDEO OUTPUT', requires: HudFeatures.linkLightbridge),
KeySwitchSpec(
id: 'secondaryOut',
label: 'HDMI / SDI output',
hint: "The remote's video out to a monitor or recorder",
key: SettingsKeys.secondaryOut,
requires: HudFeatures.linkLightbridge,
),
KeyChoiceSpec(
id: 'secondaryPort',
label: 'Output port',
key: SettingsKeys.secondaryPort,
options: const <(String, String)>[('HDMI', 'HDMI'), ('SDI', 'SDI')],
requires: HudFeatures.linkLightbridge,
visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut),
),
KeyChoiceSpec(
id: 'hdmiFormat',
label: 'HDMI format',
key: SettingsKeys.hdmiFormat,
options: _videoFormats,
requires: HudFeatures.linkLightbridge,
visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut) && asString(c.keys.value(SettingsKeys.secondaryPort)) != 'SDI',
),
KeyChoiceSpec(
id: 'sdiFormat',
label: 'SDI format',
key: SettingsKeys.sdiFormat,
options: _videoFormats,
requires: HudFeatures.linkLightbridge,
visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut) && asString(c.keys.value(SettingsKeys.secondaryPort)) == 'SDI',
),
KeyChoiceSpec(
id: 'secondaryDisplay',
label: 'Picture',
hint: 'Which feed, and which one inset (dual-camera aircraft)',
key: SettingsKeys.secondaryDisplay,
options: const <(String, String)>[
('SOURCE_1_ONLY', 'Feed 1'),
('SOURCE_2_ONLY', 'Feed 2'),
('SOURCE_1_MAIN', 'Feed 1 + inset'),
('SOURCE_2_MAIN', 'Feed 2 + inset'),
],
requires: HudFeatures.linkLightbridge,
visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut),
),
KeyChoiceSpec(
id: 'pipPosition',
label: 'Inset position',
key: SettingsKeys.pipPosition,
options: const <(String, String)>[
('TOP_LEFT', 'Top left'),
('TOP_RIGHT', 'Top right'),
('BOTTOM_LEFT', 'Bottom left'),
('BOTTOM_RIGHT', 'Bottom right'),
],
requires: HudFeatures.linkLightbridge,
visibleWhen: (SettingsContext c) =>
_isOn(c.keys, SettingsKeys.secondaryOut) && (asString(c.keys.value(SettingsKeys.secondaryDisplay)) ?? '').endsWith('_MAIN'),
),
KeySwitchSpec(
id: 'secondaryOsd',
label: 'Show flight data (OSD)',
key: SettingsKeys.secondaryOsd,
requires: HudFeatures.linkLightbridge,
visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut),
),
KeyChoiceSpec(
id: 'secondaryOsdUnit',
label: 'OSD units',
key: SettingsKeys.secondaryOsdUnit,
options: const <(String, String)>[('METRIC', 'Metric'), ('IMPERIAL', 'Imperial')],
requires: HudFeatures.linkLightbridge,
visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut) && _isOn(c.keys, SettingsKeys.secondaryOsd),
),
for (final (String side, String label) in const <(String, String)>[('TOP', 'top'), ('BOTTOM', 'bottom'), ('LEFT', 'left'), ('RIGHT', 'right')])
KeyNumberSpec(
id: 'osdMargin$side',
label: 'OSD margin, $label',
key: SettingsKeys.osdMargin(side),
min: 0,
max: 50,
unit: 'px',
requires: HudFeatures.linkLightbridge,
visibleWhen: (SettingsContext c) => _isOn(c.keys, SettingsKeys.secondaryOut) && _isOn(c.keys, SettingsKeys.secondaryOsd),
),
const GroupSpec('CONTROLLER'),
const KeyChoiceSpec(
id: 'chargeMobile',
@@ -347,6 +524,7 @@ abstract final class SettingsTabs {
static final List<SettingSpec> _transmission = <SettingSpec>[
const KeyReadoutSpec(id: 'uplink', label: 'Control signal', key: SettingsKeys.uplink, format: _percent),
const KeyReadoutSpec(id: 'downlink', label: 'Video signal', key: SettingsKeys.downlink, format: _percent),
const CustomSpec(id: spectrum),
// OcuSync
GroupSpec('OCUSYNC', requires: HudFeatures.linkOcuSync),
KeyReadoutSpec(id: 'osDataRate', label: 'Video bitrate', key: SettingsKeys.osDataRate, format: _mbps, requires: HudFeatures.linkOcuSync),
@@ -433,7 +611,40 @@ abstract final class SettingsTabs {
),
// Wi-Fi
GroupSpec('WI-FI', requires: HudFeatures.linkWifi),
KeyReadoutSpec(id: 'wifiSsid', label: 'Network', key: SettingsKeys.wifiSsid, requires: HudFeatures.linkWifi),
KeyTextSpec(
id: 'wifiSsid',
label: 'Network name',
hint: 'Takes effect after Restart Wi-Fi',
key: SettingsKeys.wifiSsid,
requires: HudFeatures.linkWifi,
groundOnly: true,
send: (DjiService dji, String v) => dji.setWifiCredentials(ssid: v),
),
KeyTextSpec(
id: 'wifiPassword',
label: 'Wi-Fi password',
hint: 'At least 8 characters; takes effect after Restart Wi-Fi',
key: SettingsKeys.wifiPassword,
secret: true,
maxLength: 63,
requires: HudFeatures.linkWifi,
groundOnly: true,
send: (DjiService dji, String v) {
if (v.length < 8) throw ArgumentError('The password needs at least 8 characters');
return dji.setWifiCredentials(password: v);
},
),
KeyActionSpec(
id: 'wifiReboot',
label: 'Restart Wi-Fi',
hint: 'Applies a new name or password; the phone has to join the network again',
key: SettingsKeys.wifiReboot,
button: 'Restart',
requires: HudFeatures.linkWifi,
groundOnly: true,
confirmTitle: 'Restart the aircraft Wi-Fi',
confirmText: 'The connection drops. Join the network again in the phone\'s Wi-Fi settings, with the new name or password if you changed them.',
),
KeyChoiceSpec(
id: 'wifiBand',
label: 'Frequency band',
@@ -481,6 +692,66 @@ abstract final class SettingsTabs {
},
),
for (int i = 0; i < n; i++) ..._batteryRows(i, n),
// Inspire 2 / M200: the two packs fly as a pair and must be matched.
GroupSpec('BATTERY PAIR', requires: HudFeatures.dualBattery),
KeyReadoutSpec(
id: 'pairingState',
label: 'Pairing',
key: SettingsKeys.battery('PAIRING_STATE', 0),
requires: HudFeatures.dualBattery,
),
KeyActionSpec(
id: 'pairBatteries',
label: 'Pair the two batteries',
hint: 'After swapping a pack: the pair then ages and balances together',
key: SettingsKeys.battery('PAIR_BATTERIES', 0),
button: 'Pair',
requires: HudFeatures.dualBattery,
groundOnly: true,
confirmTitle: 'Pair batteries',
confirmText: 'The two batteries in the aircraft are recorded as a pair. Use two packs of similar age and charge.',
visibleWhen: (SettingsContext c) => asString(c.keys.value(SettingsKeys.battery('PAIRING_STATE', 0))) != 'PAIRED',
),
// Non-smart packs (A3 / N3 / M600 on plain LiPos): the FC judges cells by voltage.
GroupSpec('CELL VOLTAGE', requires: HudFeatures.nonSmartBattery),
KeyNumberSpec(
id: 'cellCount',
label: 'Number of cells',
key: SettingsKeys.battery('NUMBER_OF_CELLS', 0),
min: 3,
max: 12,
unit: 'S',
requires: HudFeatures.nonSmartBattery,
groundOnly: true,
),
for (final int level in const <int>[1, 2]) ...<SettingSpec>[
KeyNumberSpec(
id: 'cellLevel$level',
label: level == 1 ? 'Low cell voltage' : 'Critical cell voltage',
key: SettingsKeys.battery('LEVEL_${level}_CELL_VOLTAGE_THRESHOLD', 0),
min: level == 1 ? 3500 : 3300,
max: level == 1 ? 3900 : 3700,
step: 10,
unit: 'mV',
requires: HudFeatures.nonSmartBattery,
bounds: level == 1
? (KeyState s, int lo, int hi) {
final int? l2 = asInt(s.value(SettingsKeys.battery('LEVEL_2_CELL_VOLTAGE_THRESHOLD', 0)));
return (l2 == null ? lo : (l2 + 50).clamp(lo, hi), hi);
}
: (KeyState s, int lo, int hi) {
final int? l1 = asInt(s.value(SettingsKeys.battery('LEVEL_1_CELL_VOLTAGE_THRESHOLD', 0)));
return (lo, l1 == null ? hi : (l1 - 50).clamp(lo, hi));
},
),
KeyChoiceSpec(
id: 'cellBehavior$level',
label: level == 1 ? 'At low voltage' : 'At critical voltage',
key: SettingsKeys.battery('LEVEL_${level}_CELL_VOLTAGE_BEHAVIOR', 0),
options: const <(String, String)>[('FLASH_LED', 'Flash LEDs'), ('GO_HOME', 'Return home'), ('LAND', 'Land')],
requires: HudFeatures.nonSmartBattery,
),
],
];
}
@@ -504,19 +775,42 @@ abstract final class SettingsTabs {
),
KeyReadoutSpec(id: 'batterySerial$i', label: 'Serial number', key: k('SERIAL_NUMBER')),
KeyReadoutSpec(id: 'batteryFirmware$i', label: 'Firmware', key: k('FIRMWARE_VERSION')),
KeyReadoutSpec(id: 'batteryLatest$i', label: 'Latest warning', key: k('LATEST_WARNING_RECORD'), format: formatWarningRecord),
KeyReadoutSpec(
id: 'batteryHistory$i',
label: 'Warning history',
hint: 'What the pack recorded over its last flights',
key: k('HISTORICAL_WARNING_RECORDS'),
format: formatWarningHistory,
),
];
}
// ── General ─────────────────────────────────────────────────────────────────
static final List<SettingSpec> _general = <SettingSpec>[
const GroupSpec('UNITS'),
const CustomSpec(id: units),
const GroupSpec('MAP'),
const CustomSpec(id: showRoute),
const CustomSpec(id: clearRoute),
GroupSpec('GEO UNLOCKS', requires: HudFeatures.flyZones),
CustomSpec(id: flyZoneLicences, requires: HudFeatures.flyZones),
GroupSpec('VIDEO CACHE', requires: HudFeatures.cameraControls),
CustomSpec(id: videoCache, requires: HudFeatures.cameraControls),
const GroupSpec('FLIGHT RECORDS'),
const CustomSpec(id: flightLogs),
GroupSpec('LIVE STREAMING', requires: HudFeatures.liveStream),
CustomSpec(id: liveStream, requires: HudFeatures.liveStream),
GroupSpec('ON-SCREEN STICKS', requires: HudFeatures.virtualSticks),
CustomSpec(id: stickPads, requires: HudFeatures.virtualSticks),
const GroupSpec('ABOUT'),
const KeyTextSpec(
id: 'aircraftName',
label: 'Aircraft name',
hint: 'Shown in the logbook and to other DJI apps',
key: SettingsKeys.aircraftName,
maxLength: 32,
),
const CustomSpec(id: about, keys: <DjiKey>[SettingsKeys.firmwarePackage, SettingsKeys.fcSerial]),
const GroupSpec('DEVELOPER'),
const CustomSpec(id: developer),
+77
View File
@@ -59,6 +59,70 @@ class GoHomeAssessment {
batteryNeededToLand, maxRadiusCanFlyAndGoHome, smartRTHState, smartRTHCountdown);
}
/// The RTK module's fix (`rtk` group), on aircraft that have one.
@immutable
class RtkStatus {
const RtkStatus({this.solution, this.inUse, this.satellites, this.error});
factory RtkStatus.fromMap(Map<String, dynamic> m) => RtkStatus(
solution: asString(m['solution']),
inUse: asBool(m['inUse']),
satellites: asInt(m['satellites']),
error: asString(m['error']),
);
/// `NONE` / `SINGLE_POINT` / `FLOAT` / `FIXED_POINT`.
final String? solution;
final bool? inUse;
final int? satellites;
final String? error;
/// GO 4's chip text: FIX, FLOAT, SINGLE or a dash.
String get label => switch (solution) {
'FIXED_POINT' => 'FIX',
'FLOAT' => 'FLOAT',
'SINGLE_POINT' => 'SINGLE',
_ => '—',
};
bool get fixed => solution == 'FIXED_POINT';
@override
bool operator ==(Object other) =>
other is RtkStatus && other.solution == solution && other.inUse == inUse && other.satellites == satellites && other.error == error;
@override
int get hashCode => Object.hash(solution, inUse, satellites, error);
}
/// One compass's magnetic reading (`compassSensors`), for the interference meter.
@immutable
class CompassSensor {
const CompassSensor(this.index, this.state, this.value);
final int index;
/// `NORMAL_MODULUS` / `WEAK_MODULUS` / `SERIOUS_MODULUS` / `UNCALIBRATED` / …
final String? state;
/// The field's modulus as the flight controller reports it (raw units).
final int? value;
/// 0 = clean, 1 = weak interference, 2 = serious, null = not graded.
int? get level => switch (state) {
'NORMAL_MODULUS' => 0,
'WEAK_MODULUS' => 1,
'SERIOUS_MODULUS' || 'DATA_EXCEPTION' || 'DIRECTION_EXCEPTION' => 2,
_ => null,
};
@override
bool operator ==(Object other) => other is CompassSensor && other.index == index && other.state == state && other.value == value;
@override
int get hashCode => Object.hash(index, state, value);
}
/// The flight controller's state: position, motion, attitude, flight mode and
/// the safety flags. Fed from the flat keys of every `telemetry` tick (~10 Hz).
class AircraftState extends ChangeNotifier {
@@ -128,6 +192,12 @@ class AircraftState extends ChangeNotifier {
/// `NOT_CALIBRATING` / `HORIZONTAL` / `VERTICAL` / `SUCCESSFUL` / `FAILED`.
String? compassCalibrationState;
/// Per-compass interference readings; empty until the compass reports.
List<CompassSensor> compassSensors = const <CompassSensor>[];
/// The RTK fix, on aircraft with the module; null otherwise.
RtkStatus? rtk;
DateTime? updatedAt;
/// VPS height when the sensor is actually in use, else null.
@@ -180,6 +250,11 @@ class AircraftState extends ChangeNotifier {
compassHasError = asBool(t['compassHasError']);
compassCalibrating = asBool(t['compassCalibrating']);
compassCalibrationState = asString(t['compassCalibrationState']);
compassSensors = <CompassSensor>[
for (final Map<String, dynamic> c in asMapList(t['compassSensors'])) CompassSensor(asInt(c['index']) ?? 0, asString(c['state']), asInt(c['value'])),
];
final Map<String, dynamic>? r = asMap(t['rtk']);
rtk = r == null ? null : RtkStatus.fromMap(r);
updatedAt = DateTime.now();
notifyListeners();
}
@@ -196,6 +271,8 @@ class AircraftState extends ChangeNotifier {
horizontalSpeed = verticalSpeed = heading = pitch = roll = velocityX = velocityY = velocityZ = null;
goHomeHeight = ultrasonicHeight = null;
goHome = null;
compassSensors = const <CompassSensor>[];
rtk = null;
updatedAt = null;
notifyListeners();
}
+27 -3
View File
@@ -322,7 +322,15 @@ class FlyZoneState extends ChangeNotifier {
}
void applyDatabase(Map<String, dynamic> m) {
database = FlyZoneDatabase.fromMap(m);
final FlyZoneDatabase next = FlyZoneDatabase.fromMap(m);
// A read that came before the remote check keeps the version heard since.
database = next.preciseRemoteVersion == null ? next.withRemote(database?.preciseRemoteVersion) : next;
notifyListeners();
}
/// The SDK heard which precise database DJI's server has (`flyZoneDbRemote`).
void applyRemoteVersion(String? v) {
database = (database ?? const FlyZoneDatabase()).withRemote(v);
notifyListeners();
}
@@ -424,13 +432,14 @@ class FlyZoneLicence {
/// The GEO database's versions and state (`FlyZoneDatabaseState` names).
@immutable
class FlyZoneDatabase {
const FlyZoneDatabase({this.basicVersion, this.basicState, this.preciseVersion, this.preciseState});
const FlyZoneDatabase({this.basicVersion, this.basicState, this.preciseVersion, this.preciseState, this.preciseRemoteVersion});
factory FlyZoneDatabase.fromMap(Map<String, dynamic> m) => FlyZoneDatabase(
basicVersion: asString(m['basicVersion']),
basicState: asString(m['basicState']),
preciseVersion: asString(m['preciseVersion']),
preciseState: asString(m['preciseState']),
preciseRemoteVersion: asString(m['preciseRemoteVersion']),
);
final String? basicVersion;
@@ -438,7 +447,22 @@ class FlyZoneDatabase {
final String? preciseVersion;
final String? preciseState;
bool get outOfDate => basicState == 'OUT_OF_DATE' || preciseState == 'OUT_OF_DATE';
/// The newest precise database on DJI's server, once the SDK has asked.
final String? preciseRemoteVersion;
FlyZoneDatabase withRemote(String? v) => FlyZoneDatabase(
basicVersion: basicVersion,
basicState: basicState,
preciseVersion: preciseVersion,
preciseState: preciseState,
preciseRemoteVersion: v,
);
/// DJI has a newer precise database than the aircraft's (it updates with the firmware).
bool get preciseBehind =>
preciseRemoteVersion != null && preciseVersion != null && preciseRemoteVersion!.isNotEmpty && preciseRemoteVersion != preciseVersion;
bool get outOfDate => basicState == 'OUT_OF_DATE' || preciseState == 'OUT_OF_DATE' || preciseBehind;
static String stateLabel(String? s) => switch (s) {
'UP_TO_DATE' => 'Up to date',
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter/foundation.dart';
import '../rc_buttons.dart';
import 'parse.dart';
/// One press of a remote's custom button (`rcButton` event). [seq] grows with
/// every press, so two presses of the same button are two values.
@immutable
class RcButtonPress {
const RcButtonPress(this.button, this.seq);
final RcButton button;
final int seq;
@override
bool operator ==(Object other) => other is RcButtonPress && other.button == button && other.seq == seq;
@override
int get hashCode => Object.hash(button, seq);
}
/// A remote seen by master search, or linked as a slave (`rcMasters`, `getSlaves`).
@immutable
class RcPeer {
const RcPeer({required this.id, this.name, this.signal});
factory RcPeer.fromMap(Map<String, dynamic> m) => RcPeer(id: asInt(m['id']) ?? 0, name: asString(m['name']), signal: asInt(m['signal']));
final int id;
final String? name;
/// 0–100.
final int? signal;
@override
bool operator ==(Object other) => other is RcPeer && other.id == id && other.name == name && other.signal == signal;
@override
int get hashCode => Object.hash(id, name, signal);
}
/// One bar of the video link's spectrum chart.
@immutable
class SpectrumBar {
const SpectrumBar(this.label, this.power);
/// Channel number, or the band's centre in MHz.
final String label;
/// dBm (OcuSync RSSI, Lightbridge / Wi-Fi channel power); more negative is quieter.
final double power;
}
/// The video link's interference (`spectrum` events, while the chart is up).
@immutable
class Spectrum {
const Spectrum(this.link, this.bars);
/// `ocusync` / `lightbridge` / `wifi`.
final String link;
final List<SpectrumBar> bars;
factory Spectrum.fromEvent(Map<String, dynamic> e) {
final List<SpectrumBar> bars = <SpectrumBar>[
for (final Map<String, dynamic> b in asMapList(e['bands']))
if (finite(b['from']) case final double from)
if (finite(b['to']) case final double to)
if (finite(b['rssi']) case final double rssi) SpectrumBar(((from + to) / 2).toStringAsFixed(0), rssi),
for (final Map<String, dynamic> c in asMapList(e['channels']))
if (asInt(c['channel']) case final int ch)
if (finite(c['power']) case final double p) SpectrumBar('$ch', p),
];
return Spectrum(asString(e['link']) ?? '', bars);
}
/// The quietest bar, the channel to pick in Custom mode.
SpectrumBar? get quietest {
SpectrumBar? best;
for (final SpectrumBar b in bars) {
if (best == null || b.power < best.power) best = b;
}
return best;
}
}
+112 -7
View File
@@ -42,7 +42,7 @@ class WaypointProgress {
final int? target;
final bool reached;
/// MSDK `WaypointMissionExecuteState` (MOVING, DOING_ACTION, …).
/// MSDK `WaypointMissionExecuteState` (MOVING, DOING_ACTION, …).
final String? executeState;
bool get uploading => state == 'UPLOADING';
@@ -57,7 +57,7 @@ class WaypointProgress {
bool get available => state == 'READY_TO_UPLOAD' || state == 'READY_TO_EXECUTE';
bool get unsupported => state == 'NOT_SUPPORTED';
/// 0…1 through the upload, or null outside it.
/// 0…1 through the upload, or null outside it.
double? get uploadFraction {
if (!uploading || total == null || total == 0) return null;
return (((uploaded ?? -1) + 1) / total!).clamp(0.0, 1.0);
@@ -100,8 +100,12 @@ class ActiveTrackProgress {
this.subjects = const <SensedSubject>[],
this.autoSensingSupported = false,
this.autoSensing = false,
this.percent,
});
/// A QuickShot's progress, 0–100, while it flies.
final int? percent;
/// Subjects the aircraft found itself (auto-sensing), to tap instead of drawing.
final List<SensedSubject> subjects;
final bool autoSensingSupported;
@@ -128,6 +132,7 @@ class ActiveTrackProgress {
],
autoSensingSupported: asBool(e['autoSensingSupported']) ?? previous.autoSensingSupported,
autoSensing: asBool(e['autoSensing']) ?? previous.autoSensing,
percent: t == null ? (keep ? previous.percent : null) : asInt(t['percent']),
);
}
@@ -150,10 +155,10 @@ class ActiveTrackProgress {
/// MSDK `ActiveTrackState`.
final String state;
/// `ActiveTrackTargetState`: TRACKING_WITH_HIGH_CONFIDENCE, …_LOW_…, WAITING_FOR_CONFIRMATION, CANNOT_CONFIRM.
/// `ActiveTrackTargetState`: TRACKING_WITH_HIGH_CONFIDENCE, …_LOW_…, WAITING_FOR_CONFIRMATION, CANNOT_CONFIRM.
final String? targetState;
/// HUMAN, CAR, VAN, BIKE, BOAT, …
/// HUMAN, CAR, VAN, BIKE, BOAT, …
final String? targetType;
/// The subject's box, 0..1 across the live view.
@@ -206,10 +211,11 @@ class ActiveTrackProgress {
other.reason == reason &&
listEquals(other.subjects, subjects) &&
other.autoSensingSupported == autoSensingSupported &&
other.autoSensing == autoSensing;
other.autoSensing == autoSensing &&
other.percent == percent;
@override
int get hashCode => Object.hash(state, targetState, targetType, rect, reason, Object.hashAll(subjects), autoSensingSupported, autoSensing);
int get hashCode => Object.hash(state, targetState, targetType, rect, reason, Object.hashAll(subjects), autoSensingSupported, autoSensing, percent);
}
/// One subject auto-sensing found: its operator index and box (0..1 of the picture).
@@ -284,6 +290,71 @@ class TapFlyProgress {
int get hashCode => Object.hash(state, image, bypass, speed, relativeHeading);
}
/// The Point of Interest operator as last reported (plan P7).
@immutable
class HotpointProgress {
const HotpointProgress({this.state = 'UNKNOWN', this.radius, this.maxAngularVelocity});
factory HotpointProgress.fromMap(Map<String, dynamic> e, HotpointProgress previous) => HotpointProgress(
state: asString(e['state']) ?? previous.state,
radius: finite(e['radius']) ?? previous.radius,
maxAngularVelocity: finite(e['maxAngularVelocity']) ?? previous.maxAngularVelocity,
);
/// `HotpointMissionState`: UNKNOWN, DISCONNECTED, NOT_SUPPORTED, RECOVERING,
/// READY_TO_EXECUTE, INITIAL_PHASE (flying to the circle), EXECUTING, EXECUTION_PAUSED.
final String state;
final double? radius;
final double? maxAngularVelocity;
bool get active => state == 'INITIAL_PHASE' || state == 'EXECUTING' || state == 'EXECUTION_PAUSED';
bool get paused => state == 'EXECUTION_PAUSED';
bool get unsupported => state == 'NOT_SUPPORTED';
String get label => switch (state) {
'INITIAL_PHASE' => 'Flying to the circle',
'EXECUTING' => 'Circling',
'EXECUTION_PAUSED' => 'Paused',
'READY_TO_EXECUTE' => 'Ready',
'NOT_SUPPORTED' => 'Not supported',
_ => 'Set up',
};
@override
bool operator ==(Object other) =>
other is HotpointProgress && other.state == state && other.radius == radius && other.maxAngularVelocity == maxAngularVelocity;
@override
int get hashCode => Object.hash(state, radius, maxAngularVelocity);
}
/// The Follow Me operator as last reported (plan P7).
@immutable
class FollowMeProgress {
const FollowMeProgress({this.state = 'UNKNOWN', this.distance});
factory FollowMeProgress.fromMap(Map<String, dynamic> e, FollowMeProgress previous) => FollowMeProgress(
state: asString(e['state']) ?? previous.state,
distance: finite(e['distance']) ?? (asString(e['state']) == 'EXECUTING' ? previous.distance : null),
);
/// `FollowMeMissionState`: … READY_TO_EXECUTE, EXECUTING, GOT_STUCK, NOT_READY.
final String state;
/// Metres from the aircraft to the phone, while following.
final double? distance;
bool get active => state == 'EXECUTING' || state == 'GOT_STUCK';
bool get stuck => state == 'GOT_STUCK';
bool get unsupported => state == 'NOT_SUPPORTED';
@override
bool operator ==(Object other) => other is FollowMeProgress && other.state == state && other.distance == distance;
@override
int get hashCode => Object.hash(state, distance);
}
/// Waypoint / ActiveTrack / QuickShot execution, from the bridge's `mission` events.
///
/// Waypoint events carry `operator: 'waypoint'` and the operator's own state
@@ -293,6 +364,12 @@ class MissionState extends ChangeNotifier {
WaypointProgress waypoint = const WaypointProgress();
ActiveTrackProgress activeTrack = const ActiveTrackProgress();
TapFlyProgress tapFly = const TapFlyProgress();
HotpointProgress hotpoint = const HotpointProgress();
FollowMeProgress followMe = const FollowMeProgress();
/// How the last POI / Follow Me ended, like [waypointResult]; bumped in [modeFinishes].
String? modeResult;
int modeFinishes = 0;
/// How the last waypoint mission ended: null while none has, `''` when it
/// finished cleanly, else the SDK's error.
@@ -304,11 +381,13 @@ class MissionState extends ChangeNotifier {
String _legacy = 'idle';
String? error;
/// idle / ready / uploading / executing / tracking / quickshot / tapfly.
/// idle / ready / uploading / executing / tracking / quickshot / tapfly / hotpoint / followme.
String get state {
if (waypoint.active) return 'executing';
if (activeTrack.active) return activeTrack.state == 'PERFORMING_QUICK_SHOT' ? 'quickshot' : 'tracking';
if (tapFly.active) return 'tapfly';
if (hotpoint.active) return 'hotpoint';
if (followMe.active) return 'followme';
if (_legacy != 'idle') return _legacy;
if (waypoint.uploading) return 'uploading';
if (waypoint.readyToExecute) return 'ready';
@@ -346,12 +425,36 @@ class MissionState extends ChangeNotifier {
if (err != null) error = err;
notifyListeners();
return;
case 'hotpoint':
final HotpointProgress next = HotpointProgress.fromMap(e, hotpoint);
if (!_finish(e) && next == hotpoint) return;
hotpoint = next;
notifyListeners();
return;
case 'followMe':
final FollowMeProgress next = FollowMeProgress.fromMap(e, followMe);
if (!_finish(e) && next == followMe) return;
followMe = next;
notifyListeners();
return;
}
_legacy = asString(e['state']) ?? 'idle';
error = asString(e['error']);
notifyListeners();
}
/// Records a POI / Follow Me `finish` (and its error); true when the event said anything.
bool _finish(Map<String, dynamic> e) {
final String? err = asString(e['error']);
if (err != null) error = err;
if (asString(e['event']) == 'finish') {
modeResult = err ?? '';
modeFinishes++;
return true;
}
return err != null;
}
void _applyWaypoint(Map<String, dynamic> e) {
final WaypointProgress next = WaypointProgress.fromMap(e, waypoint);
final String? event = asString(e['event']);
@@ -374,6 +477,8 @@ class MissionState extends ChangeNotifier {
waypoint = const WaypointProgress();
activeTrack = const ActiveTrackProgress();
tapFly = const TapFlyProgress();
hotpoint = const HotpointProgress();
followMe = const FollowMeProgress();
notifyListeners();
}
}
+15
View File
@@ -1,6 +1,8 @@
import 'dart:ui' show Size;
import '../flight_model.dart';
import 'rc_buttons.dart';
import 'state/link_extras.dart';
import 'state/live_stream_state.dart' show MediaPlayback;
import 'state/parse.dart';
@@ -66,6 +68,19 @@ class TelemetryReducer {
case 'mediaPlayback':
model.mediaPlayback.value = MediaPlayback.fromEvent(event);
return true;
case 'rcButton':
final RcButton? b = RcButton.fromEvent(event['button']);
if (model.connected && b != null) model.rcButton.value = RcButtonPress(b, (model.rcButton.value?.seq ?? 0) + 1);
return true;
case 'rcMasters':
model.rcMasters.value = <RcPeer>[for (final Map<String, dynamic> m in asMapList(event['masters'])) RcPeer.fromMap(m)];
return true;
case 'spectrum':
model.spectrum.value = Spectrum.fromEvent(event);
return true;
case 'flyZoneDbRemote':
model.flyZones.applyRemoteVersion(asString(event['preciseRemoteVersion']));
return true;
case 'videoSize':
final int? w = asInt(event['width']), h = asInt(event['height']);
if (w != null && h != null && w > 0 && h > 0) model.videoSize.value = Size(w.toDouble(), h.toDouble());
+48
View File
@@ -0,0 +1,48 @@
/// Metric or imperial readouts (GO 4's General → Units, plan P6).
///
/// The aircraft always reports metres and m/s, and every setting is still
/// written in metres; only what the pilot reads changes.
enum UnitSystem {
metric('Metric (m, m/s)'),
imperial('Imperial (ft, mph)');
const UnitSystem(this.label);
final String label;
}
abstract final class Units {
static const double feetPerMetre = 3.28084;
static const double mphPerMs = 2.23694;
static const double kmhPerMs = 3.6;
/// A height or short distance: `12 m` / `39 ft`.
static String length(double? m, UnitSystem u, {int decimals = 0}) {
if (m == null) return '—';
return u == UnitSystem.metric ? '${m.toStringAsFixed(decimals)} m' : '${(m * feetPerMetre).toStringAsFixed(decimals)} ft';
}
/// A distance that can run long: metres then km, or feet then miles.
static String distance(double? m, UnitSystem u) {
if (m == null) return '—';
if (u == UnitSystem.metric) return m < 1000 ? '${m.round()} m' : '${(m / 1000).toStringAsFixed(m < 10000 ? 2 : 1)} km';
final double ft = m * feetPerMetre;
return ft < 5280 ? '${ft.round()} ft' : '${(ft / 5280).toStringAsFixed(ft < 52800 ? 2 : 1)} mi';
}
/// A speed: `8.2 m/s` / `18.3 mph`.
static String speed(double? ms, UnitSystem u, {int decimals = 1}) {
if (ms == null) return '—';
return u == UnitSystem.metric ? '${ms.toStringAsFixed(decimals)} m/s' : '${(ms * mphPerMs).toStringAsFixed(decimals)} mph';
}
/// The bare number for a HUD readout whose unit is drawn apart.
static double lengthValue(double m, UnitSystem u) => u == UnitSystem.metric ? m : m * feetPerMetre;
static double speedValue(double ms, UnitSystem u) => u == UnitSystem.metric ? ms : ms * mphPerMs;
static String lengthUnit(UnitSystem u) => u == UnitSystem.metric ? 'm' : 'ft';
static String speedUnit(UnitSystem u) => u == UnitSystem.metric ? 'm/s' : 'mph';
/// The other system's equivalent beside a metric setting: `≈ 394 ft` / `≈ 120 m`.
static String altHint(int metres, UnitSystem u) =>
u == UnitSystem.metric ? '≈ ${(metres * feetPerMetre).round()} ft' : '${(metres * feetPerMetre).round()} ft';
}
+7 -1
View File
@@ -379,7 +379,13 @@ class _HomePageState extends State<HomePage> {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => FlyPrefs.classicHud.value
? FlightControlPage(model: _model, dji: _dji)
: FlyScreen(model: _model, dji: _dji),
: FlyScreen(
model: _model,
dji: _dji,
onOpenFlightLogs: () => Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => FlightLogsPage(log: _flightLog, locator: _locator, model: _model),
)),
),
));
}
+14 -7
View File
@@ -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)),
]),
);
},
+246 -8
View File
@@ -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(
+697
View File
@@ -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,
),
),
]),
],
);
},
);
}
}
+81 -2
View File
@@ -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),
),
),
+110
View File
@@ -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(),
+43
View File
@@ -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});
+1
View File
@@ -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',