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>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-19 21:41:19 +02:00
1 parent 284de5442a
commit a892bfb209
20 files changed
+3119 -77

No files matched your search

@@ -4,11 +4,13 @@ import 'package:flutter/services.dart';
import '../../../dji_service.dart';
import '../../../flight_model.dart';
import '../../../fly/aircraft_checklist.dart';
import '../../../fly/calibration.dart';
import '../../../fly/state/parse.dart';
import '../../../theme.dart';
import '../../pv_icons.dart';
import '../dialogs/slide_to_confirm.dart';
import '../hud_kit.dart';
import 'settings/calibration/calibration_hub.dart';
/// Opens the Aircraft Status sheet over the right of the flight screen.
///
@@ -94,50 +96,38 @@ class _AircraftStatusSheetState extends State<AircraftStatusSheet> {
}
Future<void> _act(ChecklistAction action) async {
final ({SlideConfirmSpec? spec, String label, Future<void> Function() run}) a = switch (action) {
ChecklistAction.calibrateCompass => (
label: 'Compass calibration',
run: widget.dji.startCompassCalibration,
spec: const SlideConfirmSpec(
icon: 'compass',
title: 'Calibrate compass',
description: 'Stand away from metal, cars and power lines. Hold the aircraft level and turn it a full circle, '
'then hold it nose-down and turn again. The Compass row shows each step.',
sliderLabel: 'Slide to start',
),
),
ChecklistAction.stopCompassCalibration => (label: 'Stop calibration', run: widget.dji.stopCompassCalibration, spec: null),
ChecklistAction.calibrateImu => (
label: 'IMU calibration',
run: widget.dji.startIMUCalibration,
spec: const SlideConfirmSpec(
icon: 'gauge',
title: 'Calibrate IMU',
description: 'Put the aircraft on a flat, level surface with the motors off and do not touch it. '
'Calibration takes 5–10 minutes; some aircraft ask to be turned onto each side.',
sliderLabel: 'Slide to start',
),
),
ChecklistAction.formatSd => (
label: 'Format SD',
run: widget.dji.formatStorage,
spec: const SlideConfirmSpec(
switch (action) {
// Calibrations are guided wizards (plan P6b).
case ChecklistAction.calibrateCompass:
return _wizard(CalibrationKind.compass);
case ChecklistAction.calibrateImu:
return _wizard(CalibrationKind.imu);
case ChecklistAction.stopCompassCalibration:
return _send('Stop calibration', widget.dji.stopCompassCalibration);
case ChecklistAction.formatSd:
final SlideResult? ok = await showSlideToConfirm(
context,
const SlideConfirmSpec(
icon: 'sdcard',
title: 'Format SD card',
description: 'Every photo and video on the card is erased. This cannot be undone.',
sliderLabel: 'Slide to erase',
color: HudColors.danger,
),
),
};
if (a.spec != null) {
final SlideResult? ok = await showSlideToConfirm(context, a.spec!);
if (ok == null) return;
);
if (ok == null) return;
return _send('Format SD', widget.dji.formatStorage);
}
}
Future<void> _wizard(CalibrationKind kind) =>
openCalibration(context, kind, model: widget.model, dji: widget.dji, onError: widget.onError);
Future<void> _send(String label, Future<void> Function() run) async {
try {
await a.run();
await run();
} catch (e) {
widget.onError(a.label, e);
widget.onError(label, e);
}
}
+15 -15
View File
@@ -2,6 +2,7 @@ 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';
@@ -13,6 +14,7 @@ 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.
@@ -128,20 +130,8 @@ class GimbalSettingsList extends StatelessWidget {
}
}
Future<void> _calibrate(BuildContext context) async {
final SlideResult? ok = await showSlideToConfirm(
context,
const SlideConfirmSpec(
icon: 'gimbal',
title: 'Auto calibrate gimbal',
description: 'Put the aircraft on a flat, level surface with the motors off and the gimbal free to move. '
'Do not touch it until calibration finishes (about 30 seconds).',
sliderLabel: 'Slide to start',
),
);
if (ok == null) return;
await _run('Gimbal calibration', dji.startGimbalCalibration);
}
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(
@@ -241,6 +231,16 @@ class GimbalSettingsList extends StatelessWidget {
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 ───────────────────────────────────────────────────
@@ -313,7 +313,7 @@ class GimbalSettingsList extends StatelessWidget {
label: 'Auto calibrate gimbal',
button: 'Calibrate',
hint: hint,
onTap: blocked == null ? () => _calibrate(context) : null,
onTap: blocked == null ? () => _open(context, CalibrationKind.gimbal) : null,
);
}
@@ -0,0 +1,294 @@
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/safety_gate.dart';
import '../../../../../theme.dart';
import '../../../../pv_icons.dart';
import '../../../hud_kit.dart';
import '../../camera_menu/camera_rows.dart';
import 'illustrations.dart';
import 'wizards.dart';
/// Opens the [kind] wizard (plan P6b). Every wizard is a full-screen page that
/// leaves the live view, except the gimbal adjust step, which needs the video:
/// it closes any sheet or hub above the flight screen and lays a horizon line
/// over it instead.
Future<void> openCalibration(
BuildContext context,
CalibrationKind kind, {
required FlightModel model,
required DjiService dji,
required void Function(String label, Object error) onError,
}) async {
final NavigatorState nav = Navigator.of(context);
if (kind == CalibrationKind.gimbalAdjust) {
nav.popUntil((Route<dynamic> r) => r is! PopupRoute && r.settings.name != CalibrationHubPage.routeName);
await nav.push(PageRouteBuilder<void>(
opaque: false,
barrierColor: Colors.transparent,
settings: const RouteSettings(name: GimbalAdjustOverlay.routeName),
pageBuilder: (BuildContext context, _, __) => GimbalAdjustOverlay(model: model, dji: dji, onError: onError),
transitionsBuilder: (BuildContext context, Animation<double> a, _, Widget child) => FadeTransition(opacity: a, child: child),
));
return;
}
void adjust() {
openCalibration(nav.context, CalibrationKind.gimbalAdjust, model: model, dji: dji, onError: onError);
}
final Widget page = switch (kind) {
CalibrationKind.compass => CompassWizard(model: model, dji: dji, onError: onError),
CalibrationKind.imu => ImuWizard(model: model, dji: dji, onError: onError),
CalibrationKind.rc => RcWizard(model: model, dji: dji, onError: onError),
CalibrationKind.gimbal => GimbalCalibrationWizard(model: model, dji: dji, onError: onError, onAdjust: adjust),
CalibrationKind.lens => LensWizard(model: model, dji: dji, onError: onError),
CalibrationKind.sensorCleaning => SensorCleaningWizard(model: model, dji: dji, onError: onError),
CalibrationKind.gravityCenter => GravityCenterWizard(model: model, dji: dji, onError: onError),
CalibrationKind.propeller => PropellerWizard(model: model, dji: dji, onError: onError),
CalibrationKind.visionSensors => const VisionSensorGuide(),
CalibrationKind.gimbalAdjust => throw StateError('handled above'),
};
await nav.push(MaterialPageRoute<void>(builder: (_) => page));
}
/// Opens the list of every wizard this aircraft offers.
Future<void> showCalibrationHub(
BuildContext context, {
required FlightModel model,
required DjiService dji,
required void Function(String label, Object error) onError,
}) =>
Navigator.of(context).push(MaterialPageRoute<void>(
settings: const RouteSettings(name: CalibrationHubPage.routeName),
builder: (_) => CalibrationHubPage(model: model, dji: dji, onError: onError),
));
/// Settings → Safety → "Calibration & maintenance": every wizard the
/// connected aircraft offers, grouped like GO 4, with a one-line status each.
class CalibrationHubPage extends StatelessWidget {
const CalibrationHubPage({super.key, required this.model, required this.dji, required this.onError});
static const String routeName = 'calibrationHub';
final FlightModel model;
final DjiService dji;
final void Function(String label, Object error) onError;
static const List<(String, List<CalibrationKind>)> _groups = <(String, List<CalibrationKind>)>[
('AIRCRAFT', <CalibrationKind>[CalibrationKind.compass, CalibrationKind.imu, CalibrationKind.gravityCenter, CalibrationKind.propeller, CalibrationKind.visionSensors]),
('REMOTE CONTROLLER', <CalibrationKind>[CalibrationKind.rc]),
('GIMBAL & CAMERA', <CalibrationKind>[CalibrationKind.gimbal, CalibrationKind.gimbalAdjust, CalibrationKind.lens, CalibrationKind.sensorCleaning]),
];
String? _status(CalibrationKind k) => switch (k) {
CalibrationKind.compass => model.aircraft.compassHasError == true
? 'Error — calibrate'
: (model.aircraft.compassCalibrating == true ? 'Calibrating…' : null),
CalibrationKind.imu => model.aircraft.imuPreheating == true ? 'Warming up' : null,
CalibrationKind.gimbal => model.gimbal.calibrating ? 'Calibrating… ${model.gimbal.calibrationProgress ?? 0}%' : null,
CalibrationKind.gravityCenter => 'In a hover, after changing the payload',
CalibrationKind.visionSensors => 'Needs DJI Assistant 2 on a computer',
_ => null,
};
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF070D1A),
body: SafeArea(
child: ProductProfileScope(
profile: model.profile,
child: ListenableBuilder(
listenable: Listenable.merge(<Listenable>[model.calibration, model.aircraft, model.gimbal]),
builder: (BuildContext context, _) {
final Set<CalibrationKind> offered = CalibrationKinds.available(model.profile, model.calibration).toSet();
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Container(
height: 44,
padding: const EdgeInsets.only(left: 4, right: 16),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Glass.hairline))),
child: Row(children: <Widget>[
IconButton(
key: const ValueKey<String>('hub-back'),
onPressed: () => Navigator.of(context).pop(),
icon: const PVIcon('chevronLeft', size: 18, color: Glass.ink),
),
Expanded(
child: Text('Calibration & maintenance', style: HudText.sans.copyWith(fontSize: 15, fontWeight: FontWeight.w700)),
),
if (!model.connected) Text('NOT CONNECTED', style: HudText.label.copyWith(color: HudColors.caution)),
]),
),
Expanded(
child: ListView(padding: const EdgeInsets.only(bottom: 16), children: <Widget>[
for (final (String title, List<CalibrationKind> kinds) in _groups)
if (kinds.any(offered.contains)) ...<Widget>[
Padding(padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), child: Text(title, style: HudText.label)),
for (final CalibrationKind k in kinds)
if (offered.contains(k))
_HubRow(
kind: k,
status: _status(k),
onTap: () => openCalibration(context, k, model: model, dji: dji, onError: onError),
),
],
]),
),
]);
},
),
),
),
);
}
}
class _HubRow extends StatelessWidget {
const _HubRow({required this.kind, required this.onTap, this.status});
final CalibrationKind kind;
final String? status;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
key: ValueKey<String>('hub-${kind.name}'),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
child: Row(children: <Widget>[
PVIcon(CalibrationKinds.icon(kind), size: 18, color: Glass.ink),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
Text(CalibrationKinds.title(kind), style: HudText.sans.copyWith(fontSize: 13)),
if (status != null)
Padding(
padding: const EdgeInsets.only(top: 3),
child: Text(status!, style: HudText.sans.copyWith(fontSize: 11, fontWeight: FontWeight.w500, color: HudText.dim)),
),
]),
),
const PVIcon('chevronRight', size: 16, color: HudText.dim),
]),
),
);
}
}
/// The gimbal adjust step: a horizon reference over the live video and
/// fine-tune steppers for roll (and pitch / yaw where the gimbal moves on
/// them), in a small glass panel so most of the picture stays visible.
class GimbalAdjustOverlay extends StatelessWidget {
const GimbalAdjustOverlay({super.key, required this.model, required this.dji, required this.onError});
static const String routeName = 'gimbalAdjust';
final FlightModel model;
final DjiService dji;
final void Function(String label, Object error) onError;
Future<void> _tune(String axis, int dir) async {
try {
await dji.fineTuneGimbal(axis, dir * GimbalActions.fineTuneStep);
} catch (e) {
onError('Adjust gimbal $axis', e);
}
}
static String _deg(double? v) => v == null ? '—' : '${v > 0 ? '+' : ''}${v.toStringAsFixed(1)}°';
@override
Widget build(BuildContext context) {
final bool pitch = model.profile.gimbal?.pitchAdjustable ?? true;
final bool yaw = model.profile.gimbal?.yawAdjustable ?? false;
return Material(
type: MaterialType.transparency,
child: Stack(fit: StackFit.expand, children: <Widget>[
const IgnorePointer(child: CustomPaint(painter: HorizonPainter())),
Positioned(
left: 16,
top: 12,
child: SafeArea(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(8)),
child: Text('Line the horizon in the video up with the dashed line', style: HudText.sans),
),
),
),
Positioned(
right: 12,
top: 12,
bottom: 12,
width: 300,
child: SafeArea(
left: false,
child: Container(
decoration: BoxDecoration(color: const Color(0xE60B1426), borderRadius: BorderRadius.circular(12)),
child: ListenableBuilder(
listenable: Listenable.merge(<Listenable>[model.gimbal, model.aircraft]),
builder: (BuildContext context, _) {
final List<GateCheck> checks = SafetyGate(model: model, dji: dji).checkMaintenance(CalibrationKind.gimbalAdjust);
final bool ok = checks.every((GateCheck c) => !c.blocks);
return ListView(padding: const EdgeInsets.symmetric(vertical: 6), children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 8, 2),
child: Row(children: <Widget>[
Expanded(child: Text('Adjust gimbal', style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))),
TextButton(
key: const ValueKey<String>('adjust-done'),
onPressed: () => Navigator.of(context).pop(),
child: Text('Done', style: HudText.sans.copyWith(color: Glass.accent, fontSize: 13)),
),
]),
),
for (final GateCheck c in checks)
Padding(
padding: const EdgeInsets.fromLTRB(16, 2, 16, 4),
child: Text(c.message, style: HudText.sans.copyWith(color: c.blocks ? HudColors.danger : HudColors.caution)),
),
StepperTile(
id: 'adjustRoll',
label: 'Roll',
hint: 'Tilt of the horizon',
shown: _deg(model.gimbal.rollFineTune),
canDown: ok,
canUp: ok,
onStep: (int dir) => _tune('roll', dir),
),
if (pitch)
StepperTile(
id: 'adjustPitch',
label: 'Pitch',
hint: 'Level point when centred',
shown: _deg(model.gimbal.pitchFineTune),
canDown: ok,
canUp: ok,
onStep: (int dir) => _tune('pitch', dir),
),
if (yaw)
StepperTile(
id: 'adjustYaw',
label: 'Yaw',
hint: 'Straight ahead when centred',
shown: _deg(model.gimbal.yawFineTune),
canDown: ok,
canUp: ok,
onStep: (int dir) => _tune('yaw', dir),
),
]);
},
),
),
),
),
]),
);
}
}
@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../../dji_service.dart';
import '../../../../../flight_model.dart';
import '../../../../../fly/calibration.dart';
import '../../../../../fly/safety_gate.dart';
import '../../../../../theme.dart';
import '../../../../pv_icons.dart';
import '../../../hud_kit.dart';
/// A wizard page: the shared inputs of every calibration flow.
abstract class CalibrationWizard extends StatefulWidget {
const CalibrationWizard({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;
}
/// The plumbing every wizard shares: it folds the live model into [run],
/// sends its start command through the [SafetyGate], and on the way out
/// ([close], or the system back) [abort]s whatever is half done, so a
/// cancelled calibration leaves the aircraft in its normal state.
abstract class WizardState<W extends CalibrationWizard> extends State<W> {
CalibrationKind get kind;
CalibrationRun get run;
late final SafetyGate gate = SafetyGate(model: widget.model, dji: widget.dji);
FlightModel get model => widget.model;
DjiService get dji => widget.dji;
/// Leaving mid-run asks first unless this is false (IMU: nothing to stop).
bool get canAbort => true;
@override
void initState() {
super.initState();
model.addListener(_onModel);
}
@override
void dispose() {
model.removeListener(_onModel);
super.dispose();
}
void _onModel() {
if (!mounted) return;
setState(() => run.observe(model));
}
/// The gate's verdict right now, for the intro page.
List<GateCheck> get checks => gate.checkMaintenance(kind);
bool get blocked => checks.any((GateCheck c) => c.blocks);
/// Sends [send] through the gate; a refusal or SDK error fails the run.
Future<void> start(Future<void> Function() send) async {
HapticFeedback.selectionClick();
setState(run.begin);
try {
await gate.runMaintenance(kind, send);
if (mounted) setState(() => run.observe(model));
} catch (e) {
if (mounted) setState(() => run.startFailed(errorText(e)));
}
}
/// Runs a step command that is not the start; errors go to the snackbar.
Future<bool> send(String label, Future<void> Function() call) async {
try {
await call();
return true;
} catch (e) {
widget.onError(label, e);
return false;
}
}
/// Puts the aircraft back to normal. Called on the way out while [run] is active.
Future<void> abort() async {}
Future<void> close() async {
if (run.active && canAbort) {
final bool? stop = await showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(
backgroundColor: const Color(0xFF0B1426),
title: Text('Stop ${CalibrationKinds.title(kind).toLowerCase()}?', style: HudText.sans.copyWith(fontSize: 15)),
content: Text('The aircraft goes back to its previous calibration.', style: HudText.sans.copyWith(color: HudText.dim)),
actions: <Widget>[
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Keep going')),
TextButton(
key: const ValueKey<String>('wizard-confirm-stop'),
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Stop', style: TextStyle(color: HudColors.danger)),
),
],
),
);
if (stop != true) return;
}
if (run.active) await abort();
if (mounted) Navigator.of(context).pop();
}
static String errorText(Object e) => switch (e) {
PlatformException(:final String? message, :final String code) => message ?? code,
_ => e.toString(),
};
}
/// One button along the bottom of a wizard.
class WizardAction {
const WizardAction(this.label, this.onTap, {this.primary = false, this.danger = false, required this.id});
final String id;
final String label;
final VoidCallback? onTap;
final bool primary;
final bool danger;
}
/// The full-screen frame of every wizard: the illustration on the left, the
/// step, instructions, gate checks, progress and buttons on the right (stacked
/// in portrait). Leaves the live view on purpose — GO 4 does the same.
class CalibrationScaffold extends StatelessWidget {
const CalibrationScaffold({
super.key,
required this.title,
required this.onClose,
required this.illustration,
required this.heading,
required this.body,
this.stepLabel,
this.checks = const <GateCheck>[],
this.detail,
this.progress,
this.actions = const <WizardAction>[],
this.tone,
});
final String title;
final VoidCallback onClose;
final Widget illustration;
final String? stepLabel;
final String heading;
final String body;
final List<GateCheck> checks;
final Widget? detail;
/// 0..1; null shows no bar, NaN an indeterminate one.
final double? progress;
final List<WizardAction> actions;
/// Heading colour: success / failure.
final Color? tone;
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, _) {
if (!didPop) onClose();
},
child: Scaffold(
backgroundColor: const Color(0xFF070D1A),
body: SafeArea(
child: LayoutBuilder(builder: (BuildContext context, BoxConstraints c) {
final bool wide = c.maxWidth > c.maxHeight;
final Widget art = Container(
margin: const EdgeInsets.all(12),
decoration: BoxDecoration(color: const Color(0xFF0B1426), borderRadius: BorderRadius.circular(14)),
child: Center(child: illustration),
);
final Widget text = _text(context);
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
_titleBar(),
Expanded(
child: wide
? Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Expanded(flex: 5, child: art),
Expanded(flex: 6, child: text),
])
: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
SizedBox(height: c.maxHeight * 0.38, child: art),
Expanded(child: text),
]),
),
]);
}),
),
),
);
}
Widget _titleBar() => Container(
height: 44,
padding: const EdgeInsets.only(left: 16, right: 4),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Glass.hairline))),
child: Row(children: <Widget>[
Expanded(child: Text(title, style: HudText.sans.copyWith(fontSize: 15, fontWeight: FontWeight.w700))),
IconButton(
key: const ValueKey<String>('wizard-close'),
onPressed: onClose,
icon: const PVIcon('close', size: 18, color: HudText.dim),
),
]),
);
Widget _text(BuildContext context) {
final double? p = progress;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 12, 20, 12),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Expanded(
child: ListView(padding: EdgeInsets.zero, children: <Widget>[
if (stepLabel != null) Text(stepLabel!, key: const ValueKey<String>('wizard-step'), style: HudText.label),
if (stepLabel != null) const SizedBox(height: 6),
Text(
heading,
key: const ValueKey<String>('wizard-heading'),
style: HudText.sans.copyWith(fontSize: 18, fontWeight: FontWeight.w700, color: tone ?? Glass.ink, height: 1.2),
),
const SizedBox(height: 8),
Text(body, style: HudText.sans.copyWith(fontSize: 13, fontWeight: FontWeight.w500, color: HudText.dim, height: 1.4)),
for (final GateCheck c in <GateCheck>[...checks.where((GateCheck c) => c.blocks), ...checks.where((GateCheck c) => !c.blocks)])
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
PVIcon('alert', size: 14, color: c.blocks ? HudColors.danger : HudColors.caution),
const SizedBox(width: 6),
Expanded(
child: Text(c.message,
style: HudText.sans.copyWith(fontSize: 12, color: c.blocks ? HudColors.danger : HudColors.caution, height: 1.3)),
),
]),
),
if (detail != null) Padding(padding: const EdgeInsets.only(top: 12), child: detail),
]),
),
if (p != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
key: const ValueKey<String>('wizard-progress'),
value: p.isNaN ? null : p.clamp(0.0, 1.0),
minHeight: 6,
backgroundColor: Glass.hairline,
color: Glass.accent,
),
),
),
if (actions.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(mainAxisAlignment: MainAxisAlignment.end, children: <Widget>[
for (final WizardAction a in actions)
Padding(padding: const EdgeInsets.only(left: 10), child: _button(a)),
]),
),
]),
);
}
static Widget _button(WizardAction a) {
final Color color = a.danger ? HudColors.danger : Glass.accent;
final Text label = Text(a.label, style: const TextStyle(fontFamily: PV.fontSans, fontWeight: FontWeight.w700));
return a.primary
? FilledButton(
key: ValueKey<String>('wizard-${a.id}'),
onPressed: a.onTap,
style: FilledButton.styleFrom(
backgroundColor: color,
disabledBackgroundColor: Glass.hairline,
disabledForegroundColor: HudText.dim,
minimumSize: const Size(120, 42),
),
child: label,
)
: OutlinedButton(
key: ValueKey<String>('wizard-${a.id}'),
onPressed: a.onTap,
style: OutlinedButton.styleFrom(foregroundColor: Glass.ink, side: BorderSide(color: color), minimumSize: const Size(100, 42)),
child: label,
);
}
}
@@ -0,0 +1,416 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../../../fly/calibration.dart';
import '../../../../../fly/state/calibration_state.dart';
import '../../../../../theme.dart';
import '../../../../pv_icons.dart';
import '../../../hud_kit.dart';
const Color _line = Glass.ink;
const Color _soft = Color(0x66EAF0FA);
/// A big icon in a ring — the illustration for wizards without a drawing,
/// with the ring filling as [progress] (0..1) grows.
class IconIllustration extends StatelessWidget {
const IconIllustration(this.icon, {super.key, this.progress, this.color = Glass.accent});
final String icon;
final double? progress;
final Color color;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: 150,
child: Stack(alignment: Alignment.center, children: <Widget>[
SizedBox.expand(
child: CircularProgressIndicator(
value: progress ?? 0,
strokeWidth: 4,
backgroundColor: Glass.hairline,
color: color,
),
),
PVIcon(icon, size: 64, stroke: 1.4, color: Glass.ink),
]),
);
}
}
/// The compass dance: level spin, then nose-down spin, with a turning arrow.
class CompassIllustration extends StatefulWidget {
const CompassIllustration({super.key, required this.step, this.animate = true});
final CompassStep step;
final bool animate;
@override
State<CompassIllustration> createState() => _CompassIllustrationState();
}
class _CompassIllustrationState extends State<CompassIllustration> with SingleTickerProviderStateMixin {
late final AnimationController _spin = AnimationController(vsync: this, duration: const Duration(seconds: 4));
@override
void initState() {
super.initState();
if (widget.animate) _spin.repeat();
}
@override
void dispose() {
_spin.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
SizedBox(
width: 220,
height: 180,
child: AnimatedBuilder(
animation: _spin,
builder: (BuildContext context, _) => CustomPaint(painter: _CompassPainter(widget.step, _spin.value)),
),
),
const SizedBox(height: 8),
Text(widget.step == CompassStep.horizontal ? 'Level · turn 360°' : 'Nose down · turn 360°', style: HudText.label),
]);
}
}
class _CompassPainter extends CustomPainter {
_CompassPainter(this.step, this.t);
final CompassStep step;
final double t;
@override
void paint(Canvas canvas, Size size) {
final Offset c = size.center(Offset.zero);
final Paint ring = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2
..color = Glass.accent;
final Rect oval = Rect.fromCenter(center: c + const Offset(0, 40), width: 190, height: 50);
canvas.drawArc(oval, 0, math.pi * 1.7, false, ring);
// The arrowhead travels round the ellipse.
final double a = t * 2 * math.pi;
final Offset tip = Offset(oval.center.dx + oval.width / 2 * math.cos(a), oval.center.dy + oval.height / 2 * math.sin(a));
canvas.drawCircle(tip, 5, Paint()..color = Glass.accent);
canvas.save();
canvas.translate(c.dx, c.dy);
if (step == CompassStep.horizontal) {
// Seen from slightly above: squash vertically, spin about the vertical axis.
canvas.scale(1, 0.45);
canvas.rotate(a);
AircraftGlyph.top(canvas, 60);
} else {
// Nose down: the body stands on end, its width breathing as it spins.
canvas.scale(math.cos(a).abs().clamp(0.25, 1.0), 1);
canvas.rotate(math.pi / 2);
AircraftGlyph.side(canvas, 64);
}
canvas.restore();
}
@override
bool shouldRepaint(covariant _CompassPainter old) => old.t != t || old.step != step;
}
/// The aircraft on the side the IMU wants down, over a ground line.
class ImuIllustration extends StatelessWidget {
const ImuIllustration({super.key, this.orientation});
/// A `CalibrationOrientation` name; null draws it flat.
final String? orientation;
@override
Widget build(BuildContext context) {
return Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
SizedBox(width: 220, height: 170, child: CustomPaint(painter: _ImuPainter(orientation ?? 'BOTTOM_DOWN'))),
const SizedBox(height: 8),
Text(ImuOrientations.label(orientation ?? 'BOTTOM_DOWN').toUpperCase(), style: HudText.label),
]);
}
}
class _ImuPainter extends CustomPainter {
_ImuPainter(this.orientation);
final String orientation;
@override
void paint(Canvas canvas, Size size) {
final double ground = size.height - 18;
canvas.drawLine(Offset(20, ground), Offset(size.width - 20, ground), Paint()
..color = _soft
..strokeWidth = 2);
const double w = 70;
// How far the drawing reaches below its centre on each side, so it rests on the line.
final double reach = switch (orientation) {
'NOSE_DOWN' || 'TAIL_DOWN' => w * 0.66,
'LEFT_DOWN' || 'RIGHT_DOWN' => w * 0.64,
'TOP_DOWN' => w * 0.18,
_ => w * 0.28,
};
canvas.save();
canvas.translate(size.width / 2, ground - reach - 1);
final bool front = orientation == 'LEFT_DOWN' || orientation == 'RIGHT_DOWN';
canvas.rotate(switch (orientation) {
'TOP_DOWN' => math.pi,
'NOSE_DOWN' => math.pi / 2,
'TAIL_DOWN' => -math.pi / 2,
'LEFT_DOWN' => -math.pi / 2,
'RIGHT_DOWN' => math.pi / 2,
_ => 0,
});
front ? AircraftGlyph.front(canvas, w) : AircraftGlyph.side(canvas, w);
canvas.restore();
}
@override
bool shouldRepaint(covariant _ImuPainter old) => old.orientation != orientation;
}
/// Simple quadcopter line drawings, centred on the origin, [w] wide.
abstract final class AircraftGlyph {
static Paint get _stroke => Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.4
..strokeCap = StrokeCap.round
..color = _line;
/// From above, nose up: X arms, four rotors, a nose dot.
static void top(Canvas c, double w) {
final Paint p = _stroke;
final double r = w * 0.5;
for (final double a in <double>[math.pi / 4, 3 * math.pi / 4, 5 * math.pi / 4, 7 * math.pi / 4]) {
final Offset e = Offset(math.cos(a) * r, math.sin(a) * r);
c.drawLine(Offset.zero, e, p);
c.drawCircle(e, w * 0.2, p);
}
c.drawRRect(RRect.fromRectAndRadius(Rect.fromCenter(center: Offset.zero, width: w * 0.3, height: w * 0.45), const Radius.circular(6)),
Paint()..color = const Color(0xFF0B1426));
c.drawRRect(RRect.fromRectAndRadius(Rect.fromCenter(center: Offset.zero, width: w * 0.3, height: w * 0.45), const Radius.circular(6)), p);
c.drawCircle(Offset(0, -w * 0.16), 3.5, Paint()..color = HudColors.danger);
}
/// From the left side, nose to the right (+x): body, arms, rotors, legs.
static void side(Canvas c, double w) {
final Paint p = _stroke;
c.drawRRect(RRect.fromRectAndRadius(Rect.fromCenter(center: Offset.zero, width: w * 0.6, height: w * 0.22), const Radius.circular(5)), p);
for (final double x in <double>[-w * 0.5, w * 0.5]) {
c.drawLine(Offset(x * 0.55, -w * 0.06), Offset(x, -w * 0.14), p);
c.drawLine(Offset(x - w * 0.16, -w * 0.18), Offset(x + w * 0.16, -w * 0.18), p);
c.drawLine(Offset(x * 0.5, w * 0.11), Offset(x * 0.6, w * 0.28), p);
}
// Nose camera.
c.drawCircle(Offset(w * 0.33, w * 0.04), w * 0.05, Paint()..color = HudColors.danger);
}
/// From the front: body, two arms each side, rotors, legs.
static void front(Canvas c, double w) {
final Paint p = _stroke;
c.drawRRect(RRect.fromRectAndRadius(Rect.fromCenter(center: Offset.zero, width: w * 0.32, height: w * 0.24), const Radius.circular(5)), p);
for (final double s in <double>[-1, 1]) {
c.drawLine(Offset(s * w * 0.16, -w * 0.04), Offset(s * w * 0.5, -w * 0.12), p);
c.drawLine(Offset(s * w * 0.5 - w * 0.14, -w * 0.17), Offset(s * w * 0.5 + w * 0.14, -w * 0.17), p);
c.drawLine(Offset(s * w * 0.12, w * 0.12), Offset(s * w * 0.22, w * 0.3), p);
}
c.drawCircle(Offset(0, w * 0.02), w * 0.05, Paint()..color = HudColors.danger);
}
}
/// The remote's two sticks and two dials, live, with the area each stick has
/// swept so far — the RC wizard's "move to every limit" view.
class RcSticksView extends StatefulWidget {
const RcSticksView({super.key, required this.hardware, this.showCoverage = false});
final RcHardware? hardware;
final bool showCoverage;
/// Stick travel either side of centre, as the SDK reports it.
static const int travel = 660;
@override
State<RcSticksView> createState() => _RcSticksViewState();
}
class _RcSticksViewState extends State<RcSticksView> {
Rect _left = Rect.zero, _right = Rect.zero;
@override
void didUpdateWidget(RcSticksView old) {
super.didUpdateWidget(old);
if (!widget.showCoverage) {
_left = _right = Rect.zero;
return;
}
final RcHardware? h = widget.hardware;
if (h == null) return;
_left = _grow(_left, h.lx, h.ly);
_right = _grow(_right, h.rx, h.ry);
}
static Rect _grow(Rect r, int? x, int? y) {
if (x == null || y == null) return r;
final Offset p = Offset(x.toDouble(), -y.toDouble());
return r.expandToInclude(Rect.fromCenter(center: p, width: 0, height: 0));
}
@override
Widget build(BuildContext context) {
final RcHardware? h = widget.hardware;
return Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
_Stick(x: h?.lx, y: h?.ly, swept: widget.showCoverage ? _left : null, key: const ValueKey<String>('rc-left-stick')),
const SizedBox(width: 28),
_Stick(x: h?.rx, y: h?.ry, swept: widget.showCoverage ? _right : null, key: const ValueKey<String>('rc-right-stick')),
]),
const SizedBox(height: 14),
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
_Dial(label: 'LEFT DIAL', value: h?.leftDial),
const SizedBox(width: 28),
_Dial(label: 'RIGHT DIAL', value: h?.rightDial),
]),
]);
}
}
class _Stick extends StatelessWidget {
const _Stick({super.key, this.x, this.y, this.swept});
final int? x, y;
final Rect? swept;
@override
Widget build(BuildContext context) =>
SizedBox.square(dimension: 104, child: CustomPaint(painter: _StickPainter(x, y, swept)));
}
class _StickPainter extends CustomPainter {
_StickPainter(this.x, this.y, this.swept);
final int? x, y;
final Rect? swept;
@override
void paint(Canvas canvas, Size size) {
final Rect box = Offset.zero & size;
final double half = size.width / 2;
double px(num v) => half + v / RcSticksView.travel * (half - 8);
canvas.drawRRect(RRect.fromRectAndRadius(box, const Radius.circular(10)), Paint()..color = const Color(0xFF111C33));
if (swept != null && swept != Rect.zero) {
canvas.drawRect(
Rect.fromLTRB(px(swept!.left), px(swept!.top), px(swept!.right), px(swept!.bottom)),
Paint()..color = HudColors.ready.withValues(alpha: 0.25),
);
}
final Paint cross = Paint()
..color = _soft
..strokeWidth = 1;
canvas.drawLine(Offset(half, 6), Offset(half, size.height - 6), cross);
canvas.drawLine(Offset(6, half), Offset(size.width - 6, half), cross);
if (x != null && y != null) {
canvas.drawCircle(Offset(px(x!), px(-y!)), 8, Paint()..color = Glass.accent);
}
}
@override
bool shouldRepaint(covariant _StickPainter old) => old.x != x || old.y != y || old.swept != swept;
}
class _Dial extends StatelessWidget {
const _Dial({required this.label, this.value});
final String label;
final int? value;
@override
Widget build(BuildContext context) {
final double t = ((value ?? 0) / RcSticksView.travel).clamp(-1.0, 1.0);
return SizedBox(
width: 104,
child: Column(children: <Widget>[
Text(label, style: HudText.label),
const SizedBox(height: 4),
SizedBox(
height: 8,
child: LayoutBuilder(builder: (BuildContext context, BoxConstraints c) {
final double mid = c.maxWidth / 2;
return Stack(children: <Widget>[
Container(decoration: BoxDecoration(color: const Color(0xFF111C33), borderRadius: BorderRadius.circular(4))),
Positioned(
left: t < 0 ? mid + t * mid : mid,
width: t.abs() * mid,
top: 0,
bottom: 0,
child: Container(color: Glass.accent),
),
]);
}),
),
]),
);
}
}
/// Reference lines drawn over the live video for the gimbal adjust step: a
/// dashed level line through the centre and faint thirds to judge the
/// horizon against.
class HorizonPainter extends CustomPainter {
const HorizonPainter();
@override
void paint(Canvas canvas, Size size) {
final double mid = size.height / 2;
final Paint ref = Paint()
..color = Glass.subject
..strokeWidth = 1.5;
for (double x = 0; x < size.width; x += 18) {
canvas.drawLine(Offset(x, mid), Offset(math.min(x + 10, size.width), mid), ref);
}
final Paint thirds = Paint()
..color = const Color(0x33FFFFFF)
..strokeWidth = 1;
for (final double f in <double>[1 / 3, 2 / 3]) {
canvas.drawLine(Offset(0, size.height * f), Offset(size.width, size.height * f), thirds);
}
}
@override
bool shouldRepaint(covariant HorizonPainter old) => false;
}
/// A row of sides for the multi-side IMU calibration, done ones ticked.
class SideChips extends StatelessWidget {
const SideChips({super.key, required this.sides, this.current});
final List<(String, bool)> sides;
final String? current;
@override
Widget build(BuildContext context) {
return Wrap(spacing: 6, runSpacing: 6, children: <Widget>[
for (final (String side, bool done) in sides)
Container(
key: ValueKey<String>('imu-side-$side'),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: side == current ? Glass.accent : (done ? HudColors.ready.withValues(alpha: 0.25) : const Color(0xFF111C33)),
),
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
if (done) const PVIcon('check', size: 12, color: HudColors.ready),
if (done) const SizedBox(width: 4),
Text(ImuOrientations.label(side), style: HudText.sans.copyWith(fontSize: 11, fontFamily: PV.fontSans)),
]),
),
]);
}
}
@@ -0,0 +1,608 @@
import 'package:flutter/material.dart';
import '../../../../../fly/calibration.dart';
import '../../../hud_kit.dart';
import 'calibration_scaffold.dart';
import 'illustrations.dart';
/// The pieces every wizard's pages share: the intro with the gate's checks,
/// the "starting" page, and the success / failure ends.
mixin _Pages<W extends CalibrationWizard> on WizardState<W> {
String get title => CalibrationKinds.title(kind);
Widget intro({
required Widget illustration,
required String heading,
required String body,
required Future<void> Function() onStart,
String startLabel = 'Start',
Widget? detail,
}) {
final bool b = blocked;
return CalibrationScaffold(
title: title,
onClose: close,
illustration: illustration,
heading: heading,
body: body,
checks: checks,
detail: detail,
actions: <WizardAction>[
WizardAction(startLabel, b ? null : onStart, primary: true, id: 'start'),
],
);
}
Widget starting(Widget illustration) => CalibrationScaffold(
title: title,
onClose: close,
illustration: illustration,
heading: 'Starting…',
body: 'Waiting for the aircraft to begin.',
progress: double.nan,
);
Widget succeeded({required Widget illustration, required String heading, required String body, List<WizardAction> more = const <WizardAction>[]}) =>
CalibrationScaffold(
title: title,
onClose: close,
illustration: illustration,
heading: heading,
body: body,
tone: HudColors.ready,
actions: <WizardAction>[...more, WizardAction('Done', close, primary: true, id: 'done')],
);
Widget failed({required Widget illustration, required VoidCallback onRetry}) => CalibrationScaffold(
title: title,
onClose: close,
illustration: illustration,
heading: 'Calibration failed',
body: run.failure ?? 'Try again.',
tone: HudColors.danger,
checks: checks,
actions: <WizardAction>[
WizardAction('Close', close, id: 'close'),
WizardAction('Try again', blocked ? null : onRetry, primary: true, id: 'retry'),
],
);
/// Ends a run from its Stop button: undo, then back to the intro.
Future<void> stopHere() async {
await abort();
if (mounted) setState(run.reset);
}
WizardAction get stopAction => WizardAction('Stop', stopHere, danger: true, id: 'stop');
}
// ── Compass ─────────────────────────────────────────────────────────────────
class CompassWizard extends CalibrationWizard {
const CompassWizard({super.key, required super.model, required super.dji, required super.onError});
@override
State<CompassWizard> createState() => _CompassWizardState();
}
class _CompassWizardState extends WizardState<CompassWizard> with _Pages<CompassWizard> {
@override
CalibrationKind get kind => CalibrationKind.compass;
@override
final CompassRun run = CompassRun();
@override
Future<void> abort() => send('Stop compass calibration', dji.stopCompassCalibration);
Future<void> _start() => start(dji.startCompassCalibration);
@override
Widget build(BuildContext context) {
final CompassStep step = run.step ?? CompassStep.horizontal;
final int count = model.calibration.compassCount ?? model.profile.flightController?.compassCount ?? 1;
return switch (run.phase) {
RunPhase.idle => intro(
illustration: const CompassIllustration(step: CompassStep.horizontal),
heading: 'Calibrate the compass',
body: 'Do it outdoors, away from metal, cars, reinforced concrete and power lines, and without a phone or keys '
'in your hand. Take the aircraft off any metal surface. You will turn it a full circle level, '
'then a full circle nose down.${count > 1 ? ' Its $count compasses calibrate together.' : ''}',
onStart: _start,
),
RunPhase.starting => starting(const CompassIllustration(step: CompassStep.horizontal, animate: false)),
RunPhase.running => CalibrationScaffold(
title: title,
onClose: close,
illustration: CompassIllustration(key: ValueKey<CompassStep>(step), step: step),
stepLabel: step == CompassStep.horizontal ? 'STEP 1 OF 2' : 'STEP 2 OF 2',
heading: step == CompassStep.horizontal ? 'Hold it level and turn a full circle' : 'Hold it nose down and turn a full circle',
body: step == CompassStep.horizontal
? 'Turn slowly on the spot about 360° until the next step appears.'
: 'Point the nose at the ground and turn about 360° again.',
progress: step == CompassStep.horizontal ? 0.25 : 0.75,
actions: <WizardAction>[stopAction],
),
RunPhase.succeeded => succeeded(
illustration: const IconIllustration('compass', progress: 1, color: HudColors.ready),
heading: 'Compass calibrated',
body: 'Recalibrate after flying somewhere far away or if the app warns about compass interference.',
),
RunPhase.failed => failed(illustration: const IconIllustration('compass', color: HudColors.danger), onRetry: _start),
};
}
}
// ── IMU ─────────────────────────────────────────────────────────────────────
class ImuWizard extends CalibrationWizard {
const ImuWizard({super.key, required super.model, required super.dji, required super.onError});
@override
State<ImuWizard> createState() => _ImuWizardState();
}
class _ImuWizardState extends WizardState<ImuWizard> with _Pages<ImuWizard> {
@override
CalibrationKind get kind => CalibrationKind.imu;
@override
final ImuRun run = ImuRun();
/// The SDK has no stop: leaving the page lets it finish on its own.
@override
bool get canAbort => false;
Future<void> _start() => start(dji.startIMUCalibration);
@override
Widget build(BuildContext context) {
final int count = model.calibration.imuCount ?? model.profile.flightController?.imuCount ?? 1;
final double? p = run.progress;
return switch (run.phase) {
RunPhase.idle => intro(
illustration: const ImuIllustration(),
heading: 'Calibrate the IMU',
body: 'Take the propellers off and put the aircraft on a flat, level, steady surface with the motors off and '
'the gimbal clamp removed. Do not touch it while it calibrates — about 5 to 10 minutes. Some aircraft ask '
'to be turned onto each side; this screen shows which.${count > 1 ? ' All $count IMUs calibrate together.' : ''}',
onStart: _start,
),
RunPhase.starting => starting(const ImuIllustration()),
RunPhase.running => CalibrationScaffold(
title: title,
onClose: close,
illustration: ImuIllustration(key: ValueKey<String?>(run.orientation), orientation: run.orientation),
stepLabel: run.orientation == null
? null
: 'SIDE ${run.sides.where(((String, bool) s) => s.$2).length + 1} OF ${run.sides.length}',
heading: run.orientation == null
? 'Calibrating — do not move the aircraft'
: 'Put it ${ImuOrientations.label(run.orientation!).toLowerCase()}',
body: run.orientation == null
? 'Keep it still until calibration finishes. Leaving this screen does not stop it.'
: 'Hold it steady on that side until the side is ticked, then turn it as shown.',
detail: run.sides.isEmpty ? null : SideChips(sides: run.sides, current: run.orientation),
progress: p ?? double.nan,
),
RunPhase.succeeded => succeeded(
illustration: const IconIllustration('gauge', progress: 1, color: HudColors.ready),
heading: 'IMU calibrated',
body: 'Restart the aircraft before flying.',
),
RunPhase.failed => failed(illustration: const IconIllustration('gauge', color: HudColors.danger), onRetry: _start),
};
}
}
// ── Remote controller ───────────────────────────────────────────────────────
class RcWizard extends CalibrationWizard {
const RcWizard({super.key, required super.model, required super.dji, required super.onError});
@override
State<RcWizard> createState() => _RcWizardState();
}
class _RcWizardState extends WizardState<RcWizard> with _Pages<RcWizard> {
@override
CalibrationKind get kind => CalibrationKind.rc;
@override
final RcRun run = RcRun();
@override
void initState() {
super.initState();
dji.watchRcHardware(true).catchError((Object _) {});
}
@override
void dispose() {
dji.watchRcHardware(false).catchError((Object _) {});
super.dispose();
}
/// QUIT is the only way out the remote knows.
@override
Future<void> abort() => send('Leave RC calibration', () => dji.setRcCalibrationStep('QUIT'));
Future<void> _start() => start(() => dji.setRcCalibrationStep('MIDDLE'));
Future<void> _finish() async {
if (!await send('Save RC calibration', () => dji.setRcCalibrationStep('QUIT'))) return;
if (!mounted) return;
setState(() {
run.finishing();
run.observe(model);
});
}
Widget? _axes() {
final Map<String, Object?> axes = model.calibration.rcAxes;
if (axes.isEmpty) return null;
final int? segments = model.calibration.rcSegments;
return Text(
[for (final MapEntry<String, Object?> e in axes.entries) '${e.key} ${e.value}${segments == null ? '' : '/$segments'}'].join(' '),
key: const ValueKey<String>('rc-axes'),
style: HudText.mono,
);
}
@override
Widget build(BuildContext context) {
final bool limits = run.step == RcStep.limits;
final Widget sticks = RcSticksView(hardware: model.calibration.rcHardware, showCoverage: run.active && limits);
return switch (run.phase) {
RunPhase.idle => intro(
illustration: sticks,
heading: 'Calibrate the remote controller',
body: 'Keep the aircraft on the ground with the motors off. Let go of both sticks and centre both dials, then start. '
'Calibrate when the aircraft drifts with the sticks centred or the sticks feel off.',
onStart: _start,
),
RunPhase.starting => starting(sticks),
RunPhase.running when !limits => CalibrationScaffold(
title: title,
onClose: close,
illustration: sticks,
stepLabel: 'STEP 1 OF 2',
heading: 'Leave the sticks and dials centred',
body: 'The remote is recording their centre. Do not touch them, then tap Next.',
progress: 0.3,
actions: <WizardAction>[
stopAction,
WizardAction('Next', () => send('RC calibration', () => dji.setRcCalibrationStep('LIMITS')), primary: true, id: 'next'),
],
),
RunPhase.running => CalibrationScaffold(
title: title,
onClose: close,
illustration: sticks,
stepLabel: 'STEP 2 OF 2',
heading: 'Move every stick and dial to its limits',
body: 'Push both sticks around their full range a few times and turn both dials all the way each way. '
'Tap Finish when the green areas fill both boxes.',
detail: _axes(),
progress: 0.7,
actions: <WizardAction>[
stopAction,
WizardAction('Finish', _finish, primary: true, id: 'finish'),
],
),
RunPhase.succeeded => succeeded(illustration: sticks, heading: 'Remote controller calibrated', body: 'The new centre and limits are saved.'),
RunPhase.failed => failed(illustration: sticks, onRetry: _start),
};
}
}
// ── Gimbal auto calibration ─────────────────────────────────────────────────
class GimbalCalibrationWizard extends CalibrationWizard {
const GimbalCalibrationWizard({super.key, required super.model, required super.dji, required super.onError, this.onAdjust});
/// Offered after a calibration: opens the horizon adjust step.
final VoidCallback? onAdjust;
@override
State<GimbalCalibrationWizard> createState() => _GimbalCalibrationWizardState();
}
class _GimbalCalibrationWizardState extends WizardState<GimbalCalibrationWizard> with _Pages<GimbalCalibrationWizard> {
@override
CalibrationKind get kind => CalibrationKind.gimbal;
@override
final GimbalRun run = GimbalRun();
/// The SDK has no stop for gimbal auto calibration.
@override
bool get canAbort => false;
Future<void> _start() => start(dji.startGimbalCalibration);
@override
Widget build(BuildContext context) {
final double? p = run.progress == null ? null : run.progress! / 100;
return switch (run.phase) {
RunPhase.idle => intro(
illustration: const IconIllustration('gimbal'),
heading: 'Auto calibrate the gimbal',
body: 'Put the aircraft on a flat, level surface with the motors off, the gimbal clamp removed and nothing touching '
'the camera. Do not touch it until calibration finishes — about 30 seconds.',
onStart: _start,
),
RunPhase.starting => starting(const IconIllustration('gimbal')),
RunPhase.running => CalibrationScaffold(
title: title,
onClose: close,
illustration: IconIllustration('gimbal', progress: p),
heading: 'Calibrating — do not touch the aircraft',
body: 'The gimbal moves through its range on its own.',
progress: p ?? double.nan,
),
RunPhase.succeeded => succeeded(
illustration: const IconIllustration('gimbal', progress: 1, color: HudColors.ready),
heading: 'Gimbal calibrated',
body: 'If the horizon still looks tilted, adjust it against the live video.',
more: <WizardAction>[
if (widget.onAdjust != null) WizardAction('Adjust horizon', widget.onAdjust, id: 'adjust'),
],
),
RunPhase.failed => failed(illustration: const IconIllustration('gimbal', color: HudColors.danger), onRetry: _start),
};
}
}
// ── Lens ────────────────────────────────────────────────────────────────────
class LensWizard extends CalibrationWizard {
const LensWizard({super.key, required super.model, required super.dji, required super.onError});
@override
State<LensWizard> createState() => _LensWizardState();
}
class _LensWizardState extends WizardState<LensWizard> with _Pages<LensWizard> {
@override
CalibrationKind get kind => CalibrationKind.lens;
@override
final LensRun run = LensRun();
@override
Future<void> abort() => send('Stop lens calibration', dji.stopLensCalibration);
Future<void> _start() => start(dji.startLensCalibration);
@override
Widget build(BuildContext context) {
return switch (run.phase) {
RunPhase.idle => intro(
illustration: const IconIllustration('focus'),
heading: 'Calibrate the lens',
body: 'Do this after fitting a lens, or when manual focus does not match the distance scale. '
'Point the camera at a distant, detailed scene in good light and keep the aircraft still.',
onStart: _start,
),
RunPhase.starting => starting(const IconIllustration('focus')),
RunPhase.running => CalibrationScaffold(
title: title,
onClose: close,
illustration: const IconIllustration('focus'),
heading: 'Calibrating the lens…',
body: 'The lens runs through its focus range. Keep the camera still.',
progress: double.nan,
actions: <WizardAction>[stopAction],
),
RunPhase.succeeded => succeeded(
illustration: const IconIllustration('focus', progress: 1, color: HudColors.ready),
heading: 'Lens calibrated',
body: 'Manual focus now follows the lens\'s distance scale.',
),
RunPhase.failed => failed(illustration: const IconIllustration('focus', color: HudColors.danger), onRetry: _start),
};
}
}
// ── Sensor cleaning ─────────────────────────────────────────────────────────
class SensorCleaningWizard extends CalibrationWizard {
const SensorCleaningWizard({super.key, required super.model, required super.dji, required super.onError});
@override
State<SensorCleaningWizard> createState() => _SensorCleaningWizardState();
}
class _SensorCleaningWizardState extends WizardState<SensorCleaningWizard> with _Pages<SensorCleaningWizard> {
@override
CalibrationKind get kind => CalibrationKind.sensorCleaning;
@override
final SensorCleaningRun run = SensorCleaningRun();
@override
Future<void> abort() => send('Leave sensor cleaning', dji.exitSensorCleaning);
Future<void> _start() => start(dji.initSensorCleaning);
Future<void> _finish() async {
setState(run.exiting);
await send('Leave sensor cleaning', dji.exitSensorCleaning);
if (mounted) setState(() => run.observe(model));
}
@override
Widget build(BuildContext context) {
final CleaningStep step = run.step;
const Widget art = IconIllustration('aperture');
return switch (run.phase) {
RunPhase.idle => intro(
illustration: art,
heading: 'Clean the image sensor',
body: '${SensorCleaning.instruction(CleaningStep.off)} Do it indoors, out of the wind, with the aircraft on a table.',
onStart: _start,
),
RunPhase.starting => starting(art),
RunPhase.running => CalibrationScaffold(
title: title,
onClose: close,
illustration: art,
stepLabel: switch (step) {
CleaningStep.removeLens || CleaningStep.entering => 'STEP 1 OF 3',
CleaningStep.ready || CleaningStep.cleaning || CleaningStep.finishing => 'STEP 2 OF 3',
CleaningStep.remountLens || CleaningStep.off => 'STEP 3 OF 3',
},
heading: switch (step) {
CleaningStep.removeLens => 'Remove the lens',
CleaningStep.ready => 'Ready to clean',
CleaningStep.cleaning => 'Cleaning',
CleaningStep.remountLens => 'Refit the lens',
_ => 'Please wait',
},
body: SensorCleaning.instruction(step),
progress: step == CleaningStep.cleaning || step == CleaningStep.entering || step == CleaningStep.finishing ? double.nan : null,
actions: <WizardAction>[
stopAction,
if (step == CleaningStep.ready) WizardAction('Clean', () => send('Sensor cleaning', dji.startSensorCleaning), primary: true, id: 'clean'),
if (step == CleaningStep.remountLens) WizardAction('Finish', _finish, primary: true, id: 'finish'),
],
),
RunPhase.succeeded => succeeded(
illustration: const IconIllustration('aperture', progress: 1, color: HudColors.ready),
heading: 'Sensor cleaned',
body: 'If spots remain on a plain sky, have the sensor cleaned by DJI support.',
),
RunPhase.failed => failed(illustration: const IconIllustration('aperture', color: HudColors.danger), onRetry: _start),
};
}
}
// ── Center of gravity ───────────────────────────────────────────────────────
class GravityCenterWizard extends CalibrationWizard {
const GravityCenterWizard({super.key, required super.model, required super.dji, required super.onError});
@override
State<GravityCenterWizard> createState() => _GravityCenterWizardState();
}
class _GravityCenterWizardState extends WizardState<GravityCenterWizard> with _Pages<GravityCenterWizard> {
@override
CalibrationKind get kind => CalibrationKind.gravityCenter;
@override
final GravityCenterRun run = GravityCenterRun();
@override
Future<void> abort() => send('Stop gravity calibration', dji.stopGravityCenterCalibration);
Future<void> _start() => start(dji.startGravityCenterCalibration);
@override
Widget build(BuildContext context) {
const Widget art = IconIllustration('drone');
return switch (run.phase) {
RunPhase.idle => intro(
illustration: art,
heading: 'Calibrate the center of gravity',
body: 'After fitting, moving or changing a payload. Take off, hover at least 2 m above open ground in calm air, '
'let go of the sticks, then start. The aircraft measures its balance for about a minute.',
onStart: _start,
),
RunPhase.starting => starting(art),
RunPhase.running => CalibrationScaffold(
title: title,
onClose: close,
illustration: art,
heading: 'Measuring — keep the sticks centred',
body: 'The aircraft holds its hover while it measures. Take over at once if it drifts towards anything.',
progress: double.nan,
actions: <WizardAction>[stopAction],
),
RunPhase.succeeded => succeeded(
illustration: const IconIllustration('drone', progress: 1, color: HudColors.ready),
heading: 'Center of gravity calibrated',
body: 'Land when ready. Recalibrate whenever the payload changes.',
),
RunPhase.failed => failed(illustration: const IconIllustration('drone', color: HudColors.danger), onRetry: _start),
};
}
}
// ── Propellers ──────────────────────────────────────────────────────────────
class PropellerWizard extends CalibrationWizard {
const PropellerWizard({super.key, required super.model, required super.dji, required super.onError});
@override
State<PropellerWizard> createState() => _PropellerWizardState();
}
class _PropellerWizardState extends WizardState<PropellerWizard> with _Pages<PropellerWizard> {
@override
CalibrationKind get kind => CalibrationKind.propeller;
@override
final PropellerRun run = PropellerRun();
@override
Future<void> abort() => send('Stop propeller calibration', dji.stopPropellerCalibration);
Future<void> _start() async {
await start(dji.startPropellerCalibration);
if (mounted) setState(run.accepted);
}
Future<void> _done() async {
await send('Stop propeller calibration', dji.stopPropellerCalibration);
if (mounted) setState(run.complete);
}
@override
Widget build(BuildContext context) {
const Widget art = IconIllustration('drone');
return switch (run.phase) {
RunPhase.idle => intro(
illustration: art,
heading: 'Calibrate the propellers',
body: 'Put the aircraft on open, level ground with the propellers fitted. The motors spin during calibration: '
'step back and keep everyone clear.',
onStart: _start,
),
RunPhase.starting => starting(art),
RunPhase.running => CalibrationScaffold(
title: title,
onClose: close,
illustration: art,
heading: 'Calibrating — keep clear',
body: 'The aircraft does not report progress to the app. When the motors have stopped, tap Done.',
progress: double.nan,
actions: <WizardAction>[
stopAction,
WizardAction('Done', _done, primary: true, id: 'finish'),
],
),
RunPhase.succeeded => succeeded(
illustration: const IconIllustration('drone', progress: 1, color: HudColors.ready),
heading: 'Propeller calibration ended',
body: 'If the aircraft reported an error on its status lights, run it again.',
),
RunPhase.failed => failed(illustration: const IconIllustration('drone', color: HudColors.danger), onRetry: _start),
};
}
}
// ── Vision sensors ──────────────────────────────────────────────────────────
/// No SDK call exists; GO 4 also only points to DJI Assistant 2.
class VisionSensorGuide extends StatelessWidget {
const VisionSensorGuide({super.key});
@override
Widget build(BuildContext context) {
void close() => Navigator.of(context).pop();
return CalibrationScaffold(
title: CalibrationKinds.title(CalibrationKind.visionSensors),
onClose: close,
illustration: const IconIllustration('obstacle'),
heading: 'Calibrate on a computer with DJI Assistant 2',
body: 'The vision sensors cannot be calibrated from a mobile app. Connect the aircraft to a computer with '
'DJI Assistant 2 (the Consumer or Enterprise series that matches the aircraft), open Calibration and '
'follow the steps on screen. Do it when the app reports a vision sensor error or after a hard knock.',
actions: <WizardAction>[WizardAction('Close', close, primary: true, id: 'done')],
);
}
}
@@ -17,6 +17,7 @@ import '../../dialogs/slide_to_confirm.dart';
import '../../hud_kit.dart';
import '../camera_menu/camera_rows.dart';
import '../gimbal_sheet.dart';
import 'calibration/calibration_hub.dart';
import 'setting_tiles.dart';
/// Opens the aircraft settings (plan P6, GO 4's "•••") as a right-side glass
@@ -379,11 +380,18 @@ class _FlySettingsSheetState extends State<FlySettingsSheet> {
}}',
valueColor: compassError == true ? HudColors.danger : null,
),
ButtonTile(
id: 'openCalibration',
label: 'Calibration & maintenance',
hint: 'Compass, IMU, remote, gimbal and more',
button: 'Open',
onTap: () => showCalibrationHub(context, model: _m, dji: widget.dji, onError: widget.onError),
),
if (widget.onOpenStatus != null)
ButtonTile(
id: 'openStatus',
label: 'Aircraft status & calibration',
hint: 'Compass and IMU calibration',
label: 'Aircraft status',
hint: 'Every subsystem at a glance',
button: 'Open',
onTap: widget.onOpenStatus,
),