// 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(); /// A JSON array of strings — the key sets a car carries for what its page shows /// and in which order. Absent or null reads as empty, which every consumer takes /// to mean "nothing hidden" / "the default order". List _asStrList(dynamic v) => v is List ? v.map(_asStr).where((s) => s.isNotEmpty).toList() : const []; /// A JSON object of plain values, read as strings — a service's own fields, /// relayed under its own keys. A value that says nothing leaves no entry, so a /// caller can take a key's presence to mean the service answered it. Map _asStrMap(dynamic v) { if (v is! Map) return const {}; final out = {}; v.forEach((key, value) { final s = _asStr(value); if (s.isNotEmpty) out[key.toString()] = s; }); return out; } /// 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? 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 // ISO 8601 reduced precision: "2015", "2015-03" or "2015-03-10". Entered on // the web, which can say how much of it is known; this app's date picker only // makes full ones, but it reads and preserves the rest. See formatPartialDate. final String buildDate; 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; /// The manufacturer service this car came from ("toyota") and that service's /// own id for the vehicle (the VIN, for Toyota). Both blank for a car entered /// by hand. Written only by the link/import endpoints, never by a car edit — /// which is why _carPayload leaves them out. final String provider; final String providerVehicleId; /// What this car's page does not show, and the order it lays out what it /// does. Properties of the car, so everyone it is shared with sees the same /// page. The hidden sets rather than the visible ones, so a tab or field added /// in a later release is on by default; the orders may be partial, and any key /// they leave out follows the ones they name. Written only through /// PUT /cars/{id}/view. final List hiddenTabs; final List hiddenFields; final List tabOrder; final List fieldOrder; final List metricOrder; /// The columns of the Service history the car does not show, and the order it /// lays out the ones it does. The same rules as the lists above, with one /// difference: "date" is never in the hidden set — a service is the day it /// happened, and a history with the day taken out stops being a history — but /// it is in the order, because there is no reason it has to come first. final List hiddenServiceColumns; final List serviceColumnOrder; /// The parts this car's services never change, as part keys. They come off /// the service form and out of the history's chips together — a part nobody /// records is one nobody wants offered. No order beside it: the parts are a /// checkbox list inside one column, and their position says nothing. final List hiddenServiceParts; 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", this.provider = "", this.providerVehicleId = "", this.hiddenTabs = const [], this.hiddenFields = const [], this.tabOrder = const [], this.fieldOrder = const [], this.metricOrder = const [], this.hiddenServiceColumns = const [], this.serviceColumnOrder = const [], this.hiddenServiceParts = const [], }); factory Car.fromJson(Map 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"]), provider: _asStr(j["provider"]), providerVehicleId: _asStr(j["providerVehicleId"]), hiddenTabs: _asStrList(j["hiddenTabs"]), hiddenFields: _asStrList(j["hiddenFields"]), tabOrder: _asStrList(j["tabOrder"]), fieldOrder: _asStrList(j["fieldOrder"]), metricOrder: _asStrList(j["metricOrder"]), hiddenServiceColumns: _asStrList(j["hiddenServiceColumns"]), serviceColumnOrder: _asStrList(j["serviceColumnOrder"]), hiddenServiceParts: _asStrList(j["hiddenServiceParts"]), ); 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 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 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.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 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 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 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 charge of an EV — the electric counterpart of [FuelEntry], and derived /// the same way: consumption is measured between full charges, so a top-up /// counts towards the next full one. The server computes every figure below the /// raw kWh/cost and omits any it could not derive. class ChargingSession with HasAttachment { final String id; final String car; final DateTime? date; final int km; final double kwh; final double cost; /// Taken to the car's usual full point — the reference the efficiency windows /// are measured between. final bool fullCharge; /// The car was charged before this without being logged, so the odometer span /// is not accounted for by the kWh on record. Any window containing it is /// left uncomputed rather than reported as implausibly efficient. final bool missedSession; final String location; final String notes; final double? pricePerKwh; final int? distanceKm; final double? kwhUsed; final double? consumptionKwh100; final double? kmPerKwh; final double? costPerKm; @override final String fileName; @override final bool hasFile; ChargingSession({ required this.id, required this.car, required this.date, required this.km, required this.kwh, required this.cost, this.fullCharge = true, this.missedSession = false, this.location = "", this.notes = "", this.pricePerKwh, this.distanceKm, this.kwhUsed, this.consumptionKwh100, this.kmPerKwh, this.costPerKm, this.fileName = "", this.hasFile = false, }); factory ChargingSession.fromJson(Map j) => ChargingSession( id: _asStr(j["id"]), car: _asStr(j["car"]), date: _asDate(j["date"]), km: _asInt(j["km"]), kwh: _asDouble(j["kwh"]), cost: _asDouble(j["cost"]), fullCharge: _asBool(j["fullCharge"]), missedSession: _asBool(j["missedSession"]), location: _asStr(j["location"]), notes: _asStr(j["notes"]), pricePerKwh: _asDoubleOrNull(j["pricePerKwh"]), distanceKm: _asIntOrNull(j["distanceKm"]), kwhUsed: _asDoubleOrNull(j["kwhUsed"]), consumptionKwh100: _asDoubleOrNull(j["consumptionKwh100"]), kmPerKwh: _asDoubleOrNull(j["kmPerKwh"]), costPerKm: _asDoubleOrNull(j["costPerKm"]), fileName: _asStr(j["fileName"]), hasFile: _asBool(j["hasFile"]), ); } /// A summary of a car's whole charging history. [trackedDistanceKm] is the /// distance covered by computable full-charge windows — the same caveat as /// [FuelStats.trackedDistanceKm]. class ChargingStats { final int entries; final double totalKwh; final double totalCost; final int trackedDistanceKm; final double? avgConsumptionKwh100; final double? bestConsumptionKwh100; final double? worstConsumptionKwh100; final double? avgKmPerKwh; final double? avgPricePerKwh; final double? costPerKm; final DateTime? firstDate; final DateTime? lastDate; const ChargingStats({ this.entries = 0, this.totalKwh = 0, this.totalCost = 0, this.trackedDistanceKm = 0, this.avgConsumptionKwh100, this.bestConsumptionKwh100, this.worstConsumptionKwh100, this.avgKmPerKwh, this.avgPricePerKwh, this.costPerKm, this.firstDate, this.lastDate, }); factory ChargingStats.fromJson(Map j) => ChargingStats( entries: _asInt(j["entries"]), totalKwh: _asDouble(j["totalKwh"]), totalCost: _asDouble(j["totalCost"]), trackedDistanceKm: _asInt(j["trackedDistanceKm"]), avgConsumptionKwh100: _asDoubleOrNull(j["avgConsumptionKwh100"]), bestConsumptionKwh100: _asDoubleOrNull(j["bestConsumptionKwh100"]), worstConsumptionKwh100: _asDoubleOrNull(j["worstConsumptionKwh100"]), avgKmPerKwh: _asDoubleOrNull(j["avgKmPerKwh"]), avgPricePerKwh: _asDoubleOrNull(j["avgPricePerKwh"]), 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 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 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.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 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 j) { final user = Map.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 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 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 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"; } /// A tenant users belong to, as returned by the organization endpoints. class Organization { final String id; final String name; final String created; Organization({required this.id, required this.name, this.created = ""}); factory Organization.fromJson(Map j) => Organization( id: _asStr(j["id"]), name: _asStr(j["name"]), created: _asStr(j["created"]), ); } /// 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 timeFormat; // auto (the region's own) | 24 | 12 /// The day a week is drawn as starting on, wherever weekdays are laid out in /// a row — the charging scheduler's day picker today. final String weekStart; // auto (the region's own) | monday | sunday final String currency; // ISO 4217 code, e.g. "EUR" final String fontSize; // small | medium | large final String role; // user | admin final String organization; // org record id ("" = belongs to no organization) final String organizationName; // resolved name ("" when unset/unresolvable) /// The charging page's arrangements — which order its tabs and its cards are /// in. Kept on the profile rather than on the device because they are layout /// choices that should follow the account, the way the garage order does. final List chargerTabOrder; final List chargerCardOrder; 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.timeFormat = "auto", this.weekStart = "auto", this.currency = "USD", required this.fontSize, required this.role, this.organization = "", this.organizationName = "", this.chargerTabOrder = const [], this.chargerCardOrder = const [], required this.deletionRequestedAt, }); factory UserProfile.fromJson(Map 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"]), timeFormat: j["timeFormat"] == null ? "auto" : _asStr(j["timeFormat"]), weekStart: j["weekStart"] == null ? "auto" : _asStr(j["weekStart"]), currency: j["currency"] == null ? "USD" : _asStr(j["currency"]), fontSize: j["fontSize"] == null ? "medium" : _asStr(j["fontSize"]), role: j["role"] == null ? "user" : _asStr(j["role"]), organization: _asStr(j["organization"]), organizationName: _asStr(j["organizationName"]), chargerTabOrder: _asStrList(j["chargerTabOrder"]), chargerCardOrder: _asStrList(j["chargerCardOrder"]), 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 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 fields; /// Anker only. The control modes this scope may still choose — the server has /// already dropped whatever the layers above it hid. Empty for an integration /// that has no such list. final List controlModes; /// Anker, org scope only. The modes this organization hides from its own /// users, which its admin edits here. final List controlModesDisabled; const IntegrationScope({ this.editableLayer = "user", this.fields = const {}, this.controlModes = const [], this.controlModesDisabled = const [], }); factory IntegrationScope.fromJson(Map j) { final raw = j["fields"]; final fields = {}; if (raw is Map) { raw.forEach((k, v) { if (v is Map) fields[k.toString()] = IntegrationField.fromJson(Map.from(v)); }); } return IntegrationScope( editableLayer: _asStr(j["editableLayer"]).isEmpty ? "user" : _asStr(j["editableLayer"]), fields: fields, controlModes: _asStrList(j["controlModes"]), controlModesDisabled: _asStrList(j["controlModesDisabled"]), ); } 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 control mode (off|own|proxy|modbus) final Map 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 j) { final raw = j["scopes"]; final scopes = {}; if (raw is Map) { raw.forEach((k, v) { if (v is Map) scopes[k.toString()] = IntegrationScope.fromJson(Map.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 j) => IntegrationHealth( status: _asStr(j["status"]), detail: _asStr(j["detail"]), ); } /// A charger's control view, from …/chargers/{sn}/control — over whichever /// transport the user's control mode selects. Serves the Settings provisioning /// card (endpoint/token/connection) and the Charging home tab's live control. /// /// The two transports are provisioned differently and report differently. OCPP /// waits for the charger to dial us, so what it needs is a token installed into /// the charger and what it reports is a session snapshot counting a meter in /// watt-hours. Modbus dials the charger, so what it needs is an address on the /// local network and what it reports is the whole register map — including a /// session's own energy, which is not the same number as a meter reading. 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 reachable over the active transport final String controlMode; // off | mqtt | modbus | own | proxy final String connectorStatus; // OCPP connector status, e.g. "Charging" final int meterWh; // last OCPP meter reading in watt-hours /// Where the charger lives on the local network (Modbus mode only). Blank /// until an address is saved, which is what the Charging page asks for. final String modbusHost; final int modbusPort; /// The server's own sentence about why there is nothing to control — no /// address saved yet, or a charger that did not answer at the one there is. /// Better than a generic hint, because the server has already tried. final String detail; /// The charger's own snapshot, when the active transport produced one — /// Modbus registers or the cloud's device messages, which name the same /// quantities the same way. final ChargerStatus? device; const AnkerControl({ this.endpoint = "", this.hasToken = false, this.tokenHint = "", this.connected = false, this.controlMode = "off", this.connectorStatus = "", this.meterWh = 0, this.modbusHost = "", this.modbusPort = 502, this.detail = "", this.device, }); factory AnkerControl.fromJson(Map j) { final status = j["status"]; final s = status is Map ? Map.from(status) : const {}; final mode = _asStr(j["controlMode"]).isEmpty ? "off" : _asStr(j["controlMode"]); final port = _asInt(j["modbusPort"]); return AnkerControl( endpoint: _asStr(j["endpoint"]), hasToken: _asBool(j["hasToken"]), tokenHint: _asStr(j["tokenHint"]), connected: _asBool(j["connected"]), controlMode: mode, connectorStatus: _asStr(s["connectorStatus"]), meterWh: _asInt(s["meterWh"]), modbusHost: _asStr(j["modbusHost"]), modbusPort: port == 0 ? 502 : port, detail: _asStr(j["detail"]), device: (mode == "modbus" || mode == "mqtt") && status is Map ? ChargerStatus(Map.from(status)) : null, ); } bool get isModbus => controlMode == "modbus"; /// The Anker cloud path: commands ride the connection the charger already /// holds to Anker, so nothing on the customer's side has to be reachable. bool get isCloud => controlMode == "mqtt"; /// Both of those read the charger itself and answer with its own snapshot, /// where OCPP answers with the session our CSMS is holding. bool get readsDevice => isModbus || isCloud; /// The energy this card shows. The transports word a charging session /// differently — an OCPP snapshot counts a meter, the charger's own counts the /// session — and both land in the same tile. double get meterKwh => (readsDevice ? (device?.sessionWh ?? 0) : meterWh) / 1000.0; /// What the charger says it is doing, in whichever transport's words. String get statusLabel { final label = readsDevice ? (device?.statusDesc ?? "") : connectorStatus; return label.isEmpty ? "—" : label; } } /// The Anker charger's own snapshot, read over Modbus TCP or over Anker's cloud. /// /// A wrapper over the raw JSON rather than forty declared fields: what a /// transport reports is the server's to describe, every value is optional (a /// charger on older firmware answers a shorter register block, and the cloud /// sends its settings only after a command), and a reading added upstream /// surfaces here without a change to this file. The getters name what the /// Charging page reads, and each keeps null distinct from zero — a relay that /// reported no temperature is not a relay at 0 °C. class ChargerStatus { final Map raw; const ChargerStatus(this.raw); double? number(String key) => _asDoubleOrNull(raw[key]); int? integer(String key) => _asIntOrNull(raw[key]); bool? flag(String key) => raw[key] is bool ? raw[key] as bool : null; String text(String key) => _asStr(raw[key]); /// The control block, which the charger answers as its own object. Map get settings { final v = raw["settings"]; return v is Map ? Map.from(v) : const {}; } double? setting(String key) => _asDoubleOrNull(settings[key]); int? settingInt(String key) => _asIntOrNull(settings[key]); bool? settingFlag(String key) => settings[key] is bool ? settings[key] as bool : null; String get statusDesc => text("statusDesc"); int get sessionWh => _asInt(raw["sessionWh"]); /// The modes the charger can be moved to from the one it is in. Only the /// cloud transport derives them — it is the only one that can see the boost /// flag and the countdowns the derivation depends on. List get modeOptions { final v = raw["modeOptions"]; return v is List ? v.map(_asStr).where((s) => s.isNotEmpty).toList() : const []; } /// What the charger says about its own local side, when the transport carries /// it: the address the Modbus mode otherwise has to be given by hand. Map get local { final v = raw["local"]; return v is Map ? Map.from(v) : const {}; } /// Everything the charger sent that no field above has a name for: the values /// a message carries that the integration has not modelled, and the fields no /// published map names at all, keyed by the message and field byte they /// arrived in. Raw and unscaled — a unit would be a meaning we do not have. Map get extra { final v = raw["extra"]; return v is Map ? Map.from(v) : const {}; } /// The alarm words, as they arrive: the spec defers what the individual bits /// mean to a list Anker does not publish, so which word is set is still the /// thing to report. bool get alarm => _asBool(raw["alarm"]); List get alarms { final v = raw["alarms"]; return v is List ? v.map(_asInt).toList() : const []; } } /// One EV charger on the linked Anker account, from …/anker-solix/chargers. The /// server merges the cloud's several views of an account, so a charger reads the /// same here whether it stands on its own or belongs to a system; a field no /// view supplied simply stays empty. class AnkerCharger { final String sn; final String name; final String model; final String firmware; final String siteName; final String statusDesc; // charging | standby | … as the cloud names it final bool? online; // null when no view reported a connection state /// The product shot the service holds for this model, when it sent one. A URL /// rather than an image: it is fetched only where it is drawn. final String imageUrl; const AnkerCharger({ required this.sn, this.name = "", this.model = "", this.firmware = "", this.siteName = "", this.statusDesc = "", this.online, this.imageUrl = "", }); factory AnkerCharger.fromJson(Map j) => AnkerCharger( sn: _asStr(j["sn"]), name: _asStr(j["name"]), model: _asStr(j["model"]), firmware: _asStr(j["firmware"]), siteName: _asStr(j["siteName"]), statusDesc: _asStr(j["statusDesc"]), online: j["online"] is bool ? j["online"] as bool : null, imageUrl: _asStr(j["imageUrl"]), ); /// What to call the charger in a list: its name when it has one, its serial /// otherwise — never an empty row. String get label => name.isNotEmpty ? name : sn; } /// The charger list plus the reason it may be empty (a gate that is off, or an /// account that exposed nothing), so a caller can say why rather than show a /// blank panel. class AnkerChargerList { final List chargers; final String detail; const AnkerChargerList({this.chargers = const [], this.detail = ""}); factory AnkerChargerList.fromJson(Map j) { final raw = j["chargers"]; return AnkerChargerList( chargers: raw is List ? raw .whereType() .map((c) => AnkerCharger.fromJson(Map.from(c))) .where((c) => c.sn.isNotEmpty) .toList() : const [], detail: _asStr(j["detail"]), ); } } // --- RFID cards (who may start a charge without a phone) -------------------- /// One card authorised on a charger, as the account holds it. The cloud answers /// with its own field names — alias_name, card_number, create_time — and this is /// the same three read out: a number to delete by, the name it was given, and /// when it was added. class RfidCard { final String number; final String name; final String added; // as the cloud sent it: unix seconds, or "" const RfidCard({required this.number, this.name = "", this.added = ""}); factory RfidCard.fromJson(Map j) { final number = _asStr(j["card_number"]).trim(); final name = _asStr(j["alias_name"]).trim(); return RfidCard( number: number, // A card with no name of its own is still a card somebody holds, and its // number is the only honest thing to call it. name: name.isEmpty ? number : name, added: _asStr(j["create_time"]).trim(), ); } } /// What a card write answers with: whether the account holds that card now, and /// the whole list as it stands after the write. Anker documents neither endpoint, /// so a 200 proves nothing on its own — it is the list that says what happened. class RfidCardWrite { final bool present; final List cards; final String detail; const RfidCardWrite({this.present = false, this.cards = const [], this.detail = ""}); factory RfidCardWrite.fromJson(Map j) => RfidCardWrite( present: _asBool(j["present"]), cards: j["cards"] is List ? (j["cards"] as List) .whereType() .map((c) => RfidCard.fromJson(Map.from(c))) .where((c) => c.number.isNotEmpty) .toList() : const [], detail: _asStr(j["detail"]), ); } /// What a scan answers with: the card that was held against the reader, or the /// plain fact that nothing was. A window that closed empty is an answer, not a /// timeout, which is why [tapped] is separate from [card]. class RfidScan { final bool tapped; final String card; const RfidScan({this.tapped = false, this.card = ""}); factory RfidScan.fromJson(Map j) => RfidScan(tapped: _asBool(j["tapped"]), card: _asStr(j["card"]).trim()); } // --- the charging scheduler ------------------------------------------------- // // The charger's own cloud schedule can say one thing — "charge between these // hours" — and it says it inside one charger. This is a list, and each entry is // a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30, on these // chargers, on these days. One named thing, switched on and off as one. /// One command in a task's flow: what to do, and at what time of day. class ChargingStep { /// start, stop, limit (to [amps]) or boost. final String action; final double amps; /// A 24-hour "HH:MM", read in the task's zone. final String time; const ChargingStep({required this.action, required this.time, this.amps = 0}); factory ChargingStep.fromJson(Map j) => ChargingStep( action: _asStr(j["action"]), time: _asStr(j["time"]), amps: _asDouble(j["amps"]), ); Map toJson() => {"action": action, "time": time, "amps": amps}; } /// One entry in the home-charger scheduler. It belongs to the person, like the /// chargers it acts on — one list covering every charger they own, rather than a /// separate schedule inside each one. class ChargingTask { final String id; final String name; /// The home-charger records this task acts on. Empty means every charger the /// owner has, including ones imported after the task was written — "all of /// them" is a standing wish, not the list that happened to exist that day. final List chargers; /// The flow, in the order it runs. final List steps; /// The IANA zone the steps' times are read in. The server's own clock is not /// the one the user set 23:00 by. final String zone; /// The weekdays it repeats on, 0=Sunday … 6=Saturday. Empty means every day. final List days; final bool enabled; /// What happened the last time a step of it fired, so a task that has been /// failing quietly for a week says so in the list rather than in a log nobody /// reads. final DateTime? lastRun; final String lastResult; const ChargingTask({ required this.id, this.name = "", this.chargers = const [], this.steps = const [], this.zone = "", this.days = const [], this.enabled = true, this.lastRun, this.lastResult = "", }); factory ChargingTask.fromJson(Map j) => ChargingTask( id: _asStr(j["id"]), name: _asStr(j["name"]), chargers: _asStrList(j["chargers"]), steps: j["steps"] is List ? (j["steps"] as List) .whereType() .map((e) => ChargingStep.fromJson(Map.from(e))) .toList() : const [], zone: _asStr(j["zone"]), days: j["days"] is List ? (j["days"] as List).map(_asInt).toList() : const [], enabled: _asBool(j["enabled"]), lastRun: j["lastRun"] == null ? null : DateTime.tryParse(_asStr(j["lastRun"]))?.toLocal(), lastResult: _asStr(j["lastResult"]), ); /// The time of day the task begins — its first step's, which is what the list /// is ordered by, so the evening's task sits below the morning's. String get firstTime => steps.isEmpty ? "99:99" : steps.first.time; /// Whether the last firing reached every charger it was aimed at. The server /// words the outcome as the step it fired and then "n of m sent", so every /// charger answering is the only good case; unknown until it has fired once. bool? get lastRunOk { if (lastRun == null) return null; final m = RegExp(r"(\d+) of (\d+) sent$").firstMatch(lastResult); return m != null && m.group(1) == m.group(2); } ChargingTask copyWith({bool? enabled, DateTime? lastRun, String? lastResult}) => ChargingTask( id: id, name: name, chargers: chargers, steps: steps, zone: zone, days: days, enabled: enabled ?? this.enabled, lastRun: lastRun ?? this.lastRun, lastResult: lastResult ?? this.lastResult, ); } // --- home chargers (the user's own wallbox) --------------------------------- // // The garage's import, aimed at the wall: a charger on a connected service // becomes a record of the user's own, and stays one after the account it came // from is disconnected. It belongs to the person rather than to a car — it // charges whichever car is plugged into it, and it outlives any of them. /// One charger the user owns, from GET /home-chargers. class HomeCharger { final String id; final String name; final String serial; final String vendor; // "Anker Solix", "Greencell" final String model; // "A5191" final String siteName; // the system it belongs to, where it has one final double powerKw; final String connector; /// The service it was imported from and that service's own id for it. Both /// blank for a charger added by hand; set only by the import, so a rename /// cannot break the link. final String provider; final String providerChargerId; final String created; const HomeCharger({ required this.id, this.name = "", this.serial = "", this.vendor = "", this.model = "", this.siteName = "", this.powerKw = 0, this.connector = "", this.provider = "", this.providerChargerId = "", this.created = "", }); factory HomeCharger.fromJson(Map j) => HomeCharger( id: _asStr(j["id"]), name: _asStr(j["name"]), serial: _asStr(j["serial"]), vendor: _asStr(j["vendor"]), model: _asStr(j["model"]), siteName: _asStr(j["siteName"]), powerKw: _asDouble(j["powerKw"]), connector: _asStr(j["connector"]), provider: _asStr(j["provider"]), providerChargerId: _asStr(j["providerChargerId"]), created: _asStr(j["created"]), ); /// The line under the name in a list: what the charger is, in as many of the /// three terms as the record actually holds. String get subtitle => [serial, model, siteName].where((s) => s.isNotEmpty).join(" · "); } /// One charger service the user could import from, from GET /charger-providers. /// [detail] says, in one sentence, what to do about a closed gate — the same /// shape the vehicle providers use. class ChargerProvider { final String id; final String label; // "Anker Solix" final String service; // "Anker Solix cloud" final bool connected; final String detail; const ChargerProvider({ required this.id, this.label = "", this.service = "", this.connected = false, this.detail = "", }); factory ChargerProvider.fromJson(Map j) => ChargerProvider( id: _asStr(j["id"]), label: _asStr(j["label"]), service: _asStr(j["service"]), connected: _asBool(j["connected"]), detail: _asStr(j["detail"]), ); } /// One charger as the service that has it describes it, from /// /charger-providers/{provider}/chargers. /// /// This is the live half: a record says what a charger *is*, and only the /// service it came from knows whether it is reachable right now. The import /// sheet lists these to pick from; the Charging page holds them beside the /// imported records to say which are online. class ProviderCharger { final String id; // the provider's own id — the serial, for both services final String name; final String vendor; final String model; final String firmware; final String siteId; final String siteName; final String status; // the service's own word for its state final bool? online; // null when the service reported no connection state /// How the charger is registered on the account — standalone, inside a /// system, or merely bound to it. A charger can be several at once. final List sources; /// The charge power as the service words it. The unit is upstream's, so it is /// relayed verbatim rather than given one here. final String power; final int? ocppStatus; final String ocppStatusDesc; /// What the account knows about the box on the wall, as opposed to the /// charging: which networks it is on, where it thinks it is, when it was /// bound, and the picture the app shows for it. final String wifiName; final String wifiMac; final int? wifiRssi; final String bleMac; final String timeZone; final double? linkedAt; // unix seconds final String imageUrl; final List relatedBy; // ble, wifi — how the app reaches it /// Everything else the service said about this charger, under its own field /// names. The fields above are the ones DriverVault has a name for; this is /// the remainder, kept rather than dropped — the service documents none of it, /// so its key is the only honest label these values have. final Map attrs; /// Set when this charger is already in DriverVault, so the import never /// offers the same one twice. final String linkedChargerId; const ProviderCharger({ required this.id, this.name = "", this.vendor = "", this.model = "", this.firmware = "", this.siteId = "", this.siteName = "", this.status = "", this.online, this.sources = const [], this.power = "", this.ocppStatus, this.ocppStatusDesc = "", this.wifiName = "", this.wifiMac = "", this.wifiRssi, this.bleMac = "", this.timeZone = "", this.linkedAt, this.imageUrl = "", this.relatedBy = const [], this.attrs = const {}, this.linkedChargerId = "", }); factory ProviderCharger.fromJson(Map j) => ProviderCharger( id: _asStr(j["id"]), name: _asStr(j["name"]), vendor: _asStr(j["vendor"]), model: _asStr(j["model"]), firmware: _asStr(j["firmware"]), siteId: _asStr(j["siteId"]), siteName: _asStr(j["siteName"]), status: _asStr(j["status"]), online: j["online"] is bool ? j["online"] as bool : null, sources: _asStrList(j["sources"]), power: _asStr(j["power"]), ocppStatus: _asIntOrNull(j["ocppStatus"]), ocppStatusDesc: _asStr(j["ocppStatusDesc"]), wifiName: _asStr(j["wifiName"]), wifiMac: _asStr(j["wifiMac"]), wifiRssi: _asIntOrNull(j["wifiRssi"]), bleMac: _asStr(j["bleMac"]), timeZone: _asStr(j["timeZone"]), linkedAt: _asDoubleOrNull(j["linkedAt"]), imageUrl: _asStr(j["imageUrl"]), relatedBy: _asStrList(j["relatedBy"]), attrs: _asStrMap(j["attrs"]), linkedChargerId: _asStr(j["linkedChargerId"]), ); String get subtitle => [vendor, model, siteName].where((s) => s.isNotEmpty).join(" · "); /// The OCPP connector state as the *service* sees it — the cloud's own /// reading, not our CSMS's. It words it when it can and numbers it when it /// cannot. String get ocppLabel { if (ocppStatusDesc.isNotEmpty) return ocppStatusDesc; return ocppStatus == null ? "" : "$ocppStatus"; } } /// One of the account's views of a charger — the station record, the totals, /// the history, the OCPP backend, the cards, the sharing, the firmware, or any /// of the others. Relayed as the fields it sent: Anker documents none of these /// payloads, so the cloud's own keys are the only honest labels. class ChargerDetailView { final String id; final Map attrs; /// The one line in DriverVault's words rather than the cloud's: a number this /// view reports that another view gives a meaning to, resolved by the server. final String note; final String error; const ChargerDetailView({required this.id, this.attrs = const {}, this.note = "", this.error = ""}); factory ChargerDetailView.fromJson(Map j) => ChargerDetailView( id: _asStr(j["id"]), attrs: _asStrMap(j["attrs"]), note: _asStr(j["note"]), error: _asStr(j["error"]), ); /// The fields, sorted, so the same charger reads the same way every time. List<(String, String)> get rows { final keys = attrs.keys.toList()..sort(); return [for (final key in keys) (key, attrs[key]!)]; } } /// Every per-charger view for one charger, from /// …/anker-solix/chargers/{sn}/details. class ChargerDetails { final String sn; final List views; const ChargerDetails({this.sn = "", this.views = const []}); factory ChargerDetails.fromJson(Map j) { final raw = j["views"]; return ChargerDetails( sn: _asStr(j["sn"]), views: raw is List ? raw .whereType() .map((v) => ChargerDetailView.fromJson(Map.from(v))) .toList() : const [], ); } } /// The chargers on one provider account, plus the reason the list may be empty: /// a gate that is off answers 200 with nothing and a sentence, so the UI can say /// "connect this in Settings" rather than show a blank panel. class ProviderChargerList { final List chargers; final bool unavailable; final String detail; const ProviderChargerList({ this.chargers = const [], this.unavailable = false, this.detail = "", }); factory ProviderChargerList.fromJson(Map j) { final raw = j["chargers"]; return ProviderChargerList( chargers: raw is List ? raw .whereType() .map((c) => ProviderCharger.fromJson(Map.from(c))) .where((c) => c.id.isNotEmpty) .toList() : const [], unavailable: _asBool(j["unavailable"]), detail: _asStr(j["detail"]), ); } } // --- vehicle providers (the connected-service tab) --------------------------- // // A provider is a manufacturer service a car can be linked to (MyToyota today). // Nothing below models an upstream schema: the API Server flattens whatever the // plugin returned into key/value pairs, so a provider adding a field surfaces it // here without a change to this file. See the API's vehicleproviders.go. /// One registered provider and whether this user can currently use it. /// [detail] says, in one sentence, what to do about a closed gate. class VehicleProvider { final String id; final String label; // "MyToyota" final String service; // "Toyota Connected Europe" final bool connected; final String detail; const VehicleProvider({ required this.id, required this.label, this.service = "", this.connected = false, this.detail = "", }); factory VehicleProvider.fromJson(Map j) => VehicleProvider( id: _asStr(j["id"]), label: _asStr(j["label"]), service: _asStr(j["service"]), connected: _asBool(j["connected"]), detail: _asStr(j["detail"]), ); } /// One leaf of a provider payload, flattened to a dotted path. class ProviderField { final String key; final String value; const ProviderField({required this.key, required this.value}); factory ProviderField.fromJson(Map j) => ProviderField(key: _asStr(j["key"]), value: _asStr(j["value"])); static List listFrom(dynamic v) => v is List ? v.map((e) => ProviderField.fromJson(Map.from(e))).toList() : const []; } /// One vehicle on the user's provider account. class ProviderVehicle { final String id; // the provider's own id (the VIN, for Toyota) final String vin; final String name; final String make; final String model; final int year; final String imageUrl; final List fields; /// Set when this user already has a car linked to this vehicle, so the UI can /// say so instead of offering to link it twice. final String linkedCarId; const ProviderVehicle({ required this.id, this.vin = "", this.name = "", this.make = "", this.model = "", this.year = 0, this.imageUrl = "", this.fields = const [], this.linkedCarId = "", }); factory ProviderVehicle.fromJson(Map j) => ProviderVehicle( id: _asStr(j["id"]), vin: _asStr(j["vin"]), name: _asStr(j["name"]), make: _asStr(j["make"]), model: _asStr(j["model"]), year: _asInt(j["year"]), imageUrl: _asStr(j["imageUrl"]), fields: ProviderField.listFrom(j["fields"]), linkedCarId: _asStr(j["linkedCarId"]), ); String get subtitle => [make, model, year > 0 ? "$year" : ""].where((s) => s.isNotEmpty).join(" "); } /// A headline reading lifted out of the sections — the few values worth showing /// large. [key] is a stable id the app localizes (car.provider.metrics.*). class ProviderMetric { final String key; final String value; final String unit; const ProviderMetric({required this.key, required this.value, this.unit = ""}); factory ProviderMetric.fromJson(Map j) => ProviderMetric( key: _asStr(j["key"]), value: _asStr(j["value"]), unit: _asStr(j["unit"]), ); } /// One capability's outcome. A section that fails carries its error and the rest /// still render: half a snapshot beats an error page. class ProviderSection { final String id; final String status; // ok | error | empty final String error; final List fields; final bool truncated; const ProviderSection({ required this.id, this.status = "ok", this.error = "", this.fields = const [], this.truncated = false, }); factory ProviderSection.fromJson(Map j) => ProviderSection( id: _asStr(j["id"]), status: _asStr(j["status"]).isEmpty ? "ok" : _asStr(j["status"]), error: _asStr(j["error"]), fields: ProviderField.listFrom(j["fields"]), truncated: _asBool(j["truncated"]), ); } /// Everything a provider can currently tell us about one car. /// /// [unavailable] replaces the payload when the provider cannot be reached for /// this car at all — not connected, or the vehicle is not on *this* user's /// account. It arrives as a 200, because "we asked and here is why there is /// nothing" is an answer rather than a failure. class ProviderSnapshot { final String provider; final String label; final String service; final bool unavailable; final String detail; final DateTime? fetchedAt; final ProviderVehicle? vehicle; final List metrics; final List sections; /// The odometer the provider reports when it is ahead of the car's stored /// reading — what the tab's "update odometer" offers. 0 when they agree. final int suggestedCurrentKm; const ProviderSnapshot({ this.provider = "", this.label = "", this.service = "", this.unavailable = false, this.detail = "", this.fetchedAt, this.vehicle, this.metrics = const [], this.sections = const [], this.suggestedCurrentKm = 0, }); factory ProviderSnapshot.fromJson(Map j) { final v = j["vehicle"]; return ProviderSnapshot( provider: _asStr(j["provider"]), label: _asStr(j["label"]), service: _asStr(j["service"]), unavailable: _asBool(j["unavailable"]), detail: _asStr(j["detail"]), fetchedAt: _asDate(j["fetchedAt"]), vehicle: v is Map ? ProviderVehicle.fromJson(Map.from(v)) : null, metrics: j["metrics"] is List ? (j["metrics"] as List) .map((e) => ProviderMetric.fromJson(Map.from(e))) .toList() : const [], sections: j["sections"] is List ? (j["sections"] as List) .map((e) => ProviderSection.fromJson(Map.from(e))) .toList() : const [], suggestedCurrentKm: _asInt(j["suggestedCurrentKm"]), ); } } /// What POST /me/import created. Every count is of brand-new records — the /// import never merges with or overwrites anything already there. class ImportResult { final int cars; final int services; final int parts; const ImportResult({this.cars = 0, this.services = 0, this.parts = 0}); factory ImportResult.fromJson(Map j) => ImportResult( cars: _asInt(j["carsImported"]), services: _asInt(j["servicesImported"]), parts: _asInt(j["partsImported"]), ); }