P7a of the GO 4-parity plan. The rail's GRID button becomes MODES, which opens a picker of the product's intelligent flight modes with first-use terms; Waypoints is the first one built. Waypoints opens on the big map: tap to add waypoints or POIs, long-press to drag, "+" to insert on a leg, and a point editor for altitude, speed, heading (route / custom / POI), gimbal (set or aim at POI), camera action and hover, and turn radius, with a swipe-to-adjust readout. Route settings, a local mission library and undo sit in a tool column. Start runs the SafetyGate's pre-start check in the slide-to-confirm dialog, then uploads and starts; a run panel shows upload and flight progress with Pause, Resume and Stop. MissionBridge now listens to the waypoint operator and builds the full V1 mission (per-waypoint headings, turn direction, gimbal pitch, actions). Not yet run on a simulator or aircraft. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
468 lines
18 KiB
Dart
468 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import '../../../../fly/modes/waypoint_editor.dart';
|
||
import '../../../../fly/modes/waypoint_mission.dart';
|
||
import '../../../../theme.dart';
|
||
import '../../../pv_icons.dart';
|
||
import '../../hud_kit.dart';
|
||
|
||
/// The bottom point editor (plan P7, GO 4's `gs_waypoint_2_point_editor_view`):
|
||
/// tabs for Altitude · Speed · Heading · Gimbal · Action · POI · Turn radius,
|
||
/// numbers set by swiping over a big readout or with −/+, plus prev / next,
|
||
/// "apply to all" and delete. Shows the POI editor instead when a POI is selected.
|
||
class PointEditorPanel extends StatelessWidget {
|
||
const PointEditorPanel({super.key, required this.editor, this.gimbalMin = -90, this.gimbalMax = 30});
|
||
|
||
final WaypointEditor editor;
|
||
final double gimbalMin;
|
||
final double gimbalMax;
|
||
|
||
static const double height = 150;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListenableBuilder(
|
||
listenable: editor,
|
||
builder: (BuildContext context, _) {
|
||
final Widget? body = editor.selectedPoint != null
|
||
? _PointBody(editor: editor, gimbalMin: gimbalMin, gimbalMax: gimbalMax)
|
||
: editor.selectedPoiSpec != null
|
||
? _PoiBody(editor: editor)
|
||
: null;
|
||
if (body == null) return const SizedBox.shrink();
|
||
return Container(
|
||
height: height,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xE60B1426),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: Glass.hairline),
|
||
),
|
||
padding: const EdgeInsets.fromLTRB(10, 6, 10, 8),
|
||
child: body,
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PointBody extends StatelessWidget {
|
||
const _PointBody({required this.editor, required this.gimbalMin, required this.gimbalMax});
|
||
|
||
final WaypointEditor editor;
|
||
final double gimbalMin;
|
||
final double gimbalMax;
|
||
|
||
int get _i => editor.selected!;
|
||
WaypointSpec get _p => editor.selectedPoint!;
|
||
WaypointMissionSpec get _m => editor.mission;
|
||
|
||
void _update(WaypointSpec Function(WaypointSpec p) f, {bool record = true}) => editor.updatePoint(_i, f, record: record);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final bool curved = _m.pathMode == RoutePathMode.curved;
|
||
final List<PointField> tabs = <PointField>[
|
||
for (final PointField f in PointField.values)
|
||
if (f != PointField.radius || curved) f,
|
||
];
|
||
final PointField field = tabs.contains(editor.field) ? editor.field : PointField.altitude;
|
||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||
Row(children: <Widget>[
|
||
_IconBtn(key: const ValueKey<String>('wp-prev'), icon: 'chevronLeft', onTap: _i > 0 ? () => editor.selectNext(-1) : null),
|
||
SizedBox(
|
||
width: 92,
|
||
child: Text('WAYPOINT ${_i + 1}/${_m.points.length}', textAlign: TextAlign.center, style: HudText.label.copyWith(color: Glass.ink)),
|
||
),
|
||
_IconBtn(
|
||
key: const ValueKey<String>('wp-next'),
|
||
icon: 'chevronRight',
|
||
onTap: _i + 1 < _m.points.length ? () => editor.selectNext(1) : null,
|
||
),
|
||
const Spacer(),
|
||
_TextBtn(key: const ValueKey<String>('wp-apply-all'), label: 'APPLY TO ALL', onTap: () => editor.applyToAll(field)),
|
||
const SizedBox(width: 6),
|
||
_IconBtn(key: const ValueKey<String>('wp-delete'), icon: 'close', color: HudColors.danger, onTap: () => editor.removePoint(_i)),
|
||
_IconBtn(key: const ValueKey<String>('wp-deselect'), icon: 'chevronDown', onTap: () => editor.select(null)),
|
||
]),
|
||
const SizedBox(height: 4),
|
||
SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: Row(children: <Widget>[
|
||
for (final PointField f in tabs)
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: 4),
|
||
child: HudChip(key: ValueKey<String>('tab-${f.name}'), label: f.label, selected: f == field, onTap: () => editor.setField(f)),
|
||
),
|
||
]),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Expanded(child: _fieldBody(field)),
|
||
]);
|
||
}
|
||
|
||
Widget _fieldBody(PointField f) {
|
||
switch (f) {
|
||
case PointField.altitude:
|
||
return SwipeReadout(
|
||
id: 'altitude',
|
||
value: _p.altitude,
|
||
range: WaypointEditor.altitudeRange,
|
||
caption: 'above take-off',
|
||
onStart: editor.beginDrag,
|
||
onChanged: (double v, bool drag) => _update((WaypointSpec p) => p.copyWith(altitude: v), record: !drag),
|
||
);
|
||
case PointField.speed:
|
||
final bool own = _p.speed != null;
|
||
return Row(children: <Widget>[
|
||
_choices(<(String, bool, VoidCallback)>[
|
||
('Cruise ${_m.cruiseSpeed.toStringAsFixed(1)}', !own, () => _update((WaypointSpec p) => p.copyWith(speed: null))),
|
||
('Custom', own, () => _update((WaypointSpec p) => p.copyWith(speed: p.speed ?? _m.cruiseSpeed))),
|
||
]),
|
||
const SizedBox(width: 12),
|
||
if (own)
|
||
Expanded(
|
||
child: SwipeReadout(
|
||
id: 'speed',
|
||
value: _p.speed!,
|
||
range: WaypointEditor.speedRange,
|
||
digits: 1,
|
||
caption: 'to the next waypoint',
|
||
onStart: editor.beginDrag,
|
||
onChanged: (double v, bool drag) => _update((WaypointSpec p) => p.copyWith(speed: v), record: !drag),
|
||
),
|
||
)
|
||
else
|
||
Expanded(child: _note('Flies the route\'s cruise speed.')),
|
||
]);
|
||
case PointField.heading:
|
||
final bool perPoint = _m.headingMode == RouteHeadingMode.perPoint;
|
||
return Row(children: <Widget>[
|
||
_choices(<(String, bool, VoidCallback)>[
|
||
for (final WaypointHeading h in WaypointHeading.values)
|
||
if (h != WaypointHeading.poi || _p.poiId != null)
|
||
(h.label, perPoint && _p.heading == h, () => editor.setHeading(_i, h)),
|
||
]),
|
||
const SizedBox(width: 12),
|
||
if (perPoint && _p.heading == WaypointHeading.custom)
|
||
Expanded(
|
||
child: SwipeReadout(
|
||
id: 'heading',
|
||
value: _p.customHeading.toDouble(),
|
||
range: WaypointEditor.headingRange,
|
||
caption: 'from north',
|
||
onStart: editor.beginDrag,
|
||
onChanged: (double v, bool drag) => _update((WaypointSpec p) => p.copyWith(customHeading: v.round()), record: !drag),
|
||
),
|
||
)
|
||
else
|
||
Expanded(
|
||
child: _note(perPoint
|
||
? (_p.heading == WaypointHeading.poi ? 'Faces its POI.' : 'Faces along the route.')
|
||
: 'Route heading: ${_m.headingMode.label}. Pick one here to set it per waypoint.'),
|
||
),
|
||
]);
|
||
case PointField.gimbal:
|
||
final bool aim = _p.gimbalAimPoi && _p.poiId != null;
|
||
final bool set = !aim && _p.gimbalPitch != null;
|
||
return Row(children: <Widget>[
|
||
_choices(<(String, bool, VoidCallback)>[
|
||
('Keep', !aim && !set, () => _update((WaypointSpec p) => p.copyWith(gimbalPitch: null, gimbalAimPoi: false))),
|
||
('Set', set, () => _update((WaypointSpec p) => p.copyWith(gimbalPitch: p.gimbalPitch ?? -30, gimbalAimPoi: false))),
|
||
if (_p.poiId != null) ('Aim at POI', aim, () => _update((WaypointSpec p) => p.copyWith(gimbalAimPoi: true))),
|
||
]),
|
||
const SizedBox(width: 12),
|
||
if (set)
|
||
Expanded(
|
||
child: SwipeReadout(
|
||
id: 'gimbal',
|
||
value: _p.gimbalPitch!.clamp(gimbalMin, gimbalMax).toDouble(),
|
||
range: WaypointEditor.gimbalRange(min: gimbalMin, max: gimbalMax),
|
||
caption: 'pitch',
|
||
onStart: editor.beginDrag,
|
||
onChanged: (double v, bool drag) => _update((WaypointSpec p) => p.copyWith(gimbalPitch: v), record: !drag),
|
||
),
|
||
)
|
||
else
|
||
Expanded(
|
||
child: _note(aim
|
||
? 'Tilts to the POI: ${_m.resolvedGimbal(min: gimbalMin, max: gimbalMax)?[_i].round() ?? '—'}°.'
|
||
: 'Keeps the pitch from the waypoint before, or yours.'),
|
||
),
|
||
]);
|
||
case PointField.action:
|
||
return Row(children: <Widget>[
|
||
_choices(<(String, bool, VoidCallback)>[
|
||
for (final WaypointCameraAction a in WaypointCameraAction.values)
|
||
(a.label, _p.action == a, () => _update((WaypointSpec p) => p.copyWith(action: a))),
|
||
]),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: SwipeReadout(
|
||
id: 'hover',
|
||
value: _p.hoverSeconds.toDouble(),
|
||
range: WaypointEditor.hoverRange,
|
||
caption: 'hover first',
|
||
onStart: editor.beginDrag,
|
||
onChanged: (double v, bool drag) => _update((WaypointSpec p) => p.copyWith(hoverSeconds: v.round()), record: !drag),
|
||
),
|
||
),
|
||
]);
|
||
case PointField.poi:
|
||
return Row(children: <Widget>[
|
||
_choices(<(String, bool, VoidCallback)>[
|
||
('None', _p.poiId == null, () => editor.linkPoi(_i, null)),
|
||
for (int k = 0; k < _m.pois.length; k++) ('P${k + 1}', _p.poiId == _m.pois[k].id, () => editor.linkPoi(_i, _m.pois[k].id)),
|
||
]),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: _note(_m.pois.isEmpty
|
||
? 'No POI yet: switch the map tool to POI and tap the map.'
|
||
: 'A linked waypoint faces its POI and can aim the gimbal at it.'),
|
||
),
|
||
]);
|
||
case PointField.radius:
|
||
return SwipeReadout(
|
||
id: 'radius',
|
||
value: _p.turnRadius,
|
||
range: WaypointEditor.radiusRange,
|
||
digits: 1,
|
||
caption: 'curve through this waypoint',
|
||
onStart: editor.beginDrag,
|
||
onChanged: (double v, bool drag) => _update((WaypointSpec p) => p.copyWith(turnRadius: v), record: !drag),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
class _PoiBody extends StatelessWidget {
|
||
const _PoiBody({required this.editor});
|
||
|
||
final WaypointEditor editor;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final MissionPoi poi = editor.selectedPoiSpec!;
|
||
final List<MissionPoi> pois = editor.mission.pois;
|
||
final int n = pois.indexOf(poi) + 1;
|
||
final int linked = editor.mission.points.where((WaypointSpec p) => p.poiId == poi.id).length;
|
||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||
Row(children: <Widget>[
|
||
Text('POINT OF INTEREST P$n', style: HudText.label.copyWith(color: Glass.ink)),
|
||
const SizedBox(width: 10),
|
||
Text('$linked waypoint${linked == 1 ? '' : 's'} linked', style: HudText.label),
|
||
const Spacer(),
|
||
_TextBtn(
|
||
key: const ValueKey<String>('poi-link-all'),
|
||
label: 'LINK ALL',
|
||
onTap: editor.mission.points.isEmpty
|
||
? null
|
||
: () {
|
||
for (int i = 0; i < editor.mission.points.length; i++) {
|
||
editor.linkPoi(i, poi.id);
|
||
}
|
||
},
|
||
),
|
||
const SizedBox(width: 4),
|
||
_IconBtn(key: const ValueKey<String>('poi-delete'), icon: 'close', color: HudColors.danger, onTap: () => editor.removePoi(poi.id)),
|
||
_IconBtn(key: const ValueKey<String>('poi-deselect'), icon: 'chevronDown', onTap: () => editor.selectPoi(null)),
|
||
]),
|
||
const SizedBox(height: 6),
|
||
Expanded(
|
||
child: SwipeReadout(
|
||
id: 'poi-height',
|
||
value: poi.height,
|
||
range: WaypointEditor.poiHeightRange,
|
||
caption: 'subject height, for aiming the gimbal',
|
||
onStart: editor.beginDrag,
|
||
onChanged: (double v, bool drag) => editor.updatePoi(poi.id, (MissionPoi p) => p.copyWith(height: v), record: !drag),
|
||
),
|
||
),
|
||
]);
|
||
}
|
||
}
|
||
|
||
Widget _note(String text) => Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Text(text, maxLines: 3, style: HudText.sans.copyWith(fontSize: 10.5, fontWeight: FontWeight.w400, color: HudText.dim)),
|
||
);
|
||
|
||
Widget _choices(List<(String, bool, VoidCallback)> options) => ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 260),
|
||
child: Wrap(spacing: 5, runSpacing: 5, children: <Widget>[
|
||
for (final (String label, bool on, VoidCallback tap) in options)
|
||
HudChip(key: ValueKey<String>('choice-$label'), label: label, selected: on, onTap: tap),
|
||
]),
|
||
);
|
||
|
||
/// A big number set by swiping up / down over it (one step per [pxPerStep])
|
||
/// or with −/+ (GO 4's point editor readout). [onChanged]'s second argument
|
||
/// is true for swipe steps, which [onStart] has already put in the history.
|
||
class SwipeReadout extends StatefulWidget {
|
||
const SwipeReadout({
|
||
super.key,
|
||
required this.id,
|
||
required this.value,
|
||
required this.range,
|
||
required this.onChanged,
|
||
this.onStart,
|
||
this.caption,
|
||
this.digits = 0,
|
||
});
|
||
|
||
/// Keys the parts as `readout-<id>`, `readout-<id>-minus`, `readout-<id>-plus`.
|
||
final String id;
|
||
final double value;
|
||
final FieldRange range;
|
||
final void Function(double value, bool drag) onChanged;
|
||
final VoidCallback? onStart;
|
||
final String? caption;
|
||
final int digits;
|
||
|
||
static const double pxPerStep = 8;
|
||
|
||
@override
|
||
State<SwipeReadout> createState() => _SwipeReadoutState();
|
||
}
|
||
|
||
class _SwipeReadoutState extends State<SwipeReadout> {
|
||
double _acc = 0;
|
||
double? _dragValue;
|
||
|
||
double get _shown => _dragValue ?? widget.value;
|
||
|
||
void _step(int dir) {
|
||
final double next = widget.range.clamp(widget.value + dir * widget.range.step);
|
||
if (next != widget.value) widget.onChanged(next, false);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final FieldRange r = widget.range;
|
||
Widget btn(String label, int dir) {
|
||
final bool on = dir > 0 ? widget.value < r.max : widget.value > r.min;
|
||
return Opacity(
|
||
opacity: on ? 1 : 0.35,
|
||
child: GestureDetector(
|
||
key: ValueKey<String>('readout-${widget.id}-${dir > 0 ? 'plus' : 'minus'}'),
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: on ? () => _step(dir) : null,
|
||
child: Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(9), border: Border.all(color: Glass.hairline)),
|
||
alignment: Alignment.center,
|
||
child: Text(label, style: HudText.value.copyWith(fontSize: 18)),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return Row(children: <Widget>[
|
||
btn('−', -1),
|
||
Expanded(
|
||
child: GestureDetector(
|
||
key: ValueKey<String>('readout-${widget.id}'),
|
||
behavior: HitTestBehavior.opaque,
|
||
onVerticalDragStart: (_) {
|
||
_acc = 0;
|
||
_dragValue = widget.value;
|
||
widget.onStart?.call();
|
||
},
|
||
onVerticalDragUpdate: (DragUpdateDetails d) {
|
||
_acc -= d.delta.dy;
|
||
int steps = 0;
|
||
while (_acc >= SwipeReadout.pxPerStep) {
|
||
_acc -= SwipeReadout.pxPerStep;
|
||
steps++;
|
||
}
|
||
while (_acc <= -SwipeReadout.pxPerStep) {
|
||
_acc += SwipeReadout.pxPerStep;
|
||
steps--;
|
||
}
|
||
if (steps == 0) return;
|
||
final double next = r.clamp(_shown + steps * r.step);
|
||
if (next == _shown) return;
|
||
setState(() => _dragValue = next);
|
||
widget.onChanged(next, true);
|
||
},
|
||
onVerticalDragEnd: (_) => setState(() => _dragValue = null),
|
||
onVerticalDragCancel: () => setState(() => _dragValue = null),
|
||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
|
||
Row(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: <Widget>[
|
||
Text(_shown.toStringAsFixed(widget.digits), style: HudText.value.copyWith(fontSize: 26)),
|
||
const SizedBox(width: 3),
|
||
Text(r.unit, style: HudText.unit.copyWith(fontSize: 11)),
|
||
]),
|
||
if (widget.caption != null) Text(widget.caption!, style: HudText.label.copyWith(fontSize: 8)),
|
||
Text('SWIPE', style: HudText.label.copyWith(fontSize: 7, color: const Color(0x66EAF0FA))),
|
||
]),
|
||
),
|
||
),
|
||
btn('+', 1),
|
||
]);
|
||
}
|
||
}
|
||
|
||
/// A small selectable chip, for tabs and choices on the HUD.
|
||
class HudChip extends StatelessWidget {
|
||
const HudChip({super.key, required this.label, required this.selected, required this.onTap});
|
||
|
||
final String label;
|
||
final bool selected;
|
||
final VoidCallback? onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onTap,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
|
||
decoration: BoxDecoration(
|
||
color: selected ? Glass.accent : Glass.pill,
|
||
borderRadius: BorderRadius.circular(7),
|
||
border: Border.all(color: selected ? Glass.accent : Glass.hairline),
|
||
),
|
||
child: Text(label, style: HudText.sans.copyWith(fontSize: 10.5)),
|
||
),
|
||
);
|
||
}
|
||
|
||
class _IconBtn extends StatelessWidget {
|
||
const _IconBtn({super.key, required this.icon, required this.onTap, this.color = Glass.ink});
|
||
|
||
final String icon;
|
||
final VoidCallback? onTap;
|
||
final Color color;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Opacity(
|
||
opacity: onTap == null ? 0.35 : 1,
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onTap,
|
||
child: SizedBox(width: 28, height: 26, child: Center(child: PVIcon(icon, size: 15, color: color))),
|
||
),
|
||
);
|
||
}
|
||
|
||
class _TextBtn extends StatelessWidget {
|
||
const _TextBtn({super.key, required this.label, required this.onTap});
|
||
|
||
final String label;
|
||
final VoidCallback? onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Opacity(
|
||
opacity: onTap == null ? 0.35 : 1,
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onTap,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4),
|
||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), border: Border.all(color: Glass.accent)),
|
||
child: Text(label, style: HudText.label.copyWith(color: Glass.ink, fontSize: 8.5)),
|
||
),
|
||
),
|
||
);
|
||
}
|