Files
PilotVault/Fly App/lib/ui/fly/top/status_bar.dart
T
tajniak81andClaude Opus 5.5 284de5442a Give the Fly App a GO 4-style settings sheet
The "•••" button now opens a right-side glass sheet over the live view
with Safety, Vision, Remote, Video link, Battery, Gimbal and General
tabs. Each row is a one-line spec bound to a KeyManager key: rows hide
when the aircraft does not support their key, writes show at once and
roll back if refused, and ground-only settings lock in flight.

KeyBridge gains argument-less action keys and converts range and LED
settings values. settings_menu_page.dart is retired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:13:45 +02:00

348 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import '../../../flight_model.dart';
import '../../../fly/flight_status.dart';
import '../../../fly/hud_features.dart';
import '../../../fly/state/alert_state.dart';
import '../../../fly/state/link_state.dart';
import '../../../fly/state/vision_state.dart';
import '../../../theme.dart';
import '../../pv_icons.dart';
import '../hud_kit.dart';
/// Row 1 of the flight HUD (plan P2): back · status chip · GPS · flight mode ·
/// vision · tip marquee · RC / HD (or Wi-Fi) signal · battery · "•••".
///
/// Every cluster listens to the one sub-model it draws, so a 10 Hz flight
/// controller tick repaints the GPS and mode text, not the whole bar.
class StatusBar extends StatelessWidget {
const StatusBar({super.key, required this.model, this.onBack, this.onStatus, this.onMore});
final FlightModel model;
final VoidCallback? onBack;
/// Tapping the status chip — the Aircraft Status sheet, from P3.
final VoidCallback? onStatus;
final VoidCallback? onMore;
static const double height = 28;
@override
Widget build(BuildContext context) {
return SizedBox(
height: height,
child: Row(children: <Widget>[
GlassPill(
height: height,
onTap: onBack,
padding: const EdgeInsets.symmetric(horizontal: 6),
child: const PVIcon('chevronLeft', size: 16, color: Glass.ink),
),
const SizedBox(width: 6),
_StatusChip(model: model, onTap: onStatus),
const SizedBox(width: 6),
_GpsCluster(model: model),
const SizedBox(width: 6),
_ModeText(model: model),
Requires(
HudFeatures.visionIndicator,
child: Padding(padding: const EdgeInsets.only(left: 6), child: _VisionIcon(vision: model.vision)),
),
const SizedBox(width: 8),
Expanded(child: _TipMarquee(alerts: model.alerts)),
const SizedBox(width: 8),
_SimBadge(model: model),
Requires(
HudFeatures.masterSlaveBadge,
whenDisconnected: false,
child: _RoleBadge(links: model.links),
),
_SignalCluster(links: model.links),
const SizedBox(width: 6),
_BatteryChip(model: model),
const SizedBox(width: 6),
GlassPill(
key: const ValueKey<String>('settings-button'),
height: height,
onTap: onMore,
padding: const EdgeInsets.symmetric(horizontal: 7),
child: const PVIcon('more', size: 16, color: Glass.ink),
),
]),
);
}
}
class _StatusChip extends StatelessWidget {
const _StatusChip({required this.model, this.onTap});
final FlightModel model;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: Listenable.merge(<Listenable>[model.aircraft, model.alerts]),
builder: (BuildContext context, _) {
final FlightStatus s = FlightStatus.of(connected: model.connected, a: model.aircraft, alerts: model.alerts);
return GlassPill(
height: StatusBar.height,
color: HudColors.forLevel(s.level),
onTap: onTap,
padding: const EdgeInsets.symmetric(horizontal: 10),
// Capped, so a long status can never push the bar into overflow.
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 170),
child: Text(s.text,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
style: HudText.sans.copyWith(color: Colors.white, fontWeight: FontWeight.w700)),
),
);
},
);
}
}
class _GpsCluster extends StatelessWidget {
const _GpsCluster({required this.model});
final FlightModel model;
@override
Widget build(BuildContext context) {
return GlassPill(
height: StatusBar.height,
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
const PVIcon('satellite', size: 14, color: Glass.ink),
const SizedBox(width: 4),
ListenableBuilder(
listenable: model.aircraft,
builder: (BuildContext context, _) => Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
SignalBars(level: model.aircraft.gpsSignalLevel ?? 0),
const SizedBox(width: 4),
Text('${model.aircraft.satellites ?? 0}', style: HudText.mono),
]),
),
]),
);
}
}
class _ModeText extends StatelessWidget {
const _ModeText({required this.model});
final FlightModel model;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: model.aircraft,
builder: (BuildContext context, _) {
final String? mode = model.aircraft.flightMode;
final String? orientation = switch (model.aircraft.orientationMode) {
'COURSE_LOCK' => 'CL',
'HOME_LOCK' => 'HL',
_ => null,
};
return GlassPill(
height: StatusBar.height,
child: Text(
<String>[if (mode != null && mode.isNotEmpty) mode else '—', if (orientation != null) orientation].join(' · '),
style: HudText.mono.copyWith(fontWeight: FontWeight.w700),
),
);
},
);
}
}
class _VisionIcon extends StatelessWidget {
const _VisionIcon({required this.vision});
final VisionState vision;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: vision,
builder: (BuildContext context, _) {
final Iterable<VisionReading> r = vision.sensors.values;
final Color c = r.any((VisionReading s) => s.dangerous)
? HudColors.danger
: r.any((VisionReading s) => s.isSensorBeingUsed)
? Glass.sat
: HudColors.off;
return GlassPill(height: StatusBar.height, child: PVIcon('obstacle', size: 14, color: c));
},
);
}
}
class _TipMarquee extends StatelessWidget {
const _TipMarquee({required this.alerts});
final AlertState alerts;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: alerts,
builder: (BuildContext context, _) {
final DiagnosticAlert? top = alerts.top;
if (top == null) return const SizedBox.shrink();
final Color c = switch (top.severity) {
AlertSeverity.critical => HudColors.danger,
AlertSeverity.warning => HudColors.caution,
AlertSeverity.info => Glass.ink,
};
final int more = alerts.all.length - 1;
return GlassPill(
height: StatusBar.height,
child: Row(children: <Widget>[
Container(width: 6, height: 6, decoration: BoxDecoration(color: c, shape: BoxShape.circle)),
const SizedBox(width: 6),
Expanded(child: Marquee(text: top.reason, style: HudText.sans.copyWith(color: c))),
if (more > 0) Padding(padding: const EdgeInsets.only(left: 6), child: Text('+$more', style: HudText.monoSmall)),
]),
);
},
);
}
}
class _SimBadge extends StatelessWidget {
const _SimBadge({required this.model});
final FlightModel model;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: model.aircraft,
builder: (BuildContext context, _) => model.aircraft.simulatorActive == true
? Padding(
padding: const EdgeInsets.only(right: 6),
child: GlassPill(
height: StatusBar.height,
color: const Color(0xCC7A4DD8),
child: Text('SIM', style: HudText.mono.copyWith(fontWeight: FontWeight.w700)),
),
)
: const SizedBox.shrink(),
);
}
}
class _RoleBadge extends StatelessWidget {
const _RoleBadge({required this.links});
final LinkState links;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: links,
builder: (BuildContext context, _) {
final String? role = links.rcRoleBadge;
if (role == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(right: 6),
child: GlassPill(
height: StatusBar.height,
color: links.masterSlaveConnected == false ? HudColors.caution : Glass.accent,
child: Text(role, style: HudText.mono.copyWith(fontWeight: FontWeight.w700, color: Colors.white)),
),
);
},
);
}
}
/// RC + HD bars on Lightbridge / OcuSync products, one Wi-Fi cluster on Wi-Fi ones.
class _SignalCluster extends StatelessWidget {
const _SignalCluster({required this.links});
final LinkState links;
@override
Widget build(BuildContext context) {
final Widget rcHd = GlassPill(
height: StatusBar.height,
child: ListenableBuilder(
listenable: links,
builder: (BuildContext context, _) => Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
const Text('RC', style: HudText.label),
const SizedBox(width: 3),
SignalBars(level: LinkState.bars(links.uplinkQuality)),
const SizedBox(width: 8),
const Text('HD', style: HudText.label),
const SizedBox(width: 3),
SignalBars(level: LinkState.bars(links.downlinkQuality)),
]),
),
);
final Widget wifi = GlassPill(
height: StatusBar.height,
child: ListenableBuilder(
listenable: links,
builder: (BuildContext context, _) => Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
const Text('WI-FI', style: HudText.label),
const SizedBox(width: 3),
// A Wi-Fi link is only as good as its weaker direction.
SignalBars(level: LinkState.bars(_min(links.uplinkQuality, links.downlinkQuality))),
]),
),
);
return Requires(
HudFeatures.wifiSignal,
whenDisconnected: false,
fallback: rcHd,
child: wifi,
);
}
static int? _min(int? a, int? b) => a == null ? b : (b == null ? a : (a < b ? a : b));
}
class _BatteryChip extends StatelessWidget {
const _BatteryChip({required this.model});
final FlightModel model;
@override
Widget build(BuildContext context) {
return GlassPill(
height: StatusBar.height,
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
const PVIcon('battery', size: 16, color: Glass.ink),
const SizedBox(width: 4),
ListenableBuilder(
listenable: model.battery,
builder: (BuildContext context, _) {
final int? p = model.battery.percent;
final double? v = model.battery.voltage;
final int low = model.battery.lowWarningThreshold ?? 30;
final int critical = model.battery.seriousLowWarningThreshold ?? 15;
final Color c = p == null
? Glass.ink
: p <= critical
? HudColors.danger
: p <= low
? HudColors.caution
: Glass.sat;
return Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
Text(p == null ? '—' : '$p%', style: HudText.mono.copyWith(color: c, fontWeight: FontWeight.w700)),
if (v != null) ...<Widget>[
const SizedBox(width: 5),
Text('${v.toStringAsFixed(1)}V', style: HudText.monoSmall.copyWith(color: HudText.dim)),
],
]);
},
),
]),
);
}
}