- 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>
131 lines
4.9 KiB
Dart
131 lines
4.9 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../../../flight_model.dart';
|
|
import '../../../fly/action_rail_state.dart';
|
|
import '../../../fly/hud_features.dart';
|
|
import '../../../theme.dart';
|
|
import '../../pv_icons.dart';
|
|
import '../flight_command_flow.dart';
|
|
import '../hud_kit.dart';
|
|
|
|
/// The left rail (plan P3): the [RailState] machine's buttons — Take-off ⇄
|
|
/// Land ⇄ Cancel / Confirm landing, RTH ⇄ Cancel RTH while airborne — then
|
|
/// the Intelligent Flight Modes button (plan P7), which Stop replaces while a
|
|
/// mode flies the aircraft. Every flight press goes through
|
|
/// [FlightCommandFlow], i.e. the SafetyGate and slide-to-confirm.
|
|
///
|
|
/// The grid toggle that used to sit last lives in the camera menu (Other →
|
|
/// Grid): a fourth button would reach the minimap on a phone.
|
|
class ActionRail extends StatelessWidget {
|
|
const ActionRail({super.key, required this.model, required this.flow, required this.onModes, this.modeActive = false, this.trailing});
|
|
|
|
final FlightModel model;
|
|
final FlightCommandFlow flow;
|
|
|
|
/// Opens the mode picker.
|
|
final VoidCallback onModes;
|
|
|
|
/// A mode's editor is open (the IFM button lights up).
|
|
final bool modeActive;
|
|
|
|
/// A small control under the buttons (plan P8: the STICKS toggle).
|
|
final Widget? trailing;
|
|
|
|
static ({String icon, String label, Color color}) _look(FlightCommand c) => switch (c) {
|
|
FlightCommand.takeOff => (icon: 'takeoff', label: 'TAKE OFF', color: Glass.accent),
|
|
FlightCommand.land => (icon: 'land', label: 'LAND', color: Glass.pillStrong),
|
|
FlightCommand.cancelLanding => (icon: 'close', label: 'CANCEL LAND', color: HudColors.caution),
|
|
FlightCommand.confirmLanding => (icon: 'check', label: 'CONFIRM LAND', color: HudColors.caution),
|
|
FlightCommand.goHome => (icon: 'rth', label: 'RTH', color: Glass.pillStrong),
|
|
FlightCommand.cancelGoHome => (icon: 'close', label: 'CANCEL RTH', color: HudColors.caution),
|
|
FlightCommand.stopMode => (icon: 'stop', label: 'STOP', color: HudColors.danger),
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListenableBuilder(
|
|
listenable: Listenable.merge(<Listenable>[model.aircraft, model.mission]),
|
|
builder: (BuildContext context, _) {
|
|
final RailState rail = RailState.of(connected: model.connected, a: model.aircraft, mission: model.mission);
|
|
Widget button(FlightCommand c) {
|
|
final ({String icon, String label, Color color}) l = _look(c);
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: RailButton(
|
|
key: ValueKey<String>('rail-${c.name}'),
|
|
icon: l.icon,
|
|
label: l.label,
|
|
color: l.color,
|
|
enabled: rail.enabled,
|
|
onTap: () => flow.request(context, c),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
|
button(rail.primary),
|
|
if (rail.rth != null) button(rail.rth!),
|
|
// Four buttons would reach the minimap on a phone; Stop outranks the picker.
|
|
if (rail.showStop)
|
|
button(FlightCommand.stopMode)
|
|
else
|
|
Requires(
|
|
HudFeatures.modePicker,
|
|
child: RailButton(
|
|
key: const ValueKey<String>('rail-modes'),
|
|
icon: 'master',
|
|
label: 'MODES',
|
|
color: modeActive ? Glass.accent : Glass.pillStrong,
|
|
onTap: onModes,
|
|
),
|
|
),
|
|
if (trailing != null) Padding(padding: const EdgeInsets.only(top: 2), child: trailing),
|
|
]);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class RailButton extends StatelessWidget {
|
|
const RailButton({
|
|
super.key,
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onTap,
|
|
this.color = Glass.pillStrong,
|
|
this.enabled = true,
|
|
});
|
|
|
|
final String icon;
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
final Color color;
|
|
final bool enabled;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: enabled ? onTap : null,
|
|
child: Opacity(
|
|
opacity: enabled ? 1 : 0.45,
|
|
// 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)),
|
|
]),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|