Files
PilotVault/Fly App/lib/ui/fly/left/action_rail.dart
T
tajniak81andClaude Opus 5.5 e02bb0e5cd Give the Fly App GO 4-style obstacle radar, on-screen sticks, AirSense and fly zones
Vision radar: graded obstacle sectors around the centre of the video,
the nearest distance per side, the obstacle overhead, and BLOCKED on a
side whose sensors cannot see once the motors run. On aircraft with 360°
sensing, sides without their own readings are filled from the
perception ring. The same sectors sit on the attitude ball's rim, turned
by the heading. Settings > Vision gains Display radar chart and the
braking / sensing distances per direction.

On-screen sticks: the rail's STICKS button hands the aircraft to two
stick pads after a slide-to-confirm. The 25 Hz stream runs natively and
sends zeros once Dart's last command is 300 ms old, so a stalled UI
leaves the aircraft hovering. The sticks close when the remote takes
over, on RTH or on landing. They are simulator-only on aircraft with a
remote unless Settings > General > Developer allows real flights; Spark
and Mavic Air on Wi-Fi may fly them for real. Which MSDK axis is forward
in velocity + body mode is coded from DJI's documentation and must be
checked in the simulator first.

AirSense: a banner for manned aircraft at warning level 2 and up, with
distance, direction and ICAO code; the planes on the map; the full list
in a sheet and Settings > Safety; and the aircraft's own avoidance
action with its countdown.

Fly zones: DJI GEO zones around the aircraft (or the phone) on the map
with their height limits, a banner for FlySafe notices and in/near-zone
states, take-off refused in restricted and locked authorization zones,
self-unlock of authorization zones, the aircraft's licences (enable,
sync from the DJI account, remove all; ground only), and the GEO
database version with an update button.

Tested with unit and widget tests and screenshot renders only; nothing
has run on an aircraft or the simulator yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:14:12 +02:00

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