Phone App: finish the admin screen; translate the last English strings

Three loose ends from the last two commits, each of which was named as
deliberately-not-done and none of which is worth carrying further.

The phone's create-user sheet had no organization picker. The endpoint has
taken an `organization` since orgs existed and the Web App has offered the
choice all along, so a superadmin on the phone could only ever create
accounts in their own org - a silent restriction rather than a stated one.
The sheet now loads the orgs and offers them to a superadmin, with the same
blank "no organization" option and the same hint as the web. An admin still
gets no picker, because the server forces its own org on their members and a
picker that cannot change the outcome is a lie. The listing is manager-only
and can fail, in which case the picker offers only "no organization" rather
than blocking the form.

A locked role picker or delete action was greyed out with no reason given.
The web has explained itself in a title attribute since those guards existed,
and the sentences - admin.cantChangeOwnRole and the rest - have been sitting
translated in the phone's own language files since the screen was translated.
Hover has no touch equivalent, so the two controls take different routes: a
long-press on the role picker shows the reason as a tooltip, and the overflow
menu carries it under the action, because a disabled menu item cannot be
long-pressed and silently greying it out is the thing being fixed.

settings.integrations.* and charging.control.* were English-only in *both*
apps - 70 keys, identical text, identical key sets - so they are translated
once and land in all four language files. OCPP and CSMS are protocol names
and stay; product names (Toyota Connected, MyToyota, Anker Solix, Lexus) stay;
everything else follows the wording already in each language's file.

The Web App's files are edited as text rather than round-tripped through a
JSON dump, because they keep a blank line before every nested block and a
dump flattens it - a 900-line translation file is hard enough to read
without losing its paragraphs. Both diffs are purely additive as a result.

Both apps now have every key in all three languages: 738 in the web, and the
phone reports zero fallbacks. A new test locks that in - every key en.json
carries must exist in pl.json and da.json - and it was checked by deleting a
key and watching it fail, because a guard that cannot fire is not a guard.

Verified by flutter analyze (clean), flutter test - 21 pass, 1 of them new -
flutter build apk --debug, and npm run build for the Web App. The key checker
reports 575 static t() keys in the phone and 738 in the web resolving with no
fallbacks in either language.

