Files
PilotVault/Fly App/lib/ui/fly/panels/gimbal_sheet.dart
T
tajniak81andClaude Opus 5.5 a892bfb209 Give the Fly App GO 4-style calibration wizards
P6b: a new CalibrationBridge owns every calibration call and publishes
a `calibration` telemetry group (IMU per-unit and per-side progress,
gravity centre, lens, sensor cleaning, RC calibration state and live
sticks). Guided full-screen wizards cover compass, IMU, remote
controller, gimbal auto calibration, lens, sensor cleaning, centre of
gravity, propellers and a vision-sensor guide; gimbal adjust is a
horizon-line overlay on the live video. Every start goes through
SafetyGate.runMaintenance, and closing mid-run stops what the SDK can
stop.

Reachable from Settings > Safety > Calibration & maintenance, the
Aircraft Status calibrate buttons and the gimbal sheet. Not yet run on
an aircraft; the RC key sequence still needs checking per remote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:41:19 +02:00

336 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import '../../../dji_service.dart';
import '../../../flight_model.dart';
import '../../../fly/calibration.dart';
import '../../../fly/gimbal_control.dart';
import '../../../fly/hud_features.dart';
import '../../../fly/product_profile.dart';
import '../../../fly/state/gimbal_state.dart';
import '../../../fly/state/parse.dart';
import '../../../theme.dart';
import '../../pv_icons.dart';
import '../dialogs/slide_to_confirm.dart';
import '../hud_kit.dart';
import 'camera_menu/camera_rows.dart';
import 'camera_write.dart';
import 'settings/calibration/calibration_hub.dart';
/// Opens the gimbal settings (plan P5) as a right-side glass sheet over the
/// live view, like the camera menu.
Future<void> showGimbalSettings(
BuildContext context, {
required FlightModel model,
required DjiService dji,
required void Function(String label, Object error) onError,
}) {
return showGeneralDialog<void>(
context: context,
barrierDismissible: true,
barrierLabel: 'Gimbal settings',
barrierColor: const Color(0x55050A14),
transitionDuration: const Duration(milliseconds: 200),
pageBuilder: (BuildContext context, _, __) => Align(
alignment: Alignment.centerRight,
// The dialog route sits outside the flight screen's scope.
child: ListenableBuilder(
listenable: model,
builder: (BuildContext context, Widget? child) => ProductProfileScope(profile: model.profile, child: child!),
child: _GimbalSheet(model: model, dji: dji, onError: onError),
),
),
transitionBuilder: (BuildContext context, Animation<double> a, _, Widget child) => SlideTransition(
position: Tween<Offset>(begin: const Offset(1, 0), end: Offset.zero).animate(CurvedAnimation(parent: a, curve: Curves.easeOut)),
child: child,
),
);
}
class _GimbalSheet extends StatelessWidget {
const _GimbalSheet({required this.model, required this.dji, required this.onError});
final FlightModel model;
final DjiService dji;
final void Function(String label, Object error) onError;
static const double width = 380;
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xF20B1426),
child: SizedBox(
width: width,
height: MediaQuery.sizeOf(context).height,
child: SafeArea(
left: false,
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 4, 6),
child: Row(children: <Widget>[
const PVIcon('gimbal', size: 16, color: Glass.ink),
const SizedBox(width: 8),
Expanded(child: Text('Gimbal', style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const PVIcon('close', size: 18, color: HudText.dim),
),
]),
),
const Divider(height: 1, color: Glass.hairline),
Expanded(child: GimbalSettingsList(model: model, dji: dji, onError: onError)),
]),
),
),
);
}
}
/// The gimbal rows — mode, position, adjust, calibration, advanced — as one
/// scrolling list, so the P6 settings sheet's Gimbal tab can host it as is.
///
/// Rows hide per [HudFeature] on the connected profile, and the getter-only
/// settings also hide once the gimbal has reported and left them out.
class GimbalSettingsList extends StatelessWidget {
const GimbalSettingsList({super.key, required this.model, required this.dji, required this.onError});
final FlightModel model;
final DjiService dji;
final void Function(String label, Object error) onError;
GimbalState get _g => model.gimbal;
/// A getter-only setting is there unless the gimbal reported and omitted it.
bool _reported(ProductProfile p, String id) => !p.connected || _g.reportedSettings[id] != null;
Future<void> _run(String label, Future<void> Function() send) async {
try {
await send();
} catch (e) {
onError(label, e);
}
}
Future<void> _write(String label, String id, Object value) => writeCameraSetting(
_g,
label: label,
pending: <String, Object?>{id: value},
send: () => dji.setGimbalSetting(id, value),
onError: onError,
);
Future<void> _setMode(String mode) async {
final String? before = _g.mode;
_g.setModeOptimistic(mode);
try {
await dji.setGimbalMode(mode);
} catch (e) {
if (before != null) _g.setModeOptimistic(before);
onError('Gimbal mode', e);
}
}
Future<void> _open(BuildContext context, CalibrationKind kind) =>
openCalibration(context, kind, model: model, dji: dji, onError: onError);
Future<void> _resetAdvanced(BuildContext context) async {
final SlideResult? ok = await showSlideToConfirm(
context,
const SlideConfirmSpec(
icon: 'gimbal',
title: 'Reset gimbal settings',
description: 'Speed, smoothness and sensitivity go back to their defaults.',
sliderLabel: 'Slide to reset',
),
);
if (ok == null) return;
await _run('Reset gimbal settings', dji.resetGimbalControlParameters);
}
static Widget _section(String title) => Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
child: Text(title, style: HudText.label),
);
static String _signed(double v, {int decimals = 1}) => '${v > 0 ? '+' : ''}${v.toStringAsFixed(decimals)}°';
@override
Widget build(BuildContext context) {
final ProductProfile p = ProductProfileScope.of(context);
return ListenableBuilder(
listenable: Listenable.merge(<Listenable>[_g, model.aircraft]),
builder: (BuildContext context, _) => ListView(
key: const ValueKey<String>('gimbal-settings'),
padding: const EdgeInsets.only(bottom: 16),
children: <Widget>[
_status(p),
// ── Mode ──────────────────────────────────────────────────────────
ChoiceTile(
key: const ValueKey<String>('row-gimbalMode'),
label: 'Gimbal mode',
options: <(String, String)>[for (final GimbalModeOption o in GimbalModes.optionsFor(p.gimbal)) (o.value, o.label)],
selected: _g.mode,
onSelect: _setMode,
),
Requires(
HudFeatures.gimbalPitchExtension,
child: _reported(p, 'pitchExtension')
? SwitchTile(
key: const ValueKey<String>('row-pitchExtension'),
label: '30° extended upward',
value: _g.pitchExtension,
onChanged: (bool on) => _write('Extended pitch', 'pitchExtension', on),
)
: const SizedBox.shrink(),
),
Requires(
HudFeatures.gimbalYaw,
child: _reported(p, 'yawFollow')
? SwitchTile(
key: const ValueKey<String>('row-yawFollow'),
label: 'Yaw synchronous follow',
value: _g.yawFollow,
onChanged: (bool on) => _write('Yaw follow', 'yawFollow', on),
)
: const SizedBox.shrink(),
),
// ── Position ──────────────────────────────────────────────────────
_section('POSITION'),
ButtonTile(
id: 'recenter',
label: 'Recenter gimbal',
button: 'Recenter',
onTap: () => _run('Recenter gimbal', dji.resetGimbal),
),
ButtonTile(
id: 'faceDown',
label: 'Face down',
button: 'Down',
onTap: () => _run('Gimbal down', () => dji.rotateGimbalPitch(GimbalActions.faceDownPitch(p.gimbal?.pitch))),
),
Requires(
HudFeatures.gimbalYaw,
child: ButtonTile(
id: 'yawRecenter',
label: 'Yaw recenter',
button: 'Recenter',
onTap: () => _run('Yaw recenter', dji.resetGimbalYaw),
),
),
// ── Adjust ────────────────────────────────────────────────────────
Requires(
HudFeatures.gimbalRollAdjust,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
_section('ADJUST'),
StepperTile(
id: 'rollFineTune',
label: 'Roll fine-tune',
hint: 'Level the horizon against the video',
shown: _g.rollFineTune == null ? '—' : _signed(_g.rollFineTune!),
canDown: true,
canUp: true,
onStep: (int dir) => _run('Roll adjust', () => dji.fineTuneGimbal('roll', dir * GimbalActions.fineTuneStep)),
),
Requires(
HudFeatures.gimbalAdjust,
child: ButtonTile(
id: 'adjustGimbal',
label: 'Adjust gimbal',
hint: 'Roll, pitch and yaw against a horizon line on the video',
button: 'Adjust',
onTap: () => _open(context, CalibrationKind.gimbalAdjust),
),
),
]),
),
// ── Calibration ───────────────────────────────────────────────────
Requires(
HudFeatures.gimbalCalibration,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
_section('CALIBRATION'),
_calibrationRow(context),
]),
),
// ── Advanced ──────────────────────────────────────────────────────
Requires(
HudFeatures.gimbalAdvanced,
whenDisconnected: false,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
_section('ADVANCED'),
for (final (GimbalTuning t, AxisRange r) in GimbalTuning.rowsFor(p))
if (_g.reportedSettings[t.id] != null) _tuningRow(t, r),
ButtonTile(
id: 'resetAdvanced',
label: 'Reset all gimbal settings',
button: 'Reset',
danger: true,
onTap: () => _resetAdvanced(context),
),
]),
),
],
),
);
}
/// Live attitude, plus whatever the gimbal is complaining about.
Widget _status(ProductProfile p) {
final List<String> warnings = <String>[
if (_g.motorOverloaded) 'Motor overloaded — check for obstructions',
if (_g.pitchAtStop) 'Pitch at its limit',
if (_g.yawAtStop && (p.gimbal?.yawAdjustable ?? false)) 'Yaw at its limit',
];
String deg(double? v) => v == null ? '—' : '${v.round()}°';
return Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 4),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
Text(
'PITCH ${deg(_g.pitch)} ROLL ${deg(_g.roll)} YAW ${deg(_g.yawRelative ?? _g.yaw)}',
key: const ValueKey<String>('gimbal-attitude'),
style: HudText.mono,
),
for (final String w in warnings)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(w, style: HudText.sans.copyWith(color: HudColors.caution)),
),
]),
);
}
Widget _calibrationRow(BuildContext context) {
final String? blocked = GimbalActions.calibrationBlocked(model.aircraft, _g);
final String? hint = _g.calibrating
? 'Calibrating… ${_g.calibrationProgress ?? 0}%'
: blocked ??
switch (_g.lastCalibrationOk) {
true => 'Calibrated',
false => 'Calibration failed — retry on level ground',
null => null,
};
return ButtonTile(
id: 'calibrateGimbal',
label: 'Auto calibrate gimbal',
button: 'Calibrate',
hint: hint,
onTap: blocked == null ? () => _open(context, CalibrationKind.gimbal) : null,
);
}
Widget _tuningRow(GimbalTuning t, AxisRange r) {
final int? v = asInt(_g.setting(t.id));
final int step = GimbalTuning.stepFor(r);
final int lo = r.min.round(), hi = r.max.round();
return StepperTile(
key: ValueKey<String>('row-${t.id}'),
id: t.id,
label: t.label,
shown: v?.toString() ?? '—',
pending: _g.isPending(t.id),
canDown: v != null && v > lo,
canUp: v != null && v < hi,
onStep: (int dir) => _write(t.label, t.id, (v! + dir * step).clamp(lo, hi)),
);
}
}