import "dart:convert"; import "package:http/http.dart" as http; import "package:shared_preferences/shared_preferences.dart"; import "config.dart"; import "models.dart"; /// Thrown when the API Server returns a non-2xx response. class ApiException implements Exception { final int status; final String message; ApiException(this.status, this.message); @override String toString() => message; } /// The single client for the Car Control API Server. Holds the bearer token and /// attaches it to every request. On 401 it calls [onUnauthorized] so the app can /// route back to login. class ApiClient { String? token; void Function()? onUnauthorized; static const _serverKey = "cc_server_url"; /// The effective API base URL. Defaults to [kDefaultApiBase]; a saved override /// (login screen "Server settings") replaces it via [loadServerUrl]. String baseUrl = kDefaultApiBase; /// Loads a saved server-URL override, if any. Call before the first request. Future loadServerUrl() async { final prefs = await SharedPreferences.getInstance(); final saved = prefs.getString(_serverKey); if (saved != null && saved.isNotEmpty) baseUrl = saved; } /// The current override URL, or "" when using the default. Future serverOverride() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString(_serverKey) ?? ""; } /// Persists a server-URL override. Blank clears it (reverts to the default). /// Trailing slashes are trimmed. Future setServerUrl(String url) async { final prefs = await SharedPreferences.getInstance(); final trimmed = url.trim().replaceAll(RegExp(r"/+$"), ""); if (trimmed.isEmpty) { await prefs.remove(_serverKey); baseUrl = kDefaultApiBase; } else { await prefs.setString(_serverKey, trimmed); baseUrl = trimmed; } } Map get _headers => { "Content-Type": "application/json", if (token != null) "Authorization": "Bearer $token", }; Uri _uri(String path) => Uri.parse("$baseUrl$path"); Future _send(String method, String path, {Object? body}) async { final req = http.Request(method, _uri(path))..headers.addAll(_headers); if (body != null) req.body = jsonEncode(body); final streamed = await http.Client().send(req); final res = await http.Response.fromStream(streamed); if (res.statusCode == 401 && path != "/auth/login") { onUnauthorized?.call(); throw ApiException(401, "Session expired — please log in again."); } if (res.statusCode == 204 || res.body.isEmpty) return null; final data = jsonDecode(res.body); if (res.statusCode < 200 || res.statusCode >= 300) { throw ApiException(res.statusCode, _errorMessage(data, res.reasonPhrase)); } return data; } /// Digs a human-readable message out of the error shapes in play: this /// server's {error}, and PocketBase's {message, data:{field:{message}}} — /// which the user endpoints relay verbatim, so a duplicate email arrives as a /// per-field error rather than a flat string. String _errorMessage(dynamic data, String? fallback) { if (data is! Map) return fallback ?? "Request failed"; if (data["error"] != null) return data["error"].toString(); final fields = data["data"]; if (fields is Map && fields.isNotEmpty) { final parts = fields.entries.map((e) { final v = e.value; final msg = v is Map && v["message"] != null ? v["message"] : v; return "${e.key}: $msg"; }); return parts.join("; "); } if (data["message"] != null) return data["message"].toString(); return fallback ?? "Request failed"; } // --- auth --- /// The API Server proxies login to PocketBase and relays its response /// verbatim, so the user arrives under `record` (PocketBase's name) and the /// token is PocketBase's own — the server no longer mints its own JWT. Future<(String, AuthUser)> login(String email, String password) async { final data = await _send("POST", "/auth/login", body: {"email": email, "password": password}); return (data["token"] as String, AuthUser.fromJson(Map.from(data["record"]))); } // --- cars --- Future> listCars() async { final data = await _send("GET", "/cars") as List; return data.map((e) => Car.fromJson(Map.from(e))).toList(); } Future getCar(String id) async { final data = await _send("GET", "/cars/$id"); return Car.fromJson(Map.from(data)); } Future createCar(Map body) async { final data = await _send("POST", "/cars", body: body); return Car.fromJson(Map.from(data)); } Future updateCar(String id, Map body) async { final data = await _send("PATCH", "/cars/$id", body: body); return Car.fromJson(Map.from(data)); } Future deleteCar(String id) => _send("DELETE", "/cars/$id"); /// What this car's page shows and in which order — its own endpoint rather /// than part of the car PATCH, so saving the car form can never silently /// reveal a hidden tab or undo an arrangement. Every field is optional; only /// the ones passed are written. Future updateCarView( String id, { List? hiddenTabs, List? hiddenFields, List? tabOrder, List? fieldOrder, List? metricOrder, }) async { final body = { if (hiddenTabs != null) "hiddenTabs": hiddenTabs, if (hiddenFields != null) "hiddenFields": hiddenFields, if (tabOrder != null) "tabOrder": tabOrder, if (fieldOrder != null) "fieldOrder": fieldOrder, if (metricOrder != null) "metricOrder": metricOrder, }; final data = await _send("PUT", "/cars/$id/view", body: body); return Car.fromJson(Map.from(data)); } // --- sharing (owner-only) --- Future> listCarShares(String carId) async { final data = await _send("GET", "/cars/$carId/shares") as List; return data.map((e) => CarShare.fromJson(Map.from(e))).toList(); } Future addCarShare(String carId, String email, String permission) async { final data = await _send("POST", "/cars/$carId/shares", body: {"email": email, "permission": permission}); return CarShare.fromJson(Map.from(data)); } Future removeCarShare(String carId, String userId) => _send("DELETE", "/cars/$carId/shares/$userId"); // --- user management (admin or superadmin) --- // Admins are scoped by the server to their own organization; superadmins see // everyone. Responses are enveloped ({users}/{user}). Future> listUsers() async { final data = await _send("GET", "/users"); final items = (data["users"] ?? []) as List; return items.map((e) => AdminUser.fromJson(Map.from(e))).toList(); } /// Creates a user. [organization] is the superadmin's choice of tenant — an /// empty string deliberately means "no organization". Omit it entirely for an /// admin: the server forces its own org on their members, so sending anything /// would be noise the server ignores. Future createUser({ required String email, required String password, String? name, String role = "user", String? organization, }) async { final data = await _send("POST", "/users", body: { "email": email, "password": password, "name": name ?? "", "role": role, if (organization != null) "organization": organization, }); return AdminUser.fromJson(Map.from(data["user"])); } Future updateUser(String id, {String? name, String? role}) async { final body = {}; if (name != null) body["name"] = name; if (role != null) body["role"] = role; final data = await _send("PATCH", "/users/$id", body: body); return AdminUser.fromJson(Map.from(data["user"])); } /// Password resets are a field on the user PATCH now, not a separate endpoint. Future setUserPassword(String id, String newPassword) => _send("PATCH", "/users/$id", body: {"password": newPassword}); Future deleteUser(String id) => _send("DELETE", "/users/$id"); // --- organizations --- // Listing is manager-only (an admin sees just their own org), but creating is // open to any user who has none — the creator becomes that org's admin in the // same request. Renames and deletes are scoped to the caller's own org unless // they are a superadmin. Responses are enveloped ({organizations}/{organization}). Future> listOrgs() async { final data = await _send("GET", "/orgs"); final items = (data["organizations"] ?? []) as List; return items.map((e) => Organization.fromJson(Map.from(e))).toList(); } Future createOrg(String name) async { final data = await _send("POST", "/orgs", body: {"name": name}); return Organization.fromJson(Map.from(data["organization"])); } Future renameOrg(String id, String name) async { final data = await _send("PATCH", "/orgs/$id", body: {"name": name}); return Organization.fromJson(Map.from(data["organization"])); } Future deleteOrg(String id) => _send("DELETE", "/orgs/$id"); // --- service records --- Future> listCarServices(String carId) async { final data = await _send("GET", "/cars/$carId/service-records") as List; return data.map((e) => ServiceRecord.fromJson(Map.from(e))).toList(); } Future createService(Map body) async { final data = await _send("POST", "/service-records", body: body); return ServiceRecord.fromJson(Map.from(data)); } Future updateService(String id, Map body) async { final data = await _send("PATCH", "/service-records/$id", body: body); return ServiceRecord.fromJson(Map.from(data)); } Future deleteService(String id) => _send("DELETE", "/service-records/$id"); // --- parts --- Future> listCarParts(String carId) async { final data = await _send("GET", "/cars/$carId/parts") as List; return data.map((e) => Part.fromJson(Map.from(e))).toList(); } Future createPart(Map body) async { final data = await _send("POST", "/parts", body: body); return Part.fromJson(Map.from(data)); } Future updatePart(String id, Map body) async { final data = await _send("PATCH", "/parts/$id", body: body); return Part.fromJson(Map.from(data)); } Future deletePart(String id) => _send("DELETE", "/parts/$id"); // --- technical checks --- Future> listCarTechnicalChecks(String carId) async { final data = await _send("GET", "/cars/$carId/technical-checks") as List; return data.map((e) => TechnicalCheck.fromJson(Map.from(e))).toList(); } Future createTechnicalCheck(Map body) async { final data = await _send("POST", "/technical-checks", body: body); return TechnicalCheck.fromJson(Map.from(data)); } Future updateTechnicalCheck(String id, Map body) async { final data = await _send("PATCH", "/technical-checks/$id", body: body); return TechnicalCheck.fromJson(Map.from(data)); } Future deleteTechnicalCheck(String id) => _send("DELETE", "/technical-checks/$id"); // --- fuel --- Future> listCarFuelEntries(String carId) async { final data = await _send("GET", "/cars/$carId/fuel-entries") as List; return data.map((e) => FuelEntry.fromJson(Map.from(e))).toList(); } Future getCarFuelStats(String carId) async { final data = await _send("GET", "/cars/$carId/fuel-stats"); return FuelStats.fromJson(Map.from(data)); } Future createFuelEntry(Map body) async { final data = await _send("POST", "/fuel-entries", body: body); return FuelEntry.fromJson(Map.from(data)); } Future updateFuelEntry(String id, Map body) async { final data = await _send("PATCH", "/fuel-entries/$id", body: body); return FuelEntry.fromJson(Map.from(data)); } Future deleteFuelEntry(String id) => _send("DELETE", "/fuel-entries/$id"); // --- charging sessions --- // The electric counterpart of the fuel entries, on identical terms: the // per-car list plus the derived summary, and CRUD on the flat collection. Future> listCarChargingSessions(String carId) async { final data = await _send("GET", "/cars/$carId/charging-sessions") as List; return data.map((e) => ChargingSession.fromJson(Map.from(e))).toList(); } Future getCarChargingStats(String carId) async { final data = await _send("GET", "/cars/$carId/charging-stats"); return ChargingStats.fromJson(Map.from(data)); } Future createChargingSession(Map body) async { final data = await _send("POST", "/charging-sessions", body: body); return ChargingSession.fromJson(Map.from(data)); } Future updateChargingSession(String id, Map body) async { final data = await _send("PATCH", "/charging-sessions/$id", body: body); return ChargingSession.fromJson(Map.from(data)); } Future deleteChargingSession(String id) => _send("DELETE", "/charging-sessions/$id"); // --- maintenance --- Future> listCarMaintenance(String carId) async { final data = await _send("GET", "/cars/$carId/maintenance") as List; return data.map((e) => MaintenanceEntry.fromJson(Map.from(e))).toList(); } Future createMaintenance(Map body) async { final data = await _send("POST", "/maintenance", body: body); return MaintenanceEntry.fromJson(Map.from(data)); } Future updateMaintenance(String id, Map body) async { final data = await _send("PATCH", "/maintenance/$id", body: body); return MaintenanceEntry.fromJson(Map.from(data)); } Future deleteMaintenance(String id) => _send("DELETE", "/maintenance/$id"); // --- documents --- // The path is /car-documents so it can't be mistaken for the user-facing // account documents other Vault services expose. Future> listCarDocuments(String carId) async { final data = await _send("GET", "/cars/$carId/documents") as List; return data.map((e) => CarDocument.fromJson(Map.from(e))).toList(); } Future createDocument(Map body) async { final data = await _send("POST", "/car-documents", body: body); return CarDocument.fromJson(Map.from(data)); } Future updateDocument(String id, Map body) async { final data = await _send("PATCH", "/car-documents/$id", body: body); return CarDocument.fromJson(Map.from(data)); } Future deleteDocument(String id) => _send("DELETE", "/car-documents/$id"); // --- reminders --- Future> listCarReminders(String carId) async { final data = await _send("GET", "/cars/$carId/reminders") as List; return data.map((e) => Reminder.fromJson(Map.from(e))).toList(); } Future createReminder(Map body) async { final data = await _send("POST", "/reminders", body: body); return Reminder.fromJson(Map.from(data)); } Future updateReminder(String id, Map body) async { final data = await _send("PATCH", "/reminders/$id", body: body); return Reminder.fromJson(Map.from(data)); } Future deleteReminder(String id) => _send("DELETE", "/reminders/$id"); /// Completing a recurring reminder rolls its trigger forward instead of /// closing it out; the server decides which, so the caller just re-reads. Future completeReminder(String id) async { final data = await _send("POST", "/reminders/$id/complete"); return Reminder.fromJson(Map.from(data)); } // --- attachments --- // Every attachable collection takes a file on identical terms, so one set of // helpers is parameterized by the collection's path rather than repeated six // times. See the API's attachments.go. /// Uploads (or replaces) a record's file. Returns the re-read record JSON, so /// the caller decodes it into whichever model it owns. Future> uploadAttachment( String path, String id, List bytes, String filename) async { final req = http.MultipartRequest("POST", _uri("$path/$id/file")); if (token != null) req.headers["Authorization"] = "Bearer $token"; req.files.add(http.MultipartFile.fromBytes("file", bytes, filename: filename)); final res = await http.Response.fromStream(await req.send()); if (res.statusCode == 401) { onUnauthorized?.call(); throw ApiException(401, "Session expired — please log in again."); } final data = jsonDecode(res.body); if (res.statusCode < 200 || res.statusCode >= 300) { throw ApiException(res.statusCode, _errorMessage(data, res.reasonPhrase)); } return Map.from(data); } /// The attachment's bytes, or null when there is no file. Never a public URL — /// the server re-checks car access on every fetch. Future?> getAttachmentBytes(String path, String id) async { final res = await http.get(_uri("$path/$id/file"), headers: {if (token != null) "Authorization": "Bearer $token"}); if (res.statusCode == 200) return res.bodyBytes; return null; } Future deleteAttachment(String path, String id) => _send("DELETE", "$path/$id/file"); // --- settings: profile / account --- Future getMe() async { final data = await _send("GET", "/me"); return UserProfile.fromJson(Map.from(data)); } Future updateMe(Map patch) async { final data = await _send("PATCH", "/me", body: patch); return UserProfile.fromJson(Map.from(data)); } Future changePassword(String oldPassword, String newPassword) => _send( "POST", "/me/password", body: {"oldPassword": oldPassword, "newPassword": newPassword}, ); Future requestVerification() => _send("POST", "/me/verify/request"); // --- settings: avatar --- Future uploadAvatar(List bytes, String filename) async { final req = http.MultipartRequest("POST", _uri("/me/avatar")); if (token != null) req.headers["Authorization"] = "Bearer $token"; req.files.add(http.MultipartFile.fromBytes("avatar", bytes, filename: filename)); final res = await http.Response.fromStream(await req.send()); if (res.statusCode == 401) { onUnauthorized?.call(); throw ApiException(401, "Session expired — please log in again."); } final data = jsonDecode(res.body); if (res.statusCode < 200 || res.statusCode >= 300) { final msg = data is Map && data["error"] != null ? data["error"].toString() : res.reasonPhrase; throw ApiException(res.statusCode, msg ?? "Upload failed"); } return UserProfile.fromJson(Map.from(data)); } Future?> getAvatarBytes() async { final res = await http.get(_uri("/me/avatar"), headers: {if (token != null) "Authorization": "Bearer $token"}); if (res.statusCode == 200) return res.bodyBytes; return null; } Future deleteAvatar() => _send("DELETE", "/me/avatar"); // --- settings: account deletion --- Future requestAccountDeletion(String confirmEmail) async { final data = await _send("POST", "/me/delete", body: {"confirmEmail": confirmEmail}); final at = (data is Map) ? data["eligibleAt"] : null; return at == null ? null : DateTime.tryParse(at.toString()); } Future cancelAccountDeletion() => _send("POST", "/me/delete/cancel"); Future finalizeAccountDeletion() => _send("DELETE", "/me"); // --- data export / import --- /// The whole account as a JSON file: the profile plus every car the user owns /// with its service records and parts. Cars merely shared with them are not /// included. Returns the bytes and the filename the server named it, which is /// dated — the phone has to write the file itself, so it needs both. Future<(List, String)> exportData() async { final res = await http.get(_uri("/me/export"), headers: {if (token != null) "Authorization": "Bearer $token"}); if (res.statusCode == 401) { onUnauthorized?.call(); throw ApiException(401, "Session expired — please log in again."); } if (res.statusCode < 200 || res.statusCode >= 300) { throw ApiException(res.statusCode, _errorMessage(_tryDecode(res.body), res.reasonPhrase)); } return (res.bodyBytes, _filenameFrom(res.headers["content-disposition"])); } /// Adds the cars in a previously exported file. Always creates new records — /// nothing is merged with or overwritten, so importing the same file twice /// leaves two copies rather than one updated one. Future importData(Map payload) async { final data = await _send("POST", "/me/import", body: payload); return ImportResult.fromJson(Map.from(data)); } static dynamic _tryDecode(String body) { try { return jsonDecode(body); } catch (_) { return null; } } /// The filename out of `attachment; filename="…"`, falling back to a plain /// name so the export is still saveable if the header is missing or unquoted. static String _filenameFrom(String? disposition) { if (disposition == null) return "drivervault-export.json"; final m = RegExp(r'filename="?([^";]+)"?').firstMatch(disposition); final name = m?.group(1)?.trim() ?? ""; return name.isEmpty ? "drivervault-export.json" : name; } // --- vehicle providers (the connected-service tab) --- // Every call runs server-side under *this* user's manufacturer account, so a // car shared from someone else only shows provider data when that vehicle is // on this user's account too. A closed gate is a 200 with `unavailable` set // rather than an error: "we asked, and here is why there is nothing". Future> listVehicleProviders() async { final data = await _send("GET", "/vehicle-providers"); final items = (data["providers"] ?? []) as List; return items.map((e) => VehicleProvider.fromJson(Map.from(e))).toList(); } Future> listProviderVehicles(String provider) async { final data = await _send("GET", "/vehicle-providers/${Uri.encodeComponent(provider)}/vehicles"); final items = (data["vehicles"] ?? []) as List; return items.map((e) => ProviderVehicle.fromJson(Map.from(e))).toList(); } Future getCarProvider(String carId) async { final data = await _send("GET", "/cars/$carId/provider"); return ProviderSnapshot.fromJson(Map.from(data)); } /// Links this car to a vehicle on the caller's provider account; an empty /// [provider] unlinks it. Returns the updated car. Future linkCarProvider(String carId, {String provider = "", String vehicleId = ""}) async { final data = await _send("POST", "/cars/$carId/provider", body: {"provider": provider, "vehicleId": vehicleId}); return Car.fromJson(Map.from(data)); } /// Re-applies the provider's data to the car. [include] selects what to take /// (identity, fuelType, dates, odometer); omitted means everything. Future syncCarProvider(String carId, {Map? include}) async { final data = await _send("POST", "/cars/$carId/provider/sync", body: {if (include != null) "include": include}); final car = (data is Map && data["car"] != null) ? data["car"] : data; return Car.fromJson(Map.from(car)); } // --- integrations (per-user plugin settings, superadmin → org → user cascade) --- // getToyota/getAnkerSolix return the resolved view (effective/own/locked per // field, secrets and inherited values masked); saveToyota/saveAnkerSolix write // the caller's editable layer (scope "user" by default, "org" for org admins); // testToyota/testAnkerSolix run a live login probe under the resolved creds. Future getToyota() async { final data = await _send("GET", "/integrations/toyota"); return IntegrationView.fromJson(Map.from(data)); } Future saveToyota(Map body) async { final data = await _send("PUT", "/integrations/toyota", body: body); return IntegrationView.fromJson(Map.from(data)); } Future testToyota() async { final data = await _send("POST", "/integrations/toyota/health"); final h = (data is Map ? data["health"] : null) ?? {}; return IntegrationHealth.fromJson(Map.from(h)); } Future getAnkerSolix() async { final data = await _send("GET", "/integrations/anker-solix"); return IntegrationView.fromJson(Map.from(data)); } Future saveAnkerSolix(Map body) async { final data = await _send("PUT", "/integrations/anker-solix", body: body); return IntegrationView.fromJson(Map.from(data)); } Future testAnkerSolix() async { final data = await _send("POST", "/integrations/anker-solix/health"); final h = (data is Map ? data["health"] : null) ?? {}; return IntegrationHealth.fromJson(Map.from(h)); } // --- Anker Solix OCPP control (per charger) --- // getAnkerControl returns the control mode, connection status, provisioning // endpoint + token, and a live status snapshot; ankerControlToken (re)generates // the per-charger token (returned exactly once); ankerControlRevoke deletes it; // ankerControlAction issues one OCPP command (start/stop/limit/clear-limit/reset/…). String _sn(String sn) => Uri.encodeComponent(sn); Future getAnkerControl(String sn) async { final data = await _send("GET", "/integrations/anker-solix/chargers/${_sn(sn)}/control"); return AnkerControl.fromJson(Map.from(data)); } /// (Re)generates the per-charger control token; the plaintext token is returned /// exactly once, so the caller must show it immediately. Future ankerControlToken(String sn) async { final data = await _send("POST", "/integrations/anker-solix/chargers/${_sn(sn)}/control/token"); return (data is Map ? _asString(data["token"]) : ""); } Future ankerControlRevoke(String sn) => _send("DELETE", "/integrations/anker-solix/chargers/${_sn(sn)}/control/token"); Future ankerControlAction(String sn, String action, [Map body = const {}]) => _send("POST", "/integrations/anker-solix/chargers/${_sn(sn)}/$action", body: body); static String _asString(dynamic v) => v == null ? "" : v.toString(); }