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 createState() => _AlertStackState(); } class _AlertStackState extends State { 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 all = [ 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: [ for (final DiagnosticAlert a in all.take(shown)) Padding( padding: const EdgeInsets.only(bottom: 4), child: AlertCard( key: ValueKey('alert-${a.key}'), alert: a, onClose: () => widget.alerts.dismiss(a.key), ), ), if (hidden > 0) GlassPill( key: const ValueKey('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: [ 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: [ 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)), ), ]), ); } }