Files
PilotVault/Fly App/lib/ui/fly/modes/waypoints/waypoint_panels.dart
T
tajniak81andClaude Opus 5.5 636da1ac2c Give the Fly App every remaining GO 4 flight mode and the missing settings
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>
2026-10-20 23:06:16 +02:00

392 lines
16 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 '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)),
]),
),
);
}