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>
366 lines
15 KiB
Dart
366 lines
15 KiB
Dart
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)),
|
||
);
|
||
}
|