Files
PilotVault/Fly App/lib/fly/hud_features.dart
T
tajniak81andClaude Opus 5.5 60c2fd7f64 Give the Fly App GO 4-style gimbal controls
Plan P5: a value bubble on the pitch slider while dragging, press-and-drag
on the video as a speed joystick (re-sent at 10 Hz, zero on release), and a
gimbal settings sheet: mode, 30° extension, yaw follow, recenter / face down /
yaw recenter, roll fine-tune, auto calibration and advanced speed/smoothness
rows gated by the gimbal's capability ranges.

GimbalBridge reports mode, trim, calibration and stop/overload state in the
gimbal telemetry group, and getter-only settings in a new gimbalSettings slow
group. The reported-plus-pending overlay moves out of CameraState into a
SettingsOverlay mixin shared by camera and gimbal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:22:42 +02:00

193 lines
9.8 KiB
Dart

import 'package:flutter/widgets.dart';
import 'product_profile.dart';
/// One capability-gated piece of UI: a HUD element, settings row or mode tile.
///
/// Its [requires] predicate is the only place that decides whether the element
/// exists on the connected product — GO 4 does the same with per-product `gone`
/// views. Gate a widget with [Requires]; list every feature in
/// [HudFeatures.all] so the debug screen and the widget tests see it.
class HudFeature {
const HudFeature(this.id, this.label, this.group, this.requires);
final String id;
final String label;
/// Where the element lives (`Status bar`, `Camera`, …), for the debug screen.
final String group;
final bool Function(ProductProfile p) requires;
bool enabledFor(ProductProfile p) => requires(p);
}
abstract final class HudFeatures {
// ── Status bar ────────────────────────────────────────────────────────────
static final HudFeature rcHdSignal = HudFeature(
'status.rcHdSignal', 'RC + HD signal bars', 'Status bar', (ProductProfile p) => p.hasRcLink);
static final HudFeature wifiSignal = HudFeature(
'status.wifiSignal', 'Wi-Fi signal', 'Status bar', (ProductProfile p) => p.hasWifiLink);
static final HudFeature visionIndicator = HudFeature(
'status.vision', 'Vision system icon', 'Status bar', (ProductProfile p) => p.hasVps);
static final HudFeature masterSlaveBadge = HudFeature(
'status.masterSlave', 'Master / slave RC badge', 'Status bar', (ProductProfile p) => p.hasMasterSlaveRc);
static final HudFeature rtkStatus = HudFeature(
'status.rtk', 'RTK status', 'Status bar', (ProductProfile p) => p.hasRtk);
// ── Battery ───────────────────────────────────────────────────────────────
static final HudFeature singleBattery = HudFeature(
'battery.single', 'Single battery bar', 'Battery', (ProductProfile p) => p.batteryCount == 1);
static final HudFeature dualBattery = HudFeature(
'battery.dual', 'Dual battery bars', 'Battery', (ProductProfile p) => p.batteryCount == 2);
static final HudFeature multiBattery = HudFeature(
'battery.multi', 'Multi-battery summary', 'Battery', (ProductProfile p) => p.batteryCount > 2);
static final HudFeature nonSmartBattery = HudFeature(
'battery.nonSmart', 'Cell-voltage actions (non-smart)', 'Battery',
(ProductProfile p) => p.batteryKind == BatteryKind.nonSmart);
// ── Camera ────────────────────────────────────────────────────────────────
static final HudFeature cameraControls = HudFeature(
'camera.controls', 'Shutter + mode column', 'Camera', (ProductProfile p) => p.hasCamera);
static final HudFeature apertureReadout = HudFeature(
'camera.aperture', 'Aperture readout + wheel', 'Camera',
(ProductProfile p) => p.camera?.adjustableApertureSupported == true);
static final HudFeature zoomColumn = HudFeature(
'camera.zoom', 'Zoom column', 'Camera', (ProductProfile p) => p.hasZoom);
static final HudFeature ssdStatus = HudFeature(
'camera.ssd', 'SSD status', 'Camera', (ProductProfile p) => p.hasSsd);
static final HudFeature thermalReadout = HudFeature(
'camera.thermal', 'Thermal readouts', 'Camera', (ProductProfile p) => p.hasThermal);
static final HudFeature spotMetering = HudFeature(
'camera.metering', 'Tap-to-meter', 'Camera', (ProductProfile p) => p.camera?.meteringSupported == true);
static final HudFeature tapFocus = HudFeature(
'camera.focus', 'Tap-to-focus', 'Camera', (ProductProfile p) => p.camera?.adjustableFocalPointSupported == true);
static final HudFeature playback = HudFeature('camera.playback', 'Playback button', 'Camera',
(ProductProfile p) => p.camera?.mediaDownloadSupported == true || p.camera?.playbackSupported == true);
static final HudFeature histogram = HudFeature(
'camera.histogram', 'Histogram', 'Camera', (ProductProfile p) => p.hasCamera);
// ── Gimbal ────────────────────────────────────────────────────────────────
static final HudFeature gimbalPitch = HudFeature(
'gimbal.pitch', 'Gimbal pitch slider', 'Gimbal', (ProductProfile p) => p.gimbal?.pitchAdjustable == true);
static final HudFeature gimbalYaw = HudFeature(
'gimbal.yaw', 'Gimbal yaw (drag, recenter, Free mode)', 'Gimbal', (ProductProfile p) => p.gimbal?.yawAdjustable == true);
static final HudFeature gimbalPitchExtension = HudFeature(
'gimbal.pitchExtension', 'Extend pitch to +30°', 'Gimbal',
(ProductProfile p) => p.gimbal?.pitchRangeExtensionSupported == true);
static final HudFeature gimbalDrag = HudFeature(
'gimbal.drag', 'Drag on video to aim', 'Gimbal', (ProductProfile p) => p.gimbal?.pitchAdjustable == true);
static final HudFeature gimbalSettings = HudFeature(
'gimbal.settings', 'Gimbal settings (mode, recenter)', 'Gimbal', (ProductProfile p) => p.hasGimbal);
static final HudFeature gimbalRollAdjust = HudFeature(
'gimbal.rollAdjust', 'Adjust gimbal roll', 'Gimbal', (ProductProfile p) => p.hasGimbal);
static final HudFeature gimbalCalibration = HudFeature(
'gimbal.calibration', 'Auto calibrate gimbal', 'Gimbal', (ProductProfile p) => p.hasGimbal);
static final HudFeature gimbalAdvanced = HudFeature('gimbal.advanced', 'Gimbal speed / smoothness', 'Gimbal',
(ProductProfile p) => p.gimbal?.controller.isNotEmpty == true);
// ── Vision / control ──────────────────────────────────────────────────────
static final HudFeature vpsReadout = HudFeature(
'telemetry.vps', 'VPS height', 'Telemetry', (ProductProfile p) => p.hasVps);
static final HudFeature visionRadar = HudFeature(
'overlay.radar', 'Vision radar', 'Overlays', (ProductProfile p) => p.hasObstacleSensing);
static final HudFeature virtualSticks = HudFeature(
'control.virtualSticks', 'On-screen sticks', 'Control', (ProductProfile p) => p.supportsVirtualStick);
/// Precision take-off records the spot with the downward cameras.
static final HudFeature precisionTakeoff = HudFeature(
'control.precisionTakeoff', 'Precision Takeoff option', 'Control', (ProductProfile p) => p.hasVps);
static final HudFeature simulator = HudFeature(
'control.simulator', 'Simulator toggle', 'Control', (ProductProfile p) => p.hasSimulator);
// ── Intelligent flight modes ──────────────────────────────────────────────
static final List<HudFeature> modes = <HudFeature>[
for (final IntelligentMode m in IntelligentMode.values)
HudFeature('mode.${m.name}', _modeLabel(m), 'Intelligent modes', (ProductProfile p) => p.supportsMode(m)),
];
static final List<HudFeature> all = <HudFeature>[
rcHdSignal,
wifiSignal,
visionIndicator,
masterSlaveBadge,
rtkStatus,
singleBattery,
dualBattery,
multiBattery,
nonSmartBattery,
cameraControls,
apertureReadout,
zoomColumn,
ssdStatus,
thermalReadout,
spotMetering,
tapFocus,
playback,
histogram,
gimbalPitch,
gimbalYaw,
gimbalPitchExtension,
gimbalDrag,
gimbalSettings,
gimbalRollAdjust,
gimbalCalibration,
gimbalAdvanced,
vpsReadout,
visionRadar,
virtualSticks,
precisionTakeoff,
simulator,
...modes,
];
static String _modeLabel(IntelligentMode m) => switch (m) {
IntelligentMode.waypoint => 'Waypoints',
IntelligentMode.waypointV2 => 'Waypoints V2',
IntelligentMode.pointOfInterest => 'Point of Interest',
IntelligentMode.activeTrack => 'ActiveTrack',
IntelligentMode.tapFly => 'TapFly',
IntelligentMode.followMe => 'Follow Me',
IntelligentMode.quickShots => 'QuickShots',
IntelligentMode.courseLock => 'Course Lock',
IntelligentMode.homeLock => 'Home Lock',
IntelligentMode.tripod => 'Tripod',
IntelligentMode.hyperlapse => 'Hyperlapse',
};
}
/// Makes the active [ProductProfile] available to every [Requires] below it.
class ProductProfileScope extends InheritedWidget {
const ProductProfileScope({super.key, required this.profile, required super.child});
final ProductProfile profile;
/// The nearest scope's profile, or [ProductProfile.disconnected] when none.
static ProductProfile of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ProductProfileScope>()?.profile ??
ProductProfile.disconnected;
@override
bool updateShouldNotify(ProductProfileScope oldWidget) => !identical(profile, oldWidget.profile);
}
/// Shows [child] only when [feature] is enabled for the scope's profile.
///
/// With no product connected there is nothing to be capable *of*, so the gate
/// is open by default and the HUD keeps its full layout as a preview; pass
/// `whenDisconnected: false` for elements that must never appear without a
/// product.
class Requires extends StatelessWidget {
const Requires(this.feature, {super.key, required this.child, this.fallback, this.whenDisconnected = true});
final HudFeature feature;
final Widget child;
final Widget? fallback;
final bool whenDisconnected;
@override
Widget build(BuildContext context) {
final ProductProfile p = ProductProfileScope.of(context);
final bool on = p.connected ? feature.enabledFor(p) : whenDisconnected;
return on ? child : (fallback ?? const SizedBox.shrink());
}
}