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>
This commit is contained in:
tajniak81
2026-07-13 11:43:33 +02:00
co-authored by Claude Opus 4.8
commit afc6952eda
172 changed files with 24591 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
import 'package:local_auth/local_auth.dart';
/// Thin wrapper over [LocalAuthentication] for the login screen. Exposes what
/// the device can do (fingerprint vs. face) and a single [authenticate] call.
class BiometricAuth {
final LocalAuthentication _auth = LocalAuthentication();
bool _supported = false;
bool _canCheck = false;
List<BiometricType> _types = const <BiometricType>[];
/// Whether any biometric login can be offered (device supports it and the
/// user has at least one biometric enrolled).
bool get available => _supported && _canCheck;
/// The device advertises a face enrolment. On some Androids the platform only
/// reports weak/strong instead of the specific modality — see [showFace].
bool get hasFace => _types.contains(BiometricType.face);
bool get _hasFingerprint =>
_types.contains(BiometricType.fingerprint) ||
_types.contains(BiometricType.strong) ||
_types.contains(BiometricType.weak);
/// Show the face button when face is reported, or when the device supports
/// biometrics but reports no specific modality (BiometricPrompt still lets the
/// user use whatever strong biometric — often face — is enrolled).
bool get showFace => available && (hasFace || _types.isEmpty);
/// Show the fingerprint button when fingerprint is reported, or as the generic
/// fallback when no specific modality is advertised.
bool get showFingerprint => available && (_hasFingerprint || _types.isEmpty);
/// Refreshes the capability flags. Safe to call repeatedly.
Future<void> refresh() async {
try {
_supported = await _auth.isDeviceSupported();
_canCheck = await _auth.canCheckBiometrics;
_types = _supported ? await _auth.getAvailableBiometrics() : const <BiometricType>[];
} catch (_) {
_supported = false;
_canCheck = false;
_types = const <BiometricType>[];
}
}
/// Prompts the OS biometric sheet. Returns true only on a verified match.
Future<bool> authenticate({required String reason}) {
return _auth.authenticate(
localizedReason: reason,
options: const AuthenticationOptions(
biometricOnly: true,
stickyAuth: true,
useErrorDialogs: true,
),
);
}
}
+39
View File
@@ -0,0 +1,39 @@
import 'package:flutter/services.dart';
/// Thin Dart wrapper over the native DJI Mobile SDK bridge.
///
/// Mirrors the channels defined in `DjiSdkBridge.kt`:
/// * method channel `dji_msdk/methods` for imperative calls
/// * event channel `dji_msdk/events` for the SDK's async updates
class DjiService {
static const MethodChannel _methods = MethodChannel('dji_msdk/methods');
static const EventChannel _events = EventChannel('dji_msdk/events');
/// Broadcast stream of SDK events. Each event is a map with a `type` key:
/// `registration`, `connection`, `telemetry`, `battery`, `database`, `init`.
Stream<Map<String, dynamic>> events() {
return _events
.receiveBroadcastStream()
.map((dynamic e) => Map<String, dynamic>.from(e as Map));
}
Future<String> getSdkVersion() async {
return await _methods.invokeMethod<String>('getSdkVersion') ?? 'unknown';
}
/// Kicks off DJI app registration (requires a valid App Key + internet).
Future<void> registerApp() => _methods.invokeMethod<void>('registerApp');
/// Starts scanning for a connected product (USB remote controller / Wi-Fi).
Future<bool> startConnection() async {
return await _methods.invokeMethod<bool>('startConnection') ?? false;
}
Future<void> stopConnection() =>
_methods.invokeMethod<void>('stopConnection');
Future<Map<String, dynamic>> getProductInfo() async {
final dynamic info = await _methods.invokeMethod('getProductInfo');
return Map<String, dynamic>.from(info as Map);
}
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter/foundation.dart';
import 'uploader.dart';
enum RegistrationState { idle, registering, success, failed }
/// Live aircraft/session state, shared by the Go Fly launch screen and the
/// Flight Control overlay. [_HomePageState] owns the DJI/uploader plumbing and
/// pushes updates here; the screens observe it via [AnimatedBuilder].
class FlightModel extends ChangeNotifier {
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;
UploadStatus upload = UploadStatus.disabled;
bool get registered => registration == RegistrationState.success;
/// Notify observers after a batch of field writes.
void bump() => notifyListeners();
}
+260
View File
@@ -0,0 +1,260 @@
import 'package:flutter/material.dart';
import 'biometric_auth.dart';
import 'pb_auth.dart';
import 'theme.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key, required this.onSignedIn});
final VoidCallback onSignedIn;
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
late final TextEditingController _server =
TextEditingController(text: auth.serverUrl);
final TextEditingController _email = TextEditingController();
final TextEditingController _password = TextEditingController();
final BiometricAuth _bio = BiometricAuth();
bool _busy = false;
bool _showServer = false;
bool _obscurePassword = true;
String? _error;
@override
void initState() {
super.initState();
_bio.refresh().then((_) {
if (mounted) setState(() {});
});
}
Future<void> _signIn() async {
setState(() {
_busy = true;
_error = null;
});
try {
await auth.signIn(
serverUrl: _server.text,
email: _email.text,
password: _password.text,
);
widget.onSignedIn();
} catch (e) {
setState(() => _error = _friendly(e));
} finally {
if (mounted) setState(() => _busy = false);
}
}
/// Runs a device biometric / face check, then replays the remembered login.
Future<void> _biometricSignIn({required bool face}) async {
if (!auth.hasRememberedAccount) {
setState(() => _error = 'Sign in with your password once to enable ${face ? 'face' : 'biometric'} login.');
return;
}
setState(() {
_busy = true;
_error = null;
});
try {
final bool ok = await _bio.authenticate(
reason: face ? 'Confirm your face to sign in to PilotVault' : 'Confirm your fingerprint to sign in to PilotVault',
);
if (!ok) {
if (mounted) setState(() => _busy = false);
return;
}
await auth.signInWithRememberedCredentials();
widget.onSignedIn();
} catch (e) {
if (mounted) setState(() => _error = _friendly(e));
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _forgetAccount() async {
await auth.forgetAccount();
if (mounted) setState(() {});
}
String _friendly(Object e) {
final String s = e.toString();
if (s.contains('SocketException') || s.contains('Failed host lookup') || s.contains('Connection refused')) {
return 'Cannot reach the API server. Check the address under "Server settings".';
}
return s;
}
@override
void dispose() {
_server.dispose();
_email.dispose();
_password.dispose();
super.dispose();
}
/// Biometric / face quick-login controls, shown only when the device supports
/// biometrics. Buttons are enabled once an account has been remembered.
List<Widget> _biometricSection() {
final bool ready = auth.hasRememberedAccount;
return <Widget>[
const SizedBox(height: 16),
Row(
children: <Widget>[
Expanded(child: Divider(color: PV.inkMuted.withValues(alpha: 0.3))),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text('or', style: PV.caption.copyWith(color: PV.inkMuted)),
),
Expanded(child: Divider(color: PV.inkMuted.withValues(alpha: 0.3))),
],
),
const SizedBox(height: 12),
Row(
children: <Widget>[
if (_bio.showFingerprint)
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _biometricSignIn(face: false),
icon: const Icon(Icons.fingerprint, size: 20),
label: const Text('Fingerprint'),
),
),
if (_bio.showFingerprint && _bio.showFace) const SizedBox(width: 10),
if (_bio.showFace)
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _biometricSignIn(face: true),
icon: const Icon(Icons.face_outlined, size: 20),
label: const Text('Face'),
),
),
],
),
if (ready) ...<Widget>[
const SizedBox(height: 6),
Center(
child: TextButton(
onPressed: _busy ? null : _forgetAccount,
child: Text(
'Use ${auth.rememberedEmail.isEmpty ? "biometrics" : auth.rememberedEmail} · Forget account',
style: PV.caption.copyWith(color: PV.inkMuted),
),
),
),
] else ...<Widget>[
const SizedBox(height: 6),
Center(
child: Text(
'Sign in once to enable biometric login',
style: PV.caption.copyWith(color: PV.inkMuted),
),
),
],
];
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: PvPanel(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Row(
children: <Widget>[
const PvBrandMark(size: 34),
const SizedBox(width: 12),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: const <Widget>[
Text('PilotVault', style: PV.mode),
Text('FLY APP', style: PV.label),
],
),
],
),
const SizedBox(height: 14),
Text('Sign in to continue', style: PV.caption),
const SizedBox(height: 22),
TextField(
controller: _email,
keyboardType: TextInputType.emailAddress,
autofillHints: const <String>[AutofillHints.username],
textInputAction: TextInputAction.next,
style: PV.body,
decoration: const InputDecoration(labelText: 'Email', prefixIcon: Icon(Icons.person_outline)),
),
const SizedBox(height: 12),
TextField(
controller: _password,
obscureText: _obscurePassword,
autofillHints: const <String>[AutofillHints.password],
onSubmitted: (_) => _busy ? null : _signIn(),
style: PV.body,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined),
tooltip: _obscurePassword ? 'Show password' : 'Hide password',
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
),
),
if (_showServer) ...<Widget>[
const SizedBox(height: 12),
TextField(
controller: _server,
keyboardType: TextInputType.url,
style: PV.body,
decoration: const InputDecoration(
labelText: 'API Server', hintText: '10.2.1.101:8080', prefixIcon: Icon(Icons.dns_outlined),
),
),
],
if (_error != null) ...<Widget>[
const SizedBox(height: 12),
Text(_error!, style: PV.body.copyWith(color: PV.warning)),
],
const SizedBox(height: 22),
FilledButton(
onPressed: _busy ? null : _signIn,
child: _busy
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Sign in'),
),
if (_bio.available) ..._biometricSection(),
const SizedBox(height: 4),
TextButton(
onPressed: () => setState(() => _showServer = !_showServer),
child: Text(
_showServer ? 'Hide server settings' : 'Server settings',
style: PV.caption.copyWith(color: PV.inkMuted),
),
),
],
),
),
),
),
),
);
}
}
+430
View File
@@ -0,0 +1,430 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations(<DeviceOrientation>[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<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final DjiService _dji = DjiService();
final FlightModel _model = FlightModel();
StreamSubscription<Map<String, dynamic>>? _sub;
StreamSubscription<AuthStatus>? _authSub;
// Streams telemetry to the API Server and receives commands back.
late final ServerUploader _uploader;
StreamSubscription<UploadStatus>? _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();
}
Future<void> _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<String, dynamic> 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<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 _snack(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(msg)));
}
// ── API server upload ──────────────────────────────────────────────────────
Future<void> _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<Map<String, dynamic>> _buildStateSnapshot() {
final List<Map<String, dynamic>> events = <Map<String, dynamic>>[
<String, dynamic>{'type': 'registration', 'state': _registrationWire()},
<String, dynamic>{'type': 'connection', 'connected': _model.connected, 'model': _model.model ?? ''},
];
if (_model.batteryPercent != null) {
events.add(<String, dynamic>{'type': 'battery', 'percent': _model.batteryPercent});
}
final Map<String, dynamic> tel = <String, dynamic>{'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 (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<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');
}
@override
void dispose() {
_sub?.cancel();
_authSub?.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<void> _openLogin() async {
await Navigator.of(context).push(MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (BuildContext ctx) => LoginPage(onSignedIn: () => Navigator.of(ctx).pop()),
));
}
// ── Navigation ───────────────────────────────────────────────────────────
void _goFly() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => FlightControlPage(model: _model),
));
}
void _openAlbum() {
Navigator.of(context).push(MaterialPageRoute<void>(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<void>(
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: <Widget>[
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: <Widget>[
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) ...<Widget>[
Divider(color: s.border, height: 24),
_settingRow(s, 'Registration', reg.$1, reg.$2, trailing: 'MSDK · ${_model.sdkVersion}'),
if (_model.registrationError != null) ...<Widget>[
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 ...<Widget>[
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: <Widget>[
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)),
],
);
}
}
+147
View File
@@ -0,0 +1,147 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:shared_preferences/shared_preferences.dart';
enum AuthStatus { signedOut, signingIn, signedIn, error }
/// Authentication through the **API Server** (login only, for now).
///
/// The app talks only to the API Server; PocketBase is hidden behind it. The
/// API Server address is configurable on the login screen ("Server settings").
/// A single global [auth] instance is shared across the app.
class PbAuth {
static const String defaultServer = 'http://10.2.1.101:8080';
SharedPreferences? _prefs;
String? _token;
String _email = '';
final StreamController<AuthStatus> _statusController =
StreamController<AuthStatus>.broadcast();
Stream<AuthStatus> get status => _statusController.stream;
AuthStatus _status = AuthStatus.signedOut;
AuthStatus get currentStatus => _status;
bool get isAuthed => _token != null && _token!.isNotEmpty;
String get userEmail => _email;
String get token => _token ?? '';
String get serverUrl => _prefs?.getString('api_url') ?? defaultServer;
/// A previously successful password login can be replayed via biometrics.
/// Credentials are stored on-device (see [signIn]); presence of a saved
/// password means "remembered account" and unlocks the biometric buttons.
bool get hasRememberedAccount => (_prefs?.getString('remember_password') ?? '').isNotEmpty;
String get rememberedEmail => _prefs?.getString('remember_email') ?? '';
void _set(AuthStatus s) {
_status = s;
if (!_statusController.isClosed) _statusController.add(s);
}
/// Restores a persisted session (if any) on app start.
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
_token = _prefs!.getString('api_token');
_email = _prefs!.getString('api_email') ?? '';
if (isAuthed) _set(AuthStatus.signedIn);
}
Future<void> signIn({
required String serverUrl,
required String email,
required String password,
}) async {
final String base = _normalize(serverUrl);
_set(AuthStatus.signingIn);
try {
final Map<String, dynamic> result = await _postLogin(base, email.trim(), password);
final String? token = result['token'] as String?;
if (token == null || token.isEmpty) {
throw const _AuthException('Unexpected response from the API server.');
}
_token = token;
_email = ((result['record'] as Map?)?['email'] as String?) ?? email.trim();
await _prefs?.setString('api_url', base);
await _prefs?.setString('api_token', _token!);
await _prefs?.setString('api_email', _email);
// Remember the credentials so a later biometric/face check can replay them.
// NOTE: stored in plain SharedPreferences like the token above — move to
// flutter_secure_storage (Keystore) when hardening.
await _prefs?.setString('remember_email', email.trim());
await _prefs?.setString('remember_password', password);
_set(AuthStatus.signedIn);
} catch (e) {
_set(AuthStatus.error);
rethrow;
}
}
/// Replays the remembered credentials — call this only after the caller has
/// passed a device biometric / face check.
Future<void> signInWithRememberedCredentials() async {
final String email = _prefs?.getString('remember_email') ?? '';
final String password = _prefs?.getString('remember_password') ?? '';
if (password.isEmpty) {
throw const _AuthException('No remembered account. Sign in with your password once first.');
}
await signIn(serverUrl: serverUrl, email: email, password: password);
}
/// Clears the remembered credentials (disables biometric quick-login) without
/// necessarily ending the current session.
Future<void> forgetAccount() async {
await _prefs?.remove('remember_email');
await _prefs?.remove('remember_password');
}
Future<Map<String, dynamic>> _postLogin(String base, String email, String password) async {
final HttpClient http = HttpClient()..connectionTimeout = const Duration(seconds: 10);
try {
final HttpClientRequest req = await http.postUrl(Uri.parse('$base/api/auth/login'));
req.headers.contentType = ContentType.json;
req.add(utf8.encode(jsonEncode(<String, String>{'email': email, 'password': password})));
final HttpClientResponse resp = await req.close().timeout(const Duration(seconds: 12));
final String text = await resp.transform(utf8.decoder).join();
final Map<String, dynamic> body =
text.isNotEmpty ? (jsonDecode(text) as Map).cast<String, dynamic>() : <String, dynamic>{};
if (resp.statusCode == 200) return body;
if (resp.statusCode == 400) throw const _AuthException('Invalid email or password.');
if (resp.statusCode == 502) throw const _AuthException("API server can't reach PocketBase.");
throw _AuthException(
(body['message'] ?? body['error'] ?? 'Login failed (${resp.statusCode})').toString(),
);
} finally {
http.close(force: true);
}
}
Future<void> signOut() async {
_token = null;
_email = '';
await _prefs?.remove('api_token');
await _prefs?.remove('api_email');
_set(AuthStatus.signedOut);
}
String _normalize(String url) {
String u = url.trim();
if (u.isEmpty) return defaultServer;
if (!u.startsWith('http://') && !u.startsWith('https://')) u = 'http://$u';
if (u.endsWith('/')) u = u.substring(0, u.length - 1);
return u;
}
}
class _AuthException implements Exception {
const _AuthException(this.message);
final String message;
@override
String toString() => message;
}
/// App-wide authentication instance.
final PbAuth auth = PbAuth();
+375
View File
@@ -0,0 +1,375 @@
import 'package:flutter/material.dart';
/// PilotVault design system (mobile) — light-default brand theme.
/// Signal Blue accent over Vault Navy and cool slate neutrals; Space Grotesk
/// for structure, Space Mono for data/telemetry and uppercase eyebrow labels.
class PV {
PV._();
static const String fontSans = 'Space Grotesk';
static const String fontMono = 'Space Mono';
// Brand
static const Color navy = Color(0xFF0F1E3D); // Vault Navy
static const Color accent = Color(0xFF3D7BF0); // Signal Blue
static const Color accentHover = Color(0xFF2B62CC);
// Status (names kept: ready=green, caution=amber, warning=red fault)
static const Color ready = Color(0xFF1F8A5B);
static const Color readyFg = Color(0xFF177049);
static const Color caution = Color(0xFFD9852B);
static const Color cautionFg = Color(0xFFB86C1B);
static const Color warning = Color(0xFFD64545);
static const Color warningFg = Color(0xFFB83232);
// Surfaces (light)
static const Color surface0 = Color(0xFFEEF0F3); // Cloud app ground
static const Color surface1 = Color(0xFFFFFFFF); // card / chrome
static const Color surface2 = Color(0xFFF6F7F9); // inset tiles / inputs
// Text
static const Color ink = navy; // primary
static const Color inkSecondary = Color(0xFF5A6B85); // steel
static const Color inkMuted = Color(0xFF97A1B0); // slate-400
// Hairlines
static const Color line = Color(0xFFDCE0E7);
static const Color lineStrong = Color(0xFFC5CCD7);
static const double radius = 14; // cards
static const double radiusCtl = 10; // controls
static const List<BoxShadow> shadowXs = <BoxShadow>[
BoxShadow(color: Color(0x0F0F1E3D), blurRadius: 2, offset: Offset(0, 1)),
];
// Type scale — mono for data, sans for chrome
static const TextStyle telemetry = TextStyle(
fontFamily: fontMono,
fontSize: 30,
fontWeight: FontWeight.w500,
height: 1.0,
color: ink,
fontFeatures: <FontFeature>[FontFeature.tabularFigures()],
);
static const TextStyle mode = TextStyle(
fontFamily: fontSans,
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.4,
color: ink,
);
static const TextStyle body = TextStyle(fontFamily: fontSans, fontSize: 14, color: ink);
static const TextStyle caption = TextStyle(fontFamily: fontSans, fontSize: 12, color: inkSecondary);
// Mono ALL-CAPS eyebrow (telemetry field names / section labels)
static const TextStyle label = TextStyle(
fontFamily: fontMono,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: inkMuted,
);
// Mono value (serials, coordinates, counts)
static const TextStyle mono = TextStyle(
fontFamily: fontMono,
fontSize: 14,
fontWeight: FontWeight.w700,
color: ink,
fontFeatures: <FontFeature>[FontFeature.tabularFigures()],
);
static ThemeData theme() {
final ThemeData base = ThemeData.light(useMaterial3: true);
final ColorScheme scheme = ColorScheme.fromSeed(
seedColor: accent,
brightness: Brightness.light,
).copyWith(primary: accent, surface: surface1, onSurface: ink);
OutlineInputBorder borderOf(Color c, [double w = 1]) => OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusCtl),
borderSide: BorderSide(color: c, width: w),
);
return base.copyWith(
colorScheme: scheme,
scaffoldBackgroundColor: surface0,
dividerColor: line,
textTheme: base.textTheme.apply(
fontFamily: fontSans,
bodyColor: ink,
displayColor: ink,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surface2,
isDense: true,
hintStyle: const TextStyle(color: inkMuted),
labelStyle: const TextStyle(color: inkSecondary),
prefixIconColor: inkMuted,
enabledBorder: borderOf(line),
focusedBorder: borderOf(accent, 2),
border: borderOf(line),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: accent,
foregroundColor: Colors.white,
disabledBackgroundColor: surface2,
disabledForegroundColor: inkMuted,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusCtl)),
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18),
textStyle: const TextStyle(fontFamily: fontSans, fontWeight: FontWeight.w600, fontSize: 14),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: ink,
backgroundColor: surface1,
side: const BorderSide(color: lineStrong),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusCtl)),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
textStyle: const TextStyle(fontFamily: fontSans, fontWeight: FontWeight.w600, fontSize: 14),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: inkSecondary),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: navy,
contentTextStyle: const TextStyle(fontFamily: fontSans, color: Colors.white),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusCtl)),
behavior: SnackBarBehavior.floating,
),
);
}
}
/// PilotVault Vector mark — two offset chevrons. Back wing Signal Blue,
/// front wing the foreground color (navy on light).
class PvBrandMark extends StatelessWidget {
const PvBrandMark({super.key, this.size = 28, this.frontColor});
final double size;
final Color? frontColor;
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(size, size),
painter: _ChevronPainter(frontColor ?? PV.ink),
);
}
}
class _ChevronPainter extends CustomPainter {
_ChevronPainter(this.front);
final Color front;
@override
void paint(Canvas canvas, Size size) {
final double s = size.width / 48.0;
Paint stroke(Color c) => Paint()
..color = c
..style = PaintingStyle.stroke
..strokeWidth = 4 * s
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final Path back = Path()
..moveTo(8 * s, 30 * s)
..lineTo(19 * s, 17 * s)
..lineTo(30 * s, 30 * s);
final Path frontWing = Path()
..moveTo(18 * s, 33 * s)
..lineTo(29 * s, 20 * s)
..lineTo(40 * s, 33 * s);
canvas.drawPath(back, stroke(PV.accent));
canvas.drawPath(frontWing, stroke(front));
}
@override
bool shouldRepaint(covariant _ChevronPainter old) => old.front != front;
}
/// A card surface: white face, hairline border, soft cool shadow.
class PvPanel extends StatelessWidget {
const PvPanel({super.key, required this.child, this.padding = const EdgeInsets.all(16)});
final Widget child;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
return Container(
padding: padding,
decoration: BoxDecoration(
color: PV.surface1,
borderRadius: BorderRadius.circular(PV.radius),
border: Border.all(color: PV.line),
boxShadow: PV.shadowXs,
),
child: child,
);
}
}
/// Mono uppercase eyebrow label (telemetry field / section label).
class SectionLabel extends StatelessWidget {
const SectionLabel(this.text, {super.key});
final String text;
@override
Widget build(BuildContext context) => Text(text.toUpperCase(), style: PV.label);
}
/// A status dot. Solid by default; brand carries status by color, not glow.
class StatusDot extends StatelessWidget {
const StatusDot(this.color, {super.key, this.glow = false, this.size = 9});
final Color color;
final bool glow;
final double size;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: glow ? <BoxShadow>[BoxShadow(color: color.withValues(alpha: 0.35), blurRadius: 6)] : null,
),
);
}
}
/// ─────────────────────────────────────────────────────────────────────────────
/// Full semantic token set, theme-flipping (mirrors tokens/colors.css). The Go
/// Fly and Album screens follow the device brightness; Flight Control overlays a
/// live camera feed and is always dark, so it uses fixed glass colors (below).
/// ─────────────────────────────────────────────────────────────────────────────
class PVScheme {
const PVScheme({
required this.brightness,
required this.bgApp,
required this.surface,
required this.surface2,
required this.surfaceInset,
required this.surfaceRaised,
required this.border,
required this.borderStrong,
required this.borderSubtle,
required this.textPrimary,
required this.textSecondary,
required this.textTertiary,
required this.textInverse,
required this.accent,
required this.accentHover,
required this.accentSoft,
required this.accentSoftFg,
required this.success,
required this.successSoft,
required this.successFg,
required this.warning,
required this.warningSoft,
required this.warningFg,
required this.danger,
required this.dangerSoft,
required this.dangerFg,
required this.shadowXs,
required this.shadowSm,
});
final Brightness brightness;
final Color bgApp, surface, surface2, surfaceInset, surfaceRaised;
final Color border, borderStrong, borderSubtle;
final Color textPrimary, textSecondary, textTertiary, textInverse;
final Color accent, accentHover, accentSoft, accentSoftFg;
final Color success, successSoft, successFg;
final Color warning, warningSoft, warningFg;
final Color danger, dangerSoft, dangerFg;
final List<BoxShadow> shadowXs, shadowSm;
bool get isDark => brightness == Brightness.dark;
static const PVScheme light = PVScheme(
brightness: Brightness.light,
bgApp: Color(0xFFEEF0F3),
surface: Color(0xFFFFFFFF),
surface2: Color(0xFFF6F7F9),
surfaceInset: Color(0xFFEEF0F3),
surfaceRaised: Color(0xFFFFFFFF),
border: Color(0xFFDCE0E7),
borderStrong: Color(0xFFC5CCD7),
borderSubtle: Color(0xFFE6E9EE),
textPrimary: Color(0xFF0F1E3D),
textSecondary: Color(0xFF5A6B85),
textTertiary: Color(0xFF97A1B0),
textInverse: Color(0xFFFFFFFF),
accent: Color(0xFF3D7BF0),
accentHover: Color(0xFF2B62CC),
accentSoft: Color(0xFFEAF1FE),
accentSoftFg: Color(0xFF1F4CA0),
success: Color(0xFF1F8A5B),
successSoft: Color(0xFFDCF1E7),
successFg: Color(0xFF177049),
warning: Color(0xFFD9852B),
warningSoft: Color(0xFFFBEBD5),
warningFg: Color(0xFFB86C1B),
danger: Color(0xFFD64545),
dangerSoft: Color(0xFFFBE0E0),
dangerFg: Color(0xFFB83232),
shadowXs: <BoxShadow>[BoxShadow(color: Color(0x0F0F1E3D), blurRadius: 2, offset: Offset(0, 1))],
shadowSm: <BoxShadow>[
BoxShadow(color: Color(0x0F0F1E3D), blurRadius: 3, offset: Offset(0, 1)),
BoxShadow(color: Color(0x0A0F1E3D), blurRadius: 2, offset: Offset(0, 1)),
],
);
static const PVScheme dark = PVScheme(
brightness: Brightness.dark,
bgApp: Color(0xFF0B1730),
surface: Color(0xFF10203F),
surface2: Color(0xFF142748),
surfaceInset: Color(0xFF0B1730),
surfaceRaised: Color(0xFF16294B),
border: Color(0x1AFFFFFF),
borderStrong: Color(0x2EFFFFFF),
borderSubtle: Color(0x0FFFFFFF),
textPrimary: Color(0xFFF4F7FC),
textSecondary: Color(0xFF8FA0BE),
textTertiary: Color(0xFF5E6E8C),
textInverse: Color(0xFF0F1E3D),
accent: Color(0xFF5B93F5),
accentHover: Color(0xFF8FB4F6),
accentSoft: Color(0x2E3D7BF0),
accentSoftFg: Color(0xFF8FB4F6),
success: Color(0xFF1F8A5B),
successSoft: Color(0x381F8A5B),
successFg: Color(0xFF5FD3A0),
warning: Color(0xFFD9852B),
warningSoft: Color(0x38D9852B),
warningFg: Color(0xFFF0B26A),
danger: Color(0xFFD64545),
dangerSoft: Color(0x38D64545),
dangerFg: Color(0xFFF08A8A),
shadowXs: <BoxShadow>[BoxShadow(color: Color(0x59000000), blurRadius: 2, offset: Offset(0, 1))],
shadowSm: <BoxShadow>[BoxShadow(color: Color(0x66000000), blurRadius: 3, offset: Offset(0, 1))],
);
/// Resolves the scheme from the device brightness.
static PVScheme of(BuildContext context) =>
MediaQuery.platformBrightnessOf(context) == Brightness.dark ? dark : light;
}
/// Fixed "glass" palette for the Flight Control overlay (always over a dark feed).
class Glass {
Glass._();
static const Color ink = Color(0xFFEAF0FA);
static const Color pill = Color(0x80081020); // rgba(8,16,32,0.5)
static const Color pillStrong = Color(0x8C081020); // rgba(8,16,32,0.55)
static const Color accent = Color(0xEB3D7BF0); // rgba(61,123,240,0.92)
static const Color hairline = Color(0x1FFFFFFF); // rgba(255,255,255,0.12)
static const Color sat = Color(0xFF7FE0B0);
static const Color rec = Color(0xFFD64545);
static const Color subject = Color(0xFFF4C542);
}
+145
View File
@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import '../theme.dart';
/// Portrait media grid — mirrors the ui_kit/fly "Album" mockup. Media is
/// placeholder content (the app has no on-device gallery source yet); the
/// layout, filters and badges match the design.
class AlbumPage extends StatefulWidget {
const AlbumPage({super.key});
@override
State<AlbumPage> createState() => _AlbumPageState();
}
class _AlbumPageState extends State<AlbumPage> {
static const List<String> _filters = <String>['All', 'Photos', 'Videos', 'Pano'];
int _active = 0;
// (isVideo, duration) — placeholder set from the mockup.
static const List<(bool, String?)> _media = <(bool, String?)>[
(true, '0:24'), (false, null), (false, null),
(true, '1:12'), (false, null), (false, null),
(false, null), (true, '0:08'), (false, null),
(false, null), (true, '0:31'), (false, null),
];
@override
Widget build(BuildContext context) {
final PVScheme s = PVScheme.of(context);
return Scaffold(
backgroundColor: s.bgApp,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_header(s),
_filterBar(s),
const SizedBox(height: 14),
Expanded(child: _grid(s)),
],
),
),
);
}
Widget _header(PVScheme s) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 20, 12),
child: Row(
children: <Widget>[
IconButton(
onPressed: () => Navigator.of(context).maybePop(),
icon: Icon(Icons.chevron_left, size: 26, color: s.textSecondary),
),
Text(
'Album',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: s.textPrimary),
),
const Spacer(),
Text('${_media.length} items', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textTertiary)),
],
),
);
}
Widget _filterBar(PVScheme s) {
return SizedBox(
height: 28,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: _filters.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (BuildContext context, int i) {
final bool active = i == _active;
return GestureDetector(
onTap: () => setState(() => _active = i),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
alignment: Alignment.center,
decoration: BoxDecoration(
color: active ? s.accent : s.surfaceInset,
borderRadius: BorderRadius.circular(999),
),
child: Text(
_filters[i],
style: TextStyle(
fontFamily: PV.fontSans,
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: active ? Colors.white : s.textSecondary,
),
),
),
);
},
),
);
}
Widget _grid(PVScheme s) {
return GridView.builder(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 6,
mainAxisSpacing: 6,
),
itemCount: _media.length,
itemBuilder: (BuildContext context, int i) {
final (bool isVideo, String? dur) = _media[i];
return ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[s.surface2, s.surfaceInset],
transform: GradientRotation((140 + i * 14) * 3.1415926 / 180),
),
border: Border.all(color: s.border),
borderRadius: BorderRadius.circular(10),
),
child: Stack(
children: <Widget>[
Center(child: Icon(isVideo ? Icons.videocam_outlined : Icons.image_outlined, size: 20, color: s.textTertiary)),
if (dur != null)
Positioned(
right: 6,
bottom: 5,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(color: const Color(0xB30B1730), borderRadius: BorderRadius.circular(5)),
child: Text(dur, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 9.5, color: Colors.white)),
),
),
],
),
),
);
},
);
}
}
+29
View File
@@ -0,0 +1,29 @@
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Live DJI primary video feed, rendered by the native `DjiVideoView`
/// [PlatformView] (Android only). Used as the full-bleed background of the
/// Flight Control HUD.
///
/// On non-Android targets (or when the SDK has no active feed) the native side
/// simply renders black, so callers gate this behind a real connection and fall
/// back to a placeholder otherwise.
class DjiVideoView extends StatelessWidget {
const DjiVideoView({super.key});
/// Must match `DjiVideoView.VIEW_TYPE` on the native side.
static const String _viewType = 'dji_msdk/video';
@override
Widget build(BuildContext context) {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
return const SizedBox.shrink();
}
return const AndroidView(
viewType: _viewType,
creationParamsCodec: StandardMessageCodec(),
);
}
}
+474
View File
@@ -0,0 +1,474 @@
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;
}
+292
View File
@@ -0,0 +1,292 @@
import 'package:flutter/material.dart';
import '../flight_model.dart';
import '../theme.dart';
import '../uploader.dart';
/// Portrait launch screen — mirrors the ui_kit/fly "Go Fly · launch" mockup:
/// brand header, aircraft connection card, big GO FLY, and a 2×2 tile grid.
class GoFlyPage extends StatelessWidget {
const GoFlyPage({
super.key,
required this.model,
required this.onGoFly,
required this.onOpenAlbum,
required this.onSettings,
required this.onTile,
});
final FlightModel model;
final VoidCallback onGoFly;
final VoidCallback onOpenAlbum;
final VoidCallback onSettings;
final void Function(String tile) onTile;
@override
Widget build(BuildContext context) {
final PVScheme s = PVScheme.of(context);
return Scaffold(
backgroundColor: s.bgApp,
body: SafeArea(
child: AnimatedBuilder(
animation: model,
builder: (BuildContext context, _) {
return Column(
children: <Widget>[
_header(s),
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: _connectionCard(s),
),
),
),
Padding(
// No bottom gap here: the design pins GO FLY directly above the
// tiles' 18px top pad, which centers the card 4px lower to match.
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
child: _goFlyButton(s),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
child: _tiles(s),
),
],
);
},
),
),
);
}
Widget _header(PVScheme s) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 0),
child: Row(
children: <Widget>[
const PvBrandMark(size: 22),
const SizedBox(width: 8),
Text.rich(
TextSpan(children: <TextSpan>[
TextSpan(
text: 'Pilot',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 19, fontWeight: FontWeight.w500, letterSpacing: -0.38, color: s.textSecondary),
),
TextSpan(
text: 'Vault',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 19, fontWeight: FontWeight.w700, letterSpacing: -0.38, color: s.textPrimary),
),
TextSpan(
text: ' Fly',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 19, fontWeight: FontWeight.w500, letterSpacing: -0.38, color: s.accent),
),
]),
),
const Spacer(),
GestureDetector(
onTap: onSettings,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(color: s.surfaceInset, shape: BoxShape.circle),
child: Icon(Icons.person_outline, size: 17, color: s.textSecondary),
),
),
],
),
);
}
Widget _connectionCard(PVScheme s) {
final bool connected = model.connected;
final Color dot = connected ? s.success : s.textTertiary;
final Color statusFg = connected ? s.successFg : s.textTertiary;
final String statusText = connected ? 'CONNECTED' : 'DISCONNECTED';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: s.surface,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: s.border),
boxShadow: s.shadowSm,
),
child: Column(
mainAxisSize: MainAxisSize.min, // size to content; Center handles vertical placement
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Container(width: 7, height: 7, decoration: BoxDecoration(color: dot, shape: BoxShape.circle)),
const SizedBox(width: 6),
Text(
statusText,
style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, letterSpacing: 1.1, fontWeight: FontWeight.w700, color: statusFg),
),
],
),
const SizedBox(height: 14),
Row(
children: <Widget>[
Container(
width: 56,
height: 56,
decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(14)),
child: Icon(Icons.flight, size: 28, color: s.accent),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
connected ? (model.model ?? 'Aircraft') : 'No aircraft',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, color: s.textPrimary),
),
const SizedBox(height: 2),
Text(
'MSDK · ${model.sdkVersion}',
style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textSecondary),
),
],
),
),
],
),
const SizedBox(height: 14),
Row(
children: <Widget>[
_chip(s, Icons.battery_full, model.batteryPercent == null ? '' : '${model.batteryPercent}%'),
const SizedBox(width: 8),
_chip(s, Icons.satellite_alt, model.satellites == null ? '— sats' : '${model.satellites} sats'),
const SizedBox(width: 8),
_chip(s, Icons.link, _linkLabel(model.upload, connected)),
],
),
],
),
);
}
String _linkLabel(UploadStatus u, bool connected) {
switch (u) {
case UploadStatus.connected:
return 'Streaming';
case UploadStatus.connecting:
return 'Linking…';
case UploadStatus.error:
return 'Retrying';
case UploadStatus.disabled:
return connected ? 'Linked' : 'Off';
}
}
Widget _chip(PVScheme s, IconData icon, String value) {
return Expanded(
child: Container(
height: 34,
decoration: BoxDecoration(color: s.surfaceInset, borderRadius: BorderRadius.circular(9)),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(icon, size: 14, color: s.textTertiary),
const SizedBox(width: 6),
Flexible(
child: Text(
value,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontFamily: PV.fontMono, fontSize: 11.5, color: s.textSecondary),
),
),
],
),
),
);
}
Widget _goFlyButton(PVScheme s) {
return SizedBox(
height: 58,
child: FilledButton(
onPressed: onGoFly,
style: FilledButton.styleFrom(
backgroundColor: s.accent,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget>[
Icon(Icons.play_arrow_rounded, size: 22),
SizedBox(width: 10),
Text('GO FLY', style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: 0.4)),
],
),
),
);
}
Widget _tiles(PVScheme s) {
const List<(IconData, String)> tiles = <(IconData, String)>[
(Icons.photo_library_outlined, 'Album'),
(Icons.school_outlined, 'Academy'),
(Icons.route_outlined, 'Routes'),
(Icons.speed, 'Flight logs'),
];
return Column(
children: <Widget>[
Row(children: <Widget>[
Expanded(child: _tile(s, tiles[0])),
const SizedBox(width: 12),
Expanded(child: _tile(s, tiles[1])),
]),
const SizedBox(height: 12),
Row(children: <Widget>[
Expanded(child: _tile(s, tiles[2])),
const SizedBox(width: 12),
Expanded(child: _tile(s, tiles[3])),
]),
],
);
}
Widget _tile(PVScheme s, (IconData, String) t) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => t.$2 == 'Album' ? onOpenAlbum() : onTile(t.$2),
child: Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: s.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: s.border),
boxShadow: s.shadowXs,
),
child: Row(
children: <Widget>[
Container(
width: 32,
height: 32,
decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(9)),
child: Icon(t.$1, size: 17, color: s.accentSoftFg),
),
const SizedBox(width: 10),
Flexible(
child: Text(
t.$2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, fontWeight: FontWeight.w600, color: s.textPrimary),
),
),
],
),
),
),
);
}
}
+155
View File
@@ -0,0 +1,155 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
enum UploadStatus { disabled, connecting, connected, error }
/// Streams DJI events to the API Server over a WebSocket and dispatches
/// commands the server pushes back. Auto-reconnects while enabled.
///
/// Wire format matches the server's `/ws/device` endpoint:
/// * outbound: the raw DJI event maps (registration/connection/battery/telemetry)
/// * inbound : `{"type":"command","command":"...","payload":{...}}`
class ServerUploader {
ServerUploader({required this.onCommand, this.snapshotProvider});
/// Invoked when the server pushes a command frame.
final void Function(String command, Map<String, dynamic> payload) onCommand;
/// Returns the app's last-known events (registration/connection/battery/
/// telemetry) to replay right after (re)connecting, so the server reflects
/// current state even though those events fired in the past.
final List<Map<String, dynamic>> Function()? snapshotProvider;
/// High-frequency telemetry is throttled to this interval; other event
/// types (battery, connection, registration) are sent immediately.
Duration telemetryInterval = const Duration(milliseconds: 250);
WebSocket? _socket;
bool _enabled = false;
String _host = '';
String _deviceId = 'phone';
Timer? _reconnectTimer;
DateTime _lastTelemetrySent = DateTime.fromMillisecondsSinceEpoch(0);
final StreamController<UploadStatus> _statusController =
StreamController<UploadStatus>.broadcast();
Stream<UploadStatus> get status => _statusController.stream;
UploadStatus _status = UploadStatus.disabled;
UploadStatus get currentStatus => _status;
void _setStatus(UploadStatus s) {
_status = s;
if (!_statusController.isClosed) _statusController.add(s);
}
/// host accepts "10.0.0.5", "10.0.0.5:8080", or "ws://10.0.0.5:8080".
Future<void> enable(String host, {String deviceId = 'phone'}) async {
_host = host.trim();
_deviceId = deviceId.trim().isEmpty ? 'phone' : deviceId.trim();
_enabled = true;
await _connect();
}
Future<void> disable() async {
_enabled = false;
_reconnectTimer?.cancel();
await _socket?.close();
_socket = null;
_setStatus(UploadStatus.disabled);
}
Future<void> _connect() async {
if (!_enabled) return;
_setStatus(UploadStatus.connecting);
try {
final Uri uri = _buildUri(_host, _deviceId);
final WebSocket sock =
await WebSocket.connect(uri.toString()).timeout(const Duration(seconds: 6));
_socket = sock;
_setStatus(UploadStatus.connected);
// Replay last-known state so the server is immediately in sync.
final List<Map<String, dynamic>> snapshot = snapshotProvider?.call() ?? const <Map<String, dynamic>>[];
for (final Map<String, dynamic> e in snapshot) {
try {
sock.add(jsonEncode(e));
} catch (_) {}
}
sock.listen(
_onData,
onDone: _onDisconnect,
onError: (Object _) => _onDisconnect(),
cancelOnError: true,
);
} catch (_) {
_socket = null;
_setStatus(UploadStatus.error);
_scheduleReconnect();
}
}
Uri _buildUri(String host, String id) {
String h = host;
if (h.startsWith('ws://')) h = h.substring(5);
if (h.startsWith('wss://')) h = h.substring(6);
final int slash = h.indexOf('/');
if (slash >= 0) h = h.substring(0, slash);
if (!h.contains(':')) h = '$h:8080';
return Uri.parse('ws://$h/ws/device?id=${Uri.encodeQueryComponent(id)}');
}
void _onData(dynamic data) {
if (data is! String) return;
try {
final Map<String, dynamic> msg =
(jsonDecode(data) as Map).cast<String, dynamic>();
if (msg['type'] == 'command') {
final String? cmd = msg['command'] as String?;
final Map<String, dynamic> payload =
(msg['payload'] as Map?)?.cast<String, dynamic>() ?? <String, dynamic>{};
if (cmd != null) onCommand(cmd, payload);
}
} catch (_) {
// ignore malformed frames
}
}
void _onDisconnect() {
_socket = null;
if (_enabled) {
_setStatus(UploadStatus.error);
_scheduleReconnect();
}
}
void _scheduleReconnect() {
_reconnectTimer?.cancel();
if (!_enabled) return;
_reconnectTimer = Timer(const Duration(seconds: 3), _connect);
}
/// Feed an event coming from `DjiService.events()`.
void onEvent(Map<String, dynamic> event) {
final WebSocket? sock = _socket;
if (sock == null || _status != UploadStatus.connected) return;
if (event['type'] == 'telemetry') {
final DateTime now = DateTime.now();
if (now.difference(_lastTelemetrySent) < telemetryInterval) return;
_lastTelemetrySent = now;
}
try {
sock.add(jsonEncode(event));
} catch (_) {
// drop on transient write failure; reconnect logic handles the rest
}
}
void dispose() {
_enabled = false;
_reconnectTimer?.cancel();
_socket?.close();
_statusController.close();
}
}