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>
348 lines
11 KiB
Dart
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)),
|
|
],
|
|
]);
|
|
},
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
}
|