Files
PilotVault/Fly App/lib/ui/flight_logs_page.dart
T
tajniak81andClaude Opus 5.5 91ca150a35 Give the Fly App GO 4-style playback, flight records, Find My Drone and live streaming
Playback: the flight screen's playback button opens the album over the
live view, so a video tapped there plays on the aircraft into the feed
with a transport bar. Long press selects files to download or delete
(delete asks first); one file opens in a viewer with the camera's
preview. Leaving the album puts the camera back in its capture mode.

Flight records: every flight is recorded from take-off to landing (1 Hz
track, max height / distance / speed, distance flown, battery used) and
kept on the phone. When it lands while signed in, it is uploaded to the
PilotVault logbook through the existing /api/drones/auto and
/api/flights endpoints as an automatic-path entry with the track as
GeoJSON. Simulator flights are never uploaded; flights recorded while
signed out retry on sign-in. Flight logs lists them with their upload
state and a track map, and the profile stats come from real flights.

Find My Drone: the aircraft's last fix is kept across lost links and
restarts, and shown against the phone's own GPS with distance, direction
and a hand-off to the maps app.

Live streaming: a new LiveStreamBridge streams the main camera to an
RTMP address set in Settings > General, with a LIVE pill on the flight
screen that turns amber when the uplink falls behind and stops on tap.

