Take-off, land, RTH, the landing cancel/confirm and Stop now all go through one SafetyGate (lib/fly/safety_gate.dart). The gate sorts its preconditions into blockers and warnings: connection, flight state, battery against the thresholds, IMU warm-up, compass, critical diagnostics, GPS, home point, SD card and the RTH battery budget. It checks again right before sending. The left rail is now a state machine (lib/fly/action_rail_state.dart): Take-off becomes Land, then Cancel or Confirm landing. RTH becomes Cancel RTH. A Stop button shows while an intelligent mode is flying. Moving the aircraft needs a slide-to-confirm dialog, which can offer Precision Takeoff or set the RTH altitude. A blocked command shows why and has no slider. When the pilot is more than 30 m from home, RTH asks whether to return to them or to the original home point. The HUD gains an error stack. Its cards can be dismissed, it fits the space it has, and a new warning triggers haptic feedback. It also gets a smart-RTH countdown banner with Cancel / Go home now, a forced-landing banner, and an Aircraft Status sheet. The sheet opens from the status chip and appears before each connection's first take-off. It offers Calibrate compass/IMU and Format SD. On the bridge side this adds precisionTakeOff, setHomeLocation, compass and IMU calibration start/stop, formatStorage, and compass health in the telemetry tick. The classic flight screen stays available until the simulator run passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
167 lines
6.0 KiB
Dart
167 lines
6.0 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../../../fly/state/alert_state.dart';
|
|
import '../../../theme.dart';
|
|
import '../../pv_icons.dart';
|
|
import '../hud_kit.dart';
|
|
|
|
/// The error stack (plan P3, GO 4's `fpv_error_pop_item`): the aircraft's
|
|
/// live diagnostics as up to [maxCards] dismissible cards, most severe first.
|
|
///
|
|
/// [AlertState] already de-duplicates by code and keeps a dismissed card hidden
|
|
/// until its fault clears; this widget adds the haptic buzz when a new warning
|
|
/// or critical fault appears. Info-level messages stay in the tip marquee only.
|
|
///
|
|
/// It shows as many cards as fit the height it is given; what does not fit
|
|
/// collapses into a "+N more" pill ([onMore], the Aircraft Status sheet).
|
|
class AlertStack extends StatefulWidget {
|
|
const AlertStack({super.key, required this.alerts, this.width = 280, this.onMore});
|
|
|
|
final AlertState alerts;
|
|
final double width;
|
|
final VoidCallback? onMore;
|
|
|
|
static const int maxCards = 6;
|
|
|
|
/// A two-line card plus its gap, for working out how many fit.
|
|
static const double cardExtent = 41;
|
|
|
|
@override
|
|
State<AlertStack> createState() => _AlertStackState();
|
|
}
|
|
|
|
class _AlertStackState extends State<AlertStack> {
|
|
DateTime? _seen;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_seen = widget.alerts.updatedAt;
|
|
widget.alerts.addListener(_onAlerts);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(AlertStack old) {
|
|
super.didUpdateWidget(old);
|
|
if (old.alerts != widget.alerts) {
|
|
old.alerts.removeListener(_onAlerts);
|
|
widget.alerts.addListener(_onAlerts);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.alerts.removeListener(_onAlerts);
|
|
super.dispose();
|
|
}
|
|
|
|
/// Buzzes once per bridge update that added a warning or worse — not on a
|
|
/// dismissal, which notifies too but leaves [AlertState.updatedAt] alone.
|
|
void _onAlerts() {
|
|
final AlertState a = widget.alerts;
|
|
if (a.updatedAt == _seen) return;
|
|
_seen = a.updatedAt;
|
|
AlertSeverity? worst;
|
|
for (final DiagnosticAlert d in a.all) {
|
|
if (!a.lastAdded.contains(d.key)) continue;
|
|
if (worst == null || d.severity.index > worst.index) worst = d.severity;
|
|
}
|
|
if (worst == AlertSeverity.critical) {
|
|
HapticFeedback.heavyImpact();
|
|
} else if (worst == AlertSeverity.warning) {
|
|
HapticFeedback.mediumImpact();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListenableBuilder(
|
|
listenable: widget.alerts,
|
|
builder: (BuildContext context, _) {
|
|
final List<DiagnosticAlert> all = <DiagnosticAlert>[
|
|
for (final DiagnosticAlert a in widget.alerts.active)
|
|
if (a.severity != AlertSeverity.info) a,
|
|
];
|
|
return LayoutBuilder(builder: (BuildContext context, BoxConstraints box) {
|
|
final int room = box.maxHeight.isFinite ? (box.maxHeight / AlertStack.cardExtent).floor() : AlertStack.maxCards;
|
|
final int fit = room.clamp(1, AlertStack.maxCards);
|
|
// When some must go, the last slot becomes the "+N more" pill.
|
|
final int shown = all.length <= fit ? all.length : fit - 1;
|
|
final int hidden = all.length - shown;
|
|
return SizedBox(
|
|
width: widget.width,
|
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
|
for (final DiagnosticAlert a in all.take(shown))
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: AlertCard(
|
|
key: ValueKey<String>('alert-${a.key}'),
|
|
alert: a,
|
|
onClose: () => widget.alerts.dismiss(a.key),
|
|
),
|
|
),
|
|
if (hidden > 0)
|
|
GlassPill(
|
|
key: const ValueKey<String>('alert-more'),
|
|
height: 24,
|
|
color: const Color(0xCC0B1426),
|
|
onTap: widget.onMore,
|
|
child: Text('+$hidden more', style: HudText.sans.copyWith(fontSize: 11, color: AlertCard.colorOf(all[shown].severity))),
|
|
),
|
|
]),
|
|
);
|
|
});
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class AlertCard extends StatelessWidget {
|
|
const AlertCard({super.key, required this.alert, required this.onClose});
|
|
|
|
final DiagnosticAlert alert;
|
|
final VoidCallback onClose;
|
|
|
|
static Color colorOf(AlertSeverity s) => switch (s) {
|
|
AlertSeverity.critical => HudColors.danger,
|
|
AlertSeverity.warning => HudColors.caution,
|
|
AlertSeverity.info => Glass.ink,
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final Color c = colorOf(alert.severity);
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xCC0B1426),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border(left: BorderSide(color: c, width: 3)),
|
|
),
|
|
padding: const EdgeInsets.fromLTRB(8, 5, 2, 5),
|
|
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
|
Padding(padding: const EdgeInsets.only(top: 1), child: PVIcon('alert', size: 13, color: c)),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: <Widget>[
|
|
Text(alert.reason,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: HudText.sans.copyWith(fontSize: 11.5, fontWeight: FontWeight.w700, color: c)),
|
|
if (alert.solution != null)
|
|
Text(alert.solution!,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: HudText.sans.copyWith(fontSize: 10.5, fontWeight: FontWeight.w400, color: HudText.dim)),
|
|
]),
|
|
),
|
|
GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onClose,
|
|
child: const Padding(padding: EdgeInsets.fromLTRB(6, 0, 6, 4), child: PVIcon('close', size: 13, color: HudText.dim)),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
}
|