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>
476 lines
20 KiB
Dart
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();
|
|
}
|