The GO 4-parity HUD has to work on every aircraft MSDK V4 drives, so no screen may assume one model. This adds the capability layer the rest of the plan builds on (P0). ProductProfileBridge reads what the SDK can report at runtime (cameras and their ISO/shutter/aperture/EV ranges, gimbal axes, air link, FC and RC feature flags, batteries) and emits it as a productProfile event on connect and component change. Capability ranges stay empty until the camera handshakes, so it re-probes at 3, 10 and 30 s and emits only when something changed. On the Dart side, ProductProfile merges that with a static product_matrix keyed by the MSDK Model enum name, for facts the SDK cannot report: vision sensor layout, battery bay count, intelligent modes. SDK answers win and every fact records its source. HudFeatures lists each capability-gated element with its predicate, and Requires hides a widget the profile rules out. The flight page now gates its gimbal slider, camera column and vision pill, labels the link Wi-Fi or HD, and takes the gimbal pitch range from the profile. Settings > About > Capability profile shows the active profile, fact sources and which features are on. Matrix rows are seeded from published specs until runtime capture confirms them; nothing has run on a real aircraft yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
165 lines
8.1 KiB
Dart
165 lines
8.1 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);
|
|
|
|
// ── 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', '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);
|
|
|
|
// ── 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);
|
|
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,
|
|
gimbalPitch,
|
|
gimbalYaw,
|
|
gimbalPitchExtension,
|
|
vpsReadout,
|
|
visionRadar,
|
|
virtualSticks,
|
|
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());
|
|
}
|
|
}
|