Files
PilotVault/Fly App/lib/fly/state/fly_zone_state.dart
T
tajniak81andClaude Opus 5.5 636da1ac2c Give the Fly App every remaining GO 4 flight mode and the missing settings
Intelligent modes: Point of Interest (orbit on the map, live radius and
speed), Follow Me (follows the phone), QuickShots on a boxed subject,
Course Lock / Home Lock, Tripod and Cinematic, a Free hyperlapse, and
Waypoints 2.0 for V2-only aircraft. ActiveTrack gains Spotlight Pro and
Circle. Every mode has a first-use tutorial and stops from the rail.
Waypoints can resume an interrupted mission, change cruise speed in
flight and check terrain clearance (Open-Meteo, on tap).

Settings: units, C1/C2 actions, dual-operator master/slave, HDMI/SDI
output, a link spectrum chart, Wi-Fi name and password, aircraft name,
video cache, battery warning history and pairing, non-smart cell
voltages. Calibration: compass interference meter, per-IMU calibration,
lens infinity focus and a first-manual-focus prompt. Also an RTK chip,
gimbal movement presets and motor tuning, Precision Takeoff gated on the
flight controller, and the precise GEO database's server version.

Built and unit/widget-tested only; nothing has flown yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:06:16 +02:00

475 lines
18 KiB
Dart

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) {
final FlyZoneDatabase next = FlyZoneDatabase.fromMap(m);
// A read that came before the remote check keeps the version heard since.
database = next.preciseRemoteVersion == null ? next.withRemote(database?.preciseRemoteVersion) : next;
notifyListeners();
}
/// The SDK heard which precise database DJI's server has (`flyZoneDbRemote`).
void applyRemoteVersion(String? v) {
database = (database ?? const FlyZoneDatabase()).withRemote(v);
notifyListeners();
}
/// 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, this.preciseRemoteVersion});
factory FlyZoneDatabase.fromMap(Map<String, dynamic> m) => FlyZoneDatabase(
basicVersion: asString(m['basicVersion']),
basicState: asString(m['basicState']),
preciseVersion: asString(m['preciseVersion']),
preciseState: asString(m['preciseState']),
preciseRemoteVersion: asString(m['preciseRemoteVersion']),
);
final String? basicVersion;
final String? basicState; // INITIALIZING / NO_INTERNET_CONNECTION / OUT_OF_DATE / UP_TO_DATE / UNKNOWN
final String? preciseVersion;
final String? preciseState;
/// The newest precise database on DJI's server, once the SDK has asked.
final String? preciseRemoteVersion;
FlyZoneDatabase withRemote(String? v) => FlyZoneDatabase(
basicVersion: basicVersion,
basicState: basicState,
preciseVersion: preciseVersion,
preciseState: preciseState,
preciseRemoteVersion: v,
);
/// DJI has a newer precise database than the aircraft's (it updates with the firmware).
bool get preciseBehind =>
preciseRemoteVersion != null && preciseVersion != null && preciseRemoteVersion!.isNotEmpty && preciseRemoteVersion != preciseVersion;
bool get outOfDate => basicState == 'OUT_OF_DATE' || preciseState == 'OUT_OF_DATE' || preciseBehind;
static String stateLabel(String? s) => switch (s) {
'UP_TO_DATE' => 'Up to date',
'OUT_OF_DATE' => 'Out of date',
'NO_INTERNET_CONNECTION' => 'No internet connection',
'INITIALIZING' => 'Initializing',
_ => 'Unknown',
};
}