Files
PilotVault/Fly App/lib/ui/flight_control_page.dart
T
tajniak81andClaude Opus 4.8 afc6952eda Initial commit: PilotVault multi-service project
Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App
(Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment
configs. Design assets and build artifacts are gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:43:33 +02:00

475 lines
16 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../flight_model.dart';
import '../theme.dart';
import 'dji_video_view.dart';
/// Landscape live-flight overlay — mirrors the ui_kit/fly "Flight control ·
/// landscape" mockup. Locks to landscape while shown and restores portrait on
/// exit. Camera-feed HUD is composited over a painted placeholder feed; real
/// telemetry (satellites, battery, altitude, mode) is bound where available.
class FlightControlPage extends StatefulWidget {
const FlightControlPage({super.key, required this.model});
final FlightModel model;
@override
State<FlightControlPage> createState() => _FlightControlPageState();
}
class _FlightControlPageState extends State<FlightControlPage> {
Timer? _recTimer;
int _recSeconds = 0;
String _mode = 'Video';
@override
void initState() {
super.initState();
SystemChrome.setPreferredOrientations(<DeviceOrientation>[
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
_recTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() => _recSeconds++);
});
}
@override
void dispose() {
_recTimer?.cancel();
SystemChrome.setPreferredOrientations(<DeviceOrientation>[DeviceOrientation.portraitUp]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
String get _recLabel {
final String mm = (_recSeconds ~/ 60).toString().padLeft(2, '0');
final String ss = (_recSeconds % 60).toString().padLeft(2, '0');
return '$mm:$ss';
}
@override
Widget build(BuildContext context) {
final FlightModel m = widget.model;
return Scaffold(
backgroundColor: const Color(0xFF0A1120),
body: AnimatedBuilder(
animation: m,
builder: (BuildContext context, _) {
return Stack(
children: <Widget>[
// Background: live DJI camera feed when a product is connected,
// painted placeholder otherwise. The HUD layers below paint over
// it since they come later in the stack.
Positioned.fill(
child: m.connected
? const DjiVideoView()
: const CustomPaint(painter: _FeedPainter()),
),
// Center reticle
const Center(child: Icon(Icons.add, size: 30, color: Color(0xB3FFFFFF))),
// Top bar
Positioned(
top: 12,
left: 14,
right: 14,
child: _topBar(m),
),
// Left rail
Positioned(
left: 14,
top: 58,
child: Column(
children: <Widget>[
_sideBtn(Icons.control_camera, active: true),
const SizedBox(height: 10),
_sideBtn(Icons.wb_sunny_outlined),
const SizedBox(height: 10),
_sideBtn(Icons.camera_outlined),
const SizedBox(height: 10),
_sideBtn(Icons.grid_on),
],
),
),
// Gimbal pitch slider
Positioned(
left: 70,
top: 58,
bottom: 96,
child: _gimbalSlider(),
),
// Right camera controls
Positioned(
right: 16,
top: 0,
bottom: 0,
child: Center(child: _cameraControls()),
),
// Bottom-left minimap
Positioned(left: 14, bottom: 12, child: _minimap()),
// Bottom-center telemetry
Positioned(
bottom: 14,
left: 0,
right: 0,
child: Center(child: _telemetry(m)),
),
// RTH button
Positioned(right: 92, bottom: 20, child: _rthButton()),
],
);
},
),
);
}
// ── Top bar ──────────────────────────────────────────────────────────────
Widget _topBar(FlightModel m) {
return Row(
children: <Widget>[
GestureDetector(
onTap: () => Navigator.of(context).maybePop(),
child: _pill(child: const Icon(Icons.chevron_left, size: 16, color: Glass.ink)),
),
const SizedBox(width: 8),
Container(
height: 26,
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(color: Glass.accent, borderRadius: BorderRadius.circular(8)),
alignment: Alignment.center,
child: Text(
(m.flightMode != null && m.flightMode!.isNotEmpty) ? m.flightMode! : 'N',
style: const TextStyle(fontFamily: PV.fontSans, fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 0.4, color: Colors.white),
),
),
const SizedBox(width: 8),
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
const Icon(Icons.satellite_alt, size: 14, color: Glass.sat),
const SizedBox(width: 4),
_mono(m.satellites?.toString() ?? '0'),
])),
const SizedBox(width: 8),
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: const <Widget>[
Icon(Icons.sensors, size: 14, color: Glass.ink),
SizedBox(width: 4),
Text('HD', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink)),
])),
const Spacer(),
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
_mono('REC'),
const SizedBox(width: 6),
Container(width: 7, height: 7, decoration: const BoxDecoration(color: Glass.rec, shape: BoxShape.circle)),
const SizedBox(width: 6),
_mono(_recLabel),
])),
const SizedBox(width: 8),
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
const Icon(Icons.battery_full, size: 16, color: Glass.sat),
const SizedBox(width: 4),
_mono(m.batteryPercent == null ? '—' : '${m.batteryPercent}%'),
])),
const SizedBox(width: 8),
_pill(child: const Icon(Icons.settings, size: 16, color: Glass.ink)),
],
);
}
// ── Reusable glass pieces ────────────────────────────────────────────────
Widget _pill({required Widget child}) {
return Container(
height: 26,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(8)),
alignment: Alignment.center,
child: child,
);
}
Widget _mono(String t) => Text(t, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink));
Widget _sideBtn(IconData icon, {bool active = false}) {
return Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: active ? Glass.accent : Glass.pill,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Glass.hairline),
),
child: Icon(icon, size: 20, color: Glass.ink),
);
}
Widget _gimbalSlider() {
return SizedBox(
width: 16,
child: Stack(
alignment: Alignment.topCenter,
children: <Widget>[
Container(width: 6, decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6))),
const Align(
alignment: Alignment(0, -0.24),
child: _Thumb(),
),
],
),
);
}
Widget _cameraControls() {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0x99FFFFFF), width: 2),
gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: <Color>[Color(0xFF2A4E86), Color(0xFF12201A)]),
),
),
const SizedBox(height: 14),
// Shutter
Container(
width: 62,
height: 62,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: const Color(0xD9FFFFFF), width: 4),
),
child: Center(
child: Container(
width: 26,
height: 26,
decoration: BoxDecoration(color: Glass.rec, borderRadius: BorderRadius.circular(7)),
),
),
),
const SizedBox(height: 14),
// Mode switch
Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)),
child: Column(
children: <Widget>[
_modeBtn(Icons.photo_outlined, 'Photo'),
_modeBtn(Icons.videocam_outlined, 'Video'),
_modeBtn(Icons.panorama_outlined, 'Pano'),
],
),
),
],
);
}
Widget _modeBtn(IconData icon, String mode) {
final bool active = _mode == mode;
return GestureDetector(
onTap: () => setState(() => _mode = mode),
child: Container(
width: 40,
height: 30,
margin: const EdgeInsets.symmetric(vertical: 1),
decoration: BoxDecoration(
color: active ? Glass.accent : Colors.transparent,
borderRadius: BorderRadius.circular(7),
),
child: Icon(icon, size: 17, color: Glass.ink),
),
);
}
Widget _minimap() {
return Container(
width: 148,
height: 78,
decoration: BoxDecoration(
color: Glass.pillStrong,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Glass.hairline),
),
child: Stack(
children: <Widget>[
const Positioned.fill(child: CustomPaint(painter: _MinimapPainter())),
const Positioned(
top: 6,
left: 8,
child: Row(children: <Widget>[
Icon(Icons.home_outlined, size: 12, color: Glass.ink),
SizedBox(width: 5),
Text('RTH 340m', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Glass.ink)),
]),
),
],
),
);
}
Widget _telemetry(FlightModel m) {
final List<(String, String, String)> fields = <(String, String, String)>[
('H', m.altitude == null ? '—' : m.altitude!.toStringAsFixed(1), 'm'),
('D', '—', 'm'),
('H.S', '—', 'm/s'),
('V.S', '—', 'm/s'),
];
return Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(12)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
for (int i = 0; i < fields.length; i++) ...<Widget>[
if (i > 0) const SizedBox(width: 20),
Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(fields[i].$1, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 0.8, color: Color(0x99EAF0FA))),
const SizedBox(height: 2),
Text.rich(TextSpan(children: <TextSpan>[
TextSpan(text: fields[i].$2, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 15, fontWeight: FontWeight.w700, color: Glass.ink)),
TextSpan(text: ' ${fields[i].$3}', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Color(0x99EAF0FA))),
])),
],
),
],
],
),
);
}
Widget _rthButton() {
return Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Glass.pillStrong,
shape: BoxShape.circle,
border: Border.all(color: const Color(0x2EFFFFFF)),
),
child: const Icon(Icons.home_outlined, size: 20, color: Glass.ink),
);
}
}
class _Thumb extends StatelessWidget {
const _Thumb();
@override
Widget build(BuildContext context) {
return Container(
width: 16,
height: 16,
decoration: const BoxDecoration(
color: Glass.ink,
shape: BoxShape.circle,
boxShadow: <BoxShadow>[BoxShadow(color: Color(0x80000000), blurRadius: 3, offset: Offset(0, 1))],
),
);
}
}
/// Painted placeholder camera feed: graded sky→ground, perspective grid, haze,
/// distant buildings, and a yellow tracked-subject bracket.
class _FeedPainter extends CustomPainter {
const _FeedPainter();
@override
void paint(Canvas canvas, Size size) {
final double w = size.width, h = size.height;
final double horizon = h * 0.52;
// Sky → ground gradient.
final Rect full = Offset.zero & size;
final Paint sky = Paint()
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[Color(0xFF2A4E86), Color(0xFF3E6199), Color(0xFF1C2A1E), Color(0xFF0E1710)],
stops: <double>[0.0, 0.51, 0.54, 1.0],
).createShader(full);
canvas.drawRect(full, sky);
// Horizon haze.
final Paint haze = Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[const Color(0x808FB4D8), const Color(0x008FB4D8)],
).createShader(Rect.fromLTWH(0, horizon - 30, w, 60));
canvas.drawRect(Rect.fromLTWH(0, horizon - 30, w, 60), haze);
// Distant buildings just under the horizon.
final Paint bld = Paint()..color = const Color(0xE612201A);
void building(double x, double y, double bw, double bh) => canvas.drawRect(Rect.fromLTWH(x * w, horizon + y, bw, bh), bld);
building(0.10, -40, 46, 40);
building(0.17, -52, 30, 52);
building(0.74, -46, 54, 46);
building(0.83, -34, 34, 34);
// Perspective ground grid.
final Paint grid = Paint()
..color = const Color(0x297FE0B0)
..strokeWidth = 1;
for (int i = 1; i <= 5; i++) {
final double t = i / 5.0;
final double y = horizon + (h - horizon) * t * t;
canvas.drawLine(Offset(0, y), Offset(w, y), grid);
}
final double vx = w / 2;
for (int k = -6; k <= 6; k += 2) {
final double bx = vx + k * (w * 0.16);
canvas.drawLine(Offset(vx + k * 10, horizon), Offset(bx, h), grid);
}
// Tracked-subject bracket, centered.
final Paint subj = Paint()
..color = Glass.subject
..style = PaintingStyle.stroke
..strokeWidth = 2;
final double bxw = 118, bxh = 82;
final Rect box = Rect.fromCenter(center: Offset(vx, horizon + 8), width: bxw, height: bxh);
const double c = 14;
// Four corner brackets.
canvas.drawPath(Path()..moveTo(box.left + c, box.top)..lineTo(box.left, box.top)..lineTo(box.left, box.top + c), subj);
canvas.drawPath(Path()..moveTo(box.right - c, box.top)..lineTo(box.right, box.top)..lineTo(box.right, box.top + c), subj);
canvas.drawPath(Path()..moveTo(box.left + c, box.bottom)..lineTo(box.left, box.bottom)..lineTo(box.left, box.bottom - c), subj);
canvas.drawPath(Path()..moveTo(box.right - c, box.bottom)..lineTo(box.right, box.bottom)..lineTo(box.right, box.bottom - c), subj);
}
@override
bool shouldRepaint(covariant _FeedPainter oldDelegate) => false;
}
class _MinimapPainter extends CustomPainter {
const _MinimapPainter();
@override
void paint(Canvas canvas, Size size) {
final Path route = Path()
..moveTo(20, 60)
..cubicTo(50, 40, 70, 30, 120, 24);
final Paint line = Paint()
..color = const Color(0xFF5B93F5)
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawPath(route, line);
canvas.drawCircle(const Offset(20, 60), 4, Paint()..color = Glass.sat);
canvas.drawCircle(const Offset(120, 24), 4, Paint()..color = const Color(0xFF5B93F5));
}
@override
bool shouldRepaint(covariant _MinimapPainter oldDelegate) => false;
}