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>
475 lines
16 KiB
Dart
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;
|
|
}
|