Files
PilotVault/Fly App/lib/ui/fly/modes/waypoints/waypoint_sheets.dart
T
tajniak81andClaude Opus 5.5 a813648b75 Stop text dialogs collapsing their field above the landscape keyboard
On the tablet in landscape the keyboard leaves ~200 dp, and the save-route
dialog squeezed its text field to a line. The route-name, stream-address and
settings number/text dialogs now scroll instead and use smaller margins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:49:26 +02:00

478 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(
// In landscape the keyboard leaves ~200 dp: scroll rather than
// squeeze the field to a line, and spend little on margins.
scrollable: true,
insetPadding: const EdgeInsets.symmetric(horizontal: 40, vertical: 8),
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;
}