Files
PilotVault/Fly App/lib/ui/academy_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

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)),
]),
),
]),
),
);
}
}