255 lines
9.8 KiB
Dart
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");
|
|
}
|