Files
DriverVault/Phone App/lib/format.dart
T
tajniak81andClaude Opus 5 e5759df52c Say how far the next service is, not only how long
The service badge has always watched two triggers - the next-due date and the
next-due odometer reading - and shown one of them. It ranked the two and
printed the worse one's sentence, so a car comfortable on both read "OK ·
354d" and never said that the odometer target was 13.612 km away, even though
the Information card right under it prints the 15.000 km the badge is
counting towards. Whichever trigger arrives first ends the interval, so
naming only one of them describes half the thing.

Both are named now. With both signals known the label is a severity headline
followed by each trigger as a bare quantity - "OK · 354d · 13.612 km", "Due
in 12d · 13.612 km" - which is the shape reminderStatus in the same file
already uses, it having had the two-trigger problem first. The signals gained
the number behind their own wording to make that possible; they were
returning only a formatted sentence.

Wording is unchanged wherever only one signal has data, which is the case
this rewrite most risked disturbing: a car with no odometer target still
reads "OK · 354d" exactly as before, one with no service date still reads
"13.612 km left", and neither still reads "No data". The new keys are only
reached when there are genuinely two numbers to print.

An overdue badge lists only the triggers that have actually passed. "Service
Overdue 30d · 13.612 km" would read as overdue by 13.612 km, which is the
opposite of what that number means, so the trigger that is still comfortable
stays out of a sentence headed "Overdue". It costs the remaining distance on
a date-overdue badge; the alternative costs the reader's trust in the number.

The phone carried a line-for-line copy of this logic and gets the same
treatment rather than being left a version behind - the two would otherwise
disagree about the same car on the same day. Its signals become a private
record type, since Status is public and shared with the expiry, reminder and
warranty badges that have no second trigger and no use for the field.

Two new keys (status.okIn, status.serviceOverdueBy) in all three languages in
both apps. The day and km fragments they interpolate were already translated
for the reminder badge, so the parts assemble in Polish and Danish without
new wording: "OK · 354 dni · 13 612 km", "OK · 354 d · 13.612 km", each with
its own grouping separator.

Verified by flutter analyze (clean), flutter test - 21 pass, including the
key-parity test that would have caught a key added in English alone - and npm
run build for the web. The web function was driven through the real module in
a browser over ten cases: both signals known at each severity, each of the
two overdue alone, both overdue together, either signal missing, neither, and
a zero-odometer car, in all three languages.

Not verified: no new automated test covers this. The web app has no test
runner and the phone's format tests cover the catalogue lookups rather than
the badge, so the ten cases above were checked by hand and are not guarded
against the next edit. The deployed Web App still serves the previous build
and will keep reading "OK · 354d" until it is redeployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 23:23:19 +02:00

252 lines
11 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);
}
/// A timestamp: the user's chosen date format with the wall clock beside it.
/// Only the provider snapshot needs one — everything else in the app is
/// date-only — but it follows the same settings as [formatDate] so the two never
/// disagree on screen.
String formatDateTime(DateTime? d) {
if (d == null) return "—";
return "${formatDate(d)} ${DateFormat.Hm(_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";
/// The charging figures. Same rule as the fuel ones: null is "could not be
/// derived", which reads as "—" rather than a misleading zero.
String formatKwh(double? value) => value == null ? "—" : "${value.toStringAsFixed(2)} kWh";
String formatKwhConsumption(double? value) =>
value == null ? "—" : "${value.toStringAsFixed(1)} kWh/100km";
String formatKmPerKwh(double? value) =>
value == null ? "—" : "${value.toStringAsFixed(2)} km/kWh";
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,
};
/// One of the two service triggers: its badge state and its own wording, plus
/// the number behind that wording so a badge holding both can quote the two
/// side by side.
typedef _Signal = ({StatusKey key, String label, int? value});
_Signal _dateSignal(DateTime? nextDate) {
if (nextDate == null) return (key: StatusKey.unknown, label: t("status.noData"), value: null);
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 (key: StatusKey.overdue, label: t("status.serviceOverdueDays", params: {"days": days.abs()}), value: days);
}
if (days <= 30) {
return (key: StatusKey.soon, label: t("status.dueInDays", params: {"days": days}), value: days);
}
return (key: StatusKey.ok, label: t("status.okDays", params: {"days": days}), value: days);
}
_Signal _kmSignal(int currentKm, int? nextKm) {
if (nextKm == null) return (key: StatusKey.unknown, label: t("status.noKm"), value: null);
final remaining = nextKm - currentKm;
if (remaining < 0) {
return (
key: StatusKey.overdue,
label: t("status.serviceOverdueKm", params: {"km": _num(remaining.abs())}),
value: remaining,
);
}
if (remaining <= _kmSoon) {
return (key: StatusKey.soon, label: t("status.inKm", params: {"km": _num(remaining)}), value: remaining);
}
return (key: StatusKey.ok, label: t("status.kmLeft", params: {"km": _num(remaining)}), value: 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 Status(StatusKey.unknown, t("status.noData"));
}
// With one signal to go on, that signal's own sentence says it best.
if (date.key == StatusKey.unknown) return Status(km.key, km.label);
if (km.key == StatusKey.unknown) return Status(date.key, date.label);
return Status(worse.key, _bothSignals(date, km, worse.key));
}
/// Words a badge that has a due date AND an odometer target. A service falls
/// due on whichever arrives first, so "OK · 354d" on its own left out half of
/// what the badge is watching: the distance still to run belongs beside the
/// days. One headline for the severity, then each trigger as a bare quantity -
/// the shape reminderStatus already uses, it having had the two-trigger
/// problem first.
String _bothSignals(_Signal date, _Signal km, StatusKey key) {
final parts = <String>[];
if (key == StatusKey.overdue) {
// Only what has actually passed. The other trigger is not late, and its
// comfortable remainder under an "Overdue" headline would read as one.
if (date.key == StatusKey.overdue) parts.add(t("status.days", params: {"days": date.value!.abs()}));
if (km.key == StatusKey.overdue) parts.add(t("status.km", params: {"km": _num(km.value!.abs())}));
return t("status.serviceOverdueBy", params: {"parts": parts.join(" · ")});
}
parts.add(t("status.days", params: {"days": date.value}));
parts.add(t("status.km", params: {"km": _num(km.value!)}));
return t(key == StatusKey.soon ? "status.dueIn" : "status.okIn", params: {"parts": parts.join(" · ")});
}