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)} ${formatTime(d)}"; } /// The clock alone. "auto" leaves the reading to the region, which is what every /// time in the app said before there was a setting; the other two are for the /// people whose region and habit disagree — plenty of Poles read 12-hour clocks /// and plenty of Americans read 24-hour ones, and the region picker also decides /// how money and numbers are grouped, so it is the wrong lever to reach for. /// /// The setting decides *which* clock; this file decides how it is punctuated. /// A region is worth asking whether a reader expects 13:45 or 01:45 pm — that is /// a real difference in how people tell the time. It is not worth asking whether /// the two numbers are joined by a colon or a dot: Danish writes 13.45, and one /// screen of DriverVault writing 13.45 while the next writes 13:45 is not local /// colour, it is an inconsistency. So every time this app prints comes out of /// the two lines below, the same as the web app's format.js. /// /// The cost is that the am/pm marker reads in English everywhere. It is the same /// trade the setting itself makes: a 12-hour clock is not a convention most of /// these regions use, so choosing one — or living in a region that does — is /// choosing the clock that comes with it. String formatTime(DateTime? d) { if (d == null) return "—"; final mm = d.minute.toString().padLeft(2, "0"); if (!clockIsTwelveHour()) return "${d.hour.toString().padLeft(2, "0")}:$mm"; // 12 for both noon and midnight, and midnight is the am one. final h = d.hour % 12 == 0 ? 12 : d.hour % 12; return "${h.toString().padLeft(2, "0")}:$mm ${d.hour < 12 ? "am" : "pm"}"; } /// A wall-clock "HH:MM" — a schedule is a time of day, not a moment, so there is /// no date to hand [formatTime] — read on the clock the user chose. Anything /// that is not a time of day comes back as it arrived. String formatClock(String hhmm) { final m = RegExp(r"^(\d{1,2}):(\d{2})$").firstMatch(hhmm.trim()); if (m == null) return hhmm; final h = int.parse(m.group(1)!); if (!clockIsTwelveHour()) return "${h.toString().padLeft(2, "0")}:${m.group(2)}"; final twelve = h % 12 == 0 ? 12 : h % 12; return "${twelve.toString().padLeft(2, "0")}:${m.group(2)} ${h < 12 ? "am" : "pm"}"; } /// Whether times are written on a 12-hour clock right now: what the setting says /// outright, or what the region says when it is left on auto. /// /// Not only [formatTime]'s business. A control that lets somebody *enter* a time /// has to offer the same clock, and a box that reads 13:45 beside a picker that /// says 01:45 PM is the disagreement this setting exists to end — see /// widgets/time_field.dart. bool clockIsTwelveHour() { switch (appSettings.timeFormat) { case "12": return true; case "24": return false; default: return _regionReadsTwelveHour(); } } /// Whether the chosen region tells the time on a 12-hour clock — the one /// question "auto" asks it. Cached because this is asked once per timestamp on a /// page that can hold a great many, and the answer only changes with the region. final Map _twelveHourRegions = {}; bool _regionReadsTwelveHour() { return _twelveHourRegions.putIfAbsent(_locale, () { try { // DateFormat.j() is the locale's own preferred hour field: "h" where it is // read on a 12-hour clock, "H" where it is not. return DateFormat.j(_locale).pattern?.contains("h") ?? false; } catch (_) { // An unusable locale is not a reason to print nothing; 24-hour is the // safer default, being the one that cannot be read as the wrong half of // the day. return false; } }); } // --- Weekdays --------------------------------------------------------------- // // A week does not start on the same day everywhere: Monday across most of // Europe, Sunday in the US and a good deal of Asia. A row of weekday buttons // that always begins on Sunday reads wrong to half the people looking at it, // and reads wrong in a way that is easy to mistap — Settings › Appearance › // First day of the week is the answer, with "auto" following the chosen region // the way the clock setting does. // // Everything that lays weekdays out in a row goes through these, so there is one // answer to "which day comes first" rather than one per screen. Days are // numbered the way the scheduler's stored tasks number them: 0 = Sunday … // 6 = Saturday. /// Whether weeks are drawn as starting on Monday right now: what the setting /// says outright, or what the region says when it is left on auto. bool weekStartsOnMonday() { switch (appSettings.weekStart) { case "monday": return true; case "sunday": return false; default: return _regionStartsOnMonday(); } } final Map _mondayRegions = {}; bool _regionStartsOnMonday() { return _mondayRegions.putIfAbsent(_locale, () { try { // intl carries the region's own answer in its date symbols, numbered // 0 = Monday … 6 = Sunday (the Closure convention its data came from). return DateFormat.yMd(_locale).dateSymbols.FIRSTDAYOFWEEK == 0; } catch (_) { // Monday is the safer default: it is ISO 8601's, and the convention in // every region this app's own currency list covers bar one. return true; } }); } /// The seven days in the order they should be drawn, as day numbers. List weekdaysInOrder() => weekStartsOnMonday() ? const [1, 2, 3, 4, 5, 6, 0] : const [0, 1, 2, 3, 4, 5, 6]; /// One day's short name in the user's own language, so a row reads Pn Wt Śr in /// Polish without a table here. 2024-01-07 was a Sunday, which is where day 0 /// sits, so the offset lands each number on its own day. String weekdayShortName(int day) { try { return DateFormat.E(_locale).format(DateTime.utc(2024, 1, 7 + day)); } catch (_) { return "$day"; } } /// A set of days, listed in the order this account reads a week in — so the same /// three days always come out in the same order wherever they are shown. List sortWeekdays(Iterable days) { final order = weekdaysInOrder(); return days.toList()..sort((a, b) => order.indexOf(a).compareTo(order.indexOf(b))); } // 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 = []; 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 = []; 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(" · ")}); }