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 tabs = [ 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: [ Row(children: [ _IconBtn(key: const ValueKey('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('wp-next'), icon: 'chevronRight', onTap: _i + 1 < _m.points.length ? () => editor.selectNext(1) : null, ), const Spacer(), _TextBtn(key: const ValueKey('wp-apply-all'), label: 'APPLY TO ALL', onTap: () => editor.applyToAll(field)), const SizedBox(width: 6), _IconBtn(key: const ValueKey('wp-delete'), icon: 'close', color: HudColors.danger, onTap: () => editor.removePoint(_i)), _IconBtn(key: const ValueKey('wp-deselect'), icon: 'chevronDown', onTap: () => editor.select(null)), ]), const SizedBox(height: 4), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row(children: [ for (final PointField f in tabs) Padding( padding: const EdgeInsets.only(right: 4), child: HudChip(key: ValueKey('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: [ _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: [ _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: [ _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: [ _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: [ _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 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: [ Row(children: [ 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('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('poi-delete'), icon: 'close', color: HudColors.danger, onTap: () => editor.removePoi(poi.id)), _IconBtn(key: const ValueKey('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: [ for (final (String label, bool on, VoidCallback tap) in options) HudChip(key: ValueKey('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-`, `readout--minus`, `readout--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 createState() => _SwipeReadoutState(); } class _SwipeReadoutState extends State { 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('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: [ btn('−', -1), Expanded( child: GestureDetector( key: ValueKey('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: [ Row(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ 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)), ), ), ); }