Files
DriverVault/Phone App/lib/api.dart
T
2026-07-06 08:50:52 +02:00

255 lines
9.8 KiB
Dart

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<void> 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<String> 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<void> 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<String, String> get _headers => {
"Content-Type": "application/json",
if (token != null) "Authorization": "Bearer $token",
};
Uri _uri(String path) => Uri.parse("$baseUrl$path");
Future<dynamic> _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<String, dynamic>.from(data["user"])));
}
// --- cars ---
Future<List<Car>> listCars() async {
final data = await _send("GET", "/cars") as List;
return data.map((e) => Car.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<Car> getCar(String id) async {
final data = await _send("GET", "/cars/$id");
return Car.fromJson(Map<String, dynamic>.from(data));
}
Future<Car> createCar(Map<String, dynamic> body) async {
final data = await _send("POST", "/cars", body: body);
return Car.fromJson(Map<String, dynamic>.from(data));
}
Future<Car> updateCar(String id, Map<String, dynamic> body) async {
final data = await _send("PATCH", "/cars/$id", body: body);
return Car.fromJson(Map<String, dynamic>.from(data));
}
Future<void> deleteCar(String id) => _send("DELETE", "/cars/$id");
// --- sharing (owner-only) ---
Future<List<CarShare>> listCarShares(String carId) async {
final data = await _send("GET", "/cars/$carId/shares") as List;
return data.map((e) => CarShare.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<CarShare> 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<String, dynamic>.from(data));
}
Future<void> removeCarShare(String carId, String userId) =>
_send("DELETE", "/cars/$carId/shares/$userId");
// --- admin: user management (admin role only) ---
Future<List<AdminUser>> listUsers() async {
final data = await _send("GET", "/admin/users") as List;
return data.map((e) => AdminUser.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<AdminUser> 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<String, dynamic>.from(data));
}
Future<AdminUser> updateUser(String id, {String? name, String? role}) async {
final body = <String, dynamic>{};
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<String, dynamic>.from(data));
}
Future<void> setUserPassword(String id, String newPassword) =>
_send("POST", "/admin/users/$id/password", body: {"newPassword": newPassword});
Future<void> deleteUser(String id) => _send("DELETE", "/admin/users/$id");
// --- service records ---
Future<List<ServiceRecord>> listCarServices(String carId) async {
final data = await _send("GET", "/cars/$carId/service-records") as List;
return data.map((e) => ServiceRecord.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<ServiceRecord> createService(Map<String, dynamic> body) async {
final data = await _send("POST", "/service-records", body: body);
return ServiceRecord.fromJson(Map<String, dynamic>.from(data));
}
Future<ServiceRecord> updateService(String id, Map<String, dynamic> body) async {
final data = await _send("PATCH", "/service-records/$id", body: body);
return ServiceRecord.fromJson(Map<String, dynamic>.from(data));
}
Future<void> deleteService(String id) => _send("DELETE", "/service-records/$id");
// --- parts ---
Future<List<Part>> listCarParts(String carId) async {
final data = await _send("GET", "/cars/$carId/parts") as List;
return data.map((e) => Part.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<Part> createPart(Map<String, dynamic> body) async {
final data = await _send("POST", "/parts", body: body);
return Part.fromJson(Map<String, dynamic>.from(data));
}
Future<Part> updatePart(String id, Map<String, dynamic> body) async {
final data = await _send("PATCH", "/parts/$id", body: body);
return Part.fromJson(Map<String, dynamic>.from(data));
}
Future<void> deletePart(String id) => _send("DELETE", "/parts/$id");
// --- settings: profile / account ---
Future<UserProfile> getMe() async {
final data = await _send("GET", "/me");
return UserProfile.fromJson(Map<String, dynamic>.from(data));
}
Future<UserProfile> updateMe(Map<String, dynamic> patch) async {
final data = await _send("PATCH", "/me", body: patch);
return UserProfile.fromJson(Map<String, dynamic>.from(data));
}
Future<void> changePassword(String oldPassword, String newPassword) => _send(
"POST",
"/me/password",
body: {"oldPassword": oldPassword, "newPassword": newPassword},
);
Future<void> requestVerification() => _send("POST", "/me/verify/request");
// --- settings: avatar ---
Future<UserProfile> uploadAvatar(List<int> 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<String, dynamic>.from(data));
}
Future<List<int>?> 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<void> deleteAvatar() => _send("DELETE", "/me/avatar");
// --- settings: sessions ---
Future<List<Session>> listSessions() async {
final data = await _send("GET", "/sessions") as List;
return data.map((e) => Session.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<void> revokeSession(String id) => _send("DELETE", "/sessions/$id");
Future<void> revokeOtherSessions() => _send("DELETE", "/sessions");
// --- settings: account deletion ---
Future<DateTime?> 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<void> cancelAccountDeletion() => _send("POST", "/me/delete/cancel");
Future<void> finalizeAccountDeletion() => _send("DELETE", "/me");
}