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, }; 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 = []; 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; }