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`,