Files
PilotVault/Fly App/lib/ui/flight_logs_page.dart
T
tajniak81andClaude Opus 5.5 91520560a7 Retry map tiles that failed while they are still on screen
The previous fix silenced tile errors, which made flutter_map count a failed
tile as a loaded transparent one, so it was never fetched again. Errors now
reach the layer, and OsmTileLayer rebuilds itself a few seconds after a
failure: loaded tiles come back from the image cache and only the failed ones
are requested again. Seen on the tablet, whose 2.4 GHz Wi-Fi loses a fifth of
its packets next to the OcuSync remote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:35:19 +02:00

367 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';
import 'osm_tiles.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>[
const OsmTileLayer(),
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)),
);
}