From 4372b870ac69dd5374cd11dcf89949fc6b63329b Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:58:00 +0200 Subject: [PATCH] Phone App: take the admin users screen off hardcoded English The last screen the phone rendered in English regardless of the language picker. Its strings are the web AdminUsers.vue's, which have been translated since b6bb6b1, so the admin.* subtree is copied across the same way the rest were and Polish and Danish arrive complete. Two strings are the phone's own, and only because the two UIs confirm differently: the web deletes behind a browser confirm(), which supplies its own title, and reports a password reset by closing the modal. The phone has a dialog title and a snackbar to fill, so admin.deleteTitle and admin.passwordUpdated are new. The role picker printed its values raw - Text(r) over "user"/"admin"/ "superadmin" - which happened to read as English because the API's enum is English. It goes through admin.roles.* now, like the web's, and the new lookup is covered by the catalogue test alongside the other dynamic ones. The Web App needed nothing: AdminUsers.vue was already fully translated, including the role options and the tooltips explaining why a locked row cannot be edited. Checked rather than assumed - it has 31 t() calls and no bare text nodes in the template. Two differences from the web turned up while reading them side by side, both feature gaps rather than translation ones, and both left alone here: the phone's create-user sheet has no organization picker for a superadmin (the server puts the account in the creator's org), and the phone disables a locked role dropdown or delete action without saying why, where the web explains it in a title attribute. The strings for that explanation are now sitting in the phone's language files, so it is a small change if wanted. Verified by flutter analyze (clean), flutter test - 20 pass, 1 of them new - and flutter build apk --debug. The key checker reports all 568 static t() keys resolving in en.json. Not verified: still nothing run against a live API Server or on a device. TRANSLATIONS.md and the Phone App README both listed this screen as outstanding; the only gap either records now is settings.integrations.* / charging.control.*, which is the web app's too. Co-Authored-By: Claude Opus 5 --- Phone App/README.md | 5 +- Phone App/assets/i18n/da.json | 36 ++++++++++ Phone App/assets/i18n/en.json | 36 ++++++++++ Phone App/assets/i18n/pl.json | 36 ++++++++++ Phone App/lib/screens/admin_users_screen.dart | 71 +++++++++++-------- Phone App/test/models_format_test.dart | 9 +++ TRANSLATIONS.md | 12 ++-- 7 files changed, 166 insertions(+), 39 deletions(-) diff --git a/Phone App/README.md b/Phone App/README.md index 92b30e7..9fcb9dd 100644 --- a/Phone App/README.md +++ b/Phone App/README.md @@ -115,9 +115,8 @@ 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. -The car screen and every sheet it opens read their text from the language files. -What is left in English is the **admin users** screen, and the Integrations and -charger-control strings — the latter being a gap the web app shares, so 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). ## Biometric / face sign-in & app lock diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index 68a4055..7b89384 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -846,5 +846,41 @@ "attachmentFailed": "Gemt, men filen blev ikke uploadet: {error}", "openFailed": "Filen kunne ikke åbnes: {error}", "noFile": "Ingen fil vedhæftet." + }, + "admin": { + "title": "Brugere", + "subtitleAll": "Konti på tværs af alle organisationer.", + "subtitleOrg": "Konti i din organisation.", + "addUser": "Tilføj bruger", + "colEmail": "E-mail", + "colName": "Navn", + "colOrganization": "Organisation", + "noOrganization": "— Ingen organisation —", + "organizationHint": "Lad feltet stå tomt for at oprette en konto uden organisation.", + "colRole": "Rolle", + "colCreated": "Oprettet", + "you": "(dig)", + "resetPassword": "Nulstil adgangskode", + "confirmDelete": "Slet {name}? Dette kan ikke fortrydes.", + "cantDeleteSelf": "Du kan ikke slette din egen konto.", + "onlySuperadminDeletes": "Kun en superadministrator kan slette en superadministrator.", + "cantChangeOwnRole": "Du kan ikke ændre din egen rolle.", + "onlySuperadminEdits": "Kun en superadministrator kan redigere en superadministrator.", + "createTitle": "Tilføj en bruger", + "emailRequired": "E-mail *", + "passwordRequired": "Adgangskode *", + "minChars": "(mindst 8)", + "creating": "Opretter…", + "createUser": "Opret bruger", + "resetTitle": "Nulstil adgangskode — {email}", + "newPassword": "Ny adgangskode", + "setPassword": "Angiv adgangskode", + "roles": { + "user": "bruger", + "admin": "administrator", + "superadmin": "superadministrator" + }, + "deleteTitle": "Slet bruger?", + "passwordUpdated": "Adgangskoden er opdateret." } } diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json index 4d683f3..7a0a659 100644 --- a/Phone App/assets/i18n/en.json +++ b/Phone App/assets/i18n/en.json @@ -920,5 +920,41 @@ "attachmentFailed": "Saved, but the file did not upload: {error}", "openFailed": "Could not open the file: {error}", "noFile": "No file attached." + }, + "admin": { + "title": "Users", + "subtitleAll": "Accounts across every organization.", + "subtitleOrg": "Accounts in your organization.", + "addUser": "Add user", + "colEmail": "Email", + "colName": "Name", + "colOrganization": "Organization", + "noOrganization": "— No organization —", + "organizationHint": "Leave unset to create an account that belongs to no organization.", + "colRole": "Role", + "colCreated": "Created", + "you": "(you)", + "resetPassword": "Reset password", + "confirmDelete": "Delete {name}? This cannot be undone.", + "cantDeleteSelf": "You can't delete your own account.", + "onlySuperadminDeletes": "Only a superadmin can delete a superadmin.", + "cantChangeOwnRole": "You can't change your own role.", + "onlySuperadminEdits": "Only a superadmin can edit a superadmin.", + "createTitle": "Add a user", + "emailRequired": "Email *", + "passwordRequired": "Password *", + "minChars": "(min 8)", + "creating": "Creating…", + "createUser": "Create user", + "resetTitle": "Reset password — {email}", + "newPassword": "New password", + "setPassword": "Set password", + "roles": { + "user": "user", + "admin": "admin", + "superadmin": "superadmin" + }, + "deleteTitle": "Delete user?", + "passwordUpdated": "Password updated." } } diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index 6d592f8..0a44b3a 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -862,5 +862,41 @@ "attachmentFailed": "Zapisano, ale plik nie został przesłany: {error}", "openFailed": "Nie udało się otworzyć pliku: {error}", "noFile": "Brak załączonego pliku." + }, + "admin": { + "title": "Użytkownicy", + "subtitleAll": "Konta ze wszystkich organizacji.", + "subtitleOrg": "Konta w Twojej organizacji.", + "addUser": "Dodaj użytkownika", + "colEmail": "E-mail", + "colName": "Imię i nazwisko", + "colOrganization": "Organizacja", + "noOrganization": "— Bez organizacji —", + "organizationHint": "Zostaw puste, aby utworzyć konto bez organizacji.", + "colRole": "Rola", + "colCreated": "Utworzono", + "you": "(Ty)", + "resetPassword": "Zresetuj hasło", + "confirmDelete": "Usunąć użytkownika {name}? Tej operacji nie można cofnąć.", + "cantDeleteSelf": "Nie możesz usunąć własnego konta.", + "onlySuperadminDeletes": "Tylko superadministrator może usunąć superadministratora.", + "cantChangeOwnRole": "Nie możesz zmienić własnej roli.", + "onlySuperadminEdits": "Tylko superadministrator może edytować superadministratora.", + "createTitle": "Dodaj użytkownika", + "emailRequired": "E-mail *", + "passwordRequired": "Hasło *", + "minChars": "(min. 8)", + "creating": "Tworzenie…", + "createUser": "Utwórz użytkownika", + "resetTitle": "Reset hasła — {email}", + "newPassword": "Nowe hasło", + "setPassword": "Ustaw hasło", + "roles": { + "user": "użytkownik", + "admin": "administrator", + "superadmin": "superadministrator" + }, + "deleteTitle": "Usunąć użytkownika?", + "passwordUpdated": "Hasło zostało zmienione." } } diff --git a/Phone App/lib/screens/admin_users_screen.dart b/Phone App/lib/screens/admin_users_screen.dart index 6c6632b..999841e 100644 --- a/Phone App/lib/screens/admin_users_screen.dart +++ b/Phone App/lib/screens/admin_users_screen.dart @@ -1,5 +1,6 @@ import "package:flutter/material.dart"; +import "../i18n.dart"; import "../main.dart"; import "../models.dart"; import "../format.dart"; @@ -51,25 +52,27 @@ class _AdminUsersScreenState extends State { final saved = await showDialog( context: context, builder: (ctx) => AlertDialog( - title: Text("Reset password — ${u.email}"), + title: Text(t("admin.resetTitle", params: {"email": u.email})), content: TextField( controller: controller, autofocus: true, - decoration: const InputDecoration( - labelText: "New password (min 8)", - border: OutlineInputBorder(), + decoration: InputDecoration( + labelText: '${t("admin.newPassword")} ${t("admin.minChars")}', + border: const OutlineInputBorder(), ), ), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text("Set password")), + TextButton( + onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), child: Text(t("admin.setPassword"))), ], ), ); if (saved != true) return; try { await apiClient.setUserPassword(u.id, controller.text); - _snack("Password updated."); + _snack(t("admin.passwordUpdated")); } catch (e) { _snack("$e"); } @@ -79,14 +82,16 @@ class _AdminUsersScreenState extends State { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text("Delete user?"), - content: Text("Delete ${u.name.isEmpty ? u.email : u.name}? This cannot be undone."), + title: Text(t("admin.deleteTitle")), + content: Text(t("admin.confirmDelete", + params: {"name": u.name.isEmpty ? u.email : u.name})), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")), + TextButton( + onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))), FilledButton( style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), onPressed: () => Navigator.pop(ctx, true), - child: const Text("Delete"), + child: Text(t("common.delete")), ), ], ), @@ -112,11 +117,11 @@ class _AdminUsersScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text("Users")), + appBar: AppBar(title: Text(t("admin.title"))), floatingActionButton: FloatingActionButton.extended( onPressed: _createUser, icon: const Icon(Icons.person_add_alt_1), - label: const Text("Add user"), + label: Text(t("admin.addUser")), ), body: FutureBuilder>( future: _future, @@ -143,14 +148,15 @@ class _AdminUsersScreenState extends State { children: [ Flexible(child: Text(u.email, overflow: TextOverflow.ellipsis)), if (isSelf) - const Padding( - padding: EdgeInsets.only(left: 6), - child: Text("(you)", style: TextStyle(color: Colors.grey, fontSize: 12)), + Padding( + padding: const EdgeInsets.only(left: 6), + child: Text(t("admin.you"), + style: const TextStyle(color: Colors.grey, fontSize: 12)), ), ], ), subtitle: Text( - "${u.name.isEmpty ? '—' : u.name}" + "${u.name.isEmpty ? t("common.empty") : u.name}" "${u.organizationName.isEmpty ? '' : ' · ${u.organizationName}'}" " · ${formatDate(DateTime.tryParse(u.created))}", style: const TextStyle(fontSize: 12), @@ -167,11 +173,12 @@ class _AdminUsersScreenState extends State { (isSelf || locked) ? null : (v) => v == null ? null : _changeRole(u, v), items: [ for (final r in _assignableRoles) - DropdownMenuItem(value: r, child: Text(r)), + 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. if (!_assignableRoles.contains(u.role)) - DropdownMenuItem(value: u.role, child: Text(u.role)), + DropdownMenuItem( + value: u.role, child: Text(t("admin.roles.${u.role}"))), ], ), PopupMenuButton( @@ -183,13 +190,14 @@ class _AdminUsersScreenState extends State { PopupMenuItem( value: "password", enabled: !locked, - child: const Text("Reset password"), + child: Text(t("admin.resetPassword")), ), PopupMenuItem( value: "delete", // Can't delete yourself, or a superadmin you don't outrank. enabled: !isSelf && !locked, - child: const Text("Delete", style: TextStyle(color: DriverVault.danger)), + child: Text(t("common.delete"), + style: const TextStyle(color: DriverVault.danger)), ), ], ), @@ -265,8 +273,8 @@ class _CreateUserSheetState extends State<_CreateUserSheet> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text("Add a user", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + Text(t("admin.createTitle"), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), const SizedBox(height: 12), if (_error != null) Padding( @@ -276,12 +284,14 @@ class _CreateUserSheetState extends State<_CreateUserSheet> { TextField( controller: _email, keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder()), + decoration: InputDecoration( + labelText: t("admin.emailRequired"), border: const OutlineInputBorder()), ), const SizedBox(height: 8), TextField( controller: _name, - decoration: const InputDecoration(labelText: "Name", border: OutlineInputBorder()), + decoration: InputDecoration( + labelText: t("admin.colName"), border: const OutlineInputBorder()), ), const SizedBox(height: 8), Row( @@ -289,9 +299,9 @@ class _CreateUserSheetState extends State<_CreateUserSheet> { Expanded( child: TextField( controller: _password, - decoration: const InputDecoration( - labelText: "Password (min 8)", - border: OutlineInputBorder(), + decoration: InputDecoration( + labelText: '${t("admin.passwordRequired")} ${t("admin.minChars")}', + border: const OutlineInputBorder(), ), ), ), @@ -300,7 +310,8 @@ class _CreateUserSheetState extends State<_CreateUserSheet> { value: _role, onChanged: (v) => setState(() => _role = v ?? "user"), items: [ - for (final r in _assignableRoles) DropdownMenuItem(value: r, child: Text(r)), + for (final r in _assignableRoles) + DropdownMenuItem(value: r, child: Text(t("admin.roles.$r"))), ], ), ], @@ -310,7 +321,7 @@ class _CreateUserSheetState extends State<_CreateUserSheet> { width: double.infinity, child: FilledButton( onPressed: _saving ? null : _save, - child: Text(_saving ? "Creating…" : "Create user"), + child: Text(t(_saving ? "admin.creating" : "admin.createUser")), ), ), ], diff --git a/Phone App/test/models_format_test.dart b/Phone App/test/models_format_test.dart index 015c543..86fd5d4 100644 --- a/Phone App/test/models_format_test.dart +++ b/Phone App/test/models_format_test.dart @@ -353,6 +353,15 @@ void main() { } }); + test("the roles the admin screen assigns", () { + // Mirrors the roles in the API's users.go; the picker labels every one it + // offers, including a superadmin's own role shown to an admin who cannot + // assign it. + for (final key in ["user", "admin", "superadmin"]) { + expectLabelled("admin.roles.$key"); + } + }); + test("the connected service's headline readings", () { // Mirrors headlineMetricSpecs + unmeasuredMetricKeys in the API's // vehicleproviders.go: every reading it can report needs a label here. diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md index 3da7261..8faab19 100644 --- a/TRANSLATIONS.md +++ b/TRANSLATIONS.md @@ -91,9 +91,9 @@ No screen code changes are needed to add a language. documentation. - **Phone App** — navigation, login, lock screen, dashboard, the full Settings panel (including the language picker), the status/badge wording in - `lib/format.dart`, and the whole car screen: its tabs, every record tile, the - share and delete-car dialogs, and all of the form sheets (`car_form_sheet`, - `record_form_sheets`, `attachment_field`). + `lib/format.dart`, the admin users screen, and the whole car screen: its tabs, + every record tile, the share and delete-car dialogs, and all of the form sheets + (`car_form_sheet`, `record_form_sheets`, `attachment_field`). The strings the two apps share are **copied out of `Web App/web/src/i18n/`** rather than retyped, so a phrase has one translation across both and cannot @@ -102,9 +102,9 @@ 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: **admin users** (`admin_users_screen.dart`), and 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. + 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`,