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>
313 lines
13 KiB
Dart
313 lines
13 KiB
Dart
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)),
|
||
]),
|
||
),
|
||
);
|
||
}
|