Auto-detect: on aircraft that can sense subjects themselves, the ActiveTrack panel offers Auto-detect; the subjects it finds show as dashed boxes on the video, and tapping one then GO follows it by its index. Trace gets a Backward switch (fly ahead of a subject walking towards the aircraft, obstacle-sensing aircraft only) and TapFly Free a Reset heading button. Settings > Vision gains Gesture mode and Palm control where the flight assistant supports them, and the HUD shows when palm control is flying the aircraft. The live view used to stretch the feed over the screen. It now fits the picture to the decoder's own size with black bars, and reports that size, so the ActiveTrack / TapFly overlays, tap-to-focus / meter and the grid all map into the picture and ignore taps on the bars. Not yet run on a simulator or aircraft. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
259 lines
11 KiB
Dart
259 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import '../../../../flight_model.dart';
|
||
import '../../../../fly/modes/vision_modes.dart';
|
||
import '../../../../fly/safety_gate.dart';
|
||
import '../../../../fly/state/mission_state.dart';
|
||
import '../../../../theme.dart';
|
||
import '../../../pv_icons.dart';
|
||
import '../../dialogs/slide_to_confirm.dart';
|
||
import '../../flight_command_flow.dart';
|
||
import '../../hud_kit.dart';
|
||
import '../waypoints/point_editor.dart' show HudChip;
|
||
|
||
/// Starts a vision mode the GO 4 way: GO on the video is the pilot's
|
||
/// confirmation, so a clean check starts at once; warnings get the
|
||
/// slide-to-confirm dialog, and blockers its reasons with no slider.
|
||
Future<void> startVisionMode(
|
||
BuildContext context, {
|
||
required String title,
|
||
required String icon,
|
||
required String description,
|
||
required List<GateCheck> checks,
|
||
required Future<void> Function() start,
|
||
required void Function(String label, Object error) onError,
|
||
}) async {
|
||
if (checks.isNotEmpty) {
|
||
final SlideResult? r = await showSlideToConfirm(
|
||
context,
|
||
SlideConfirmSpec(icon: icon, title: title, description: description, sliderLabel: 'Slide to start', checks: checks),
|
||
);
|
||
if (r == null) return;
|
||
}
|
||
try {
|
||
await start();
|
||
} catch (e) {
|
||
onError(title, e);
|
||
}
|
||
}
|
||
|
||
/// The ActiveTrack / TapFly panel at the head of the left column: what the
|
||
/// operator is doing, the follow style or direction, TapFly's speed, Stop
|
||
/// while it flies, and Exit.
|
||
class VisionModePanel extends StatelessWidget {
|
||
const VisionModePanel({
|
||
super.key,
|
||
required this.tapFly,
|
||
required this.model,
|
||
required this.session,
|
||
required this.gate,
|
||
required this.flow,
|
||
required this.onExit,
|
||
required this.onError,
|
||
});
|
||
|
||
/// TapFly's panel, or ActiveTrack's.
|
||
final bool tapFly;
|
||
final FlightModel model;
|
||
final VisionSession session;
|
||
final SafetyGate gate;
|
||
final FlightCommandFlow flow;
|
||
final VoidCallback onExit;
|
||
final void Function(String label, Object error) onError;
|
||
|
||
Future<void> _guard(String label, Future<void> Function() f) async {
|
||
try {
|
||
await f();
|
||
} catch (e) {
|
||
onError(label, e);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListenableBuilder(
|
||
listenable: Listenable.merge(<Listenable>[model.mission, session]),
|
||
builder: (BuildContext context, _) {
|
||
final MissionState ms = model.mission;
|
||
final bool active = tapFly ? ms.tapFly.active : ms.activeTrack.active;
|
||
final String title = tapFly ? 'TAPFLY' : 'ACTIVETRACK';
|
||
final String status;
|
||
final Color statusColor;
|
||
String? note;
|
||
if (tapFly) {
|
||
final TapFlyProgress f = ms.tapFly;
|
||
status = f.active ? (f.paused ? 'PAUSED' : 'FLYING') : (session.pendingPoint != null ? 'TAP GO' : 'TAP WHERE TO FLY');
|
||
statusColor = f.active ? (f.paused ? HudColors.caution : HudColors.ready) : HudText.dim;
|
||
note = f.bypassLabel;
|
||
} else {
|
||
final ActiveTrackProgress t = ms.activeTrack;
|
||
status = t.active
|
||
? t.stateLabel.toUpperCase()
|
||
: session.pendingBox != null
|
||
? 'TAP GO'
|
||
: t.autoSensing
|
||
? (t.subjects.isEmpty ? 'LOOKING FOR SUBJECTS' : 'TAP A SUBJECT')
|
||
: 'DRAW A BOX ROUND THE SUBJECT';
|
||
statusColor = t.cannotConfirm ? HudColors.danger : (t.active ? (t.following ? HudColors.ready : HudColors.caution) : HudText.dim);
|
||
note = t.cannotConfirm ? t.reasonLabel : null;
|
||
}
|
||
|
||
final List<Widget> chips = tapFly
|
||
? <Widget>[
|
||
for (final TapFlyDirection d in gate.tapFlyDirections())
|
||
HudChip(
|
||
key: ValueKey<String>('tapfly-${d.name}'),
|
||
label: d.label,
|
||
selected: session.direction == d,
|
||
// The direction is fixed once flying; tap a new point to change course.
|
||
onTap: active ? null : () => session.setDirection(d),
|
||
),
|
||
]
|
||
: <Widget>[
|
||
for (final TrackMode m in TrackMode.values)
|
||
HudChip(
|
||
key: ValueKey<String>('track-${m.name}'),
|
||
label: m.label,
|
||
selected: session.trackMode == m,
|
||
onTap: () {
|
||
session.setTrackMode(m);
|
||
if (active) _guard('Switch ActiveTrack mode', () => gate.switchActiveTrackMode(m));
|
||
},
|
||
),
|
||
// Trace only: fly backwards ahead of a subject coming towards the aircraft.
|
||
if (session.trackMode == TrackMode.trace && model.profile.hasObstacleSensing)
|
||
HudChip(
|
||
key: const ValueKey<String>('track-retreat'),
|
||
label: session.retreat == true ? 'Backward on' : 'Backward off',
|
||
selected: session.retreat == true,
|
||
onTap: () {
|
||
final bool on = session.retreat != true;
|
||
_guard('ActiveTrack backward', () async {
|
||
await gate.setTrackRetreat(on);
|
||
session.setRetreat(on);
|
||
});
|
||
},
|
||
),
|
||
// The aircraft finds subjects itself; tap one instead of drawing.
|
||
if (ms.activeTrack.autoSensingSupported && !active)
|
||
HudChip(
|
||
key: const ValueKey<String>('track-autosense'),
|
||
label: 'Auto-detect',
|
||
selected: ms.activeTrack.autoSensing,
|
||
onTap: () => _guard('Auto-detect', () => gate.setAutoSensing(!ms.activeTrack.autoSensing)),
|
||
),
|
||
];
|
||
|
||
return Container(
|
||
key: ValueKey<String>(tapFly ? 'tapfly-panel' : 'track-panel'),
|
||
constraints: const BoxConstraints(maxWidth: 390),
|
||
padding: const EdgeInsets.fromLTRB(10, 6, 6, 6),
|
||
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(10), border: Border.all(color: Glass.hairline)),
|
||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
PVIcon(tapFly ? 'chevronsRight' : 'crosshair', size: 13, color: Glass.ink),
|
||
const SizedBox(width: 6),
|
||
Text(title, style: HudText.label.copyWith(color: Glass.ink, fontWeight: FontWeight.w700)),
|
||
const SizedBox(width: 8),
|
||
Flexible(
|
||
child: Text(status, overflow: TextOverflow.ellipsis, style: HudText.label.copyWith(color: statusColor, fontWeight: FontWeight.w700)),
|
||
),
|
||
const SizedBox(width: 8),
|
||
if (active)
|
||
_PanelButton(
|
||
key: const ValueKey<String>('vision-stop'),
|
||
icon: 'stop',
|
||
label: 'STOP',
|
||
color: HudColors.danger,
|
||
onTap: () => flow.request(context, FlightCommand.stopMode),
|
||
),
|
||
const SizedBox(width: 4),
|
||
_PanelButton(key: const ValueKey<String>('vision-exit'), icon: 'close', label: 'EXIT', color: Glass.pill, onTap: onExit),
|
||
]),
|
||
if (note != null) Padding(padding: const EdgeInsets.only(top: 3), child: Text(note, style: HudText.label.copyWith(color: HudColors.caution))),
|
||
const SizedBox(height: 5),
|
||
Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
|
||
...chips,
|
||
if (tapFly) ...<Widget>[
|
||
const SizedBox(width: 6),
|
||
_SpeedStepper(
|
||
speed: session.speed,
|
||
onStep: (int d) {
|
||
final double v = (session.speed + d).clamp(SafetyGate.tapFlyMinSpeed, SafetyGate.tapFlyMaxSpeed);
|
||
session.setSpeed(v);
|
||
if (active) _guard('TapFly speed', () => gate.setTapFlySpeed(v));
|
||
},
|
||
),
|
||
// Free lets the pilot turn away; this turns it back to face the way it flies.
|
||
if (active && session.direction == TapFlyDirection.free)
|
||
HudChip(
|
||
key: const ValueKey<String>('tapfly-reset-heading'),
|
||
label: 'Reset heading',
|
||
selected: false,
|
||
onTap: () => _guard('Reset heading', gate.resetTapFlyHeading),
|
||
),
|
||
],
|
||
]),
|
||
]),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SpeedStepper extends StatelessWidget {
|
||
const _SpeedStepper({required this.speed, required this.onStep});
|
||
|
||
final double speed;
|
||
final ValueChanged<int> onStep;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
Widget b(String l, int d, bool on) => Opacity(
|
||
opacity: on ? 1 : 0.35,
|
||
child: GestureDetector(
|
||
key: ValueKey<String>('tapfly-speed-${d > 0 ? 'plus' : 'minus'}'),
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: on ? () => onStep(d) : null,
|
||
child: Container(
|
||
width: 24,
|
||
height: 22,
|
||
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6), border: Border.all(color: Glass.hairline)),
|
||
alignment: Alignment.center,
|
||
child: Text(l, style: HudText.mono.copyWith(fontWeight: FontWeight.w700)),
|
||
),
|
||
),
|
||
);
|
||
return Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
b('−', -1, speed > SafetyGate.tapFlyMinSpeed),
|
||
SizedBox(width: 54, child: Text('${speed.toStringAsFixed(0)} m/s', textAlign: TextAlign.center, style: HudText.mono)),
|
||
b('+', 1, speed < SafetyGate.tapFlyMaxSpeed),
|
||
]);
|
||
}
|
||
}
|
||
|
||
class _PanelButton extends StatelessWidget {
|
||
const _PanelButton({super.key, required this.icon, required this.label, required this.color, required this.onTap});
|
||
|
||
final String icon;
|
||
final String label;
|
||
final Color color;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onTap,
|
||
child: Container(
|
||
height: 24,
|
||
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(7), border: Border.all(color: Glass.hairline)),
|
||
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
PVIcon(icon, size: 11, color: Colors.white),
|
||
const SizedBox(width: 3),
|
||
Text(label, style: HudText.label.copyWith(color: Colors.white, fontWeight: FontWeight.w700)),
|
||
]),
|
||
),
|
||
);
|
||
}
|