Files
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

117 lines
4.5 KiB
Dart
Raw Permalink 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 '../api/api.dart';
import '../services.dart';
import '../session/session_controller.dart';
import '../theme.dart';
import 'format.dart';
/// Recent logbook entries from PilotVault, plus entries still queued on the
/// phone because they were filed offline.
class LogbookTab extends StatefulWidget {
const LogbookTab({super.key});
@override
State<LogbookTab> createState() => _LogbookTabState();
}
class _LogbookTabState extends State<LogbookTab> {
Future<List<Flight>>? _flights;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_flights ??= Services.of(context).api.flights();
}
Future<void> _refresh() async {
final SessionController c = Services.of(context).sessions;
if (c.pending.isNotEmpty) await c.retryPending();
if (!mounted) return;
final Future<List<Flight>> f = Services.of(context).api.flights();
setState(() => _flights = f);
await f.catchError((Object _) => <Flight>[]);
}
@override
Widget build(BuildContext context) {
final SessionController c = Services.of(context).sessions;
return RefreshIndicator(
onRefresh: _refresh,
child: ListenableBuilder(
listenable: c,
builder: (BuildContext context, _) => ListView(
padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(),
children: <Widget>[
if (c.pending.isNotEmpty) ...<Widget>[
Row(
children: <Widget>[
const Expanded(child: SectionLabel('Waiting to be filed')),
TextButton(
onPressed: () async {
final int n = await c.retryPending();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Filed $n entr${n == 1 ? 'y' : 'ies'}.')));
}
if (n > 0) _refresh();
},
child: const Text('Retry'),
),
],
),
for (final PendingFlight p in c.pending)
Card(
child: ListTile(
leading: const Icon(Icons.cloud_off, color: PV.caution),
title: Text(p.summary),
subtitle: Text(p.error, maxLines: 2, overflow: TextOverflow.ellipsis),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Drop',
onPressed: () => c.dropPending(p),
),
),
),
],
const SectionLabel('Recent flights'),
FutureBuilder<List<Flight>>(
future: _flights,
builder: (BuildContext context, AsyncSnapshot<List<Flight>> snap) {
if (snap.connectionState != ConnectionState.done) {
return const Padding(padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator()));
}
if (snap.hasError) return Text('Could not load the logbook: ${snap.error}', style: PV.caption);
final List<Flight> flights = snap.data!;
if (flights.isEmpty) return const Text('No flights logged yet.', style: PV.caption);
return Column(
children: <Widget>[
for (final Flight f in flights.take(50)) ...<Widget>[
Card(
child: ListTile(
title: Text(f.droneName.isNotEmpty ? f.droneName : 'Unknown drone'),
subtitle: Text(
'${f.operationDate} · ${f.startTime}–${f.endTime} · max ${f.maxAltitudeAgl.toStringAsFixed(0)} m'
'${f.purpose.isNotEmpty ? ' · ${purposeLabels[f.purpose] ?? f.purpose}' : ''}',
),
trailing: f.redFlags.isEmpty
? const Icon(Icons.check_circle_outline, color: PV.ready)
: Tooltip(
message: f.redFlags.join('\n'),
child: const Icon(Icons.flag_outlined, color: PV.caution),
),
),
),
const SizedBox(height: 6),
],
],
);
},
),
],
),
),
);
}
}