Files
DriverVault/Phone App/lib/models.dart
T
tajniak81andClaude Opus 4.8 ee28b522c7 Bring the Phone App up to parity with the web app
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>
2026-07-17 15:15:01 +02:00

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.