Files
DriverVault/Phone App/lib/api.dart
T
tajniak81andClaude Opus 4.8 13f50aa9a4 Phone App: add Charging screen + Settings integration tabs
Brings the Flutter app to parity with the recent Web App changes, which
touched features the Phone App did not yet have — so this builds the
Charging and Integrations subsystems, then applies the tab/fold structure.

Charging (new nav destination): split into "Public chargers" (stylized
discovery map + demo session + nearby public stations) and "Home chargers"
(the real Anker Solix OCPP control card — serial refresh, connector/energy
tiles, start/stop, current limit, password step-up on reset — plus the
user's home charger list). Gated by the per-user control mode, degrading to
a Settings hint when off.

Settings: split into "Personal settings" (the existing account/appearance/
profile/security/privacy/danger sections) and "Integrations". The latter
holds foldable Toyota and Anker Solix cards over the superadmin -> org ->
user cascade: locked fields with "inherited from" notes, org-admin scope
switch, enable toggle, save/test with health result, and Anker OCPP token
provisioning. Both tabs stay mounted (IndexedStack) so in-flight edits
survive a switch.

Adds the integration + OCPP control endpoints to api.dart, the resolved
IntegrationView/Scope/Field, IntegrationHealth and AnkerControl models, and
charging.*/settings.tabs.*/nav.charging strings (en/pl/da) plus
settings.integrations.* (en) — mirroring the Web App's own pl/da coverage,
which leaves integrations and charger control untranslated as an English
fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:03:52 +02:00

476 lines
20 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) {
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<String, dynamic>.from(data["record"])));
}
// --- 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");
// --- 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();
}
Future<AdminUser> createUser(
{required String email, required String password, String? name, String role = "user"}) async {
final data = await _send("POST", "/users",
body: {"email": email, "password": password, "name": name ?? "", "role": role});
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");
// --- 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");
// --- 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 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<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 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<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 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: 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");
// --- 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();
}