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) { final msg = data is Map && data["error"] != null ? data["error"].toString() : res.reasonPhrase; throw ApiException(res.statusCode, msg ?? "Request failed"); } return data; } // --- auth --- 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["user"]))); } // --- 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"); // --- 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"); // --- admin: user management (admin role only) --- Future> listUsers() async { final data = await _send("GET", "/admin/users") as List; return data.map((e) => AdminUser.fromJson(Map.from(e))).toList(); } Future createUser( {required String email, required String password, String? name, String role = "user"}) async { final data = await _send("POST", "/admin/users", body: {"email": email, "password": password, "name": name ?? "", "role": role}); return AdminUser.fromJson(Map.from(data)); } 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", "/admin/users/$id", body: body); return AdminUser.fromJson(Map.from(data)); } Future setUserPassword(String id, String newPassword) => _send("POST", "/admin/users/$id/password", body: {"newPassword": newPassword}); Future deleteUser(String id) => _send("DELETE", "/admin/users/$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"); // --- 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: sessions --- Future> listSessions() async { final data = await _send("GET", "/sessions") as List; return data.map((e) => Session.fromJson(Map.from(e))).toList(); } Future revokeSession(String id) => _send("DELETE", "/sessions/$id"); Future revokeOtherSessions() => _send("DELETE", "/sessions"); // --- 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"); }