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

653 lines
25 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import '../../dji_service.dart';
import '../../flight_model.dart';
import '../../fly/flight_trail.dart';
import '../../fly/fly_prefs.dart';
import '../../fly/hud_features.dart';
import '../../fly/modes/mission_library.dart';
import '../../fly/modes/mission_sync.dart' show ApiMissionRemote;
import '../../fly/modes/vision_modes.dart';
import '../../fly/modes/waypoint_editor.dart';
import '../../fly/modes/waypoint_mission.dart' show MissionLimits;
import '../../fly/product_profile.dart';
import '../../fly/safety_gate.dart';
import '../../fly/state/mission_state.dart';
import '../../theme.dart';
import '../album_page.dart';
import '../capture_modes_page.dart';
import '../pv_icons.dart';
import 'bottom/attitude_ball.dart';
import 'bottom/minimap.dart';
import 'bottom/telemetry_strip.dart';
import 'flight_command_flow.dart';
import 'hud_kit.dart';
import 'layers/video_layer.dart';
import 'left/action_rail.dart';
import 'left/gimbal_slider.dart';
import 'modes/mode_picker.dart';
import 'modes/vision/vision_layers.dart';
import 'modes/vision/vision_panel.dart';
import 'modes/waypoints/point_editor.dart';
import 'modes/waypoints/waypoint_layers.dart';
import 'modes/waypoints/waypoint_panels.dart';
import 'modes/waypoints/waypoint_sheets.dart';
import 'modes/waypoints/waypoint_start.dart';
import 'overlays/alert_stack.dart';
import 'overlays/camera_touch_layer.dart';
import 'overlays/gimbal_drag_layer.dart';
import 'overlays/histogram_view.dart';
import 'overlays/safety_banners.dart';
import 'panels/aircraft_status_sheet.dart';
import 'panels/camera_menu/camera_menu_sheet.dart';
import 'panels/camera_quick_exposure.dart';
import 'panels/gimbal_sheet.dart';
import 'panels/settings/settings_sheet.dart';
import 'right/camera_column.dart';
import 'right/zoom_column.dart';
import 'top/camera_info_strip.dart';
import 'top/smart_battery_bar.dart';
import 'top/status_bar.dart';
/// The flight HUD v2 (plan P2), replacing [FlightControlPage].
///
/// A stack of layers in `fpv_litchi`'s order — video → visual overlays → map →
/// camera column → gimbal → top bar → bottom attitude → left rail → minimap —
/// where every readout listens to its own telemetry sub-model, so a 10 Hz tick
/// rebuilds only what moved. The HUD is laid out once in design pixels and
/// scaled to phone or tablet by [HudCanvas]; capability-gated parts use
/// [Requires] against the connected [ProductProfile].
///
/// Tapping the minimap swaps it with the video: the map goes full-bleed (and
/// becomes interactive) while the feed shrinks into the corner, and back.
///
/// Safety (plan P3): every aircraft-moving command on the rail goes through
/// one [SafetyGate] via [FlightCommandFlow]; the error stack and low-battery
/// banners sit under the top bar; the status chip opens the Aircraft Status
/// sheet, which is also offered before each connection's first take-off.
///
/// Camera (plan P4): the camera column, the zoom column on zoom cameras, the
/// quick exposure panel (from the camera info strip), the tabbed camera menu,
/// tap-to-focus / tap-to-meter on the video and the histogram.
///
/// Gimbal (plan P5): the pitch slider with its value bubble, press-and-drag
/// on the video as a speed joystick, and the gimbal settings sheet from the
/// button under the slider.
///
/// Intelligent modes (plan P7): the rail's MODES button opens the picker.
/// Waypoints turns the map big and editable — tap to add, the point editor
/// along the bottom, the tool column on the right — and the run panel at the
/// head of the left column follows the upload and the flight, whatever the
/// screen shows. The route stays on the map after leaving the mode.
class FlyScreen extends StatefulWidget {
const FlyScreen({super.key, required this.model, required this.dji, this.waypoints});
final FlightModel model;
final DjiService dji;
/// The Waypoints editor to use; the screen makes (and disposes) its own when null.
final WaypointEditor? waypoints;
@override
State<FlyScreen> createState() => _FlyScreenState();
}
class _FlyScreenState extends State<FlyScreen> {
late final FlightTrail _trail = FlightTrail(widget.model.aircraft);
final MinimapController _minimap = MinimapController();
late final SafetyGate _gate = SafetyGate(model: widget.model, dji: widget.dji);
late final FlightCommandFlow _flow = FlightCommandFlow(
gate: _gate,
onError: _error,
reviewBeforeTakeOff: (BuildContext context) => _openStatus(context, offerTakeOff: true),
);
bool _wasConnected = false;
bool _mapBig = false;
/// The intelligent mode whose editor is open, or null.
IntelligentMode? _mode;
late final WaypointEditor _waypoints = widget.waypoints ?? WaypointEditor();
final VisionSession _vision = VisionSession();
late final MissionLibrary _library = MissionLibrary(remote: ApiMissionRemote())..load();
MissionLimits _limits = const MissionLimits();
int _seenFinishes = 0;
/// Waypoints is open on the big map and nothing is flying: taps edit.
bool get _editing {
final WaypointProgress run = _m.mission.waypoint;
return _mode == IntelligentMode.waypoint && _mapBig && !run.active && !run.uploading;
}
/// The picture-in-picture box, in HUD canvas pixels from the bottom-left corner.
static const Size _pipSize = Size(150, 90);
FlightModel get _m => widget.model;
DjiService get _dji => widget.dji;
@override
void initState() {
super.initState();
SystemChrome.setPreferredOrientations(<DeviceOrientation>[
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
_wasConnected = _m.connected;
_m.addListener(_onModel);
_seenFinishes = _m.mission.waypointFinishes;
_m.mission.addListener(_onMission);
}
/// Says how a waypoint mission ended, and leaves edit mode when one starts.
void _onMission() {
final MissionState ms = _m.mission;
if (ms.waypointFinishes != _seenFinishes) {
_seenFinishes = ms.waypointFinishes;
final String? r = ms.waypointResult;
if (mounted && r != null) {
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(r.isEmpty ? 'Waypoint mission complete' : 'Waypoint mission ended: $r')));
}
}
// Execution events arrive at ~10 Hz; rebuild the screen only when editing turns on or off.
final bool editing = _editing;
if (mounted && editing != _wasEditing) setState(() => _wasEditing = editing);
}
bool _wasEditing = false;
Future<void> _openModes(BuildContext context) async {
final IntelligentMode? m = await showModePicker(context, profile: _m.profile);
if (m == null || !mounted) return;
switch (m) {
case IntelligentMode.waypoint:
setState(() {
_mode = m;
_mapBig = true;
});
_minimap.setFollow(_waypoints.mission.points.isEmpty);
final MissionLimits limits = await _gate.missionLimits();
if (mounted) setState(() => _limits = limits);
case IntelligentMode.activeTrack:
case IntelligentMode.tapFly:
// Vision modes work on the live video.
_vision.clearPending();
setState(() {
_mode = m;
_mapBig = false;
});
if (m == IntelligentMode.activeTrack && _m.connected) {
try {
_vision.setRetreat(await _dji.getActiveTrackRetreat());
} catch (_) {
_vision.setRetreat(null); // not reported: the chip starts off
}
}
default:
break; // the picker only returns built modes
}
}
void _exitMode() => setState(() {
_mode = null;
_mapBig = false;
_waypoints.select(null);
});
bool get _visionMode => _mode == IntelligentMode.activeTrack || _mode == IntelligentMode.tapFly;
/// Leaving a vision mode while it flies stops it first (GO 4's Exit).
Future<void> _exitVision(BuildContext context) async {
final MissionState ms = _m.mission;
final bool running = _mode == IntelligentMode.tapFly ? ms.tapFly.active : ms.activeTrack.active;
if (running) await _flow.request(context, FlightCommand.stopMode);
_vision.clearPending();
if (mounted) setState(() => _mode = null);
}
void _goTrack(BuildContext context, Rect box, {int? subject}) {
final TrackMode mode = _vision.trackMode;
startVisionMode(
context,
title: 'ActiveTrack · ${mode.label}',
icon: 'crosshair',
description: 'The aircraft follows the boxed subject. Stop, the remote\'s pause button, or the sticks take back control.',
checks: _gate.checkActiveTrack(mode),
start: () async {
// A subject the aircraft sensed itself is started by its index, not a drawn box.
await (subject != null ? _gate.startActiveTrackSubject(subject, mode) : _gate.startActiveTrack(box, mode));
_vision.setPendingBox(null);
},
onError: _error,
);
}
void _goTapFly(BuildContext context, Offset target) {
final TapFlyDirection d = _vision.direction;
startVisionMode(
context,
title: 'TapFly · ${d.label}',
icon: 'chevronsRight',
description: 'The aircraft flies towards the point at ${_vision.speed.toStringAsFixed(0)} m/s until you stop it.',
checks: _gate.checkTapFly(d),
start: () async {
await _gate.startTapFly(target, d, speed: _vision.speed);
_vision.setPendingPoint(null);
},
onError: _error,
);
}
Future<void> _confirmTrack(bool yes) async {
try {
await _gate.confirmActiveTrack(yes: yes);
} catch (e) {
_error('ActiveTrack', e);
}
}
/// A new connection gets its own pre-flight review.
void _onModel() {
if (_m.connected == _wasConnected) return;
_wasConnected = _m.connected;
if (!_m.connected) _flow.resetReview();
}
Future<bool> _openStatus(BuildContext context, {bool offerTakeOff = false}) {
// Looking at the sheet on purpose counts as the pre-flight review.
if (!offerTakeOff) _flow.markReviewed();
return showAircraftStatusSheet(context, model: _m, dji: _dji, onError: _error, offerTakeOff: offerTakeOff);
}
@override
void dispose() {
_m.removeListener(_onModel);
_m.mission.removeListener(_onMission);
if (widget.waypoints == null) _waypoints.dispose();
_vision.dispose();
_library.dispose();
_trail.dispose();
_minimap.dispose();
SystemChrome.setPreferredOrientations(<DeviceOrientation>[DeviceOrientation.portraitUp]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
void _error(String label, Object e) {
if (!mounted) return;
final String reason = e is PlatformException ? (e.message ?? e.code) : e.toString();
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text('$label failed: $reason')));
}
void _push(Widget page) => Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => page));
@override
Widget build(BuildContext context) {
final MediaQueryData mq = MediaQuery.of(context);
final double s = HudScale.scaleFor(mq.size.shortestSide);
final Rect area = HudCanvas.screenRect(mq);
final double canvasHeight = area.height / s;
final Rect pip = HudCanvas.toScreen(mq, Rect.fromLTWH(0, canvasHeight - _pipSize.height, _pipSize.width, _pipSize.height));
final Rect full = Offset.zero & mq.size;
_wasEditing = _editing;
final Widget video = ListenableBuilder(
key: const ValueKey<String>('video'),
listenable: _m,
builder: (BuildContext context, _) => VideoLayer(connected: _m.connected),
);
final Widget map = FlyMap(
key: const ValueKey<String>('map'),
model: _m,
trail: _trail,
controller: _minimap,
interactive: _mapBig,
layers: waypointMapLayers(editor: _waypoints, mission: _m.mission, editable: _editing),
onTap: (LatLng at) {
if (_editing) _waypoints.tapMap(at);
},
);
final Widget body = Stack(fit: StackFit.expand, children: <Widget>[
// The background view, then whatever sits in the corner box. Keyed, so
// the swap moves the native video view instead of recreating it.
if (!_mapBig) ...<Widget>[
Positioned.fromRect(key: const ValueKey<String>('video-slot'), rect: full, child: video),
Positioned.fill(child: VisualOverlays(video: _m.videoSize)),
// A vision mode owns every touch on the video: a box or a point.
if (_mode == IntelligentMode.activeTrack)
Positioned.fill(
child: ActiveTrackLayer(
mission: _m.mission,
session: _vision,
video: _m.videoSize,
onGo: (Rect box, int? subject) => _goTrack(context, box, subject: subject),
onConfirm: _confirmTrack,
),
)
else if (_mode == IntelligentMode.tapFly)
Positioned.fill(
child: TapFlyLayer(mission: _m.mission, session: _vision, video: _m.videoSize, onGo: (Offset p) => _goTapFly(context, p)),
)
else ...<Widget>[
// Under the camera touch layer, in the same gesture arena: drags aim
// the gimbal, taps and long presses still reach the camera.
Positioned.fill(
child: Requires(
HudFeatures.gimbalDrag,
child: ListenableBuilder(
listenable: _m,
builder: (BuildContext context, _) => GimbalDragLayer(
gimbal: _m.gimbal,
dji: _dji,
connected: _m.connected,
yaw: HudFeatures.gimbalYaw.enabledFor(ProductProfileScope.of(context)),
onError: _error,
),
),
),
),
Positioned.fill(
child: Requires(
HudFeatures.cameraControls,
child: ListenableBuilder(
listenable: _m,
builder: (BuildContext context, _) =>
CameraTouchLayer(camera: _m.camera, dji: _dji, connected: _m.connected, onError: _error, video: _m.videoSize),
),
),
),
],
Positioned.fromRect(key: const ValueKey<String>('map-slot'), rect: pip, child: map),
] else ...<Widget>[
Positioned.fromRect(key: const ValueKey<String>('map-slot'), rect: full, child: map),
Positioned.fromRect(key: const ValueKey<String>('video-slot'), rect: pip, child: video),
],
Positioned.fill(child: HudCanvas(builder: _hud)),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: IgnorePointer(
child: ValueListenableBuilder<bool>(
valueListenable: FlyPrefs.perfOverlay,
builder: (BuildContext context, bool on, _) => on ? PerformanceOverlay.allEnabled() : const SizedBox.shrink(),
),
),
),
]);
return Scaffold(
backgroundColor: const Color(0xFF0A1120),
body: ListenableBuilder(
listenable: _m,
// Only the scope is rebuilt per tick; it notifies [Requires] below it
// when the profile object itself changes.
builder: (BuildContext context, Widget? child) => ProductProfileScope(profile: _m.profile, child: child!),
child: body,
),
);
}
Widget _hud(BuildContext context, Size canvas, double scale) {
return Stack(clipBehavior: Clip.none, children: <Widget>[
// ── Camera column (right) ─────────────────────────────────────────────
Positioned(
right: 0,
top: 76,
bottom: 0,
child: Center(
child: Requires(
HudFeatures.cameraControls,
child: CameraColumn(
model: _m,
dji: _dji,
onError: _error,
onCameraMenu: () => showCameraMenu(context, model: _m, dji: _dji, onError: _error),
onPlayback: () => _push(AlbumPage(model: _m, dji: _dji)),
onCaptureModes: () => _push(CaptureModesPage(model: _m, dji: _dji)),
),
),
),
),
// ── Zoom column (zoom cameras only) ───────────────────────────────────
if (!_mapBig)
Positioned(
right: 68,
top: 76,
bottom: 0,
child: Center(
child: Requires(
HudFeatures.zoomColumn,
whenDisconnected: false,
child: ZoomColumn(camera: _m.camera, dji: _dji, onError: _error),
),
),
),
if (!_editing)
Positioned(
right: 68,
top: StatusBar.height + SmartBatteryBar.height + 32,
child: Requires(HudFeatures.histogram, child: HistogramView(camera: _m.camera)),
),
// ── Gimbal ────────────────────────────────────────────────────────────
Positioned(
left: 54,
top: 64,
bottom: _pipSize.height + 24,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
Expanded(
child: Requires(
HudFeatures.gimbalPitch,
child: Builder(
builder: (BuildContext context) => GimbalSlider(
gimbal: _m.gimbal,
dji: _dji,
range: ProductProfileScope.of(context).gimbal?.pitch,
onError: _error,
),
),
),
),
const SizedBox(height: 8),
Requires(
HudFeatures.gimbalSettings,
child: GlassPill(
key: const ValueKey<String>('gimbal-settings-button'),
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 6),
onTap: () => showGimbalSettings(context, model: _m, dji: _dji, onError: _error),
child: const PVIcon('gimbal', size: 16, color: Glass.ink),
),
),
]),
),
// ── Top bar, battery bar, camera info ─────────────────────────────────
Positioned(
left: 0,
right: 0,
top: 0,
child: StatusBar(
model: _m,
onBack: () => Navigator.of(context).maybePop(),
onStatus: () => _openStatus(context),
onMore: () => showFlySettings(
context,
model: _m,
dji: _dji,
onError: _error,
onOpenStatus: () => _openStatus(context),
onClearRoute: _trail.clear,
),
),
),
Positioned(left: 0, right: 0, top: StatusBar.height + 3, child: SmartBatteryBar(model: _m)),
// Exposure means nothing while planning a route on the map.
if (!_editing)
Positioned(
right: 0,
top: StatusBar.height + SmartBatteryBar.height + 5,
child: CameraInfoStrip(
camera: _m.camera,
onTap: () => showQuickExposure(context, model: _m, dji: _dji, onError: _error),
),
),
// ── Bottom: attitude ball + telemetry ─────────────────────────────────
// Gives way to the point editor while a waypoint or POI is selected.
Positioned(
left: _pipSize.width + 10,
bottom: 0,
child: ListenableBuilder(
listenable: _waypoints,
builder: (BuildContext context, Widget? child) =>
_editing && (_waypoints.selected != null || _waypoints.selectedPoi != null) ? const SizedBox.shrink() : child!,
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: <Widget>[
AttitudeBall(model: _m, size: 84),
const SizedBox(width: 6),
TelemetryStrip(aircraft: _m.aircraft),
]),
),
),
// ── Mission run panel / route board, error stack, low-battery banners ─
// Bounded above the attitude ball; the stack fits what is left under the banners.
Positioned(
left: 108,
top: StatusBar.height + SmartBatteryBar.height + 8,
bottom: 104,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
WaypointRunPanel(model: _m, editor: _waypoints, gate: _gate, flow: _flow, onError: _error),
// Palm control (Spark) flies the aircraft from the pilot's hand: say so.
ListenableBuilder(
listenable: _m.vision,
builder: (BuildContext context, _) => !_m.vision.palmActive
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(bottom: 6),
child: GlassPill(
key: const ValueKey<String>('palm-control'),
color: Glass.pillStrong,
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
const PVIcon('user', size: 12, color: Glass.ink),
const SizedBox(width: 6),
Text('PALM CONTROL', style: HudText.label.copyWith(color: Glass.ink, fontWeight: FontWeight.w700)),
const SizedBox(width: 6),
Text(_m.vision.palmLabel.toUpperCase(), style: HudText.label.copyWith(color: HudColors.ready)),
]),
),
),
),
if (_visionMode)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: VisionModePanel(
tapFly: _mode == IntelligentMode.tapFly,
model: _m,
session: _vision,
gate: _gate,
flow: _flow,
onExit: () => _exitVision(context),
onError: _error,
),
),
if (_editing)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: WaypointBoard(
editor: _waypoints,
limits: _limits,
onTap: () => showRouteSettings(context, editor: _waypoints, limits: _limits),
),
),
SafetyBanners(gate: _gate, onError: _error),
Flexible(child: AlertStack(alerts: _m.alerts, onMore: () => _openStatus(context))),
]),
),
// ── Left rail ─────────────────────────────────────────────────────────
Positioned(
left: 0,
top: 60,
child: ActionRail(
model: _m,
flow: _flow,
modeActive: _mode != null,
onModes: () => _openModes(context),
),
),
if (_mapBig) Positioned(right: _editing ? 122 : 70, top: 80, child: MinimapControls(controller: _minimap, trail: _trail)),
// ── Waypoints editor: tools + point editor ────────────────────────────
if (_editing) ...<Widget>[
Positioned(
right: 70,
top: 76,
child: WaypointTools(
editor: _waypoints,
onStart: () => startWaypointFlow(context, gate: _gate, spec: _waypoints.mission, onError: _error),
onSettings: () => showRouteSettings(context, editor: _waypoints, limits: _limits),
onLibrary: () => showMissionLibrary(context, editor: _waypoints, library: _library),
onExit: _exitMode,
),
),
Positioned(
left: _pipSize.width + 10,
right: 124,
bottom: 0,
child: Builder(
builder: (BuildContext context) {
final ProductProfile p = ProductProfileScope.of(context);
return PointEditorPanel(
editor: _waypoints,
gimbalMin: p.gimbal?.pitch?.min ?? -90,
gimbalMax: p.gimbal?.pitch?.max ?? 30,
);
},
),
),
],
// ── Minimap / video corner box ────────────────────────────────────────
Positioned(
left: 0,
bottom: 0,
width: _pipSize.width,
height: _pipSize.height,
child: _PipFrame(label: _mapBig ? 'VIDEO' : 'MAP', onTap: () => setState(() => _mapBig = !_mapBig)),
),
]);
}
}
/// The corner box's frame and tap target. The content underneath is the real
/// map or video layer, placed at the same spot by [FlyScreen].
class _PipFrame extends StatelessWidget {
const _PipFrame({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0x66EAF0FA)),
),
alignment: Alignment.topLeft,
padding: const EdgeInsets.all(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(3)),
child: Text(label, style: HudText.label.copyWith(color: Glass.ink)),
),
),
);
}
}