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:
tajniak81andClaude Opus 5.5 committed 2026-10-20 21:14:12 +02:00
1 parent 93f4a580e9
commit e02bb0e5cd
34 files changed
+4376 -15

No files matched your search

+71
View File
@@ -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.
+11 -1
View File
@@ -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 ────────────────────────────────────────
+33
View File
@@ -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;
+94
View File
@@ -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.
}
}
}
+8
View File
@@ -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,
+160
View File
@@ -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;
+71
View File
@@ -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'),
+6 -1
View File
@@ -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;
+120
View File
@@ -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();
}
}
+450
View File
@@ -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',
};
}
+59
View File
@@ -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();
+15 -2
View File
@@ -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']));
}
}
+217
View File
@@ -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();
}
}
+175
View File
@@ -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;
}
}
+40 -4
View File
@@ -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)),
);
}
}
+156 -5
View File
@@ -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)),
+13
View File
@@ -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});
+5 -1
View File
@@ -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),
]);
},
);
+264
View File
@@ -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);
}
},
),
]);
},
);
}
}
+333
View File
@@ -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,
),
),
]);
}
}