Files
PilotVault/Fly App/lib/ui/fly/modes/vision/vision_panel.dart
T
tajniak81andClaude Opus 5.5 636da1ac2c Give the Fly App every remaining GO 4 flight mode and the missing settings
Intelligent modes: Point of Interest (orbit on the map, live radius and
speed), Follow Me (follows the phone), QuickShots on a boxed subject,
Course Lock / Home Lock, Tripod and Cinematic, a Free hyperlapse, and
Waypoints 2.0 for V2-only aircraft. ActiveTrack gains Spotlight Pro and
Circle. Every mode has a first-use tutorial and stops from the rail.
Waypoints can resume an interrupted mission, change cruise speed in
flight and check terrain clearance (Open-Meteo, on tap).

Settings: units, C1/C2 actions, dual-operator master/slave, HDMI/SDI
output, a link spectrum chart, Wi-Fi name and password, aircraft name,
video cache, battery warning history and pairing, non-smart cell
voltages. Calibration: compass interference meter, per-IMU calibration,
lens infinity focus and a first-manual-focus prompt. Also an RTK chip,
gimbal movement presets and motor tuning, Precision Takeoff gated on the
flight controller, and the precise GEO database's server version.

Built and unit/widget-tested only; nothing has flown yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:06:16 +02:00

313 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import '../../../../flight_model.dart';
import '../../../../fly/modes/orbit_modes.dart' show QuickShot;
import '../../../../fly/modes/vision_modes.dart';
import '../../../../fly/product_profile.dart' show VisionSensors;
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,
this.quickShots = false,
});
/// TapFly's panel, or ActiveTrack's.
final bool tapFly;
/// QuickShots' panel: ActiveTrack's operator, the shot instead of the follow style.
final bool quickShots;
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' : (quickShots ? 'QUICKSHOTS' : 'ACTIVETRACK');
final String status;
final Color statusColor;
String? note;
if (quickShots) {
final ActiveTrackProgress t = ms.activeTrack;
final bool filming = t.state == 'PERFORMING_QUICK_SHOT';
status = filming
? 'FILMING${t.percent == null ? '' : ' ${t.percent}%'}'
: t.active
? t.stateLabel.toUpperCase()
: session.pendingBox != null
? 'TAP GO'
: 'DRAW A BOX ROUND THE SUBJECT';
statusColor = t.cannotConfirm ? HudColors.danger : (filming ? HudColors.ready : (t.active ? HudColors.caution : HudText.dim));
note = t.cannotConfirm ? t.reasonLabel : (active ? null : session.quickShot.blurb);
} else 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 = quickShots
? <Widget>[
for (final QuickShot s in QuickShot.offered(
omni: model.profile.vision == VisionSensors.omni || !model.profile.connected,
zoom: model.profile.hasZoom || !model.profile.connected,
))
HudChip(
key: ValueKey<String>('quickshot-${s.name}'),
label: s.label,
selected: session.quickShot == s,
// A shot cannot change once it flies.
onTap: active ? null : () => session.setQuickShot(s),
),
]
: 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.offered(omni: model.profile.vision == VisionSensors.omni || !model.profile.connected))
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);
});
},
),
// Circle: the orbit speed, live while circling (negative = anticlockwise).
if (session.trackMode == TrackMode.circle)
_SpeedStepper(
speed: session.circleSpeed,
min: -10,
max: 10,
keyPrefix: 'track-circle',
onStep: (int d) {
final double v = (session.circleSpeed + d).clamp(-10.0, 10.0);
session.setCircleSpeed(v);
_guard('Circle speed', () => gate.setTrackCircleSpeed(v));
},
),
// 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' : (quickShots ? 'quickshot-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' : (quickShots ? 'dronie' : '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,
this.min = SafetyGate.tapFlyMinSpeed,
this.max = SafetyGate.tapFlyMaxSpeed,
this.keyPrefix = 'tapfly-speed',
});
final double speed;
final ValueChanged<int> onStep;
final double min;
final double max;
final String keyPrefix;
@override
Widget build(BuildContext context) {
Widget b(String l, int d, bool on) => Opacity(
opacity: on ? 1 : 0.35,
child: GestureDetector(
key: ValueKey<String>('$keyPrefix-${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 > min),
SizedBox(width: 54, child: Text('${speed.toStringAsFixed(0)} m/s', textAlign: TextAlign.center, style: HudText.mono)),
b('+', 1, speed < max),
]);
}
}
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)),
]),
),
);
}