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 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 [], }); factory FlyZone.fromMap(Map 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: [for (final Map 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 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 [], required this.at}); factory FlySafeNotice.fromMap(Map 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: [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 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 zones = const []; /// Where [zones] were fetched around, and when. (double, double)? zonesAround; DateTime? zonesAt; Set unlocked = const {}; /// 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 zonesFor(FlySafeNotice n) => [for (final FlyZone z in zones) if (n.zoneIds.contains(z.id)) z]; /// Zones nearest first from ([lat], [lon]). List nearest(double? lat, double? lon) { if (lat == null || lon == null) return zones; return [...zones]..sort((FlyZone a, FlyZone b) => (a.distanceFrom(lat, lon) ?? double.infinity).compareTo(b.distanceFrom(lat, lon) ?? double.infinity)); } void applyState(Map? m) { state = asString(m?['state']); notifyListeners(); } void applyNotice(Map m, {DateTime? now}) { notice = FlySafeNotice.fromMap(m, now ?? DateTime.now()); notifyListeners(); } void applyZones(List> list, {required double latitude, required double longitude, DateTime? now}) { zones = [for (final Map m in list) FlyZone.fromMap(m)]; zonesAround = (latitude, longitude); zonesAt = now ?? DateTime.now(); notifyListeners(); } void applyUnlocked(Iterable 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 licences = const []; 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 m) { licencesSupported = asBool(m['supported']) ?? false; licences = [for (final Map z in asMapList(m['zones'])) FlyZoneLicence.fromMap(z)] ..sort((FlyZoneLicence a, FlyZoneLicence b) => a.index.compareTo(b.index)); notifyListeners(); } void applyDatabase(Map 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 {}; licencesSupported = null; licences = const []; 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 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 => [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 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', }; }