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

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