Files
PilotVault/Fly App/lib/ui/fly/modes/vision/vision_layers.dart
T
tajniak81andClaude Opus 5.5 e49f318a0e Finish the Fly App's ActiveTrack and TapFly extras and letterbox the feed
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>
2026-10-20 17:23:37 +02:00

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),
),
);
}