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>
458 lines
16 KiB
Dart
458 lines
16 KiB
Dart
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();
|
||
}
|