Files
DriverVault/Phone App/lib/models.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

873 lines
28 KiB
Dart

// Domain models mirroring the API Server JSON (which mirrors Car Service.xlsx).
int _asInt(dynamic v) => v is int ? v : (v is num ? v.toInt() : 0);
String _asStr(dynamic v) => v == null ? "" : v.toString();
bool _asBool(dynamic v) => v == true;
double _asDouble(dynamic v) => v is num ? v.toDouble() : 0.0;
/// Nullable variants for the derived fields the server omits when it could not
/// compute them. A missing consumption figure is not zero — it means "unknown",
/// and must stay distinguishable so the UI can render "—" instead of a
/// misleading 0.0.
int? _asIntOrNull(dynamic v) => v is num ? v.toInt() : null;
double? _asDoubleOrNull(dynamic v) => v is num ? v.toDouble() : null;
DateTime? _asDate(dynamic v) =>
v == null ? null : DateTime.tryParse(v.toString())?.toLocal();
/// The single optional file a record carries — a receipt, a scan, a photo of a
/// part's box. Mirrors the API's embedded Attachment: the bytes are never in the
/// JSON, only whether there are any and what they were stored as. Fetch them
/// from GET /{records}/{id}/file, which re-checks access per request.
mixin HasAttachment {
String get fileName;
bool get hasFile;
}
/// The server-computed lifecycle state of a dated document or certificate.
/// [days] is null when there is no expiry date at all.
class ExpiryAssessment {
final String state; // no_expiry | valid | expiring_soon | expired
final int? days;
const ExpiryAssessment({this.state = "no_expiry", this.days});
factory ExpiryAssessment.fromJson(Map<String, dynamic>? j) {
if (j == null) return const ExpiryAssessment();
return ExpiryAssessment(
state: _asStr(j["state"]).isEmpty ? "no_expiry" : _asStr(j["state"]),
days: _asIntOrNull(j["daysUntilExpiry"]),
);
}
}
class Car {
final String id;
final String name;
final String make;
final String model;
final int year;
final String registration;
final String registrationCountry;
final String vin;
final String oilSpec;
final String transmissionOilSpec;
final String differentialOilSpec;
final String brakeFluidSpec;
final String coolantSpec;
final String fuelType; // petrol | petrol_lpg | diesel | diesel_lpg | hybrid | electric | hydrogen
final String buildDate; // ISO YYYY-MM-DD (date-only)
final String firstRegistrationDate; // ISO YYYY-MM-DD (date-only)
final int serviceIntervalDays;
final int serviceIntervalKm;
/// The roadworthiness inspection cycle. Only prefills the next date — the
/// interval is set by law rather than by the car, so any check can override it
/// with the date its certificate actually carries.
final int technicalCheckIntervalDays;
final int currentKm;
/// The requesting user's permission on this car: "owner", "write", or "read".
/// The API sets it on every car read. Defaults to "owner" so older responses
/// (or any code that constructs a Car without it) stay fully editable.
final String access;
Car({
required this.id,
required this.name,
required this.make,
required this.model,
required this.year,
required this.registration,
this.registrationCountry = "",
required this.vin,
required this.oilSpec,
required this.transmissionOilSpec,
required this.differentialOilSpec,
required this.brakeFluidSpec,
required this.coolantSpec,
this.fuelType = "",
this.buildDate = "",
this.firstRegistrationDate = "",
required this.serviceIntervalDays,
required this.serviceIntervalKm,
this.technicalCheckIntervalDays = 0,
required this.currentKm,
this.access = "owner",
});
factory Car.fromJson(Map<String, dynamic> j) => Car(
id: _asStr(j["id"]),
name: _asStr(j["name"]),
make: _asStr(j["make"]),
model: _asStr(j["model"]),
year: _asInt(j["year"]),
registration: _asStr(j["registration"]),
registrationCountry: _asStr(j["registrationCountry"]),
vin: _asStr(j["vin"]),
oilSpec: _asStr(j["oilSpec"]),
transmissionOilSpec: _asStr(j["transmissionOilSpec"]),
differentialOilSpec: _asStr(j["differentialOilSpec"]),
brakeFluidSpec: _asStr(j["brakeFluidSpec"]),
coolantSpec: _asStr(j["coolantSpec"]),
fuelType: _asStr(j["fuelType"]),
buildDate: _asStr(j["buildDate"]),
firstRegistrationDate: _asStr(j["firstRegistrationDate"]),
serviceIntervalDays: _asInt(j["serviceIntervalDays"]),
serviceIntervalKm: _asInt(j["serviceIntervalKm"]),
technicalCheckIntervalDays: _asInt(j["technicalCheckIntervalDays"]),
currentKm: _asInt(j["currentKm"]),
access: j["access"] == null ? "owner" : _asStr(j["access"]),
);
bool get isOwner => access == "owner";
bool get canWrite => access == "owner" || access == "write";
bool get isReadOnly => access == "read";
String get subtitle =>
[make, model, year > 0 ? "$year" : ""].where((s) => s.isNotEmpty).join(" ");
}
class ServiceRecord with HasAttachment {
final String id;
final String car;
final DateTime? date;
final int km;
final bool changedOil;
final bool changedEngineAirFilter;
final bool changedCabinAirFilter;
final String notes;
final DateTime? nextServiceDate;
final int? nextServiceKm;
@override
final String fileName;
@override
final bool hasFile;
ServiceRecord({
required this.id,
required this.car,
required this.date,
required this.km,
required this.changedOil,
required this.changedEngineAirFilter,
required this.changedCabinAirFilter,
required this.notes,
required this.nextServiceDate,
required this.nextServiceKm,
this.fileName = "",
this.hasFile = false,
});
factory ServiceRecord.fromJson(Map<String, dynamic> j) => ServiceRecord(
id: _asStr(j["id"]),
car: _asStr(j["car"]),
date: _asDate(j["date"]),
km: _asInt(j["km"]),
changedOil: _asBool(j["changedOil"]),
changedEngineAirFilter: _asBool(j["changedEngineAirFilter"]),
changedCabinAirFilter: _asBool(j["changedCabinAirFilter"]),
notes: _asStr(j["notes"]),
nextServiceDate: _asDate(j["nextServiceDate"]),
nextServiceKm: _asIntOrNull(j["nextServiceKm"]),
fileName: _asStr(j["fileName"]),
hasFile: _asBool(j["hasFile"]),
);
}
/// One mandatory roadworthiness inspection — przegląd techniczny, MOT, TÜV,
/// contrôle technique, depending on where the car is registered. Shaped like a
/// [ServiceRecord] but recurring on time alone: an inspection falls due on a
/// date whatever the odometer says.
class TechnicalCheck with HasAttachment {
final String id;
final String car;
final DateTime? date;
final String result; // passed | failed
final double cost;
final String station;
final String notes;
/// The expiry printed on the certificate. When set it wins over the car's
/// interval, because it is the date that actually governs.
final DateTime? validUntil;
final DateTime? nextCheckDate;
final ExpiryAssessment expiry;
@override
final String fileName;
@override
final bool hasFile;
TechnicalCheck({
required this.id,
required this.car,
required this.date,
required this.result,
required this.cost,
this.station = "",
this.notes = "",
this.validUntil,
this.nextCheckDate,
this.expiry = const ExpiryAssessment(),
this.fileName = "",
this.hasFile = false,
});
factory TechnicalCheck.fromJson(Map<String, dynamic> j) => TechnicalCheck(
id: _asStr(j["id"]),
car: _asStr(j["car"]),
date: _asDate(j["date"]),
result: _asStr(j["result"]).isEmpty ? "passed" : _asStr(j["result"]),
cost: _asDouble(j["cost"]),
station: _asStr(j["station"]),
notes: _asStr(j["notes"]),
validUntil: _asDate(j["validUntil"]),
nextCheckDate: _asDate(j["nextCheckDate"]),
expiry: ExpiryAssessment.fromJson(
j["expiry"] == null ? null : Map<String, dynamic>.from(j["expiry"])),
fileName: _asStr(j["fileName"]),
hasFile: _asBool(j["hasFile"]),
);
bool get passed => result == "passed";
}
class Part with HasAttachment {
final String id;
final String car;
final String name;
final String partNumber;
final String category;
final String notes;
@override
final String fileName;
@override
final bool hasFile;
Part({
required this.id,
required this.car,
required this.name,
required this.partNumber,
this.category = "",
this.notes = "",
this.fileName = "",
this.hasFile = false,
});
factory Part.fromJson(Map<String, dynamic> j) => Part(
id: _asStr(j["id"]),
car: _asStr(j["car"]),
name: _asStr(j["name"]),
partNumber: _asStr(j["partNumber"]),
category: _asStr(j["category"]),
notes: _asStr(j["notes"]),
fileName: _asStr(j["fileName"]),
hasFile: _asBool(j["hasFile"]),
);
}
/// One refuelling stop. The efficiency figures are derived by the server using
/// the full-tank method and are null wherever it could not compute them — a
/// window with a missed fill, or the first tank ever logged.
class FuelEntry with HasAttachment {
final String id;
final String car;
final DateTime? date;
final int km;
final double liters;
final double cost;
final bool fullTank;
final bool missedFill;
final String station;
final String notes;
final double? pricePerLiter;
final int? distanceKm;
final double? litersUsed;
final double? consumptionL100;
final double? kmPerLiter;
final double? costPerKm;
@override
final String fileName;
@override
final bool hasFile;
FuelEntry({
required this.id,
required this.car,
required this.date,
required this.km,
required this.liters,
required this.cost,
this.fullTank = true,
this.missedFill = false,
this.station = "",
this.notes = "",
this.pricePerLiter,
this.distanceKm,
this.litersUsed,
this.consumptionL100,
this.kmPerLiter,
this.costPerKm,
this.fileName = "",
this.hasFile = false,
});
factory FuelEntry.fromJson(Map<String, dynamic> j) => FuelEntry(
id: _asStr(j["id"]),
car: _asStr(j["car"]),
date: _asDate(j["date"]),
km: _asInt(j["km"]),
liters: _asDouble(j["liters"]),
cost: _asDouble(j["cost"]),
fullTank: _asBool(j["fullTank"]),
missedFill: _asBool(j["missedFill"]),
station: _asStr(j["station"]),
notes: _asStr(j["notes"]),
pricePerLiter: _asDoubleOrNull(j["pricePerLiter"]),
distanceKm: _asIntOrNull(j["distanceKm"]),
litersUsed: _asDoubleOrNull(j["litersUsed"]),
consumptionL100: _asDoubleOrNull(j["consumptionL100"]),
kmPerLiter: _asDoubleOrNull(j["kmPerLiter"]),
costPerKm: _asDoubleOrNull(j["costPerKm"]),
fileName: _asStr(j["fileName"]),
hasFile: _asBool(j["hasFile"]),
);
}
/// A summary of a car's whole refill history. [trackedDistanceKm] is the
/// distance covered by computable full-tank windows — less than the odometer
/// span whenever the history starts or ends on a partial fill. The averages
/// describe exactly this distance.
class FuelStats {
final int entries;
final double totalLiters;
final double totalCost;
final int trackedDistanceKm;
final double? avgConsumptionL100;
final double? bestConsumptionL100;
final double? worstConsumptionL100;
final double? avgKmPerLiter;
final double? avgPricePerLiter;
final double? costPerKm;
final DateTime? firstDate;
final DateTime? lastDate;
const FuelStats({
this.entries = 0,
this.totalLiters = 0,
this.totalCost = 0,
this.trackedDistanceKm = 0,
this.avgConsumptionL100,
this.bestConsumptionL100,
this.worstConsumptionL100,
this.avgKmPerLiter,
this.avgPricePerLiter,
this.costPerKm,
this.firstDate,
this.lastDate,
});
factory FuelStats.fromJson(Map<String, dynamic> j) => FuelStats(
entries: _asInt(j["entries"]),
totalLiters: _asDouble(j["totalLiters"]),
totalCost: _asDouble(j["totalCost"]),
trackedDistanceKm: _asInt(j["trackedDistanceKm"]),
avgConsumptionL100: _asDoubleOrNull(j["avgConsumptionL100"]),
bestConsumptionL100: _asDoubleOrNull(j["bestConsumptionL100"]),
worstConsumptionL100: _asDoubleOrNull(j["worstConsumptionL100"]),
avgKmPerLiter: _asDoubleOrNull(j["avgKmPerLiter"]),
avgPricePerLiter: _asDoubleOrNull(j["avgPricePerLiter"]),
costPerKm: _asDoubleOrNull(j["costPerKm"]),
firstDate: _asDate(j["firstDate"]),
lastDate: _asDate(j["lastDate"]),
);
}
/// One workshop visit or repair — work done outside the routine service
/// schedule (which lives in [ServiceRecord]). A broken alternator replaced at a
/// garage belongs here; the annual oil change does not.
class MaintenanceEntry with HasAttachment {
final String id;
final String car;
final DateTime? date;
final int km;
final String type; // repair|inspection|bodywork|tyres|diagnostics|recall|warranty|other
final String status; // scheduled|in_progress|completed
final String workshop;
final String location;
final String description;
final String partsUsed;
final double laborCost;
final double partsCost;
final String invoiceNumber;
final DateTime? warrantyUntil;
final String notes;
final double totalCost;
final bool? warrantyActive;
final int? warrantyDaysLeft;
@override
final String fileName;
@override
final bool hasFile;
MaintenanceEntry({
required this.id,
required this.car,
required this.date,
required this.km,
required this.type,
required this.status,
this.workshop = "",
this.location = "",
this.description = "",
this.partsUsed = "",
this.laborCost = 0,
this.partsCost = 0,
this.invoiceNumber = "",
this.warrantyUntil,
this.notes = "",
this.totalCost = 0,
this.warrantyActive,
this.warrantyDaysLeft,
this.fileName = "",
this.hasFile = false,
});
factory MaintenanceEntry.fromJson(Map<String, dynamic> j) => MaintenanceEntry(
id: _asStr(j["id"]),
car: _asStr(j["car"]),
date: _asDate(j["date"]),
km: _asInt(j["km"]),
type: _asStr(j["type"]).isEmpty ? "repair" : _asStr(j["type"]),
status: _asStr(j["status"]).isEmpty ? "completed" : _asStr(j["status"]),
workshop: _asStr(j["workshop"]),
location: _asStr(j["location"]),
description: _asStr(j["description"]),
partsUsed: _asStr(j["partsUsed"]),
laborCost: _asDouble(j["laborCost"]),
partsCost: _asDouble(j["partsCost"]),
invoiceNumber: _asStr(j["invoiceNumber"]),
warrantyUntil: _asDate(j["warrantyUntil"]),
notes: _asStr(j["notes"]),
totalCost: _asDouble(j["totalCost"]),
warrantyActive: j["warrantyActive"] == null ? null : _asBool(j["warrantyActive"]),
warrantyDaysLeft: _asIntOrNull(j["warrantyDaysLeft"]),
fileName: _asStr(j["fileName"]),
hasFile: _asBool(j["hasFile"]),
);
}
/// A piece of paperwork tied to a car — insurance, emissions certificate,
/// registration papers. The renewal date is the point of the record: an expired
/// policy is a car that cannot legally be driven, so [expiry] is computed live
/// by the server on every read.
class CarDocument with HasAttachment {
final String id;
final String car;
final String type; // insurance|pollution|registration|inspection|roadTax|warranty|other
final String title;
final String provider;
final String reference;
final DateTime? issueDate;
final DateTime? expiryDate; // blank = never expires
final double cost;
final String notes;
final ExpiryAssessment expiry;
@override
final String fileName;
@override
final bool hasFile;
CarDocument({
required this.id,
required this.car,
required this.type,
required this.title,
this.provider = "",
this.reference = "",
this.issueDate,
this.expiryDate,
this.cost = 0,
this.notes = "",
this.expiry = const ExpiryAssessment(),
this.fileName = "",
this.hasFile = false,
});
factory CarDocument.fromJson(Map<String, dynamic> j) => CarDocument(
id: _asStr(j["id"]),
car: _asStr(j["car"]),
type: _asStr(j["type"]).isEmpty ? "other" : _asStr(j["type"]),
title: _asStr(j["title"]),
provider: _asStr(j["provider"]),
reference: _asStr(j["reference"]),
issueDate: _asDate(j["issueDate"]),
expiryDate: _asDate(j["expiryDate"]),
cost: _asDouble(j["cost"]),
notes: _asStr(j["notes"]),
expiry: ExpiryAssessment.fromJson(
j["expiry"] == null ? null : Map<String, dynamic>.from(j["expiry"])),
fileName: _asStr(j["fileName"]),
hasFile: _asBool(j["hasFile"]),
);
}
/// Something the user wants to be told about: a booked workshop slot, an
/// insurance renewal, a tyre swap. Fires on a date, an odometer reading, or
/// both — whichever comes first. [auto] marks a reminder the server derived from
/// a document or service record, which is read-only.
class Reminder {
final String id;
final String car;
final String title;
final String type; // maintenance|document|service|inspection|other
final DateTime? dueDate;
final int dueKm;
final int repeatDays;
final int repeatKm;
final bool done;
final DateTime? doneAt;
final String notes;
final String status; // done | overdue | due_soon | upcoming | no_trigger
final int? daysLeft;
final int? kmLeft;
final bool auto;
final String sourceRef;
Reminder({
required this.id,
required this.car,
required this.title,
required this.type,
this.dueDate,
this.dueKm = 0,
this.repeatDays = 0,
this.repeatKm = 0,
this.done = false,
this.doneAt,
this.notes = "",
this.status = "no_trigger",
this.daysLeft,
this.kmLeft,
this.auto = false,
this.sourceRef = "",
});
factory Reminder.fromJson(Map<String, dynamic> j) => Reminder(
id: _asStr(j["id"]),
car: _asStr(j["car"]),
title: _asStr(j["title"]),
type: _asStr(j["type"]).isEmpty ? "other" : _asStr(j["type"]),
dueDate: _asDate(j["dueDate"]),
dueKm: _asInt(j["dueKm"]),
repeatDays: _asInt(j["repeatDays"]),
repeatKm: _asInt(j["repeatKm"]),
done: _asBool(j["done"]),
doneAt: _asDate(j["doneAt"]),
notes: _asStr(j["notes"]),
status: _asStr(j["status"]).isEmpty ? "no_trigger" : _asStr(j["status"]),
daysLeft: _asIntOrNull(j["daysLeft"]),
kmLeft: _asIntOrNull(j["kmLeft"]),
auto: _asBool(j["auto"]),
sourceRef: _asStr(j["sourceRef"]),
);
/// Recurring reminders roll their trigger forward on completion instead of
/// closing out.
bool get repeats => repeatDays > 0 || repeatKm > 0;
}
/// A sharing grant: another user's access to one of your cars.
class CarShare {
final String userId;
final String email;
final String name;
final String permission; // "read" | "write"
CarShare({
required this.userId,
required this.email,
required this.name,
required this.permission,
});
factory CarShare.fromJson(Map<String, dynamic> j) {
final user = Map<String, dynamic>.from(j["user"] ?? {});
return CarShare(
userId: _asStr(user["id"]),
email: _asStr(user["email"]),
name: _asStr(user["name"]),
permission: _asStr(j["permission"]),
);
}
String get label => name.isNotEmpty ? name : email;
}
/// Roles allowed to manage users. A superadmin is an admin that also spans
/// every organization; the API Server enforces that difference.
const _managerRoles = {"admin", "superadmin"};
class AuthUser {
final String id;
final String email;
final String name;
final String role; // "user" | "admin" | "superadmin"
AuthUser({required this.id, required this.email, required this.name, this.role = "user"});
factory AuthUser.fromJson(Map<String, dynamic> j) => AuthUser(
id: _asStr(j["id"]),
email: _asStr(j["email"]),
name: _asStr(j["name"]),
// An empty role is treated as "user", matching the server.
role: _asStr(j["role"]).isEmpty ? "user" : _asStr(j["role"]),
);
bool get isAdmin => _managerRoles.contains(role);
bool get isSuperadmin => role == "superadmin";
Map<String, dynamic> toJson() => {"id": id, "email": email, "name": name, "role": role};
}
/// A user record as returned by the admin user-management endpoints.
class AdminUser {
final String id;
final String email;
final String name;
final String role;
final String created;
final String organizationName; // "" when the user belongs to no organization
AdminUser({
required this.id,
required this.email,
required this.name,
required this.role,
required this.created,
this.organizationName = "",
});
factory AdminUser.fromJson(Map<String, dynamic> j) => AdminUser(
id: _asStr(j["id"]),
email: _asStr(j["email"]),
name: _asStr(j["name"]),
role: _asStr(j["role"]).isEmpty ? "user" : _asStr(j["role"]),
created: _asStr(j["created"]),
organizationName: _asStr(j["organizationName"]),
);
bool get isSuperadmin => role == "superadmin";
}
/// The full authenticated profile (Settings panel), mirroring /api/me.
class UserProfile {
final String id;
final String email;
final bool verified;
final String name;
final String bio;
final bool hasAvatar;
final String theme; // light | dark | system
final String locale; // BCP-47 language-REGION, e.g. "en-US"
final String dateFormat; // YMD | DMY_NUM | DMY | MDY
final String currency; // ISO 4217 code, e.g. "EUR"
final String fontSize; // small | medium | large
final String role; // user | admin
final DateTime? deletionRequestedAt;
UserProfile({
required this.id,
required this.email,
required this.verified,
required this.name,
required this.bio,
required this.hasAvatar,
required this.theme,
required this.locale,
required this.dateFormat,
this.currency = "USD",
required this.fontSize,
required this.role,
required this.deletionRequestedAt,
});
factory UserProfile.fromJson(Map<String, dynamic> j) => UserProfile(
id: _asStr(j["id"]),
email: _asStr(j["email"]),
verified: _asBool(j["verified"]),
name: _asStr(j["name"]),
bio: _asStr(j["bio"]),
hasAvatar: _asBool(j["hasAvatar"]),
theme: j["theme"] == null ? "system" : _asStr(j["theme"]),
locale: j["locale"] == null ? "en-US" : _asStr(j["locale"]),
dateFormat: j["dateFormat"] == null ? "YMD" : _asStr(j["dateFormat"]),
currency: j["currency"] == null ? "USD" : _asStr(j["currency"]),
fontSize: j["fontSize"] == null ? "medium" : _asStr(j["fontSize"]),
role: j["role"] == null ? "user" : _asStr(j["role"]),
deletionRequestedAt: j["deletionRequestedAt"] == null
? null
: DateTime.tryParse(_asStr(j["deletionRequestedAt"]))?.toLocal(),
);
bool get isAdmin => _managerRoles.contains(role);
bool get isSuperadmin => role == "superadmin";
bool get deletionPending => deletionRequestedAt != null;
}
// The Session model is gone: auth now uses PocketBase's own stateless tokens,
// so there is no per-device session list to show or revoke.
// --- Integrations (Toyota / Anker Solix) -----------------------------------
//
// The server resolves a superadmin → org admin → user cascade and returns, per
// field, the effective value (secrets/inherited values masked), the caller's
// own-layer value, its source layer, and whether it's locked (set above us).
// These mirror the resolved shapes the web Settings.vue consumes.
/// One field within a scope: the resolved value plus provenance.
class IntegrationField {
final String effective; // resolved value (secrets masked to "")
final String own; // the caller's own-layer value (unmasked, editable)
final String source; // "global" | "org" | "user" | "unset"
final bool locked; // set above the caller's editable layer
const IntegrationField({
this.effective = "",
this.own = "",
this.source = "unset",
this.locked = false,
});
factory IntegrationField.fromJson(Map<String, dynamic> j) => IntegrationField(
effective: _asStr(j["effective"]),
own: _asStr(j["own"]),
source: _asStr(j["source"]).isEmpty ? "unset" : _asStr(j["source"]),
locked: _asBool(j["locked"]),
);
}
/// A cascade scope ("user" or "org") the caller may edit, with its fields.
class IntegrationScope {
final String editableLayer; // "user" | "org"
final Map<String, IntegrationField> fields;
const IntegrationScope({this.editableLayer = "user", this.fields = const {}});
factory IntegrationScope.fromJson(Map<String, dynamic> j) {
final raw = j["fields"];
final fields = <String, IntegrationField>{};
if (raw is Map) {
raw.forEach((k, v) {
if (v is Map) fields[k.toString()] = IntegrationField.fromJson(Map<String, dynamic>.from(v));
});
}
return IntegrationScope(
editableLayer: _asStr(j["editableLayer"]).isEmpty ? "user" : _asStr(j["editableLayer"]),
fields: fields,
);
}
IntegrationField field(String key) => fields[key] ?? const IntegrationField();
}
/// The resolved view of one integration for the signed-in user, from
/// GET /integrations/{toyota|anker-solix}.
class IntegrationView {
final bool available; // the integration is enabled at the master layer
final bool enabled; // the user's personal opt-in
final bool orgEnabled; // the organization gate
final String orgId; // "" when the user has no organization
final bool canEditOrg; // the caller is an org admin
final bool isSuperadmin; // manages the shared layer elsewhere (read-only here)
final String controlMode; // Anker only: effective OCPP mode (off|own|proxy)
final Map<String, IntegrationScope> scopes; // "user" and optionally "org"
const IntegrationView({
this.available = false,
this.enabled = false,
this.orgEnabled = false,
this.orgId = "",
this.canEditOrg = false,
this.isSuperadmin = false,
this.controlMode = "off",
this.scopes = const {},
});
factory IntegrationView.fromJson(Map<String, dynamic> j) {
final raw = j["scopes"];
final scopes = <String, IntegrationScope>{};
if (raw is Map) {
raw.forEach((k, v) {
if (v is Map) scopes[k.toString()] = IntegrationScope.fromJson(Map<String, dynamic>.from(v));
});
}
return IntegrationView(
available: _asBool(j["available"]),
enabled: _asBool(j["enabled"]),
orgEnabled: _asBool(j["orgEnabled"]),
orgId: _asStr(j["orgId"]),
canEditOrg: _asBool(j["canEditOrg"]),
isSuperadmin: _asBool(j["isSuperadmin"]),
controlMode: _asStr(j["controlMode"]).isEmpty ? "off" : _asStr(j["controlMode"]),
scopes: scopes,
);
}
IntegrationScope scope(String key) => scopes[key] ?? const IntegrationScope();
}
/// The result of a live connection probe (POST …/health).
class IntegrationHealth {
final String status; // "ok" | "error" | …
final String detail;
const IntegrationHealth({this.status = "", this.detail = ""});
factory IntegrationHealth.fromJson(Map<String, dynamic> j) => IntegrationHealth(
status: _asStr(j["status"]),
detail: _asStr(j["detail"]),
);
}
/// A charger's OCPP control view, from …/chargers/{sn}/control. Serves both the
/// Settings provisioning card (endpoint/token/connection) and the Charging home
/// tab's live control (connector status + meter).
class AnkerControl {
final String endpoint; // OCPP backend URL to point the charger at
final bool hasToken; // a per-charger token has been generated
final String tokenHint; // last chars of the token, for display
final bool connected; // the charger is connected to the control backend
final String controlMode; // off | own | proxy
final String connectorStatus; // OCPP connector status, e.g. "Charging"
final int meterWh; // last meter reading in watt-hours
const AnkerControl({
this.endpoint = "",
this.hasToken = false,
this.tokenHint = "",
this.connected = false,
this.controlMode = "off",
this.connectorStatus = "",
this.meterWh = 0,
});
factory AnkerControl.fromJson(Map<String, dynamic> j) {
final status = j["status"];
final s = status is Map ? Map<String, dynamic>.from(status) : const {};
return AnkerControl(
endpoint: _asStr(j["endpoint"]),
hasToken: _asBool(j["hasToken"]),
tokenHint: _asStr(j["tokenHint"]),
connected: _asBool(j["connected"]),
controlMode: _asStr(j["controlMode"]).isEmpty ? "off" : _asStr(j["controlMode"]),
connectorStatus: _asStr(s["connectorStatus"]),
meterWh: _asInt(s["meterWh"]),
);
}
double get meterKwh => meterWh / 1000.0;
}