A Flutter app that runs next to DJI Fly without the DJI SDK. It receives the aircraft's Remote ID (Bluetooth, BT5, Wi-Fi NaN and Beacon) in a foreground service, streams the paired aircraft to the hub over /ws/device, and turns take-off to landing into a logbook entry filed through /api/flights, queued when offline. Covers phases A0-A3 of the Fly Agent plan; needs no server change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
8.6 KiB
Dart
236 lines
8.6 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
/// Raised for any non-2xx answer; [message] is what the server said.
|
|
class ApiException implements Exception {
|
|
const ApiException(this.message, {this.status = 0});
|
|
final String message;
|
|
final int status;
|
|
|
|
/// No HTTP answer at all (offline, server down): worth queueing and retrying.
|
|
bool get isNetwork => status == 0;
|
|
|
|
@override
|
|
String toString() => message;
|
|
}
|
|
|
|
class Me {
|
|
const Me({required this.id, required this.email, required this.role, required this.orgName});
|
|
final String id;
|
|
final String email;
|
|
final String role;
|
|
final String orgName;
|
|
}
|
|
|
|
class Drone {
|
|
const Drone({required this.id, required this.displayName, required this.model, required this.serial});
|
|
final String id;
|
|
final String displayName;
|
|
final String model;
|
|
final String serial;
|
|
|
|
factory Drone.fromJson(Map<String, dynamic> j) => Drone(
|
|
id: (j['id'] ?? '').toString(),
|
|
displayName: (j['displayName'] ?? '').toString(),
|
|
model: (j['model'] ?? '').toString(),
|
|
serial: (j['serial'] ?? '').toString(),
|
|
);
|
|
}
|
|
|
|
class Flight {
|
|
const Flight({
|
|
required this.id,
|
|
required this.operationDate,
|
|
required this.startTime,
|
|
required this.endTime,
|
|
required this.droneName,
|
|
required this.maxAltitudeAgl,
|
|
required this.purpose,
|
|
required this.redFlags,
|
|
});
|
|
final String id;
|
|
final String operationDate;
|
|
final String startTime;
|
|
final String endTime;
|
|
final String droneName;
|
|
final double maxAltitudeAgl;
|
|
final String purpose;
|
|
final List<String> redFlags;
|
|
|
|
factory Flight.fromJson(Map<String, dynamic> j) => Flight(
|
|
id: (j['id'] ?? '').toString(),
|
|
operationDate: (j['operationDate'] ?? '').toString(),
|
|
startTime: (j['startTime'] ?? '').toString(),
|
|
endTime: (j['endTime'] ?? '').toString(),
|
|
droneName: (j['droneName'] ?? '').toString(),
|
|
maxAltitudeAgl: (j['maxAltitudeAgl'] as num?)?.toDouble() ?? 0,
|
|
purpose: (j['purpose'] ?? '').toString(),
|
|
redFlags: ((j['compliance'] as Map?)?['redFlags'] as List?)?.map((Object? e) => e.toString()).toList() ??
|
|
const <String>[],
|
|
);
|
|
}
|
|
|
|
/// Talks to the PilotVault **API Server** (never PocketBase directly), the
|
|
/// same gateway the Fly App and Web App use. Holds the session token.
|
|
class Api extends ChangeNotifier {
|
|
static const String defaultServer = 'http://10.2.1.101:8080';
|
|
|
|
SharedPreferences? _prefs;
|
|
String _token = '';
|
|
String _email = '';
|
|
Me? me;
|
|
|
|
bool get isAuthed => _token.isNotEmpty;
|
|
String get email => _email;
|
|
String get serverUrl => _prefs?.getString('api_url') ?? defaultServer;
|
|
|
|
Future<void> init() async {
|
|
_prefs = await SharedPreferences.getInstance();
|
|
_token = _prefs!.getString('api_token') ?? '';
|
|
_email = _prefs!.getString('api_email') ?? '';
|
|
if (isAuthed) unawaited(refreshMe());
|
|
}
|
|
|
|
Future<void> signIn({required String server, required String email, required String password}) async {
|
|
final String base = normalizeServer(server);
|
|
final http.Response resp = await _send(
|
|
() => http.post(
|
|
Uri.parse('$base/api/auth/login'),
|
|
headers: <String, String>{'Content-Type': 'application/json'},
|
|
body: jsonEncode(<String, String>{'email': email.trim(), 'password': password}),
|
|
),
|
|
);
|
|
if (resp.statusCode == 400) throw const ApiException('Invalid email or password.', status: 400);
|
|
final Map<String, dynamic> body = _decode(resp);
|
|
final String token = (body['token'] ?? '').toString();
|
|
if (token.isEmpty) throw const ApiException('Unexpected response from the API server.', status: 500);
|
|
_token = token;
|
|
_email = ((body['record'] as Map?)?['email'] ?? email.trim()).toString();
|
|
await _prefs?.setString('api_url', base);
|
|
await _prefs?.setString('api_token', _token);
|
|
await _prefs?.setString('api_email', _email);
|
|
await refreshMe();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> signOut() async {
|
|
_token = '';
|
|
_email = '';
|
|
me = null;
|
|
await _prefs?.remove('api_token');
|
|
await _prefs?.remove('api_email');
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> refreshMe() async {
|
|
try {
|
|
final Map<String, dynamic> j = await get('/api/me');
|
|
me = Me(
|
|
id: (j['id'] ?? '').toString(),
|
|
email: (j['email'] ?? '').toString(),
|
|
role: (j['role'] ?? '').toString(),
|
|
orgName: (j['organizationName'] ?? '').toString(),
|
|
);
|
|
notifyListeners();
|
|
} on ApiException catch (e) {
|
|
// An expired token signs the user out; being offline does not.
|
|
if (e.status == 401) await signOut();
|
|
}
|
|
}
|
|
|
|
Future<List<Drone>> drones() async {
|
|
final Map<String, dynamic> j = await get('/api/drones');
|
|
return ((j['drones'] as List?) ?? const <Object?>[])
|
|
.map((Object? e) => Drone.fromJson((e as Map).cast<String, dynamic>()))
|
|
.toList();
|
|
}
|
|
|
|
Future<List<Flight>> flights() async {
|
|
final Map<String, dynamic> j = await get('/api/flights');
|
|
return ((j['flights'] as List?) ?? const <Object?>[])
|
|
.map((Object? e) => Flight.fromJson((e as Map).cast<String, dynamic>()))
|
|
.toList();
|
|
}
|
|
|
|
Future<Drone> createDrone({required String name, required String model, required String serial}) async {
|
|
final Map<String, dynamic> j =
|
|
await post('/api/drones', <String, dynamic>{'name': name, 'model': model, 'serial': serial});
|
|
return Drone.fromJson((j['drone'] as Map).cast<String, dynamic>());
|
|
}
|
|
|
|
/// One-line weather for the briefing and the logbook entry, or null when the
|
|
/// OpenWeather integration is off for this user.
|
|
Future<String?> weatherSummary(double lat, double lon) async {
|
|
final Map<String, dynamic> j =
|
|
await get('/api/integrations/openweather/current?lat=${lat.toStringAsFixed(5)}&lon=${lon.toStringAsFixed(5)}');
|
|
final Map<String, dynamic>? w = (j['weather'] as Map?)?.cast<String, dynamic>();
|
|
if (j['unavailable'] == true || w == null) return null;
|
|
final bool metric = (j['units'] ?? 'metric') != 'imperial';
|
|
final List<String> parts = <String>[
|
|
if (w['temp'] != null) '${(w['temp'] as num).round()}°${metric ? 'C' : 'F'}',
|
|
if (w['windSpeed'] != null)
|
|
'wind ${(w['windSpeed'] as num).toStringAsFixed(1)} ${metric ? 'm/s' : 'mph'}'
|
|
'${w['windDeg'] != null ? ' from ${w['windDeg']}°' : ''}',
|
|
if ((w['description'] ?? '').toString().isNotEmpty) w['description'].toString(),
|
|
if (w['clouds'] != null) 'clouds ${w['clouds']}%',
|
|
];
|
|
return parts.isEmpty ? null : parts.join(', ');
|
|
}
|
|
|
|
/// Files a logbook entry; returns the new flight id.
|
|
Future<String> createFlight(Map<String, dynamic> flight) async {
|
|
final Map<String, dynamic> j = await post('/api/flights', flight);
|
|
return ((j['flight'] as Map?)?['id'] ?? '').toString();
|
|
}
|
|
|
|
// ── transport ──────────────────────────────────────────────────────────
|
|
|
|
Map<String, String> get _headers => <String, String>{
|
|
'Authorization': _token,
|
|
'Content-Type': 'application/json',
|
|
};
|
|
|
|
Future<Map<String, dynamic>> get(String path) async =>
|
|
_decode(await _send(() => http.get(Uri.parse('$serverUrl$path'), headers: _headers)));
|
|
|
|
Future<Map<String, dynamic>> post(String path, Map<String, dynamic> body) async => _decode(
|
|
await _send(() => http.post(Uri.parse('$serverUrl$path'), headers: _headers, body: jsonEncode(body))),
|
|
);
|
|
|
|
Future<http.Response> _send(Future<http.Response> Function() request) async {
|
|
try {
|
|
return await request().timeout(const Duration(seconds: 15));
|
|
} on TimeoutException {
|
|
throw const ApiException('The API server did not answer.');
|
|
} catch (e) {
|
|
throw ApiException('Cannot reach the API server ($e).');
|
|
}
|
|
}
|
|
|
|
Map<String, dynamic> _decode(http.Response resp) {
|
|
Map<String, dynamic> body = <String, dynamic>{};
|
|
if (resp.body.isNotEmpty) {
|
|
try {
|
|
body = (jsonDecode(resp.body) as Map).cast<String, dynamic>();
|
|
} catch (_) {}
|
|
}
|
|
if (resp.statusCode >= 200 && resp.statusCode < 300) return body;
|
|
final String msg = (body['error'] ?? body['message'] ?? 'Request failed (${resp.statusCode})').toString();
|
|
throw ApiException(msg, status: resp.statusCode);
|
|
}
|
|
|
|
static String normalizeServer(String url) {
|
|
String u = url.trim();
|
|
if (u.isEmpty) return defaultServer;
|
|
if (!u.startsWith('http://') && !u.startsWith('https://')) u = 'http://$u';
|
|
while (u.endsWith('/')) {
|
|
u = u.substring(0, u.length - 1);
|
|
}
|
|
return u;
|
|
}
|
|
}
|