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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a2d9efec7e
commit
4372b870ac
+2
-3
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AdminUsersScreen> {
|
||||
final saved = await showDialog<bool>(
|
||||
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<AdminUsersScreen> {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<AdminUsersScreen> {
|
||||
@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<List<AdminUser>>(
|
||||
future: _future,
|
||||
@@ -143,14 +148,15 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
|
||||
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<AdminUsersScreen> {
|
||||
(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<String>(
|
||||
@@ -183,13 +190,14 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
|
||||
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")),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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.
|
||||
|
||||
+6
-6
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user