Files
PilotVault/Fly App/lib/ui/fly/modes/waypoints/waypoint_sheets.dart
T
tajniak81andClaude Opus 5.5 93f4a580e9 Sync the Fly App's waypoint routes through the API Server
Routes saved in the Fly App's mission library now sync to a new per-user
routes collection on the API Server, in the WaypointMissionSpec JSON
format. The library pulls on load and pushes on every save and delete,
merges last-writer-wins by updatedAt, keeps unacknowledged deletes as
tombstones, and keeps working offline. Each signed-in account has its
own copy on the device; routes saved while signed out go to the first
account that signs in.

The API Server gets list / get / upsert / delete route endpoints with a
PocketBase migration, and the Web App a read-only Routes view on the
dashboard (rebuilt dist).

Tested against a fake PocketBase and a fake server only; not yet tried
with a real sign-in on the tablet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:26:45 +02:00

401 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.
/// 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;
}