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>
308 lines
14 KiB
Dart
308 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../flight_model.dart';
|
|
import '../fly/academy_content.dart';
|
|
import '../fly/aircraft_checklist.dart';
|
|
import '../fly/preflight_checklist.dart';
|
|
import '../theme.dart';
|
|
import 'flight_logs_page.dart' show PageHeader;
|
|
import 'pv_icons.dart';
|
|
|
|
/// Academy — mirrors the v2 "Academy" mockup (plan P9): the pre-flight
|
|
/// checklist as the featured card, the in-app guides ([academyArticles]),
|
|
/// which work without a connection in the field, and DJI's own material in
|
|
/// the browser.
|
|
class AcademyPage extends StatelessWidget {
|
|
const AcademyPage({super.key, required this.model, required this.checklist});
|
|
|
|
final FlightModel model;
|
|
final PreflightChecklist checklist;
|
|
|
|
static const List<(String, String, String)> _links = <(String, String, String)>[
|
|
('academy', 'DJI product manuals', 'https://www.dji.com/support'),
|
|
('shield', 'EU drone rules (EASA)', 'https://www.easa.europa.eu/en/domains/drones-air-mobility'),
|
|
];
|
|
|
|
Future<void> _open(BuildContext context, String url) async {
|
|
final Uri uri = Uri.parse(url);
|
|
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not open $url')));
|
|
}
|
|
}
|
|
}
|
|
|
|
void _push(BuildContext context, Widget page) => Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => page));
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final PVScheme s = PVScheme.of(context);
|
|
return Scaffold(
|
|
backgroundColor: s.bgApp,
|
|
body: SafeArea(
|
|
child: ListView(
|
|
padding: const EdgeInsets.only(bottom: 20),
|
|
children: <Widget>[
|
|
const PageHeader(title: 'Academy'),
|
|
ListenableBuilder(
|
|
listenable: checklist,
|
|
builder: (BuildContext context, _) => GestureDetector(
|
|
key: const ValueKey<String>('academy-checklist'),
|
|
onTap: () => _push(context, PreflightChecklistPage(model: model, checklist: checklist)),
|
|
child: Container(
|
|
height: 132,
|
|
margin: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: s.border),
|
|
gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: <Color>[Color(0xFF26406E), Color(0xFF0F1E3D)]),
|
|
),
|
|
child: Stack(children: <Widget>[
|
|
Positioned(
|
|
left: 16, bottom: 16, right: 64,
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: <Widget>[
|
|
const Text('BEFORE EVERY FLIGHT', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 1.4, color: Color(0xB3EAF0FA))),
|
|
const SizedBox(height: 3),
|
|
const Text('Pre-flight checklist', style: TextStyle(fontFamily: PV.fontSans, fontSize: 17, fontWeight: FontWeight.w700, color: Color(0xFFEAF0FA))),
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
checklist.done == 0 ? '${checklist.items.length} checks' : '${checklist.done} of ${checklist.items.length} done',
|
|
style: const TextStyle(fontFamily: PV.fontMono, fontSize: 11, color: Color(0xB3EAF0FA)),
|
|
),
|
|
]),
|
|
),
|
|
Positioned(
|
|
top: 16, right: 16,
|
|
child: Container(
|
|
width: 40, height: 40,
|
|
decoration: const BoxDecoration(color: Color(0x29FFFFFF), shape: BoxShape.circle),
|
|
alignment: Alignment.center,
|
|
child: PVIcon(checklist.complete ? 'check' : 'checklist', size: 18, color: Colors.white),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
),
|
|
_label(s, 'GUIDES'),
|
|
for (final AcademyArticle a in academyArticles)
|
|
_card(s, a.icon, a.title, '${a.subtitle} · ${a.minutes} min', () => _push(context, AcademyArticlePage(article: a)),
|
|
key: ValueKey<String>('article-${a.id}')),
|
|
_label(s, 'ONLINE'),
|
|
for (final (String ic, String t, String url) in _links) _card(s, ic, t, Uri.parse(url).host, () => _open(context, url)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
static Widget _label(PVScheme s, String text) => Padding(
|
|
padding: const EdgeInsets.fromLTRB(22, 6, 20, 8),
|
|
child: Text(text, style: TextStyle(fontFamily: PV.fontMono, fontSize: 10.5, letterSpacing: 1.2, color: s.textTertiary)),
|
|
);
|
|
|
|
static Widget _card(PVScheme s, String icon, String title, String sub, VoidCallback onTap, {Key? key}) => Padding(
|
|
key: key,
|
|
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(sub, overflow: TextOverflow.ellipsis, style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, color: s.textSecondary)),
|
|
])),
|
|
PVIcon('chevronRight', size: 18, color: s.textTertiary),
|
|
]),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// One guide, for reading.
|
|
class AcademyArticlePage extends StatelessWidget {
|
|
const AcademyArticlePage({super.key, required this.article});
|
|
|
|
final AcademyArticle article;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final PVScheme s = PVScheme.of(context);
|
|
final TextStyle body = TextStyle(fontFamily: PV.fontSans, fontSize: 15, height: 1.45, color: s.textPrimary);
|
|
return Scaffold(
|
|
backgroundColor: s.bgApp,
|
|
body: SafeArea(
|
|
child: ListView(
|
|
padding: const EdgeInsets.only(bottom: 32),
|
|
children: <Widget>[
|
|
PageHeader(title: article.title),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
|
|
child: Text('${article.subtitle} · ${article.minutes} min read', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textSecondary)),
|
|
),
|
|
for (final AcademySection sec in article.sections) ...<Widget>[
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 18, 24, 6),
|
|
child: Text(sec.heading, style: TextStyle(fontFamily: PV.fontSans, fontSize: 17, fontWeight: FontWeight.w700, color: s.textPrimary)),
|
|
),
|
|
for (final String p in sec.paragraphs)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 4, 24, 4),
|
|
child: p.startsWith('• ')
|
|
? Row(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
|
Padding(padding: const EdgeInsets.only(top: 8, right: 10), child: Container(width: 5, height: 5, decoration: BoxDecoration(color: s.accent, shape: BoxShape.circle))),
|
|
Expanded(child: Text(p.substring(2), style: body)),
|
|
])
|
|
: Text(p, style: body),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The pre-flight checklist (plan P9): the aircraft's own live checks when one
|
|
/// is connected (the Aircraft Status rows), then the pilot's manual checks,
|
|
/// ticked by hand and kept for the session.
|
|
class PreflightChecklistPage extends StatelessWidget {
|
|
const PreflightChecklistPage({super.key, required this.model, required this.checklist});
|
|
|
|
final FlightModel model;
|
|
final PreflightChecklist checklist;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final PVScheme s = PVScheme.of(context);
|
|
return Scaffold(
|
|
backgroundColor: s.bgApp,
|
|
body: SafeArea(
|
|
child: ListenableBuilder(
|
|
listenable: Listenable.merge(<Listenable>[checklist, model]),
|
|
builder: (BuildContext context, _) {
|
|
final List<ChecklistItem> live = model.connected ? AircraftChecklist.build(model) : const <ChecklistItem>[];
|
|
return ListView(padding: const EdgeInsets.only(bottom: 24), children: <Widget>[
|
|
PageHeader(
|
|
title: 'Pre-flight checklist',
|
|
trailing: TextButton(
|
|
key: const ValueKey<String>('checklist-reset'),
|
|
onPressed: checklist.done == 0 ? null : checklist.reset,
|
|
child: const Text('Reset'),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: LinearProgressIndicator(
|
|
value: checklist.done / checklist.items.length,
|
|
minHeight: 6,
|
|
backgroundColor: s.surfaceInset,
|
|
color: checklist.complete ? s.success : s.accent,
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(22, 0, 20, 4),
|
|
child: Text(
|
|
checklist.complete ? 'All checks done. Fly safe.' : '${checklist.done} of ${checklist.items.length} checks done',
|
|
style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: checklist.complete ? s.successFg : s.textSecondary),
|
|
),
|
|
),
|
|
_group(s, 'AIRCRAFT · LIVE'),
|
|
if (live.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(22, 0, 20, 8),
|
|
child: Text('Connect the aircraft to see its own checks here.', style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.textTertiary)),
|
|
)
|
|
else
|
|
for (final ChecklistItem i in live) _liveRow(s, i),
|
|
for (final String g in checklist.groups) ...<Widget>[
|
|
_group(s, g.toUpperCase()),
|
|
for (final PreflightItem i in checklist.items.where((PreflightItem x) => x.group == g)) _manualRow(s, i),
|
|
],
|
|
]);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
static Widget _group(PVScheme s, String text) => Padding(
|
|
padding: const EdgeInsets.fromLTRB(22, 16, 20, 6),
|
|
child: Text(text, style: TextStyle(fontFamily: PV.fontMono, fontSize: 10.5, letterSpacing: 1.2, color: s.textTertiary)),
|
|
);
|
|
|
|
Widget _liveRow(PVScheme s, ChecklistItem i) {
|
|
final Color c = switch (i.state) {
|
|
CheckState.ok => s.success,
|
|
CheckState.caution => s.warning,
|
|
CheckState.danger => s.danger,
|
|
CheckState.unknown => s.textTertiary,
|
|
};
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(22, 5, 20, 5),
|
|
child: Row(children: <Widget>[
|
|
Container(width: 9, height: 9, decoration: BoxDecoration(color: c, shape: BoxShape.circle)),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: Text(i.label, style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, color: s.textPrimary))),
|
|
Flexible(
|
|
child: Text(i.value, textAlign: TextAlign.right, overflow: TextOverflow.ellipsis, style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textSecondary)),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
|
|
Widget _manualRow(PVScheme s, PreflightItem i) {
|
|
final bool on = checklist.isTicked(i);
|
|
return InkWell(
|
|
key: ValueKey<String>('check-${i.id}'),
|
|
onTap: () => checklist.toggle(i),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
|
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
|
Container(
|
|
width: 22,
|
|
height: 22,
|
|
margin: const EdgeInsets.only(top: 1),
|
|
decoration: BoxDecoration(
|
|
color: on ? s.accent : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(color: on ? s.accent : s.borderStrong, width: 1.6),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: on ? const PVIcon('check', size: 14, stroke: 2.6, color: Colors.white) : null,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
|
Text(i.title,
|
|
style: TextStyle(
|
|
fontFamily: PV.fontSans,
|
|
fontSize: 14.5,
|
|
fontWeight: FontWeight.w600,
|
|
color: on ? s.textSecondary : s.textPrimary,
|
|
decoration: on ? TextDecoration.lineThrough : null,
|
|
)),
|
|
const SizedBox(height: 2),
|
|
Text(i.detail, style: TextStyle(fontFamily: PV.fontSans, fontSize: 12.5, height: 1.35, color: s.textSecondary)),
|
|
]),
|
|
),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|