Files
PilotVault/Fly App/lib/ui/fly/left/action_rail.dart
T
tajniak81andClaude Opus 5.5 63acacced7 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>
2026-10-21 20:27:05 +02:00

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)),
]),
),
),
);
}
}