Files
DriverVault/Phone App/lib/screens/admin_users_screen.dart
T
tajniak81andClaude Opus 5 a25b31842d Round the connected service's readings; keep sheet buttons off the nav bar
Both found by driving the installed app on a phone rather than by reading the
code, which is worth noting: the second one is invisible in a simulator with
gesture navigation turned off.

The bZ4X's tab showed "Electric range (A/C on) 99.744 km" beside "Electric
range (A/C off) 103.9 km". The long number is a reading converted out of
miles: headlineMetrics multiplied by 1.609344 and printed whatever came out,
so a range estimate claimed to know the distance to the metre, and the two
readings disagreed about their own precision on the same card. Distances now
keep one decimal and percentages none, applied by the reading's kind rather
than by whether it was converted - a provider reporting 99.744 km natively
gets the same treatment. Anything else is left alone, because without knowing
what it measures there is no safe place to cut. The odometer already rounded
to a whole number on its own path; this only changes the headline readings.

The Add-user sheet's "Create user" button sat underneath the system
navigation bar. Every one of these sheets padded its bottom with
viewInsets.bottom, which is the keyboard - correct while typing and wrong the
rest of the time, because with the keyboard down that inset is zero and the
navigation bar is still there. They take the larger of the keyboard and the
navigation bar now, since a raised keyboard covers the bar and the two must
not be added. One helper on DriverVault rather than the same expression in
six files, which is how the six drifted into being identical and identically
wrong.

Verified: go build, go vet and go test ./... pass, with a new test covering
the conversion (62 mi reads 99.8 km), a native over-precise reading, a
percentage, and the odometer's whole number surviving. flutter analyze clean,
21 tests pass, and the rebuilt release APK was installed on the phone - the
Create user button now sits clear of the navigation bar, where the screenshot
that prompted this showed it clipped.

Not verified: the rounding is not visible on the phone yet. It talks to a
deployed API Server that has not been rebuilt from this commit, so that tab
will keep reading 99.744 until the server is redeployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:44:05 +02:00

440 lines
16 KiB
Dart

import "package:flutter/material.dart";
import "../i18n.dart";
import "../main.dart";
import "../models.dart";
import "../format.dart";
import "../theme.dart";
/// Admin-only screen to manage user accounts: list, create, change role,
/// reset password, delete. The API enforces the real access control — an admin
/// is scoped to their own organization and cannot touch a superadmin, and
/// nobody may change their own role or delete their own account. The UI mirrors
/// those guards to avoid offering dead actions.
class AdminUsersScreen extends StatefulWidget {
const AdminUsersScreen({super.key});
@override
State<AdminUsersScreen> createState() => _AdminUsersScreenState();
}
class _AdminUsersScreenState extends State<AdminUsersScreen> {
late Future<List<AdminUser>> _future;
@override
void initState() {
super.initState();
_future = apiClient.listUsers();
}
void _reload() => setState(() => _future = apiClient.listUsers());
String? get _myId => authService.user?.id;
bool get _iamSuperadmin => authService.user?.isSuperadmin == true;
/// Roles this viewer may hand out. Only a superadmin can mint another one.
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)));
Future<void> _changeRole(AdminUser u, String role) async {
try {
await apiClient.updateUser(u.id, role: role);
_reload();
} catch (e) {
_snack("$e");
}
}
Future<void> _resetPassword(AdminUser u) async {
final controller = TextEditingController();
final saved = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t("admin.resetTitle", params: {"email": u.email})),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
labelText: '${t("admin.newPassword")} ${t("admin.minChars")}',
border: const OutlineInputBorder(),
),
),
actions: [
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(t("admin.passwordUpdated"));
} catch (e) {
_snack("$e");
}
}
Future<void> _delete(AdminUser u) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
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: Text(t("common.cancel"))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
onPressed: () => Navigator.pop(ctx, true),
child: Text(t("common.delete")),
),
],
),
);
if (confirmed != true) return;
try {
await apiClient.deleteUser(u.id);
_reload();
} catch (e) {
_snack("$e");
}
}
Future<void> _createUser() async {
final created = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => const _CreateUserSheet(),
);
if (created == true) _reload();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(t("admin.title"))),
floatingActionButton: FloatingActionButton.extended(
onPressed: _createUser,
icon: const Icon(Icons.person_add_alt_1),
label: Text(t("admin.addUser")),
),
body: FutureBuilder<List<AdminUser>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(child: Text("${snap.error}"));
}
final users = snap.data ?? [];
return ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: users.length,
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;
final locked = u.isSuperadmin && !_iamSuperadmin;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: Row(
children: [
Flexible(child: Text(u.email, overflow: TextOverflow.ellipsis)),
if (isSelf)
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 ? t("common.empty") : u.name}"
"${u.organizationName.isEmpty ? '' : ' · ${u.organizationName}'}"
" · ${formatDate(DateTime.tryParse(u.created))}",
style: const TextStyle(fontSize: 12),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 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(),
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.
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);
if (choice == "delete") _delete(u);
},
itemBuilder: (_) => [
PopupMenuItem(
value: "password",
enabled: !locked,
child: _MenuEntry(
label: t("admin.resetPassword"),
reason: locked ? t("admin.onlySuperadminEdits") : "",
),
),
PopupMenuItem(
value: "delete",
enabled: deleteLock.isEmpty,
child: _MenuEntry(
label: t("common.delete"),
reason: deleteLock,
color: DriverVault.danger,
),
),
],
),
],
),
);
},
);
},
),
);
}
}
class _CreateUserSheet extends StatefulWidget {
const _CreateUserSheet();
@override
State<_CreateUserSheet> createState() => _CreateUserSheetState();
}
class _CreateUserSheetState extends State<_CreateUserSheet> {
final _email = TextEditingController();
final _name = TextEditingController();
final _password = TextEditingController();
String _role = "user";
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();
_name.dispose();
_password.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() {
_saving = true;
_error = null;
});
try {
await apiClient.createUser(
email: _email.text.trim(),
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) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: DriverVault.sheetBottomInset(context),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t("admin.createTitle"),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
),
TextField(
controller: _email,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: t("admin.emailRequired"), border: const OutlineInputBorder()),
),
const SizedBox(height: 8),
TextField(
controller: _name,
decoration: InputDecoration(
labelText: t("admin.colName"), border: const OutlineInputBorder()),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _password,
decoration: InputDecoration(
labelText: '${t("admin.passwordRequired")} ${t("admin.minChars")}',
border: const OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
DropdownButton<String>(
value: _role,
onChanged: (v) => setState(() => _role = v ?? "user"),
items: [
for (final r in _assignableRoles)
DropdownMenuItem(value: r, child: Text(t("admin.roles.$r"))),
],
),
],
),
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,
child: FilledButton(
onPressed: _saving ? null : _save,
child: Text(t(_saving ? "admin.creating" : "admin.createUser")),
),
),
],
),
);
}
}
/// 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),
],
);
}
}