Fix what the first Fly App simulator runs on a Mavic Pro turned up

- Virtual sticks come from the product matrix: the Mavic Pro answers
  isVirtualStickControlModeAvailable = false even in flight, which hid
  the STICKS button.
- While landing, the rail's second slot belongs to the landing (the
  Mavic Pro keeps isGoingHome set through a plain Land, so it showed
  Cancel RTH beside Confirm landing); Cancel landing is allowed at the
  confirmation.
- Rail buttons have a fixed width so long labels wrap instead of
  pushing the rail onto the gimbal slider.
- Outlined HUD buttons get a transparent background (the app theme
  filled them white behind white text).

Verified in the simulator: take-off, RTH, cancel RTH, land, cancel and
confirm landing, and the virtual-stick axes (roll = forward, pitch = right).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-21 20:27:05 +02:00
1 parent 56d43ddb1e
commit 63acacced7
8 files changed
+84 -18

No files matched your search

+19 -3
View File
@@ -22,7 +22,8 @@ class RailState {
/// Take-off, Land, Cancel landing or Confirm landing.
final FlightCommand primary;
/// Go home or Cancel RTH; null on the ground.
/// Go home or Cancel RTH; Cancel landing while waiting for a landing
/// confirmation; null on the ground and during an auto-landing.
final FlightCommand? rth;
/// The intelligent mode a Stop button would end; [ActiveMode.none] hides it.
@@ -44,8 +45,11 @@ class RailState {
final String mode = a.flightModeEnum ?? '';
final bool goingHome = a.goingHome == true || mode == 'GO_HOME';
final bool confirming = a.landingConfirmationNeeded == true || mode == 'CONFIRM_LANDING';
final bool landing = confirming || mode == 'AUTO_LANDING';
final FlightCommand primary;
if (a.landingConfirmationNeeded == true || mode == 'CONFIRM_LANDING') {
if (confirming) {
primary = FlightCommand.confirmLanding;
} else if (mode == 'AUTO_LANDING') {
primary = FlightCommand.cancelLanding;
@@ -55,9 +59,21 @@ class RailState {
primary = FlightCommand.takeOff;
}
// While landing, the second slot belongs to the landing: the Mavic Pro keeps
// `isGoingHome` set through a plain Land's descent (seen in the simulator,
// 2026-10-21), which used to put "Cancel RTH" beside Confirm landing.
final FlightCommand? rth;
if (!airborne) {
rth = null;
} else if (landing) {
rth = confirming ? FlightCommand.cancelLanding : null;
} else {
rth = goingHome ? FlightCommand.cancelGoHome : FlightCommand.goHome;
}
return RailState(
primary: primary,
rth: airborne ? (goingHome ? FlightCommand.cancelGoHome : FlightCommand.goHome) : null,
rth: rth,
mode: airborne ? activeMode(a, mission) : ActiveMode.none,
enabled: reported,
);
+13 -2
View File
@@ -386,8 +386,19 @@ class ProductProfile {
sources['batteryKind'] = FactSource.unknown;
}
// `isVirtualStickControlModeAvailable` is a live state, not a capability: a
// Mavic Pro answers false on the ground and in flight (simulator,
// 2026-10-21). Every matrix aircraft takes MSDK virtual sticks, so the SDK
// only decides for models the matrix does not know.
final bool? sdkSticks = fc?.virtualStickAvailable;
sources['virtualStick'] = sdkSticks == null ? FactSource.unknown : FactSource.sdk;
final bool supportsSticks = sdkSticks == true || row != null;
sources['virtualStick'] = sdkSticks == true
? FactSource.sdk
: row != null
? FactSource.matrix
: sdkSticks == null
? FactSource.unknown
: FactSource.sdk;
sources['modes'] = row == null ? FactSource.unknown : FactSource.matrix;
sources['cameras'] = FactSource.sdk;
@@ -406,7 +417,7 @@ class ProductProfile {
batteryCount: batteryCount,
batteryKind: batteryKind,
modes: row?.modes ?? const <IntelligentMode>{},
supportsVirtualStick: sdkSticks ?? false,
supportsVirtualStick: supportsSticks,
virtualStickPrimary: row?.virtualStickPrimary ?? false,
flightController: fc,
remoteController: rc,
+5 -1
View File
@@ -140,7 +140,11 @@ class SafetyGate {
if (_a.isFlying != true) checks.add(const GateCheck.block('The aircraft is not flying.'));
if (_a.goingHome == true) checks.add(const GateCheck.warn('This ends Return to Home and lands where the aircraft is now.'));
case FlightCommand.cancelLanding:
if (_a.flightModeEnum != 'AUTO_LANDING') checks.add(const GateCheck.block('The aircraft is not landing.'));
if (_a.flightModeEnum != 'AUTO_LANDING' &&
_a.flightModeEnum != 'CONFIRM_LANDING' &&
_a.landingConfirmationNeeded != true) {
checks.add(const GateCheck.block('The aircraft is not landing.'));
}
case FlightCommand.confirmLanding:
if (_a.landingConfirmationNeeded != true && _a.flightModeEnum != 'CONFIRM_LANDING') {
checks.add(const GateCheck.block('The aircraft is not waiting for a landing confirmation.'));
+16 -11
View File
@@ -108,17 +108,22 @@ class RailButton extends StatelessWidget {
onTap: enabled ? onTap : null,
child: Opacity(
opacity: enabled ? 1 : 0.45,
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
Container(
width: 42,
height: 42,
decoration: BoxDecoration(color: color, shape: BoxShape.circle, border: Border.all(color: const Color(0x2EFFFFFF))),
alignment: Alignment.center,
child: PVIcon(icon, size: 20, color: Colors.white),
),
const SizedBox(height: 2),
Text(label, style: HudText.label.copyWith(fontSize: 7.5)),
]),
// Fixed width: a long label ("CONFIRM LAND") used to widen the rail and
// push the buttons onto the gimbal slider; it wraps instead.
child: SizedBox(
width: 50,
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
Container(
width: 42,
height: 42,
decoration: BoxDecoration(color: color, shape: BoxShape.circle, border: Border.all(color: const Color(0x2EFFFFFF))),
alignment: Alignment.center,
child: PVIcon(icon, size: 20, color: Colors.white),
),
const SizedBox(height: 2),
Text(label, textAlign: TextAlign.center, maxLines: 2, style: HudText.label.copyWith(fontSize: 7.5)),
]),
),
),
);
}
@@ -267,6 +267,8 @@ class _Row extends StatelessWidget {
onPressed: onAction,
style: OutlinedButton.styleFrom(
foregroundColor: Glass.ink,
// The app theme fills outlined buttons with its light surface.
backgroundColor: Colors.transparent,
side: const BorderSide(color: Glass.hairline),
padding: const EdgeInsets.symmetric(horizontal: 10),
minimumSize: const Size(0, 30),
@@ -284,7 +284,11 @@ class CalibrationScaffold extends StatelessWidget {
: OutlinedButton(
key: ValueKey<String>('wizard-${a.id}'),
onPressed: a.onTap,
style: OutlinedButton.styleFrom(foregroundColor: Glass.ink, side: BorderSide(color: color), minimumSize: const Size(100, 42)),
style: OutlinedButton.styleFrom(
foregroundColor: Glass.ink,
backgroundColor: Colors.transparent,
side: BorderSide(color: color),
minimumSize: const Size(100, 42)),
child: label,
);
}
@@ -58,6 +58,17 @@ void main() {
expect(p.sources['link'], FactSource.matrix);
});
test('virtual sticks: the matrix wins over the SDK\'s live "not available"', () {
// A real Mavic Pro answers isVirtualStickControlModeAvailable = false (2026-10-21).
final Map<String, dynamic> fcNo = <String, dynamic>{...phantom4['flightController'] as Map<String, dynamic>, 'virtualStickAvailable': false};
final ProductProfile mavic = ProductProfile.fromBridge(<String, dynamic>{...phantom4, 'model': 'MAVIC_PRO', 'flightController': fcNo});
expect(mavic.supportsVirtualStick, isTrue);
expect(mavic.sources['virtualStick'], FactSource.matrix);
final ProductProfile unknown = ProductProfile.fromBridge(<String, dynamic>{...phantom4, 'model': 'SOMETHING_NEW', 'flightController': fcNo});
expect(unknown.supportsVirtualStick, isFalse);
expect(unknown.sources['virtualStick'], FactSource.sdk);
});
test('battery count falls back to the matrix bay count', () {
final ProductProfile p = ProductProfile.fromBridge(<String, dynamic>{...inspire2X5s, 'batteries': <dynamic>[]});
expect(p.batteryCount, 2);
+13
View File
@@ -58,6 +58,18 @@ void main() {
expect(rail(telemetryTick()..['flightModeEnum'] = 'CONFIRM_LANDING').primary, FlightCommand.confirmLanding);
});
test('landing owns the second slot even while the FC says it is going home', () {
// The Mavic Pro keeps isGoingHome set through a plain Land (simulator, 2026-10-21).
final RailState descending = rail(telemetryTick()
..['flightModeEnum'] = 'AUTO_LANDING'
..['isGoingHome'] = true);
expect(descending, const RailState(primary: FlightCommand.cancelLanding));
final RailState asking = rail(telemetryTick()
..['isLandingConfirmationNeeded'] = true
..['isGoingHome'] = true);
expect(asking, const RailState(primary: FlightCommand.confirmLanding, rth: FlightCommand.cancelLanding));
});
test('going home: RTH turns into Cancel RTH, Land stays', () {
final RailState r = rail(telemetryTick()
..['isGoingHome'] = true
@@ -146,6 +158,7 @@ void main() {
expect(blockers(flying, FlightCommand.cancelGoHome), isNotEmpty);
expect(blockers(flying, FlightCommand.stopMode), isNotEmpty);
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'AUTO_LANDING'), FlightCommand.cancelLanding), isEmpty);
expect(blockers(modelWith(telemetryTick()..['isLandingConfirmationNeeded'] = true), FlightCommand.cancelLanding), isEmpty);
expect(blockers(modelWith(telemetryTick()..['isLandingConfirmationNeeded'] = true), FlightCommand.confirmLanding), isEmpty);
expect(blockers(modelWith(telemetryTick()..['isGoingHome'] = true), FlightCommand.cancelGoHome), isEmpty);
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'DRAW'), FlightCommand.stopMode).single, contains('pause button'));