Intelligent modes: Point of Interest (orbit on the map, live radius and speed), Follow Me (follows the phone), QuickShots on a boxed subject, Course Lock / Home Lock, Tripod and Cinematic, a Free hyperlapse, and Waypoints 2.0 for V2-only aircraft. ActiveTrack gains Spotlight Pro and Circle. Every mode has a first-use tutorial and stops from the rail. Waypoints can resume an interrupted mission, change cruise speed in flight and check terrain clearance (Open-Meteo, on tap). Settings: units, C1/C2 actions, dual-operator master/slave, HDMI/SDI output, a link spectrum chart, Wi-Fi name and password, aircraft name, video cache, battery warning history and pairing, non-smart cell voltages. Calibration: compass interference meter, per-IMU calibration, lens infinity focus and a first-manual-focus prompt. Also an RTK chip, gimbal movement presets and motor tuning, Precision Takeoff gated on the flight controller, and the precise GEO database's server version. Built and unit/widget-tested only; nothing has flown yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
392 lines
16 KiB
Dart
392 lines
16 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, this.resumeFrom, this.onResume});
|
||
|
||
final WaypointEditor editor;
|
||
final MissionLimits limits;
|
||
|
||
/// Opens the route settings (where the issues are listed).
|
||
final VoidCallback onTap;
|
||
|
||
/// The waypoint (0-based) this route was interrupted before, when it was:
|
||
/// a RESUME chip flies the rest of it.
|
||
final int? resumeFrom;
|
||
final VoidCallback? onResume;
|
||
|
||
@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)),
|
||
],
|
||
if (resumeFrom != null && onResume != null && resumeFrom! < m.points.length) ...<Widget>[
|
||
const SizedBox(width: 8),
|
||
GestureDetector(
|
||
key: const ValueKey<String>('wp-resume'),
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onResume,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(color: Glass.accent, borderRadius: BorderRadius.circular(6)),
|
||
child: Text('RESUME FROM ${resumeFrom! + 1}', style: HudText.label.copyWith(color: Colors.white, fontWeight: FontWeight.w700)),
|
||
),
|
||
),
|
||
],
|
||
]),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
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: 8),
|
||
_CruiseSpeed(initial: editor.mission.cruiseSpeed, gate: gate, onError: onError),
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The mission's cruise speed, changed in flight (`setAutoFlightSpeed`): − / +
|
||
/// by 1 m/s. Starts at the route's speed; the operator does not report it back.
|
||
class _CruiseSpeed extends StatefulWidget {
|
||
const _CruiseSpeed({required this.initial, required this.gate, required this.onError});
|
||
|
||
final double initial;
|
||
final SafetyGate gate;
|
||
final void Function(String label, Object error) onError;
|
||
|
||
@override
|
||
State<_CruiseSpeed> createState() => _CruiseSpeedState();
|
||
}
|
||
|
||
class _CruiseSpeedState extends State<_CruiseSpeed> {
|
||
late double _speed = widget.initial;
|
||
|
||
Future<void> _step(int d) async {
|
||
final double before = _speed;
|
||
final double next = (_speed + d).clamp(WaypointMissionSpec.minSpeed, WaypointMissionSpec.maxSpeed);
|
||
setState(() => _speed = next);
|
||
try {
|
||
await widget.gate.setWaypointCruiseSpeed(next);
|
||
} catch (e) {
|
||
if (mounted) setState(() => _speed = before);
|
||
widget.onError('Cruise speed', e);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
Widget b(String l, int d, bool on) => Opacity(
|
||
opacity: on ? 1 : 0.35,
|
||
child: GestureDetector(
|
||
key: ValueKey<String>('wp-speed-${d > 0 ? 'plus' : 'minus'}'),
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: on ? () => _step(d) : null,
|
||
child: Container(
|
||
width: 22,
|
||
height: 22,
|
||
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6), border: Border.all(color: Glass.hairline)),
|
||
alignment: Alignment.center,
|
||
child: Text(l, style: HudText.mono.copyWith(fontWeight: FontWeight.w700)),
|
||
),
|
||
),
|
||
);
|
||
return Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
b('−', -1, _speed > WaypointMissionSpec.minSpeed),
|
||
SizedBox(width: 50, child: Text('${_speed.toStringAsFixed(0)} m/s', key: const ValueKey<String>('wp-speed'), textAlign: TextAlign.center, style: HudText.mono)),
|
||
b('+', 1, _speed < WaypointMissionSpec.maxSpeed),
|
||
]);
|
||
}
|
||
}
|
||
|
||
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)),
|
||
]),
|
||
),
|
||
);
|
||
}
|