Files
PilotVault/Fly App/lib/ui/fly/bottom/attitude_ball.dart
T
tajniak81andClaude Opus 5.5 e02bb0e5cd 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>
2026-10-20 21:14:12 +02:00

251 lines
9.5 KiB
Dart

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';
/// GO 4's attitude ball (plan P2): the horizon from the aircraft's pitch and
/// 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});
final FlightModel model;
final double size;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
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,
height: size + 12,
child: Column(children: <Widget>[
SizedBox.square(dimension: size, child: CustomPaint(painter: AttitudeBallPainter(d))),
const SizedBox(height: 1),
Text(d.hasPoints ? '${d.rangeM.toStringAsFixed(0)} m' : '', style: HudText.label),
]),
);
},
);
}
}
/// 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
class AttitudeBallData {
const AttitudeBallData({
this.pitch,
this.roll,
this.heading,
this.gimbalYaw,
this.homeBearing,
this.homeDistance,
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, 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;
if (fix && a.homeLatitude != null && a.homeLongitude != null && a.homeLocationSet != false) {
hb = HudGeometry.bearing(lat, lon, a.homeLatitude!, a.homeLongitude!);
hd = HudGeometry.distance(lat, lon, a.homeLatitude!, a.homeLongitude!);
}
if (fix && phoneLat != null && phoneLon != null) {
pb = HudGeometry.bearing(lat, lon, phoneLat, phoneLon);
pd = HudGeometry.distance(lat, lon, phoneLat, phoneLon);
}
return AttitudeBallData(
pitch: a.pitch,
roll: a.roll,
heading: a.heading,
gimbalYaw: gimbalYaw,
homeBearing: hb,
homeDistance: hd,
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;
final double? gimbalYaw;
final double? homeBearing;
final double? homeDistance;
final double? pilotBearing;
final double? pilotDistance;
final double rangeM;
bool get hasPoints => homeDistance != null || pilotDistance != null;
@override
bool operator ==(Object other) =>
other is AttitudeBallData &&
other.pitch == pitch &&
other.roll == roll &&
other.heading == heading &&
other.gimbalYaw == gimbalYaw &&
other.homeBearing == homeBearing &&
other.homeDistance == homeDistance &&
other.pilotBearing == pilotBearing &&
other.pilotDistance == pilotDistance &&
other.rangeM == rangeM &&
listEquals(other.obstacles, obstacles);
@override
int get hashCode =>
Object.hash(pitch, roll, heading, gimbalYaw, homeBearing, homeDistance, pilotBearing, pilotDistance, rangeM, Object.hashAll(obstacles));
}
class AttitudeBallPainter extends CustomPainter {
AttitudeBallPainter(this.d);
final AttitudeBallData d;
static const Color _sky = Color(0x803D7BF0);
static const Color _ground = Color(0x806B4E2E);
static const Color _pilot = Color(0xFF5B93F5);
@override
void paint(Canvas canvas, Size size) {
final double r = size.shortestSide / 2;
final Offset c = size.center(Offset.zero);
final Rect ball = Rect.fromCircle(center: c, radius: r);
// ── Horizon ────────────────────────────────────────────────────────────
canvas.save();
canvas.clipPath(Path()..addOval(ball));
canvas.drawRect(ball, Paint()..color = _sky);
canvas.translate(c.dx, c.dy);
canvas.rotate(-(d.roll ?? 0) * math.pi / 180);
final double hy = HudGeometry.horizonShift(d.pitch ?? 0) * r;
canvas.drawRect(Rect.fromLTRB(-2 * r, hy, 2 * r, 2 * r), Paint()..color = _ground);
canvas.drawLine(Offset(-r, hy), Offset(r, hy), Paint()
..color = const Color(0xCCEAF0FA)
..strokeWidth = 1);
canvas.restore();
// ── Rim, range ring and north tick ─────────────────────────────────────
canvas.drawCircle(c, r - 0.5, Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = const Color(0x66EAF0FA));
canvas.drawCircle(c, r / 2, Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.6
..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) {
final double a = gy * math.pi / 180;
final Offset dir = Offset(math.sin(a), -math.cos(a));
canvas.drawLine(c + dir * (r - 7), c + dir * (r - 1), Paint()
..color = Glass.subject
..strokeWidth = 2.5
..strokeCap = StrokeCap.round);
}
// ── Home and pilot ─────────────────────────────────────────────────────
final double plot = r - 6;
if (d.homeBearing != null && d.homeDistance != null) {
final Offset p = c +
HudGeometry.ballOffset(bearingDeg: d.homeBearing!, distanceM: d.homeDistance!, radius: plot, rangeM: d.rangeM);
canvas.drawCircle(p, 5.5, Paint()..color = Glass.sat);
_label(canvas, 'H', p, const Color(0xFF0A1120), 7);
}
if (d.pilotBearing != null && d.pilotDistance != null) {
final Offset p = c +
HudGeometry.ballOffset(bearingDeg: d.pilotBearing!, distanceM: d.pilotDistance!, radius: plot, rangeM: d.rangeM);
canvas.drawCircle(p, 3.5, Paint()..color = _pilot);
canvas.drawCircle(p, 3.5, Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = Colors.white);
}
// ── Aircraft arrow (heading, north-up) ─────────────────────────────────
canvas.save();
canvas.translate(c.dx, c.dy);
canvas.rotate((d.heading ?? 0) * math.pi / 180);
final Path arrow = Path()
..moveTo(0, -9)
..lineTo(6, 6)
..lineTo(0, 3)
..lineTo(-6, 6)
..close();
canvas.drawPath(arrow, Paint()..color = d.heading == null ? HudColors.off : Colors.white);
canvas.drawPath(arrow, Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.8
..color = const Color(0xFF0A1120));
canvas.restore();
}
void _label(Canvas canvas, String t, Offset centre, Color color, double size) {
final TextPainter tp = TextPainter(
text: TextSpan(text: t, style: TextStyle(fontFamily: PV.fontMono, fontSize: size, fontWeight: FontWeight.w700, color: color)),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(canvas, centre - Offset(tp.width / 2, tp.height / 2));
}
@override
bool shouldRepaint(AttitudeBallPainter old) => old.d != d;
}