Remove the old PocketBase backup from the Fly App
backup_pocketbase_app/ held the pre-API-Server login and PocketBase service from June; nothing uses it, and its imports made `flutter analyze` report 20 errors. It stays in git history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8f259339fc
commit
66c3dbcd71
4 files changed
-894
No files matched your search
@@ -1,146 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'pb_service.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: pb.defaultBaseUrl);
|
||||
final TextEditingController _email =
|
||||
TextEditingController(text: 'pilot@dji.local');
|
||||
final TextEditingController _password = TextEditingController();
|
||||
final TextEditingController _device =
|
||||
TextEditingController(text: 'phone');
|
||||
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _signIn() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await pb.signIn(
|
||||
baseUrl: _server.text,
|
||||
email: _email.text,
|
||||
password: _password.text,
|
||||
deviceId: _device.text,
|
||||
);
|
||||
widget.onSignedIn();
|
||||
} catch (e) {
|
||||
setState(() => _error = _friendly(e));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _friendly(Object e) {
|
||||
final String s = e.toString();
|
||||
if (s.contains('Failed host lookup') || s.contains('SocketException')) {
|
||||
return 'Cannot reach the server. Check the address and that PocketBase is running.';
|
||||
}
|
||||
if (s.contains('Failed to authenticate')) return 'Invalid email or password.';
|
||||
return s;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_server.dispose();
|
||||
_email.dispose();
|
||||
_password.dispose();
|
||||
_device.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 380),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Icon(Icons.flight_takeoff,
|
||||
size: 56, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 12),
|
||||
Text('DJI MSDK Sample',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall),
|
||||
Text('Sign in to the PocketBase backend',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade600)),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _server,
|
||||
keyboardType: TextInputType.url,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server', hintText: '10.2.1.101:8090',
|
||||
border: OutlineInputBorder(), prefixIcon: Icon(Icons.dns),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _email,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const <String>[AutofillHints.username],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email', border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.person),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _password,
|
||||
obscureText: true,
|
||||
autofillHints: const <String>[AutofillHints.password],
|
||||
onSubmitted: (_) => _busy ? null : _signIn(),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password', border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.lock),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _device,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Device id', border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.smartphone),
|
||||
),
|
||||
),
|
||||
if (_error != null) ...<Widget>[
|
||||
const SizedBox(height: 12),
|
||||
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _signIn,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: _busy
|
||||
? const SizedBox(
|
||||
height: 20, width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Sign in'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'dji_service.dart';
|
||||
import 'login_page.dart';
|
||||
import 'pb_service.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await pb.init();
|
||||
runApp(const DjiSampleApp());
|
||||
}
|
||||
|
||||
class DjiSampleApp extends StatelessWidget {
|
||||
const DjiSampleApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'DJI MSDK Sample',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1565C0)),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const AuthGate(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the login screen until the user is authenticated with PocketBase.
|
||||
class AuthGate extends StatefulWidget {
|
||||
const AuthGate({super.key});
|
||||
|
||||
@override
|
||||
State<AuthGate> createState() => _AuthGateState();
|
||||
}
|
||||
|
||||
class _AuthGateState extends State<AuthGate> {
|
||||
StreamSubscription<BackendStatus>? _sub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sub = pb.status.listen((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (pb.isAuthed) {
|
||||
return const HomePage();
|
||||
}
|
||||
return LoginPage(onSignedIn: () => setState(() {}));
|
||||
}
|
||||
}
|
||||
|
||||
enum RegistrationState { idle, registering, success, failed }
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
final DjiService _dji = DjiService();
|
||||
StreamSubscription<Map<String, dynamic>>? _sub;
|
||||
StreamSubscription<BackendStatus>? _backendSub;
|
||||
BackendStatus _backend = BackendStatus.online;
|
||||
|
||||
String _sdkVersion = '…';
|
||||
RegistrationState _registration = RegistrationState.idle;
|
||||
String? _registrationError;
|
||||
|
||||
bool _connected = false;
|
||||
String? _model;
|
||||
|
||||
int? _satellites;
|
||||
bool? _isFlying;
|
||||
String? _flightMode;
|
||||
double? _altitude;
|
||||
double? _latitude;
|
||||
double? _longitude;
|
||||
int? _batteryPercent;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_backend = pb.currentStatus;
|
||||
// Route server interactions through PocketBase.
|
||||
pb.onCommand = _handleServerCommand;
|
||||
pb.stateProvider = _currentState;
|
||||
_backendSub = pb.status.listen((BackendStatus s) {
|
||||
if (mounted) setState(() => _backend = s);
|
||||
});
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
_sub = _dji.events().listen(_onEvent, onError: (Object e) {
|
||||
_snack('Event channel error: $e');
|
||||
});
|
||||
try {
|
||||
final String version = await _dji.getSdkVersion();
|
||||
if (mounted) setState(() => _sdkVersion = version);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _sdkVersion = 'unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
void _onEvent(Map<String, dynamic> event) {
|
||||
if (!mounted) return;
|
||||
// Persist every event to PocketBase (telemetry is throttled inside).
|
||||
pb.onEvent(event);
|
||||
switch (event['type'] as String?) {
|
||||
case 'registration':
|
||||
setState(() {
|
||||
switch (event['state'] as String?) {
|
||||
case 'registering':
|
||||
_registration = RegistrationState.registering;
|
||||
_registrationError = null;
|
||||
break;
|
||||
case 'success':
|
||||
_registration = RegistrationState.success;
|
||||
break;
|
||||
case 'failed':
|
||||
_registration = RegistrationState.failed;
|
||||
_registrationError = event['error'] as String?;
|
||||
break;
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'connection':
|
||||
setState(() {
|
||||
_connected = event['connected'] as bool? ?? false;
|
||||
_model = event['model'] as String?;
|
||||
if (!_connected) _clearTelemetry();
|
||||
});
|
||||
break;
|
||||
case 'telemetry':
|
||||
setState(() {
|
||||
_satellites = event['satelliteCount'] as int?;
|
||||
_isFlying = event['isFlying'] as bool?;
|
||||
_flightMode = event['flightMode'] as String?;
|
||||
_altitude = (event['altitude'] as num?)?.toDouble();
|
||||
_latitude = (event['latitude'] as num?)?.toDouble();
|
||||
_longitude = (event['longitude'] as num?)?.toDouble();
|
||||
});
|
||||
break;
|
||||
case 'battery':
|
||||
setState(() => _batteryPercent = event['percent'] as int?);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _clearTelemetry() {
|
||||
_satellites = null;
|
||||
_isFlying = null;
|
||||
_flightMode = null;
|
||||
_altitude = null;
|
||||
_latitude = null;
|
||||
_longitude = null;
|
||||
_batteryPercent = null;
|
||||
}
|
||||
|
||||
/// Current app state shared with PocketBase for the device presence record.
|
||||
Map<String, dynamic> _currentState() => <String, dynamic>{
|
||||
'connected': _connected,
|
||||
'model': _model ?? '',
|
||||
'registration': _registrationWire(),
|
||||
};
|
||||
|
||||
String _registrationWire() {
|
||||
switch (_registration) {
|
||||
case RegistrationState.success:
|
||||
return 'success';
|
||||
case RegistrationState.registering:
|
||||
return 'registering';
|
||||
case RegistrationState.failed:
|
||||
return 'failed';
|
||||
case RegistrationState.idle:
|
||||
return 'not registered';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _register() async {
|
||||
try {
|
||||
await _dji.registerApp();
|
||||
} catch (e) {
|
||||
_snack('registerApp failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
try {
|
||||
final bool started = await _dji.startConnection();
|
||||
_snack(started ? 'Scanning for product…' : 'Could not start connection');
|
||||
} catch (e) {
|
||||
_snack('startConnection failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _handleServerCommand(String command, Map<String, dynamic> payload) {
|
||||
switch (command) {
|
||||
case 'registerApp':
|
||||
_register();
|
||||
break;
|
||||
case 'startConnection':
|
||||
_connect();
|
||||
break;
|
||||
case 'stopConnection':
|
||||
_dji.stopConnection();
|
||||
break;
|
||||
default:
|
||||
_snack('Unknown server command: $command');
|
||||
return;
|
||||
}
|
||||
_snack('Server command: $command');
|
||||
}
|
||||
|
||||
Future<void> _logout() async {
|
||||
await pb.signOut();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const AuthGate(),
|
||||
),
|
||||
(Route<dynamic> route) => false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _snack(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..clearSnackBars()
|
||||
..showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
_backendSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('DJI MSDK Sample'),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
tooltip: 'Log out',
|
||||
onPressed: _logout,
|
||||
icon: const Icon(Icons.logout),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: <Widget>[
|
||||
_backendCard(),
|
||||
const SizedBox(height: 12),
|
||||
_registrationCard(),
|
||||
const SizedBox(height: 12),
|
||||
_connectionCard(),
|
||||
const SizedBox(height: 12),
|
||||
_telemetryCard(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _backendCard() {
|
||||
final (Color color, String label) = switch (_backend) {
|
||||
BackendStatus.online => (Colors.green, 'Streaming to PocketBase'),
|
||||
BackendStatus.signingIn => (Colors.orange, 'Connecting…'),
|
||||
BackendStatus.error => (Colors.red, 'Backend error'),
|
||||
BackendStatus.signedOut => (Colors.grey, 'Signed out'),
|
||||
};
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.cloud_done, color: color),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(label, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 2),
|
||||
Text('${pb.userEmail} · device "${pb.deviceId}"',
|
||||
style: const TextStyle(color: Colors.black54, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _logout,
|
||||
icon: const Icon(Icons.logout),
|
||||
label: const Text('Log out'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _registrationCard() {
|
||||
final (Color color, IconData icon, String label) = switch (_registration) {
|
||||
RegistrationState.idle => (Colors.grey, Icons.help_outline, 'Not registered'),
|
||||
RegistrationState.registering => (Colors.orange, Icons.sync, 'Registering…'),
|
||||
RegistrationState.success => (Colors.green, Icons.verified, 'Registered'),
|
||||
RegistrationState.failed => (Colors.red, Icons.error_outline, 'Registration failed'),
|
||||
};
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(icon, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Text(label, style: Theme.of(context).textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('SDK version: $_sdkVersion'),
|
||||
if (_registrationError != null) ...<Widget>[
|
||||
const SizedBox(height: 4),
|
||||
Text(_registrationError!, style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _registration == RegistrationState.registering ? null : _register,
|
||||
icon: const Icon(Icons.app_registration),
|
||||
label: const Text('Register app'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _connectionCard() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(_connected ? Icons.link : Icons.link_off,
|
||||
color: _connected ? Colors.green : Colors.grey),
|
||||
const SizedBox(width: 8),
|
||||
Text(_connected ? 'Product connected' : 'No product',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('Model: ${_model ?? '—'}'),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _registration == RegistrationState.success ? _connect : null,
|
||||
icon: const Icon(Icons.usb),
|
||||
label: const Text('Connect to product'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _telemetryCard() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text('Telemetry', style: Theme.of(context).textTheme.titleMedium),
|
||||
const Divider(),
|
||||
_row('Battery', _batteryPercent == null ? '—' : '$_batteryPercent%'),
|
||||
_row('GPS satellites', _satellites?.toString() ?? '—'),
|
||||
_row('Flight mode', _flightMode ?? '—'),
|
||||
_row('Flying', _isFlying == null ? '—' : (_isFlying! ? 'yes' : 'no')),
|
||||
_row('Altitude', _altitude == null ? '—' : '${_altitude!.toStringAsFixed(1)} m'),
|
||||
_row('Latitude', _latitude?.toStringAsFixed(6) ?? '—'),
|
||||
_row('Longitude', _longitude?.toStringAsFixed(6) ?? '—'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(label, style: const TextStyle(color: Colors.black54)),
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum BackendStatus { signedOut, signingIn, online, error }
|
||||
|
||||
/// PocketBase-backed service: authentication, telemetry persistence, device
|
||||
/// presence, and inbound command subscription. A single global [pb] instance is
|
||||
/// shared across the app.
|
||||
class PbService {
|
||||
PocketBase? _pb;
|
||||
SharedPreferences? _prefs;
|
||||
String _deviceId = 'phone';
|
||||
|
||||
Timer? _heartbeat;
|
||||
String? _devicesRecordId;
|
||||
UnsubscribeFunc? _cmdUnsub;
|
||||
DateTime _lastTelemetry = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
/// Called when a command record targeting this device is created.
|
||||
void Function(String command, Map<String, dynamic> payload)? onCommand;
|
||||
|
||||
/// Returns the app's current state for the device presence record.
|
||||
/// Expected keys: connected (bool), model (String), registration (String).
|
||||
Map<String, dynamic> Function()? stateProvider;
|
||||
|
||||
final StreamController<BackendStatus> _statusController =
|
||||
StreamController<BackendStatus>.broadcast();
|
||||
Stream<BackendStatus> get status => _statusController.stream;
|
||||
|
||||
BackendStatus _status = BackendStatus.signedOut;
|
||||
BackendStatus get currentStatus => _status;
|
||||
|
||||
bool get isAuthed => _pb?.authStore.isValid ?? false;
|
||||
String get userEmail => _pb?.authStore.record?.getStringValue('email') ?? '';
|
||||
String get deviceId => _deviceId;
|
||||
String get baseUrl => _pb?.baseURL ?? '';
|
||||
|
||||
void _setStatus(BackendStatus 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();
|
||||
_deviceId = _prefs!.getString('pb_device') ?? 'phone';
|
||||
final String? url = _prefs!.getString('pb_url');
|
||||
final String? auth = _prefs!.getString('pb_auth');
|
||||
if (url != null && auth != null && auth.isNotEmpty) {
|
||||
_pb = _build(url, auth);
|
||||
if (_pb!.authStore.isValid) {
|
||||
_setStatus(BackendStatus.online);
|
||||
await _afterAuth();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PocketBase _build(String url, String? initialAuth) {
|
||||
final AsyncAuthStore store = AsyncAuthStore(
|
||||
save: (String data) async => _prefs?.setString('pb_auth', data),
|
||||
clear: () async => _prefs?.remove('pb_auth'),
|
||||
initial: initialAuth,
|
||||
);
|
||||
return PocketBase(url, authStore: store);
|
||||
}
|
||||
|
||||
String get defaultBaseUrl => _prefs?.getString('pb_url') ?? 'http://10.2.1.101:8090';
|
||||
|
||||
Future<void> signIn({
|
||||
required String baseUrl,
|
||||
required String email,
|
||||
required String password,
|
||||
String deviceId = 'phone',
|
||||
}) async {
|
||||
_deviceId = deviceId.trim().isEmpty ? 'phone' : deviceId.trim();
|
||||
final String url = _normalize(baseUrl);
|
||||
_setStatus(BackendStatus.signingIn);
|
||||
try {
|
||||
final PocketBase pb = _build(url, null);
|
||||
await pb.collection('users').authWithPassword(email.trim(), password);
|
||||
_pb = pb;
|
||||
await _prefs?.setString('pb_url', url);
|
||||
await _prefs?.setString('pb_device', _deviceId);
|
||||
_setStatus(BackendStatus.online);
|
||||
await _afterAuth();
|
||||
} catch (e) {
|
||||
_setStatus(BackendStatus.error);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
_heartbeat?.cancel();
|
||||
_heartbeat = null;
|
||||
try {
|
||||
await _upsertDevice(online: false);
|
||||
} catch (_) {}
|
||||
try {
|
||||
await _cmdUnsub?.call();
|
||||
} catch (_) {}
|
||||
_cmdUnsub = null;
|
||||
_devicesRecordId = null;
|
||||
_pb?.authStore.clear();
|
||||
_setStatus(BackendStatus.signedOut);
|
||||
}
|
||||
|
||||
Future<void> _afterAuth() async {
|
||||
await _upsertDevice(online: true);
|
||||
_startHeartbeat();
|
||||
await _subscribeCommands();
|
||||
}
|
||||
|
||||
String _normalize(String url) {
|
||||
String u = url.trim();
|
||||
if (!u.startsWith('http://') && !u.startsWith('https://')) u = 'http://$u';
|
||||
return u;
|
||||
}
|
||||
|
||||
// ── Telemetry ingestion ────────────────────────────────────────────────────
|
||||
|
||||
/// Feed an event from `DjiService.events()`.
|
||||
void onEvent(Map<String, dynamic> event) {
|
||||
final PocketBase? pb = _pb;
|
||||
if (pb == null || !pb.authStore.isValid) return;
|
||||
final String? type = event['type'] as String?;
|
||||
|
||||
if (type == 'telemetry') {
|
||||
final DateTime now = DateTime.now();
|
||||
if (now.difference(_lastTelemetry) < const Duration(milliseconds: 500)) return;
|
||||
_lastTelemetry = now;
|
||||
}
|
||||
|
||||
_appendEvent(type ?? 'event', event);
|
||||
|
||||
// Reflect registration/connection promptly in the presence record.
|
||||
if (type == 'registration' || type == 'connection') {
|
||||
_upsertDevice(online: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _appendEvent(String kind, Map<String, dynamic> event) async {
|
||||
final PocketBase? pb = _pb;
|
||||
if (pb == null) return;
|
||||
try {
|
||||
await pb.collection('telemetry').create(body: <String, dynamic>{
|
||||
'device': _deviceId,
|
||||
'kind': kind,
|
||||
'payload': event,
|
||||
'owner': pb.authStore.record?.id,
|
||||
});
|
||||
} catch (_) {
|
||||
// best-effort; drop on transient failure
|
||||
}
|
||||
}
|
||||
|
||||
// ── Device presence ─────────────────────────────────────────────────────────
|
||||
|
||||
void _startHeartbeat() {
|
||||
_heartbeat?.cancel();
|
||||
_heartbeat = Timer.periodic(const Duration(seconds: 6), (_) => _upsertDevice(online: true));
|
||||
}
|
||||
|
||||
Future<void> _upsertDevice({required bool online}) async {
|
||||
final PocketBase? pb = _pb;
|
||||
if (pb == null) return;
|
||||
final Map<String, dynamic> st = stateProvider?.call() ?? <String, dynamic>{};
|
||||
final Map<String, dynamic> body = <String, dynamic>{
|
||||
'device': _deviceId,
|
||||
'online': online,
|
||||
'connected': st['connected'] ?? false,
|
||||
'model': st['model'] ?? '',
|
||||
'registration': st['registration'] ?? '',
|
||||
'lastSeen': DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
'owner': pb.authStore.record?.id,
|
||||
};
|
||||
try {
|
||||
if (_devicesRecordId == null) {
|
||||
try {
|
||||
final RecordModel existing =
|
||||
await pb.collection('devices').getFirstListItem('device="$_deviceId"');
|
||||
_devicesRecordId = existing.id;
|
||||
} catch (_) {
|
||||
// none yet
|
||||
}
|
||||
}
|
||||
if (_devicesRecordId == null) {
|
||||
final RecordModel rec = await pb.collection('devices').create(body: body);
|
||||
_devicesRecordId = rec.id;
|
||||
} else {
|
||||
await pb.collection('devices').update(_devicesRecordId!, body: body);
|
||||
}
|
||||
} catch (_) {
|
||||
_devicesRecordId = null; // record may have been removed; recreate next tick
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commands ────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _subscribeCommands() async {
|
||||
final PocketBase? pb = _pb;
|
||||
if (pb == null) return;
|
||||
try {
|
||||
await _cmdUnsub?.call();
|
||||
} catch (_) {}
|
||||
_cmdUnsub = await pb.collection('commands').subscribe(
|
||||
'*',
|
||||
(RecordSubscriptionEvent e) {
|
||||
if (e.action != 'create') return;
|
||||
final RecordModel? rec = e.record;
|
||||
if (rec == null) return;
|
||||
if (rec.getStringValue('device') != _deviceId) return;
|
||||
final String cmd = rec.getStringValue('command');
|
||||
final Map<String, dynamic> payload =
|
||||
(rec.get<Map<String, dynamic>?>('payload') ?? <String, dynamic>{});
|
||||
if (cmd.isNotEmpty) onCommand?.call(cmd, payload);
|
||||
},
|
||||
filter: 'device="$_deviceId"',
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_heartbeat?.cancel();
|
||||
_statusController.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// App-wide PocketBase service instance.
|
||||
final PbService pb = PbService();
|
||||
@@ -1,94 +0,0 @@
|
||||
name: pilotvault_fly
|
||||
description: "DJI Mobile SDK V4 sample app built with Flutter"
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
|
||||
# The following defines the version and build number for your application.
|
||||
# A version number is three numbers separated by dots, like 1.2.43
|
||||
# followed by an optional build number separated by a +.
|
||||
# Both the version and the builder number may be overridden in flutter
|
||||
# build by specifying --build-name and --build-number, respectively.
|
||||
# In Android, build-name is used as versionName while build-number used as versionCode.
|
||||
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.6.2
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||
# dependencies can be manually updated by changing the version numbers below to
|
||||
# the latest version available on pub.dev. To see which dependencies have newer
|
||||
# versions available, run `flutter pub outdated`.
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
# PocketBase backend client (auth, records, realtime).
|
||||
pocketbase: ^0.22.0
|
||||
# Persists the auth token across app launches.
|
||||
shared_preferences: ^2.3.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^5.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
Reference in new issue
Block a user