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>
319 lines
12 KiB
Dart
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)),
|
|
]),
|
|
),
|
|
);
|
|
}
|