A car collected new sits at 0 km, and every km calculation in the app
quietly refused to work for it. ComputeDerived only filled NextServiceKm
when Km > 0, so a service record entered at 0 produced no next-due
distance at all — the date side worked, because it guards on IsZero(),
which is genuine absence rather than a number that happens to be low.
The same conflation had been copied outward from there. The reminder's
km signal wanted currentKm > 0 before it would count anything down, the
web badge and the service-life ring tested the odometer for truthiness,
formatKm printed an em dash for zero, and fuel and charging rejected a
0 km entry as "odometer (km) is required" — which is the first charge
of an EV on the driveway on delivery day. The phone app carried its own
copy of each. Editing such a car offered an empty odometer box, since
the forms only prefilled a reading above zero.
Everywhere the odometer is a measurement, absence is now tested as
absence: null in the clients, negative on the server, and the required
fields check that the box was filled rather than that the number cleared
zero. Fuel and charging validate Km < 0 instead, and their inputs drop
min="1". Completing a repeating km reminder rolls from the car's actual
reading in every case; the old fallback to the previous target existed
to keep an untracked car off a due date in the past, but CurrentKm +
RepeatKm is ahead of the car by construction, so it could not have
happened.
Left as it was: dueKm, repeatKm and the service intervals, where zero
really does encode "no trigger" and "use the default", and the liters
and kwh checks, since a zero fill is not a fill.
Maintenance is the exception. Its odometer is the one that is genuinely
optional, so zero there still has to mean "not recorded" and those three
sites keep the truthiness test, commented. Fixing that properly wants a
nullable field rather than an int, which is a schema change and its own
commit — the same shape of problem as the latency em dash in 3c4eba8.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
8.5 KiB
Dart
191 lines
8.5 KiB
Dart
import "package:flutter/material.dart";
|
|
import "package:intl/intl.dart";
|
|
|
|
import "i18n.dart";
|
|
import "main.dart";
|
|
import "models.dart";
|
|
import "theme.dart";
|
|
|
|
/// The languages intl actually ships symbols for. It throws rather than falling
|
|
/// back on the rest, and the API only validates a locale's *shape*
|
|
/// (`^[a-z]{2}-[A-Z]{2}$`), so an unsupported tag can legitimately arrive here —
|
|
/// set from the web, which has the browser's full ICU data behind it. Guarding
|
|
/// once keeps that from throwing out of every date on screen.
|
|
final _supportedLanguages = DateFormat.allLocalesWithSymbols().toSet();
|
|
|
|
/// The user's locale when intl can render it, else a safe default. Only the
|
|
/// language subtag is checked: intl resolves an unknown *region* by falling back
|
|
/// to the language ("en-PL" formats as "en"), but an unknown language throws.
|
|
String get _locale =>
|
|
_supportedLanguages.contains(appSettings.language) ? appSettings.locale : "en-US";
|
|
|
|
/// Every number we render goes through here so the grouping separator follows
|
|
/// the user's chosen region rather than the device's own locale — otherwise the
|
|
/// odometer disagrees with the dates and costs beside it.
|
|
String _num(num value) => NumberFormat.decimalPattern(_locale).format(value);
|
|
|
|
/// Formats a date per the signed-in user's chosen date format (appSettings),
|
|
/// mirroring the web app's format.js. Month names follow the locale's language.
|
|
String formatDate(DateTime? d) {
|
|
if (d == null) return "—";
|
|
final pattern = switch (appSettings.dateFormat) {
|
|
"DMY_NUM" => "dd-MM-yyyy",
|
|
"DMY" => "dd MMM yyyy",
|
|
"MDY" => "MMM dd, yyyy",
|
|
_ => "yyyy-MM-dd", // YMD
|
|
};
|
|
return DateFormat(pattern, _locale).format(d);
|
|
}
|
|
|
|
// 0 km is a reading — a car collected new — not a blank. See format.js.
|
|
String formatKm(int? km) => km == null ? "—" : "${_num(km)} km";
|
|
|
|
// The fuel figures. The server sends null for anything it could not derive (a
|
|
// window with a missed fill, a first-ever tank), which reads as "—" rather than
|
|
// a misleading zero.
|
|
String formatLiters(double? value) => value == null ? "—" : "${value.toStringAsFixed(2)} L";
|
|
|
|
/// Amounts are stored as plain numbers; the user's currency setting only decides
|
|
/// how they are displayed. Nothing is converted — a figure entered as 40 reads as
|
|
/// 40 in whichever currency is selected.
|
|
///
|
|
/// No explicit fraction digits: the currency's own minor unit pins them, which
|
|
/// keeps the 2 decimals the fuel figures were written for while still rendering
|
|
/// yen without phantom sen.
|
|
String formatMoney(double? value) {
|
|
if (value == null) return "—";
|
|
return NumberFormat.simpleCurrency(locale: _locale, name: appSettings.currency).format(value);
|
|
}
|
|
|
|
/// One decimal: the interesting differences between tanks live in tenths, and
|
|
/// rounding to whole litres collapses a best of 6.8 and a worst of 7.0 into the
|
|
/// same number.
|
|
String formatConsumption(double? value) =>
|
|
value == null ? "—" : "${value.toStringAsFixed(1)} L/100km";
|
|
|
|
String formatKmPerLiter(double? value) =>
|
|
value == null ? "—" : "${value.toStringAsFixed(2)} km/L";
|
|
|
|
const int _kmSoon = 1000;
|
|
|
|
enum StatusKey { unknown, ok, soon, overdue }
|
|
|
|
class Status {
|
|
final StatusKey key;
|
|
final String label;
|
|
const Status(this.key, this.label);
|
|
|
|
/// Soft tint background for the status pill — DriverVault semantic colours,
|
|
/// re-cut for dark surfaces.
|
|
Color bg(bool dark) => switch (key) {
|
|
StatusKey.overdue => dark ? DriverVault.dangerSoftDark : DriverVault.dangerSoft,
|
|
StatusKey.soon => dark ? DriverVault.warningSoftDark : DriverVault.warningSoft,
|
|
StatusKey.ok => dark ? DriverVault.successSoftDark : DriverVault.successSoft,
|
|
StatusKey.unknown => dark ? DriverVault.darkSunken : DriverVault.ink50,
|
|
};
|
|
Color fg(bool dark) => switch (key) {
|
|
StatusKey.overdue => DriverVault.danger,
|
|
StatusKey.soon => DriverVault.warning,
|
|
StatusKey.ok => DriverVault.success,
|
|
StatusKey.unknown => dark ? DriverVault.darkTextMuted : DriverVault.ink500,
|
|
};
|
|
}
|
|
|
|
int _rank(StatusKey k) => switch (k) {
|
|
StatusKey.unknown => 0,
|
|
StatusKey.ok => 1,
|
|
StatusKey.soon => 2,
|
|
StatusKey.overdue => 3,
|
|
};
|
|
|
|
Status _dateSignal(DateTime? nextDate) {
|
|
if (nextDate == null) return Status(StatusKey.unknown, t("status.noData"));
|
|
final today = DateTime.now();
|
|
final days = DateTime(nextDate.year, nextDate.month, nextDate.day)
|
|
.difference(DateTime(today.year, today.month, today.day))
|
|
.inDays;
|
|
if (days < 0) return Status(StatusKey.overdue, t("status.serviceOverdueDays", params: {"days": days.abs()}));
|
|
if (days <= 30) return Status(StatusKey.soon, t("status.dueInDays", params: {"days": days}));
|
|
return Status(StatusKey.ok, t("status.okDays", params: {"days": days}));
|
|
}
|
|
|
|
Status _kmSignal(int currentKm, int? nextKm) {
|
|
if (nextKm == null) return Status(StatusKey.unknown, t("status.noKm"));
|
|
final remaining = nextKm - currentKm;
|
|
if (remaining < 0) return Status(StatusKey.overdue, t("status.serviceOverdueKm", params: {"km": _num(remaining.abs())}));
|
|
if (remaining <= _kmSoon) return Status(StatusKey.soon, t("status.inKm", params: {"km": _num(remaining)}));
|
|
return Status(StatusKey.ok, t("status.kmLeft", params: {"km": _num(remaining)}));
|
|
}
|
|
|
|
/// Maps the server's expiry/reminder state names onto the badge palette. The
|
|
/// client never re-derives the date maths — it only chooses the wording.
|
|
StatusKey _expiryKey(String state) => switch (state) {
|
|
"expired" => StatusKey.overdue,
|
|
"expiring_soon" => StatusKey.soon,
|
|
"valid" => StatusKey.ok,
|
|
_ => StatusKey.unknown, // no_expiry
|
|
};
|
|
|
|
/// Renewal badge for a dated document or certificate, driven by the server's
|
|
/// expiry assessment.
|
|
Status expiryStatus(ExpiryAssessment e) {
|
|
final days = e.days;
|
|
final label = switch (e.state) {
|
|
"expired" => t("status.expiredAgo", params: {"days": (days ?? 0).abs()}),
|
|
"expiring_soon" => days == 0 ? t("status.expiresToday") : t("status.renewInDays", params: {"days": days}),
|
|
"valid" => t("status.validDays", params: {"days": days}),
|
|
_ => t("status.noExpiry"),
|
|
};
|
|
return Status(_expiryKey(e.state), label);
|
|
}
|
|
|
|
/// Reminder badge. The server has already picked the worse of the date and
|
|
/// odometer signals; this only chooses the wording, leading with whichever
|
|
/// trigger is actually closest to firing.
|
|
Status reminderStatus(Reminder r) {
|
|
final key = switch (r.status) {
|
|
"overdue" => StatusKey.overdue,
|
|
"due_soon" => StatusKey.soon,
|
|
"upcoming" => StatusKey.ok,
|
|
_ => StatusKey.unknown, // done | no_trigger
|
|
};
|
|
|
|
final days = r.daysLeft;
|
|
final km = r.kmLeft;
|
|
if (r.status == "done") return Status(StatusKey.unknown, t("status.done"));
|
|
if (r.status == "no_trigger") return Status(StatusKey.unknown, t("status.noTrigger"));
|
|
|
|
final parts = <String>[];
|
|
if (r.status == "overdue") {
|
|
if (days != null && days < 0) parts.add(t("status.days", params: {"days": days.abs()}));
|
|
if (km != null && km < 0) parts.add(t("status.km", params: {"km": _num(km.abs())}));
|
|
return Status(key, parts.isEmpty ? t("status.overdue") : t("status.overdueBy", params: {"parts": parts.join(" · ")}));
|
|
}
|
|
|
|
if (days != null && days >= 0) parts.add(days == 0 ? t("status.today") : t("status.days", params: {"days": days}));
|
|
if (km != null && km >= 0) parts.add(t("status.km", params: {"km": _num(km)}));
|
|
return Status(key, parts.isEmpty ? t("status.upcoming") : t("status.dueIn", params: {"parts": parts.join(" · ")}));
|
|
}
|
|
|
|
/// Warranty badge for a maintenance entry. Unlike the others this one has no
|
|
/// server-side assessment — only the raw active/days-left pair — so the wording
|
|
/// is chosen from those directly.
|
|
Status? warrantyStatus(MaintenanceEntry m) {
|
|
final days = m.warrantyDaysLeft;
|
|
if (m.warrantyActive == null || days == null) return null;
|
|
if (!m.warrantyActive!) return Status(StatusKey.unknown, t("status.warrantyExpiredAgo", params: {"days": days.abs()}));
|
|
if (days <= 30) return Status(StatusKey.soon, t("status.warrantyEndsIn", params: {"days": days}));
|
|
return Status(StatusKey.ok, t("status.underWarranty", params: {"days": days}));
|
|
}
|
|
|
|
/// Combines the date- and km-based signals, returning the worse of the two —
|
|
/// the same logic as the web app and the spreadsheet idea.
|
|
Status serviceStatus(ServiceRecord? latest, Car car) {
|
|
final date = _dateSignal(latest?.nextServiceDate);
|
|
final km = _kmSignal(car.currentKm, latest?.nextServiceKm);
|
|
final worse = _rank(km.key) > _rank(date.key) ? km : date;
|
|
if (date.key == StatusKey.unknown && km.key != StatusKey.unknown) return km;
|
|
if (km.key == StatusKey.unknown && date.key != StatusKey.unknown) return date;
|
|
return worse;
|
|
}
|