Files
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

425 lines
12 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'dji_service.dart';
import 'login_page.dart';
import 'pb_service.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await pb.init();
runApp(const DjiSampleApp());
}
class DjiSampleApp extends StatelessWidget {
const DjiSampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'DJI MSDK Sample',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1565C0)),
useMaterial3: true,
),
home: const AuthGate(),
);
}
}
/// Shows the login screen until the user is authenticated with PocketBase.
class AuthGate extends StatefulWidget {
const AuthGate({super.key});
@override
State<AuthGate> createState() => _AuthGateState();
}
class _AuthGateState extends State<AuthGate> {
StreamSubscription<BackendStatus>? _sub;
@override
void initState() {
super.initState();
_sub = pb.status.listen((_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (pb.isAuthed) {
return const HomePage();
}
return LoginPage(onSignedIn: () => setState(() {}));
}
}
enum RegistrationState { idle, registering, success, failed }
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final DjiService _dji = DjiService();
StreamSubscription<Map<String, dynamic>>? _sub;
StreamSubscription<BackendStatus>? _backendSub;
BackendStatus _backend = BackendStatus.online;
String _sdkVersion = '…';
RegistrationState _registration = RegistrationState.idle;
String? _registrationError;
bool _connected = false;
String? _model;
int? _satellites;
bool? _isFlying;
String? _flightMode;
double? _altitude;
double? _latitude;
double? _longitude;
int? _batteryPercent;
@override
void initState() {
super.initState();
_backend = pb.currentStatus;
// Route server interactions through PocketBase.
pb.onCommand = _handleServerCommand;
pb.stateProvider = _currentState;
_backendSub = pb.status.listen((BackendStatus s) {
if (mounted) setState(() => _backend = s);
});
_init();
}
Future<void> _init() async {
_sub = _dji.events().listen(_onEvent, onError: (Object e) {
_snack('Event channel error: $e');
});
try {
final String version = await _dji.getSdkVersion();
if (mounted) setState(() => _sdkVersion = version);
} catch (_) {
if (mounted) setState(() => _sdkVersion = 'unavailable');
}
}
void _onEvent(Map<String, dynamic> event) {
if (!mounted) return;
// Persist every event to PocketBase (telemetry is throttled inside).
pb.onEvent(event);
switch (event['type'] as String?) {
case 'registration':
setState(() {
switch (event['state'] as String?) {
case 'registering':
_registration = RegistrationState.registering;
_registrationError = null;
break;
case 'success':
_registration = RegistrationState.success;
break;
case 'failed':
_registration = RegistrationState.failed;
_registrationError = event['error'] as String?;
break;
}
});
break;
case 'connection':
setState(() {
_connected = event['connected'] as bool? ?? false;
_model = event['model'] as String?;
if (!_connected) _clearTelemetry();
});
break;
case 'telemetry':
setState(() {
_satellites = event['satelliteCount'] as int?;
_isFlying = event['isFlying'] as bool?;
_flightMode = event['flightMode'] as String?;
_altitude = (event['altitude'] as num?)?.toDouble();
_latitude = (event['latitude'] as num?)?.toDouble();
_longitude = (event['longitude'] as num?)?.toDouble();
});
break;
case 'battery':
setState(() => _batteryPercent = event['percent'] as int?);
break;
}
}
void _clearTelemetry() {
_satellites = null;
_isFlying = null;
_flightMode = null;
_altitude = null;
_latitude = null;
_longitude = null;
_batteryPercent = null;
}
/// Current app state shared with PocketBase for the device presence record.
Map<String, dynamic> _currentState() => <String, dynamic>{
'connected': _connected,
'model': _model ?? '',
'registration': _registrationWire(),
};
String _registrationWire() {
switch (_registration) {
case RegistrationState.success:
return 'success';
case RegistrationState.registering:
return 'registering';
case RegistrationState.failed:
return 'failed';
case RegistrationState.idle:
return 'not registered';
}
}
Future<void> _register() async {
try {
await _dji.registerApp();
} catch (e) {
_snack('registerApp failed: $e');
}
}
Future<void> _connect() async {
try {
final bool started = await _dji.startConnection();
_snack(started ? 'Scanning for product…' : 'Could not start connection');
} catch (e) {
_snack('startConnection failed: $e');
}
}
void _handleServerCommand(String command, Map<String, dynamic> payload) {
switch (command) {
case 'registerApp':
_register();
break;
case 'startConnection':
_connect();
break;
case 'stopConnection':
_dji.stopConnection();
break;
default:
_snack('Unknown server command: $command');
return;
}
_snack('Server command: $command');
}
Future<void> _logout() async {
await pb.signOut();
if (mounted) {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute<void>(
builder: (_) => const AuthGate(),
),
(Route<dynamic> route) => false,
);
}
}
void _snack(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(msg)));
}
@override
void dispose() {
_sub?.cancel();
_backendSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('DJI MSDK Sample'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: <Widget>[
IconButton(
tooltip: 'Log out',
onPressed: _logout,
icon: const Icon(Icons.logout),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
_backendCard(),
const SizedBox(height: 12),
_registrationCard(),
const SizedBox(height: 12),
_connectionCard(),
const SizedBox(height: 12),
_telemetryCard(),
],
),
);
}
Widget _backendCard() {
final (Color color, String label) = switch (_backend) {
BackendStatus.online => (Colors.green, 'Streaming to PocketBase'),
BackendStatus.signingIn => (Colors.orange, 'Connecting…'),
BackendStatus.error => (Colors.red, 'Backend error'),
BackendStatus.signedOut => (Colors.grey, 'Signed out'),
};
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: <Widget>[
Icon(Icons.cloud_done, color: color),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(label, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 2),
Text('${pb.userEmail} · device "${pb.deviceId}"',
style: const TextStyle(color: Colors.black54, fontSize: 12)),
],
),
),
TextButton.icon(
onPressed: _logout,
icon: const Icon(Icons.logout),
label: const Text('Log out'),
),
],
),
),
);
}
Widget _registrationCard() {
final (Color color, IconData icon, String label) = switch (_registration) {
RegistrationState.idle => (Colors.grey, Icons.help_outline, 'Not registered'),
RegistrationState.registering => (Colors.orange, Icons.sync, 'Registering…'),
RegistrationState.success => (Colors.green, Icons.verified, 'Registered'),
RegistrationState.failed => (Colors.red, Icons.error_outline, 'Registration failed'),
};
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Icon(icon, color: color),
const SizedBox(width: 8),
Text(label, style: Theme.of(context).textTheme.titleMedium),
],
),
const SizedBox(height: 4),
Text('SDK version: $_sdkVersion'),
if (_registrationError != null) ...<Widget>[
const SizedBox(height: 4),
Text(_registrationError!, style: const TextStyle(color: Colors.red)),
],
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _registration == RegistrationState.registering ? null : _register,
icon: const Icon(Icons.app_registration),
label: const Text('Register app'),
),
],
),
),
);
}
Widget _connectionCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Icon(_connected ? Icons.link : Icons.link_off,
color: _connected ? Colors.green : Colors.grey),
const SizedBox(width: 8),
Text(_connected ? 'Product connected' : 'No product',
style: Theme.of(context).textTheme.titleMedium),
],
),
const SizedBox(height: 4),
Text('Model: ${_model ?? '—'}'),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _registration == RegistrationState.success ? _connect : null,
icon: const Icon(Icons.usb),
label: const Text('Connect to product'),
),
],
),
),
);
}
Widget _telemetryCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('Telemetry', style: Theme.of(context).textTheme.titleMedium),
const Divider(),
_row('Battery', _batteryPercent == null ? '—' : '$_batteryPercent%'),
_row('GPS satellites', _satellites?.toString() ?? '—'),
_row('Flight mode', _flightMode ?? '—'),
_row('Flying', _isFlying == null ? '—' : (_isFlying! ? 'yes' : 'no')),
_row('Altitude', _altitude == null ? '—' : '${_altitude!.toStringAsFixed(1)} m'),
_row('Latitude', _latitude?.toStringAsFixed(6) ?? '—'),
_row('Longitude', _longitude?.toStringAsFixed(6) ?? '—'),
],
),
),
);
}
Widget _row(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(label, style: const TextStyle(color: Colors.black54)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
);
}
}