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

389 lines
17 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 '../../../../fly/hud_geometry.dart';
import '../../../../fly/modes/mission_library.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)}'),
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();
}
},
),
]);
},
);
}
}
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.
Future<void> showMissionLibrary(BuildContext context, {required WaypointEditor editor, required MissionLibrary library}) =>
_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));
}
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('SAVED ON THIS DEVICE', 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: 'The saved route is removed from this device.', 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;
}