Take-off, land, RTH, the landing cancel/confirm and Stop now all go through one SafetyGate (lib/fly/safety_gate.dart). The gate sorts its preconditions into blockers and warnings: connection, flight state, battery against the thresholds, IMU warm-up, compass, critical diagnostics, GPS, home point, SD card and the RTH battery budget. It checks again right before sending. The left rail is now a state machine (lib/fly/action_rail_state.dart): Take-off becomes Land, then Cancel or Confirm landing. RTH becomes Cancel RTH. A Stop button shows while an intelligent mode is flying. Moving the aircraft needs a slide-to-confirm dialog, which can offer Precision Takeoff or set the RTH altitude. A blocked command shows why and has no slider. When the pilot is more than 30 m from home, RTH asks whether to return to them or to the original home point. The HUD gains an error stack. Its cards can be dismissed, it fits the space it has, and a new warning triggers haptic feedback. It also gets a smart-RTH countdown banner with Cancel / Go home now, a forced-landing banner, and an Aircraft Status sheet. The sheet opens from the status chip and appears before each connection's first take-off. It offers Calibrate compass/IMU and Format SD. On the bridge side this adds precisionTakeOff, setHomeLocation, compass and IMU calibration start/stop, formatStorage, and compass health in the telemetry tick. The classic flight screen stays available until the simulator run passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
329 lines
12 KiB
Dart
329 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
|
||
import '../../../fly/safety_gate.dart';
|
||
import '../../../theme.dart';
|
||
import '../../pv_icons.dart';
|
||
import '../hud_kit.dart';
|
||
|
||
/// A number the pilot sets inside the confirm dialog (the RTH altitude), with
|
||
/// −/+ steppers rather than a keyboard, which would cover the landscape HUD.
|
||
@immutable
|
||
class SlideNumber {
|
||
const SlideNumber({required this.label, required this.initial, required this.min, required this.max, this.step = 5, this.unit = 'm'});
|
||
|
||
final String label;
|
||
final int initial;
|
||
final int min;
|
||
final int max;
|
||
final int step;
|
||
final String unit;
|
||
}
|
||
|
||
/// What the pilot chose on a confirmed dialog.
|
||
@immutable
|
||
class SlideResult {
|
||
const SlideResult({this.checked = false, this.number});
|
||
|
||
final bool checked;
|
||
final int? number;
|
||
}
|
||
|
||
/// Everything the slide-to-confirm dialog (plan P3, GO 4's `fpv_leftmenu_dlg`)
|
||
/// shows: icon, title, description, the gate's warnings or blockers, an
|
||
/// optional checkbox and number, and the slider.
|
||
@immutable
|
||
class SlideConfirmSpec {
|
||
const SlideConfirmSpec({
|
||
required this.icon,
|
||
required this.title,
|
||
required this.description,
|
||
required this.sliderLabel,
|
||
this.color = Glass.accent,
|
||
this.checks = const <GateCheck>[],
|
||
this.checkbox,
|
||
this.checkboxHint,
|
||
this.number,
|
||
});
|
||
|
||
final String icon;
|
||
final String title;
|
||
final String description;
|
||
final String sliderLabel;
|
||
final Color color;
|
||
final List<GateCheck> checks;
|
||
|
||
/// Label of an optional checkbox ("Precision Takeoff").
|
||
final String? checkbox;
|
||
final String? checkboxHint;
|
||
final SlideNumber? number;
|
||
|
||
bool get blocked => checks.any((GateCheck c) => c.blocks);
|
||
}
|
||
|
||
/// Shows [spec]; resolves to the pilot's choices once the slider reaches the
|
||
/// end, or null when dismissed. A [SlideConfirmSpec.blocked] dialog has no
|
||
/// slider, only the reasons and Close.
|
||
Future<SlideResult?> showSlideToConfirm(BuildContext context, SlideConfirmSpec spec) {
|
||
return showDialog<SlideResult>(
|
||
context: context,
|
||
barrierColor: const Color(0x99050A14),
|
||
builder: (BuildContext context) => SlideConfirmDialog(spec: spec),
|
||
);
|
||
}
|
||
|
||
class SlideConfirmDialog extends StatefulWidget {
|
||
const SlideConfirmDialog({super.key, required this.spec});
|
||
|
||
final SlideConfirmSpec spec;
|
||
|
||
@override
|
||
State<SlideConfirmDialog> createState() => _SlideConfirmDialogState();
|
||
}
|
||
|
||
class _SlideConfirmDialogState extends State<SlideConfirmDialog> {
|
||
bool _checked = false;
|
||
late int? _number = widget.spec.number?.initial;
|
||
|
||
SlideConfirmSpec get _s => widget.spec;
|
||
|
||
void _step(int delta) {
|
||
final SlideNumber n = _s.number!;
|
||
setState(() => _number = ((_number ?? n.initial) + delta).clamp(n.min, n.max));
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final List<GateCheck> blockers = <GateCheck>[for (final GateCheck c in _s.checks) if (c.blocks) c];
|
||
final List<GateCheck> warnings = <GateCheck>[for (final GateCheck c in _s.checks) if (!c.blocks) c];
|
||
return Dialog(
|
||
backgroundColor: const Color(0xF20E1A33),
|
||
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14), side: const BorderSide(color: Glass.hairline)),
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 420),
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||
Row(children: <Widget>[
|
||
Container(
|
||
width: 34,
|
||
height: 34,
|
||
decoration: BoxDecoration(color: blockers.isEmpty ? _s.color : HudColors.danger, shape: BoxShape.circle),
|
||
alignment: Alignment.center,
|
||
child: PVIcon(_s.icon, size: 18, color: Colors.white),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(_s.title,
|
||
style: const TextStyle(fontFamily: PV.fontSans, fontSize: 17, fontWeight: FontWeight.w700, color: Glass.ink)),
|
||
),
|
||
GestureDetector(
|
||
onTap: () => Navigator.of(context).pop(),
|
||
child: const Padding(padding: EdgeInsets.all(4), child: PVIcon('close', size: 18, color: HudText.dim)),
|
||
),
|
||
]),
|
||
const SizedBox(height: 10),
|
||
Text(_s.description, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: Color(0xFFB5C2DA), height: 1.35)),
|
||
for (final GateCheck c in <GateCheck>[...blockers, ...warnings]) _CheckLine(check: c),
|
||
if (blockers.isEmpty && _s.checkbox != null) ...<Widget>[
|
||
const SizedBox(height: 8),
|
||
InkWell(
|
||
onTap: () => setState(() => _checked = !_checked),
|
||
child: Row(children: <Widget>[
|
||
SizedBox(
|
||
width: 28,
|
||
height: 28,
|
||
child: Checkbox(value: _checked, onChanged: (bool? v) => setState(() => _checked = v ?? false)),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Expanded(
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||
Text(_s.checkbox!, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13, fontWeight: FontWeight.w600, color: Glass.ink)),
|
||
if (_s.checkboxHint != null)
|
||
Text(_s.checkboxHint!, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 11, color: HudText.dim)),
|
||
]),
|
||
),
|
||
]),
|
||
),
|
||
],
|
||
if (blockers.isEmpty && _s.number != null) ...<Widget>[
|
||
const SizedBox(height: 10),
|
||
Row(children: <Widget>[
|
||
Expanded(
|
||
child: Text(_s.number!.label, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: Glass.ink)),
|
||
),
|
||
_StepButton(icon: '−', onTap: () => _step(-_s.number!.step)),
|
||
SizedBox(
|
||
width: 72,
|
||
child: Text('$_number ${_s.number!.unit}', textAlign: TextAlign.center, style: HudText.value),
|
||
),
|
||
_StepButton(icon: '+', onTap: () => _step(_s.number!.step)),
|
||
]),
|
||
],
|
||
const SizedBox(height: 14),
|
||
if (blockers.isEmpty)
|
||
SlideToConfirmTrack(
|
||
label: _s.sliderLabel,
|
||
color: _s.color,
|
||
onConfirmed: () => Navigator.of(context).pop(SlideResult(checked: _checked, number: _number)),
|
||
)
|
||
else
|
||
Align(
|
||
alignment: Alignment.centerRight,
|
||
child: TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')),
|
||
),
|
||
]),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CheckLine extends StatelessWidget {
|
||
const _CheckLine({required this.check});
|
||
|
||
final GateCheck check;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final Color c = check.blocks ? HudColors.danger : HudColors.caution;
|
||
return Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||
Padding(padding: const EdgeInsets.only(top: 1), child: PVIcon('alert', size: 14, color: c)),
|
||
const SizedBox(width: 6),
|
||
Expanded(child: Text(check.message, style: TextStyle(fontFamily: PV.fontSans, fontSize: 12, color: c, height: 1.3))),
|
||
]),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _StepButton extends StatelessWidget {
|
||
const _StepButton({required this.icon, required this.onTap});
|
||
|
||
final String icon;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: Container(
|
||
width: 34,
|
||
height: 30,
|
||
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(8), border: Border.all(color: Glass.hairline)),
|
||
alignment: Alignment.center,
|
||
child: Text(icon, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 18, color: Glass.ink, height: 1)),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The slider itself: drag the thumb to the far end to confirm. Let go early
|
||
/// and it springs back, so a brush of the thumb never sends a command.
|
||
class SlideToConfirmTrack extends StatefulWidget {
|
||
const SlideToConfirmTrack({super.key, required this.label, required this.onConfirmed, this.color = Glass.accent});
|
||
|
||
final String label;
|
||
final VoidCallback onConfirmed;
|
||
final Color color;
|
||
|
||
static const Key thumbKey = ValueKey<String>('slide-thumb');
|
||
|
||
/// How far along the track counts as confirmed.
|
||
static const double threshold = 0.92;
|
||
|
||
@override
|
||
State<SlideToConfirmTrack> createState() => _SlideToConfirmTrackState();
|
||
}
|
||
|
||
class _SlideToConfirmTrackState extends State<SlideToConfirmTrack> with SingleTickerProviderStateMixin {
|
||
static const double _height = 48;
|
||
static const double _thumb = 42;
|
||
|
||
late final AnimationController _c = AnimationController(vsync: this, duration: const Duration(milliseconds: 220));
|
||
bool _done = false;
|
||
|
||
@override
|
||
void dispose() {
|
||
_c.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _drag(DragUpdateDetails d, double travel) {
|
||
if (_done || travel <= 0) return;
|
||
_c.value = (_c.value + d.delta.dx / travel).clamp(0.0, 1.0);
|
||
}
|
||
|
||
void _release() {
|
||
if (_done) return;
|
||
if (_c.value >= SlideToConfirmTrack.threshold) {
|
||
_done = true;
|
||
_c.value = 1;
|
||
HapticFeedback.mediumImpact();
|
||
widget.onConfirmed();
|
||
} else {
|
||
_c.animateBack(0, curve: Curves.easeOut);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return LayoutBuilder(builder: (BuildContext context, BoxConstraints box) {
|
||
final double travel = box.maxWidth - _thumb - 6;
|
||
return Container(
|
||
height: _height,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0x33081020),
|
||
borderRadius: BorderRadius.circular(_height / 2),
|
||
border: Border.all(color: widget.color.withValues(alpha: 0.6)),
|
||
),
|
||
child: AnimatedBuilder(
|
||
animation: _c,
|
||
builder: (BuildContext context, _) => Stack(children: <Widget>[
|
||
Positioned(
|
||
left: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
width: 3 + _thumb + _c.value * travel,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: widget.color.withValues(alpha: 0.35),
|
||
borderRadius: BorderRadius.circular(_height / 2),
|
||
),
|
||
),
|
||
),
|
||
Center(
|
||
child: Opacity(
|
||
opacity: (1 - _c.value * 1.6).clamp(0.0, 1.0),
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(left: _thumb),
|
||
child: Text(widget.label.toUpperCase(), style: HudText.label.copyWith(fontSize: 11, color: Glass.ink)),
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
left: 3 + _c.value * travel,
|
||
top: (_height - 2 - _thumb) / 2,
|
||
child: GestureDetector(
|
||
key: SlideToConfirmTrack.thumbKey,
|
||
onHorizontalDragUpdate: (DragUpdateDetails d) => _drag(d, travel),
|
||
onHorizontalDragEnd: (_) => _release(),
|
||
onHorizontalDragCancel: _release,
|
||
child: Container(
|
||
width: _thumb,
|
||
height: _thumb,
|
||
decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle),
|
||
alignment: Alignment.center,
|
||
child: const PVIcon('chevronsRight', size: 20, color: Colors.white),
|
||
),
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
});
|
||
}
|
||
}
|