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

+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,
),
),
]);
}
}