Pre-flight checklist (manual checks plus the aircraft's live status)
and an in-app Academy with six short guides that work offline.

None of this has run on hardware yet.

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

366 lines
15 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:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import '../flight_model.dart';
import '../fly/find_my_drone.dart';
import '../fly/flight_log.dart';
import '../fly/flight_record.dart';
import '../fly/fly_prefs.dart';
import '../pb_auth.dart';
import '../theme.dart';
import 'find_my_drone_page.dart';
import 'pv_icons.dart';
/// Flight records (plan P9): every flight the app recorded, newest first, with
/// its logbook upload state, and Find My Drone at the top.
class FlightLogsPage extends StatelessWidget {
const FlightLogsPage({super.key, required this.log, required this.locator, required this.model});
final FlightLog log;
final AircraftLocator locator;
final FlightModel model;
@override
Widget build(BuildContext context) {
final PVScheme s = PVScheme.of(context);
return Scaffold(
backgroundColor: s.bgApp,
body: SafeArea(
child: ListenableBuilder(
listenable: log,
builder: (BuildContext context, _) {
final List<FlightRecord> records = log.records;
final int pending = log.pending.length;
return ListView(
padding: const EdgeInsets.only(bottom: 24),
children: <Widget>[
PageHeader(title: 'Flight logs', trailing: Text('${records.length} flights', style: _mono(s, 12, s.textTertiary))),
_findCard(context, s),
if (pending > 0) _pendingCard(context, s, pending),
_autoUploadRow(s),
if (!log.loaded)
Padding(padding: const EdgeInsets.all(40), child: Center(child: CircularProgressIndicator(color: s.accent)))
else if (records.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(40, 48, 40, 0),
child: Column(children: <Widget>[
PVIcon('gauge', size: 40, stroke: 1.4, color: s.textTertiary),
const SizedBox(height: 12),
Text('No flights recorded yet.\nEvery flight is recorded from take-off to landing.',
textAlign: TextAlign.center, style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, color: s.textSecondary)),
]),
)
else
for (final FlightRecord r in records) _row(context, s, r),
],
);
},
),
),
);
}
Widget _findCard(BuildContext context, PVScheme s) {
return ListenableBuilder(
listenable: locator,
builder: (BuildContext context, _) {
final LastKnownPosition? p = locator.last;
return _Card(
key: const ValueKey<String>('find-my-drone'),
icon: 'pin',
title: 'Find my drone',
subtitle: p == null ? 'No position recorded yet' : 'Last seen ${agoLabel(p.at, DateTime.now())}${p.inFlight ? ' · in flight' : ''}',
onTap: () => Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => FindMyDronePage(locator: locator, log: log, model: model),
)),
);
},
);
}
Widget _pendingCard(BuildContext context, PVScheme s, int pending) {
final bool signedIn = log.remote.available;
return _Card(
key: const ValueKey<String>('upload-pending'),
icon: 'share',
title: '$pending ${pending == 1 ? 'flight' : 'flights'} not in the logbook',
subtitle: signedIn ? 'Tap to upload to PilotVault' : 'Sign in to upload them to PilotVault',
onTap: signedIn
? () async {
final int ok = await log.uploadPending();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Uploaded $ok of $pending')));
}
}
: null,
);
}
Widget _autoUploadRow(PVScheme s) => ValueListenableBuilder<bool>(
valueListenable: FlyPrefs.autoUploadFlights,
builder: (BuildContext context, bool on, _) => Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 12, 4),
child: Row(children: <Widget>[
Expanded(
child: Text('Upload each flight to the logbook when it lands',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.textSecondary)),
),
Switch(key: const ValueKey<String>('auto-upload'), value: on, onChanged: FlyPrefs.setAutoUploadFlights),
]),
),
);
Widget _row(BuildContext context, PVScheme s, FlightRecord r) {
final (String label, Color color) = _status(s, r);
return Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 10),
child: GestureDetector(
key: ValueKey<String>('flight-${r.id}'),
onTap: () => Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => FlightRecordPage(record: r, log: log))),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: s.border), boxShadow: s.shadowXs),
child: Row(children: <Widget>[
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
Text('${_date(r.start)} · ${_time(r.start)}–${_time(r.end)}',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 14.5, fontWeight: FontWeight.w600, color: s.textPrimary)),
const SizedBox(height: 3),
Text(
'${r.aircraftModel ?? 'Aircraft'} · ${r.durationLabel} · ${distanceLabel(r.distanceFlown)} · max ${r.maxAltitude.round()} m',
style: _mono(s, 11, s.textSecondary),
),
]),
),
const SizedBox(width: 8),
log.isUploading(r)
? SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: s.accent))
: Text(label, style: _mono(s, 10, color)),
]),
),
),
);
}
static (String, Color) _status(PVScheme s, FlightRecord r) {
if (r.simulated) return ('SIMULATOR', s.textTertiary);
if (r.uploaded) return ('LOGGED', s.successFg);
if (r.uploadError != null) return ('NOT UPLOADED', s.warningFg);
return ('LOCAL', s.textTertiary);
}
}
TextStyle _mono(PVScheme s, double size, Color c) => TextStyle(fontFamily: PV.fontMono, fontSize: size, color: c);
String _date(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
String _time(DateTime d) => '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
/// The portrait library screens' back-arrow title row.
class PageHeader extends StatelessWidget {
const PageHeader({super.key, required this.title, this.trailing});
final String title;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final PVScheme s = PVScheme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 20, 12),
child: Row(children: <Widget>[
IconButton(onPressed: () => Navigator.of(context).maybePop(), icon: PVIcon('chevronLeft', size: 24, color: s.textSecondary)),
Expanded(
child: Text(title,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontFamily: PV.fontSans, fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: s.textPrimary)),
),
if (trailing != null) trailing!,
]),
);
}
}
class _Card extends StatelessWidget {
const _Card({super.key, required this.icon, required this.title, required this.subtitle, this.onTap});
final String icon;
final String title;
final String subtitle;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final PVScheme s = PVScheme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 10),
child: GestureDetector(
onTap: onTap,
child: Container(
height: 62,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: s.border), boxShadow: s.shadowXs),
child: Row(children: <Widget>[
Container(
width: 38, height: 38,
decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(10)),
alignment: Alignment.center,
child: PVIcon(icon, size: 19, color: s.accentSoftFg),
),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
Text(title, style: TextStyle(fontFamily: PV.fontSans, fontSize: 14.5, fontWeight: FontWeight.w600, color: s.textPrimary)),
Text(subtitle, overflow: TextOverflow.ellipsis, style: _mono(s, 11, s.textSecondary)),
]),
),
if (onTap != null) PVIcon('chevronRight', size: 18, color: s.textTertiary),
]),
),
),
);
}
}
/// One recorded flight: its track on the map, its numbers, and the logbook upload.
class FlightRecordPage extends StatelessWidget {
const FlightRecordPage({super.key, required this.record, required this.log});
final FlightRecord record;
final FlightLog log;
Future<void> _delete(BuildContext context) async {
final bool? ok = await showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: const Text('Delete this flight record?'),
content: Text(record.uploaded
? 'It is removed from this phone. The logbook entry on PilotVault stays.'
: 'It is removed from this phone and was never uploaded to the logbook.'),
actions: <Widget>[
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Delete')),
],
),
);
if (ok != true || !context.mounted) return;
await log.delete(record);
if (context.mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final PVScheme s = PVScheme.of(context);
final FlightRecord r = record;
return Scaffold(
backgroundColor: s.bgApp,
body: SafeArea(
child: ListenableBuilder(
listenable: log,
builder: (BuildContext context, _) => ListView(children: <Widget>[
PageHeader(
title: '${_date(r.start)} · ${_time(r.start)}',
trailing: IconButton(onPressed: () => _delete(context), icon: PVIcon('trash', size: 20, color: s.danger)),
),
if (r.track.isNotEmpty)
Container(
height: 260,
margin: const EdgeInsets.fromLTRB(20, 0, 20, 14),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: s.border)),
child: FlightTrackMap(record: r),
),
_facts(s, r),
Padding(padding: const EdgeInsets.fromLTRB(20, 4, 20, 20), child: _upload(context, s, r)),
]),
),
),
);
}
Widget _facts(PVScheme s, FlightRecord r) {
final List<(String, String)> facts = <(String, String)>[
('Aircraft', r.aircraftModel ?? '—'),
('Duration', r.durationLabel),
('Distance flown', distanceLabel(r.distanceFlown)),
('Max height', '${r.maxAltitude.round()} m above take-off'),
('Max distance', '${distanceLabel(r.maxDistance)} from home'),
('Max speed', '${r.maxSpeed.toStringAsFixed(1)} m/s'),
if (r.batteryStart != null) ('Battery', '${r.batteryStart}% → ${r.batteryEnd ?? '—'}%'),
('Flight controller SN', r.flightControllerSerial ?? 'not read'),
];
return Container(
margin: const EdgeInsets.symmetric(horizontal: 20),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: s.border)),
child: Column(children: <Widget>[
for (final (String k, String v) in facts)
Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(children: <Widget>[
Expanded(child: Text(k, style: TextStyle(fontFamily: PV.fontSans, fontSize: 13.5, color: s.textSecondary))),
Text(v, style: _mono(s, 12.5, s.textPrimary)),
]),
),
]),
);
}
Widget _upload(BuildContext context, PVScheme s, FlightRecord r) {
if (r.simulated) {
return Text('Flown in the simulator: not a logbook flight.', style: _mono(s, 12, s.textTertiary));
}
if (r.uploaded) {
return Row(children: <Widget>[
PVIcon('check', size: 16, color: s.successFg),
const SizedBox(width: 8),
Text('In the PilotVault logbook', style: TextStyle(fontFamily: PV.fontSans, fontSize: 13.5, color: s.successFg)),
]);
}
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
if (r.uploadError != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(r.uploadError!, style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.warningFg)),
),
FilledButton(
key: const ValueKey<String>('upload-flight'),
onPressed: log.isUploading(r) || !auth.isAuthed ? null : () => log.upload(r),
child: Text(log.isUploading(r) ? 'Uploading…' : (auth.isAuthed ? 'Upload to logbook' : 'Sign in to upload')),
),
]);
}
}
/// A recorded flight's track, fitted to the map, with take-off and landing marks.
class FlightTrackMap extends StatelessWidget {
const FlightTrackMap({super.key, required this.record, this.extraMarkers = const <Marker>[]});
final FlightRecord record;
final List<Marker> extraMarkers;
@override
Widget build(BuildContext context) {
final List<LatLng> pts = <LatLng>[for (final TrackPoint p in record.track) p.latLng];
final MapOptions options = pts.length >= 2
? MapOptions(initialCameraFit: CameraFit.bounds(bounds: LatLngBounds.fromPoints(pts), padding: const EdgeInsets.all(28), maxZoom: 18))
: MapOptions(initialCenter: pts.first, initialZoom: 17);
return FlutterMap(
options: options,
children: <Widget>[
TileLayer(urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.pilotvault.flyapp'),
if (pts.length >= 2) PolylineLayer(polylines: <Polyline>[Polyline(points: pts, strokeWidth: 3, color: const Color(0xFF3D7BF0))]),
MarkerLayer(markers: <Marker>[
Marker(point: pts.first, width: 14, height: 14, child: _dot(const Color(0xFF2FA36F))),
Marker(point: pts.last, width: 14, height: 14, child: _dot(const Color(0xFFE05252))),
...extraMarkers,
]),
],
);
}
static Widget _dot(Color c) => Container(
decoration: BoxDecoration(color: c, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2)),
);
}