import 'dart:async'; import 'package:flutter/material.dart'; import 'dji_service.dart'; import 'login_page.dart'; import 'pb_service.dart'; Future 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 createState() => _AuthGateState(); } class _AuthGateState extends State { StreamSubscription? _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 createState() => _HomePageState(); } class _HomePageState extends State { final DjiService _dji = DjiService(); StreamSubscription>? _sub; StreamSubscription? _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 _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 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 _currentState() => { '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 _register() async { try { await _dji.registerApp(); } catch (e) { _snack('registerApp failed: $e'); } } Future _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 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 _logout() async { await pb.signOut(); if (mounted) { Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( builder: (_) => const AuthGate(), ), (Route 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: [ IconButton( tooltip: 'Log out', onPressed: _logout, icon: const Icon(Icons.logout), ), ], ), body: ListView( padding: const EdgeInsets.all(16), children: [ _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: [ Icon(Icons.cloud_done, color: color), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ 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: [ Row( children: [ 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) ...[ 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: [ Row( children: [ 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: [ 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: [ Text(label, style: const TextStyle(color: Colors.black54)), Text(value, style: const TextStyle(fontWeight: FontWeight.w600)), ], ), ); } }