Files
PilotVault/Fly Agent App/lib/api/api.dart
T
tajniak81andClaude Opus 5.5 3dd31b257a Add the Fly Agent App, a Remote ID companion to DJI Fly
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>
2026-10-22 19:25:52 +02:00

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;
}
}