Files
PilotVault/Fly App/lib/ui/fly/hud_kit.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

262 lines
9.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
/// element is laid out in, and the small glass widgets they are built from.
///
/// The HUD is designed once for a ~390 dp-tall landscape phone. [HudCanvas]
/// scales that layout to the device ([HudScale.scaleFor]) inside the safe-area
/// insets, so phone and tablet get the same arrangement at a readable size —
/// GO 4 ships `layout-w1024dp` / `w1280dp` variants for the same reason.
class HudScale extends InheritedWidget {
const HudScale({super.key, required this.scale, required this.tablet, required super.child});
final double scale;
final bool tablet;
/// Shortest side at which the tablet breakpoint starts.
static const double tabletBreakpoint = 600;
/// Scale factor from the device's shortest side: phones shrink a little below
/// the design height, tablets grow.
static double scaleFor(double shortestSide) => shortestSide >= tabletBreakpoint
? (shortestSide / tabletBreakpoint * 1.2).clamp(1.2, 1.6)
: (shortestSide / 390).clamp(0.8, 1.0);
static HudScale? maybeOf(BuildContext context) => context.dependOnInheritedWidgetOfExactType<HudScale>();
@override
bool updateShouldNotify(HudScale oldWidget) => scale != oldWidget.scale || tablet != oldWidget.tablet;
}
/// Lays [builder]'s HUD out in design pixels inside the safe area and scales it
/// to the screen. [builder] receives the canvas's logical size and the scale,
/// for callers that must line up full-screen layers with HUD positions.
class HudCanvas extends StatelessWidget {
const HudCanvas({super.key, required this.builder});
final Widget Function(BuildContext context, Size canvas, double scale) builder;
/// The safe-area-inset rect, in screen pixels, the canvas occupies.
static Rect screenRect(MediaQueryData mq) {
final EdgeInsets pad = _insets(mq);
return Rect.fromLTWH(pad.left, pad.top, mq.size.width - pad.horizontal, mq.size.height - pad.vertical);
}
/// Maps a rect in canvas (design) pixels to screen pixels.
static Rect toScreen(MediaQueryData mq, Rect canvasRect) {
final double s = HudScale.scaleFor(mq.size.shortestSide);
final Rect area = screenRect(mq);
return Rect.fromLTWH(area.left + canvasRect.left * s, area.top + canvasRect.top * s, canvasRect.width * s, canvasRect.height * s);
}
/// Safe-area insets with a minimum gutter, so nothing touches a rounded corner.
static EdgeInsets _insets(MediaQueryData mq) {
const double gutter = 6;
final EdgeInsets p = mq.padding;
return EdgeInsets.fromLTRB(
p.left < gutter ? gutter : p.left,
p.top < gutter ? gutter : p.top,
p.right < gutter ? gutter : p.right,
p.bottom < gutter ? gutter : p.bottom,
);
}
@override
Widget build(BuildContext context) {
final MediaQueryData mq = MediaQuery.of(context);
final double s = HudScale.scaleFor(mq.size.shortestSide);
final Rect area = screenRect(mq);
final Size canvas = Size(area.width / s, area.height / s);
return Stack(children: <Widget>[
Positioned(
left: area.left,
top: area.top,
width: canvas.width,
height: canvas.height,
child: Transform.scale(
scale: s,
alignment: Alignment.topLeft,
child: HudScale(
scale: s,
tablet: mq.size.shortestSide >= HudScale.tabletBreakpoint,
child: MediaQuery.removePadding(
context: context,
removeLeft: true,
removeTop: true,
removeRight: true,
removeBottom: true,
child: Builder(builder: (BuildContext ctx) => builder(ctx, canvas, s)),
),
),
),
),
]);
}
}
/// Text styles of the HUD. Numbers are always Space Mono so they don't jitter.
abstract final class HudText {
static const Color dim = Color(0x99EAF0FA);
static const TextStyle mono = TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink, height: 1.1);
static const TextStyle monoSmall = TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Glass.ink, height: 1.1);
static const TextStyle label = TextStyle(fontFamily: PV.fontMono, fontSize: 9, letterSpacing: 0.8, color: dim, height: 1.1);
static const TextStyle value = TextStyle(fontFamily: PV.fontMono, fontSize: 14, fontWeight: FontWeight.w700, color: Glass.ink, height: 1.1);
static const TextStyle unit = TextStyle(fontFamily: PV.fontMono, fontSize: 9, color: dim, height: 1.1);
static const TextStyle sans = TextStyle(fontFamily: PV.fontSans, fontSize: 12, fontWeight: FontWeight.w600, color: Glass.ink, height: 1.1);
}
/// HUD colours for the status levels.
abstract final class HudColors {
static const Color ready = Color(0xFF2FA36F);
static const Color caution = Color(0xFFE09A3E);
static const Color danger = Color(0xFFE05252);
static const Color neutral = Color(0xB3253552);
static const Color off = Color(0x40EAF0FA);
static Color forLevel(StatusLevel l) => switch (l) {
StatusLevel.ready => ready,
StatusLevel.caution => caution,
StatusLevel.danger => danger,
StatusLevel.neutral => neutral,
};
}
/// 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});
final Widget child;
final double height;
final Color color;
final EdgeInsetsGeometry? padding;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final Widget pill = Container(
height: height,
padding: padding ?? const EdgeInsets.symmetric(horizontal: 9),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(8)),
alignment: Alignment.center,
child: child,
);
return onTap == null ? pill : GestureDetector(behavior: HitTestBehavior.opaque, onTap: onTap, child: pill);
}
}
/// Five rising bars for a 0–5 level (GPS, RC, HD, Wi-Fi).
class SignalBars extends StatelessWidget {
const SignalBars({super.key, required this.level, this.color = Glass.ink, this.height = 12});
final int level;
final Color color;
final double height;
@override
Widget build(BuildContext context) {
final Color on = level <= 1 ? HudColors.danger : (level == 2 ? HudColors.caution : color);
return SizedBox(
height: height,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
for (int i = 1; i <= 5; i++)
Container(
width: 2.5,
height: height * (0.2 + 0.16 * i),
margin: EdgeInsets.only(left: i == 1 ? 0 : 1.5),
decoration: BoxDecoration(color: i <= level ? on : HudColors.off, borderRadius: BorderRadius.circular(1)),
),
],
),
);
}
}
/// A one-line message that scrolls when it does not fit — GO 4's tip marquee.
class Marquee extends StatefulWidget {
const Marquee({super.key, required this.text, required this.style, this.pixelsPerSecond = 28});
final String text;
final TextStyle style;
final double pixelsPerSecond;
@override
State<Marquee> createState() => _MarqueeState();
}
class _MarqueeState extends State<Marquee> with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(vsync: this);
double _overflow = 0;
static const double _gap = 40;
@override
void dispose() {
_c.dispose();
super.dispose();
}
void _configure(double textWidth, double boxWidth) {
final double overflow = textWidth > boxWidth ? textWidth + _gap : 0;
if (overflow == _overflow) return;
_overflow = overflow;
if (overflow == 0) {
_c.stop();
_c.value = 0;
} else {
_c.duration = Duration(milliseconds: (overflow / widget.pixelsPerSecond * 1000).round());
_c.repeat();
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (BuildContext context, BoxConstraints c) {
final TextPainter tp = TextPainter(
text: TextSpan(text: widget.text, style: widget.style),
maxLines: 1,
textDirection: TextDirection.ltr,
)..layout();
final double w = tp.width;
// Starting the animation is a side effect; keep it out of the build itself.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _configure(w, c.maxWidth);
});
final Widget line = Text(widget.text, style: widget.style, maxLines: 1, softWrap: false);
if (w <= c.maxWidth) return Align(alignment: Alignment.centerLeft, child: line);
return ClipRect(
child: AnimatedBuilder(
animation: _c,
builder: (BuildContext context, Widget? child) => OverflowBox(
alignment: Alignment.centerLeft,
maxWidth: double.infinity,
child: Transform.translate(offset: Offset(-_c.value * _overflow, 0), child: child),
),
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[line, const SizedBox(width: _gap), line]),
),
);
});
}
}
/// `12.3`, or `—` when unknown.
String fmtNum(double? v, {int digits = 1}) => v == null ? '—' : v.toStringAsFixed(digits);