From 63acacced756a74020cdfbbb7ff60193f8266079 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:27:05 +0200 Subject: [PATCH] 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 --- Fly App/lib/fly/action_rail_state.dart | 22 ++++++++++++--- Fly App/lib/fly/product_profile.dart | 15 +++++++++-- Fly App/lib/fly/safety_gate.dart | 6 ++++- Fly App/lib/ui/fly/left/action_rail.dart | 27 +++++++++++-------- .../ui/fly/panels/aircraft_status_sheet.dart | 2 ++ .../calibration/calibration_scaffold.dart | 6 ++++- Fly App/test/fly/product_profile_test.dart | 11 ++++++++ Fly App/test/fly/safety_test.dart | 13 +++++++++ 8 files changed, 84 insertions(+), 18 deletions(-) diff --git a/Fly App/lib/fly/action_rail_state.dart b/Fly App/lib/fly/action_rail_state.dart index cae38a7..59d33d6 100644 --- a/Fly App/lib/fly/action_rail_state.dart +++ b/Fly App/lib/fly/action_rail_state.dart @@ -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, ); diff --git a/Fly App/lib/fly/product_profile.dart b/Fly App/lib/fly/product_profile.dart index 4a94313..42da9e2 100644 --- a/Fly App/lib/fly/product_profile.dart +++ b/Fly App/lib/fly/product_profile.dart @@ -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 {}, - supportsVirtualStick: sdkSticks ?? false, + supportsVirtualStick: supportsSticks, virtualStickPrimary: row?.virtualStickPrimary ?? false, flightController: fc, remoteController: rc, diff --git a/Fly App/lib/fly/safety_gate.dart b/Fly App/lib/fly/safety_gate.dart index 96595a2..167c4cb 100644 --- a/Fly App/lib/fly/safety_gate.dart +++ b/Fly App/lib/fly/safety_gate.dart @@ -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.')); diff --git a/Fly App/lib/ui/fly/left/action_rail.dart b/Fly App/lib/ui/fly/left/action_rail.dart index 1ba7dee..68b90fa 100644 --- a/Fly App/lib/ui/fly/left/action_rail.dart +++ b/Fly App/lib/ui/fly/left/action_rail.dart @@ -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: [ - 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: [ + 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)), + ]), + ), ), ); } diff --git a/Fly App/lib/ui/fly/panels/aircraft_status_sheet.dart b/Fly App/lib/ui/fly/panels/aircraft_status_sheet.dart index 25a968f..96f952f 100644 --- a/Fly App/lib/ui/fly/panels/aircraft_status_sheet.dart +++ b/Fly App/lib/ui/fly/panels/aircraft_status_sheet.dart @@ -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), diff --git a/Fly App/lib/ui/fly/panels/settings/calibration/calibration_scaffold.dart b/Fly App/lib/ui/fly/panels/settings/calibration/calibration_scaffold.dart index a2a2b31..e868882 100644 --- a/Fly App/lib/ui/fly/panels/settings/calibration/calibration_scaffold.dart +++ b/Fly App/lib/ui/fly/panels/settings/calibration/calibration_scaffold.dart @@ -284,7 +284,11 @@ class CalibrationScaffold extends StatelessWidget { : OutlinedButton( key: ValueKey('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, ); } diff --git a/Fly App/test/fly/product_profile_test.dart b/Fly App/test/fly/product_profile_test.dart index e3e8812..42ede0a 100644 --- a/Fly App/test/fly/product_profile_test.dart +++ b/Fly App/test/fly/product_profile_test.dart @@ -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 fcNo = {...phantom4['flightController'] as Map, 'virtualStickAvailable': false}; + final ProductProfile mavic = ProductProfile.fromBridge({...phantom4, 'model': 'MAVIC_PRO', 'flightController': fcNo}); + expect(mavic.supportsVirtualStick, isTrue); + expect(mavic.sources['virtualStick'], FactSource.matrix); + final ProductProfile unknown = ProductProfile.fromBridge({...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({...inspire2X5s, 'batteries': []}); expect(p.batteryCount, 2); diff --git a/Fly App/test/fly/safety_test.dart b/Fly App/test/fly/safety_test.dart index efb6ff8..affdca9 100644 --- a/Fly App/test/fly/safety_test.dart +++ b/Fly App/test/fly/safety_test.dart @@ -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'));