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:
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user