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) {