Two changes, both about showing what is actually known rather than a tidier version of it. The build date asked for a day. A car's build date is often only a year, or a month and a year - the VIN plate is stamped with a month, the papers carry a day, a grey import neither - so a field insisting on all three is answered either with an invented day or with nothing, and both throw away what the owner did know. The field now picks its own precision: a full date, a month and year, or a year, each with the control that suits it. A year is typed rather than picked, because a date picker that makes you walk back to 1998 is worse than four keystrokes. Stored as the ISO prefix - "2015", "2015-03", "2015-03-10" - which is ISO 8601 reduced precision, and printed back at exactly that precision. The three shapes sort and compare as strings in date order, which is why the prefix is stored rather than a date with a precision field beside it. The formatter takes the string apart rather than parsing it: "2015-03" read as a UTC instant and printed in local time hands back February west of Greenwich. Narrowing the precision keeps what is still true, so a day dropped from "2015-03-10" leaves "2015-03". Widening clears the field. That is the awkward half of the control and it is deliberate: there is nothing to widen a year with, and leaving "2015" behind an empty month box would store a date the screen is not showing. The column was free text with no validation at all, which was tolerable while only a date picker could write it and is not now that three shapes are legal. normalizeBuildDate parses rather than pattern-matches, so "2015-13" and "2015-02-31" are refused instead of stored as something no reader can print. The phone needed changing to avoid destroying this. It parsed buildDate with DateTime.tryParse, which returns null for "2015" - so a half-known date would have shown as a dash, and saving the car from the phone would have written "" back over it. It holds both date fields as the string they arrived as now, prints them at their own precision, and hands back anything it cannot set. Its picker still only makes full dates; a precision control there is a separate job. Separately: an empty cell of the service table had three different looks in one row. The dash under Notes was body-coloured, as though it were content; the one under File was 12px, having borrowed the size of the Download button that would otherwise be there; the one under Changed parts was muted at 14px. They are one constant now, muted at the row's own size, which is what Next date and Next km already did for a missing value. The Download link keeps its own styling - it is an action, not a value. Verified in a browser: a stored "2015-03" loads as month precision in a month picker, month to year narrows to "2015", year to day clears, "19x98abc" typed into the year box sanitises to "1998", saving sends buildDate:"1998" and the Information tab then reads "1998" - while a full first-registration date beside it still reads 06-08-2026. All five empty cells across the three columns now compute to the same size, colour and weight, with the filled ones unchanged. go vet and go test ./... pass with a new test over the three valid shapes and six rejects; flutter analyze is clean and 22 tests pass, one new, covering a half-known date in two date formats and the time zone that could shift it; npm run build is clean. Not verified: First registration still demands a full date. The same argument applies to it and the field is now a reusable component, but it was not asked for and is one line away. The web formatter's month-name paths - the DMY and MDY formats, which spell the month out - are covered only by the phone's mirror of the logic, the web app still having no test runner. A car created through the Toyota import bypasses the new validation; it only ever produces full dates, so nothing invalid gets in that way, but it is not guarded. Both apps need redeploying before any of this is visible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
280 lines
12 KiB
Dart
280 lines
12 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 date somebody may only half know, as an ISO 8601 reduced-precision date:
|
|
/// "2015", "2015-03" or "2015-03-10". Mirrors formatPartialDate in the web
|
|
/// app's format.js, which is where such a value is entered — a car's build date
|
|
/// is often only a year, and this app must not print a day nobody supplied.
|
|
///
|
|
/// Taken apart as a string rather than parsed: DateTime.parse rejects "2015"
|
|
/// and "2015-03" outright, which is exactly why the value has to be handled
|
|
/// here at all.
|
|
String formatPartialDate(String iso) {
|
|
final value = iso.trim();
|
|
if (value.isEmpty) return "—";
|
|
final match = RegExp(r"^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$").firstMatch(value);
|
|
if (match == null) return formatDate(DateTime.tryParse(value));
|
|
final year = match.group(1)!;
|
|
final month = match.group(2);
|
|
if (match.group(3) != null) return formatDate(DateTime.tryParse(value));
|
|
if (month == null) return year;
|
|
|
|
// Month and year: the month's name where the user's format spells months out,
|
|
// its number where the format is numeric, in the app's usual order either way.
|
|
final when = DateTime(int.parse(year), int.parse(month));
|
|
return switch (appSettings.dateFormat) {
|
|
"DMY_NUM" => "$month-$year",
|
|
"DMY" || "MDY" => DateFormat("MMM yyyy", _locale).format(when),
|
|
_ => "$year-$month",
|
|
};
|
|
}
|
|
|
|
/// 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(" · ")});
|
|
}
|