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>
425 lines
12 KiB
Dart
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)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|