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

474 lines
20 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 '../../../../fly/hud_geometry.dart';
import '../../../../fly/modes/mission_library.dart';
import '../../../../fly/modes/terrain.dart';
import '../../../../fly/modes/waypoint_editor.dart';
import '../../../../fly/modes/waypoint_mission.dart';
import '../../../../theme.dart';
import '../../../pv_icons.dart';
import '../../hud_kit.dart';
import '../../panels/camera_menu/camera_rows.dart';
/// Opens a right-side glass sheet over the map, like the camera and gimbal menus.
Future<void> _showSheet(BuildContext context, {required String title, required String icon, required Widget body}) {
return showGeneralDialog<void>(
context: context,
barrierDismissible: true,
barrierLabel: title,
barrierColor: const Color(0x55050A14),
transitionDuration: const Duration(milliseconds: 200),
pageBuilder: (BuildContext context, _, __) => Align(
alignment: Alignment.centerRight,
child: Material(
color: const Color(0xF20B1426),
child: SizedBox(
width: 380,
height: MediaQuery.sizeOf(context).height,
child: SafeArea(
left: false,
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 4, 6),
child: Row(children: <Widget>[
PVIcon(icon, size: 16, color: Glass.ink),
const SizedBox(width: 8),
Expanded(child: Text(title, style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))),
IconButton(
key: const ValueKey<String>('sheet-close'),
onPressed: () => Navigator.of(context).pop(),
icon: const PVIcon('close', size: 18, color: HudText.dim),
),
]),
),
const Divider(height: 1, color: Glass.hairline),
Expanded(child: body),
]),
),
),
),
),
transitionBuilder: (BuildContext context, Animation<double> a, _, Widget child) => SlideTransition(
position: Tween<Offset>(begin: const Offset(1, 0), end: Offset.zero).animate(CurvedAnimation(parent: a, curve: Curves.easeOut)),
child: child,
),
);
}
// ── Route settings ─────────────────────────────────────────────────────────
/// GO 4's Route Settings: route type, cruise speed, heading, finish and
/// signal-lost actions, how to reach waypoint 1, repeats — plus the route's
/// issues and Clear.
Future<void> showRouteSettings(BuildContext context, {required WaypointEditor editor, required MissionLimits limits}) =>
_showSheet(context, title: 'Route settings', icon: 'route', body: RouteSettingsList(editor: editor, limits: limits));
class RouteSettingsList extends StatelessWidget {
const RouteSettingsList({super.key, required this.editor, required this.limits});
final WaypointEditor editor;
final MissionLimits limits;
void _set(WaypointMissionSpec Function(WaypointMissionSpec m) f) => editor.updateRoute(f);
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: editor,
builder: (BuildContext context, _) {
final WaypointMissionSpec m = editor.mission;
final List<MissionIssue> issues = m.validate(limits);
return ListView(padding: const EdgeInsets.only(bottom: 16), children: <Widget>[
ValueTile(
label: 'Name',
value: m.name,
onTap: () async {
final String? name = await showRenameDialog(context, m.name);
if (name != null) _set((WaypointMissionSpec m) => m.copyWith(name: name));
},
),
ChoiceTile(
label: 'Route type',
hint: 'Curved routes fly through waypoints without stopping.',
options: <(String, String)>[for (final RoutePathMode v in RoutePathMode.values) (v.name, v.label)],
selected: m.pathMode.name,
onSelect: (String v) => _set((WaypointMissionSpec m) => m.copyWith(pathMode: RoutePathMode.values.byName(v))),
),
StepperTile(
id: 'cruise',
label: 'Cruise speed',
shown: '${m.cruiseSpeed.toStringAsFixed(1)} m/s',
canDown: m.cruiseSpeed > WaypointMissionSpec.minSpeed,
canUp: m.cruiseSpeed < WaypointMissionSpec.maxSpeed,
onStep: (int d) => _set((WaypointMissionSpec m) => m.copyWith(cruiseSpeed: WaypointEditor.speedRange.clamp(m.cruiseSpeed + d * 0.5))),
),
ChoiceTile(
label: 'Heading',
hint: m.headingMode == RouteHeadingMode.perPoint ? 'Set on each waypoint\'s Heading tab.' : null,
options: <(String, String)>[
for (final RouteHeadingMode v in RouteHeadingMode.values)
if (v != RouteHeadingMode.poi || m.pois.isNotEmpty) (v.name, v.label),
],
selected: m.headingMode.name,
onSelect: (String v) => _set((WaypointMissionSpec m) => m.copyWith(headingMode: RouteHeadingMode.values.byName(v))),
),
ChoiceTile(
label: 'When finished',
options: <(String, String)>[for (final RouteFinishAction v in RouteFinishAction.values) (v.name, v.label)],
selected: m.finishAction.name,
onSelect: (String v) => _set((WaypointMissionSpec m) => m.copyWith(finishAction: RouteFinishAction.values.byName(v))),
),
ChoiceTile(
label: 'RC signal lost',
hint: m.exitOnSignalLost ? 'The mission ends and the aircraft does its signal-lost action (Settings → Safety).' : 'The aircraft finishes the route on its own.',
options: const <(String, String)>[('exit', 'End mission'), ('continue', 'Finish the route')],
selected: m.exitOnSignalLost ? 'exit' : 'continue',
onSelect: (String v) => _set((WaypointMissionSpec m) => m.copyWith(exitOnSignalLost: v == 'exit')),
),
ChoiceTile(
label: 'To waypoint 1',
options: <(String, String)>[for (final RouteGotoFirst v in RouteGotoFirst.values) (v.name, v.label)],
selected: m.gotoFirst.name,
onSelect: (String v) => _set((WaypointMissionSpec m) => m.copyWith(gotoFirst: RouteGotoFirst.values.byName(v))),
),
StepperTile(
id: 'repeat',
label: 'Fly the route',
shown: m.repeatTimes == 1 ? 'once' : '${m.repeatTimes}×',
canDown: m.repeatTimes > 1,
canUp: m.repeatTimes < 10,
onStep: (int d) => _set((WaypointMissionSpec m) => m.copyWith(repeatTimes: (m.repeatTimes + d).clamp(1, 10))),
),
ValueTile(label: 'Length', value: '${m.length.round()} m · ≈${formatDuration(m.estimatedSeconds)}'),
TerrainCheckRow(editor: editor, home: limits.home),
if (issues.isNotEmpty) ...<Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
child: Text('CHECKS', style: HudText.label),
),
for (final MissionIssue i in issues) IssueRow(issue: i, onTap: i.point == null ? null : () => editor.select(i.point)),
],
const SizedBox(height: 8),
ButtonTile(
id: 'clear-route',
label: 'Clear the route',
button: 'Clear',
danger: true,
onTap: m.points.isEmpty && m.pois.isEmpty
? null
: () async {
if (await confirmHud(context, title: 'Clear the route?', body: 'Every waypoint and POI is removed. Undo brings them back.', action: 'Clear')) {
editor.clear();
}
},
),
]);
},
);
}
}
/// "Check terrain": samples the ground along the route online and lists where
/// it comes within 15 m. Runs only on tap — it sends the route's coordinates
/// to the elevation service — and from the home point, or waypoint 1 when the
/// aircraft has none yet (then altitudes are taken as above waypoint 1's ground).
class TerrainCheckRow extends StatefulWidget {
const TerrainCheckRow({super.key, required this.editor, this.home, this.source = const OpenMeteoElevation()});
final WaypointEditor editor;
final LatLng? home;
final ElevationSource source;
@override
State<TerrainCheckRow> createState() => _TerrainCheckRowState();
}
class _TerrainCheckRowState extends State<TerrainCheckRow> {
bool _running = false;
List<TerrainIssue>? _found;
String? _error;
/// The route the result belongs to; editing it makes the result stale.
WaypointMissionSpec? _checked;
Future<void> _run() async {
final WaypointMissionSpec m = widget.editor.mission;
setState(() {
_running = true;
_error = null;
});
try {
final List<TerrainIssue> found = await TerrainCheck(widget.source).run(m, widget.home ?? m.points.first.at);
if (mounted) {
setState(() {
_found = found;
_checked = m;
});
}
} catch (_) {
if (mounted) setState(() => _error = 'Could not reach the elevation service');
} finally {
if (mounted) setState(() => _running = false);
}
}
@override
Widget build(BuildContext context) {
final WaypointMissionSpec m = widget.editor.mission;
final bool stale = _checked != null && !identical(_checked, m);
final List<TerrainIssue>? f = stale ? null : _found;
final String hint = _running
? 'Checking…'
: _error ??
(f == null
? 'Sends the route to open-meteo.com for ground heights${widget.home == null ? ' (no home point yet: measured from waypoint 1)' : ''}'
: f.isEmpty
? 'At least 15 m over the ground all the way'
: '${f.length} leg${f.length == 1 ? '' : 's'} too close to the ground');
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
ButtonTile(
id: 'terrain-check',
label: 'Check terrain',
hint: hint,
button: 'Check',
onTap: _running || m.points.length < 2 ? null : _run,
),
for (final TerrainIssue t in f ?? const <TerrainIssue>[]) IssueRow(issue: t.toIssue(), onTap: () => widget.editor.select(t.leg + 1)),
]);
}
}
class IssueRow extends StatelessWidget {
const IssueRow({super.key, required this.issue, this.onTap});
final MissionIssue issue;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 5),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
PVIcon('alert', size: 13, color: issue.blocks ? HudColors.danger : HudColors.caution),
const SizedBox(width: 8),
Expanded(child: Text(issue.message, style: HudText.sans.copyWith(fontSize: 11, fontWeight: FontWeight.w400, height: 1.3))),
]),
),
);
}
// ── Mission library ────────────────────────────────────────────────────────
/// The saved routes: save the current one, start a new one, load or delete.
/// Opening it also syncs, so routes saved on another device show up.
Future<void> showMissionLibrary(BuildContext context, {required WaypointEditor editor, required MissionLibrary library}) {
library.sync();
return _showSheet(context, title: 'Mission library', icon: 'album', body: MissionLibraryList(editor: editor, library: library));
}
class MissionLibraryList extends StatelessWidget {
const MissionLibraryList({super.key, required this.editor, required this.library});
final WaypointEditor editor;
final MissionLibrary library;
Future<void> _save(BuildContext context) async {
WaypointMissionSpec m = editor.mission;
if (m.id.isEmpty) {
final String? name = await showRenameDialog(context, m.name, title: 'Save route as');
if (name == null) return;
m = m.copyWith(name: name);
}
editor.markSaved(await library.save(m));
}
static String _heading(MissionSyncStatus s) => switch (s) {
MissionSyncStatus.local => 'SAVED ON THIS DEVICE',
MissionSyncStatus.syncing => 'SAVED ROUTES · SYNCING…',
MissionSyncStatus.synced => 'SAVED ROUTES · SYNCED',
MissionSyncStatus.failed => 'SAVED ROUTES · OFFLINE, SYNCS LATER',
};
Future<bool> _discardOk(BuildContext context) async {
if (!editor.dirty || editor.mission.points.isEmpty) return true;
return confirmHud(context, title: 'Discard changes?', body: 'The route on the map has unsaved changes.', action: 'Discard');
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: Listenable.merge(<Listenable>[editor, library]),
builder: (BuildContext context, _) {
final WaypointMissionSpec current = editor.mission;
return ListView(padding: const EdgeInsets.only(bottom: 16), children: <Widget>[
ButtonTile(
id: 'save-route',
label: current.id.isEmpty ? 'This route is not saved' : (editor.dirty ? 'Unsaved changes' : 'Saved'),
button: 'Save',
onTap: current.points.isEmpty || (!editor.dirty && current.id.isNotEmpty) ? null : () => _save(context),
),
ButtonTile(
id: 'new-route',
label: 'Start a new route',
button: 'New',
onTap: current.points.isEmpty && current.id.isEmpty
? null
: () async {
if (await _discardOk(context)) editor.load(const WaypointMissionSpec());
},
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
child: Text(_heading(library.syncStatus), style: HudText.label),
),
if (library.missions.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('No saved routes yet.', style: HudText.sans.copyWith(color: HudText.dim, fontWeight: FontWeight.w400)),
),
for (final WaypointMissionSpec m in library.missions)
_MissionRow(
mission: m,
current: m.id == current.id,
onLoad: () async {
if (!await _discardOk(context)) return;
editor.load(m);
if (context.mounted) Navigator.of(context).pop();
},
onDelete: () async {
if (await confirmHud(context, title: 'Delete "${m.name}"?', body: library.syncStatus == MissionSyncStatus.local
? 'The saved route is removed from this device.'
: 'The saved route is removed from this device and your account.', action: 'Delete')) {
await library.delete(m.id);
}
},
),
]);
},
);
}
}
class _MissionRow extends StatelessWidget {
const _MissionRow({required this.mission, required this.current, required this.onLoad, required this.onDelete});
final WaypointMissionSpec mission;
final bool current;
final VoidCallback onLoad;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final DateTime? at = mission.updatedAt?.toLocal();
final String when = at == null
? ''
: '${at.year}-${at.month.toString().padLeft(2, '0')}-${at.day.toString().padLeft(2, '0')} '
'${at.hour.toString().padLeft(2, '0')}:${at.minute.toString().padLeft(2, '0')}';
return GestureDetector(
key: ValueKey<String>('mission-${mission.id}'),
behavior: HitTestBehavior.opaque,
onTap: onLoad,
child: Container(
padding: const EdgeInsets.fromLTRB(16, 8, 6, 8),
color: current ? const Color(0x1A3D7BF0) : null,
child: Row(children: <Widget>[
const PVIcon('route', size: 15, color: Glass.ink),
const SizedBox(width: 10),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
Text(mission.name, style: HudText.sans),
const SizedBox(height: 2),
Text('${mission.points.length} WP · ${mission.length.round()} m · $when', style: HudText.label),
]),
),
IconButton(
key: ValueKey<String>('mission-delete-${mission.id}'),
onPressed: onDelete,
icon: const PVIcon('close', size: 15, color: HudText.dim),
),
]),
),
);
}
}
// ── Small dialogs ──────────────────────────────────────────────────────────
/// Asks for a route name. The dialog's own State owns the text controller:
/// disposing it right after `showDialog` returns would crash the exit animation.
Future<String?> showRenameDialog(BuildContext context, String initial, {String title = 'Route name'}) =>
showDialog<String>(context: context, builder: (BuildContext context) => _RenameDialog(initial: initial, title: title));
class _RenameDialog extends StatefulWidget {
const _RenameDialog({required this.initial, required this.title});
final String initial;
final String title;
@override
State<_RenameDialog> createState() => _RenameDialogState();
}
class _RenameDialogState extends State<_RenameDialog> {
late final TextEditingController _c = TextEditingController(text: widget.initial);
@override
void dispose() {
_c.dispose();
super.dispose();
}
void _done() {
final String v = _c.text.trim();
Navigator.of(context).pop(v.isEmpty ? null : v);
}
@override
Widget build(BuildContext context) => AlertDialog(
backgroundColor: const Color(0xF20B1426),
title: Text(widget.title, style: HudText.sans.copyWith(fontSize: 14)),
content: TextField(
key: const ValueKey<String>('rename-field'),
controller: _c,
autofocus: true,
maxLength: 40,
style: HudText.sans.copyWith(fontWeight: FontWeight.w400),
onSubmitted: (_) => _done(),
),
actions: <Widget>[
TextButton(onPressed: () => Navigator.of(context).pop(), child: Text('Cancel', style: HudText.sans.copyWith(color: HudText.dim))),
FilledButton(
key: const ValueKey<String>('rename-ok'),
style: FilledButton.styleFrom(backgroundColor: PV.accent),
onPressed: _done,
child: Text('OK', style: HudText.sans.copyWith(color: Colors.white)),
),
],
);
}
/// A yes / no question in the HUD's style; true on [action].
Future<bool> confirmHud(BuildContext context, {required String title, required String body, required String action}) async {
final bool? ok = await showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(
backgroundColor: const Color(0xF20B1426),
title: Text(title, style: HudText.sans.copyWith(fontSize: 14)),
content: Text(body, style: HudText.sans.copyWith(fontWeight: FontWeight.w400, height: 1.3)),
actions: <Widget>[
TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text('Cancel', style: HudText.sans.copyWith(color: HudText.dim))),
FilledButton(
key: const ValueKey<String>('confirm-ok'),
style: FilledButton.styleFrom(backgroundColor: HudColors.danger),
onPressed: () => Navigator.of(context).pop(true),
child: Text(action, style: HudText.sans.copyWith(color: Colors.white)),
),
],
),
);
return ok == true;
}