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>
474 lines
20 KiB
Dart
474 lines
20 KiB
Dart
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;
|
||
}
|