Files
PilotVault/Fly Agent App/lib/ui/flight_tab.dart
T
tajniak81andClaude Opus 5.5 3dd31b257a Add the Fly Agent App, a Remote ID companion to DJI Fly
A Flutter app that runs next to DJI Fly without the DJI SDK. It receives the
aircraft's Remote ID (Bluetooth, BT5, Wi-Fi NaN and Beacon) in a foreground
service, streams the paired aircraft to the hub over /ws/device, and turns
take-off to landing into a logbook entry filed through /api/flights, queued
when offline. Covers phases A0-A3 of the Fly Agent plan; needs no server change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:25:52 +02:00

458 lines
16 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:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import '../rid/rid_aircraft.dart';
import '../services.dart';
import '../session/flight_session.dart';
import '../session/session_controller.dart';
import '../theme.dart';
import 'briefing_page.dart';
import 'format.dart';
import 'track_map.dart';
/// The flight session: idle → armed → active → landed review → filed.
class FlightTab extends StatelessWidget {
const FlightTab({super.key});
@override
Widget build(BuildContext context) {
final Services s = Services.of(context);
return ListenableBuilder(
listenable: Listenable.merge(<Listenable>[s.sessions, s.rid, s.pilot]),
builder: (BuildContext context, _) {
final FlightSession f = s.sessions.session;
return switch (f.phase) {
SessionPhase.idle => const _IdleView(),
SessionPhase.armed || SessionPhase.active => const _LiveView(),
SessionPhase.landed => const _ReviewView(),
};
},
);
}
}
class _IdleView extends StatelessWidget {
const _IdleView();
@override
Widget build(BuildContext context) {
final Services s = Services.of(context);
final RidAircraft? a = s.rid.paired;
return ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
_PairedCard(aircraft: a),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(Icons.checklist),
label: const Text('Plan a flight'),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52)),
onPressed: () => Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const BriefingPage())),
),
const SizedBox(height: 10),
OutlinedButton.icon(
icon: const Icon(Icons.open_in_new),
label: const Text('Open DJI Fly'),
style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(48)),
onPressed: () => _openDjiFly(context),
),
if (s.sessions.pending.isNotEmpty) ...<Widget>[
const SizedBox(height: 16),
Card(
child: ListTile(
leading: const Icon(Icons.cloud_upload_outlined, color: PV.caution),
title: Text('${s.sessions.pending.length} flight(s) waiting to be filed'),
subtitle: const Text('See Logbook to retry'),
),
),
],
const SizedBox(height: 24),
const Text(
'How it works: plan the flight here, then fly with DJI Fly. The Agent starts the session when your '
'paired aircraft takes off, follows it through Remote ID, and stops when it lands. You review the '
'entry and file it to your logbook.',
style: PV.caption,
),
],
);
}
}
class _PairedCard extends StatelessWidget {
const _PairedCard({required this.aircraft});
final RidAircraft? aircraft;
@override
Widget build(BuildContext context) {
final Services s = Services.of(context);
final RidAircraft? a = aircraft;
final String title;
final String sub;
final Color dot;
if (s.rid.pairing == null) {
title = 'No aircraft paired';
sub = 'Open Nearby, power on your aircraft and tap it to pair.';
dot = PV.inkMuted;
} else if (a == null) {
title = s.rid.pairing!.droneName;
sub = 'Not heard yet · ${s.rid.pairing!.serial}';
dot = PV.caution;
} else {
title = s.rid.pairing!.droneName;
final bool fresh = DateTime.now().difference(a.lastSeen) < const Duration(seconds: 10);
sub = '${a.status.name} · ${a.transports.map(transportLabel).join(', ')} · ${fmtAgo(a.lastSeen)}';
dot = fresh ? PV.ready : PV.caution;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: <Widget>[
StatusDot(dot, size: 12),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(title, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
const SizedBox(height: 2),
Text(sub, style: PV.caption),
],
),
),
],
),
),
);
}
}
class _LiveView extends StatelessWidget {
const _LiveView();
@override
Widget build(BuildContext context) {
final Services s = Services.of(context);
final SessionController c = s.sessions;
final FlightSession f = c.session;
final RidAircraft? a = s.rid.paired;
final Position? me = s.pilot.position;
final bool active = f.phase == SessionPhase.active;
final double? distFromPilot = me != null && a != null && a.hasFix
? Geolocator.distanceBetween(me.latitude, me.longitude, a.latitude!, a.longitude!)
: null;
return ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Card(
color: active ? PV.navy : PV.surface1,
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(active ? 'IN FLIGHT' : 'WAITING FOR TAKE-OFF',
style: PV.label.copyWith(color: active ? const Color(0xFF7FE0B0) : PV.inkMuted)),
const SizedBox(height: 8),
Text(
active ? fmtDuration(f.elapsed(DateTime.now())) : f.droneName,
style: PV.telemetry.copyWith(color: active ? Colors.white : PV.ink, fontSize: active ? 40 : 22),
),
const SizedBox(height: 6),
Text(
active
? '${f.droneName} · since ${fmtClock(f.startedAt!)}${f.autoStarted ? ' (auto)' : ''}'
: 'Starts by itself when the paired aircraft is airborne.',
style: PV.caption.copyWith(color: active ? const Color(0xFFB8C4D9) : PV.inkSecondary),
),
],
),
),
),
const SizedBox(height: 12),
Row(
children: <Widget>[
_Stat('HEIGHT', fmtMeters(a?.height)),
_Stat('DISTANCE', fmtDistance(distFromPilot)),
_Stat('SPEED', fmtSpeed(a?.speedHorizontal)),
],
),
const SizedBox(height: 8),
Row(
children: <Widget>[
_Stat('MAX HEIGHT', fmtMeters(f.maxHeight)),
_Stat('FLOWN', fmtDistance(f.distance)),
_Stat('RID', a == null ? 'none' : fmtAgo(a.lastSeen)),
],
),
if (f.alerts.isNotEmpty) ...<Widget>[
const SectionLabel('Alerts'),
for (final SessionAlert al in f.alerts)
Card(
color: const Color(0xFFFCEDED),
child: ListTile(
leading: const Icon(Icons.warning_amber, color: PV.warning),
title: Text(al.message),
subtitle: Text(fmtClock(al.at)),
),
),
],
const SizedBox(height: 12),
TrackMap(
track: f.track,
aircraft: a != null && a.hasFix ? LatLng(a.latitude!, a.longitude!) : null,
pilot: me != null ? LatLng(me.latitude, me.longitude) : null,
),
const SizedBox(height: 16),
Row(
children: <Widget>[
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.open_in_new),
label: const Text('DJI Fly'),
style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(48)),
onPressed: () => _openDjiFly(context),
),
),
const SizedBox(width: 12),
Expanded(
child: active
? FilledButton.icon(
icon: const Icon(Icons.flight_land),
label: const Text('Landed'),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48), backgroundColor: PV.navy),
onPressed: c.stopNow,
)
: FilledButton.icon(
icon: const Icon(Icons.play_arrow),
label: const Text('Start now'),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48)),
onPressed: c.startNow,
),
),
],
),
if (!active)
TextButton(
onPressed: () => _confirm(context, 'Cancel this session?', 'Nothing has been recorded yet.', c.discard),
child: const Text('Cancel session'),
),
],
);
}
}
class _ReviewView extends StatefulWidget {
const _ReviewView();
@override
State<_ReviewView> createState() => _ReviewViewState();
}
class _ReviewViewState extends State<_ReviewView> {
late final TextEditingController _area;
late final TextEditingController _notes;
late final TextEditingController _incidents;
late final TextEditingController _maxHeight;
bool _init = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_init) return;
_init = true;
final FlightSession f = Services.of(context).sessions.session;
_area = TextEditingController(text: f.areaRoute);
_notes = TextEditingController(text: f.notes);
_incidents = TextEditingController(text: f.incidents);
_maxHeight = TextEditingController(text: f.effectiveMaxHeight > 0 ? f.effectiveMaxHeight.toStringAsFixed(1) : '');
}
@override
void dispose() {
_area.dispose();
_notes.dispose();
_incidents.dispose();
_maxHeight.dispose();
super.dispose();
}
void _pushEdits() {
final SessionController c = Services.of(context).sessions;
final FlightSession f = c.session;
f.areaRoute = _area.text;
f.notes = _notes.text;
f.incidents = _incidents.text;
final double? h = double.tryParse(_maxHeight.text.replaceAll(',', '.'));
f.maxHeightOverride = h != null && (h - f.maxHeight).abs() > 0.05 ? h : null;
c.edited();
}
Future<void> _file() async {
_pushEdits();
final FlightSession f = Services.of(context).sessions.session;
if (f.effectiveMaxHeight <= 0) {
_snack('Enter the maximum height: the logbook requires it.');
return;
}
if (f.areaRoute.trim().isEmpty && f.track.isEmpty) {
_snack('Describe the area flown: there is no Remote ID track to describe it from.');
return;
}
final String? err = await Services.of(context).sessions.file();
if (!mounted) return;
_snack(err ?? 'Filed to your logbook.');
}
void _snack(String m) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(m)));
@override
Widget build(BuildContext context) {
final SessionController c = Services.of(context).sessions;
final FlightSession f = c.session;
return ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('REVIEW FLIGHT', style: PV.label),
const SizedBox(height: 6),
Text(f.droneName, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18)),
const SizedBox(height: 4),
Text(
'${f.startedAt != null ? fmtClock(f.startedAt!) : '—'} – ${f.endedAt != null ? fmtClock(f.endedAt!) : '—'}'
' · ${fmtDuration(f.elapsed(DateTime.now()))}'
'${f.autoStopped ? ' · stopped automatically' : ''}',
style: PV.caption,
),
const SizedBox(height: 4),
Text(
'${purposeLabels[f.purpose] ?? f.purpose} · ${categoryLabels[f.category] ?? f.category}'
' · flown ${fmtDistance(f.distance)} · ${f.track.length} fixes',
style: PV.caption,
),
],
),
),
),
const SizedBox(height: 12),
TrackMap(track: f.track),
const SectionLabel('Logbook entry'),
TextField(
controller: _maxHeight,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: 'Max height above take-off (m)',
helperText: f.maxHeight > 0 ? 'Remote ID: ${f.maxHeight.toStringAsFixed(1)} m' : 'No height from Remote ID',
),
onEditingComplete: _pushEdits,
),
const SizedBox(height: 12),
TextField(
controller: _area,
decoration: const InputDecoration(labelText: 'Area or route', helperText: 'Blank: described from the track'),
onEditingComplete: _pushEdits,
),
const SizedBox(height: 12),
TextField(
controller: _incidents,
maxLines: 2,
decoration: InputDecoration(
labelText: 'Incidents',
helperText: f.alerts.isEmpty ? null : '${f.alerts.length} alert(s) are added automatically',
),
onEditingComplete: _pushEdits,
),
const SizedBox(height: 12),
TextField(
controller: _notes,
maxLines: 3,
decoration: const InputDecoration(labelText: 'Notes'),
onEditingComplete: _pushEdits,
),
const SizedBox(height: 20),
FilledButton.icon(
icon: c.filing
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.save_alt),
label: const Text('File to logbook'),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52)),
onPressed: c.filing ? null : _file,
),
const SizedBox(height: 8),
Row(
children: <Widget>[
Expanded(
child: TextButton(
onPressed: () {
_pushEdits();
c.resume();
},
child: const Text('Still flying: resume'),
),
),
Expanded(
child: TextButton(
style: TextButton.styleFrom(foregroundColor: PV.warning),
onPressed: () => _confirm(context, 'Discard this flight?', 'The track and times are deleted and nothing is filed.', c.discard),
child: const Text('Discard'),
),
),
],
),
],
);
}
}
class _Stat extends StatelessWidget {
const _Stat(this.label, this.value);
final String label;
final String value;
@override
Widget build(BuildContext context) => Expanded(
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 3),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(label, style: PV.label.copyWith(fontSize: 10)),
const SizedBox(height: 6),
FittedBox(fit: BoxFit.scaleDown, child: Text(value, style: PV.telemetry.copyWith(fontSize: 18))),
],
),
),
),
);
}
Future<void> _openDjiFly(BuildContext context) async {
final bool ok = await Services.of(context).rid.openDjiFly();
if (!ok && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('DJI Fly is not installed on this phone.')));
}
}
Future<void> _confirm(BuildContext context, String title, String body, VoidCallback onYes) async {
final bool? ok = await showDialog<bool>(
context: context,
builder: (BuildContext c) => AlertDialog(
title: Text(title),
content: Text(body),
actions: <Widget>[
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Keep')),
FilledButton(onPressed: () => Navigator.pop(c, true), child: const Text('Yes')),
],
),
);
if (ok == true) onYes();
}