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>
319 lines
12 KiB
Dart
319 lines
12 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/foundation.dart' show ValueListenable;
|
|
import 'package:flutter/gestures.dart' show DragStartBehavior;
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../../../fly/modes/vision_modes.dart';
|
|
import '../../../../fly/state/mission_state.dart';
|
|
import '../../../../theme.dart';
|
|
import '../../../pv_icons.dart';
|
|
import '../../hud_kit.dart';
|
|
|
|
/// ActiveTrack on the live view (plan P7b, GO 4's `fpv_track_*`): drag a box
|
|
/// round the subject, or tap it for a person-sized box, then GO. While the
|
|
/// operator tracks, its own box is drawn — green when sure, amber when not,
|
|
/// blue with ✓ / ✕ when it asks "this subject?", red with the reason when it
|
|
/// cannot follow.
|
|
///
|
|
/// Full-screen, in place of the camera and gimbal touch layers while the
|
|
/// mode is open: every touch on the video means "this subject".
|
|
class ActiveTrackLayer extends StatefulWidget {
|
|
const ActiveTrackLayer({
|
|
super.key,
|
|
required this.mission,
|
|
required this.session,
|
|
required this.video,
|
|
required this.onGo,
|
|
required this.onConfirm,
|
|
});
|
|
|
|
final MissionState mission;
|
|
final VisionSession session;
|
|
|
|
/// The feed's picture size, for the letterbox ([VisionGeometry.contentRect]).
|
|
final ValueListenable<Size?> video;
|
|
|
|
/// GO on a box; the subject index when it is one the aircraft sensed itself.
|
|
final void Function(Rect box, int? subject) onGo;
|
|
final ValueChanged<bool> onConfirm;
|
|
|
|
@override
|
|
State<ActiveTrackLayer> createState() => _ActiveTrackLayerState();
|
|
}
|
|
|
|
class _ActiveTrackLayerState extends State<ActiveTrackLayer> {
|
|
Offset? _from;
|
|
Offset? _to;
|
|
|
|
/// A tap on a sensed subject picks it; anywhere else on the picture makes a box.
|
|
void _tap(Offset p, Rect content, List<SensedSubject> subjects) {
|
|
for (final SensedSubject s in subjects) {
|
|
if (VisionGeometry.toScreen(s.rect, content).inflate(8).contains(p)) {
|
|
widget.session.pickSubject(s.index, s.rect);
|
|
return;
|
|
}
|
|
}
|
|
final Rect? box = VisionGeometry.tapBoxAt(p, content);
|
|
if (box != null) widget.session.setPendingBox(box);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return LayoutBuilder(builder: (BuildContext context, BoxConstraints c) {
|
|
final Size full = c.biggest;
|
|
return ListenableBuilder(
|
|
listenable: Listenable.merge(<Listenable>[widget.mission, widget.session, widget.video]),
|
|
builder: (BuildContext context, _) {
|
|
final Rect view = VisionGeometry.contentRect(full, widget.video.value);
|
|
final ActiveTrackProgress t = widget.mission.activeTrack;
|
|
final bool tracking = t.active;
|
|
final Rect? pending = tracking ? null : widget.session.pendingBox;
|
|
final List<SensedSubject> subjects = tracking ? const <SensedSubject>[] : t.subjects;
|
|
final Rect? drawing = _from != null && _to != null ? Rect.fromPoints(_from!, _to!) : null;
|
|
return GestureDetector(
|
|
key: const ValueKey<String>('track-layer'),
|
|
behavior: HitTestBehavior.opaque,
|
|
dragStartBehavior: DragStartBehavior.down,
|
|
onTapUp: tracking ? null : (TapUpDetails d) => _tap(d.localPosition, view, subjects),
|
|
onPanStart: tracking
|
|
? null
|
|
: (DragStartDetails d) => setState(() {
|
|
_from = d.localPosition;
|
|
_to = d.localPosition;
|
|
}),
|
|
onPanUpdate: tracking ? null : (DragUpdateDetails d) => setState(() => _to = d.localPosition),
|
|
onPanEnd: tracking
|
|
? null
|
|
: (_) {
|
|
final Rect? box = _from == null || _to == null ? null : VisionGeometry.dragBox(_from!, _to!, view);
|
|
setState(() => _from = _to = null);
|
|
if (box != null) widget.session.setPendingBox(box);
|
|
},
|
|
child: Stack(children: <Widget>[
|
|
const SizedBox.expand(),
|
|
for (final SensedSubject s in subjects)
|
|
if (s.index != widget.session.pendingSubject)
|
|
Positioned.fromRect(
|
|
rect: VisionGeometry.toScreen(s.rect, view),
|
|
child: _Box(key: ValueKey<String>('subject-${s.index}'), color: Glass.ink, dashed: true),
|
|
),
|
|
if (drawing != null) Positioned.fromRect(rect: drawing, child: const _Box(color: Glass.ink, dashed: true)),
|
|
if (pending != null) ..._pending(VisionGeometry.toScreen(pending, view), full),
|
|
if (tracking && t.rect != null) ..._tracked(t, VisionGeometry.toScreen(t.rect!, view), full),
|
|
]),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
List<Widget> _pending(Rect r, Size view) => <Widget>[
|
|
Positioned.fromRect(rect: r, child: const _Box(color: PV.accent)),
|
|
_beside(r, view, Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
|
_RoundButton(
|
|
key: const ValueKey<String>('track-go'),
|
|
label: 'GO',
|
|
color: PV.accent,
|
|
onTap: () => widget.onGo(widget.session.pendingBox!, widget.session.pendingSubject),
|
|
),
|
|
const SizedBox(width: 6),
|
|
_RoundButton(
|
|
key: const ValueKey<String>('track-discard'),
|
|
icon: 'close',
|
|
color: Glass.pillStrong,
|
|
onTap: () => widget.session.setPendingBox(null),
|
|
),
|
|
])),
|
|
];
|
|
|
|
List<Widget> _tracked(ActiveTrackProgress t, Rect r, Size view) {
|
|
final Color c = t.cannotConfirm
|
|
? HudColors.danger
|
|
: t.waitingForConfirmation
|
|
? PV.accent
|
|
: (t.lowConfidence || t.state == 'FINDING_TRACKED_TARGET')
|
|
? HudColors.caution
|
|
: HudColors.ready;
|
|
final String? caption = t.cannotConfirm ? (t.reasonLabel ?? 'Cannot follow') : (t.targetType == null || t.targetType == 'UNKNOWN' ? null : _type(t.targetType!));
|
|
return <Widget>[
|
|
Positioned.fromRect(rect: r, child: _Box(key: const ValueKey<String>('track-box'), color: c)),
|
|
if (caption != null)
|
|
Positioned(
|
|
left: r.left,
|
|
top: math.max(0, r.top - 18),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
|
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(4)),
|
|
child: Text(caption, style: HudText.label.copyWith(color: Colors.white, fontWeight: FontWeight.w700)),
|
|
),
|
|
),
|
|
if (t.waitingForConfirmation || t.cannotConfirm)
|
|
_beside(r, view, Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
|
if (t.waitingForConfirmation)
|
|
_RoundButton(key: const ValueKey<String>('track-confirm'), icon: 'check', color: HudColors.ready, onTap: () => widget.onConfirm(true)),
|
|
const SizedBox(width: 6),
|
|
_RoundButton(key: const ValueKey<String>('track-reject'), icon: 'close', color: HudColors.danger, onTap: () => widget.onConfirm(false)),
|
|
])),
|
|
];
|
|
}
|
|
|
|
static String _type(String t) => switch (t) {
|
|
'HUMAN' => 'Person',
|
|
'CAR' => 'Car',
|
|
'VAN' => 'Van',
|
|
'BIKE' => 'Bike',
|
|
'BOAT' => 'Boat',
|
|
'HOT_POINT' => 'Heat spot',
|
|
_ => t,
|
|
};
|
|
}
|
|
|
|
/// Places [child] just right of [r], or left of it near the right edge.
|
|
Widget _beside(Rect r, Size view, Widget child) {
|
|
const double w = 100;
|
|
final bool right = r.right + 8 + w < view.width;
|
|
return Positioned(
|
|
left: right ? r.right + 8 : null,
|
|
right: right ? null : view.width - r.left + 8,
|
|
top: (r.center.dy - 20).clamp(0, view.height - 40),
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
/// TapFly on the live view (plan P7b, GO 4's `fpv_tapfly_*`): tap where to
|
|
/// fly, then GO. While flying, the aircraft's own heading point is drawn and
|
|
/// a new tap re-aims it.
|
|
class TapFlyLayer extends StatelessWidget {
|
|
const TapFlyLayer({super.key, required this.mission, required this.session, required this.video, required this.onGo});
|
|
|
|
final MissionState mission;
|
|
final VisionSession session;
|
|
|
|
/// The feed's picture size, for the letterbox.
|
|
final ValueListenable<Size?> video;
|
|
final ValueChanged<Offset> onGo;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return LayoutBuilder(builder: (BuildContext context, BoxConstraints c) {
|
|
final Size full = c.biggest;
|
|
return ListenableBuilder(
|
|
listenable: Listenable.merge(<Listenable>[mission, session, video]),
|
|
builder: (BuildContext context, _) {
|
|
final Rect view = VisionGeometry.contentRect(full, video.value);
|
|
final TapFlyProgress f = mission.tapFly;
|
|
final Offset? pending = session.pendingPoint;
|
|
final Offset? heading = f.active ? f.image : null;
|
|
return GestureDetector(
|
|
key: const ValueKey<String>('tapfly-layer'),
|
|
behavior: HitTestBehavior.opaque,
|
|
onTapUp: (TapUpDetails d) {
|
|
final Offset? p = VisionGeometry.normalise(d.localPosition, view);
|
|
if (p != null) session.setPendingPoint(p); // a tap on a bar is no target
|
|
},
|
|
child: Stack(children: <Widget>[
|
|
const SizedBox.expand(),
|
|
if (heading != null) _marker(VisionGeometry.pointToScreen(heading, view), HudColors.ready, key: 'tapfly-heading'),
|
|
if (pending != null) ...<Widget>[
|
|
_marker(VisionGeometry.pointToScreen(pending, view), PV.accent, key: 'tapfly-target'),
|
|
_beside(
|
|
Rect.fromCenter(center: VisionGeometry.pointToScreen(pending, view), width: 40, height: 40),
|
|
full,
|
|
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
|
_RoundButton(key: const ValueKey<String>('tapfly-go'), label: 'GO', color: PV.accent, onTap: () => onGo(pending)),
|
|
const SizedBox(width: 6),
|
|
_RoundButton(
|
|
key: const ValueKey<String>('tapfly-discard'),
|
|
icon: 'close',
|
|
color: Glass.pillStrong,
|
|
onTap: () => session.setPendingPoint(null),
|
|
),
|
|
]),
|
|
),
|
|
],
|
|
]),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
static Widget _marker(Offset p, Color c, {required String key}) => Positioned(
|
|
left: p.dx - 20,
|
|
top: p.dy - 20,
|
|
child: IgnorePointer(
|
|
child: SizedBox(
|
|
key: ValueKey<String>(key),
|
|
width: 40,
|
|
height: 40,
|
|
child: CustomPaint(painter: _TargetPainter(c)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _TargetPainter extends CustomPainter {
|
|
const _TargetPainter(this.color);
|
|
final Color color;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final Offset c = size.center(Offset.zero);
|
|
final Paint p = Paint()
|
|
..color = color
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 2;
|
|
canvas.drawCircle(c, size.width / 2 - 4, p);
|
|
canvas.drawCircle(c, 3, Paint()..color = color);
|
|
for (final Offset d in const <Offset>[Offset(1, 0), Offset(-1, 0), Offset(0, 1), Offset(0, -1)]) {
|
|
canvas.drawLine(c + d * (size.width / 2 - 8), c + d * (size.width / 2), p);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _TargetPainter old) => old.color != color;
|
|
}
|
|
|
|
class _Box extends StatelessWidget {
|
|
const _Box({super.key, required this.color, this.dashed = false});
|
|
|
|
final Color color;
|
|
final bool dashed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => IgnorePointer(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: dashed ? 0.06 : 0.12),
|
|
border: Border.all(color: color, width: dashed ? 1.2 : 2),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _RoundButton extends StatelessWidget {
|
|
const _RoundButton({super.key, required this.color, required this.onTap, this.label, this.icon});
|
|
|
|
final Color color;
|
|
final VoidCallback onTap;
|
|
final String? label;
|
|
final String? icon;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onTap,
|
|
child: Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle, border: Border.all(color: const Color(0x66FFFFFF))),
|
|
alignment: Alignment.center,
|
|
child: label != null
|
|
? Text(label!, style: HudText.value.copyWith(fontSize: 13, color: Colors.white))
|
|
: PVIcon(icon!, size: 16, color: Colors.white),
|
|
),
|
|
);
|
|
}
|