Files
DriverVault/Phone App/lib/format.dart
T
tajniak81andClaude Opus 4.8 b6bb6b1df0 Add a language-switch system with per-language files
Introduce a hand-rolled i18n layer across all three UIs, each reading its
text from per-language JSON files (English base + Polish + Danish). Nothing
in the converted screens hardcodes English any more.

- Web App (Vue): src/i18n/{en,pl,da}.json + index.js exposing t()/tSplit(),
  reactive to the signed-in profile locale. Every view, component, form and
  the status labels in lib/format.js go through t().
- API Server panel (Vue): src/i18n/ with its own localStorage-persisted
  language (the panel has no user profile) and a header language picker.
  Chrome, cards, login and API section titles translated; endpoint reference
  descriptions intentionally kept in English. Rebuilt embedded dist.
- Phone App (Flutter): assets/i18n/ + lib/i18n.dart loaded at startup,
  driven by AppSettings.locale. Nav, login, lock, dashboard, the full
  Settings panel (incl. language picker) and format.dart status labels
  translated; remaining detail screens fall back to English.

Language = the language half of the existing BCP-47 locale; the region half
still drives date/number/currency formatting. Missing keys fall back to
English, and plurals use Intl.PluralRules / Intl.plural so Polish gets the
correct one/few/many forms. Settings flags languages without a translation.

Tests updated to assert the localized (Polish) status wording; all pass.
See TRANSLATIONS.md for the format and how to add a language.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:07:48 +02:00

190 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);
}
String formatKm(int? km) => (km == null || km == 0) ? "—" : "${_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 (currentKm == 0 || 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;
}