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>
117 lines
4.5 KiB
Dart
117 lines
4.5 KiB
Dart
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),
|
||
],
|
||
],
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|