Four rounds of web-app features never reached the phone: fuel, maintenance, document and reminder tracking; attachments; the currency setting and the locale split; and technical check history. The README claimed full parity throughout, so the gap was invisible. Catch the phone up, mirroring the web components field for field. Car detail grows the web app's tabs, in its order: technical checks, maintenance, fuel (with the summary panel), documents and reminders, beside the existing service and parts lists. The derived figures are the server's and are rendered as "—" wherever it sent null — a window with a missed fill has no consumption, and a plausible-looking 0.0 there would be a lie. Attachments hang off service records, technical checks, workshop visits, refills, documents and parts on identical terms, so one field and one apply helper cover all six rather than being copied per form. As on the web, the form only collects intent: the file endpoints address a record that must already exist, so a create-with-file is two calls, and a failure on the second reports as an attachment error because the metadata is committed. Two bugs fixed on the way: - _carPayload omitted technicalCheckIntervalDays. The API rewrites every column from the body, so any car edit — including the one-tap odometer update — silently zeroed the car's inspection interval. - main() never called initializeDateFormatting, so month names ignored the chosen language that the new Language picker exists to set. Luxembourgish and Romansh are deliberately left off the language list: intl ships no symbols for them and throws rather than falling back, which would take out every date on screen. The browser has full ICU data and has no such limit, so the web app can offer them. The server only validates a locale's shape, so an unrenderable tag can still arrive from the web; format.dart resolves through a supported-language check and falls back to en-US. Labels for the language/region/currency lists are hand-kept because Dart has no Intl.DisplayNames. The lists mirror validCurrencies in me.go. file_picker is pinned to ^10: v8 compiles against android-34, which no longer builds against the other plugins' compileSdk requirement of 36. Adds the project's first test, covering the parts that fail silently rather than loudly — null derived fields, the badge wording, and the locale guard. The phone was not authorized over ADB, so the UI was not exercised on a device: this is analyzer-, test- and build-clean, and every JSON field name and route was cross-checked against models.go and server.go. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
723 lines
23 KiB
Dart
723 lines
23 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.
|