// 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? 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 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 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 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"; } /// 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 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.