Not verified: still nothing run against a live API Server or on a device. In
particular the organization picker's happy path - a superadmin creating an
account into a chosen org - has not been exercised end to end; it is the one
piece here that touches the API rather than only the language files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-08-21 22:13:37 +02:00
co-authored by Claude Opus 5
parent 4372b870ac
commit dc6febf815
9 changed files with 508 additions and 43 deletions
+8 -4
View File
@@ -89,7 +89,11 @@ navigation bar** — Garage, Charging, Settings, and Users for admins — in an
asks first, with the number of cars the file actually holds. Auth relays PocketBase's own stateless
tokens, so there is no per-device session list to show or revoke.
- **Users (admin)** — user management tab (list / create / role / reset password
/ delete), shown only for the admin role.
/ delete), shown only for the admin role. A blocked control says why rather
than only greying out: long-press a locked role picker, and the overflow menu
carries the reason under the action. Superadmins also choose which
organization a new account lands in (or none at all); an admin gets no picker,
because the server puts their members in their own organization regardless.
Sharing/ownership: `Car.access` drives `isOwner` / `canWrite` / `isReadOnly`
getters that gate the UI, mirroring the server's access checks.
@@ -115,9 +119,9 @@ tag the phone cannot render can still arrive from the web. `format.dart` resolve
through a supported-language check and falls back to `en-US` instead of throwing;
`test/models_format_test.dart` covers it.
Every screen reads its text from the language files. The one gap left is the
Integrations and charger-control strings, which the web app shares — so it
belongs to both apps at once. See [TRANSLATIONS.md](../TRANSLATIONS.md).
Every screen reads its text from the language files, and `flutter test` fails if
a key exists in English but not in Polish or Danish. See
[TRANSLATIONS.md](../TRANSLATIONS.md).
## Biometric / face sign-in & app lock
+74
View File
@@ -113,6 +113,24 @@
"full": "Optaget",
"offPeak": "uden for spidsbelastning",
"homeCharger": "Hjemmelader"
},
"control": {
"title": "Laderstyring",
"connected": "Forbundet",
"disconnected": "Offline",
"serialPlaceholder": "Laderens serienummer (f.eks. A5191-XXXXXXXX)",
"refresh": "Opdater",
"status": "Stik",
"meter": "Energi",
"start": "Start opladning",
"stop": "Stop opladning",
"limit": "Strømgrænse",
"applyLimit": "Anvend grænse",
"clearLimit": "Ryd grænse",
"reset": "Genstart laderen",
"resetConfirm": "Genstart denne lader nu? En igangværende opladning bliver afbrudt. Indtast din adgangskode igen for at bekræfte.",
"resetPassword": "Din kontoadgangskode",
"connectHint": "Indtast laderens serienummer og opdater. Laderen skal være forbundet til DriverVaults OCPP-backend (opsættes under Indstillinger → Integrationer)."
}
},
"settings": {
@@ -244,6 +262,62 @@
"exported": "Gemte {file}",
"open": "Åbn",
"openFailed": "Filen kunne ikke åbnes."
},
"integrations": {
"title": "Integrationer",
"subtitle": "Forbind eksterne tjenester til din konto.",
"toyotaDesc": "Log ind med din MyToyota-konto for at hente køretøjsdata fra Toyota Connected Europe. Kun Europa.",
"enable": "Aktivér for min konto",
"enableOrg": "Aktivér for organisationen",
"unavailable": "Denne integration er slået fra af administratoren.",
"orgDisabled": "Denne integration er slået fra for din organisation.",
"scopeMy": "Mine indstillinger",
"scopeOrg": "Organisation",
"scopeHint": "Organisationens indstillinger gælder for alle i din organisation, der lader et felt stå tomt.",
"readOnly": "Denne integrations fælles indstillinger administreres i API-serverens panel; her er de skrivebeskyttede.",
"email": "MyToyota-e-mail",
"password": "MyToyota-adgangskode",
"brand": "Mærke",
"inherited": "Sat på et højere niveau",
"inheritedFrom": "Nedarvet fra {source}",
"sourceGlobal": "administratoren",
"sourceOrg": "din organisation",
"sourceUser": "dig",
"passwordKeep": "Lad feltet stå tomt for at beholde den gemte adgangskode.",
"test": "Test forbindelsen",
"testing": "Tester…",
"save": "Gem",
"saved": "Gemt ✓",
"connected": "Forbundet",
"notConnected": "Ikke forbundet",
"ankerSolixDesc": "Log ind med din Anker-konto for at hente laderdata fra Anker Solix-skyen.",
"ankerEmail": "E-mail til Anker-konto",
"ankerPassword": "Adgangskode til Anker-konto",
"country": "Land",
"countryHint": "Landekode på to bogstaver for din Anker-konto (f.eks. DE, GB, US).",
"controlMode": "Styringstilstand",
"controlModeHint": "Hvordan DriverVault styrer laderen over OCPP.",
"controlOff": "Fra (kun overvågning)",
"controlOwn": "Eget CSMS (fuld styring)",
"controlProxy": "Proxy-CSMS (videresendelse + styring)",
"controlTitle": "Laderstyring (OCPP)",
"controlOwnHint": "Laderen forbinder direkte til DriverVault som sit centralsystem. Peg laderens OCPP-backend på endepunktet nedenfor.",
"controlProxyHint": "DriverVault videresender til Ankers sky og kan indsætte kommandoer. Peg laderens OCPP-backend på endepunktet nedenfor.",
"controlProvisionSteps": "I Anker-appen (eller laderens OCPP-indstillinger) sættes OCPP-backend-URL'en til endepunktet og autorisationsnøglen til tokenet ovenfor.",
"chargerSerial": "Laderens serienummer",
"controlCheck": "Tjek status",
"controlGenerate": "Generér token",
"controlEndpoint": "OCPP-endepunkt",
"controlToken": "Autorisationstoken",
"controlTokenOnce": "Kopiér dette token nu — det vises kun én gang og kan ikke hentes frem igen.",
"controlRevoke": "Tilbagekald token",
"controlRevokeConfirm": "Tilbagekald denne laders styringstoken? Den bliver afbrudt og kan ikke forbinde igen, før du genererer et nyt.",
"controlConnected": "Forbundet til styrings-backend",
"controlDisconnected": "Ikke forbundet",
"toyota": "Toyota Connected (MyToyota)",
"brandToyota": "Toyota",
"brandLexus": "Lexus",
"ankerSolix": "Anker Solix (V1 Smart EV Charger)"
}
},
"status": {
+74
View File
@@ -117,6 +117,24 @@
"full": "Zajęte",
"offPeak": "poza szczytem",
"homeCharger": "Ładowarka domowa"
},
"control": {
"title": "Sterowanie ładowarką",
"connected": "Połączono",
"disconnected": "Offline",
"serialPlaceholder": "Numer seryjny ładowarki (np. A5191-XXXXXXXX)",
"refresh": "Odśwież",
"status": "Złącze",
"meter": "Energia",
"start": "Rozpocznij ładowanie",
"stop": "Zatrzymaj ładowanie",
"limit": "Limit prądu",
"applyLimit": "Zastosuj limit",
"clearLimit": "Usuń limit",
"reset": "Zrestartuj ładowarkę",
"resetConfirm": "Zrestartować teraz tę ładowarkę? Trwająca sesja ładowania zostanie przerwana. Wpisz ponownie hasło, aby potwierdzić.",
"resetPassword": "Hasło do Twojego konta",
"connectHint": "Wpisz numer seryjny ładowarki i odśwież. Ładowarka musi być połączona z backendem OCPP DriverVault (konfiguracja w Ustawienia → Integracje)."
}
},
"settings": {
@@ -248,6 +266,62 @@
"exported": "Zapisano {file}",
"open": "Otwórz",
"openFailed": "Nie udało się otworzyć pliku."
},
"integrations": {
"title": "Integracje",
"subtitle": "Połącz zewnętrzne usługi ze swoim kontem.",
"toyotaDesc": "Zaloguj się kontem MyToyota, aby pobierać dane pojazdu z Toyota Connected Europe. Tylko Europa.",
"enable": "Włącz dla mojego konta",
"enableOrg": "Włącz dla organizacji",
"unavailable": "Ta integracja została wyłączona przez administratora.",
"orgDisabled": "Ta integracja jest wyłączona dla Twojej organizacji.",
"scopeMy": "Moje ustawienia",
"scopeOrg": "Organizacja",
"scopeHint": "Ustawienia organizacji obowiązują każdego w Twojej organizacji, kto pozostawi pole puste.",
"readOnly": "Wspólnymi ustawieniami tej integracji zarządzasz w panelu API Servera; tutaj są tylko do odczytu.",
"email": "E-mail MyToyota",
"password": "Hasło MyToyota",
"brand": "Marka",
"inherited": "Ustawione na wyższym poziomie",
"inheritedFrom": "Odziedziczone od: {source}",
"sourceGlobal": "administratora",
"sourceOrg": "Twojej organizacji",
"sourceUser": "Ciebie",
"passwordKeep": "Pozostaw puste, aby zachować zapisane hasło.",
"test": "Testuj połączenie",
"testing": "Testowanie…",
"save": "Zapisz",
"saved": "Zapisano ✓",
"connected": "Połączono",
"notConnected": "Nie połączono",
"ankerSolixDesc": "Zaloguj się kontem Anker, aby pobierać dane ładowarki EV z chmury Anker Solix.",
"ankerEmail": "E-mail konta Anker",
"ankerPassword": "Hasło konta Anker",
"country": "Kraj",
"countryHint": "Dwuliterowy kod kraju Twojego konta Anker (np. DE, GB, US).",
"controlMode": "Tryb sterowania",
"controlModeHint": "Sposób, w jaki DriverVault steruje ładowarką przez OCPP.",
"controlOff": "Wyłączone (tylko monitorowanie)",
"controlOwn": "Własny CSMS (pełne sterowanie)",
"controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)",
"controlTitle": "Sterowanie ładowarką (OCPP)",
"controlOwnHint": "Ładowarka łączy się bezpośrednio z DriverVault jako swoim systemem centralnym. Ustaw backend OCPP ładowarki na poniższy adres.",
"controlProxyHint": "DriverVault przekazuje ruch do chmury Anker i może wysyłać własne polecenia. Ustaw backend OCPP ładowarki na poniższy adres.",
"controlProvisionSteps": "W aplikacji Anker (lub w ustawieniach OCPP ładowarki) ustaw adres backendu OCPP na powyższy endpoint, a klucz autoryzacji na powyższy token.",
"chargerSerial": "Numer seryjny ładowarki",
"controlCheck": "Sprawdź status",
"controlGenerate": "Wygeneruj token",
"controlEndpoint": "Endpoint OCPP",
"controlToken": "Token autoryzacji",
"controlTokenOnce": "Skopiuj ten token teraz — jest pokazywany tylko raz i nie da się go później odzyskać.",
"controlRevoke": "Unieważnij token",
"controlRevokeConfirm": "Unieważnić token sterowania tej ładowarki? Rozłączy się i nie połączy ponownie, dopóki nie wygenerujesz nowego.",
"controlConnected": "Połączono z backendem sterowania",
"controlDisconnected": "Nie połączono",
"toyota": "Toyota Connected (MyToyota)",
"brandToyota": "Toyota",
"brandLexus": "Lexus",
"ankerSolix": "Anker Solix (V1 Smart EV Charger)"
}
},
"status": {
+18 -4
View File
@@ -180,10 +180,24 @@ class ApiClient {
return items.map((e) => AdminUser.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<AdminUser> createUser(
{required String email, required String password, String? name, String role = "user"}) async {
final data = await _send("POST", "/users",
body: {"email": email, "password": password, "name": name ?? "", "role": role});
/// Creates a user. [organization] is the superadmin's choice of tenant — an
/// empty string deliberately means "no organization". Omit it entirely for an
/// admin: the server forces its own org on their members, so sending anything
/// would be noise the server ignores.
Future<AdminUser> createUser({
required String email,
required String password,
String? name,
String role = "user",
String? organization,
}) async {
final data = await _send("POST", "/users", body: {
"email": email,
"password": password,
"name": name ?? "",
"role": role,
if (organization != null) "organization": organization,
});
return AdminUser.fromJson(Map<String, dynamic>.from(data["user"]));
}
+121 -13
View File
@@ -35,6 +35,22 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
List<String> get _assignableRoles =>
_iamSuperadmin ? const ["user", "admin", "superadmin"] : const ["user", "admin"];
/// Why a row's destructive and role controls are locked, or "" when they are
/// not. These mirror the server's guards, so the UI never offers an action
/// that would come back a 403 — and, unlike simply greying the control out,
/// they say which guard it is. Same wording as the web app's tooltips.
String _deleteBlockedReason(AdminUser u) {
if (u.id == _myId) return t("admin.cantDeleteSelf");
if (u.isSuperadmin && !_iamSuperadmin) return t("admin.onlySuperadminDeletes");
return "";
}
String _roleLockReason(AdminUser u) {
if (u.id == _myId) return t("admin.cantChangeOwnRole");
if (u.isSuperadmin && !_iamSuperadmin) return t("admin.onlySuperadminEdits");
return "";
}
void _snack(String msg) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
@@ -139,8 +155,12 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) {
final u = users[i];
// An admin may not edit or delete a superadmin, and nobody may
// touch their own role or account. Both guards carry the sentence
// that explains them.
final roleLock = _roleLockReason(u);
final deleteLock = _deleteBlockedReason(u);
final isSelf = u.id == _myId;
// An admin may not edit or delete a superadmin; only a superadmin may.
final locked = u.isSuperadmin && !_iamSuperadmin;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
@@ -164,23 +184,31 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButton<String>(
// A long-press on the greyed-out picker says why it is
// greyed out — the touch equivalent of the web's tooltip.
Tooltip(
message: roleLock,
triggerMode: roleLock.isEmpty
? TooltipTriggerMode.manual
: TooltipTriggerMode.longPress,
child: DropdownButton<String>(
value: u.role,
underline: const SizedBox.shrink(),
// Nobody may change their own role; only a superadmin may
// touch a superadmin.
onChanged:
(isSelf || locked) ? null : (v) => v == null ? null : _changeRole(u, v),
onChanged: roleLock.isNotEmpty
? null
: (v) => v == null ? null : _changeRole(u, v),
items: [
for (final r in _assignableRoles)
DropdownMenuItem(value: r, child: Text(t("admin.roles.$r"))),
// Keep the current role selectable even when this viewer
// can't assign it, so the dropdown has a valid value.
// Keep the current role selectable even when this
// viewer can't assign it, so the dropdown has a
// valid value.
if (!_assignableRoles.contains(u.role))
DropdownMenuItem(
value: u.role, child: Text(t("admin.roles.${u.role}"))),
],
),
),
PopupMenuButton<String>(
onSelected: (choice) {
if (choice == "password") _resetPassword(u);
@@ -190,14 +218,19 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
PopupMenuItem(
value: "password",
enabled: !locked,
child: Text(t("admin.resetPassword")),
child: _MenuEntry(
label: t("admin.resetPassword"),
reason: locked ? t("admin.onlySuperadminEdits") : "",
),
),
PopupMenuItem(
value: "delete",
// Can't delete yourself, or a superadmin you don't outrank.
enabled: !isSelf && !locked,
child: Text(t("common.delete"),
style: const TextStyle(color: DriverVault.danger)),
enabled: deleteLock.isEmpty,
child: _MenuEntry(
label: t("common.delete"),
reason: deleteLock,
color: DriverVault.danger,
),
),
],
),
@@ -226,12 +259,40 @@ class _CreateUserSheetState extends State<_CreateUserSheet> {
bool _saving = false;
String? _error;
/// Organizations a superadmin can drop the new account into. An admin gets no
/// picker: the server puts their members in their own org regardless, so
/// offering a choice would be a lie. Empty until loaded, and left empty if the
/// listing fails — a picker that cannot be filled must not block the form.
List<Organization> _orgs = const [];
/// The chosen tenant; "" is a real choice, meaning an account belonging to no
/// organization at all.
String _organization = "";
bool get _iamSuperadmin => authService.user?.isSuperadmin == true;
/// Only a superadmin can create another one. An admin's new users are placed
/// in the admin's own organization by the server.
List<String> get _assignableRoles => authService.user?.isSuperadmin == true
? const ["user", "admin", "superadmin"]
: const ["user", "admin"];
@override
void initState() {
super.initState();
if (_iamSuperadmin) _loadOrgs();
}
Future<void> _loadOrgs() async {
try {
final orgs = await apiClient.listOrgs();
if (mounted) setState(() => _orgs = orgs);
} catch (_) {
// Listing is manager-only and can fail; the picker then offers only "no
// organization", which is still a valid account to create.
}
}
@override
void dispose() {
_email.dispose();
@@ -251,6 +312,9 @@ class _CreateUserSheetState extends State<_CreateUserSheet> {
password: _password.text,
name: _name.text.trim(),
role: _role,
// Only a superadmin picks; for an admin the server forces its own org,
// so sending anything here would be noise.
organization: _iamSuperadmin ? _organization : null,
);
if (mounted) Navigator.pop(context, true);
} catch (e) {
@@ -316,6 +380,24 @@ class _CreateUserSheetState extends State<_CreateUserSheet> {
),
],
),
if (_iamSuperadmin) ...[
const SizedBox(height: 8),
DropdownButtonFormField<String>(
initialValue: _organization,
isExpanded: true,
decoration: InputDecoration(
labelText: t("admin.colOrganization"),
helperText: t("admin.organizationHint"),
helperMaxLines: 3,
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem(value: "", child: Text(t("admin.noOrganization"))),
for (final o in _orgs) DropdownMenuItem(value: o.id, child: Text(o.name)),
],
onChanged: (v) => setState(() => _organization = v ?? ""),
),
],
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
@@ -329,3 +411,29 @@ class _CreateUserSheetState extends State<_CreateUserSheet> {
);
}
}
/// One entry of a row's overflow menu. A blocked action keeps its label and
/// gains the sentence saying why it is blocked: the item is disabled, so it
/// cannot be long-pressed for a tooltip the way the role picker can, and a
/// silently greyed-out action is the thing this is meant to avoid.
class _MenuEntry extends StatelessWidget {
final String label;
final String reason;
final Color? color;
const _MenuEntry({required this.label, required this.reason, this.color});
@override
Widget build(BuildContext context) {
if (reason.isEmpty) return Text(label, style: TextStyle(color: color));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: TextStyle(color: color)),
Text(reason,
style: TextStyle(fontSize: 11, color: DriverVault.muted(context)),
softWrap: true),
],
);
}
}
+37
View File
@@ -6,6 +6,9 @@
// plausible-looking zero, the badge wording has to match the web app's, and an
// unsupported locale (settable from the web, whose lists are wider than the
// phone's) must not throw out of every date on screen.
import "dart:convert";
import "dart:io";
import "package:flutter_test/flutter_test.dart";
import "package:intl/date_symbol_data_local.dart";
import "package:drivervault_phone/format.dart";
@@ -372,6 +375,40 @@ void main() {
});
});
test("no key is left behind in a translation", () {
// Every key English has, the other languages have too. t() falls back to
// English for a missing one, so a gap is invisible until someone reads a
// half-translated screen — this is what makes it a test failure instead.
final en = json.decode(
File("assets/i18n/en.json").readAsStringSync()) as Map<String, dynamic>;
List<String> flatten(Map<String, dynamic> tree, [String prefix = ""]) => [
for (final e in tree.entries)
if (e.value is Map<String, dynamic>)
...flatten(e.value as Map<String, dynamic>, "$prefix${e.key}.")
else
"$prefix${e.key}",
];
dynamic lookup(Map<String, dynamic> tree, String key) {
dynamic node = tree;
for (final part in key.split(".")) {
if (node is! Map || !node.containsKey(part)) return null;
node = node[part];
}
return node;
}
final keys = flatten(en);
expect(keys, isNotEmpty);
for (final lang in translatedLanguages.where((l) => l != "en")) {
final other = json.decode(
File("assets/i18n/$lang.json").readAsStringSync()) as Map<String, dynamic>;
final missing = keys.where((k) => lookup(other, k) == null).toList();
expect(missing, isEmpty, reason: "$lang.json is missing ${missing.length} key(s)");
}
});
test("a day count reads as prose in each language's own plural forms", () {
appSettings.locale = "en-GB";
expect(t("car.info.daysValue", n: 1), "1 day");
+15 -13
View File
@@ -81,10 +81,13 @@ No screen code changes are needed to add a language.
## Coverage
- **Web App** — fully translated (every view, component, form, and the status
labels in `lib/format.js`), except the Integrations settings and the OCPP
charger-control card: `settings.integrations.*` and `charging.control.*` exist
in `en.json` only, so both fall back to English in Polish and Danish.
- **Web App** — fully translated: every view, component, form, and the status
labels in `lib/format.js`.
Both apps are complete in all three languages. The one place the wording is
deliberately not translated is proper nouns: protocol and product names (OCPP,
CSMS, Toyota Connected, MyToyota, Anker Solix, Lexus) read the same in every
file, as do the units.
- **API Server panel** — UI chrome, cards, login, status, and the API section
titles are translated. The individual REST endpoint **descriptions** in the
API reference table are intentionally left in English as developer reference
@@ -102,12 +105,11 @@ No screen code changes are needed to add a language.
data out as table columns), client-side validation (the web leans on the
browser's `required`), and the snackbars.
Still English: the same `settings.integrations.*` / `charging.control.*` gap
the web app has, which is worth closing in both at once rather than letting the
phone run ahead.
`test/models_format_test.dart` guards the lookups the analyzer cannot see —
the ones built from a key at render time (`car.tabs.$key`,
`enums.fuelType.$v`, the delete dialog's plural counts, the connected
service's readings). A catalogue entry with no label fails the test rather
than reaching a screen as a raw key path.
`test/models_format_test.dart` guards two things the analyzer cannot see. The
lookups built from a key at render time (`car.tabs.$key`, `enums.fuelType.$v`,
`admin.roles.$r`, the delete dialog's plural counts, the connected service's
readings) must resolve to a real label in every language — a catalogue entry
with no translation fails the test rather than reaching a screen as a raw key
path. And every key `en.json` carries must exist in `pl.json` and `da.json`,
so a phrase added in English alone is caught at the point it is added rather
than by whoever next reads a half-translated screen.
+76
View File
@@ -66,6 +66,25 @@
"full": "Optaget",
"offPeak": "uden for spidsbelastning",
"homeCharger": "Hjemmelader"
},
"control": {
"title": "Laderstyring",
"connected": "Forbundet",
"disconnected": "Offline",
"serialPlaceholder": "Laderens serienummer (f.eks. A5191-XXXXXXXX)",
"refresh": "Opdater",
"status": "Stik",
"meter": "Energi",
"start": "Start opladning",
"stop": "Stop opladning",
"limit": "Strømgrænse",
"applyLimit": "Anvend grænse",
"clearLimit": "Ryd grænse",
"reset": "Genstart laderen",
"resetConfirm": "Genstart denne lader nu? En igangværende opladning bliver afbrudt. Indtast din adgangskode igen for at bekræfte.",
"resetPassword": "Din kontoadgangskode",
"connectHint": "Indtast laderens serienummer og opdater. Laderen skal være forbundet til DriverVaults OCPP-backend (opsættes under Indstillinger → Integrationer)."
}
},
@@ -222,6 +241,63 @@
"saveBio": "Gem beskrivelse"
},
"integrations": {
"title": "Integrationer",
"subtitle": "Forbind eksterne tjenester til din konto.",
"toyota": "Toyota Connected (MyToyota)",
"toyotaDesc": "Log ind med din MyToyota-konto for at hente køretøjsdata fra Toyota Connected Europe. Kun Europa.",
"enable": "Aktivér for min konto",
"enableOrg": "Aktivér for organisationen",
"unavailable": "Denne integration er slået fra af administratoren.",
"orgDisabled": "Denne integration er slået fra for din organisation.",
"scopeMy": "Mine indstillinger",
"scopeOrg": "Organisation",
"scopeHint": "Organisationens indstillinger gælder for alle i din organisation, der lader et felt stå tomt.",
"readOnly": "Denne integrations fælles indstillinger administreres i API-serverens panel; her er de skrivebeskyttede.",
"email": "MyToyota-e-mail",
"password": "MyToyota-adgangskode",
"brand": "Mærke",
"brandToyota": "Toyota",
"brandLexus": "Lexus",
"inherited": "Sat på et højere niveau",
"inheritedFrom": "Nedarvet fra {source}",
"sourceGlobal": "administratoren",
"sourceOrg": "din organisation",
"sourceUser": "dig",
"passwordKeep": "Lad feltet stå tomt for at beholde den gemte adgangskode.",
"test": "Test forbindelsen",
"testing": "Tester…",
"save": "Gem",
"saved": "Gemt ✓",
"connected": "Forbundet",
"notConnected": "Ikke forbundet",
"ankerSolix": "Anker Solix (V1 Smart EV Charger)",
"ankerSolixDesc": "Log ind med din Anker-konto for at hente laderdata fra Anker Solix-skyen.",
"ankerEmail": "E-mail til Anker-konto",
"ankerPassword": "Adgangskode til Anker-konto",
"country": "Land",
"countryHint": "Landekode på to bogstaver for din Anker-konto (f.eks. DE, GB, US).",
"controlMode": "Styringstilstand",
"controlModeHint": "Hvordan DriverVault styrer laderen over OCPP.",
"controlOff": "Fra (kun overvågning)",
"controlOwn": "Eget CSMS (fuld styring)",
"controlProxy": "Proxy-CSMS (videresendelse + styring)",
"controlTitle": "Laderstyring (OCPP)",
"controlOwnHint": "Laderen forbinder direkte til DriverVault som sit centralsystem. Peg laderens OCPP-backend på endepunktet nedenfor.",
"controlProxyHint": "DriverVault videresender til Ankers sky og kan indsætte kommandoer. Peg laderens OCPP-backend på endepunktet nedenfor.",
"controlProvisionSteps": "I Anker-appen (eller laderens OCPP-indstillinger) sættes OCPP-backend-URL'en til endepunktet og autorisationsnøglen til tokenet ovenfor.",
"chargerSerial": "Laderens serienummer",
"controlCheck": "Tjek status",
"controlGenerate": "Generér token",
"controlEndpoint": "OCPP-endepunkt",
"controlToken": "Autorisationstoken",
"controlTokenOnce": "Kopiér dette token nu — det vises kun én gang og kan ikke hentes frem igen.",
"controlRevoke": "Tilbagekald token",
"controlRevokeConfirm": "Tilbagekald denne laders styringstoken? Den bliver afbrudt og kan ikke forbinde igen, før du genererer et nyt.",
"controlConnected": "Forbundet til styrings-backend",
"controlDisconnected": "Ikke forbundet"
},
"org": {
"title": "Organisation",
"titleAll": "Organisationer",
+76
View File
@@ -68,6 +68,25 @@
"full": "Zajęte",
"offPeak": "poza szczytem",
"homeCharger": "Ładowarka domowa"
},
"control": {
"title": "Sterowanie ładowarką",
"connected": "Połączono",
"disconnected": "Offline",
"serialPlaceholder": "Numer seryjny ładowarki (np. A5191-XXXXXXXX)",
"refresh": "Odśwież",
"status": "Złącze",
"meter": "Energia",
"start": "Rozpocznij ładowanie",
"stop": "Zatrzymaj ładowanie",
"limit": "Limit prądu",
"applyLimit": "Zastosuj limit",
"clearLimit": "Usuń limit",
"reset": "Zrestartuj ładowarkę",
"resetConfirm": "Zrestartować teraz tę ładowarkę? Trwająca sesja ładowania zostanie przerwana. Wpisz ponownie hasło, aby potwierdzić.",
"resetPassword": "Hasło do Twojego konta",
"connectHint": "Wpisz numer seryjny ładowarki i odśwież. Ładowarka musi być połączona z backendem OCPP DriverVault (konfiguracja w Ustawienia → Integracje)."
}
},
@@ -226,6 +245,63 @@
"saveBio": "Zapisz opis"
},
"integrations": {
"title": "Integracje",
"subtitle": "Połącz zewnętrzne usługi ze swoim kontem.",
"toyota": "Toyota Connected (MyToyota)",
"toyotaDesc": "Zaloguj się kontem MyToyota, aby pobierać dane pojazdu z Toyota Connected Europe. Tylko Europa.",
"enable": "Włącz dla mojego konta",
"enableOrg": "Włącz dla organizacji",
"unavailable": "Ta integracja została wyłączona przez administratora.",
"orgDisabled": "Ta integracja jest wyłączona dla Twojej organizacji.",
"scopeMy": "Moje ustawienia",
"scopeOrg": "Organizacja",
"scopeHint": "Ustawienia organizacji obowiązują każdego w Twojej organizacji, kto pozostawi pole puste.",
"readOnly": "Wspólnymi ustawieniami tej integracji zarządzasz w panelu API Servera; tutaj są tylko do odczytu.",
"email": "E-mail MyToyota",
"password": "Hasło MyToyota",
"brand": "Marka",
"brandToyota": "Toyota",
"brandLexus": "Lexus",
"inherited": "Ustawione na wyższym poziomie",
"inheritedFrom": "Odziedziczone od: {source}",
"sourceGlobal": "administratora",
"sourceOrg": "Twojej organizacji",
"sourceUser": "Ciebie",
"passwordKeep": "Pozostaw puste, aby zachować zapisane hasło.",
"test": "Testuj połączenie",
"testing": "Testowanie…",
"save": "Zapisz",
"saved": "Zapisano ✓",
"connected": "Połączono",
"notConnected": "Nie połączono",
"ankerSolix": "Anker Solix (V1 Smart EV Charger)",
"ankerSolixDesc": "Zaloguj się kontem Anker, aby pobierać dane ładowarki EV z chmury Anker Solix.",
"ankerEmail": "E-mail konta Anker",
"ankerPassword": "Hasło konta Anker",
"country": "Kraj",
"countryHint": "Dwuliterowy kod kraju Twojego konta Anker (np. DE, GB, US).",
"controlMode": "Tryb sterowania",
"controlModeHint": "Sposób, w jaki DriverVault steruje ładowarką przez OCPP.",
"controlOff": "Wyłączone (tylko monitorowanie)",
"controlOwn": "Własny CSMS (pełne sterowanie)",
"controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)",
"controlTitle": "Sterowanie ładowarką (OCPP)",
"controlOwnHint": "Ładowarka łączy się bezpośrednio z DriverVault jako swoim systemem centralnym. Ustaw backend OCPP ładowarki na poniższy adres.",
"controlProxyHint": "DriverVault przekazuje ruch do chmury Anker i może wysyłać własne polecenia. Ustaw backend OCPP ładowarki na poniższy adres.",
"controlProvisionSteps": "W aplikacji Anker (lub w ustawieniach OCPP ładowarki) ustaw adres backendu OCPP na powyższy endpoint, a klucz autoryzacji na powyższy token.",
"chargerSerial": "Numer seryjny ładowarki",
"controlCheck": "Sprawdź status",
"controlGenerate": "Wygeneruj token",
"controlEndpoint": "Endpoint OCPP",
"controlToken": "Token autoryzacji",
"controlTokenOnce": "Skopiuj ten token teraz — jest pokazywany tylko raz i nie da się go później odzyskać.",
"controlRevoke": "Unieważnij token",
"controlRevokeConfirm": "Unieważnić token sterowania tej ładowarki? Rozłączy się i nie połączy ponownie, dopóki nie wygenerujesz nowego.",
"controlConnected": "Połączono z backendem sterowania",
"controlDisconnected": "Nie połączono"
},
"org": {
"title": "Organizacja",
"titleAll": "Organizacje",