Give the Fly App GO 4-style obstacle radar, on-screen sticks, AirSense and fly zones
Vision radar: graded obstacle sectors around the centre of the video, the nearest distance per side, the obstacle overhead, and BLOCKED on a side whose sensors cannot see once the motors run. On aircraft with 360° sensing, sides without their own readings are filled from the perception ring. The same sectors sit on the attitude ball's rim, turned by the heading. Settings > Vision gains Display radar chart and the braking / sensing distances per direction. On-screen sticks: the rail's STICKS button hands the aircraft to two stick pads after a slide-to-confirm. The 25 Hz stream runs natively and sends zeros once Dart's last command is 300 ms old, so a stalled UI leaves the aircraft hovering. The sticks close when the remote takes over, on RTH or on landing. They are simulator-only on aircraft with a remote unless Settings > General > Developer allows real flights; Spark and Mavic Air on Wi-Fi may fly them for real. Which MSDK axis is forward in velocity + body mode is coded from DJI's documentation and must be checked in the simulator first. AirSense: a banner for manned aircraft at warning level 2 and up, with distance, direction and ICAO code; the planes on the map; the full list in a sheet and Settings > Safety; and the aircraft's own avoidance action with its countdown. Fly zones: DJI GEO zones around the aircraft (or the phone) on the map with their height limits, a banner for FlySafe notices and in/near-zone states, take-off refused in restricted and locked authorization zones, self-unlock of authorization zones, the aircraft's licences (enable, sync from the DJI account, remove all; ground only), and the GEO database version with an update button. Tested with unit and widget tests and screenshot renders only; nothing has run on an aircraft or the simulator yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
93f4a580e9
commit
e02bb0e5cd
34 files changed
+4376
-15
No files matched your search
@@ -334,6 +334,77 @@ class DjiService {
|
||||
Future<void> setTapFlySpeed(double speed) => _methods.invokeMethod<void>('setTapFlySpeed', <String, dynamic>{'speed': speed});
|
||||
Future<void> resetTapFlyHeading() => _methods.invokeMethod<void>('resetTapFlyHeading');
|
||||
|
||||
// ── Virtual sticks (plan P8) ────────────────────────────────────────────────
|
||||
/// Puts the flight controller in (or out of) virtual-stick mode; while on, the
|
||||
/// native side streams the last [sendVirtualStick] at 25 Hz, or zeros once it
|
||||
/// is more than 300 ms old.
|
||||
Future<void> setVirtualStickEnabled(bool on) =>
|
||||
_methods.invokeMethod<void>('setVirtualStickEnabled', <String, dynamic>{'on': on});
|
||||
|
||||
/// Body-frame velocities (m/s, forward / right / up positive) and yaw rate (°/s, clockwise).
|
||||
Future<void> sendVirtualStick({required double forward, required double right, required double yaw, required double up}) =>
|
||||
_methods.invokeMethod<void>('sendVirtualStick', <String, dynamic>{'forward': forward, 'right': right, 'yaw': yaw, 'up': up});
|
||||
|
||||
// ── Sensing distances (plan P8) ─────────────────────────────────────────────
|
||||
/// `{avoidance: {Horizontal, Upward, Downward}, perception: {…}}` in metres;
|
||||
/// a direction the aircraft does not support reads null.
|
||||
Future<Map<String, dynamic>> getSensingDistances() async {
|
||||
final dynamic m = await _methods.invokeMethod('getSensingDistances');
|
||||
return Map<String, dynamic>.from(m as Map);
|
||||
}
|
||||
|
||||
/// Where the aircraft brakes for an obstacle in [direction] (`Horizontal` / `Upward` / `Downward`).
|
||||
Future<void> setAvoidanceDistance(String direction, double metres) =>
|
||||
_methods.invokeMethod<void>('setAvoidanceDistance', <String, dynamic>{'direction': direction, 'value': metres});
|
||||
|
||||
/// How far the aircraft looks for obstacles in [direction].
|
||||
Future<void> setMaxPerceptionDistance(String direction, double metres) =>
|
||||
_methods.invokeMethod<void>('setMaxPerceptionDistance', <String, dynamic>{'direction': direction, 'value': metres});
|
||||
|
||||
// ── Fly zones (plan P8) ─────────────────────────────────────────────────────
|
||||
/// GEO zones around the point, or around the aircraft when omitted.
|
||||
Future<List<Map<String, dynamic>>> getFlyZonesNearby({double? latitude, double? longitude}) async {
|
||||
final List<Object?>? l = await _methods.invokeMethod<List<Object?>>('getFlyZonesNearby', <String, dynamic>{
|
||||
if (latitude != null) 'latitude': latitude,
|
||||
if (longitude != null) 'longitude': longitude,
|
||||
});
|
||||
return <Map<String, dynamic>>[for (final Object? z in l ?? const <Object?>[]) if (z is Map) Map<String, dynamic>.from(z)];
|
||||
}
|
||||
|
||||
/// Ids of the authorization zones unlocked on the connected aircraft.
|
||||
Future<List<int>> getUnlockedFlyZones() async {
|
||||
final List<Object?>? l = await _methods.invokeMethod<List<Object?>>('getUnlockedFlyZones');
|
||||
return <int>[for (final Object? id in l ?? const <Object?>[]) if (id is int) id];
|
||||
}
|
||||
|
||||
/// Self-unlocks an authorization zone; needs a logged-in DJI account.
|
||||
Future<void> unlockAuthorizationFlyZone(int id) =>
|
||||
_methods.invokeMethod<void>('unlockAuthorizationFlyZone', <String, dynamic>{'id': id});
|
||||
|
||||
/// The aircraft's licences (custom unlock zones): `{supported, zones: [...]}`.
|
||||
Future<Map<String, dynamic>> getFlyZoneLicences() async {
|
||||
final dynamic m = await _methods.invokeMethod('getFlyZoneLicences');
|
||||
return Map<String, dynamic>.from(m as Map);
|
||||
}
|
||||
|
||||
Future<void> setFlyZoneLicenceEnabled(int id, bool enabled) =>
|
||||
_methods.invokeMethod<void>('setFlyZoneLicenceEnabled', <String, dynamic>{'id': id, 'enabled': enabled});
|
||||
|
||||
/// Downloads the logged-in DJI account's licences and writes them to the aircraft.
|
||||
Future<void> syncFlyZoneLicences() => _methods.invokeMethod<void>('syncFlyZoneLicences');
|
||||
|
||||
Future<void> deleteAllFlyZoneLicences() => _methods.invokeMethod<void>('deleteAllFlyZoneLicences');
|
||||
|
||||
/// `{basicVersion, basicState, preciseVersion, preciseState}`; unknown parts are null.
|
||||
Future<Map<String, dynamic>> getFlyZoneDatabase() async {
|
||||
final dynamic m = await _methods.invokeMethod('getFlyZoneDatabase');
|
||||
return Map<String, dynamic>.from(m as Map);
|
||||
}
|
||||
|
||||
/// Downloads the basic GEO database; progress arrives as `flyZoneDbProgress` events.
|
||||
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.
|
||||
|
||||
@@ -4,10 +4,12 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'fly/product_profile.dart';
|
||||
import 'fly/state/aircraft_state.dart';
|
||||
import 'fly/state/airsense_state.dart';
|
||||
import 'fly/state/alert_state.dart';
|
||||
import 'fly/state/battery_state.dart';
|
||||
import 'fly/state/calibration_state.dart';
|
||||
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_state.dart';
|
||||
@@ -128,6 +130,12 @@ class FlightModel extends ChangeNotifier {
|
||||
final AlertState alerts = AlertState();
|
||||
final MissionState mission = MissionState();
|
||||
|
||||
/// Manned aircraft the AirSense receiver hears (plan P8).
|
||||
final AirSenseState airSense = AirSenseState();
|
||||
|
||||
/// DJI GEO fly zones around and the latest FlySafe notice (plan P8).
|
||||
final FlyZoneState flyZones = FlyZoneState();
|
||||
|
||||
/// KeyManager values the settings sheet listens to (plan P6).
|
||||
final KeyState keys = KeyState();
|
||||
|
||||
@@ -139,7 +147,7 @@ class FlightModel extends ChangeNotifier {
|
||||
final ValueNotifier<Size?> videoSize = ValueNotifier<Size?>(null);
|
||||
|
||||
late final List<ChangeNotifier> _subs = <ChangeNotifier>[
|
||||
aircraft, battery, links, camera, gimbal, vision, alerts, mission, keys, calibration, //
|
||||
aircraft, battery, links, camera, gimbal, vision, alerts, mission, keys, calibration, airSense, flyZones, //
|
||||
];
|
||||
|
||||
/// Wipes the aircraft-bound sub-models on disconnect.
|
||||
@@ -153,6 +161,8 @@ class FlightModel extends ChangeNotifier {
|
||||
alerts.clear();
|
||||
keys.clear();
|
||||
calibration.clear();
|
||||
airSense.clear();
|
||||
flyZones.clearAircraft();
|
||||
});
|
||||
|
||||
// ── SDK / registration / connection ────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'virtual_sticks.dart' show StickMode;
|
||||
|
||||
/// The video overlay grid (camera menu → Other, and the rail's GRID button).
|
||||
enum GridStyle {
|
||||
off('Off'),
|
||||
@@ -31,6 +33,9 @@ abstract final class FlyPrefs {
|
||||
static const String _longPressKey = 'fly.longPress';
|
||||
static const String _routeKey = 'fly.showRoute';
|
||||
static const String _termsKey = 'fly.modeTerms';
|
||||
static const String _radarKey = 'fly.radar';
|
||||
static const String _stickModeKey = 'fly.stickMode';
|
||||
static const String _sticksRealKey = 'fly.sticksRealFlight';
|
||||
|
||||
/// Open the pre-P2 `FlightControlPage` instead of the HUD v2 — kept
|
||||
/// reachable until P3's safety flows pass their simulator run (plan P2/P3).
|
||||
@@ -50,6 +55,16 @@ abstract final class FlyPrefs {
|
||||
/// Intelligent flight modes whose first-use terms the pilot accepted (plan P7), by name.
|
||||
static final ValueNotifier<Set<String>> acceptedModeTerms = ValueNotifier<Set<String>>(<String>{});
|
||||
|
||||
/// Draw the vision radar on the video (GO 4's "Display Radar Chart", plan P8).
|
||||
static final ValueNotifier<bool> showRadar = ValueNotifier<bool>(true);
|
||||
|
||||
/// What each on-screen stick does (plan P8); matches the remote's stick mode by default.
|
||||
static final ValueNotifier<StickMode> stickMode = ValueNotifier<StickMode>(StickMode.mode2);
|
||||
|
||||
/// Developer switch: allow on-screen sticks outside the simulator on aircraft
|
||||
/// that have a remote. Off by default (plan P8: simulator first).
|
||||
static final ValueNotifier<bool> sticksInRealFlight = ValueNotifier<bool>(false);
|
||||
|
||||
static SharedPreferences? _prefs;
|
||||
|
||||
static Future<void> load() async {
|
||||
@@ -62,6 +77,9 @@ abstract final class FlyPrefs {
|
||||
longPress.value = _byName(LongPressAction.values, _prefs!.getString(_longPressKey)) ?? LongPressAction.meter;
|
||||
showRoute.value = _prefs!.getBool(_routeKey) ?? true;
|
||||
acceptedModeTerms.value = (_prefs!.getStringList(_termsKey) ?? const <String>[]).toSet();
|
||||
showRadar.value = _prefs!.getBool(_radarKey) ?? true;
|
||||
stickMode.value = _byName(StickMode.values, _prefs!.getString(_stickModeKey)) ?? StickMode.mode2;
|
||||
sticksInRealFlight.value = _prefs!.getBool(_sticksRealKey) ?? false;
|
||||
} catch (_) {
|
||||
// Storage unavailable: run on the defaults.
|
||||
}
|
||||
@@ -103,6 +121,21 @@ abstract final class FlyPrefs {
|
||||
await _prefs?.setStringList(_termsKey, acceptedModeTerms.value.toList()..sort());
|
||||
}
|
||||
|
||||
static Future<void> setShowRadar(bool on) async {
|
||||
showRadar.value = on;
|
||||
await _prefs?.setBool(_radarKey, on);
|
||||
}
|
||||
|
||||
static Future<void> setStickMode(StickMode m) async {
|
||||
stickMode.value = m;
|
||||
await _prefs?.setString(_stickModeKey, m.name);
|
||||
}
|
||||
|
||||
static Future<void> setSticksInRealFlight(bool on) async {
|
||||
sticksInRealFlight.value = on;
|
||||
await _prefs?.setBool(_sticksRealKey, on);
|
||||
}
|
||||
|
||||
static T? _byName<T extends Enum>(List<T> values, String? name) {
|
||||
for (final T v in values) {
|
||||
if (v.name == name) return v;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../dji_service.dart';
|
||||
import '../flight_model.dart';
|
||||
|
||||
/// Keeps [FlightModel.flyZones] stocked with the GEO zones around (plan P8):
|
||||
/// fetched around the aircraft's fix, or the phone's before it has one, again
|
||||
/// after moving 2 km or every 10 minutes; plus the unlocked list once per
|
||||
/// connection with the aircraft's licences, and the GEO database's state at
|
||||
/// start. One fetch at a time; failures wait for the next position.
|
||||
class FlyZoneLoader {
|
||||
FlyZoneLoader({required this.model, required this.dji});
|
||||
|
||||
final FlightModel model;
|
||||
final DjiService dji;
|
||||
|
||||
bool _busy = false;
|
||||
bool _unlockedLoaded = false;
|
||||
|
||||
void start() {
|
||||
model.aircraft.addListener(_check);
|
||||
model.addListener(_onModel);
|
||||
unawaited(refreshDatabase());
|
||||
_check();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
model.aircraft.removeListener(_check);
|
||||
model.removeListener(_onModel);
|
||||
}
|
||||
|
||||
void _onModel() {
|
||||
if (!model.connected) _unlockedLoaded = false;
|
||||
_check();
|
||||
}
|
||||
|
||||
/// The aircraft's fix, else the phone's.
|
||||
(double, double)? get _position {
|
||||
final double? lat = model.aircraft.latitude, lon = model.aircraft.longitude;
|
||||
if (lat != null && lon != null && !(lat == 0 && lon == 0)) return (lat, lon);
|
||||
final double? pLat = model.phoneLatitude, pLon = model.phoneLongitude;
|
||||
if (pLat != null && pLon != null) return (pLat, pLon);
|
||||
return null;
|
||||
}
|
||||
|
||||
void _check() {
|
||||
if (model.connected && !_unlockedLoaded) {
|
||||
_unlockedLoaded = true;
|
||||
unawaited(refreshUnlocked());
|
||||
unawaited(refreshLicences());
|
||||
}
|
||||
final (double, double)? p = _position;
|
||||
if (p == null || _busy || !model.flyZones.needsZonesFor(p.$1, p.$2)) return;
|
||||
unawaited(_fetch(p));
|
||||
}
|
||||
|
||||
Future<void> _fetch((double, double) p) async {
|
||||
_busy = true;
|
||||
try {
|
||||
final List<Map<String, dynamic>> zones = await dji.getFlyZonesNearby(latitude: p.$1, longitude: p.$2);
|
||||
model.flyZones.applyZones(zones, latitude: p.$1, longitude: p.$2);
|
||||
} catch (_) {
|
||||
// Database not ready or offline: retry when the position moves on.
|
||||
} finally {
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The aircraft's licences (custom unlock zones).
|
||||
Future<void> refreshLicences() async {
|
||||
try {
|
||||
model.flyZones.applyLicences(await dji.getFlyZoneLicences());
|
||||
} catch (_) {
|
||||
// Not reported by this aircraft.
|
||||
}
|
||||
}
|
||||
|
||||
/// The GEO database's versions and state (app-level, no aircraft needed).
|
||||
Future<void> refreshDatabase() async {
|
||||
try {
|
||||
model.flyZones.applyDatabase(await dji.getFlyZoneDatabase());
|
||||
} catch (_) {
|
||||
// SDK not registered yet.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshUnlocked() async {
|
||||
try {
|
||||
model.flyZones.applyUnlocked(await dji.getUnlockedFlyZones());
|
||||
} catch (_) {
|
||||
// Not reported by this aircraft.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,12 @@ abstract final class HudFeatures {
|
||||
/// 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);
|
||||
/// 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);
|
||||
/// DJI GEO zones: the database is app-level, every aircraft gets it.
|
||||
static final HudFeature flyZones = HudFeature(
|
||||
'overlay.flyZones', 'Fly zones (GEO) warnings + map', 'Overlays', (ProductProfile p) => p.isAircraft);
|
||||
static final HudFeature simulator = HudFeature(
|
||||
'control.simulator', 'Simulator toggle', 'Control', (ProductProfile p) => p.hasSimulator);
|
||||
|
||||
@@ -174,6 +180,8 @@ abstract final class HudFeatures {
|
||||
vpsReadout,
|
||||
visionRadar,
|
||||
virtualSticks,
|
||||
airSense,
|
||||
flyZones,
|
||||
precisionTakeoff,
|
||||
simulator,
|
||||
settingsVision,
|
||||
|
||||
@@ -8,13 +8,16 @@ import '../flight_model.dart';
|
||||
import 'action_rail_state.dart';
|
||||
import 'calibration.dart';
|
||||
import 'flight_status.dart';
|
||||
import 'fly_prefs.dart';
|
||||
import 'hud_geometry.dart';
|
||||
import 'modes/vision_modes.dart';
|
||||
import 'modes/waypoint_mission.dart';
|
||||
import 'product_profile.dart';
|
||||
import 'state/aircraft_state.dart';
|
||||
import 'state/alert_state.dart';
|
||||
import 'state/fly_zone_state.dart';
|
||||
import 'state/mission_state.dart';
|
||||
import 'virtual_sticks.dart';
|
||||
|
||||
export 'action_rail_state.dart' show FlightCommand;
|
||||
|
||||
@@ -197,6 +200,93 @@ class SafetyGate {
|
||||
if (model.profile.hasCamera && model.camera.storage != null && !model.camera.storage!.usable) {
|
||||
checks.add(GateCheck.warn('${model.camera.storage!.status}: photos and video will not be saved.'));
|
||||
}
|
||||
_flyZone(checks);
|
||||
}
|
||||
|
||||
// ── Fly zones (plan P8) ─────────────────────────────────────────────────────
|
||||
|
||||
/// DJI's GEO system refuses take-off in restricted and locked authorization
|
||||
/// zones on its own; saying so here keeps the slider from promising otherwise.
|
||||
void _flyZone(List<GateCheck> checks) {
|
||||
final FlyZoneState z = model.flyZones;
|
||||
switch (z.state) {
|
||||
case 'IN_RESTRICTED_ZONE':
|
||||
case 'SUSPECTED_IN_RESTRICTED_ZONE':
|
||||
checks.add(const GateCheck.block('The aircraft is in a restricted zone: take-off is not allowed here.'));
|
||||
case 'IN_AUTHORIZED_ZONE':
|
||||
case 'SUSPECTED_IN_AUTHORIZED_ZONE':
|
||||
checks.add(const GateCheck.block('Authorization zone: unlock it first (Settings → Safety → Fly zones).'));
|
||||
case 'IN_ENHANCE_WARNING_ZONE':
|
||||
checks.add(const GateCheck.warn('Enhanced warning zone: check local rules before flying.'));
|
||||
case 'IN_WARNING_ZONE':
|
||||
checks.add(const GateCheck.warn('Warning zone: check local rules before flying.'));
|
||||
case 'IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION':
|
||||
case 'SUSPECTED_IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION':
|
||||
checks.add(const GateCheck.warn('Height-limited zone: the aircraft will not climb above the zone\'s limit.'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Switching or removing licences changes what the flight controller lets the
|
||||
/// aircraft do, so it happens on the ground only.
|
||||
List<GateCheck> checkLicenceChange() => <GateCheck>[
|
||||
if (!model.connected) const GateCheck.block('Connect the aircraft to change its licences.'),
|
||||
if (model.connected && _a.isFlying == true) const GateCheck.block('Land before changing licences.'),
|
||||
];
|
||||
|
||||
Future<void> _licences(Future<void> Function() change) async {
|
||||
final List<GateCheck> checks = checkLicenceChange();
|
||||
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
|
||||
await change();
|
||||
try {
|
||||
model.flyZones.applyLicences(await dji.getFlyZoneLicences());
|
||||
} catch (_) {
|
||||
// The list refreshes on the next connection.
|
||||
}
|
||||
}
|
||||
|
||||
/// Enables [licence] (the aircraft keeps one enabled at a time) or disables it.
|
||||
Future<void> setLicenceEnabled(FlyZoneLicence licence, bool enabled) async {
|
||||
if (enabled && licence.expired) throw const MissionRefused(<GateCheck>[GateCheck.block('This licence has expired.')]);
|
||||
await _licences(() => dji.setFlyZoneLicenceEnabled(licence.id, enabled));
|
||||
}
|
||||
|
||||
/// Fetches the DJI account's licences and writes them to the aircraft.
|
||||
Future<void> syncLicences() => _licences(dji.syncFlyZoneLicences);
|
||||
|
||||
/// Removes every licence from the aircraft.
|
||||
Future<void> deleteAllLicences() => _licences(dji.deleteAllFlyZoneLicences);
|
||||
|
||||
/// Downloads the basic GEO database; the database state refreshes when it ends.
|
||||
Future<void> upgradeFlyZoneDatabase() async {
|
||||
model.flyZones.applyUpgradeProgress(0);
|
||||
try {
|
||||
await dji.startFlyZoneDatabaseUpgrade();
|
||||
} finally {
|
||||
model.flyZones.applyUpgradeProgress(null);
|
||||
try {
|
||||
model.flyZones.applyDatabase(await dji.getFlyZoneDatabase());
|
||||
} catch (_) {
|
||||
// Keeps the last known state.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelFlyZoneDatabaseUpgrade() async {
|
||||
await dji.stopFlyZoneDatabaseUpgrade();
|
||||
model.flyZones.applyUpgradeProgress(null);
|
||||
}
|
||||
|
||||
/// Self-unlocks an authorization zone (DJI account required). Unlocking is
|
||||
/// the pilot declaring they may fly there, so the UI confirms it first.
|
||||
Future<void> unlockFlyZone(FlyZone zone) async {
|
||||
if (!zone.unlockable) throw const MissionRefused(<GateCheck>[GateCheck.block('Only authorization zones can be self-unlocked.')]);
|
||||
if (!model.connected) throw const MissionRefused(<GateCheck>[GateCheck.block('Connect the aircraft to unlock a zone for it.')]);
|
||||
await dji.unlockAuthorizationFlyZone(zone.id);
|
||||
try {
|
||||
model.flyZones.applyUnlocked(<int>{...model.flyZones.unlocked, ...await dji.getUnlockedFlyZones()});
|
||||
} catch (_) {
|
||||
model.flyZones.applyUnlocked(<int>{...model.flyZones.unlocked, zone.id});
|
||||
}
|
||||
}
|
||||
|
||||
void _goHome(List<GateCheck> checks) {
|
||||
@@ -577,6 +667,76 @@ class SafetyGate {
|
||||
await dji.setTapFlySpeed(speed.clamp(tapFlyMinSpeed, tapFlyMaxSpeed));
|
||||
}
|
||||
|
||||
// ── Virtual sticks (plan P8) ────────────────────────────────────────────────
|
||||
|
||||
/// Preconditions for handing the aircraft to the on-screen sticks.
|
||||
///
|
||||
/// Simulator first: on an aircraft that has a remote, the sticks are refused
|
||||
/// outside the simulator unless the developer switch allows them. Aircraft
|
||||
/// whose primary control *is* the phone (Spark / Mavic Air on Wi-Fi) may fly
|
||||
/// them for real, with a warning.
|
||||
List<GateCheck> checkVirtualSticks() {
|
||||
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.'));
|
||||
final ProductProfile p = model.profile;
|
||||
if (p.connected && !p.supportsVirtualStick) return checks..add(const GateCheck.block('This aircraft cannot be flown with on-screen sticks.'));
|
||||
final String mode = _a.flightModeEnum ?? '';
|
||||
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.'));
|
||||
switch (RailState.activeMode(_a, model.mission)) {
|
||||
case ActiveMode.waypoint:
|
||||
checks.add(const GateCheck.block('Stop the waypoint mission first.'));
|
||||
case ActiveMode.activeTrack:
|
||||
checks.add(const GateCheck.block('Stop ActiveTrack first.'));
|
||||
case ActiveMode.tapFly:
|
||||
checks.add(const GateCheck.block('Stop TapFly first.'));
|
||||
case ActiveMode.other:
|
||||
checks.add(const GateCheck.block('Stop the intelligent flight mode first.'));
|
||||
case ActiveMode.none:
|
||||
break;
|
||||
}
|
||||
final int? percent = model.battery.percent;
|
||||
final int critical = model.battery.seriousLowWarningThreshold ?? 15;
|
||||
if (_a.seriousLowBattery == true || (percent != null && percent <= critical)) {
|
||||
checks.add(GateCheck.block('Battery critically low${percent == null ? '' : ' ($percent%)'}.'));
|
||||
}
|
||||
final bool simulating = _a.simulatorActive == true;
|
||||
if (!simulating) {
|
||||
if (!p.virtualStickPrimary && !FlyPrefs.sticksInRealFlight.value) {
|
||||
checks.add(const GateCheck.block(
|
||||
'On-screen sticks are for the simulator on this aircraft: start it in Settings → General → Developer, '
|
||||
'or allow real flights there.'));
|
||||
} else {
|
||||
checks.add(const GateCheck.warn('This is a real flight: the aircraft moves as you push the sticks.'));
|
||||
}
|
||||
}
|
||||
if (_a.isFlying != true) {
|
||||
checks.add(const GateCheck.warn('The sticks do not start the motors: take off with TAKE OFF, then fly.'));
|
||||
}
|
||||
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel && !simulating) {
|
||||
checks.add(const GateCheck.warn('Weak GPS: the aircraft drifts when the sticks are centred.'));
|
||||
}
|
||||
if (p.hasRcLink) {
|
||||
checks.add(const GateCheck.warn("The remote's sticks are ignored meanwhile: flip its flight-mode switch to take back control."));
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
|
||||
/// Re-checks and puts the flight controller in virtual-stick mode.
|
||||
Future<void> enableVirtualSticks() async {
|
||||
final List<GateCheck> checks = checkVirtualSticks();
|
||||
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
|
||||
await dji.setVirtualStickEnabled(true);
|
||||
}
|
||||
|
||||
/// Hands control back to the remote. Always allowed.
|
||||
Future<void> disableVirtualSticks() => dji.setVirtualStickEnabled(false);
|
||||
|
||||
/// One stick packet. Not re-checked per packet (25 Hz); the controller closes
|
||||
/// the sticks when RTH, landing or the remote takes over.
|
||||
Future<void> sendSticks(StickCommand c) => dji.sendVirtualStick(forward: c.forward, right: c.right, yaw: c.yaw, up: c.up);
|
||||
|
||||
/// True when the pilot stands far enough from the recorded home point that
|
||||
/// RTH should ask whether to come back to them instead (GO 4's RTH target).
|
||||
bool get offersRthTarget => (pilotToHome ?? 0) > rthTargetChoiceDistance;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'state/parse.dart';
|
||||
|
||||
/// The three directions MSDK's FlightAssistant sets distances for.
|
||||
enum SensingDirection {
|
||||
horizontal('Horizontal', 'Horizontal'),
|
||||
up('Upward', 'Upward'),
|
||||
down('Downward', 'Downward');
|
||||
|
||||
const SensingDirection(this.sdk, this.label);
|
||||
|
||||
/// `DJIFlightAssistantObstacleSensingDirection` name.
|
||||
final String sdk;
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// Which of the two distances.
|
||||
enum SensingKind {
|
||||
/// `setVisualObstaclesAvoidanceDistance`: where the aircraft brakes.
|
||||
avoidance,
|
||||
|
||||
/// `setMaxPerceptionDistance`: how far it looks (the warning distance).
|
||||
perception,
|
||||
}
|
||||
|
||||
/// The aircraft's obstacle braking and perception distances (plan P8, GO 4 /
|
||||
/// Pilot's Vision "obstacle distance" rows). A null value is a direction the
|
||||
/// aircraft refused to report, and its row hides.
|
||||
@immutable
|
||||
class SensingDistances {
|
||||
const SensingDistances({this.avoidance = const <SensingDirection, double?>{}, this.perception = const <SensingDirection, double?>{}});
|
||||
|
||||
factory SensingDistances.fromBridge(Map<String, dynamic> m) {
|
||||
Map<SensingDirection, double?> read(Object? v) {
|
||||
final Map<String, dynamic>? g = asMap(v);
|
||||
return <SensingDirection, double?>{for (final SensingDirection d in SensingDirection.values) d: finite(g?[d.sdk])};
|
||||
}
|
||||
|
||||
return SensingDistances(avoidance: read(m['avoidance']), perception: read(m['perception']));
|
||||
}
|
||||
|
||||
final Map<SensingDirection, double?> avoidance;
|
||||
final Map<SensingDirection, double?> perception;
|
||||
|
||||
double? value(SensingKind k, SensingDirection d) => (k == SensingKind.avoidance ? avoidance : perception)[d];
|
||||
|
||||
bool get isEmpty => avoidance.values.every((double? v) => v == null) && perception.values.every((double? v) => v == null);
|
||||
|
||||
SensingDistances withValue(SensingKind k, SensingDirection d, double v) => SensingDistances(
|
||||
avoidance: k == SensingKind.avoidance ? <SensingDirection, double?>{...avoidance, d: v} : avoidance,
|
||||
perception: k == SensingKind.perception ? <SensingDirection, double?>{...perception, d: v} : perception,
|
||||
);
|
||||
|
||||
/// Range and step a row offers. MSDK 4.18 documents no limits; these follow
|
||||
/// DJI Pilot's sliders and the flight controller refuses anything it does not
|
||||
/// accept (the row then rolls back). Unverified on hardware.
|
||||
static ({double min, double max, double step}) range(SensingKind k, SensingDirection d) => switch ((k, d)) {
|
||||
(SensingKind.avoidance, SensingDirection.down) => (min: 0.5, max: 3, step: 0.1),
|
||||
(SensingKind.avoidance, _) => (min: 1, max: 10, step: 0.5),
|
||||
(SensingKind.perception, SensingDirection.down) => (min: 1, max: 10, step: 0.5),
|
||||
(SensingKind.perception, _) => (min: 5, max: 40, step: 1),
|
||||
};
|
||||
|
||||
/// [v] moved [steps] steps within the range, snapped to the step.
|
||||
static double stepped(SensingKind k, SensingDirection d, double v, int steps) {
|
||||
final ({double min, double max, double step}) r = range(k, d);
|
||||
final double next = ((v / r.step).round() + steps) * r.step;
|
||||
return double.parse(next.clamp(r.min, r.max).toStringAsFixed(2));
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,13 @@ abstract final class SettingsTabs {
|
||||
static const String stickDiagram = 'stickDiagram';
|
||||
static const String gimbalList = 'gimbalList';
|
||||
static const String showRoute = 'showRoute';
|
||||
static const String radarChart = 'radarChart';
|
||||
static const String stickPads = 'stickPads';
|
||||
static const String airSenseList = 'airSenseList';
|
||||
static const String sensingDistances = 'sensingDistances';
|
||||
static const String flyZoneList = 'flyZoneList';
|
||||
static const String flyZoneLicences = 'flyZoneLicences';
|
||||
static const String flyZoneDatabase = 'flyZoneDatabase';
|
||||
static const String clearRoute = 'clearRoute';
|
||||
static const String about = 'about';
|
||||
static const String developer = 'developer';
|
||||
@@ -195,11 +202,18 @@ abstract final class SettingsTabs {
|
||||
const KeySwitchSpec(id: 'escBeep', label: 'ESC beeping', key: SettingsKeys.escBeep),
|
||||
const GroupSpec('SENSORS'),
|
||||
const CustomSpec(id: sensors, keys: <DjiKey>[SettingsKeys.imuCount, SettingsKeys.compassCount]),
|
||||
GroupSpec('AIRSENSE', requires: HudFeatures.airSense),
|
||||
CustomSpec(id: airSenseList, requires: HudFeatures.airSense),
|
||||
GroupSpec('FLY ZONES', requires: HudFeatures.flyZones),
|
||||
CustomSpec(id: flyZoneList, requires: HudFeatures.flyZones),
|
||||
CustomSpec(id: flyZoneLicences, requires: HudFeatures.flyZones),
|
||||
CustomSpec(id: flyZoneDatabase, requires: HudFeatures.flyZones),
|
||||
];
|
||||
|
||||
// ── Vision ──────────────────────────────────────────────────────────────────
|
||||
static final List<SettingSpec> _vision = <SettingSpec>[
|
||||
GroupSpec('OBSTACLE AVOIDANCE', requires: HudFeatures.settingsObstacle),
|
||||
CustomSpec(id: radarChart, requires: HudFeatures.visionRadar),
|
||||
KeySwitchSpec(
|
||||
id: 'collision',
|
||||
label: 'Obstacle avoidance',
|
||||
@@ -238,6 +252,7 @@ abstract final class SettingsTabs {
|
||||
key: SettingsKeys.rthRemoteAvoidance,
|
||||
requires: HudFeatures.settingsObstacle,
|
||||
),
|
||||
CustomSpec(id: sensingDistances, requires: HudFeatures.settingsObstacle),
|
||||
const GroupSpec('POSITIONING & LANDING'),
|
||||
const KeySwitchSpec(id: 'vps', label: 'Vision positioning', key: SettingsKeys.vps),
|
||||
const KeySwitchSpec(
|
||||
@@ -496,6 +511,8 @@ abstract final class SettingsTabs {
|
||||
const GroupSpec('MAP'),
|
||||
const CustomSpec(id: showRoute),
|
||||
const CustomSpec(id: clearRoute),
|
||||
GroupSpec('ON-SCREEN STICKS', requires: HudFeatures.virtualSticks),
|
||||
CustomSpec(id: stickPads, requires: HudFeatures.virtualSticks),
|
||||
const GroupSpec('ABOUT'),
|
||||
const CustomSpec(id: about, keys: <DjiKey>[SettingsKeys.firmwarePackage, SettingsKeys.fcSerial]),
|
||||
const GroupSpec('DEVELOPER'),
|
||||
|
||||
@@ -118,6 +118,10 @@ class AircraftState extends ChangeNotifier {
|
||||
/// The flight controller's built-in simulator is running (developer only).
|
||||
bool? simulatorActive;
|
||||
|
||||
/// The flight controller is in virtual-stick mode (plan P8), from the native
|
||||
/// stick loop's `virtualStick` group; null until sticks were first enabled.
|
||||
bool? virtualStickEnabled;
|
||||
|
||||
bool? compassHasError;
|
||||
bool? compassCalibrating;
|
||||
|
||||
@@ -172,6 +176,7 @@ class AircraftState extends ChangeNotifier {
|
||||
failsafe = asBool(t['isFailsafe']);
|
||||
orientationMode = asString(t['orientationMode']);
|
||||
simulatorActive = asBool(t['simulatorActive']);
|
||||
virtualStickEnabled = asBool(asMap(t['virtualStick'])?['enabled']);
|
||||
compassHasError = asBool(t['compassHasError']);
|
||||
compassCalibrating = asBool(t['compassCalibrating']);
|
||||
compassCalibrationState = asString(t['compassCalibrationState']);
|
||||
@@ -183,7 +188,7 @@ class AircraftState extends ChangeNotifier {
|
||||
satellites = gpsSignalLevel = flightTimeSeconds = null;
|
||||
isFlying = motorsOn = homeLocationSet = ultrasonicBeingUsed = ultrasonicError = null;
|
||||
visionPositioningBeingUsed = landingConfirmationNeeded = goingHome = null;
|
||||
lowBattery = seriousLowBattery = reachedMaxHeight = reachedMaxRadius = imuPreheating = failsafe = simulatorActive = null;
|
||||
lowBattery = seriousLowBattery = reachedMaxHeight = reachedMaxRadius = imuPreheating = failsafe = simulatorActive = virtualStickEnabled = null;
|
||||
compassHasError = compassCalibrating = null;
|
||||
compassCalibrationState = null;
|
||||
flightMode = flightModeEnum = goHomeExecutionState = batteryThresholdBehavior = windWarning = orientationMode = null;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'parse.dart';
|
||||
|
||||
/// One manned aircraft the AirSense (ADS-B In) receiver hears.
|
||||
@immutable
|
||||
class AirSensePlane {
|
||||
const AirSensePlane({
|
||||
required this.code,
|
||||
this.warningLevel = 0,
|
||||
this.direction,
|
||||
this.heading,
|
||||
this.distance,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
});
|
||||
|
||||
factory AirSensePlane.fromMap(Map<String, dynamic> m) => AirSensePlane(
|
||||
code: (asString(m['code']) ?? '').trim(),
|
||||
warningLevel: asInt(m['warningLevel']) ?? 0,
|
||||
direction: asString(m['direction']),
|
||||
heading: finite(m['heading']),
|
||||
distance: finite(m['distance']),
|
||||
latitude: finite(m['latitude']),
|
||||
longitude: finite(m['longitude']),
|
||||
);
|
||||
|
||||
/// ICAO 24-bit address, as the receiver formats it.
|
||||
final String code;
|
||||
|
||||
/// 0 (no threat) … 4 (collision risk).
|
||||
final int warningLevel;
|
||||
|
||||
/// `AirSenseDirection` octant relative to the drone: `NORTH`, `NORTH_EAST`, … `UNKNOWN`.
|
||||
final String? direction;
|
||||
|
||||
final double? heading; // deg
|
||||
final double? distance; // m
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
bool get hasPosition => latitude != null && longitude != null && !(latitude == 0 && longitude == 0);
|
||||
|
||||
/// `NE`, `S`, … or null when unknown.
|
||||
String? get octant => switch (direction) {
|
||||
'NORTH' => 'N',
|
||||
'NORTH_EAST' => 'NE',
|
||||
'EAST' => 'E',
|
||||
'SOUTH_EAST' => 'SE',
|
||||
'SOUTH' => 'S',
|
||||
'SOUTH_WEST' => 'SW',
|
||||
'WEST' => 'W',
|
||||
'NORTH_WEST' => 'NW',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// AirSense (plan P8): the receiver's overall warning and every plane it hears,
|
||||
/// from the telemetry tick's `airSense` group. [reported] stays false on
|
||||
/// aircraft without a receiver.
|
||||
class AirSenseState extends ChangeNotifier {
|
||||
bool reported = false;
|
||||
|
||||
/// The receiver's overall level, 0–4.
|
||||
int warningLevel = 0;
|
||||
|
||||
/// Nearest first.
|
||||
List<AirSensePlane> planes = const <AirSensePlane>[];
|
||||
|
||||
/// The level to act on: the receiver's own, or the worst single plane's.
|
||||
int get level => planes.fold(warningLevel, (int w, AirSensePlane p) => p.warningLevel > w ? p.warningLevel : w);
|
||||
|
||||
/// Level 2 asks for caution; 3 and 4 ask the pilot to descend.
|
||||
static const int cautionLevel = 2;
|
||||
static const int dangerLevel = 3;
|
||||
|
||||
/// Planes at caution level or worse, nearest first.
|
||||
List<AirSensePlane> get threats => <AirSensePlane>[for (final AirSensePlane p in planes) if (p.warningLevel >= cautionLevel) p];
|
||||
|
||||
/// The plane a warning should name: the worst, then the nearest.
|
||||
AirSensePlane? get worst {
|
||||
AirSensePlane? best;
|
||||
for (final AirSensePlane p in planes) {
|
||||
if (best == null || p.warningLevel > best.warningLevel) best = p;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
void apply(Map<String, dynamic>? m) {
|
||||
reported = m != null;
|
||||
warningLevel = asInt(m?['warningLevel']) ?? 0;
|
||||
planes = <AirSensePlane>[for (final Map<String, dynamic> p in asMapList(m?['airplanes'])) AirSensePlane.fromMap(p)]
|
||||
..sort((AirSensePlane a, AirSensePlane b) => (a.distance ?? double.infinity).compareTo(b.distance ?? double.infinity));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// What the flight controller does about a threat by itself, on the aircraft
|
||||
/// that report it (`airSenseAvoidance`): `NONE`, `AVOIDING` or `LANDING`.
|
||||
String? avoidanceMode;
|
||||
|
||||
/// Seconds until the avoidance action starts.
|
||||
double? avoidanceCountdown;
|
||||
|
||||
bool get avoiding => avoidanceMode == 'AVOIDING' || avoidanceMode == 'LANDING';
|
||||
|
||||
void applyAvoidance(Map<String, dynamic>? m) {
|
||||
avoidanceMode = asString(m?['mode']);
|
||||
avoidanceCountdown = finite(m?['countdown']);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
reported = false;
|
||||
warningLevel = 0;
|
||||
planes = const <AirSensePlane>[];
|
||||
avoidanceMode = null;
|
||||
avoidanceCountdown = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../hud_geometry.dart';
|
||||
import 'parse.dart';
|
||||
|
||||
/// How a fly zone (or the aircraft's standing towards one) should look.
|
||||
enum ZoneLevel { none, info, caution, danger }
|
||||
|
||||
/// One part of a GEO zone: a circle, or a polygon with its own height limit.
|
||||
@immutable
|
||||
class SubFlyZone {
|
||||
const SubFlyZone({required this.shape, this.latitude, this.longitude, this.radius, this.maxHeight, this.vertices = const <(double, double)>[]});
|
||||
|
||||
factory SubFlyZone.fromMap(Map<String, dynamic> m) => SubFlyZone(
|
||||
shape: asString(m['shape']) ?? 'UNKNOWN',
|
||||
latitude: finite(m['latitude']),
|
||||
longitude: finite(m['longitude']),
|
||||
radius: finite(m['radius']),
|
||||
maxHeight: asInt(m['maxHeight']),
|
||||
vertices: <(double, double)>[
|
||||
if (m['vertices'] is List)
|
||||
for (final Object? v in m['vertices'] as List)
|
||||
if (v is List && v.length == 2 && finite(v[0]) != null && finite(v[1]) != null) (finite(v[0])!, finite(v[1])!),
|
||||
],
|
||||
);
|
||||
|
||||
/// `CYLINDER` or `POLYGON`.
|
||||
final String shape;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final double? radius; // m
|
||||
/// Height limit inside this part, m (0 = no flight).
|
||||
final int? maxHeight;
|
||||
final List<(double, double)> vertices;
|
||||
|
||||
bool get isPolygon => shape == 'POLYGON' && vertices.length >= 3;
|
||||
|
||||
/// Where the height label goes: the circle's centre, or the polygon's vertex average.
|
||||
(double, double)? get labelPoint {
|
||||
if (isPolygon) {
|
||||
double la = 0, lo = 0;
|
||||
for (final (double, double) v in vertices) {
|
||||
la += v.$1;
|
||||
lo += v.$2;
|
||||
}
|
||||
return (la / vertices.length, lo / vertices.length);
|
||||
}
|
||||
return latitude == null || longitude == null ? null : (latitude!, longitude!);
|
||||
}
|
||||
|
||||
/// `NO FLY` at 0 m, `120 m` for a ceiling, null when the part sets none.
|
||||
String? get heightLabel {
|
||||
final int? h = maxHeight;
|
||||
if (h == null || h < 0) return null;
|
||||
return h == 0 ? 'NO FLY' : '$h m';
|
||||
}
|
||||
}
|
||||
|
||||
/// One DJI GEO zone (`FlyZoneInformation`).
|
||||
@immutable
|
||||
class FlyZone {
|
||||
const FlyZone({
|
||||
required this.id,
|
||||
this.name = '',
|
||||
this.category = 'UNKNOWN',
|
||||
this.reason,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.radius,
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
this.unlockStartTime,
|
||||
this.unlockEndTime,
|
||||
this.subZones = const <SubFlyZone>[],
|
||||
});
|
||||
|
||||
factory FlyZone.fromMap(Map<String, dynamic> m) => FlyZone(
|
||||
id: asInt(m['id']) ?? 0,
|
||||
name: (asString(m['name']) ?? '').trim(),
|
||||
category: asString(m['category']) ?? 'UNKNOWN',
|
||||
reason: asString(m['reason']),
|
||||
latitude: finite(m['latitude']),
|
||||
longitude: finite(m['longitude']),
|
||||
radius: finite(m['radius']),
|
||||
startTime: _blank(asString(m['startTime'])),
|
||||
endTime: _blank(asString(m['endTime'])),
|
||||
unlockStartTime: _blank(asString(m['unlockStartTime'])),
|
||||
unlockEndTime: _blank(asString(m['unlockEndTime'])),
|
||||
subZones: <SubFlyZone>[for (final Map<String, dynamic> s in asMapList(m['subZones'])) SubFlyZone.fromMap(s)],
|
||||
);
|
||||
|
||||
static String? _blank(String? s) => s == null || s.trim().isEmpty ? null : s.trim();
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
|
||||
/// `WARNING`, `ENHANCED_WARNING`, `AUTHORIZATION`, `RESTRICTED`, `UNKNOWN`.
|
||||
final String category;
|
||||
|
||||
/// `FlyZoneReason` name: `AIRPORT`, `MILITARY`, `PRISON`, …
|
||||
final String? reason;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final double? radius; // m
|
||||
final String? startTime;
|
||||
final String? endTime;
|
||||
final String? unlockStartTime;
|
||||
final String? unlockEndTime;
|
||||
final List<SubFlyZone> subZones;
|
||||
|
||||
bool get unlockable => category == 'AUTHORIZATION';
|
||||
|
||||
ZoneLevel get level => switch (category) {
|
||||
'RESTRICTED' => ZoneLevel.danger,
|
||||
'AUTHORIZATION' || 'ENHANCED_WARNING' => ZoneLevel.caution,
|
||||
'WARNING' => ZoneLevel.info,
|
||||
_ => ZoneLevel.none,
|
||||
};
|
||||
|
||||
String get categoryLabel => switch (category) {
|
||||
'RESTRICTED' => 'Restricted zone',
|
||||
'AUTHORIZATION' => 'Authorization zone',
|
||||
'ENHANCED_WARNING' => 'Enhanced warning zone',
|
||||
'WARNING' => 'Warning zone',
|
||||
_ => 'Fly zone',
|
||||
};
|
||||
|
||||
/// `COMMERCIAL_AIRPORTS` → `Commercial airports`.
|
||||
String? get reasonLabel {
|
||||
final String? r = reason;
|
||||
if (r == null || r == 'UNKNOWN') return null;
|
||||
final String s = r.replaceAll('_', ' ').toLowerCase();
|
||||
return s[0].toUpperCase() + s.substring(1);
|
||||
}
|
||||
|
||||
/// Metres from ([lat], [lon]) to the zone's centre, minus its radius (0 inside).
|
||||
double? distanceFrom(double lat, double lon) {
|
||||
if (latitude == null || longitude == null) return null;
|
||||
final double d = HudGeometry.distance(lat, lon, latitude!, longitude!) - (radius ?? 0);
|
||||
return d < 0 ? 0 : d;
|
||||
}
|
||||
}
|
||||
|
||||
/// One FlySafe notification: transient advice from DJI's GEO system.
|
||||
@immutable
|
||||
class FlySafeNotice {
|
||||
const FlySafeNotice({required this.kind, this.event, this.description, this.heightLimit, this.countdown, this.zoneIds = const <int>[], required this.at});
|
||||
|
||||
factory FlySafeNotice.fromMap(Map<String, dynamic> m, DateTime at) => FlySafeNotice(
|
||||
kind: asString(m['kind']) ?? 'UNKNOWN',
|
||||
event: asString(m['event']),
|
||||
description: (asString(m['description']) ?? '').trim(),
|
||||
heightLimit: asInt(m['heightLimit']),
|
||||
countdown: asInt(m['countdown']),
|
||||
zoneIds: <int>[if (m['zones'] is List) for (final Object? z in m['zones'] as List) if (z is int) z],
|
||||
at: at,
|
||||
);
|
||||
|
||||
/// `FLYING_SAFETY_TIP`, `WARNING`, `SERIOUS_WARNING`, `RETURN_TO_HOME`.
|
||||
final String kind;
|
||||
final String? event;
|
||||
final String? description;
|
||||
final int? heightLimit;
|
||||
final int? countdown;
|
||||
final List<int> zoneIds;
|
||||
final DateTime at;
|
||||
|
||||
ZoneLevel get level => switch (kind) {
|
||||
'SERIOUS_WARNING' => ZoneLevel.danger,
|
||||
'WARNING' || 'RETURN_TO_HOME' => ZoneLevel.caution,
|
||||
_ => ZoneLevel.info,
|
||||
};
|
||||
|
||||
/// A short title when the SDK's description is empty.
|
||||
String get title => switch (event) {
|
||||
'IN_NO_FLY_ZONE' => 'In a no-fly zone: the aircraft is landing',
|
||||
'IN_LIMIT_HEIGHT_AREA' => 'In a height-limited zone',
|
||||
'IN_AUTHORIZATION_ZONE_WITHOUT_UNLOCKING' => 'In an authorization zone that is not unlocked',
|
||||
'TAKE_OFF_FAILED_IN_NO_FLY_ZONE' || 'TAKE_OFF_FAILED_IN_NO_FLY_ZONE_WITHOUT_GPS' => 'Take-off refused: no-fly zone',
|
||||
'TAKE_OFF_FAILED_IN_AUTHORIZED_ZONE_WITHOUT_UNLOCKING' ||
|
||||
'TAKE_OFF_FAILED_IN_AUTHORIZED_AREA_WITHOUT_GPS_AND_UNLOCKING' =>
|
||||
'Take-off refused: unlock the authorization zone first',
|
||||
'NEAR_NO_FLY_ZONE' => 'Return to Home passes near a no-fly zone',
|
||||
'CROSS_NO_FLY_ZONE' => 'Return to Home would cross a no-fly zone',
|
||||
'TOUCH_NO_FLY_ZONE' => 'At the edge of a no-fly zone',
|
||||
'TOUCH_AUTHORIZATION_ZONE' => 'At the edge of an authorization zone',
|
||||
'REACH_MAXIMUM_FLIGHT_HEIGHT' => 'At the zone\'s height limit',
|
||||
'TAKE_OFF_NEAR_NO_FLY_ZONE' => 'Near a no-fly zone',
|
||||
'AUTHORIZED_ZONE_WITHOUT_UNLOCKING_NEARBY' || 'AUTHORIZED_ZONE_AND_RESTRICTIONS_NEARBY' => 'Authorization zone nearby',
|
||||
'IN_AUTHORIZATION_ZONE' || 'TAKE_OFF_FAILED_IN_AUTHORIZED_ZONE' => 'In an authorization zone',
|
||||
'LIMITED_RESTRICTIONS_NEARBY' || 'RESTRICTIONS_IN_LIMITED_FLY_ZONE_NEARBY' => 'Height-limited zone nearby',
|
||||
'TAKE_OFF_IN_ENHANCED_WARNING_ZONE' => 'In an enhanced warning zone',
|
||||
'TAKE_OFF_IN_WARNING_AREA' => 'In a warning zone',
|
||||
'TAKE_OFF_IN_HEIGHT_LIMIT_ZONE' || 'TAKE_OFF_IN_HEIGHT_LIMIT_ZONE_WITHOUT_GPS' => 'Taking off in a height-limited zone',
|
||||
'RTH_AFFECTED_AT_BOUNDARY_OF_GEO_ZONE' => 'Return to Home is limited by a fly zone',
|
||||
_ => kind == 'SERIOUS_WARNING' ? 'Fly zone warning' : 'Fly zone notice',
|
||||
};
|
||||
}
|
||||
|
||||
/// DJI GEO fly zones (plan P8): the aircraft's standing (`flyZone.state`), the
|
||||
/// latest FlySafe notification, the zones around, and which are unlocked.
|
||||
class FlyZoneState extends ChangeNotifier {
|
||||
/// `FlyZoneState` name, null until reported.
|
||||
String? state;
|
||||
|
||||
FlySafeNotice? notice;
|
||||
|
||||
List<FlyZone> zones = const <FlyZone>[];
|
||||
|
||||
/// Where [zones] were fetched around, and when.
|
||||
(double, double)? zonesAround;
|
||||
DateTime? zonesAt;
|
||||
|
||||
Set<int> unlocked = const <int>{};
|
||||
|
||||
/// A notice stays on screen this long (a serious one with a countdown, until replaced).
|
||||
static const Duration noticeLife = Duration(seconds: 15);
|
||||
|
||||
ZoneLevel get stateLevel => switch (state) {
|
||||
'IN_RESTRICTED_ZONE' || 'SUSPECTED_IN_RESTRICTED_ZONE' || 'PHONE_IN_RESTRICTED_ZONE' => ZoneLevel.danger,
|
||||
'IN_AUTHORIZED_ZONE' || 'SUSPECTED_IN_AUTHORIZED_ZONE' || 'IN_ENHANCE_WARNING_ZONE' || 'NEAR_RESTRICTED_ZONE' => ZoneLevel.caution,
|
||||
'IN_WARNING_ZONE' ||
|
||||
'IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' ||
|
||||
'SUSPECTED_IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' ||
|
||||
'PHONE_IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' ||
|
||||
'NEAR_HEIGHT_LIMITED_ZONE' ||
|
||||
'NEAR_AUTHORIZED_ZONE' ||
|
||||
'NEAR_AUTHORIZED_ZONE_WITH_LICENSE' ||
|
||||
'NEAR_MULTIPLE_TYPE_FLY_ZONE' ||
|
||||
'IN_AUTHORIZED_ZONE_WITH_LICENSE' =>
|
||||
ZoneLevel.info,
|
||||
_ => ZoneLevel.none,
|
||||
};
|
||||
|
||||
String get stateLabel => switch (state) {
|
||||
'IN_RESTRICTED_ZONE' || 'SUSPECTED_IN_RESTRICTED_ZONE' => 'In a restricted zone',
|
||||
'PHONE_IN_RESTRICTED_ZONE' => 'You are in a restricted zone',
|
||||
'NEAR_RESTRICTED_ZONE' => 'Near a restricted zone',
|
||||
'IN_AUTHORIZED_ZONE' || 'SUSPECTED_IN_AUTHORIZED_ZONE' => 'In an authorization zone',
|
||||
'IN_AUTHORIZED_ZONE_WITH_LICENSE' => 'In an unlocked authorization zone',
|
||||
'NEAR_AUTHORIZED_ZONE' || 'NEAR_AUTHORIZED_ZONE_WITH_LICENSE' => 'Near an authorization zone',
|
||||
'IN_ENHANCE_WARNING_ZONE' => 'In an enhanced warning zone',
|
||||
'IN_WARNING_ZONE' => 'In a warning zone',
|
||||
'IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' ||
|
||||
'SUSPECTED_IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' ||
|
||||
'PHONE_IN_WARNING_ZONE_WITH_HEIGHT_LIMITATION' =>
|
||||
'In a height-limited zone',
|
||||
'NEAR_HEIGHT_LIMITED_ZONE' => 'Near a height-limited zone',
|
||||
'NEAR_MULTIPLE_TYPE_FLY_ZONE' => 'Near several fly zones',
|
||||
_ => 'No fly zone around',
|
||||
};
|
||||
|
||||
/// The notice to show at [now]: fresh, or a serious one still counting down.
|
||||
FlySafeNotice? noticeAt(DateTime now) {
|
||||
final FlySafeNotice? n = notice;
|
||||
if (n == null) return null;
|
||||
final Duration age = now.difference(n.at);
|
||||
if (age <= noticeLife) return n;
|
||||
if (n.kind == 'SERIOUS_WARNING' && (n.countdown ?? 0) > age.inSeconds) return n;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The zone(s) a notice names, as loaded.
|
||||
List<FlyZone> zonesFor(FlySafeNotice n) => <FlyZone>[for (final FlyZone z in zones) if (n.zoneIds.contains(z.id)) z];
|
||||
|
||||
/// Zones nearest first from ([lat], [lon]).
|
||||
List<FlyZone> nearest(double? lat, double? lon) {
|
||||
if (lat == null || lon == null) return zones;
|
||||
return <FlyZone>[...zones]..sort((FlyZone a, FlyZone b) =>
|
||||
(a.distanceFrom(lat, lon) ?? double.infinity).compareTo(b.distanceFrom(lat, lon) ?? double.infinity));
|
||||
}
|
||||
|
||||
void applyState(Map<String, dynamic>? m) {
|
||||
state = asString(m?['state']);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void applyNotice(Map<String, dynamic> m, {DateTime? now}) {
|
||||
notice = FlySafeNotice.fromMap(m, now ?? DateTime.now());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void applyZones(List<Map<String, dynamic>> list, {required double latitude, required double longitude, DateTime? now}) {
|
||||
zones = <FlyZone>[for (final Map<String, dynamic> m in list) FlyZone.fromMap(m)];
|
||||
zonesAround = (latitude, longitude);
|
||||
zonesAt = now ?? DateTime.now();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void applyUnlocked(Iterable<int> ids) {
|
||||
unlocked = ids.toSet();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Whether a fresh fetch is due for a fix at ([lat], [lon]).
|
||||
bool needsZonesFor(double lat, double lon, {DateTime? now, double moveM = 2000, Duration maxAge = const Duration(minutes: 10)}) {
|
||||
final (double, double)? at = zonesAround;
|
||||
final DateTime? when = zonesAt;
|
||||
if (at == null || when == null) return true;
|
||||
if ((now ?? DateTime.now()).difference(when) > maxAge) return true;
|
||||
return HudGeometry.distance(at.$1, at.$2, lat, lon) > moveM;
|
||||
}
|
||||
|
||||
// ── Licences and the GEO database ─────────────────────────────────────────
|
||||
|
||||
/// Null until listed; false on aircraft without custom unlock zones.
|
||||
bool? licencesSupported;
|
||||
List<FlyZoneLicence> licences = const <FlyZoneLicence>[];
|
||||
|
||||
FlyZoneDatabase? database;
|
||||
|
||||
/// Basic database download, 0..1, while an upgrade runs; null otherwise.
|
||||
double? upgradeProgress;
|
||||
|
||||
FlyZoneLicence? get enabledLicence => licences.where((FlyZoneLicence l) => l.enabled).firstOrNull;
|
||||
|
||||
void applyLicences(Map<String, dynamic> m) {
|
||||
licencesSupported = asBool(m['supported']) ?? false;
|
||||
licences = <FlyZoneLicence>[for (final Map<String, dynamic> z in asMapList(m['zones'])) FlyZoneLicence.fromMap(z)]
|
||||
..sort((FlyZoneLicence a, FlyZoneLicence b) => a.index.compareTo(b.index));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void applyDatabase(Map<String, dynamic> m) {
|
||||
database = FlyZoneDatabase.fromMap(m);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// The SDK's progress float; read as a percentage when above 1. A complete
|
||||
/// download ends the upgrade (MSDK does not say whether the start call's
|
||||
/// completion waits for it).
|
||||
void applyUpgradeProgress(double? p) {
|
||||
final double? v = p == null ? null : (p > 1 ? p / 100 : p).clamp(0.0, 1.0);
|
||||
upgradeProgress = v != null && v >= 1 ? null : v;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Aircraft-bound parts only: the zones database is app-level and stays.
|
||||
void clearAircraft() {
|
||||
state = null;
|
||||
notice = null;
|
||||
unlocked = const <int>{};
|
||||
licencesSupported = null;
|
||||
licences = const <FlyZoneLicence>[];
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// One licence on the aircraft (`CustomUnlockZone`): a DJI-issued unlock for a
|
||||
/// circle, polygon, height or whole country, valid between two dates. One at a
|
||||
/// time is enabled.
|
||||
@immutable
|
||||
class FlyZoneLicence {
|
||||
const FlyZoneLicence({
|
||||
required this.id,
|
||||
this.index = 0,
|
||||
this.name = '',
|
||||
this.type = 'UNKNOWN',
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.radius,
|
||||
this.height,
|
||||
this.country,
|
||||
this.points = const <(double, double)>[],
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
this.expired = false,
|
||||
this.enabled = false,
|
||||
});
|
||||
|
||||
factory FlyZoneLicence.fromMap(Map<String, dynamic> m) => FlyZoneLicence(
|
||||
id: asInt(m['id']) ?? 0,
|
||||
index: asInt(m['index']) ?? 0,
|
||||
name: (asString(m['name']) ?? '').trim(),
|
||||
type: asString(m['type']) ?? 'UNKNOWN',
|
||||
latitude: finite(m['latitude']),
|
||||
longitude: finite(m['longitude']),
|
||||
radius: finite(m['radius']),
|
||||
height: asInt(m['height']),
|
||||
country: asString(m['country']),
|
||||
points: <(double, double)>[
|
||||
if (m['points'] is List)
|
||||
for (final Object? v in m['points'] as List)
|
||||
if (v is List && v.length == 2 && finite(v[0]) != null && finite(v[1]) != null) (finite(v[0])!, finite(v[1])!),
|
||||
],
|
||||
startTime: FlyZone._blank(asString(m['startTime'])),
|
||||
endTime: FlyZone._blank(asString(m['endTime'])),
|
||||
expired: asBool(m['expired']) ?? false,
|
||||
enabled: asBool(m['enabled']) ?? false,
|
||||
);
|
||||
|
||||
final int id;
|
||||
final int index;
|
||||
final String name;
|
||||
|
||||
/// `UnlockZoneType`: `GEO_UNLOCK`, `CIRCLE_UNLOCK_AREA`, `COUNTRY_UNLOCK`, `HEIGHT_UNLOCK`, `PENTAGON_UNLOCK`.
|
||||
final String type;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final double? radius; // m
|
||||
final int? height; // m, for height unlocks
|
||||
final String? country;
|
||||
final List<(double, double)> points;
|
||||
final String? startTime;
|
||||
final String? endTime;
|
||||
final bool expired;
|
||||
final bool enabled;
|
||||
|
||||
String get typeLabel => switch (type) {
|
||||
'GEO_UNLOCK' => 'GEO zone unlock',
|
||||
'CIRCLE_UNLOCK_AREA' => 'Circle unlock',
|
||||
'PENTAGON_UNLOCK' => 'Area unlock',
|
||||
'COUNTRY_UNLOCK' => 'Country unlock',
|
||||
'HEIGHT_UNLOCK' => 'Height unlock',
|
||||
_ => 'Licence',
|
||||
};
|
||||
|
||||
/// Drawable on the map: a circle with a centre and radius, or a polygon.
|
||||
bool get hasArea => points.length >= 3 || (latitude != null && longitude != null && (radius ?? 0) > 0);
|
||||
|
||||
String get validity => <String>[if (startTime != null) startTime!, if (endTime != null) endTime!].join(' → ');
|
||||
}
|
||||
|
||||
/// The GEO database's versions and state (`FlyZoneDatabaseState` names).
|
||||
@immutable
|
||||
class FlyZoneDatabase {
|
||||
const FlyZoneDatabase({this.basicVersion, this.basicState, this.preciseVersion, this.preciseState});
|
||||
|
||||
factory FlyZoneDatabase.fromMap(Map<String, dynamic> m) => FlyZoneDatabase(
|
||||
basicVersion: asString(m['basicVersion']),
|
||||
basicState: asString(m['basicState']),
|
||||
preciseVersion: asString(m['preciseVersion']),
|
||||
preciseState: asString(m['preciseState']),
|
||||
);
|
||||
|
||||
final String? basicVersion;
|
||||
final String? basicState; // INITIALIZING / NO_INTERNET_CONNECTION / OUT_OF_DATE / UP_TO_DATE / UNKNOWN
|
||||
final String? preciseVersion;
|
||||
final String? preciseState;
|
||||
|
||||
bool get outOfDate => basicState == 'OUT_OF_DATE' || preciseState == 'OUT_OF_DATE';
|
||||
|
||||
static String stateLabel(String? s) => switch (s) {
|
||||
'UP_TO_DATE' => 'Up to date',
|
||||
'OUT_OF_DATE' => 'Out of date',
|
||||
'NO_INTERNET_CONNECTION' => 'No internet connection',
|
||||
'INITIALIZING' => 'Initializing',
|
||||
_ => 'Unknown',
|
||||
};
|
||||
}
|
||||
@@ -56,13 +56,70 @@ class VisionReading {
|
||||
bool get dangerous => systemWarning == 'DANGEROUS';
|
||||
}
|
||||
|
||||
/// Omnidirectional perception (Mavic 2-class): one distance per [angleInterval]
|
||||
/// degrees clockwise from the nose, plus straight up and down. Null distances
|
||||
/// saw nothing in range.
|
||||
@immutable
|
||||
class Perception {
|
||||
const Perception({required this.angleInterval, this.distances = const <double?>[], this.up, this.down});
|
||||
|
||||
factory Perception.fromMap(Map<String, dynamic> m) => Perception(
|
||||
angleInterval: finite(m['angleInterval']) ?? 0,
|
||||
distances: <double?>[if (m['distances'] is List) for (final Object? d in m['distances'] as List) finite(d)],
|
||||
up: finite(m['up']),
|
||||
down: finite(m['down']),
|
||||
);
|
||||
|
||||
final double angleInterval; // deg
|
||||
final List<double?> distances; // m
|
||||
final double? up;
|
||||
final double? down;
|
||||
}
|
||||
|
||||
/// One obstacle-sensing direction's health (`ObstacleAvoidanceSensorState`).
|
||||
@immutable
|
||||
class SensorHealth {
|
||||
const SensorHealth({required this.working, required this.enabled});
|
||||
|
||||
final bool working;
|
||||
final bool enabled;
|
||||
|
||||
/// Switched on but not seeing: covered, dirty or too dark.
|
||||
bool get blocked => enabled && !working;
|
||||
}
|
||||
|
||||
/// Obstacle sensing per sensor position. Fed from the `vision` group of the
|
||||
/// telemetry tick; empty on aircraft without obstacle sensing.
|
||||
class VisionState extends ChangeNotifier {
|
||||
Map<String, VisionReading> sensors = const <String, VisionReading>{};
|
||||
|
||||
/// Omni perception, on the aircraft that report it (`perception` group).
|
||||
Perception? perception;
|
||||
|
||||
/// Per-direction sensor health, keyed `forward` / `backward` / `left` /
|
||||
/// `right` / `up` / `down` (`obstacleSensors` group).
|
||||
Map<String, SensorHealth> health = const <String, SensorHealth>{};
|
||||
|
||||
DateTime? updatedAt;
|
||||
|
||||
void applyPerception(Map<String, dynamic>? m) {
|
||||
perception = m == null ? null : Perception.fromMap(m);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void applyHealth(Map<String, dynamic>? m) {
|
||||
health = <String, SensorHealth>{
|
||||
if (m != null)
|
||||
for (final MapEntry<String, dynamic> e in m.entries)
|
||||
if (e.value is Map)
|
||||
e.key: SensorHealth(
|
||||
working: asBool((e.value as Map)['working']) ?? true,
|
||||
enabled: asBool((e.value as Map)['enabled']) ?? true,
|
||||
),
|
||||
};
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
VisionReading? get nose => sensors['NOSE'];
|
||||
VisionReading? get tail => sensors['TAIL'];
|
||||
|
||||
@@ -108,6 +165,8 @@ class VisionState extends ChangeNotifier {
|
||||
|
||||
void clear() {
|
||||
sensors = const <String, VisionReading>{};
|
||||
perception = null;
|
||||
health = const <String, SensorHealth>{};
|
||||
updatedAt = null;
|
||||
palmControl = null;
|
||||
notifyListeners();
|
||||
|
||||
@@ -10,7 +10,8 @@ import 'state/parse.dart';
|
||||
/// A `telemetry` event is the native [TelemetryHub]'s full snapshot: the flight
|
||||
/// controller's flat keys plus one nested group per subsystem (`battery`,
|
||||
/// `links`, `rc`, `storage`, `ssd`, `thermal`, `cameraSettings`, `gimbal`,
|
||||
/// `gimbalSettings`, `vision`, `calibration`). It arrives at
|
||||
/// `gimbalSettings`, `vision`, `calibration`, `perception`, `obstacleSensors`,
|
||||
/// `airSense`, `airSenseAvoidance`, `flyZone`). It arrives at
|
||||
/// up to 10 Hz with the slow groups repeated unchanged between their 2 Hz
|
||||
/// refreshes, so each group is compared with the last one seen and its
|
||||
/// sub-model only notified when it actually moved. A group missing from the
|
||||
@@ -22,7 +23,7 @@ class TelemetryReducer {
|
||||
|
||||
final Map<String, Object?> _lastGroups = <String, Object?>{};
|
||||
|
||||
static const List<String> _groups = <String>['battery', 'links', 'rc', 'storage', 'ssd', 'thermal', 'cameraSettings', 'gimbal', 'gimbalSettings', 'vision', 'calibration', 'palmControl'];
|
||||
static const List<String> _groups = <String>['battery', 'links', 'rc', 'storage', 'ssd', 'thermal', 'cameraSettings', 'gimbal', 'gimbalSettings', 'vision', 'calibration', 'palmControl', 'perception', 'obstacleSensors', 'airSense', 'airSenseAvoidance', 'flyZone'];
|
||||
|
||||
/// Applies [event] if it is one of the telemetry types; returns whether it was.
|
||||
bool apply(Map<String, dynamic> event) {
|
||||
@@ -50,6 +51,13 @@ class TelemetryReducer {
|
||||
case 'mission':
|
||||
model.mission.apply(event);
|
||||
return true;
|
||||
case 'flySafe':
|
||||
// The GEO system is app-level: its notices count with or without an aircraft.
|
||||
model.flyZones.applyNotice(event);
|
||||
return true;
|
||||
case 'flyZoneDbProgress':
|
||||
model.flyZones.applyUpgradeProgress(finite(event['progress']));
|
||||
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());
|
||||
@@ -97,5 +105,10 @@ class TelemetryReducer {
|
||||
if (changed.contains('vision')) model.vision.apply(asMap(t['vision']));
|
||||
if (changed.contains('calibration')) model.calibration.apply(asMap(t['calibration']));
|
||||
if (changed.contains('palmControl')) model.vision.applyPalm(asString(t['palmControl']));
|
||||
if (changed.contains('perception')) model.vision.applyPerception(asMap(t['perception']));
|
||||
if (changed.contains('obstacleSensors')) model.vision.applyHealth(asMap(t['obstacleSensors']));
|
||||
if (changed.contains('airSense')) model.airSense.apply(asMap(t['airSense']));
|
||||
if (changed.contains('airSenseAvoidance')) model.airSense.applyAvoidance(asMap(t['airSenseAvoidance']));
|
||||
if (changed.contains('flyZone')) model.flyZones.applyState(asMap(t['flyZone']));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' show Offset;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Which stick does what, as on the remote (RC `AircraftMappingStyle`).
|
||||
enum StickMode {
|
||||
/// Left: forward/back + yaw; right: throttle + sideways.
|
||||
mode1('Mode 1'),
|
||||
|
||||
/// Left: throttle + yaw; right: forward/back + sideways. DJI's default.
|
||||
mode2('Mode 2'),
|
||||
|
||||
/// Left: forward/back + sideways; right: throttle + yaw.
|
||||
mode3('Mode 3');
|
||||
|
||||
const StickMode(this.label);
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// What a full stick deflection means.
|
||||
@immutable
|
||||
class StickLimits {
|
||||
const StickLimits({this.horizontal = 5, this.vertical = 2, this.yaw = 60});
|
||||
|
||||
/// GO 4's Wi-Fi joystick default is gentle; these are a step below P mode.
|
||||
static const StickLimits normal = StickLimits();
|
||||
static const StickLimits gentle = StickLimits(horizontal: 2, vertical: 1, yaw: 30);
|
||||
|
||||
final double horizontal; // m/s
|
||||
final double vertical; // m/s
|
||||
final double yaw; // °/s
|
||||
}
|
||||
|
||||
/// One packet for the native stick loop: body-frame velocities and a yaw rate.
|
||||
@immutable
|
||||
class StickCommand {
|
||||
const StickCommand({this.forward = 0, this.right = 0, this.yaw = 0, this.up = 0});
|
||||
|
||||
static const StickCommand zero = StickCommand();
|
||||
|
||||
final double forward; // m/s
|
||||
final double right; // m/s
|
||||
final double yaw; // °/s, clockwise
|
||||
final double up; // m/s
|
||||
|
||||
bool get isZero => forward == 0 && right == 0 && yaw == 0 && up == 0;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is StickCommand && other.forward == forward && other.right == right && other.yaw == yaw && other.up == up;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(forward, right, yaw, up);
|
||||
|
||||
@override
|
||||
String toString() => 'StickCommand(f ${forward.toStringAsFixed(2)}, r ${right.toStringAsFixed(2)}, '
|
||||
'yaw ${yaw.toStringAsFixed(1)}, up ${up.toStringAsFixed(2)})';
|
||||
}
|
||||
|
||||
/// Stick positions → [StickCommand] (plan P8). Pure, so the mapping is unit-tested.
|
||||
///
|
||||
/// Positions are −1…1 with x to the right and **y up** (push = +1).
|
||||
abstract final class StickMapper {
|
||||
/// Below this deflection the stick counts as centred (thumbs never rest at 0).
|
||||
static const double deadzone = 0.08;
|
||||
|
||||
/// Share of the cubic term: small deflections are finer than a linear map.
|
||||
static const double expo = 0.4;
|
||||
|
||||
/// The curve one axis goes through: deadzone, rescale, expo.
|
||||
static double shape(double v) {
|
||||
final double a = v.abs().clamp(0.0, 1.0);
|
||||
if (a <= deadzone) return 0;
|
||||
final double x = (a - deadzone) / (1 - deadzone);
|
||||
return (expo * x * x * x + (1 - expo) * x) * v.sign;
|
||||
}
|
||||
|
||||
static StickCommand command(Offset left, Offset right, {StickMode mode = StickMode.mode2, StickLimits limits = StickLimits.normal}) {
|
||||
final (double throttle, double yaw, double pitch, double roll) = switch (mode) {
|
||||
StickMode.mode1 => (right.dy, left.dx, left.dy, right.dx),
|
||||
StickMode.mode2 => (left.dy, left.dx, right.dy, right.dx),
|
||||
StickMode.mode3 => (right.dy, right.dx, left.dy, left.dx),
|
||||
};
|
||||
double r(double v) => v == 0 ? 0 : v; // no −0.0 in packets or tests
|
||||
return StickCommand(
|
||||
forward: r(shape(pitch) * limits.horizontal),
|
||||
right: r(shape(roll) * limits.horizontal),
|
||||
yaw: r(shape(yaw) * limits.yaw),
|
||||
up: r(shape(throttle) * limits.vertical),
|
||||
);
|
||||
}
|
||||
|
||||
/// Clamps a finger's offset from the pad centre into the unit circle, y up.
|
||||
static Offset normalise(Offset fromCentre, double radius) {
|
||||
if (radius <= 0) return Offset.zero;
|
||||
Offset v = Offset(fromCentre.dx / radius, -fromCentre.dy / radius);
|
||||
final double len = v.distance;
|
||||
if (len > 1) v = v / len;
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
/// The on-screen sticks' state while they fly the aircraft.
|
||||
///
|
||||
/// [setLeft] / [setRight] send at once when the command changes, and a
|
||||
/// [heartbeat] timer repeats it while a stick is off centre — the native loop
|
||||
/// hovers once a command is 300 ms old, so a held stick must keep saying so.
|
||||
/// Centred sticks send one zero and go quiet.
|
||||
///
|
||||
/// [open] is what the pilot asked for; the aircraft's own answer
|
||||
/// (`virtualStickEnabled` in the telemetry) closes it through [onAircraft] when
|
||||
/// the remote's mode switch or RTH takes over.
|
||||
class VirtualStickController extends ChangeNotifier {
|
||||
VirtualStickController({required this.send, this.heartbeat = const Duration(milliseconds: 100)});
|
||||
|
||||
/// Delivers one command to the bridge.
|
||||
final Future<void> Function(StickCommand c) send;
|
||||
final Duration heartbeat;
|
||||
|
||||
StickMode mode = StickMode.mode2;
|
||||
StickLimits limits = StickLimits.normal;
|
||||
|
||||
Offset left = Offset.zero;
|
||||
Offset right = Offset.zero;
|
||||
|
||||
/// The sticks are on and flying the aircraft.
|
||||
bool open = false;
|
||||
|
||||
/// Why the sticks last closed without the pilot asking, for one snackbar.
|
||||
String? closedReason;
|
||||
|
||||
/// The aircraft confirmed virtual-stick mode since [open] turned on.
|
||||
bool _confirmed = false;
|
||||
|
||||
StickCommand _last = StickCommand.zero;
|
||||
Timer? _beat;
|
||||
|
||||
StickCommand get command => StickMapper.command(left, right, mode: mode, limits: limits);
|
||||
|
||||
void setLeft(Offset v) {
|
||||
left = v;
|
||||
_changed();
|
||||
}
|
||||
|
||||
void setRight(Offset v) {
|
||||
right = v;
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// Both sticks back to centre (a finger lifted off a pad does this too).
|
||||
void centre() {
|
||||
left = right = Offset.zero;
|
||||
_changed();
|
||||
}
|
||||
|
||||
void _changed() {
|
||||
notifyListeners();
|
||||
if (!open) return;
|
||||
final StickCommand c = command;
|
||||
if (c != _last) _emit(c);
|
||||
if (c.isZero) {
|
||||
_beat?.cancel();
|
||||
_beat = null;
|
||||
} else {
|
||||
_beat ??= Timer.periodic(heartbeat, (_) => _emit(command));
|
||||
}
|
||||
}
|
||||
|
||||
void _emit(StickCommand c) {
|
||||
_last = c;
|
||||
unawaited(send(c).catchError((Object _) {}));
|
||||
}
|
||||
|
||||
/// The aircraft entered virtual-stick mode.
|
||||
void opened() {
|
||||
open = true;
|
||||
_confirmed = false;
|
||||
closedReason = null;
|
||||
left = right = Offset.zero;
|
||||
_last = StickCommand.zero;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Stops sending; [reason] is shown when the aircraft, not the pilot, ended it.
|
||||
void close([String? reason]) {
|
||||
_beat?.cancel();
|
||||
_beat = null;
|
||||
final bool wasOpen = open;
|
||||
open = false;
|
||||
left = right = Offset.zero;
|
||||
_last = StickCommand.zero;
|
||||
if (wasOpen) closedReason = reason;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Follows the flight controller: once it has confirmed the mode, a report
|
||||
/// that it left (remote mode switch), or an RTH / landing, closes the sticks.
|
||||
void onAircraft({required bool? enabled, required bool goingHome, required bool landing}) {
|
||||
if (!open) return;
|
||||
if (enabled == true) _confirmed = true;
|
||||
if (_confirmed && enabled == false) {
|
||||
close('The remote took back control');
|
||||
} else if (goingHome) {
|
||||
close('Return to Home took over');
|
||||
} else if (landing) {
|
||||
close('The aircraft is landing');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_beat?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'state/vision_state.dart';
|
||||
|
||||
/// The four horizontal radar sides, clockwise from the nose.
|
||||
enum RadarSide {
|
||||
front('Front', 'NOSE', 'forward', 0, 60),
|
||||
right('Right', 'RIGHT', 'right', 90, 40),
|
||||
back('Rear', 'TAIL', 'backward', 180, 60),
|
||||
left('Left', 'LEFT', 'left', 270, 40);
|
||||
|
||||
const RadarSide(this.label, this.position, this.healthKey, this.bearing, this.span);
|
||||
|
||||
final String label;
|
||||
|
||||
/// `VisionDetectionState` position name.
|
||||
final String position;
|
||||
|
||||
/// `obstacleSensors` key.
|
||||
final String healthKey;
|
||||
|
||||
/// Centre of the side, degrees clockwise from the nose.
|
||||
final double bearing;
|
||||
|
||||
/// Arc the side covers on the radar, degrees.
|
||||
final double span;
|
||||
}
|
||||
|
||||
/// How alarming a sector is, for its colour.
|
||||
enum RadarBand { none, notice, caution, danger }
|
||||
|
||||
@immutable
|
||||
class RadarSector {
|
||||
const RadarSector({required this.start, required this.sweep, this.distance, this.level = 0});
|
||||
|
||||
/// Degrees clockwise from the nose.
|
||||
final double start;
|
||||
final double sweep;
|
||||
final double? distance; // m
|
||||
/// 0 (nothing / invalid) … 6 (very close), as MSDK's `ObstacleDetectionSectorWarning`.
|
||||
final int level;
|
||||
|
||||
RadarBand get band => VisionRadar.bandFor(level);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class RadarSideView {
|
||||
const RadarSideView({required this.side, this.sectors = const <RadarSector>[], this.closest, this.blocked = false, this.off = false});
|
||||
|
||||
final RadarSide side;
|
||||
final List<RadarSector> sectors;
|
||||
final double? closest; // m
|
||||
/// Switched on but not seeing (covered, dirty, too dark).
|
||||
final bool blocked;
|
||||
|
||||
/// Obstacle sensing for this side is switched off.
|
||||
final bool off;
|
||||
|
||||
int get level => sectors.fold(0, (int w, RadarSector s) => s.level > w ? s.level : w);
|
||||
RadarBand get band => VisionRadar.bandFor(level);
|
||||
}
|
||||
|
||||
/// What the vision radar overlay (plan P8, GO 4's `vision_radar_view`) draws:
|
||||
/// per side, sectors graded by distance, the nearest distance, and whether the
|
||||
/// side's sensors are blocked; plus the obstacle overhead.
|
||||
///
|
||||
/// Pure: built from [VisionState] so the grading is unit-tested without a
|
||||
/// painter. Each side prefers the aircraft's own `VisionDetectionState`
|
||||
/// sectors (and their warning levels); a side that has none is filled from
|
||||
/// omni perception's distance ring, graded by [levelFor].
|
||||
@immutable
|
||||
class VisionRadar {
|
||||
const VisionRadar({this.sides = const <RadarSide, RadarSideView>{}, this.up});
|
||||
|
||||
/// Obstacles further than this are not drawn.
|
||||
static const double range = 16;
|
||||
|
||||
/// Above this the overhead distance is not shown.
|
||||
static const double upRange = 10;
|
||||
|
||||
/// Sectors a perception-filled side is split into.
|
||||
static const int perceptionSectors = 4;
|
||||
|
||||
final Map<RadarSide, RadarSideView> sides;
|
||||
|
||||
/// Overhead obstacle, m, when within [upRange].
|
||||
final double? up;
|
||||
|
||||
bool get isEmpty => sides.values.every((RadarSideView s) => s.sectors.every((RadarSector x) => x.level == 0) && !s.blocked) && up == null;
|
||||
|
||||
int get level => sides.values.fold(0, (int w, RadarSideView s) => s.level > w ? s.level : w);
|
||||
|
||||
List<RadarSide> get blocked => <RadarSide>[for (final RadarSideView s in sides.values) if (s.blocked) s.side];
|
||||
|
||||
static RadarBand bandFor(int level) => level >= 5
|
||||
? RadarBand.danger
|
||||
: level >= 3
|
||||
? RadarBand.caution
|
||||
: level >= 1
|
||||
? RadarBand.notice
|
||||
: RadarBand.none;
|
||||
|
||||
/// A warning level for a bare distance (perception reports no level).
|
||||
static int levelFor(double? d) {
|
||||
if (d == null || d > range) return 0;
|
||||
if (d <= 1.5) return 6;
|
||||
if (d <= 2.5) return 5;
|
||||
if (d <= 4) return 4;
|
||||
if (d <= 6) return 3;
|
||||
if (d <= 10) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int _sdkLevel(VisionSector s) => s.distance != null && s.distance! > range ? 0 : s.level;
|
||||
|
||||
static VisionRadar of(VisionState v) {
|
||||
final Map<RadarSide, RadarSideView> sides = <RadarSide, RadarSideView>{};
|
||||
final Perception? p = v.perception;
|
||||
for (final RadarSide side in RadarSide.values) {
|
||||
final VisionReading? r = v.sensors[side.position];
|
||||
final SensorHealth? h = v.health[side.healthKey];
|
||||
List<RadarSector> sectors = const <RadarSector>[];
|
||||
if (r != null && r.sectors.isNotEmpty) {
|
||||
// Sectors run left to right as the sensor sees them: clockwise, whichever way it faces.
|
||||
final double sweep = side.span / r.sectors.length;
|
||||
final double start = side.bearing - side.span / 2;
|
||||
sectors = <RadarSector>[
|
||||
for (int i = 0; i < r.sectors.length; i++)
|
||||
RadarSector(start: start + i * sweep, sweep: sweep, distance: r.sectors[i].distance, level: _sdkLevel(r.sectors[i])),
|
||||
];
|
||||
} else if (p != null && p.angleInterval > 0 && p.distances.isNotEmpty) {
|
||||
sectors = _fromPerception(side, p);
|
||||
}
|
||||
final bool off = r?.isDisabled == true || (h != null && !h.enabled);
|
||||
final bool blocked = !off && (h?.blocked ?? false);
|
||||
if (sectors.isEmpty && !blocked && !off && r == null) continue;
|
||||
double? closest;
|
||||
for (final RadarSector s in sectors) {
|
||||
final double? d = s.distance;
|
||||
if (s.level > 0 && d != null && (closest == null || d < closest)) closest = d;
|
||||
}
|
||||
sides[side] = RadarSideView(side: side, sectors: sectors, closest: closest, blocked: blocked, off: off);
|
||||
}
|
||||
final double? up = p?.up;
|
||||
return VisionRadar(sides: sides, up: up != null && up <= upRange ? up : null);
|
||||
}
|
||||
|
||||
/// Splits [side]'s arc into [perceptionSectors] and takes the nearest ring
|
||||
/// sample in each.
|
||||
static List<RadarSector> _fromPerception(RadarSide side, Perception p) {
|
||||
final int n = p.distances.length;
|
||||
final double sweep = side.span / perceptionSectors;
|
||||
final double start = side.bearing - side.span / 2;
|
||||
return <RadarSector>[
|
||||
for (int i = 0; i < perceptionSectors; i++)
|
||||
() {
|
||||
double? nearest;
|
||||
final double a0 = start + i * sweep, a1 = a0 + sweep;
|
||||
for (int k = 0; k < n; k++) {
|
||||
final double? d = p.distances[k];
|
||||
if (d == null || !_within(k * p.angleInterval, a0, a1)) continue;
|
||||
if (nearest == null || d < nearest) nearest = d;
|
||||
}
|
||||
return RadarSector(start: a0, sweep: sweep, distance: nearest, level: levelFor(nearest));
|
||||
}(),
|
||||
];
|
||||
}
|
||||
|
||||
/// Whether bearing [a] lies in [a0, a1), all in degrees, wrapping at 360.
|
||||
static bool _within(double a, double a0, double a1) {
|
||||
double norm(double x) => ((x % 360) + 360) % 360;
|
||||
final double x = norm(a - a0);
|
||||
return x < a1 - a0;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart' show listEquals;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../flight_model.dart';
|
||||
import '../../../fly/hud_geometry.dart';
|
||||
import '../../../fly/state/aircraft_state.dart';
|
||||
import '../../../fly/vision_radar.dart';
|
||||
import '../../../theme.dart';
|
||||
import '../hud_kit.dart';
|
||||
|
||||
@@ -12,6 +14,10 @@ import '../hud_kit.dart';
|
||||
/// roll, with a north-up radar on top — the aircraft arrow in the middle
|
||||
/// pointing along its heading, the home point and the pilot (phone GPS)
|
||||
/// plotted by bearing and distance, and a tick on the rim for the gimbal's yaw.
|
||||
///
|
||||
/// Plan P8 adds GO 4's `DJIVisualObstacleRadarView` ring: the vision radar's
|
||||
/// graded sectors drawn on the rim, turned with the heading (the ball is
|
||||
/// north-up, the sensors are not).
|
||||
class AttitudeBall extends StatelessWidget {
|
||||
const AttitudeBall({super.key, required this.model, this.size = 96});
|
||||
|
||||
@@ -21,13 +27,14 @@ class AttitudeBall extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge(<Listenable>[model.aircraft, model.gimbal]),
|
||||
listenable: Listenable.merge(<Listenable>[model.aircraft, model.gimbal, model.vision]),
|
||||
builder: (BuildContext context, _) {
|
||||
final AttitudeBallData d = AttitudeBallData.from(
|
||||
model.aircraft,
|
||||
gimbalYaw: model.gimbal.yaw,
|
||||
phoneLat: model.phoneLatitude,
|
||||
phoneLon: model.phoneLongitude,
|
||||
radar: VisionRadar.of(model.vision),
|
||||
);
|
||||
return SizedBox(
|
||||
width: size,
|
||||
@@ -43,6 +50,9 @@ class AttitudeBall extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// One graded radar sector on the ball's rim.
|
||||
typedef BallObstacle = ({double start, double sweep, RadarBand band});
|
||||
|
||||
/// Everything the painter needs, resolved from the models so it can be compared
|
||||
/// for [CustomPainter.shouldRepaint] and built in tests.
|
||||
@immutable
|
||||
@@ -57,10 +67,11 @@ class AttitudeBallData {
|
||||
this.pilotBearing,
|
||||
this.pilotDistance,
|
||||
this.rangeM = 100,
|
||||
this.obstacles = const <BallObstacle>[],
|
||||
});
|
||||
|
||||
/// Bearings and distances are taken from the aircraft to each point.
|
||||
factory AttitudeBallData.from(AircraftState a, {double? gimbalYaw, double? phoneLat, double? phoneLon}) {
|
||||
factory AttitudeBallData.from(AircraftState a, {double? gimbalYaw, double? phoneLat, double? phoneLon, VisionRadar? radar}) {
|
||||
final double? lat = a.latitude, lon = a.longitude;
|
||||
final bool fix = lat != null && lon != null && !(lat == 0 && lon == 0);
|
||||
double? hb, hd, pb, pd;
|
||||
@@ -82,9 +93,18 @@ class AttitudeBallData {
|
||||
pilotBearing: pb,
|
||||
pilotDistance: pd,
|
||||
rangeM: HudGeometry.ballRange(math.max(hd ?? 0, pd ?? 0)),
|
||||
obstacles: <BallObstacle>[
|
||||
for (final RadarSideView side in radar?.sides.values ?? const <RadarSideView>[])
|
||||
for (final RadarSector s in side.sectors)
|
||||
if (s.band != RadarBand.none) (start: s.start, sweep: s.sweep, band: s.band),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Graded radar sectors, degrees clockwise from the *nose*; the painter
|
||||
/// turns them by [heading].
|
||||
final List<BallObstacle> obstacles;
|
||||
|
||||
final double? pitch;
|
||||
final double? roll;
|
||||
final double? heading;
|
||||
@@ -108,11 +128,12 @@ class AttitudeBallData {
|
||||
other.homeDistance == homeDistance &&
|
||||
other.pilotBearing == pilotBearing &&
|
||||
other.pilotDistance == pilotDistance &&
|
||||
other.rangeM == rangeM;
|
||||
other.rangeM == rangeM &&
|
||||
listEquals(other.obstacles, obstacles);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(pitch, roll, heading, gimbalYaw, homeBearing, homeDistance, pilotBearing, pilotDistance, rangeM);
|
||||
Object.hash(pitch, roll, heading, gimbalYaw, homeBearing, homeDistance, pilotBearing, pilotDistance, rangeM, Object.hashAll(obstacles));
|
||||
}
|
||||
|
||||
class AttitudeBallPainter extends CustomPainter {
|
||||
@@ -154,6 +175,21 @@ class AttitudeBallPainter extends CustomPainter {
|
||||
..color = const Color(0x33EAF0FA));
|
||||
_label(canvas, 'N', c + Offset(0, -r + 7), Glass.ink, 8);
|
||||
|
||||
// ── Obstacle ring (plan P8): sensor sectors turned by the heading ──────
|
||||
final double heading = d.heading ?? 0;
|
||||
for (final BallObstacle o in d.obstacles) {
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: c, radius: r - 2.5),
|
||||
(heading + o.start + 0.5 - 90) * math.pi / 180,
|
||||
(o.sweep - 1) * math.pi / 180,
|
||||
false,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = o.band == RadarBand.danger ? 4 : 3
|
||||
..color = RadarColors.of(o.band),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Gimbal yaw tick (on the rim) ───────────────────────────────────────
|
||||
final double? gy = d.gimbalYaw;
|
||||
if (gy != null) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/gestures.dart' show DragStartBehavior;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../fly/virtual_sticks.dart';
|
||||
import '../../../theme.dart';
|
||||
import '../hud_kit.dart';
|
||||
|
||||
/// What each pad's axes do in [mode], for the captions under the pads.
|
||||
({String left, String right}) stickCaptions(StickMode mode) => switch (mode) {
|
||||
StickMode.mode1 => (left: 'FWD · YAW', right: 'UP · SIDE'),
|
||||
StickMode.mode2 => (left: 'UP · YAW', right: 'FWD · SIDE'),
|
||||
StickMode.mode3 => (left: 'FWD · SIDE', right: 'UP · YAW'),
|
||||
};
|
||||
|
||||
/// One on-screen stick (plan P8): a ring the thumb drags a knob inside,
|
||||
/// springing back to centre on release like a remote's stick. Reports −1…1
|
||||
/// with y up; the drag starts where the finger lands, not after the slop.
|
||||
class StickPad extends StatefulWidget {
|
||||
const StickPad({super.key, required this.value, required this.onChanged, required this.caption, this.size = 110});
|
||||
|
||||
final Offset value;
|
||||
final ValueChanged<Offset> onChanged;
|
||||
final String caption;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
State<StickPad> createState() => _StickPadState();
|
||||
}
|
||||
|
||||
class _StickPadState extends State<StickPad> {
|
||||
double get _radius => widget.size / 2 - 14;
|
||||
|
||||
void _to(Offset local) {
|
||||
final Offset centre = Offset(widget.size / 2, widget.size / 2);
|
||||
widget.onChanged(StickMapper.normalise(local - centre, _radius));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Offset v = widget.value;
|
||||
final Offset knob = Offset(v.dx * _radius, -v.dy * _radius);
|
||||
final bool active = v != Offset.zero;
|
||||
return Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
dragStartBehavior: DragStartBehavior.down,
|
||||
onPanStart: (DragStartDetails d) => _to(d.localPosition),
|
||||
onPanUpdate: (DragUpdateDetails d) => _to(d.localPosition),
|
||||
onPanEnd: (_) => widget.onChanged(Offset.zero),
|
||||
onPanCancel: () => widget.onChanged(Offset.zero),
|
||||
child: SizedBox.square(
|
||||
dimension: widget.size,
|
||||
child: Stack(alignment: Alignment.center, children: <Widget>[
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Glass.pill,
|
||||
border: Border.all(color: active ? Glass.accent : const Color(0x4DEAF0FA), width: 1.5),
|
||||
),
|
||||
),
|
||||
// Cross-hair: the stick's two axes.
|
||||
Container(width: 1, height: widget.size - 28, color: Glass.hairline),
|
||||
Container(width: widget.size - 28, height: 1, color: Glass.hairline),
|
||||
Transform.translate(
|
||||
offset: knob,
|
||||
child: Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: active ? Glass.accent : const Color(0xCC253552),
|
||||
border: Border.all(color: const Color(0x66FFFFFF)),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(widget.caption, style: HudText.label.copyWith(fontSize: 8)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The rail's STICKS button: lit while the on-screen sticks fly the aircraft.
|
||||
class SticksButton extends StatelessWidget {
|
||||
const SticksButton({super.key, required this.open, required this.onTap});
|
||||
|
||||
final bool open;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GlassPill(
|
||||
key: const ValueKey<String>('rail-sticks'),
|
||||
height: 24,
|
||||
color: open ? Glass.accent : Glass.pillStrong,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
onTap: onTap,
|
||||
child: Text('STICKS', style: HudText.label.copyWith(fontSize: 8, color: Glass.ink, fontWeight: FontWeight.w700)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart' show LatLng;
|
||||
import '../../dji_service.dart';
|
||||
import '../../flight_model.dart';
|
||||
import '../../fly/flight_trail.dart';
|
||||
import '../../fly/fly_zone_loader.dart';
|
||||
import '../../fly/fly_prefs.dart';
|
||||
import '../../fly/hud_features.dart';
|
||||
import '../../fly/modes/mission_library.dart';
|
||||
@@ -15,6 +16,7 @@ import '../../fly/modes/waypoint_mission.dart' show MissionLimits;
|
||||
import '../../fly/product_profile.dart';
|
||||
import '../../fly/safety_gate.dart';
|
||||
import '../../fly/state/mission_state.dart';
|
||||
import '../../fly/virtual_sticks.dart';
|
||||
import '../../theme.dart';
|
||||
import '../album_page.dart';
|
||||
import '../capture_modes_page.dart';
|
||||
@@ -22,6 +24,7 @@ import '../pv_icons.dart';
|
||||
import 'bottom/attitude_ball.dart';
|
||||
import 'bottom/minimap.dart';
|
||||
import 'bottom/telemetry_strip.dart';
|
||||
import 'control/virtual_stick_pads.dart';
|
||||
import 'flight_command_flow.dart';
|
||||
import 'hud_kit.dart';
|
||||
import 'layers/video_layer.dart';
|
||||
@@ -35,11 +38,15 @@ import 'modes/waypoints/waypoint_layers.dart';
|
||||
import 'modes/waypoints/waypoint_panels.dart';
|
||||
import 'modes/waypoints/waypoint_sheets.dart';
|
||||
import 'modes/waypoints/waypoint_start.dart';
|
||||
import 'overlays/airsense.dart';
|
||||
import 'overlays/alert_stack.dart';
|
||||
import 'overlays/camera_touch_layer.dart';
|
||||
import 'overlays/fly_zone_licences.dart';
|
||||
import 'overlays/fly_zones.dart';
|
||||
import 'overlays/gimbal_drag_layer.dart';
|
||||
import 'overlays/histogram_view.dart';
|
||||
import 'overlays/safety_banners.dart';
|
||||
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';
|
||||
@@ -81,6 +88,12 @@ import 'top/status_bar.dart';
|
||||
/// along the bottom, the tool column on the right — and the run panel at the
|
||||
/// head of the left column follows the upload and the flight, whatever the
|
||||
/// screen shows. The route stays on the map after leaving the mode.
|
||||
///
|
||||
/// Radar, sticks, AirSense (plan P8): the vision radar around the picture's
|
||||
/// centre; the rail's STICKS button hands the aircraft to two on-screen stick
|
||||
/// pads through the SafetyGate (simulator first); AirSense warnings above the
|
||||
/// error stack and the planes it hears on the map; DJI GEO fly zones on the
|
||||
/// map with their banner, and the radar ring on the attitude ball.
|
||||
class FlyScreen extends StatefulWidget {
|
||||
const FlyScreen({super.key, required this.model, required this.dji, this.waypoints});
|
||||
|
||||
@@ -115,6 +128,13 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
MissionLimits _limits = const MissionLimits();
|
||||
int _seenFinishes = 0;
|
||||
|
||||
/// The on-screen sticks (plan P8); [_sticksOpen] mirrors its `open` for layout.
|
||||
late final VirtualStickController _sticks = VirtualStickController(send: _gate.sendSticks)..mode = FlyPrefs.stickMode.value;
|
||||
bool _sticksOpen = false;
|
||||
|
||||
/// Keeps the GEO zones around the aircraft loaded (plan P8).
|
||||
late final FlyZoneLoader _zones = FlyZoneLoader(model: widget.model, dji: widget.dji);
|
||||
|
||||
/// Waypoints is open on the big map and nothing is flying: taps edit.
|
||||
bool get _editing {
|
||||
final WaypointProgress run = _m.mission.waypoint;
|
||||
@@ -124,6 +144,9 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
/// The picture-in-picture box, in HUD canvas pixels from the bottom-left corner.
|
||||
static const Size _pipSize = Size(150, 90);
|
||||
|
||||
/// An on-screen stick pad's diameter, in HUD canvas pixels.
|
||||
static const double _stickPad = 110;
|
||||
|
||||
FlightModel get _m => widget.model;
|
||||
DjiService get _dji => widget.dji;
|
||||
|
||||
@@ -139,6 +162,61 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
_m.addListener(_onModel);
|
||||
_seenFinishes = _m.mission.waypointFinishes;
|
||||
_m.mission.addListener(_onMission);
|
||||
_m.aircraft.addListener(_onSticksAircraft);
|
||||
_sticks.addListener(_onSticks);
|
||||
FlyPrefs.stickMode.addListener(_onStickMode);
|
||||
_zones.start();
|
||||
}
|
||||
|
||||
void _onStickMode() => _sticks.mode = FlyPrefs.stickMode.value;
|
||||
|
||||
/// The flight controller can end virtual-stick mode on its own (the remote's
|
||||
/// mode switch, RTH, landing): the controller closes, and the loop is told to stop.
|
||||
void _onSticksAircraft() {
|
||||
final String mode = _m.aircraft.flightModeEnum ?? '';
|
||||
_sticks.onAircraft(
|
||||
enabled: _m.aircraft.virtualStickEnabled,
|
||||
goingHome: _m.aircraft.goingHome == true || mode == 'GO_HOME',
|
||||
landing: mode == 'AUTO_LANDING' || mode == 'CONFIRM_LANDING',
|
||||
);
|
||||
}
|
||||
|
||||
void _onSticks() {
|
||||
if (_sticks.open == _sticksOpen) return;
|
||||
final String? reason = _sticks.closedReason;
|
||||
if (!_sticks.open && reason != null) {
|
||||
_gate.disableVirtualSticks().catchError((Object _) {});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..clearSnackBars()
|
||||
..showSnackBar(SnackBar(content: Text('On-screen sticks off: $reason')));
|
||||
}
|
||||
}
|
||||
if (mounted) setState(() => _sticksOpen = _sticks.open);
|
||||
}
|
||||
|
||||
Future<void> _toggleSticks(BuildContext context) async {
|
||||
if (_sticks.open) {
|
||||
_sticks.close();
|
||||
try {
|
||||
await _gate.disableVirtualSticks();
|
||||
} catch (e) {
|
||||
_error('On-screen sticks', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await startVisionMode(
|
||||
context,
|
||||
title: 'On-screen sticks',
|
||||
icon: 'rc',
|
||||
description: '${FlyPrefs.stickMode.value.label}. Lift your thumbs and the aircraft hovers. Tap STICKS again to hand back to the remote.',
|
||||
checks: _gate.checkVirtualSticks(),
|
||||
start: () async {
|
||||
await _gate.enableVirtualSticks();
|
||||
_sticks.opened();
|
||||
},
|
||||
onError: _error,
|
||||
);
|
||||
}
|
||||
|
||||
/// Says how a waypoint mission ended, and leaves edit mode when one starts.
|
||||
@@ -254,7 +332,10 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
void _onModel() {
|
||||
if (_m.connected == _wasConnected) return;
|
||||
_wasConnected = _m.connected;
|
||||
if (!_m.connected) _flow.resetReview();
|
||||
if (!_m.connected) {
|
||||
_flow.resetReview();
|
||||
_sticks.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _openStatus(BuildContext context, {bool offerTakeOff = false}) {
|
||||
@@ -267,6 +348,12 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
void dispose() {
|
||||
_m.removeListener(_onModel);
|
||||
_m.mission.removeListener(_onMission);
|
||||
_m.aircraft.removeListener(_onSticksAircraft);
|
||||
FlyPrefs.stickMode.removeListener(_onStickMode);
|
||||
_zones.dispose();
|
||||
// Leaving the screen hands the aircraft back to the remote.
|
||||
if (_sticks.open) _gate.disableVirtualSticks().catchError((Object _) {});
|
||||
_sticks.dispose();
|
||||
if (widget.waypoints == null) _waypoints.dispose();
|
||||
_vision.dispose();
|
||||
_library.dispose();
|
||||
@@ -308,7 +395,14 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
trail: _trail,
|
||||
controller: _minimap,
|
||||
interactive: _mapBig,
|
||||
layers: waypointMapLayers(editor: _waypoints, mission: _m.mission, editable: _editing),
|
||||
layers: <Widget>[
|
||||
// Zones under the route, planes on top.
|
||||
flyZoneMapLayer(_m.flyZones),
|
||||
flyZoneLicenceLayer(_m.flyZones),
|
||||
flyZoneHeightLayer(_m.flyZones),
|
||||
...waypointMapLayers(editor: _waypoints, mission: _m.mission, editable: _editing),
|
||||
airSenseMapLayer(_m.airSense),
|
||||
],
|
||||
onTap: (LatLng at) {
|
||||
if (_editing) _waypoints.tapMap(at);
|
||||
},
|
||||
@@ -364,6 +458,14 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
// Obstacles around the picture's centre; touch-transparent.
|
||||
Positioned.fill(
|
||||
child: Requires(
|
||||
HudFeatures.visionRadar,
|
||||
whenDisconnected: false,
|
||||
child: VisionRadarOverlay(vision: _m.vision, aircraft: _m.aircraft, video: _m.videoSize),
|
||||
),
|
||||
),
|
||||
Positioned.fromRect(key: const ValueKey<String>('map-slot'), rect: pip, child: map),
|
||||
] else ...<Widget>[
|
||||
Positioned.fromRect(key: const ValueKey<String>('map-slot'), rect: full, child: map),
|
||||
@@ -417,7 +519,8 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
),
|
||||
),
|
||||
// ── Zoom column (zoom cameras only) ───────────────────────────────────
|
||||
if (!_mapBig)
|
||||
// The right stick pad takes its place while the sticks fly.
|
||||
if (!_mapBig && !_sticksOpen)
|
||||
Positioned(
|
||||
right: 68,
|
||||
top: 76,
|
||||
@@ -507,19 +610,51 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
listenable: _waypoints,
|
||||
builder: (BuildContext context, Widget? child) =>
|
||||
_editing && (_waypoints.selected != null || _waypoints.selectedPoi != null) ? const SizedBox.shrink() : child!,
|
||||
// The left stick pad takes the attitude ball's place while the sticks fly.
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: <Widget>[
|
||||
AttitudeBall(model: _m, size: 84),
|
||||
if (_sticksOpen) const SizedBox(width: _stickPad + 10) else AttitudeBall(model: _m, size: 84),
|
||||
const SizedBox(width: 6),
|
||||
TelemetryStrip(aircraft: _m.aircraft),
|
||||
]),
|
||||
),
|
||||
),
|
||||
// ── On-screen sticks (plan P8) ────────────────────────────────────────
|
||||
if (_sticksOpen)
|
||||
Positioned(
|
||||
left: _pipSize.width + 10,
|
||||
bottom: 0,
|
||||
child: ListenableBuilder(
|
||||
listenable: _sticks,
|
||||
builder: (BuildContext context, _) => StickPad(
|
||||
key: const ValueKey<String>('stick-left'),
|
||||
size: _stickPad,
|
||||
value: _sticks.left,
|
||||
onChanged: _sticks.setLeft,
|
||||
caption: stickCaptions(_sticks.mode).left,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_sticksOpen)
|
||||
Positioned(
|
||||
right: 70,
|
||||
bottom: 0,
|
||||
child: ListenableBuilder(
|
||||
listenable: _sticks,
|
||||
builder: (BuildContext context, _) => StickPad(
|
||||
key: const ValueKey<String>('stick-right'),
|
||||
size: _stickPad,
|
||||
value: _sticks.right,
|
||||
onChanged: _sticks.setRight,
|
||||
caption: stickCaptions(_sticks.mode).right,
|
||||
),
|
||||
),
|
||||
),
|
||||
// ── Mission run panel / route board, error stack, low-battery banners ─
|
||||
// Bounded above the attitude ball; the stack fits what is left under the banners.
|
||||
Positioned(
|
||||
left: 108,
|
||||
top: StatusBar.height + SmartBatteryBar.height + 8,
|
||||
bottom: 104,
|
||||
bottom: _sticksOpen ? _stickPad + 18 : 104,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||||
WaypointRunPanel(model: _m, editor: _waypoints, gate: _gate, flow: _flow, onError: _error),
|
||||
// Palm control (Spark) flies the aircraft from the pilot's hand: say so.
|
||||
@@ -564,6 +699,17 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
onTap: () => showRouteSettings(context, editor: _waypoints, limits: _limits),
|
||||
),
|
||||
),
|
||||
Requires(
|
||||
HudFeatures.airSense,
|
||||
child: AirSenseBanner(airSense: _m.airSense, onTap: () => showAirSenseSheet(context, airSense: _m.airSense)),
|
||||
),
|
||||
Requires(
|
||||
HudFeatures.flyZones,
|
||||
child: FlyZoneBanner(
|
||||
zones: _m.flyZones,
|
||||
onTap: () => showFlyZoneSheet(context, model: _m, gate: _gate, onError: _error),
|
||||
),
|
||||
),
|
||||
SafetyBanners(gate: _gate, onError: _error),
|
||||
Flexible(child: AlertStack(alerts: _m.alerts, onMore: () => _openStatus(context))),
|
||||
]),
|
||||
@@ -577,6 +723,11 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
flow: _flow,
|
||||
modeActive: _mode != null,
|
||||
onModes: () => _openModes(context),
|
||||
trailing: Requires(
|
||||
HudFeatures.virtualSticks,
|
||||
whenDisconnected: false,
|
||||
child: SticksButton(open: _sticksOpen, onTap: () => _toggleSticks(context)),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_mapBig) Positioned(right: _editing ? 122 : 70, top: 80, child: MinimapControls(controller: _minimap, trail: _trail)),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../fly/flight_status.dart';
|
||||
import '../../fly/vision_radar.dart' show RadarBand;
|
||||
import '../../theme.dart';
|
||||
|
||||
/// Shared pieces of the flight HUD v2 (plan P2): the scaling canvas every HUD
|
||||
@@ -125,6 +126,18 @@ abstract final class HudColors {
|
||||
};
|
||||
}
|
||||
|
||||
/// Vision radar band colours (plan P8), shared by the video overlay and the attitude ball.
|
||||
abstract final class RadarColors {
|
||||
static const Color notice = Color(0xFFF4D35E);
|
||||
|
||||
static Color of(RadarBand b) => switch (b) {
|
||||
RadarBand.danger => HudColors.danger,
|
||||
RadarBand.caution => HudColors.caution,
|
||||
RadarBand.notice => notice,
|
||||
RadarBand.none => HudColors.off,
|
||||
};
|
||||
}
|
||||
|
||||
/// The rounded translucent pill most HUD readouts sit on.
|
||||
class GlassPill extends StatelessWidget {
|
||||
const GlassPill({super.key, required this.child, this.height = 26, this.color = Glass.pill, this.padding, this.onTap});
|
||||
|
||||
@@ -17,7 +17,7 @@ import '../hud_kit.dart';
|
||||
/// The grid toggle that used to sit last lives in the camera menu (Other →
|
||||
/// Grid): a fourth button would reach the minimap on a phone.
|
||||
class ActionRail extends StatelessWidget {
|
||||
const ActionRail({super.key, required this.model, required this.flow, required this.onModes, this.modeActive = false});
|
||||
const ActionRail({super.key, required this.model, required this.flow, required this.onModes, this.modeActive = false, this.trailing});
|
||||
|
||||
final FlightModel model;
|
||||
final FlightCommandFlow flow;
|
||||
@@ -28,6 +28,9 @@ class ActionRail extends StatelessWidget {
|
||||
/// A mode's editor is open (the IFM button lights up).
|
||||
final bool modeActive;
|
||||
|
||||
/// A small control under the buttons (plan P8: the STICKS toggle).
|
||||
final Widget? trailing;
|
||||
|
||||
static ({String icon, String label, Color color}) _look(FlightCommand c) => switch (c) {
|
||||
FlightCommand.takeOff => (icon: 'takeoff', label: 'TAKE OFF', color: Glass.accent),
|
||||
FlightCommand.land => (icon: 'land', label: 'LAND', color: Glass.pillStrong),
|
||||
@@ -76,6 +79,7 @@ class ActionRail extends StatelessWidget {
|
||||
onTap: onModes,
|
||||
),
|
||||
),
|
||||
if (trailing != null) Padding(padding: const EdgeInsets.only(top: 2), child: trailing),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
import '../../../fly/state/airsense_state.dart';
|
||||
import '../../../theme.dart';
|
||||
import '../../pv_icons.dart';
|
||||
import '../hud_kit.dart';
|
||||
|
||||
String _km(double? m) => m == null ? '—' : (m >= 1000 ? '${(m / 1000).toStringAsFixed(1)} km' : '${m.round()} m');
|
||||
|
||||
/// Level 2 is caution, 3 and up danger; below that nothing to say.
|
||||
Color? airSenseColor(int level) => level >= AirSenseState.dangerLevel
|
||||
? HudColors.danger
|
||||
: level >= AirSenseState.cautionLevel
|
||||
? HudColors.caution
|
||||
: null;
|
||||
|
||||
/// The AirSense warning (plan P8) above the error stack: a manned aircraft is
|
||||
/// close enough for the receiver to grade it level 2 (caution) or 3–4 (descend).
|
||||
/// Names the nearest worst plane; tapping opens the list. Buzzes when the level
|
||||
/// rises.
|
||||
class AirSenseBanner extends StatefulWidget {
|
||||
const AirSenseBanner({super.key, required this.airSense, required this.onTap, this.width = 280});
|
||||
|
||||
final AirSenseState airSense;
|
||||
final VoidCallback onTap;
|
||||
final double width;
|
||||
|
||||
@override
|
||||
State<AirSenseBanner> createState() => _AirSenseBannerState();
|
||||
}
|
||||
|
||||
class _AirSenseBannerState extends State<AirSenseBanner> {
|
||||
int _lastLevel = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: widget.airSense,
|
||||
builder: (BuildContext context, _) {
|
||||
final AirSenseState s = widget.airSense;
|
||||
final int level = s.level;
|
||||
if (level > _lastLevel && level >= AirSenseState.cautionLevel) {
|
||||
level >= AirSenseState.dangerLevel ? HapticFeedback.heavyImpact() : HapticFeedback.mediumImpact();
|
||||
}
|
||||
_lastLevel = level;
|
||||
// The aircraft acting on a threat itself outranks the level.
|
||||
final Color? color = s.avoiding ? HudColors.danger : airSenseColor(level);
|
||||
if (color == null) return const SizedBox.shrink();
|
||||
final int? countdown = s.avoidanceCountdown?.ceil();
|
||||
final String? avoidTitle = !s.avoiding
|
||||
? null
|
||||
: (s.avoidanceMode == 'LANDING' ? 'Landing to avoid manned aircraft' : 'Avoiding manned aircraft') +
|
||||
(countdown != null && countdown > 0 ? ' in $countdown s' : '');
|
||||
final AirSensePlane? p = s.worst;
|
||||
final String where = p == null
|
||||
? ''
|
||||
: <String>[_km(p.distance), if (p.octant != null) p.octant!, if (p.code.isNotEmpty) p.code].join(' · ');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: GestureDetector(
|
||||
key: const ValueKey<String>('banner-airsense'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
width: widget.width,
|
||||
decoration: BoxDecoration(color: color.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.fromLTRB(9, 6, 7, 6),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
Row(children: <Widget>[
|
||||
const PVIcon('alert', size: 14, color: Colors.white),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
avoidTitle ??
|
||||
(level >= 4
|
||||
? 'Collision risk: descend now'
|
||||
: level >= AirSenseState.dangerLevel
|
||||
? 'Manned aircraft close: descend'
|
||||
: 'Manned aircraft nearby'),
|
||||
style: HudText.sans.copyWith(color: Colors.white, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
]),
|
||||
if (where.isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: 2),
|
||||
Text(where, style: HudText.monoSmall.copyWith(color: Colors.white)),
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every plane the receiver hears, nearest first (GO 4's "AirSense System"
|
||||
/// list: ICAO · distance; MSDK 4.18 has no altitude). Shared by the AirSense
|
||||
/// sheet and the settings sheet's Safety tab.
|
||||
class AirSenseList extends StatelessWidget {
|
||||
const AirSenseList({super.key, required this.airSense, this.shrinkWrap = false});
|
||||
|
||||
final AirSenseState airSense;
|
||||
final bool shrinkWrap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: airSense,
|
||||
builder: (BuildContext context, _) {
|
||||
if (!airSense.reported) {
|
||||
return _note('This aircraft has not reported AirSense. Only aircraft with an ADS-B receiver hear manned traffic.');
|
||||
}
|
||||
if (airSense.planes.isEmpty) return _note('No manned aircraft heard.');
|
||||
final Widget header = Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Row(children: <Widget>[
|
||||
Expanded(flex: 3, child: Text('ICAO', style: HudText.label)),
|
||||
Expanded(flex: 3, child: Text('DISTANCE', style: HudText.label)),
|
||||
Expanded(flex: 2, child: Text('DIR', style: HudText.label)),
|
||||
Expanded(flex: 2, child: Text('HDG', style: HudText.label)),
|
||||
]),
|
||||
);
|
||||
return ListView(
|
||||
shrinkWrap: shrinkWrap,
|
||||
physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null,
|
||||
padding: EdgeInsets.zero,
|
||||
children: <Widget>[
|
||||
header,
|
||||
for (final AirSensePlane p in airSense.planes)
|
||||
Padding(
|
||||
key: ValueKey<String>('airsense-${p.code}'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 5),
|
||||
child: Row(children: <Widget>[
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Row(children: <Widget>[
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: airSenseColor(p.warningLevel) ?? HudColors.off),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(child: Text(p.code.isEmpty ? '—' : p.code, style: HudText.mono)),
|
||||
]),
|
||||
),
|
||||
Expanded(flex: 3, child: Text(_km(p.distance), style: HudText.mono)),
|
||||
Expanded(flex: 2, child: Text(p.octant ?? '—', style: HudText.mono)),
|
||||
Expanded(flex: 2, child: Text(p.heading == null ? '—' : '${p.heading!.round()}°', style: HudText.mono)),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _note(String text) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
|
||||
child: Text(text, style: HudText.sans.copyWith(color: HudText.dim, fontWeight: FontWeight.w400)),
|
||||
);
|
||||
}
|
||||
|
||||
/// The AirSense list as a right side sheet over the video.
|
||||
Future<void> showAirSenseSheet(BuildContext context, {required AirSenseState airSense}) {
|
||||
return showGeneralDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'AirSense',
|
||||
barrierColor: const Color(0x55050A14),
|
||||
transitionDuration: const Duration(milliseconds: 200),
|
||||
pageBuilder: (BuildContext context, _, __) => Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Material(
|
||||
color: const Color(0xF20B1426),
|
||||
child: SizedBox(
|
||||
width: 340,
|
||||
height: MediaQuery.sizeOf(context).height,
|
||||
child: SafeArea(
|
||||
left: false,
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 4, 6),
|
||||
child: Row(children: <Widget>[
|
||||
const PVIcon('radio', size: 16, color: Glass.ink),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text('AirSense', style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))),
|
||||
IconButton(
|
||||
key: const ValueKey<String>('airsense-close'),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const PVIcon('close', size: 18, color: HudText.dim),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const Divider(height: 1, color: Glass.hairline),
|
||||
Expanded(child: AirSenseList(airSense: airSense)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'Manned aircraft always have right of way. Descend and give way when AirSense warns.',
|
||||
style: HudText.sans.copyWith(color: HudText.dim, fontSize: 10.5, fontWeight: FontWeight.w400),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
transitionBuilder: (BuildContext context, Animation<double> a, _, Widget child) => SlideTransition(
|
||||
position: Tween<Offset>(begin: const Offset(1, 0), end: Offset.zero).animate(CurvedAnimation(parent: a, curve: Curves.easeOut)),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The planes on the map: a heading-rotated arrow, coloured by warning level.
|
||||
Widget airSenseMapLayer(AirSenseState airSense) => ListenableBuilder(
|
||||
listenable: airSense,
|
||||
builder: (BuildContext context, _) => MarkerLayer(markers: <Marker>[
|
||||
for (final AirSensePlane p in airSense.planes)
|
||||
if (p.hasPosition)
|
||||
Marker(
|
||||
key: ValueKey<String>('airsense-marker-${p.code}'),
|
||||
point: LatLng(p.latitude!, p.longitude!),
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: Transform.rotate(
|
||||
angle: (p.heading ?? 0) * math.pi / 180,
|
||||
child: CustomPaint(painter: _PlanePainter(airSenseColor(p.warningLevel) ?? Glass.ink)),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
/// A nose-up arrowhead with a dark outline, readable on any map tile.
|
||||
class _PlanePainter extends CustomPainter {
|
||||
_PlanePainter(this.color);
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final double w = size.width, h = size.height;
|
||||
final Path path = Path()
|
||||
..moveTo(w / 2, 2)
|
||||
..lineTo(w - 4, h - 4)
|
||||
..lineTo(w / 2, h * 0.7)
|
||||
..lineTo(4, h - 4)
|
||||
..close();
|
||||
canvas.drawPath(path, Paint()..color = color);
|
||||
canvas.drawPath(path, Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5
|
||||
..color = const Color(0xCC081020));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_PlanePainter old) => old.color != color;
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
import '../../../flight_model.dart';
|
||||
import '../../../fly/safety_gate.dart';
|
||||
import '../../../fly/state/fly_zone_state.dart';
|
||||
import '../../../theme.dart';
|
||||
import '../dialogs/slide_to_confirm.dart';
|
||||
import '../hud_kit.dart';
|
||||
import '../panels/camera_menu/camera_rows.dart';
|
||||
import 'fly_zones.dart' show flyZoneColor;
|
||||
|
||||
/// Height limits on the map (plan P8): each part's ceiling at its centre —
|
||||
/// `120 m`, or `NO FLY` where the limit is 0.
|
||||
Widget flyZoneHeightLayer(FlyZoneState zones) => ListenableBuilder(
|
||||
listenable: zones,
|
||||
builder: (BuildContext context, _) => MarkerLayer(markers: <Marker>[
|
||||
for (final FlyZone z in zones.zones)
|
||||
for (final SubFlyZone s in z.subZones)
|
||||
if (s.heightLabel != null && s.labelPoint != null)
|
||||
Marker(
|
||||
key: ValueKey<String>('flyzone-height-${z.id}-${s.labelPoint}'),
|
||||
point: LatLng(s.labelPoint!.$1, s.labelPoint!.$2),
|
||||
width: 64,
|
||||
height: 18,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xCC081020),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
border: Border.all(color: flyZoneColor(z)),
|
||||
),
|
||||
child: Text(s.heightLabel!, style: HudText.label.copyWith(color: Glass.ink, fontSize: 8, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
/// The aircraft's licence areas on the map, green; the enabled one stronger.
|
||||
Widget flyZoneLicenceLayer(FlyZoneState zones) => ListenableBuilder(
|
||||
listenable: zones,
|
||||
builder: (BuildContext context, _) {
|
||||
final List<CircleMarker> circles = <CircleMarker>[];
|
||||
final List<Polygon> polygons = <Polygon>[];
|
||||
for (final FlyZoneLicence l in zones.licences) {
|
||||
if (!l.hasArea || l.expired) continue;
|
||||
final Color fill = HudColors.ready.withValues(alpha: l.enabled ? 0.18 : 0.05);
|
||||
final Color border = HudColors.ready.withValues(alpha: l.enabled ? 1 : 0.5);
|
||||
if (l.points.length >= 3) {
|
||||
polygons.add(Polygon(
|
||||
points: <LatLng>[for (final (double, double) p in l.points) LatLng(p.$1, p.$2)],
|
||||
color: fill,
|
||||
borderColor: border,
|
||||
borderStrokeWidth: 1.5,
|
||||
));
|
||||
} else {
|
||||
circles.add(CircleMarker(
|
||||
point: LatLng(l.latitude!, l.longitude!),
|
||||
radius: l.radius!,
|
||||
useRadiusInMeter: true,
|
||||
color: fill,
|
||||
borderColor: border,
|
||||
borderStrokeWidth: 1.5,
|
||||
));
|
||||
}
|
||||
}
|
||||
return Stack(children: <Widget>[
|
||||
if (polygons.isNotEmpty) PolygonLayer(polygons: polygons),
|
||||
if (circles.isNotEmpty) CircleLayer(circles: circles),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
/// Licences on the aircraft (plan P8): DJI-issued custom unlocks, one enabled
|
||||
/// at a time, synced from the logged-in DJI account. Every change happens on
|
||||
/// the ground; enabling, disabling and removing are confirmed by a slide.
|
||||
class FlyZoneLicences extends StatelessWidget {
|
||||
const FlyZoneLicences({super.key, required this.model, required this.gate, required this.onError});
|
||||
|
||||
final FlightModel model;
|
||||
final SafetyGate gate;
|
||||
final void Function(String label, Object error) onError;
|
||||
|
||||
Future<void> _run(BuildContext context, String label, SlideConfirmSpec spec, Future<void> Function() action) async {
|
||||
final SlideResult? ok = await showSlideToConfirm(context, spec);
|
||||
if (ok == null) return;
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
onError(label, e);
|
||||
}
|
||||
}
|
||||
|
||||
static String _name(FlyZoneLicence l) => l.name.isEmpty ? '${l.typeLabel} ${l.id}' : l.name;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final FlyZoneState z = model.flyZones;
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge(<Listenable>[z, model.aircraft]),
|
||||
builder: (BuildContext context, _) {
|
||||
final List<GateCheck> blocked = gate.checkLicenceChange();
|
||||
final String? lock = blocked.isEmpty ? null : blocked.first.message;
|
||||
final TextStyle note = HudText.sans.copyWith(color: HudText.dim, fontWeight: FontWeight.w400);
|
||||
final Widget header = Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 2),
|
||||
child: Text('LICENCES', style: HudText.label),
|
||||
);
|
||||
if (z.licencesSupported == false) {
|
||||
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
header,
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 10),
|
||||
child: Text('This aircraft does not take custom unlock licences.', style: note),
|
||||
),
|
||||
]);
|
||||
}
|
||||
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
header,
|
||||
if (z.licences.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 6),
|
||||
child: Text(z.licencesSupported == null ? 'Licences load when the aircraft connects.' : 'No licences on this aircraft.', style: note),
|
||||
),
|
||||
for (final FlyZoneLicence l in z.licences)
|
||||
SwitchTile(
|
||||
key: ValueKey<String>('licence-${l.id}'),
|
||||
label: _name(l),
|
||||
hint: <String>[
|
||||
l.typeLabel,
|
||||
if (l.height != null && l.height! > 0) 'up to ${l.height} m',
|
||||
if (l.country != null && l.country!.isNotEmpty) l.country!,
|
||||
if (l.validity.isNotEmpty) l.validity,
|
||||
if (l.expired) 'expired',
|
||||
].join(' · '),
|
||||
value: l.enabled,
|
||||
onChanged: (bool on) {
|
||||
if (lock != null) {
|
||||
onError('Licence', MissionRefused(blocked));
|
||||
return;
|
||||
}
|
||||
_run(
|
||||
context,
|
||||
'Licence',
|
||||
SlideConfirmSpec(
|
||||
icon: 'shield',
|
||||
title: '${on ? 'Enable' : 'Disable'} ${_name(l)}',
|
||||
description: on
|
||||
? 'The aircraft may fly in the licensed area until the licence ends. Any other enabled licence is switched off.'
|
||||
: 'The licensed area is locked again.',
|
||||
sliderLabel: on ? 'Slide to enable' : 'Slide to disable',
|
||||
color: HudColors.ready,
|
||||
),
|
||||
() => gate.setLicenceEnabled(l, on),
|
||||
);
|
||||
},
|
||||
),
|
||||
ButtonTile(
|
||||
id: 'syncLicences',
|
||||
label: 'Sync from DJI account',
|
||||
hint: lock ?? 'Downloads the licences issued to your DJI account onto the aircraft',
|
||||
button: 'Sync',
|
||||
onTap: lock != null
|
||||
? null
|
||||
: () async {
|
||||
try {
|
||||
await gate.syncLicences();
|
||||
} catch (e) {
|
||||
onError('Sync licences', e);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (z.licences.isNotEmpty)
|
||||
ButtonTile(
|
||||
id: 'deleteLicences',
|
||||
label: 'Remove all licences',
|
||||
hint: lock,
|
||||
button: 'Remove',
|
||||
danger: true,
|
||||
onTap: lock != null
|
||||
? null
|
||||
: () => _run(
|
||||
context,
|
||||
'Remove licences',
|
||||
const SlideConfirmSpec(
|
||||
icon: 'shield',
|
||||
title: 'Remove all licences',
|
||||
description: 'Every licence is deleted from the aircraft. Sync again to get them back.',
|
||||
sliderLabel: 'Slide to remove',
|
||||
color: HudColors.danger,
|
||||
),
|
||||
gate.deleteAllLicences,
|
||||
),
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The GEO database (plan P8): versions, whether DJI has a newer one, and the
|
||||
/// basic database download with its progress.
|
||||
class FlyZoneDatabaseRow extends StatelessWidget {
|
||||
const FlyZoneDatabaseRow({super.key, required this.zones, required this.gate, required this.onError});
|
||||
|
||||
final FlyZoneState zones;
|
||||
final SafetyGate gate;
|
||||
final void Function(String label, Object error) onError;
|
||||
|
||||
static String _line(String name, String? version, String? state) => '$name ${version ?? '—'} · ${FlyZoneDatabase.stateLabel(state)}';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: zones,
|
||||
builder: (BuildContext context, _) {
|
||||
final FlyZoneDatabase? db = zones.database;
|
||||
final double? p = zones.upgradeProgress;
|
||||
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 2),
|
||||
child: Text('GEO DATABASE', style: HudText.label),
|
||||
),
|
||||
Padding(
|
||||
key: const ValueKey<String>('flyzone-db'),
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
|
||||
child: Text(
|
||||
db == null
|
||||
? 'Not reported yet'
|
||||
: '${_line('Basic', db.basicVersion, db.basicState)}\n${_line('Precise', db.preciseVersion, db.preciseState)}',
|
||||
style: HudText.monoSmall.copyWith(color: db?.outOfDate == true ? HudColors.caution : Glass.ink),
|
||||
),
|
||||
),
|
||||
if (p != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
|
||||
child: Row(children: <Widget>[
|
||||
Expanded(child: LinearProgressIndicator(key: const ValueKey<String>('flyzone-db-progress'), value: p)),
|
||||
const SizedBox(width: 8),
|
||||
Text('${(p * 100).round()} %', style: HudText.monoSmall),
|
||||
TextButton(
|
||||
key: const ValueKey<String>('flyzone-db-cancel'),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await gate.cancelFlyZoneDatabaseUpgrade();
|
||||
} catch (e) {
|
||||
onError('Cancel update', e);
|
||||
}
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
]),
|
||||
)
|
||||
else
|
||||
ButtonTile(
|
||||
id: 'updateFlyZoneDb',
|
||||
label: 'Update basic database',
|
||||
hint: db?.outOfDate == true ? 'A newer database is available' : "Downloads DJI's latest GEO zones over the internet",
|
||||
button: 'Update',
|
||||
onTap: () async {
|
||||
try {
|
||||
await gate.upgradeFlyZoneDatabase();
|
||||
} catch (e) {
|
||||
onError('GEO database', e);
|
||||
}
|
||||
},
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
import '../../../flight_model.dart';
|
||||
import '../../../fly/safety_gate.dart';
|
||||
import '../../../fly/state/fly_zone_state.dart';
|
||||
import '../../../theme.dart';
|
||||
import '../../pv_icons.dart';
|
||||
import '../dialogs/slide_to_confirm.dart';
|
||||
import '../hud_kit.dart';
|
||||
import 'fly_zone_licences.dart';
|
||||
|
||||
/// GO 4's GEO colours: restricted red, authorization blue, enhanced warning
|
||||
/// orange, warning yellow.
|
||||
Color flyZoneColor(FlyZone z) => switch (z.category) {
|
||||
'RESTRICTED' => HudColors.danger,
|
||||
'AUTHORIZATION' => const Color(0xFF5B93F5),
|
||||
'ENHANCED_WARNING' => HudColors.caution,
|
||||
'WARNING' => RadarColors.notice,
|
||||
_ => HudColors.off,
|
||||
};
|
||||
|
||||
Color? zoneLevelColor(ZoneLevel l) => switch (l) {
|
||||
ZoneLevel.danger => HudColors.danger,
|
||||
ZoneLevel.caution => HudColors.caution,
|
||||
ZoneLevel.info => const Color(0xFF3A5A8C),
|
||||
ZoneLevel.none => null,
|
||||
};
|
||||
|
||||
String _km(double? m) => m == null ? '' : (m >= 1000 ? '${(m / 1000).toStringAsFixed(1)} km' : '${m.round()} m');
|
||||
|
||||
/// The fly-zone banner (plan P8) above the error stack: the latest FlySafe
|
||||
/// notice while it is fresh (a serious one with its countdown), else the
|
||||
/// aircraft's standing when it is in or next to a restricted / authorization
|
||||
/// zone. Tap opens the zone list. Buzzes on a serious warning.
|
||||
class FlyZoneBanner extends StatefulWidget {
|
||||
const FlyZoneBanner({super.key, required this.zones, required this.onTap, this.width = 280});
|
||||
|
||||
final FlyZoneState zones;
|
||||
final VoidCallback onTap;
|
||||
final double width;
|
||||
|
||||
@override
|
||||
State<FlyZoneBanner> createState() => _FlyZoneBannerState();
|
||||
}
|
||||
|
||||
class _FlyZoneBannerState extends State<FlyZoneBanner> {
|
||||
Timer? _tick;
|
||||
FlySafeNotice? _buzzed;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tick?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Rebuilds once a second while a notice is up, so it expires and counts down.
|
||||
void _schedule(bool on) {
|
||||
_tick?.cancel();
|
||||
_tick = on ? Timer(const Duration(seconds: 1), () => mounted ? setState(() {}) : null) : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: widget.zones,
|
||||
builder: (BuildContext context, _) {
|
||||
final FlyZoneState z = widget.zones;
|
||||
final DateTime now = DateTime.now();
|
||||
final FlySafeNotice? n = z.noticeAt(now);
|
||||
_schedule(n != null);
|
||||
if (n != null && n.level == ZoneLevel.danger && !identical(n, _buzzed)) {
|
||||
_buzzed = n;
|
||||
HapticFeedback.heavyImpact();
|
||||
}
|
||||
final String title;
|
||||
final String? body;
|
||||
final ZoneLevel level;
|
||||
if (n != null) {
|
||||
final int? left = n.countdown == null ? null : n.countdown! - now.difference(n.at).inSeconds;
|
||||
title = left != null && left > 0 ? '${n.title} · $left s' : n.title;
|
||||
final String? zone = z.zonesFor(n).map((FlyZone f) => f.name).where((String s) => s.isNotEmpty).firstOrNull;
|
||||
body = <String>[
|
||||
if (n.description != null && n.description!.isNotEmpty) n.description!,
|
||||
if (n.heightLimit != null && n.heightLimit! > 0) 'Height limit ${n.heightLimit} m',
|
||||
if (zone != null) zone,
|
||||
].join(' · ');
|
||||
level = n.level;
|
||||
} else if (z.stateLevel.index >= ZoneLevel.caution.index) {
|
||||
title = z.stateLabel;
|
||||
body = null;
|
||||
level = z.stateLevel;
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final Color color = zoneLevelColor(level) ?? HudColors.neutral;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: GestureDetector(
|
||||
key: const ValueKey<String>('banner-flyzone'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
width: widget.width,
|
||||
decoration: BoxDecoration(color: color.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.fromLTRB(9, 6, 7, 6),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
Row(children: <Widget>[
|
||||
const PVIcon('shield', size: 14, color: Colors.white),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: Text(title, style: HudText.sans.copyWith(color: Colors.white, fontWeight: FontWeight.w700))),
|
||||
]),
|
||||
if (body != null && body.isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: 2),
|
||||
Text(body, maxLines: 3, overflow: TextOverflow.ellipsis,
|
||||
style: HudText.sans.copyWith(color: Colors.white, fontSize: 10.5, fontWeight: FontWeight.w400)),
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The zones on the map: circles in metres and polygons, filled lightly in
|
||||
/// their category colour.
|
||||
Widget flyZoneMapLayer(FlyZoneState zones) => ListenableBuilder(
|
||||
listenable: zones,
|
||||
builder: (BuildContext context, _) {
|
||||
final List<CircleMarker> circles = <CircleMarker>[];
|
||||
final List<Polygon> polygons = <Polygon>[];
|
||||
for (final FlyZone z in zones.zones) {
|
||||
final Color c = flyZoneColor(z);
|
||||
final Color fill = c.withValues(alpha: zones.unlocked.contains(z.id) ? 0.06 : 0.16);
|
||||
final List<SubFlyZone> parts = z.subZones;
|
||||
if (parts.isEmpty && z.latitude != null && z.longitude != null && (z.radius ?? 0) > 0) {
|
||||
circles.add(CircleMarker(
|
||||
point: LatLng(z.latitude!, z.longitude!),
|
||||
radius: z.radius!,
|
||||
useRadiusInMeter: true,
|
||||
color: fill,
|
||||
borderColor: c,
|
||||
borderStrokeWidth: 1.5,
|
||||
));
|
||||
}
|
||||
for (final SubFlyZone s in parts) {
|
||||
if (s.isPolygon) {
|
||||
polygons.add(Polygon(
|
||||
points: <LatLng>[for (final (double, double) v in s.vertices) LatLng(v.$1, v.$2)],
|
||||
color: fill,
|
||||
borderColor: c,
|
||||
borderStrokeWidth: 1.5,
|
||||
));
|
||||
} else if (s.latitude != null && s.longitude != null && (s.radius ?? 0) > 0) {
|
||||
circles.add(CircleMarker(
|
||||
point: LatLng(s.latitude!, s.longitude!),
|
||||
radius: s.radius!,
|
||||
useRadiusInMeter: true,
|
||||
color: fill,
|
||||
borderColor: c,
|
||||
borderStrokeWidth: 1.5,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Stack(children: <Widget>[
|
||||
if (polygons.isNotEmpty) PolygonLayer(polygons: polygons),
|
||||
if (circles.isNotEmpty) CircleLayer(circles: circles),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
/// Nearby zones, nearest first, with Unlock on authorization zones. Shared by
|
||||
/// the zone sheet and Settings → Safety.
|
||||
class FlyZoneList extends StatelessWidget {
|
||||
const FlyZoneList({super.key, required this.model, required this.gate, required this.onError, this.shrinkWrap = false});
|
||||
|
||||
final FlightModel model;
|
||||
final SafetyGate gate;
|
||||
final void Function(String label, Object error) onError;
|
||||
final bool shrinkWrap;
|
||||
|
||||
Future<void> _unlock(BuildContext context, FlyZone z) async {
|
||||
final SlideResult? ok = await showSlideToConfirm(
|
||||
context,
|
||||
SlideConfirmSpec(
|
||||
icon: 'shield',
|
||||
title: 'Unlock ${z.name.isEmpty ? 'zone ${z.id}' : z.name}',
|
||||
description: 'Only unlock a zone you are authorized to fly in. The unlock is recorded against your DJI account '
|
||||
'and applies to this aircraft.',
|
||||
sliderLabel: 'Slide to unlock',
|
||||
color: const Color(0xFF5B93F5),
|
||||
),
|
||||
);
|
||||
if (ok == null) return;
|
||||
try {
|
||||
await gate.unlockFlyZone(z);
|
||||
} catch (e) {
|
||||
onError('Unlock', e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final FlyZoneState zones = model.flyZones;
|
||||
return ListenableBuilder(
|
||||
listenable: zones,
|
||||
builder: (BuildContext context, _) {
|
||||
final double? lat = model.aircraft.latitude ?? model.phoneLatitude;
|
||||
final double? lon = model.aircraft.longitude ?? model.phoneLongitude;
|
||||
final List<FlyZone> list = zones.nearest(lat, lon);
|
||||
final Widget status = Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 6),
|
||||
child: Text(
|
||||
zones.state == null ? 'Fly zone status: not reported yet' : zones.stateLabel,
|
||||
style: HudText.sans.copyWith(color: zoneLevelColor(zones.stateLevel) ?? Glass.ink),
|
||||
),
|
||||
);
|
||||
if (list.isEmpty) {
|
||||
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
status,
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
|
||||
child: Text(
|
||||
zones.zonesAt == null ? 'Zones load once the aircraft or this phone has a position.' : 'No fly zones nearby.',
|
||||
style: HudText.sans.copyWith(color: HudText.dim, fontWeight: FontWeight.w400),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
return ListView(
|
||||
shrinkWrap: shrinkWrap,
|
||||
physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null,
|
||||
padding: EdgeInsets.zero,
|
||||
children: <Widget>[
|
||||
status,
|
||||
for (final FlyZone z in list)
|
||||
Padding(
|
||||
key: ValueKey<String>('flyzone-${z.id}'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Row(children: <Widget>[
|
||||
Container(width: 10, height: 10, decoration: BoxDecoration(shape: BoxShape.circle, color: flyZoneColor(z))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||||
Text(z.name.isEmpty ? 'Zone ${z.id}' : z.name, style: HudText.sans, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
Text(
|
||||
<String>[
|
||||
z.categoryLabel,
|
||||
if (z.reasonLabel != null) z.reasonLabel!,
|
||||
if (lat != null && lon != null) _km(z.distanceFrom(lat, lon)),
|
||||
if (zones.unlocked.contains(z.id)) 'unlocked',
|
||||
].where((String s) => s.isNotEmpty).join(' · '),
|
||||
style: HudText.label,
|
||||
),
|
||||
]),
|
||||
),
|
||||
if (z.unlockable && !zones.unlocked.contains(z.id))
|
||||
TextButton(
|
||||
key: ValueKey<String>('flyzone-unlock-${z.id}'),
|
||||
onPressed: model.connected ? () => _unlock(context, z) : null,
|
||||
child: const Text('Unlock'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The zone list as a right side sheet over the video.
|
||||
Future<void> showFlyZoneSheet(
|
||||
BuildContext context, {
|
||||
required FlightModel model,
|
||||
required SafetyGate gate,
|
||||
required void Function(String label, Object error) onError,
|
||||
}) {
|
||||
return showGeneralDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'Fly zones',
|
||||
barrierColor: const Color(0x55050A14),
|
||||
transitionDuration: const Duration(milliseconds: 200),
|
||||
pageBuilder: (BuildContext context, _, __) => Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Material(
|
||||
color: const Color(0xF20B1426),
|
||||
child: SizedBox(
|
||||
width: 360,
|
||||
height: MediaQuery.sizeOf(context).height,
|
||||
child: SafeArea(
|
||||
left: false,
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 4, 6),
|
||||
child: Row(children: <Widget>[
|
||||
const PVIcon('shield', size: 16, color: Glass.ink),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text('Fly zones', style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))),
|
||||
IconButton(
|
||||
key: const ValueKey<String>('flyzone-close'),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const PVIcon('close', size: 18, color: HudText.dim),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const Divider(height: 1, color: Glass.hairline),
|
||||
Expanded(
|
||||
child: ListView(padding: EdgeInsets.zero, children: <Widget>[
|
||||
FlyZoneList(model: model, gate: gate, onError: onError, shrinkWrap: true),
|
||||
FlyZoneLicences(model: model, gate: gate, onError: onError),
|
||||
FlyZoneDatabaseRow(zones: model.flyZones, gate: gate, onError: onError),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
transitionBuilder: (BuildContext context, Animation<double> a, _, Widget child) => SlideTransition(
|
||||
position: Tween<Offset>(begin: const Offset(1, 0), end: Offset.zero).animate(CurvedAnimation(parent: a, curve: Curves.easeOut)),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../fly/fly_prefs.dart';
|
||||
import '../../../fly/modes/vision_modes.dart' show VisionGeometry;
|
||||
import '../../../fly/state/aircraft_state.dart';
|
||||
import '../../../fly/state/vision_state.dart';
|
||||
import '../../../fly/vision_radar.dart';
|
||||
import '../hud_kit.dart';
|
||||
|
||||
/// The vision radar (plan P8, GO 4's `vision_radar_view`): around the centre of
|
||||
/// the live picture, one arc group per sensing side — front at the top — with
|
||||
/// one to three bars per sector as the obstacle closes in, coloured notice /
|
||||
/// caution / danger, the nearest distance beside each side, the overhead
|
||||
/// distance above, and "BLOCKED" on a side whose sensors are switched on but
|
||||
/// cannot see (shown once the motors run; covered sensors on the ground are
|
||||
/// normal).
|
||||
///
|
||||
/// Touch-transparent. Buzzes when any side turns red.
|
||||
class VisionRadarOverlay extends StatefulWidget {
|
||||
const VisionRadarOverlay({super.key, required this.vision, required this.aircraft, this.video});
|
||||
|
||||
final VisionState vision;
|
||||
final AircraftState aircraft;
|
||||
final ValueListenable<Size?>? video;
|
||||
|
||||
@override
|
||||
State<VisionRadarOverlay> createState() => _VisionRadarOverlayState();
|
||||
}
|
||||
|
||||
class _VisionRadarOverlayState extends State<VisionRadarOverlay> {
|
||||
RadarBand _lastBand = RadarBand.none;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: FlyPrefs.showRadar,
|
||||
builder: (BuildContext context, bool on, _) => !on
|
||||
? const SizedBox.shrink()
|
||||
: ListenableBuilder(
|
||||
listenable: Listenable.merge(<Listenable?>[widget.vision, widget.aircraft, widget.video]),
|
||||
builder: (BuildContext context, _) {
|
||||
final VisionRadar radar = VisionRadar.of(widget.vision);
|
||||
final RadarBand band = VisionRadar.bandFor(radar.level);
|
||||
if (band == RadarBand.danger && _lastBand != RadarBand.danger) HapticFeedback.heavyImpact();
|
||||
_lastBand = band;
|
||||
if (radar.isEmpty) return const SizedBox.shrink(key: ValueKey<String>('radar-empty'));
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints c) => CustomPaint(
|
||||
key: const ValueKey<String>('vision-radar'),
|
||||
size: c.biggest,
|
||||
painter: RadarPainter(
|
||||
radar: radar,
|
||||
content: VisionGeometry.contentRect(c.biggest, widget.video?.value),
|
||||
showBlocked: widget.aircraft.motorsOn == true,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RadarPainter extends CustomPainter {
|
||||
RadarPainter({required this.radar, required this.content, required this.showBlocked});
|
||||
|
||||
final VisionRadar radar;
|
||||
final Rect content;
|
||||
final bool showBlocked;
|
||||
|
||||
/// Bearing (° clockwise from the nose, which is screen-up) → canvas radians.
|
||||
static double _rad(double bearing) => (bearing - 90) * math.pi / 180;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final Offset c = content.center;
|
||||
// Clear of the battery bar above and the telemetry strip below at phone sizes.
|
||||
final double r = content.shortestSide * 0.26;
|
||||
const double bar = 4, gap = 3;
|
||||
for (final RadarSideView side in radar.sides.values) {
|
||||
for (final RadarSector s in side.sectors) {
|
||||
final int bars = s.band.index; // notice 1, caution 2, danger 3
|
||||
if (bars == 0) continue;
|
||||
final Paint p = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = bar
|
||||
..strokeCap = StrokeCap.butt
|
||||
..color = RadarColors.of(s.band);
|
||||
// One degree of air between neighbouring sectors.
|
||||
final double start = _rad(s.start + 0.5), sweep = (s.sweep - 1) * math.pi / 180;
|
||||
for (int i = 0; i < bars; i++) {
|
||||
final double radius = r - i * (bar + gap);
|
||||
canvas.drawArc(Rect.fromCircle(center: c, radius: radius), start, sweep, false, p);
|
||||
}
|
||||
}
|
||||
final double a = _rad(side.side.bearing);
|
||||
final Offset at = c + Offset(math.cos(a), math.sin(a)) * (r + 16);
|
||||
if (side.blocked && showBlocked) {
|
||||
_label(canvas, at, 'BLOCKED', HudColors.caution);
|
||||
} else if (side.closest != null && side.level > 0) {
|
||||
_label(canvas, at, '${side.closest!.toStringAsFixed(1)} m', RadarColors.of(side.band));
|
||||
}
|
||||
}
|
||||
final double? up = radar.up;
|
||||
if (up != null) {
|
||||
// Inside the ring, under the front arcs: above them it would crowd the banners.
|
||||
_label(canvas, c + Offset(0, -(r - 26)), 'UP ${up.toStringAsFixed(1)} m', RadarColors.of(VisionRadar.bandFor(VisionRadar.levelFor(up))));
|
||||
}
|
||||
}
|
||||
|
||||
void _label(Canvas canvas, Offset centre, String text, Color color) {
|
||||
final TextPainter tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: HudText.monoSmall.copyWith(color: color, fontWeight: FontWeight.w700, shadows: const <Shadow>[Shadow(blurRadius: 3)]),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
final Rect box = Rect.fromCenter(center: centre, width: tp.width + 8, height: tp.height + 4);
|
||||
canvas.drawRRect(RRect.fromRectAndRadius(box, const Radius.circular(3)), Paint()..color = const Color(0x8C081020));
|
||||
tp.paint(canvas, box.topLeft + const Offset(4, 2));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(RadarPainter old) => old.radar != radar || old.content != content || old.showBlocked != showBlocked;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../dji_service.dart';
|
||||
import '../../../../fly/sensing_distances.dart';
|
||||
import '../../hud_kit.dart';
|
||||
import '../camera_menu/camera_rows.dart';
|
||||
|
||||
/// Settings → Vision → obstacle distances (plan P8): where the aircraft brakes
|
||||
/// and how far it looks, per direction the aircraft reports. Read once when the
|
||||
/// tab opens; a step writes at once and rolls back if the aircraft refuses.
|
||||
class SensingDistanceRows extends StatefulWidget {
|
||||
const SensingDistanceRows({super.key, required this.dji, required this.connected, required this.onError});
|
||||
|
||||
final DjiService dji;
|
||||
final bool connected;
|
||||
final void Function(String label, Object error) onError;
|
||||
|
||||
@override
|
||||
State<SensingDistanceRows> createState() => _SensingDistanceRowsState();
|
||||
}
|
||||
|
||||
class _SensingDistanceRowsState extends State<SensingDistanceRows> {
|
||||
SensingDistances? _d;
|
||||
bool _loading = false;
|
||||
final Set<(SensingKind, SensingDirection)> _pending = <(SensingKind, SensingDirection)>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SensingDistanceRows old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.connected && !old.connected) _load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
if (!widget.connected || _loading) return;
|
||||
_loading = true;
|
||||
try {
|
||||
final SensingDistances d = SensingDistances.fromBridge(await widget.dji.getSensingDistances());
|
||||
if (mounted) setState(() => _d = d);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _d = const SensingDistances());
|
||||
} finally {
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _step(SensingKind k, SensingDirection dir, int steps) async {
|
||||
final SensingDistances? before = _d;
|
||||
final double? v = before?.value(k, dir);
|
||||
if (before == null || v == null) return;
|
||||
final double next = SensingDistances.stepped(k, dir, v, steps);
|
||||
if (next == v) return;
|
||||
setState(() {
|
||||
_d = before.withValue(k, dir, next);
|
||||
_pending.add((k, dir));
|
||||
});
|
||||
try {
|
||||
await (k == SensingKind.avoidance
|
||||
? widget.dji.setAvoidanceDistance(dir.sdk, next)
|
||||
: widget.dji.setMaxPerceptionDistance(dir.sdk, next));
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _d = before);
|
||||
widget.onError(k == SensingKind.avoidance ? 'Braking distance' : 'Sensing range', e);
|
||||
} finally {
|
||||
if (mounted) setState(() => _pending.remove((k, dir)));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final SensingDistances? d = _d;
|
||||
// Nothing reported (or not connected): the rows stay out of the way.
|
||||
if (d == null || d.isEmpty) return const SizedBox.shrink();
|
||||
Widget row(SensingKind k, SensingDirection dir) {
|
||||
final double? v = d.value(k, dir);
|
||||
if (v == null) return const SizedBox.shrink();
|
||||
final ({double min, double max, double step}) r = SensingDistances.range(k, dir);
|
||||
final String what = k == SensingKind.avoidance ? 'braking distance' : 'sensing range';
|
||||
return StepperTile(
|
||||
key: ValueKey<String>('row-sensing-${k.name}-${dir.name}'),
|
||||
id: 'sensing-${k.name}-${dir.name}',
|
||||
label: '${dir.label} $what',
|
||||
hint: k == SensingKind.avoidance ? 'The aircraft brakes this far from an obstacle' : 'Obstacles closer than this are reported',
|
||||
shown: '${v.toStringAsFixed(r.step < 1 ? 1 : 0)} m',
|
||||
canDown: v > r.min,
|
||||
canUp: v < r.max,
|
||||
pending: _pending.contains((k, dir)),
|
||||
onStep: (int s) => _step(k, dir, s),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 2),
|
||||
child: Text('OBSTACLE DISTANCES', style: HudText.label),
|
||||
),
|
||||
for (final SensingKind k in SensingKind.values)
|
||||
for (final SensingDirection dir in SensingDirection.values) row(k, dir),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,25 @@ import '../../../../flight_model.dart';
|
||||
import '../../../../fly/fly_prefs.dart';
|
||||
import '../../../../fly/hud_features.dart';
|
||||
import '../../../../fly/product_profile.dart';
|
||||
import '../../../../fly/safety_gate.dart';
|
||||
import '../../../../fly/settings/settings_spec.dart';
|
||||
import '../../../../fly/settings/settings_tabs.dart';
|
||||
import '../../../../fly/state/key_state.dart';
|
||||
import '../../../../fly/state/parse.dart';
|
||||
import '../../../../fly/virtual_sticks.dart' show StickMode;
|
||||
import '../../../../theme.dart';
|
||||
import '../../../pv_icons.dart';
|
||||
import '../../debug/product_profile_page.dart';
|
||||
import '../../debug/telemetry_inspector_page.dart';
|
||||
import '../../dialogs/slide_to_confirm.dart';
|
||||
import '../../hud_kit.dart';
|
||||
import '../../overlays/airsense.dart';
|
||||
import '../../overlays/fly_zone_licences.dart';
|
||||
import '../../overlays/fly_zones.dart';
|
||||
import '../camera_menu/camera_rows.dart';
|
||||
import '../gimbal_sheet.dart';
|
||||
import 'calibration/calibration_hub.dart';
|
||||
import 'sensing_rows.dart';
|
||||
import 'setting_tiles.dart';
|
||||
|
||||
/// Opens the aircraft settings (plan P6, GO 4's "•••") as a right-side glass
|
||||
@@ -280,6 +286,49 @@ class _FlySettingsSheetState extends State<FlySettingsSheet> {
|
||||
SettingsTabs.clearRoute => widget.onClearRoute == null
|
||||
? const SizedBox.shrink()
|
||||
: ButtonTile(id: 'clearRoute', label: 'Clear flight route', button: 'Clear', onTap: widget.onClearRoute),
|
||||
SettingsTabs.radarChart => ValueListenableBuilder<bool>(
|
||||
valueListenable: FlyPrefs.showRadar,
|
||||
builder: (BuildContext context, bool on, _) => SwitchTile(
|
||||
key: const ValueKey<String>('row-radarChart'),
|
||||
label: 'Display radar chart',
|
||||
hint: 'Obstacle distances around the centre of the video',
|
||||
value: on,
|
||||
onChanged: FlyPrefs.setShowRadar,
|
||||
),
|
||||
),
|
||||
SettingsTabs.stickPads => ValueListenableBuilder<StickMode>(
|
||||
valueListenable: FlyPrefs.stickMode,
|
||||
builder: (BuildContext context, StickMode m, _) => ChoiceTile(
|
||||
key: const ValueKey<String>('row-stickPads'),
|
||||
label: 'Stick mode',
|
||||
hint: 'What each on-screen stick does; match your remote',
|
||||
options: <(String, String)>[for (final StickMode s in StickMode.values) (s.name, s.label)],
|
||||
selected: m.name,
|
||||
onSelect: (String v) => FlyPrefs.setStickMode(StickMode.values.byName(v)),
|
||||
),
|
||||
),
|
||||
SettingsTabs.sensingDistances =>
|
||||
SensingDistanceRows(key: const ValueKey<String>('row-sensingDistances'), dji: widget.dji, connected: c.connected, onError: widget.onError),
|
||||
SettingsTabs.flyZoneList => FlyZoneList(
|
||||
key: const ValueKey<String>('row-flyZones'),
|
||||
model: _m,
|
||||
gate: SafetyGate(model: _m, dji: widget.dji),
|
||||
onError: widget.onError,
|
||||
shrinkWrap: true,
|
||||
),
|
||||
SettingsTabs.flyZoneLicences => FlyZoneLicences(
|
||||
key: const ValueKey<String>('row-flyZoneLicences'),
|
||||
model: _m,
|
||||
gate: SafetyGate(model: _m, dji: widget.dji),
|
||||
onError: widget.onError,
|
||||
),
|
||||
SettingsTabs.flyZoneDatabase => FlyZoneDatabaseRow(
|
||||
key: const ValueKey<String>('row-flyZoneDatabase'),
|
||||
zones: _m.flyZones,
|
||||
gate: SafetyGate(model: _m, dji: widget.dji),
|
||||
onError: widget.onError,
|
||||
),
|
||||
SettingsTabs.airSenseList => AirSenseList(key: const ValueKey<String>('row-airSense'), airSense: _m.airSense, shrinkWrap: true),
|
||||
SettingsTabs.about => _about(),
|
||||
SettingsTabs.developer => _developer(),
|
||||
_ => const SizedBox.shrink(),
|
||||
@@ -439,6 +488,17 @@ class _FlySettingsSheetState extends State<FlySettingsSheet> {
|
||||
builder: (BuildContext context, bool on, _) =>
|
||||
SwitchTile(label: 'HUD performance overlay', value: on, onChanged: FlyPrefs.setPerfOverlay),
|
||||
),
|
||||
if (HudFeatures.virtualSticks.enabledFor(_profile) || !_profile.connected)
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: FlyPrefs.sticksInRealFlight,
|
||||
builder: (BuildContext context, bool on, _) => SwitchTile(
|
||||
key: const ValueKey<String>('row-sticksRealFlight'),
|
||||
label: 'On-screen sticks in real flight',
|
||||
hint: 'Off: the sticks only fly the simulator on aircraft with a remote',
|
||||
value: on,
|
||||
onChanged: FlyPrefs.setSticksInRealFlight,
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user