Files
PilotVault/Fly App/lib/ui/fly/modes/waypoints/waypoint_panels.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

319 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import '../../../../flight_model.dart';
import '../../../../fly/hud_geometry.dart';
import '../../../../fly/modes/waypoint_editor.dart';
import '../../../../fly/modes/waypoint_mission.dart';
import '../../../../fly/safety_gate.dart';
import '../../../../fly/state/mission_state.dart';
import '../../../../theme.dart';
import '../../../pv_icons.dart';
import '../../flight_command_flow.dart';
import '../../hud_kit.dart';
/// The route summary across the top of the map while editing (GO 4's top
/// board): name, waypoints, length, time, cruise speed, and the issue count.
class WaypointBoard extends StatelessWidget {
const WaypointBoard({super.key, required this.editor, required this.limits, required this.onTap});
final WaypointEditor editor;
final MissionLimits limits;
/// Opens the route settings (where the issues are listed).
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: editor,
builder: (BuildContext context, _) {
final WaypointMissionSpec m = editor.mission;
final List<MissionIssue> issues = m.points.isEmpty ? const <MissionIssue>[] : m.validate(limits);
final int errors = issues.where((MissionIssue i) => i.blocks).length;
final int warnings = issues.length - errors;
final String hint = switch ((m.points.length, editor.tool)) {
(_, MapTool.poi) => 'Tap the map to add a point of interest',
(0, _) => 'Tap the map to add waypoints',
(1, _) => 'Tap the map to add the next waypoint',
_ => '',
};
return GlassPill(
key: const ValueKey<String>('wp-board'),
height: 30,
color: Glass.pillStrong,
onTap: onTap,
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
const PVIcon('route', size: 14, color: Glass.ink),
const SizedBox(width: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 120),
child: Text(m.name, overflow: TextOverflow.ellipsis, style: HudText.sans.copyWith(fontSize: 11.5)),
),
if (editor.dirty) Text(' •', style: HudText.sans.copyWith(color: HudColors.caution)),
_sep(),
_stat('${m.points.length}', 'WP'),
_sep(),
_stat(m.length >= 1000 ? (m.length / 1000).toStringAsFixed(2) : m.length.round().toString(), m.length >= 1000 ? 'km' : 'm'),
_sep(),
_stat('≈${formatDuration(m.estimatedSeconds)}', ''),
_sep(),
_stat(m.cruiseSpeed.toStringAsFixed(1), 'm/s'),
if (errors > 0 || warnings > 0) ...<Widget>[
const SizedBox(width: 8),
_badge(errors > 0 ? '$errors' : '$warnings', errors > 0 ? HudColors.danger : HudColors.caution),
],
if (hint.isNotEmpty) ...<Widget>[
_sep(),
Text(hint, style: HudText.label.copyWith(color: Glass.ink)),
],
]),
);
},
);
}
static Widget _sep() => Container(width: 1, height: 14, margin: const EdgeInsets.symmetric(horizontal: 8), color: Glass.hairline);
static Widget _stat(String v, String unit) => Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: <Widget>[
Text(v, style: HudText.mono.copyWith(fontWeight: FontWeight.w700)),
if (unit.isNotEmpty) ...<Widget>[const SizedBox(width: 2), Text(unit, style: HudText.unit)],
]);
static Widget _badge(String n, Color c) => Container(
key: const ValueKey<String>('wp-issues'),
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(6)),
child: Text(n, style: HudText.label.copyWith(color: Colors.white, fontWeight: FontWeight.w700)),
);
}
/// The Waypoints tool column on the right of the map: Start, the map tool
/// (waypoint / POI), route settings, library, undo, exit.
class WaypointTools extends StatelessWidget {
const WaypointTools({
super.key,
required this.editor,
required this.onStart,
required this.onSettings,
required this.onLibrary,
required this.onExit,
});
final WaypointEditor editor;
final VoidCallback onStart;
final VoidCallback onSettings;
final VoidCallback onLibrary;
final VoidCallback onExit;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: editor,
builder: (BuildContext context, _) => Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
_ToolButton(
id: 'start',
icon: 'play2',
label: 'START',
color: editor.mission.points.length >= WaypointMissionSpec.minPoints ? Glass.accent : Glass.pillStrong,
onTap: editor.mission.points.length >= WaypointMissionSpec.minPoints ? onStart : null,
),
_ToolButton(
id: 'tool',
icon: editor.tool == MapTool.poi ? 'circle' : 'pin',
label: editor.tool == MapTool.poi ? 'POI' : 'WAYPT',
active: editor.tool == MapTool.poi,
onTap: () => editor.setTool(editor.tool == MapTool.poi ? MapTool.waypoint : MapTool.poi),
),
_ToolButton(id: 'settings', icon: 'settings', label: 'ROUTE', onTap: onSettings),
_ToolButton(id: 'library', icon: 'album', label: 'LIBRARY', onTap: onLibrary),
_ToolButton(id: 'undo', icon: 'chevronLeft', label: 'UNDO', onTap: editor.canUndo ? editor.undo : null),
_ToolButton(id: 'exit', icon: 'close', label: 'EXIT', onTap: onExit, last: true),
]),
);
}
}
class _ToolButton extends StatelessWidget {
const _ToolButton({
required this.id,
required this.icon,
required this.label,
required this.onTap,
this.color,
this.active = false,
this.last = false,
});
final String id;
final String icon;
final String label;
final VoidCallback? onTap;
final Color? color;
final bool active;
final bool last;
@override
Widget build(BuildContext context) => Padding(
padding: EdgeInsets.only(bottom: last ? 0 : 5),
child: Opacity(
opacity: onTap == null ? 0.4 : 1,
child: GestureDetector(
key: ValueKey<String>('wp-tool-$id'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
width: 44,
height: 40,
decoration: BoxDecoration(
color: color ?? (active ? Glass.accent : Glass.pillStrong),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Glass.hairline),
),
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
PVIcon(icon, size: 14, color: Glass.ink),
const SizedBox(height: 2),
Text(label, style: HudText.label.copyWith(color: Glass.ink, fontSize: 7)),
]),
),
),
),
);
}
/// The mission in flight (and its upload): waypoint n / N, what it is doing,
/// distance left, and Pause / Resume and Stop. Shown whenever the waypoint
/// operator is uploading or flying, whatever the screen shows.
class WaypointRunPanel extends StatelessWidget {
const WaypointRunPanel({
super.key,
required this.model,
required this.editor,
required this.gate,
required this.flow,
required this.onError,
});
final FlightModel model;
final WaypointEditor editor;
final SafetyGate gate;
final FlightCommandFlow flow;
final void Function(String label, Object error) onError;
/// Metres left: aircraft → target waypoint, then the remaining legs. Null
/// when the route on screen is not the one flying.
static double? remaining(WaypointMissionSpec m, WaypointProgress run, LatLng? aircraft) {
final int? t = run.target;
if (t == null || run.total != m.points.length || t >= m.points.length) return null;
double d = 0;
if (aircraft != null) {
final LatLng p = m.points[t].at;
d += HudGeometry.distance(aircraft.latitude, aircraft.longitude, p.latitude, p.longitude);
}
final List<double> legs = m.legs;
for (int i = t; i < legs.length; i++) {
d += legs[i];
}
return d;
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: Listenable.merge(<Listenable>[model.mission, model.aircraft]),
builder: (BuildContext context, _) {
final WaypointProgress run = model.mission.waypoint;
if (!run.uploading && !run.active) return const SizedBox.shrink();
final Widget content;
if (run.uploading) {
final double f = run.uploadFraction ?? 0;
content = Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
const PVIcon('download', size: 13, color: Glass.ink),
const SizedBox(width: 6),
Text('UPLOADING ${(run.uploaded ?? -1) + 1}/${run.total ?? '—'}', style: HudText.label.copyWith(color: Glass.ink)),
const SizedBox(width: 8),
SizedBox(
width: 90,
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(value: f, minHeight: 4, color: Glass.accent, backgroundColor: HudColors.off),
),
),
]);
} else {
final double? lat = model.aircraft.latitude, lon = model.aircraft.longitude;
final double? left = remaining(editor.mission, run, lat == null || lon == null ? null : LatLng(lat, lon));
content = Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
const PVIcon('route', size: 13, color: Glass.ink),
const SizedBox(width: 6),
Text('WAYPOINT ${(run.target ?? 0) + 1}/${run.total ?? '—'}', style: HudText.label.copyWith(color: Glass.ink)),
const SizedBox(width: 8),
Text(run.executeLabel.toUpperCase(),
style: HudText.label.copyWith(color: run.paused ? HudColors.caution : HudColors.ready, fontWeight: FontWeight.w700)),
if (left != null) ...<Widget>[
const SizedBox(width: 8),
Text(left >= 1000 ? '${(left / 1000).toStringAsFixed(2)} km left' : '${left.round()} m left', style: HudText.label),
],
const SizedBox(width: 10),
_RunButton(
id: run.paused ? 'resume' : 'pause',
icon: run.paused ? 'play2' : 'pause',
label: run.paused ? 'RESUME' : 'PAUSE',
onTap: () => _guard(run.paused ? 'Resume' : 'Pause', run.paused ? gate.resumeWaypointMission : gate.pauseWaypointMission),
),
const SizedBox(width: 6),
_RunButton(
id: 'stop',
icon: 'stop',
label: 'STOP',
color: HudColors.danger,
onTap: () => flow.request(context, FlightCommand.stopMode),
),
]);
}
return Container(
key: const ValueKey<String>('wp-run'),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(10), border: Border.all(color: Glass.hairline)),
child: content,
);
},
);
}
Future<void> _guard(String label, Future<void> Function() send) async {
try {
await send();
} catch (e) {
onError(label, e);
}
}
}
class _RunButton extends StatelessWidget {
const _RunButton({required this.id, required this.icon, required this.label, required this.onTap, this.color = Glass.accent});
final String id;
final String icon;
final String label;
final VoidCallback onTap;
final Color color;
@override
Widget build(BuildContext context) => GestureDetector(
key: ValueKey<String>('wp-run-$id'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
height: 26,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(7)),
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
PVIcon(icon, size: 12, color: Colors.white, fill: icon == 'play2'),
const SizedBox(width: 4),
Text(label, style: HudText.label.copyWith(color: Colors.white, fontWeight: FontWeight.w700)),
]),
),
);
}