Files
PilotVault/Fly App/lib/ui/fly/modes/waypoints/point_editor.dart
T
tajniak81andClaude Opus 5.5 43a66df8df Give the Fly App GO 4-style intelligent modes and a Waypoints editor
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>
2026-10-20 16:22:53 +02:00

468 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)),
),
),
);
}