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>
873 lines
28 KiB
Dart
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;
|
|
}
|