Files
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

98 lines
3.6 KiB
Dart

import "dart:convert";
import "package:flutter/services.dart" show rootBundle;
import "package:intl/intl.dart";
import "main.dart" show appSettings;
/// UI translation for the phone app. Every user-visible string lives in a
/// per-language JSON file under assets/i18n/; nothing hardcodes English.
///
/// The language is the language half of the signed-in user's stored BCP-47
/// locale (appSettings.locale), the same value that already drives dates and
/// numbers in format.dart — so the Settings language picker steers both. Because
/// MaterialApp is wrapped in a ListenableBuilder on appSettings, changing the
/// locale rebuilds the whole tree and every t() re-resolves.
///
/// Deliberately hand-rolled rather than gen_l10n/ARB: the app already reads a
/// runtime locale from the profile (not the device locale Flutter's own
/// localization is built around), and "a JSON file per language the app reads
/// from" is exactly the shape asked for.
const String _base = "en";
/// Languages that ship a real translation file. The Settings picker offers every
/// European language for date/number formatting; only these change the UI text,
/// and the rest fall back to English.
const List<String> translatedLanguages = ["en", "pl", "da"];
final Map<String, Map<String, dynamic>> _messages = {};
/// Loads every bundled language file into memory. Call once in main() before
/// runApp, alongside initializeDateFormatting — the files are small assets and
/// t() is synchronous thereafter.
Future<void> loadTranslations() async {
for (final code in translatedLanguages) {
final raw = await rootBundle.loadString("assets/i18n/$code.json");
_messages[code] = json.decode(raw) as Map<String, dynamic>;
}
}
String get _lang {
final lang = appSettings.locale.split("-").first;
return _messages.containsKey(lang) ? lang : _base;
}
/// True when the active language has its own file (vs. falling back to English).
bool get languageTranslated =>
_messages.containsKey(appSettings.locale.split("-").first);
dynamic _lookup(Map<String, dynamic>? dict, String key) {
dynamic node = dict;
for (final part in key.split(".")) {
if (node is! Map) return null;
node = node[part];
}
return node;
}
String _interpolate(String template, Map<String, Object?>? params) {
if (params == null) return template;
return template.replaceAllMapped(RegExp(r"\{(\w+)\}"), (m) {
final v = params[m.group(1)];
return v == null ? m.group(0)! : v.toString();
});
}
/// Translate [key] for the active language.
///
/// Pass [n] to select a plural form (a JSON object keyed by CLDR category —
/// one/few/many/other); Intl.plural picks the right one for the language, which
/// is what makes Polish read correctly rather than an English-style n==1 split.
/// Any [params] interpolate `{name}` placeholders. A key missing from the active
/// language falls back to English; missing from English too, the key is returned
/// so the gap is visible rather than blank.
String t(String key, {Map<String, Object?>? params, int? n}) {
final lang = _lang;
dynamic entry = _lookup(_messages[lang], key);
entry ??= _lookup(_messages[_base], key);
if (entry == null) return key;
if (entry is Map) {
if (n == null) return key;
final forms = entry;
entry = Intl.plural(
n,
one: forms["one"] as String?,
few: forms["few"] as String?,
many: forms["many"] as String?,
other: (forms["other"] ?? forms["one"]) as String? ?? key,
locale: lang,
);
}
if (entry is! String) return key;
final merged = <String, Object?>{if (n != null) "n": n, ...?params};
return _interpolate(entry, merged.isEmpty ? null : merged);
}