Plan P5: a value bubble on the pitch slider while dragging, press-and-drag on the video as a speed joystick (re-sent at 10 Hz, zero on release), and a gimbal settings sheet: mode, 30° extension, yaw follow, recenter / face down / yaw recenter, roll fine-tune, auto calibration and advanced speed/smoothness rows gated by the gimbal's capability ranges. GimbalBridge reports mode, trim, calibration and stop/overload state in the gimbal telemetry group, and getter-only settings in a new gimbalSettings slow group. The reported-plus-pending overlay moves out of CameraState into a SettingsOverlay mixin shared by camera and gimbal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
392 lines
15 KiB
Dart
392 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../../dji_service.dart';
|
|
import '../../flight_model.dart';
|
|
import '../../fly/flight_trail.dart';
|
|
import '../../fly/fly_prefs.dart';
|
|
import '../../fly/hud_features.dart';
|
|
import '../../fly/safety_gate.dart';
|
|
import '../../theme.dart';
|
|
import '../album_page.dart';
|
|
import '../capture_modes_page.dart';
|
|
import '../pv_icons.dart';
|
|
import '../settings_menu_page.dart';
|
|
import 'bottom/attitude_ball.dart';
|
|
import 'bottom/minimap.dart';
|
|
import 'bottom/telemetry_strip.dart';
|
|
import 'flight_command_flow.dart';
|
|
import 'hud_kit.dart';
|
|
import 'layers/video_layer.dart';
|
|
import 'left/action_rail.dart';
|
|
import 'left/gimbal_slider.dart';
|
|
import 'overlays/alert_stack.dart';
|
|
import 'overlays/camera_touch_layer.dart';
|
|
import 'overlays/gimbal_drag_layer.dart';
|
|
import 'overlays/histogram_view.dart';
|
|
import 'overlays/safety_banners.dart';
|
|
import 'panels/aircraft_status_sheet.dart';
|
|
import 'panels/camera_menu/camera_menu_sheet.dart';
|
|
import 'panels/camera_quick_exposure.dart';
|
|
import 'panels/gimbal_sheet.dart';
|
|
import 'right/camera_column.dart';
|
|
import 'right/zoom_column.dart';
|
|
import 'top/camera_info_strip.dart';
|
|
import 'top/smart_battery_bar.dart';
|
|
import 'top/status_bar.dart';
|
|
|
|
/// The flight HUD v2 (plan P2), replacing [FlightControlPage].
|
|
///
|
|
/// A stack of layers in `fpv_litchi`'s order — video → visual overlays → map →
|
|
/// camera column → gimbal → top bar → bottom attitude → left rail → minimap —
|
|
/// where every readout listens to its own telemetry sub-model, so a 10 Hz tick
|
|
/// rebuilds only what moved. The HUD is laid out once in design pixels and
|
|
/// scaled to phone or tablet by [HudCanvas]; capability-gated parts use
|
|
/// [Requires] against the connected [ProductProfile].
|
|
///
|
|
/// Tapping the minimap swaps it with the video: the map goes full-bleed (and
|
|
/// becomes interactive) while the feed shrinks into the corner, and back.
|
|
///
|
|
/// Safety (plan P3): every aircraft-moving command on the rail goes through
|
|
/// one [SafetyGate] via [FlightCommandFlow]; the error stack and low-battery
|
|
/// banners sit under the top bar; the status chip opens the Aircraft Status
|
|
/// sheet, which is also offered before each connection's first take-off.
|
|
///
|
|
/// Camera (plan P4): the camera column, the zoom column on zoom cameras, the
|
|
/// quick exposure panel (from the camera info strip), the tabbed camera menu,
|
|
/// tap-to-focus / tap-to-meter on the video and the histogram.
|
|
///
|
|
/// Gimbal (plan P5): the pitch slider with its value bubble, press-and-drag
|
|
/// on the video as a speed joystick, and the gimbal settings sheet from the
|
|
/// button under the slider.
|
|
class FlyScreen extends StatefulWidget {
|
|
const FlyScreen({super.key, required this.model, required this.dji});
|
|
|
|
final FlightModel model;
|
|
final DjiService dji;
|
|
|
|
@override
|
|
State<FlyScreen> createState() => _FlyScreenState();
|
|
}
|
|
|
|
class _FlyScreenState extends State<FlyScreen> {
|
|
late final FlightTrail _trail = FlightTrail(widget.model.aircraft);
|
|
final MinimapController _minimap = MinimapController();
|
|
late final SafetyGate _gate = SafetyGate(model: widget.model, dji: widget.dji);
|
|
late final FlightCommandFlow _flow = FlightCommandFlow(
|
|
gate: _gate,
|
|
onError: _error,
|
|
reviewBeforeTakeOff: (BuildContext context) => _openStatus(context, offerTakeOff: true),
|
|
);
|
|
bool _wasConnected = false;
|
|
|
|
bool _mapBig = false;
|
|
|
|
/// The picture-in-picture box, in HUD canvas pixels from the bottom-left corner.
|
|
static const Size _pipSize = Size(150, 90);
|
|
|
|
FlightModel get _m => widget.model;
|
|
DjiService get _dji => widget.dji;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
SystemChrome.setPreferredOrientations(<DeviceOrientation>[
|
|
DeviceOrientation.landscapeLeft,
|
|
DeviceOrientation.landscapeRight,
|
|
]);
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
|
_wasConnected = _m.connected;
|
|
_m.addListener(_onModel);
|
|
}
|
|
|
|
/// A new connection gets its own pre-flight review.
|
|
void _onModel() {
|
|
if (_m.connected == _wasConnected) return;
|
|
_wasConnected = _m.connected;
|
|
if (!_m.connected) _flow.resetReview();
|
|
}
|
|
|
|
Future<bool> _openStatus(BuildContext context, {bool offerTakeOff = false}) {
|
|
// Looking at the sheet on purpose counts as the pre-flight review.
|
|
if (!offerTakeOff) _flow.markReviewed();
|
|
return showAircraftStatusSheet(context, model: _m, dji: _dji, onError: _error, offerTakeOff: offerTakeOff);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_m.removeListener(_onModel);
|
|
_trail.dispose();
|
|
_minimap.dispose();
|
|
SystemChrome.setPreferredOrientations(<DeviceOrientation>[DeviceOrientation.portraitUp]);
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
|
super.dispose();
|
|
}
|
|
|
|
void _error(String label, Object e) {
|
|
if (!mounted) return;
|
|
final String reason = e is PlatformException ? (e.message ?? e.code) : e.toString();
|
|
ScaffoldMessenger.of(context)
|
|
..clearSnackBars()
|
|
..showSnackBar(SnackBar(content: Text('$label failed: $reason')));
|
|
}
|
|
|
|
void _push(Widget page) => Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => page));
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final MediaQueryData mq = MediaQuery.of(context);
|
|
final double s = HudScale.scaleFor(mq.size.shortestSide);
|
|
final Rect area = HudCanvas.screenRect(mq);
|
|
final double canvasHeight = area.height / s;
|
|
final Rect pip = HudCanvas.toScreen(mq, Rect.fromLTWH(0, canvasHeight - _pipSize.height, _pipSize.width, _pipSize.height));
|
|
final Rect full = Offset.zero & mq.size;
|
|
|
|
final Widget video = ListenableBuilder(
|
|
key: const ValueKey<String>('video'),
|
|
listenable: _m,
|
|
builder: (BuildContext context, _) => VideoLayer(connected: _m.connected),
|
|
);
|
|
final Widget map = FlyMap(
|
|
key: const ValueKey<String>('map'),
|
|
model: _m,
|
|
trail: _trail,
|
|
controller: _minimap,
|
|
interactive: _mapBig,
|
|
);
|
|
|
|
final Widget body = Stack(fit: StackFit.expand, children: <Widget>[
|
|
// The background view, then whatever sits in the corner box. Keyed, so
|
|
// the swap moves the native video view instead of recreating it.
|
|
if (!_mapBig) ...<Widget>[
|
|
Positioned.fromRect(key: const ValueKey<String>('video-slot'), rect: full, child: video),
|
|
const Positioned.fill(child: VisualOverlays()),
|
|
// Under the camera touch layer, in the same gesture arena: drags aim
|
|
// the gimbal, taps and long presses still reach the camera.
|
|
Positioned.fill(
|
|
child: Requires(
|
|
HudFeatures.gimbalDrag,
|
|
child: ListenableBuilder(
|
|
listenable: _m,
|
|
builder: (BuildContext context, _) => GimbalDragLayer(
|
|
gimbal: _m.gimbal,
|
|
dji: _dji,
|
|
connected: _m.connected,
|
|
yaw: HudFeatures.gimbalYaw.enabledFor(ProductProfileScope.of(context)),
|
|
onError: _error,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned.fill(
|
|
child: Requires(
|
|
HudFeatures.cameraControls,
|
|
child: ListenableBuilder(
|
|
listenable: _m,
|
|
builder: (BuildContext context, _) =>
|
|
CameraTouchLayer(camera: _m.camera, dji: _dji, connected: _m.connected, onError: _error),
|
|
),
|
|
),
|
|
),
|
|
Positioned.fromRect(key: const ValueKey<String>('map-slot'), rect: pip, child: map),
|
|
] else ...<Widget>[
|
|
Positioned.fromRect(key: const ValueKey<String>('map-slot'), rect: full, child: map),
|
|
Positioned.fromRect(key: const ValueKey<String>('video-slot'), rect: pip, child: video),
|
|
],
|
|
Positioned.fill(child: HudCanvas(builder: _hud)),
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
child: IgnorePointer(
|
|
child: ValueListenableBuilder<bool>(
|
|
valueListenable: FlyPrefs.perfOverlay,
|
|
builder: (BuildContext context, bool on, _) => on ? PerformanceOverlay.allEnabled() : const SizedBox.shrink(),
|
|
),
|
|
),
|
|
),
|
|
]);
|
|
|
|
return Scaffold(
|
|
backgroundColor: const Color(0xFF0A1120),
|
|
body: ListenableBuilder(
|
|
listenable: _m,
|
|
// Only the scope is rebuilt per tick; it notifies [Requires] below it
|
|
// when the profile object itself changes.
|
|
builder: (BuildContext context, Widget? child) => ProductProfileScope(profile: _m.profile, child: child!),
|
|
child: body,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _hud(BuildContext context, Size canvas, double scale) {
|
|
return Stack(clipBehavior: Clip.none, children: <Widget>[
|
|
// ── Camera column (right) ─────────────────────────────────────────────
|
|
Positioned(
|
|
right: 0,
|
|
top: 76,
|
|
bottom: 0,
|
|
child: Center(
|
|
child: Requires(
|
|
HudFeatures.cameraControls,
|
|
child: CameraColumn(
|
|
model: _m,
|
|
dji: _dji,
|
|
onError: _error,
|
|
onCameraMenu: () => showCameraMenu(context, model: _m, dji: _dji, onError: _error),
|
|
onPlayback: () => _push(AlbumPage(model: _m, dji: _dji)),
|
|
onCaptureModes: () => _push(CaptureModesPage(model: _m, dji: _dji)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// ── Zoom column (zoom cameras only) ───────────────────────────────────
|
|
if (!_mapBig)
|
|
Positioned(
|
|
right: 68,
|
|
top: 76,
|
|
bottom: 0,
|
|
child: Center(
|
|
child: Requires(
|
|
HudFeatures.zoomColumn,
|
|
whenDisconnected: false,
|
|
child: ZoomColumn(camera: _m.camera, dji: _dji, onError: _error),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
right: 68,
|
|
top: StatusBar.height + SmartBatteryBar.height + 32,
|
|
child: Requires(HudFeatures.histogram, child: HistogramView(camera: _m.camera)),
|
|
),
|
|
// ── Gimbal ────────────────────────────────────────────────────────────
|
|
Positioned(
|
|
left: 54,
|
|
top: 64,
|
|
bottom: _pipSize.height + 24,
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
|
Expanded(
|
|
child: Requires(
|
|
HudFeatures.gimbalPitch,
|
|
child: Builder(
|
|
builder: (BuildContext context) => GimbalSlider(
|
|
gimbal: _m.gimbal,
|
|
dji: _dji,
|
|
range: ProductProfileScope.of(context).gimbal?.pitch,
|
|
onError: _error,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Requires(
|
|
HudFeatures.gimbalSettings,
|
|
child: GlassPill(
|
|
key: const ValueKey<String>('gimbal-settings-button'),
|
|
height: 28,
|
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
|
onTap: () => showGimbalSettings(context, model: _m, dji: _dji, onError: _error),
|
|
child: const PVIcon('gimbal', size: 16, color: Glass.ink),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
// ── Top bar, battery bar, camera info ─────────────────────────────────
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
child: StatusBar(
|
|
model: _m,
|
|
onBack: () => Navigator.of(context).maybePop(),
|
|
onStatus: () => _openStatus(context),
|
|
onMore: () => _push(SettingsMenuPage(model: _m, dji: _dji)),
|
|
),
|
|
),
|
|
Positioned(left: 0, right: 0, top: StatusBar.height + 3, child: SmartBatteryBar(model: _m)),
|
|
Positioned(
|
|
right: 0,
|
|
top: StatusBar.height + SmartBatteryBar.height + 5,
|
|
child: CameraInfoStrip(
|
|
camera: _m.camera,
|
|
onTap: () => showQuickExposure(context, model: _m, dji: _dji, onError: _error),
|
|
),
|
|
),
|
|
// ── Bottom: attitude ball + telemetry ─────────────────────────────────
|
|
Positioned(
|
|
left: _pipSize.width + 10,
|
|
bottom: 0,
|
|
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: <Widget>[
|
|
AttitudeBall(model: _m, size: 84),
|
|
const SizedBox(width: 6),
|
|
TelemetryStrip(aircraft: _m.aircraft),
|
|
]),
|
|
),
|
|
// ── Error stack + low-battery banners ─────────────────────────────────
|
|
// Bounded above the attitude ball; the stack fits what is left under the banners.
|
|
Positioned(
|
|
left: 108,
|
|
top: StatusBar.height + SmartBatteryBar.height + 8,
|
|
bottom: 104,
|
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
|
SafetyBanners(gate: _gate, onError: _error),
|
|
Flexible(child: AlertStack(alerts: _m.alerts, onMore: () => _openStatus(context))),
|
|
]),
|
|
),
|
|
// ── Left rail ─────────────────────────────────────────────────────────
|
|
Positioned(
|
|
left: 0,
|
|
top: 60,
|
|
child: ValueListenableBuilder<GridStyle>(
|
|
valueListenable: FlyPrefs.grid,
|
|
builder: (BuildContext context, GridStyle g, _) => ActionRail(
|
|
model: _m,
|
|
flow: _flow,
|
|
grid: g != GridStyle.off,
|
|
onGrid: () => FlyPrefs.setGrid(g == GridStyle.off ? GridStyle.thirds : GridStyle.off),
|
|
),
|
|
),
|
|
),
|
|
if (_mapBig) Positioned(right: 70, top: 80, child: MinimapControls(controller: _minimap, trail: _trail)),
|
|
// ── Minimap / video corner box ────────────────────────────────────────
|
|
Positioned(
|
|
left: 0,
|
|
bottom: 0,
|
|
width: _pipSize.width,
|
|
height: _pipSize.height,
|
|
child: _PipFrame(label: _mapBig ? 'VIDEO' : 'MAP', onTap: () => setState(() => _mapBig = !_mapBig)),
|
|
),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/// The corner box's frame and tap target. The content underneath is the real
|
|
/// map or video layer, placed at the same spot by [FlyScreen].
|
|
class _PipFrame extends StatelessWidget {
|
|
const _PipFrame({required this.label, required this.onTap});
|
|
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onTap,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: Border.all(color: const Color(0x66EAF0FA)),
|
|
),
|
|
alignment: Alignment.topLeft,
|
|
padding: const EdgeInsets.all(4),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
|
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(3)),
|
|
child: Text(label, style: HudText.label.copyWith(color: Glass.ink)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|