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>
262 lines
9.7 KiB
Dart
262 lines
9.7 KiB
Dart
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);
|