Add petrol_lpg, diesel_lpg and hydrogen to the fuel_type choices, across the six places that define them: the PocketBase schema, the car form and detail view in both the Web App and the Phone App, and the two doc comments that enumerate the values. The Go API needed no change — it passes fuel_type through as a free string, so PocketBase is the only validator. Model the LPG conversions as their own choices rather than a separate "has LPG" flag: the car runs on either tank, so "petrol + LPG" is what an owner picks it out as. Order each variant next to its base fuel so the dropdowns read naturally. Purely additive — existing rows keep their values and need no migration. The live PocketBase schema does still need scripts/setup-pocketbase.mjs re-run before the new choices will save, since its select field allows only the old four; the script reconciles select values on existing fields, so re-running migrates it in place. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
288 lines
9.0 KiB
Dart
288 lines
9.0 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;
|
|
|
|
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;
|
|
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,
|
|
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"]),
|
|
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 {
|
|
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;
|
|
|
|
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,
|
|
});
|
|
|
|
factory ServiceRecord.fromJson(Map<String, dynamic> j) => ServiceRecord(
|
|
id: _asStr(j["id"]),
|
|
car: _asStr(j["car"]),
|
|
date: DateTime.tryParse(_asStr(j["date"]))?.toLocal(),
|
|
km: _asInt(j["km"]),
|
|
changedOil: _asBool(j["changedOil"]),
|
|
changedEngineAirFilter: _asBool(j["changedEngineAirFilter"]),
|
|
changedCabinAirFilter: _asBool(j["changedCabinAirFilter"]),
|
|
notes: _asStr(j["notes"]),
|
|
nextServiceDate: j["nextServiceDate"] != null
|
|
? DateTime.tryParse(_asStr(j["nextServiceDate"]))?.toLocal()
|
|
: null,
|
|
nextServiceKm: j["nextServiceKm"] == null ? null : _asInt(j["nextServiceKm"]),
|
|
);
|
|
}
|
|
|
|
class Part {
|
|
final String id;
|
|
final String car;
|
|
final String name;
|
|
final String partNumber;
|
|
final String category;
|
|
|
|
Part({
|
|
required this.id,
|
|
required this.car,
|
|
required this.name,
|
|
required this.partNumber,
|
|
this.category = "",
|
|
});
|
|
|
|
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"]),
|
|
);
|
|
}
|
|
|
|
/// 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;
|
|
final String dateFormat; // YMD | DMY_NUM | DMY | MDY
|
|
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,
|
|
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"]),
|
|
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.
|