Files
PilotVault/Fly App/lib/ui/fly/overlays/camera_touch_layer.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

189 lines
6.7 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 'dart:async';
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/material.dart';
import '../../../dji_service.dart';
import '../../../fly/fly_prefs.dart';
import '../../../fly/hud_features.dart';
import '../../../fly/modes/vision_modes.dart' show VisionGeometry;
import '../../../fly/product_profile.dart';
import '../../../fly/state/camera_state.dart';
import '../../../theme.dart';
import '../hud_kit.dart';
import '../panels/camera_write.dart';
/// Tap-to-focus and tap-to-meter on the live view (plan P4).
///
/// One tap focuses there — or, on a fixed-focus camera, spot-meters there. A
/// long press does what the camera menu's "Long press on video" says (GO 4's
/// Long Press setting; spot metering by default). Spot metering leaves an
/// "Exposure" box with a × that returns metering to centre-weighted; the box
/// stays while the camera reports SPOT metering.
///
/// Positions are sent as 0..1 of the picture: the native view letterboxes the
/// feed to [video]'s aspect ratio, so taps are mapped through
/// [VisionGeometry.contentRect], and a tap on a black bar does nothing.
class CameraTouchLayer extends StatefulWidget {
const CameraTouchLayer({
super.key,
required this.camera,
required this.dji,
required this.connected,
required this.onError,
this.video,
});
final CameraState camera;
final DjiService dji;
final bool connected;
final void Function(String label, Object error) onError;
/// The feed's picture size; null fills the layer.
final ValueListenable<Size?>? video;
@override
State<CameraTouchLayer> createState() => _CameraTouchLayerState();
}
enum _Mark { focus, meter }
class _CameraTouchLayerState extends State<CameraTouchLayer> {
Offset? _focusAt;
Offset? _meterAt;
Timer? _focusFade;
@override
void dispose() {
_focusFade?.cancel();
super.dispose();
}
Future<void> _act(_Mark mark, Offset local, Size size) async {
final Offset? at = VisionGeometry.normalise(local, VisionGeometry.contentRect(size, widget.video?.value));
if (at == null) return; // on a letterbox bar
final double x = at.dx, y = at.dy;
setState(() {
if (mark == _Mark.focus) {
_focusAt = local;
_focusFade?.cancel();
_focusFade = Timer(const Duration(milliseconds: 1500), () {
if (mounted) setState(() => _focusAt = null);
});
} else {
_meterAt = local;
}
});
if (!widget.connected) return;
try {
if (mark == _Mark.focus) {
await widget.dji.setFocusTarget(x, y);
} else {
await widget.dji.setSpotMeteringTarget(x, y);
}
} catch (e) {
if (mark == _Mark.meter && mounted) setState(() => _meterAt = null);
widget.onError(mark == _Mark.focus ? 'Focus' : 'Spot metering', e);
}
}
Future<void> _cancelMeter() async {
setState(() => _meterAt = null);
if (!widget.connected) return;
await writeCameraSetting(
widget.camera,
label: 'Metering',
pending: const <String, Object?>{'meteringMode': 'CENTER'},
send: () => widget.dji.setCameraValue('meteringMode', 'CENTER'),
onError: widget.onError,
);
}
@override
Widget build(BuildContext context) {
final ProductProfile p = ProductProfileScope.of(context);
// Disconnected, the layer previews both; connected, it follows the camera.
final bool canFocus = !p.connected || HudFeatures.tapFocus.enabledFor(p);
final bool canMeter = !p.connected || HudFeatures.spotMetering.enabledFor(p);
if (!canFocus && !canMeter) return const SizedBox.shrink();
final _Mark tap = canFocus ? _Mark.focus : _Mark.meter;
return LayoutBuilder(builder: (BuildContext context, BoxConstraints c) {
final Size size = c.biggest;
return ValueListenableBuilder<LongPressAction>(
valueListenable: FlyPrefs.longPress,
builder: (BuildContext context, LongPressAction long, _) {
final _Mark? longMark = switch (long) {
LongPressAction.meter when canMeter => _Mark.meter,
LongPressAction.focus when canFocus => _Mark.focus,
_ => null,
};
return GestureDetector(
key: const ValueKey<String>('camera-touch'),
behavior: HitTestBehavior.translucent,
onTapUp: (TapUpDetails d) => _act(tap, d.localPosition, size),
// Registered even when set to Nothing, so a long press never ends as a tap.
onLongPressStart: (LongPressStartDetails d) {
if (longMark != null) _act(longMark, d.localPosition, size);
},
child: ListenableBuilder(
listenable: widget.camera,
builder: (BuildContext context, _) {
// The camera left SPOT (menu, RC, reset): the box goes too.
final String? metering = widget.camera.meteringMode;
final Offset? meter = metering != null && metering != 'SPOT' && !widget.camera.isPending('meteringMode') && widget.connected
? null
: _meterAt;
return Stack(clipBehavior: Clip.none, children: <Widget>[
if (_focusAt != null) _box(_focusAt!, 64, HudColors.ready, null),
if (meter != null) _box(meter, 76, Glass.ink, 'Exposure'),
]);
},
),
);
},
);
});
}
Widget _box(Offset at, double side, Color color, String? label) {
return Positioned(
left: at.dx - side / 2,
top: at.dy - side / 2,
width: side,
height: side,
child: Stack(clipBehavior: Clip.none, children: <Widget>[
Positioned.fill(
child: IgnorePointer(
child: DecoratedBox(decoration: BoxDecoration(border: Border.all(color: color, width: 1.5))),
),
),
if (label != null) ...<Widget>[
Positioned(
left: 0,
bottom: -16,
child: IgnorePointer(child: Text(label, style: HudText.label.copyWith(color: Glass.ink))),
),
// Inside the box: a child outside its parent's bounds is not hit-tested.
Positioned(
right: 2,
top: 2,
child: GestureDetector(
key: const ValueKey<String>('meter-cancel'),
behavior: HitTestBehavior.opaque,
onTap: _cancelMeter,
child: Container(
width: 22,
height: 22,
decoration: const BoxDecoration(color: Glass.pillStrong, shape: BoxShape.circle),
alignment: Alignment.center,
child: Text('×', style: HudText.value.copyWith(fontSize: 13)),
),
),
),
],
]),
);
}
}