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:
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user