import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:geolocator/geolocator.dart'; import 'dji_service.dart'; import 'flight_model.dart'; import 'login_page.dart'; import 'pb_auth.dart'; import 'theme.dart'; import 'ui/album_page.dart'; import 'ui/flight_control_page.dart'; import 'ui/go_fly_page.dart'; import 'uploader.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); await auth.init(); runApp(const DjiSampleApp()); } class DjiSampleApp extends StatelessWidget { const DjiSampleApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'PilotVault', theme: PV.theme(), // The app is usable without signing in; login lives behind the user panel. home: const HomePage(), ); } } class HomePage extends StatefulWidget { const HomePage({super.key}); @override State createState() => _HomePageState(); } class _HomePageState extends State { final DjiService _dji = DjiService(); final FlightModel _model = FlightModel(); StreamSubscription>? _sub; StreamSubscription? _authSub; StreamSubscription? _phoneLocSub; // Streams telemetry to the API Server and receives commands back. late final ServerUploader _uploader; StreamSubscription? _uploadSub; final TextEditingController _serverHost = TextEditingController(text: '10.2.1.101:8080'); @override void initState() { super.initState(); _uploader = ServerUploader( onCommand: _handleServerCommand, snapshotProvider: _buildStateSnapshot, ); _uploadSub = _uploader.status.listen((UploadStatus s) { _model.upload = s; _model.bump(); }); // Rebuild when the session changes so the user panel reflects sign-in/out. _authSub = auth.status.listen((_) { if (mounted) setState(() {}); }); _init(); _startPhoneLocation(); } /// Streams the phone's own GPS (coarse, low-frequency) and reports it to the /// server as a telemetry field — a location fallback for the Web App's auto /// bounding box when the drone has no fix. Best-effort: silently gives up if /// location services or permission are unavailable. Future _startPhoneLocation() async { try { if (!await Geolocator.isLocationServiceEnabled()) return; LocationPermission perm = await Geolocator.checkPermission(); if (perm == LocationPermission.denied) { perm = await Geolocator.requestPermission(); } if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) { return; } _phoneLocSub = Geolocator.getPositionStream( locationSettings: const LocationSettings( accuracy: LocationAccuracy.low, // country-level is all the bbox needs distanceFilter: 1000, // metres — infrequent updates ), ).listen((Position pos) { _model.phoneLatitude = pos.latitude; _model.phoneLongitude = pos.longitude; _model.bump(); // Report to the server (rides the existing telemetry channel; only the // phone fields are present, so it never disturbs drone telemetry). _uploader.onEvent({ 'type': 'telemetry', 'phoneLatitude': pos.latitude, 'phoneLongitude': pos.longitude, }); }); } catch (_) { // Location plugin/permission unavailable — non-fatal. } } Future _init() async { _sub = _dji.events().listen(_onEvent, onError: (Object e) { _snack('Event channel error: $e'); }); try { final String version = await _dji.getSdkVersion(); _model.sdkVersion = version; _model.bump(); } catch (_) { _model.sdkVersion = 'unavailable'; _model.bump(); } } void _onEvent(Map event) { if (!mounted) return; // Forward every event to the API server (throttled internally). _uploader.onEvent(event); switch (event['type'] as String?) { case 'registration': switch (event['state'] as String?) { case 'registering': _model.registration = RegistrationState.registering; _model.registrationError = null; break; case 'success': _model.registration = RegistrationState.success; break; case 'failed': _model.registration = RegistrationState.failed; _model.registrationError = event['error'] as String?; break; } _model.bump(); break; case 'connection': _model.connected = event['connected'] as bool? ?? false; _model.model = event['model'] as String?; if (!_model.connected) _clearTelemetry(); _model.bump(); break; case 'telemetry': // Ignore stray telemetry that arrives after a disconnect — otherwise it // repopulates values _clearTelemetry() just wiped, leaving stale readings. if (!_model.connected) break; _model.satellites = event['satelliteCount'] as int?; _model.isFlying = event['isFlying'] as bool?; _model.flightMode = event['flightMode'] as String?; _model.altitude = (event['altitude'] as num?)?.toDouble(); _model.latitude = (event['latitude'] as num?)?.toDouble(); _model.longitude = (event['longitude'] as num?)?.toDouble(); _model.bump(); break; case 'battery': // Same guard: a battery packet trailing a disconnect must not revive the // last percentage (the reported "disconnected but still 42%" bug). if (!_model.connected) break; _model.batteryPercent = event['percent'] as int?; _model.bump(); break; } } void _clearTelemetry() { _model.satellites = null; _model.isFlying = null; _model.flightMode = null; _model.altitude = null; _model.latitude = null; _model.longitude = null; _model.batteryPercent = null; } 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 _snack(String msg) { if (!mounted) return; ScaffoldMessenger.of(context) ..clearSnackBars() ..showSnackBar(SnackBar(content: Text(msg))); } // ── API server upload ────────────────────────────────────────────────────── Future _toggleUpload() async { if (_model.upload == UploadStatus.disabled) { await _uploader.enable(_serverHost.text); } else { await _uploader.disable(); } } /// Builds events describing the app's CURRENT state, sent to the server right /// after (re)connecting so it always reflects reality. List> _buildStateSnapshot() { final List> events = >[ {'type': 'registration', 'state': _registrationWire()}, {'type': 'connection', 'connected': _model.connected, 'model': _model.model ?? ''}, ]; if (_model.batteryPercent != null) { events.add({'type': 'battery', 'percent': _model.batteryPercent}); } final Map tel = {'type': 'telemetry'}; if (_model.satellites != null) tel['satelliteCount'] = _model.satellites; if (_model.isFlying != null) tel['isFlying'] = _model.isFlying; if (_model.flightMode != null) tel['flightMode'] = _model.flightMode; if (_model.altitude != null) tel['altitude'] = _model.altitude; if (_model.latitude != null) tel['latitude'] = _model.latitude; if (_model.longitude != null) tel['longitude'] = _model.longitude; if (_model.phoneLatitude != null) tel['phoneLatitude'] = _model.phoneLatitude; if (_model.phoneLongitude != null) tel['phoneLongitude'] = _model.phoneLongitude; if (tel.length > 1) events.add(tel); return events; } String _registrationWire() { switch (_model.registration) { case RegistrationState.success: return 'success'; case RegistrationState.registering: return 'registering'; case RegistrationState.failed: return 'failed'; case RegistrationState.idle: return 'not registered'; } } /// Handles commands pushed down from the server. 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'); } @override void dispose() { _sub?.cancel(); _authSub?.cancel(); _phoneLocSub?.cancel(); _uploadSub?.cancel(); _uploader.dispose(); _serverHost.dispose(); _model.dispose(); super.dispose(); } /// Opens the login page as a modal popup (from the user panel). Returns to the /// caller once dismissed; the auth listener refreshes the UI on success. Future _openLogin() async { await Navigator.of(context).push(MaterialPageRoute( fullscreenDialog: true, builder: (BuildContext ctx) => LoginPage(onSignedIn: () => Navigator.of(ctx).pop()), )); } // ── Navigation ─────────────────────────────────────────────────────────── void _goFly() { Navigator.of(context).push(MaterialPageRoute( builder: (_) => FlightControlPage(model: _model), )); } void _openAlbum() { Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AlbumPage())); } void _onTile(String tile) => _snack('$tile — coming soon'); // ── UI ─────────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { return GoFlyPage( model: _model, onGoFly: _goFly, onOpenAlbum: _openAlbum, onSettings: _openSettings, onTile: _onTile, ); } /// Aircraft/connection controls that don't appear on the design's launch /// screen live here: MSDK registration, product connection, and telemetry /// streaming to the API Server, plus account sign-out. void _openSettings() { final PVScheme s = PVScheme.of(context); showModalBottomSheet( context: context, backgroundColor: s.surface, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (BuildContext context) { return AnimatedBuilder( animation: _model, builder: (BuildContext context, _) { final (Color, String) reg = switch (_model.registration) { RegistrationState.idle => (s.textTertiary, 'Not registered'), RegistrationState.registering => (s.warning, 'Registering…'), RegistrationState.success => (s.success, 'Registered'), RegistrationState.failed => (s.danger, 'Registration failed'), }; final (Color, String) up = switch (_model.upload) { UploadStatus.disabled => (s.textTertiary, 'Off'), UploadStatus.connecting => (s.warning, 'Connecting…'), UploadStatus.connected => (s.success, 'Streaming'), UploadStatus.error => (s.danger, 'Retrying…'), }; final bool streaming = _model.upload != UploadStatus.disabled; return Padding( padding: EdgeInsets.fromLTRB(20, 12, 20, 20 + MediaQuery.of(context).viewInsets.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Center( child: Container( width: 40, height: 4, decoration: BoxDecoration(color: s.borderStrong, borderRadius: BorderRadius.circular(999)), ), ), const SizedBox(height: 16), // Account — signed-in identity + sign-out, or a sign-in entry // that opens the login page (biometric / face live there). Row( children: [ Expanded( child: Text( auth.isAuthed ? (auth.userEmail.isEmpty ? 'Signed in' : auth.userEmail) : 'Not signed in', style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, fontWeight: FontWeight.w600, color: s.textPrimary), ), ), if (auth.isAuthed) TextButton.icon( onPressed: () { Navigator.of(context).pop(); auth.signOut(); }, icon: Icon(Icons.logout, size: 16, color: s.textSecondary), label: Text('Sign out', style: TextStyle(color: s.textSecondary)), ) else FilledButton.icon( onPressed: () { Navigator.of(context).pop(); _openLogin(); }, icon: const Icon(Icons.login, size: 16), label: const Text('Sign in'), ), ], ), // Registration, aircraft and telemetry controls require a // session — they only appear once the user has signed in. if (auth.isAuthed) ...[ Divider(color: s.border, height: 24), _settingRow(s, 'Registration', reg.$1, reg.$2, trailing: 'MSDK · ${_model.sdkVersion}'), if (_model.registrationError != null) ...[ const SizedBox(height: 6), Text(_model.registrationError!, style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.danger)), ], const SizedBox(height: 10), FilledButton( onPressed: _model.registration == RegistrationState.registering ? null : _register, child: const Text('Register app'), ), const SizedBox(height: 20), _settingRow(s, 'Aircraft', _model.connected ? s.success : s.textTertiary, _model.connected ? 'Connected' : 'No product', trailing: _model.model ?? '—'), const SizedBox(height: 10), OutlinedButton.icon( onPressed: _model.registered ? _connect : null, icon: const Icon(Icons.usb, size: 18), label: const Text('Connect to product'), ), const SizedBox(height: 20), _settingRow(s, 'Telemetry stream', up.$1, up.$2), const SizedBox(height: 10), TextField( controller: _serverHost, enabled: !streaming, style: PV.body, decoration: const InputDecoration(labelText: 'API Server host', hintText: '10.2.1.101:8080'), ), const SizedBox(height: 10), FilledButton.icon( style: FilledButton.styleFrom(backgroundColor: streaming ? s.danger : s.accent), onPressed: _model.upload == UploadStatus.connecting ? null : _toggleUpload, icon: Icon(streaming ? Icons.stop : Icons.play_arrow, size: 18), label: Text(streaming ? 'Stop streaming' : 'Start streaming'), ), ] else ...[ const SizedBox(height: 14), Text( 'Sign in to manage registration, aircraft connection and telemetry streaming.', style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.textSecondary), ), ], ], ), ); }, ); }, ); } Widget _settingRow(PVScheme s, String title, Color dot, String status, {String? trailing}) { return Row( children: [ Container(width: 8, height: 8, decoration: BoxDecoration(color: dot, shape: BoxShape.circle)), const SizedBox(width: 10), Text(title, style: TextStyle(fontFamily: PV.fontSans, fontSize: 15, fontWeight: FontWeight.w600, color: s.textPrimary)), const SizedBox(width: 8), Text(status, style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.textSecondary)), const Spacer(), if (trailing != null) Text(trailing, style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textTertiary)), ], ); } }