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 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 redFlags; factory Flight.fromJson(Map 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 [], ); } /// 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 init() async { _prefs = await SharedPreferences.getInstance(); _token = _prefs!.getString('api_token') ?? ''; _email = _prefs!.getString('api_email') ?? ''; if (isAuthed) unawaited(refreshMe()); } Future 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: {'Content-Type': 'application/json'}, body: jsonEncode({'email': email.trim(), 'password': password}), ), ); if (resp.statusCode == 400) throw const ApiException('Invalid email or password.', status: 400); final Map 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 signOut() async { _token = ''; _email = ''; me = null; await _prefs?.remove('api_token'); await _prefs?.remove('api_email'); notifyListeners(); } Future refreshMe() async { try { final Map 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> drones() async { final Map j = await get('/api/drones'); return ((j['drones'] as List?) ?? const []) .map((Object? e) => Drone.fromJson((e as Map).cast())) .toList(); } Future> flights() async { final Map j = await get('/api/flights'); return ((j['flights'] as List?) ?? const []) .map((Object? e) => Flight.fromJson((e as Map).cast())) .toList(); } Future createDrone({required String name, required String model, required String serial}) async { final Map j = await post('/api/drones', {'name': name, 'model': model, 'serial': serial}); return Drone.fromJson((j['drone'] as Map).cast()); } /// One-line weather for the briefing and the logbook entry, or null when the /// OpenWeather integration is off for this user. Future weatherSummary(double lat, double lon) async { final Map j = await get('/api/integrations/openweather/current?lat=${lat.toStringAsFixed(5)}&lon=${lon.toStringAsFixed(5)}'); final Map? w = (j['weather'] as Map?)?.cast(); if (j['unavailable'] == true || w == null) return null; final bool metric = (j['units'] ?? 'metric') != 'imperial'; final List parts = [ 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 createFlight(Map flight) async { final Map j = await post('/api/flights', flight); return ((j['flight'] as Map?)?['id'] ?? '').toString(); } // ── transport ────────────────────────────────────────────────────────── Map get _headers => { 'Authorization': _token, 'Content-Type': 'application/json', }; Future> get(String path) async => _decode(await _send(() => http.get(Uri.parse('$serverUrl$path'), headers: _headers))); Future> post(String path, Map body) async => _decode( await _send(() => http.post(Uri.parse('$serverUrl$path'), headers: _headers, body: jsonEncode(body))), ); Future _send(Future 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 _decode(http.Response resp) { Map body = {}; if (resp.body.isNotEmpty) { try { body = (jsonDecode(resp.body) as Map).cast(); } 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; } }