The new flight screen (lib/ui/fly/fly_screen.dart) is built from layers. Each readout listens to its own telemetry sub-model, so a 10 Hz tick only repaints what changed. It adds a status bar with a status chip and tip marquee, a smart battery bar with land/RTH markers, a camera info strip, an attitude ball, an H/D/VS/HS/VPS strip, and a minimap that swaps places with the video when tapped. The HUD is laid out in design pixels and scaled to phone or tablet inside the safe area. What the HUD shows depends on the product: dual or multi battery bars, SSD status, a thermal spot reading, a master/slave RC badge, and Wi-Fi bars in place of RC/HD. The bridges now report the data for these: the camera's ssd/thermal groups and the RC's master/slave mode. The old FlightControlPage can still be opened from Settings > About > Classic flight screen until the P3 safety flows land. The same tab also gets a toggle for the performance overlay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
9.3 KiB
Dart
249 lines
9.3 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import '../../fly/flight_status.dart';
|
||
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,
|
||
};
|
||
}
|
||
|
||
/// 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);
|