The web app can be pointed at two DriverVault stacks and switch between them in a click. The phone had one address and one session: reaching a second garage meant retyping the API base in Server settings and signing in again, losing the first server's token on the way — the same act, undone, every time you switched back. So lib/servers.dart is the web's servers.js ported rather than reinvented, down to the storage keys: cc_servers holds the list, cc_active_server the one being read, cc_session_<id> the token minted by that server and no other. The two apps describe the same thing the same way, and the upgrade path falls out of it — cc_token, cc_user and cc_server_url are read once at boot and folded onto the home entry, so the build carrying this signs nobody out. Home is the address the build ships with (kDefaultApiBase, still overridable per device from the login screen) and cannot be removed: it is what a dropped session falls back to. Any other server is added by address, with /api appended if the path is left off, because a server a phone can reach is internet-facing already. The part worth reading twice is which session a rejection ends. ApiClient no longer holds a base or a token — it pins the active server's id, base and token at the moment a request goes out, so a 401 arriving after a switch clears the session of the server that actually refused it rather than whichever one is active by then. The fallback is the web's: a remote server timing out drops its own token, the app returns to home while home is still signed in, and only when nothing is left to fall back to does the login screen come back. Log out still clears every server at once, since leaving the app means leaving all of them. Switching rebuilds the shell, keyed on the active id, because record ids belong to the server that issued them — a garage, a charging page and a settings panel still holding the other server's rows would each have to be told to forget them separately. The appearance prefs come across with the profile of whoever owns the account on the server now active. Where the picker lives is the one place the phone cannot copy the web. There is no app rail here, so it became the first button in the Garage header, beside the theme toggle and log out, which is that same cluster. It names the active server once there is a choice and goes straight to adding the second when there isn't; the eyebrow reads GARAGE · Work for the reason the rail names it — two garages otherwise look identical. The login screen gets its own way in, because a remote session can expire and land you there with that server still active, and a picker reachable only from inside the app would leave nowhere to go. One judgment call inside the sheet: saving a connected server at a new address saves and stops, rather than falling through to the sign-in it now needs. The token was minted by the PocketBase behind the old address and is dropped with it, but the credentials to replace it were never asked for, so treating the save as a login would report an empty password as the error. The strings are copied out of Web App/web/src/i18n/ like the rest of the shared wording. Two are not the web's: home reads "the address this app ships with" rather than "served with this app", since the phone has no origin to be served from, and sameOrigin has no meaning here at all and was dropped. Biometric sign-in stays global. It was never per-server and replays its stored credentials against whichever server is active; making it per-server is a change of its own, and the login screen now names the server it is about to sign into. Nothing changes on the API Server. On Android there is no origin to allow, so the CORS list the web app has to satisfy to reach a second server doesn't enter into it. Verified: flutter analyze is clean and flutter test passes, 35 tests to 46. The new ones cover the registry — a bare origin gaining its /api, a fresh install knowing one unnamed server on the built-in address, the legacy keys landing on home and being cleared, two servers holding their tokens apart, a rename keeping a session where a move drops it, removing the active server falling back to a home that is still signed in, home refusing to be removed, and a restart reading the list, the active id and every session back. Not verified: none of it has been run. There is no device or emulator on this machine and no API Server to answer, so the picker, the add sheet, a real connect, the 401 fallback and the shell rebuild on a switch exist only as code the analyzer is happy with — the tests reach the registry, not a screen. No APK was built. The legacy migration was exercised against mocked SharedPreferences, which is not a phone that had the old build on it: that is the first thing to check on a device, since the failure mode is a silent sign-out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
662 lines
28 KiB
Dart
662 lines
28 KiB
Dart
import "dart:convert";
|
|
import "package:http/http.dart" as http;
|
|
|
|
import "models.dart";
|
|
import "servers.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;
|
|
}
|
|
|
|
/// One request's destination: the server that was active when it went out, its
|
|
/// base URL and its token. Pinning all three up front is what keeps a rejection
|
|
/// attributable — re-reading the active server on the way back would let one
|
|
/// server's 401 clear a different server's session when a switch lands between
|
|
/// a call going out and its answer arriving.
|
|
class _Target {
|
|
final String id;
|
|
final String base;
|
|
final String? token;
|
|
const _Target(this.id, this.base, this.token);
|
|
|
|
Uri uri(String path) => Uri.parse("$base$path");
|
|
|
|
Map<String, String> get authHeaders =>
|
|
{if (token != null) "Authorization": "Bearer $token"};
|
|
|
|
Map<String, String> get jsonHeaders =>
|
|
{"Content-Type": "application/json", ...authHeaders};
|
|
}
|
|
|
|
/// The single client for the Car Control API Server. Which server the calls go
|
|
/// to is [serverRegistry]'s business: the base URL and the bearer token are both
|
|
/// read from whichever server is active, resolved fresh on every request so
|
|
/// switching takes effect without rebuilding the client. On 401 it calls
|
|
/// [onUnauthorized] with the server that rejected the session.
|
|
class ApiClient {
|
|
void Function(String serverId)? onUnauthorized;
|
|
|
|
/// The active server's token — read by the multipart and download helpers,
|
|
/// which build their own requests.
|
|
String? get token => serverRegistry.activeToken;
|
|
|
|
/// The base URL the next request will go to.
|
|
String get baseUrl => serverRegistry.activeBase;
|
|
|
|
_Target _target() => _Target(
|
|
serverRegistry.activeId,
|
|
serverRegistry.activeBase,
|
|
serverRegistry.activeToken,
|
|
);
|
|
|
|
Future<dynamic> _send(String method, String path, {Object? body}) async {
|
|
final target = _target();
|
|
final req = http.Request(method, target.uri(path))..headers.addAll(target.jsonHeaders);
|
|
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(target.id);
|
|
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 ---
|
|
/// Signs in against a named base rather than the active server: the add-server
|
|
/// sheet checks credentials against the server being added before anything
|
|
/// switches to it, so a wrong password leaves you where you were.
|
|
///
|
|
/// 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)> loginAt(String base, String email, String password) async {
|
|
final res = await http.post(
|
|
Uri.parse("$base/auth/login"),
|
|
headers: const {"Content-Type": "application/json"},
|
|
body: jsonEncode({"email": email, "password": password}),
|
|
);
|
|
final data = res.body.isEmpty ? null : _tryDecode(res.body);
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
throw ApiException(res.statusCode, _errorMessage(data, res.reasonPhrase));
|
|
}
|
|
return (data["token"] as String, AuthUser.fromJson(Map<String, dynamic>.from(data["record"])));
|
|
}
|
|
|
|
Future<(String, AuthUser)> login(String email, String password) =>
|
|
loginAt(serverRegistry.activeBase, email, password);
|
|
|
|
// --- 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");
|
|
|
|
/// 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<Car> updateCarView(
|
|
String id, {
|
|
List<String>? hiddenTabs,
|
|
List<String>? hiddenFields,
|
|
List<String>? tabOrder,
|
|
List<String>? fieldOrder,
|
|
List<String>? metricOrder,
|
|
List<String>? hiddenServiceColumns,
|
|
List<String>? serviceColumnOrder,
|
|
List<String>? hiddenServiceParts,
|
|
}) async {
|
|
final body = <String, dynamic>{
|
|
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,
|
|
if (hiddenServiceColumns != null) "hiddenServiceColumns": hiddenServiceColumns,
|
|
if (serviceColumnOrder != null) "serviceColumnOrder": serviceColumnOrder,
|
|
if (hiddenServiceParts != null) "hiddenServiceParts": hiddenServiceParts,
|
|
};
|
|
final data = await _send("PUT", "/cars/$id/view", body: body);
|
|
return Car.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
// --- 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");
|
|
|
|
// --- user management (admin or superadmin) ---
|
|
// Admins are scoped by the server to their own organization; superadmins see
|
|
// everyone. Responses are enveloped ({users}/{user}).
|
|
Future<List<AdminUser>> listUsers() async {
|
|
final data = await _send("GET", "/users");
|
|
final items = (data["users"] ?? []) as List;
|
|
return items.map((e) => AdminUser.fromJson(Map<String, dynamic>.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<AdminUser> 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<String, dynamic>.from(data["user"]));
|
|
}
|
|
|
|
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", "/users/$id", body: body);
|
|
return AdminUser.fromJson(Map<String, dynamic>.from(data["user"]));
|
|
}
|
|
|
|
/// Password resets are a field on the user PATCH now, not a separate endpoint.
|
|
Future<void> setUserPassword(String id, String newPassword) =>
|
|
_send("PATCH", "/users/$id", body: {"password": newPassword});
|
|
|
|
Future<void> 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<List<Organization>> listOrgs() async {
|
|
final data = await _send("GET", "/orgs");
|
|
final items = (data["organizations"] ?? []) as List;
|
|
return items.map((e) => Organization.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<Organization> createOrg(String name) async {
|
|
final data = await _send("POST", "/orgs", body: {"name": name});
|
|
return Organization.fromJson(Map<String, dynamic>.from(data["organization"]));
|
|
}
|
|
|
|
Future<Organization> renameOrg(String id, String name) async {
|
|
final data = await _send("PATCH", "/orgs/$id", body: {"name": name});
|
|
return Organization.fromJson(Map<String, dynamic>.from(data["organization"]));
|
|
}
|
|
|
|
Future<void> deleteOrg(String id) => _send("DELETE", "/orgs/$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");
|
|
|
|
// --- technical checks ---
|
|
Future<List<TechnicalCheck>> listCarTechnicalChecks(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/technical-checks") as List;
|
|
return data.map((e) => TechnicalCheck.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<TechnicalCheck> createTechnicalCheck(Map<String, dynamic> body) async {
|
|
final data = await _send("POST", "/technical-checks", body: body);
|
|
return TechnicalCheck.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<TechnicalCheck> updateTechnicalCheck(String id, Map<String, dynamic> body) async {
|
|
final data = await _send("PATCH", "/technical-checks/$id", body: body);
|
|
return TechnicalCheck.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<void> deleteTechnicalCheck(String id) => _send("DELETE", "/technical-checks/$id");
|
|
|
|
// --- fuel ---
|
|
Future<List<FuelEntry>> listCarFuelEntries(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/fuel-entries") as List;
|
|
return data.map((e) => FuelEntry.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<FuelStats> getCarFuelStats(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/fuel-stats");
|
|
return FuelStats.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<FuelEntry> createFuelEntry(Map<String, dynamic> body) async {
|
|
final data = await _send("POST", "/fuel-entries", body: body);
|
|
return FuelEntry.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<FuelEntry> updateFuelEntry(String id, Map<String, dynamic> body) async {
|
|
final data = await _send("PATCH", "/fuel-entries/$id", body: body);
|
|
return FuelEntry.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<void> 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<List<ChargingSession>> listCarChargingSessions(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/charging-sessions") as List;
|
|
return data.map((e) => ChargingSession.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<ChargingStats> getCarChargingStats(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/charging-stats");
|
|
return ChargingStats.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<ChargingSession> createChargingSession(Map<String, dynamic> body) async {
|
|
final data = await _send("POST", "/charging-sessions", body: body);
|
|
return ChargingSession.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<ChargingSession> updateChargingSession(String id, Map<String, dynamic> body) async {
|
|
final data = await _send("PATCH", "/charging-sessions/$id", body: body);
|
|
return ChargingSession.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<void> deleteChargingSession(String id) => _send("DELETE", "/charging-sessions/$id");
|
|
|
|
// --- maintenance ---
|
|
Future<List<MaintenanceEntry>> listCarMaintenance(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/maintenance") as List;
|
|
return data.map((e) => MaintenanceEntry.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<MaintenanceEntry> createMaintenance(Map<String, dynamic> body) async {
|
|
final data = await _send("POST", "/maintenance", body: body);
|
|
return MaintenanceEntry.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<MaintenanceEntry> updateMaintenance(String id, Map<String, dynamic> body) async {
|
|
final data = await _send("PATCH", "/maintenance/$id", body: body);
|
|
return MaintenanceEntry.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<void> 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<List<CarDocument>> listCarDocuments(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/documents") as List;
|
|
return data.map((e) => CarDocument.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<CarDocument> createDocument(Map<String, dynamic> body) async {
|
|
final data = await _send("POST", "/car-documents", body: body);
|
|
return CarDocument.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<CarDocument> updateDocument(String id, Map<String, dynamic> body) async {
|
|
final data = await _send("PATCH", "/car-documents/$id", body: body);
|
|
return CarDocument.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<void> deleteDocument(String id) => _send("DELETE", "/car-documents/$id");
|
|
|
|
// --- reminders ---
|
|
Future<List<Reminder>> listCarReminders(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/reminders") as List;
|
|
return data.map((e) => Reminder.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<Reminder> createReminder(Map<String, dynamic> body) async {
|
|
final data = await _send("POST", "/reminders", body: body);
|
|
return Reminder.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<Reminder> updateReminder(String id, Map<String, dynamic> body) async {
|
|
final data = await _send("PATCH", "/reminders/$id", body: body);
|
|
return Reminder.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<void> 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<Reminder> completeReminder(String id) async {
|
|
final data = await _send("POST", "/reminders/$id/complete");
|
|
return Reminder.fromJson(Map<String, dynamic>.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<Map<String, dynamic>> uploadAttachment(
|
|
String path, String id, List<int> bytes, String filename) async {
|
|
final target = _target();
|
|
final req = http.MultipartRequest("POST", target.uri("$path/$id/file"))
|
|
..headers.addAll(target.authHeaders);
|
|
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(target.id);
|
|
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<String, dynamic>.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<List<int>?> getAttachmentBytes(String path, String id) async {
|
|
final target = _target();
|
|
final res = await http.get(target.uri("$path/$id/file"), headers: target.authHeaders);
|
|
if (res.statusCode == 200) return res.bodyBytes;
|
|
return null;
|
|
}
|
|
|
|
Future<void> deleteAttachment(String path, String id) => _send("DELETE", "$path/$id/file");
|
|
|
|
// --- 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 target = _target();
|
|
final req = http.MultipartRequest("POST", target.uri("/me/avatar"))
|
|
..headers.addAll(target.authHeaders);
|
|
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(target.id);
|
|
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 target = _target();
|
|
final res = await http.get(target.uri("/me/avatar"), headers: target.authHeaders);
|
|
if (res.statusCode == 200) return res.bodyBytes;
|
|
return null;
|
|
}
|
|
|
|
Future<void> deleteAvatar() => _send("DELETE", "/me/avatar");
|
|
|
|
// --- 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");
|
|
|
|
// --- 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<int>, String)> exportData() async {
|
|
final target = _target();
|
|
final res = await http.get(target.uri("/me/export"), headers: target.authHeaders);
|
|
if (res.statusCode == 401) {
|
|
onUnauthorized?.call(target.id);
|
|
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<ImportResult> importData(Map<String, dynamic> payload) async {
|
|
final data = await _send("POST", "/me/import", body: payload);
|
|
return ImportResult.fromJson(Map<String, dynamic>.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<List<VehicleProvider>> listVehicleProviders() async {
|
|
final data = await _send("GET", "/vehicle-providers");
|
|
final items = (data["providers"] ?? []) as List;
|
|
return items.map((e) => VehicleProvider.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<List<ProviderVehicle>> 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<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<ProviderSnapshot> getCarProvider(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/provider");
|
|
return ProviderSnapshot.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
/// Links this car to a vehicle on the caller's provider account; an empty
|
|
/// [provider] unlinks it. Returns the updated car.
|
|
Future<Car> 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<String, dynamic>.from(data));
|
|
}
|
|
|
|
/// Re-applies the provider's data to the car. [include] selects what to take
|
|
/// (identity, fuelType, dates, odometer); omitted means everything.
|
|
Future<Car> syncCarProvider(String carId, {Map<String, bool>? 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<String, dynamic>.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<IntegrationView> getToyota() async {
|
|
final data = await _send("GET", "/integrations/toyota");
|
|
return IntegrationView.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<IntegrationView> saveToyota(Map<String, dynamic> body) async {
|
|
final data = await _send("PUT", "/integrations/toyota", body: body);
|
|
return IntegrationView.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<IntegrationHealth> testToyota() async {
|
|
final data = await _send("POST", "/integrations/toyota/health");
|
|
final h = (data is Map ? data["health"] : null) ?? {};
|
|
return IntegrationHealth.fromJson(Map<String, dynamic>.from(h));
|
|
}
|
|
|
|
Future<IntegrationView> getAnkerSolix() async {
|
|
final data = await _send("GET", "/integrations/anker-solix");
|
|
return IntegrationView.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<IntegrationView> saveAnkerSolix(Map<String, dynamic> body) async {
|
|
final data = await _send("PUT", "/integrations/anker-solix", body: body);
|
|
return IntegrationView.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<IntegrationHealth> testAnkerSolix() async {
|
|
final data = await _send("POST", "/integrations/anker-solix/health");
|
|
final h = (data is Map ? data["health"] : null) ?? {};
|
|
return IntegrationHealth.fromJson(Map<String, dynamic>.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<AnkerControl> getAnkerControl(String sn) async {
|
|
final data = await _send("GET", "/integrations/anker-solix/chargers/${_sn(sn)}/control");
|
|
return AnkerControl.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
/// (Re)generates the per-charger control token; the plaintext token is returned
|
|
/// exactly once, so the caller must show it immediately.
|
|
Future<String> 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<void> ankerControlRevoke(String sn) =>
|
|
_send("DELETE", "/integrations/anker-solix/chargers/${_sn(sn)}/control/token");
|
|
|
|
Future<void> ankerControlAction(String sn, String action, [Map<String, dynamic> body = const {}]) =>
|
|
_send("POST", "/integrations/anker-solix/chargers/${_sn(sn)}/$action", body: body);
|
|
|
|
static String _asString(dynamic v) => v == null ? "" : v.toString();
|
|
}
|