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>
190 lines
8.5 KiB
Dart
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;
|
|
}
|