Files
DriverVault/Phone App/lib/screens/settings_screen.dart
T
tajniak81andClaude Opus 5 3c34b708b9 A mode that does not work stops being on the menu
The control-mode picker offered all five paths to everyone, always. When one of
them is broken in a deployment — OCPP is, right now — there was nothing to do
about it: the superadmin could pick a different mode for the global layer, but
the option stayed in every organization's and every user's dropdown, waiting to
be chosen. The cascade could impose a mode. It could not withdraw one.

So each layer now carries a second, separate thing: a list of the modes it hides
from the layers below it. controlModesDisabled sits beside controlMode, on the
global layer as a plugin config field and on an organization as part of the same
pluginSettings blob its credentials already live in. A superadmin ticking Own
CSMS and Proxy CSMS takes both OCPP paths out of every picker underneath; an org
admin ticking Modbus takes it out of their own users'.

Three decisions are worth naming.

A hide-list governs the layers below, not the layer holding it. The superadmin
can keep running Proxy globally while hiding it from everyone else, which is
what you want while a mode is being repaired rather than retired: the operator
testing the fix is the one person who still needs to select it. The alternative,
a list that also invalidates its own layer's choice, would have made the panel
contradict itself — a mode chosen in one field and switched off in the one below
it.

But a hidden mode really is hidden, not merely absent from a dropdown. A user
who had picked Proxy last month stops resolving to Proxy the moment the
superadmin hides it, and falls back to monitoring only. Filtering the picker
alone would have left every existing charger on the broken path and quietly
disagreed with the list the operator had just filled in. Resolution now walks
the layers accumulating what each hides from the next, so a stored value only
takes effect if the layers above it still permit it.

And off is never hideable. It is what a charger falls back to and what an empty
cascade resolves to, so a layer that could take it away could leave the layer
below with a picker holding no valid choice at all. It is not among the
checkboxes in any of the three clients, and the parser drops it if it arrives
anyway.

The panel needed a field shape it did not have — several options, any number
chosen — so ConfigField grows a "multiselect" type, stored as the
comma-separated string that fits the flat map every other field already uses.
That is generic: any plugin can declare one now, and the PUT body is unchanged.
The phone's field specs grew the same way, a scopeOptions hook that narrows a
declared option list to what the server still offers, rather than teaching the
integration card about control modes specifically.

Both clients clamp a stored mode that has since been hidden back to off before
drawing the picker, so the box shows what will actually happen rather than a
choice that would be dropped on save.

Verified: Go tests pass, both frontends build, flutter analyze is clean, and the
panel's new checkbox field was rendered against the real stylesheet. The
end-to-end path — superadmin hides a mode, an org admin and then a user reload
and find it gone — has not been walked on a live stack; the panel is embedded in
the Go binary, so the remote deployment needs a rebuild before any of this is
visible there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 12:54:30 +02:00

2770 lines
99 KiB
Dart

import "dart:convert";
import "dart:io";
import "dart:typed_data";
import "package:file_picker/file_picker.dart";
import "package:flutter/material.dart";
import "package:image_picker/image_picker.dart";
import "package:open_filex/open_filex.dart";
import "package:path_provider/path_provider.dart";
import "../biometric.dart";
import "../i18n.dart";
import "../main.dart";
import "../models.dart";
import "../format.dart";
import "../theme.dart";
/// Full settings panel, mirroring the web Settings.vue: account
/// (name/email/password), appearance (theme/locale/date/font), profile
/// (avatar/bio), privacy (sessions), data export/import, and account deletion.
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
UserProfile? _profile;
Uint8List? _avatar;
bool _loading = true;
String? _loadError;
int _tab = 0; // 0 = personal, 1 = integrations
final _name = TextEditingController();
final _bio = TextEditingController();
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_name.dispose();
_bio.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() {
_loading = true;
_loadError = null;
});
try {
final p = await apiClient.getMe();
appSettings.applyFromProfile(p);
// The profile is the authority on the role; creating or deleting an
// organization changes it, so keep the session's cached copy (which gates
// the Users tab) in step.
await authService.adoptRole(p);
Uint8List? avatar;
if (p.hasAvatar) {
final bytes = await apiClient.getAvatarBytes();
if (bytes != null) avatar = Uint8List.fromList(bytes);
}
_name.text = p.name;
_bio.text = p.bio;
setState(() {
_profile = p;
_avatar = avatar;
});
} catch (e) {
setState(() => _loadError = e.toString());
} finally {
setState(() => _loading = false);
}
}
void _snack(String msg) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(t("settings.title"))),
body: _loading
? const Center(child: CircularProgressIndicator())
: _loadError != null
? Center(child: Text(_loadError!))
// Two tabs: personal account settings and external integrations.
// Both panels stay mounted (IndexedStack) so their loaded state and
// in-flight edits survive a tab switch.
: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: _SettingsTabBar(
tabs: [t("settings.tabs.personal"), t("settings.tabs.integrations")],
index: _tab,
onChanged: (i) => setState(() => _tab = i),
),
),
Expanded(
child: IndexedStack(
index: _tab,
children: [
_personalTab(),
const _IntegrationsTab(),
],
),
),
],
),
);
}
Widget _personalTab() => ListView(
padding: const EdgeInsets.all(12),
children: [
_AccountSection(
profile: _profile!,
nameController: _name,
onChanged: _load,
snack: _snack,
),
const SizedBox(height: 12),
_AppearanceSection(onError: _snack),
const SizedBox(height: 12),
_ProfileSection(
profile: _profile!,
bioController: _bio,
avatar: _avatar,
onChanged: _load,
snack: _snack,
),
const SizedBox(height: 12),
_SecuritySection(email: _profile!.email, snack: _snack),
const SizedBox(height: 12),
_OrganizationSection(profile: _profile!, onChanged: _load, snack: _snack),
const SizedBox(height: 12),
const _PrivacySection(),
const SizedBox(height: 12),
_DataSection(snack: _snack),
const SizedBox(height: 12),
_DangerSection(profile: _profile!, onChanged: _load, snack: _snack),
const SizedBox(height: 24),
],
);
}
/// The underline tab bar for the Personal / Integrations split.
class _SettingsTabBar extends StatelessWidget {
final List<String> tabs;
final int index;
final ValueChanged<int> onChanged;
const _SettingsTabBar({required this.tabs, required this.index, required this.onChanged});
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
final muted = DriverVault.muted(context);
final strong = DriverVault.isDark(context) ? DriverVault.darkTextStrong : DriverVault.ink900;
return Container(
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor))),
child: Row(
children: [
for (var i = 0; i < tabs.length; i++)
GestureDetector(
onTap: () => onChanged(i),
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.only(right: 22, bottom: 10, top: 2),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: i == index ? accent : Colors.transparent, width: 2),
),
),
child: Text(
tabs[i],
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: i == index ? strong : muted,
),
),
),
),
],
),
);
}
}
/// A titled card wrapper matching the web's sectioned layout.
class _Card extends StatelessWidget {
final String title;
final Color? titleColor;
final List<Widget> children;
const _Card({required this.title, this.titleColor, required this.children});
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Theme.of(context).dividerColor),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600, color: titleColor)),
const SizedBox(height: 12),
...children,
],
),
),
);
}
}
// --- Account ---------------------------------------------------------------
class _AccountSection extends StatefulWidget {
final UserProfile profile;
final TextEditingController nameController;
final Future<void> Function() onChanged;
final void Function(String) snack;
const _AccountSection({
required this.profile,
required this.nameController,
required this.onChanged,
required this.snack,
});
@override
State<_AccountSection> createState() => _AccountSectionState();
}
class _AccountSectionState extends State<_AccountSection> {
bool _savingName = false;
bool _verifying = false;
final _oldPw = TextEditingController();
final _newPw = TextEditingController();
final _confirmPw = TextEditingController();
bool _savingPw = false;
String? _pwError;
@override
void dispose() {
_oldPw.dispose();
_newPw.dispose();
_confirmPw.dispose();
super.dispose();
}
Future<void> _saveName() async {
setState(() => _savingName = true);
try {
final updated = await apiClient.updateMe({"name": widget.nameController.text.trim()});
authService.adoptUser(AuthUser(
id: updated.id,
email: updated.email,
name: updated.name,
role: updated.role,
));
widget.snack(t("settings.account.nameSaved"));
} catch (e) {
widget.snack("$e");
} finally {
if (mounted) setState(() => _savingName = false);
}
}
Future<void> _sendVerification() async {
setState(() => _verifying = true);
try {
await apiClient.requestVerification();
widget.snack(t("settings.account.verificationRequested"));
} catch (e) {
widget.snack("$e");
} finally {
if (mounted) setState(() => _verifying = false);
}
}
Future<void> _savePassword() async {
setState(() => _pwError = null);
if (_newPw.text.length < 8) {
setState(() => _pwError = t("settings.account.tooShort"));
return;
}
if (_newPw.text != _confirmPw.text) {
setState(() => _pwError = t("settings.account.mismatch"));
return;
}
setState(() => _savingPw = true);
try {
await apiClient.changePassword(_oldPw.text, _newPw.text);
_oldPw.clear();
_newPw.clear();
_confirmPw.clear();
widget.snack(t("settings.account.passwordUpdated"));
} catch (e) {
setState(() => _pwError = e.toString());
} finally {
if (mounted) setState(() => _savingPw = false);
}
}
@override
Widget build(BuildContext context) {
final p = widget.profile;
return _Card(
title: t("settings.account.title"),
children: [
Text(t("settings.account.name"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
Row(children: [
Expanded(
child: TextField(
controller: widget.nameController,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _savingName ? null : _saveName,
child: Text(_savingName ? t("common.saving") : t("common.save")),
),
]),
const SizedBox(height: 16),
Text(t("settings.account.email"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
Row(children: [
Expanded(child: Text(p.email)),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: p.verified
? (DriverVault.isDark(context) ? DriverVault.successSoftDark : DriverVault.successSoft)
: (DriverVault.isDark(context) ? DriverVault.warningSoftDark : DriverVault.warningSoft),
borderRadius: BorderRadius.circular(999),
),
child: Text(p.verified ? t("settings.account.verified") : t("settings.account.notVerified"),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: p.verified ? DriverVault.success : DriverVault.warning)),
),
]),
if (!p.verified)
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: _verifying ? null : _sendVerification,
child: Text(_verifying ? t("settings.account.sending") : t("settings.account.resendVerification")),
),
),
const Divider(height: 24),
Text(t("settings.account.changePassword"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
TextField(
controller: _oldPw,
obscureText: true,
decoration: InputDecoration(
labelText: t("settings.account.currentPassword"), border: const OutlineInputBorder(), isDense: true),
),
const SizedBox(height: 8),
TextField(
controller: _newPw,
obscureText: true,
decoration: InputDecoration(
labelText: t("settings.account.newPassword"), border: const OutlineInputBorder(), isDense: true),
),
const SizedBox(height: 8),
TextField(
controller: _confirmPw,
obscureText: true,
decoration: InputDecoration(
labelText: t("settings.account.confirmNewPassword"), border: const OutlineInputBorder(), isDense: true),
),
if (_pwError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(_pwError!, style: const TextStyle(color: DriverVault.danger, fontSize: 13)),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton(
onPressed: _savingPw ? null : _savePassword,
child: Text(_savingPw ? t("settings.account.updating") : t("settings.account.updatePassword")),
),
),
],
);
}
}
// --- Appearance ------------------------------------------------------------
// Language and region are two controls over the one stored BCP-47 tag, so the
// pair can be mixed freely (English in Poland, say) rather than being limited to
// the handful of combinations a single list could offer.
//
// Europe here means the sovereign states of the Council of Europe, plus Belarus,
// Russia, Vatican City and Kosovo — geographically European but not members.
// Dependencies (Gibraltar, Faroes, Åland) are left out: they are not countries.
// US stays on the region list because it was there before this became a Europe
// list. The lists mirror the web app's, minus Luxembourgish and Romansh: intl
// ships no symbols for those two and throws rather than falling back, so
// offering them would break every date on screen. The browser has full ICU data
// behind it and does not have that limit.
//
// Labels are hand-kept because Dart has no Intl.DisplayNames — languages read as
// endonyms (the name a speaker would recognise regardless of the current UI
// language), regions and currencies in English.
const _languages = [
("sq", "Shqip"), ("hy", "Հայերեն"), ("az", "Azərbaycan"), ("eu", "Euskara"),
("be", "Беларуская"), ("bs", "Bosanski"), ("bg", "Български"), ("ca", "Català"),
("hr", "Hrvatski"), ("cs", "Čeština"), ("da", "Dansk"), ("nl", "Nederlands"),
("en", "English"), ("et", "Eesti"), ("fi", "Suomi"), ("fr", "Français"),
("gl", "Galego"), ("ka", "ქართული"), ("de", "Deutsch"), ("el", "Ελληνικά"),
("hu", "Magyar"), ("is", "Íslenska"), ("ga", "Gaeilge"), ("it", "Italiano"),
("lv", "Latviešu"), ("lt", "Lietuvių"), ("mk", "Македонски"), ("mt", "Malti"),
("no", "Norsk"), ("pl", "Polski"), ("pt", "Português"), ("ro", "Română"),
("ru", "Русский"), ("sr", "Српски"), ("sk", "Slovenčina"), ("sl", "Slovenščina"),
("es", "Español"), ("sv", "Svenska"), ("tr", "Türkçe"), ("uk", "Українська"),
("cy", "Cymraeg"),
];
const _regions = [
("AD", "Andorra"), ("AL", "Albania"), ("AM", "Armenia"), ("AT", "Austria"),
("AZ", "Azerbaijan"), ("BA", "Bosnia & Herzegovina"), ("BE", "Belgium"),
("BG", "Bulgaria"), ("BY", "Belarus"), ("CH", "Switzerland"), ("CY", "Cyprus"),
("CZ", "Czechia"), ("DE", "Germany"), ("DK", "Denmark"), ("EE", "Estonia"),
("ES", "Spain"), ("FI", "Finland"), ("FR", "France"), ("GB", "United Kingdom"),
("GE", "Georgia"), ("GR", "Greece"), ("HR", "Croatia"), ("HU", "Hungary"),
("IE", "Ireland"), ("IS", "Iceland"), ("IT", "Italy"), ("LI", "Liechtenstein"),
("LT", "Lithuania"), ("LU", "Luxembourg"), ("LV", "Latvia"), ("MC", "Monaco"),
("MD", "Moldova"), ("ME", "Montenegro"), ("MK", "North Macedonia"), ("MT", "Malta"),
("NL", "Netherlands"), ("NO", "Norway"), ("PL", "Poland"), ("PT", "Portugal"),
("RO", "Romania"), ("RS", "Serbia"), ("RU", "Russia"), ("SE", "Sweden"),
("SI", "Slovenia"), ("SK", "Slovakia"), ("SM", "San Marino"), ("TR", "Türkiye"),
("UA", "Ukraine"), ("US", "United States"), ("VA", "Vatican City"), ("XK", "Kosovo"),
];
/// Mirrors validCurrencies in the API's me.go and the users.currency select in
/// setup-pocketbase.mjs — all three have to list the same codes.
const _currencies = [
("EUR", "Euro"), ("GBP", "British pound"), ("CHF", "Swiss franc"),
("PLN", "Polish złoty"), ("CZK", "Czech koruna"), ("HUF", "Hungarian forint"),
("RON", "Romanian leu"), ("BGN", "Bulgarian lev"), ("DKK", "Danish krone"),
("SEK", "Swedish krona"), ("NOK", "Norwegian krone"), ("ISK", "Icelandic króna"),
("ALL", "Albanian lek"), ("AMD", "Armenian dram"), ("AZN", "Azerbaijani manat"),
("BAM", "Bosnia-Herzegovina mark"), ("BYN", "Belarusian ruble"),
("GEL", "Georgian lari"), ("MDL", "Moldovan leu"), ("MKD", "Macedonian denar"),
("RSD", "Serbian dinar"), ("RUB", "Russian ruble"), ("TRY", "Turkish lira"),
("UAH", "Ukrainian hryvnia"), ("USD", "US dollar"), ("CAD", "Canadian dollar"),
("AUD", "Australian dollar"), ("JPY", "Japanese yen"),
];
class _AppearanceSection extends StatefulWidget {
final void Function(String) onError;
const _AppearanceSection({required this.onError});
@override
State<_AppearanceSection> createState() => _AppearanceSectionState();
}
class _AppearanceSectionState extends State<_AppearanceSection> {
Future<void> _save(Map<String, dynamic> patch) async {
// Optimistic: apply locally first, roll back on failure.
final prev = {
"theme": appSettings.theme,
"locale": appSettings.locale,
"dateFormat": appSettings.dateFormat,
"timeFormat": appSettings.timeFormat,
"weekStart": appSettings.weekStart,
"currency": appSettings.currency,
"fontSize": appSettings.fontSize,
};
appSettings.patch(
theme: patch["theme"],
locale: patch["locale"],
dateFormat: patch["dateFormat"],
timeFormat: patch["timeFormat"],
weekStart: patch["weekStart"],
currency: patch["currency"],
fontSize: patch["fontSize"],
);
setState(() {});
try {
await apiClient.updateMe(patch);
} catch (e) {
appSettings.patch(
theme: prev["theme"],
locale: prev["locale"],
dateFormat: prev["dateFormat"],
timeFormat: prev["timeFormat"],
weekStart: prev["weekStart"],
currency: prev["currency"],
fontSize: prev["fontSize"],
);
setState(() {});
widget.onError("$e");
}
}
/// Language and region write the one locale field, so either control sends the
/// joined tag. The half the user did not touch is read back from the pickers
/// rather than from the raw locale, so changing only one of them cannot save a
/// code the other picker is not showing.
Future<void> _saveLocale({String? lang, String? region}) => _save({
"locale": "${lang ?? _language}-${region ?? _region}",
});
String get _language => _knownOr(_languages, appSettings.language, "en");
String get _region => _knownOr(_regions, appSettings.region, "US");
/// A value the picker cannot show would render blank and silently reset on the
/// next save, so an unknown code (a locale set from the web, whose lists are
/// wider than these) falls back to the list's default rather than being
/// dropped.
static String _knownOr(List<(String, String)> options, String value, String fallback) =>
options.any((o) => o.$1 == value) ? value : fallback;
@override
Widget build(BuildContext context) {
return _Card(
title: t("settings.appearance.title"),
children: [
Text(t("settings.appearance.theme"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
SegmentedButton<String>(
segments: [
ButtonSegment(value: "light", label: Text(t("settings.appearance.themeLight"))),
ButtonSegment(value: "dark", label: Text(t("settings.appearance.themeDark"))),
ButtonSegment(value: "system", label: Text(t("settings.appearance.themeSystem"))),
],
selected: {appSettings.theme},
onSelectionChanged: (s) => _save({"theme": s.first}),
),
const SizedBox(height: 16),
Text(t("settings.appearance.language"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: _language,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: [
for (final l in _languages) DropdownMenuItem(value: l.$1, child: Text(l.$2)),
],
onChanged: (v) => v == null ? null : _saveLocale(lang: v),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
languageTranslated
? t("settings.appearance.languageHint")
: t("settings.appearance.languageFallbackHint"),
style: TextStyle(
color: languageTranslated ? Colors.grey : DriverVault.warning, fontSize: 12),
),
),
const SizedBox(height: 16),
Text(t("settings.appearance.region"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: _region,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: [
for (final r in _regions) DropdownMenuItem(value: r.$1, child: Text(r.$2)),
],
onChanged: (v) => v == null ? null : _saveLocale(region: v),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(t("settings.appearance.regionHint"),
style: const TextStyle(color: Colors.grey, fontSize: 12)),
),
const SizedBox(height: 16),
Text(t("settings.appearance.currency"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: _knownOr(_currencies, appSettings.currency, "USD"),
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: [
for (final c in _currencies)
DropdownMenuItem(value: c.$1, child: Text("${c.$2} (${c.$1})")),
],
onChanged: (v) => v == null ? null : _save({"currency": v}),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
t("settings.appearance.currencyHint", params: {"example": formatMoney(1234.5)}),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
const SizedBox(height: 16),
Text(t("settings.appearance.dateFormat"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: appSettings.dateFormat,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: const [
DropdownMenuItem(value: "YMD", child: Text("YYYY-MM-DD")),
DropdownMenuItem(value: "DMY_NUM", child: Text("DD-MM-YYYY")),
DropdownMenuItem(value: "DMY", child: Text("DD Mon YYYY")),
DropdownMenuItem(value: "MDY", child: Text("Mon DD, YYYY")),
],
onChanged: (v) => v == null ? null : _save({"dateFormat": v}),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(t("settings.appearance.dateHint", params: {"example": formatDate(DateTime.now())}),
style: const TextStyle(color: Colors.grey, fontSize: 12)),
),
// Beside the date rather than under the region, because it is the same
// question asked about the other half of a timestamp.
const SizedBox(height: 16),
Text(t("settings.appearance.timeFormat"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: appSettings.timeFormat,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: [
DropdownMenuItem(value: "auto", child: Text(t("settings.appearance.timeAuto"))),
DropdownMenuItem(value: "24", child: Text(t("settings.appearance.time24"))),
DropdownMenuItem(value: "12", child: Text(t("settings.appearance.time12"))),
],
onChanged: (v) => v == null ? null : _save({"timeFormat": v}),
),
Padding(
padding: const EdgeInsets.only(top: 4),
// Thirteen-something rather than now: an example at 09:00 reads the
// same in both conventions, which is the one time of day that cannot
// show the choice.
child: Text(
t("settings.appearance.timeExample", params: {
"example": formatTime(DateTime(2024, 1, 1, 13, 45)),
}),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
// Under the clock, as the last of the three questions a region is asked
// and the one it is least often asked out loud.
const SizedBox(height: 16),
Text(t("settings.appearance.weekStart"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: appSettings.weekStart,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: [
DropdownMenuItem(value: "auto", child: Text(t("settings.appearance.weekAuto"))),
DropdownMenuItem(value: "monday", child: Text(t("settings.appearance.weekMonday"))),
DropdownMenuItem(value: "sunday", child: Text(t("settings.appearance.weekSunday"))),
],
onChanged: (v) => v == null ? null : _save({"weekStart": v}),
),
Padding(
padding: const EdgeInsets.only(top: 4),
// The week as this account will now see it drawn — the clearest
// possible example, because the setting has no other visible effect on
// this page.
child: Text(
t("settings.appearance.weekExample", params: {
"example": weekdaysInOrder().map(weekdayShortName).join(" "),
}),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
const SizedBox(height: 16),
Text(t("settings.appearance.fontSize"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
SegmentedButton<String>(
segments: [
ButtonSegment(value: "small", label: Text(t("settings.appearance.fontSmall"))),
ButtonSegment(value: "medium", label: Text(t("settings.appearance.fontMedium"))),
ButtonSegment(value: "large", label: Text(t("settings.appearance.fontLarge"))),
],
selected: {appSettings.fontSize},
onSelectionChanged: (s) => _save({"fontSize": s.first}),
),
],
);
}
}
// --- Profile (avatar + bio) ------------------------------------------------
class _ProfileSection extends StatefulWidget {
final UserProfile profile;
final TextEditingController bioController;
final Uint8List? avatar;
final Future<void> Function() onChanged;
final void Function(String) snack;
const _ProfileSection({
required this.profile,
required this.bioController,
required this.avatar,
required this.onChanged,
required this.snack,
});
@override
State<_ProfileSection> createState() => _ProfileSectionState();
}
class _ProfileSectionState extends State<_ProfileSection> {
bool _avatarBusy = false;
bool _savingBio = false;
Future<void> _pickAvatar() async {
final picker = ImagePicker();
final file = await picker.pickImage(source: ImageSource.gallery, maxWidth: 1024);
if (file == null) return;
setState(() => _avatarBusy = true);
try {
final bytes = await file.readAsBytes();
await apiClient.uploadAvatar(bytes, file.name);
await widget.onChanged();
} catch (e) {
widget.snack("$e");
} finally {
if (mounted) setState(() => _avatarBusy = false);
}
}
Future<void> _removeAvatar() async {
setState(() => _avatarBusy = true);
try {
await apiClient.deleteAvatar();
await widget.onChanged();
} catch (e) {
widget.snack("$e");
} finally {
if (mounted) setState(() => _avatarBusy = false);
}
}
Future<void> _saveBio() async {
setState(() => _savingBio = true);
try {
await apiClient.updateMe({"bio": widget.bioController.text});
widget.snack(t("settings.profile.bioSaved"));
} catch (e) {
widget.snack("$e");
} finally {
if (mounted) setState(() => _savingBio = false);
}
}
@override
Widget build(BuildContext context) {
final p = widget.profile;
final initial = (p.name.isNotEmpty ? p.name : p.email).characters.first.toUpperCase();
return _Card(
title: t("settings.profile.title"),
children: [
Row(children: [
CircleAvatar(
radius: 32,
backgroundColor: DriverVault.brandTint(context),
backgroundImage: widget.avatar != null ? MemoryImage(widget.avatar!) : null,
child: widget.avatar == null
? Text(initial,
style: TextStyle(
fontSize: 22, fontWeight: FontWeight.w700, color: DriverVault.brandOnTint(context)))
: null,
),
const SizedBox(width: 16),
Wrap(spacing: 8, children: [
OutlinedButton(
onPressed: _avatarBusy ? null : _pickAvatar,
child: Text(_avatarBusy ? t("settings.profile.working") : t("settings.profile.uploadPhoto")),
),
if (p.hasAvatar)
OutlinedButton(
onPressed: _avatarBusy ? null : _removeAvatar,
child: Text(t("settings.profile.remove")),
),
]),
]),
const SizedBox(height: 16),
Text(t("settings.profile.bio"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
TextField(
controller: widget.bioController,
maxLines: 3,
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: t("settings.profile.bioHint"),
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton(
onPressed: _savingBio ? null : _saveBio,
child: Text(_savingBio ? t("common.saving") : t("settings.profile.saveBio")),
),
),
],
);
}
}
// --- Security (biometric sign-in) ------------------------------------------
class _SecuritySection extends StatefulWidget {
final String email;
final void Function(String) snack;
const _SecuritySection({required this.email, required this.snack});
@override
State<_SecuritySection> createState() => _SecuritySectionState();
}
class _SecuritySectionState extends State<_SecuritySection> {
bool _available = false;
bool _enabled = false;
bool _hasFace = false;
bool _hasFingerprint = false;
bool _busy = false;
bool _loaded = false;
@override
void initState() {
super.initState();
_refresh();
}
Future<void> _refresh() async {
final available = await biometricAuth.isAvailable();
final enabled = await biometricAuth.isEnabled();
final face = available && await biometricAuth.hasFace();
final finger = available && await biometricAuth.hasFingerprint();
if (!mounted) return;
setState(() {
_available = available;
_enabled = enabled;
_hasFace = face;
_hasFingerprint = finger;
_loaded = true;
});
}
String get _methodLabel {
if (_hasFace && _hasFingerprint) return t("settings.security.methodFaceOrFingerprint");
if (_hasFace) return t("settings.security.methodFace");
if (_hasFingerprint) return t("settings.security.methodFingerprint");
return t("settings.security.methodBiometrics");
}
Future<void> _toggle(bool value) async {
if (_busy) return;
if (!value) {
setState(() => _busy = true);
await biometricAuth.disable();
if (mounted) setState(() { _enabled = false; _busy = false; });
widget.snack(t("settings.security.turnedOff"));
return;
}
// Enabling requires re-confirming the password so we store known-good creds.
final pw = await _promptPassword();
if (pw == null || pw.isEmpty) return;
setState(() => _busy = true);
try {
// Verify the password (and refresh the session) before storing it.
await authService.login(widget.email, pw);
await biometricAuth.enable(widget.email, pw);
if (mounted) setState(() { _enabled = true; _busy = false; });
widget.snack(t("settings.security.enabled"));
} catch (e) {
if (mounted) setState(() => _busy = false);
widget.snack(t("settings.security.couldNotEnable", params: {"error": e.toString()}));
}
}
Future<String?> _promptPassword() {
final ctrl = TextEditingController();
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t("settings.security.confirmPassword")),
content: TextField(
controller: ctrl,
obscureText: true,
autofocus: true,
decoration: InputDecoration(
labelText: t("settings.security.password"),
border: const OutlineInputBorder(),
),
onSubmitted: (v) => Navigator.pop(ctx, v),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(t("common.cancel"))),
FilledButton(onPressed: () => Navigator.pop(ctx, ctrl.text), child: Text(t("common.confirm"))),
],
),
);
}
@override
Widget build(BuildContext context) {
return _Card(
title: t("settings.security.title"),
children: [
if (!_loaded)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(t("settings.security.checkingDevice"), style: const TextStyle(color: Colors.grey)),
)
else ...[
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(t("settings.security.biometricSignIn")),
subtitle: Text(_available
? t("settings.security.biometricAvailable", params: {"method": _methodLabel})
: t("settings.security.biometricUnavailable")),
value: _enabled,
onChanged: (!_available || _busy) ? null : _toggle,
),
],
],
);
}
}
// --- Organization -----------------------------------------------------------
/// Organization membership, adapting to who is looking:
/// - a user with no organization gets a "create your own" field, and becomes
/// the admin of what they create;
/// - an admin sees their own org with rename + delete (deleting it detaches
/// them and drops them back to a plain user);
/// - a superadmin sees every org and can create, rename and delete any of them.
/// The API Server enforces all of this; this card only mirrors it.
class _OrganizationSection extends StatefulWidget {
final UserProfile profile;
final Future<void> Function() onChanged;
final void Function(String) snack;
const _OrganizationSection({
required this.profile,
required this.onChanged,
required this.snack,
});
@override
State<_OrganizationSection> createState() => _OrganizationSectionState();
}
class _OrganizationSectionState extends State<_OrganizationSection> {
final _createName = TextEditingController();
List<Organization> _orgs = [];
bool _busy = false;
bool get _isSuperadmin => widget.profile.isSuperadmin;
bool get _isManager => widget.profile.isAdmin;
String get _myOrg => widget.profile.organization;
// Anyone who is not a superadmin and has no org yet can stand one up.
bool get _showCreateOwn => !_isSuperadmin && _myOrg.isEmpty;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_createName.dispose();
super.dispose();
}
Future<void> _load() async {
// Listing is manager-only; an org-less user just gets the create field.
if (!_isManager) {
if (mounted) setState(() => _orgs = []);
return;
}
try {
final list = await apiClient.listOrgs();
if (mounted) setState(() => _orgs = list);
} catch (e) {
widget.snack("$e");
}
}
/// Creating an org as a non-superadmin promotes the caller to its admin, so the
/// profile is reloaded to pick up the new role + membership.
Future<void> _create(String name) async {
if (name.trim().isEmpty) return;
setState(() => _busy = true);
try {
await apiClient.createOrg(name.trim());
_createName.clear();
if (!_isSuperadmin) await widget.onChanged();
await _load();
} catch (e) {
widget.snack("$e");
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _promptCreate() async {
final controller = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t("settings.org.newOrg")),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
labelText: t("settings.org.nameLabel"),
hintText: t("settings.org.namePlaceholder"),
border: const OutlineInputBorder(),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(t("settings.org.create"))),
],
),
);
if (ok == true) await _create(controller.text);
}
Future<void> _promptRename(Organization o) async {
final controller = TextEditingController(text: o.name);
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t("settings.org.rename")),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
labelText: t("settings.org.nameLabel"),
border: const OutlineInputBorder(),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(t("common.save"))),
],
),
);
if (ok != true || controller.text.trim().isEmpty) return;
setState(() => _busy = true);
try {
await apiClient.renameOrg(o.id, controller.text.trim());
await _load();
} catch (e) {
widget.snack("$e");
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _promptDelete(Organization o) async {
// Deleting your own org detaches you from it and demotes you to a plain user.
final mine = !_isSuperadmin && o.id == _myOrg;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t("settings.org.deleteTitle")),
content: Text(mine
? t("settings.org.confirmDeleteOwn", params: {"name": o.name})
: t("settings.org.confirmDelete", params: {"name": o.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("settings.org.delete")),
),
],
),
);
if (confirmed != true) return;
setState(() => _busy = true);
try {
await apiClient.deleteOrg(o.id);
if (mine) await widget.onChanged(); // now org-less and demoted to user
await _load();
} catch (e) {
// The server refuses (409) while the org still has other members.
widget.snack("$e");
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final muted = DriverVault.muted(context);
return _Card(
title: _isSuperadmin ? t("settings.org.titleAll") : t("settings.org.title"),
children: [
Text(
_isSuperadmin
? t("settings.org.subtitleAll")
: _showCreateOwn
? t("settings.org.subtitleNone")
: t("settings.org.subtitleOwn"),
style: TextStyle(fontSize: 12, color: muted),
),
const SizedBox(height: 12),
if (_showCreateOwn) ...[
TextField(
controller: _createName,
decoration: InputDecoration(
labelText: t("settings.org.nameLabel"),
hintText: t("settings.org.namePlaceholder"),
border: const OutlineInputBorder(),
),
onSubmitted: _busy ? null : _create,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: FilledButton(
onPressed: _busy ? null : () => _create(_createName.text),
child: Text(_busy ? t("common.saving") : t("settings.org.create")),
),
),
const SizedBox(height: 8),
Text(t("settings.org.createHint"), style: TextStyle(fontSize: 12, color: muted)),
] else ...[
if (_isSuperadmin)
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton(
onPressed: _busy ? null : _promptCreate,
child: Text(t("settings.org.newOrg")),
),
),
if (_orgs.isEmpty)
Text(t("settings.org.empty"), style: TextStyle(fontSize: 13, color: muted))
else
for (final o in _orgs)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(o.name, style: const TextStyle(fontWeight: FontWeight.w500)),
Text(o.id, style: TextStyle(fontSize: 11, color: muted)),
],
),
),
IconButton(
tooltip: t("settings.org.rename"),
icon: const Icon(Icons.edit_outlined, size: 20),
onPressed: _busy ? null : () => _promptRename(o),
),
IconButton(
tooltip: t("settings.org.delete"),
icon: const Icon(Icons.delete_outline, size: 20, color: DriverVault.danger),
onPressed: _busy ? null : () => _promptDelete(o),
),
],
),
),
],
],
);
}
}
// --- Privacy & security -----------------------------------------------------
/// Sessions are PocketBase's own stateless tokens, so there is no per-device
/// list to show or revoke — this card explains what "sign out" actually does.
class _PrivacySection extends StatelessWidget {
const _PrivacySection();
@override
Widget build(BuildContext context) {
return _Card(
title: t("settings.privacy.title"),
children: [
Text(
t("settings.privacy.body"),
style: const TextStyle(color: Colors.grey, fontSize: 13),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => authService.logout(),
child: Text(t("settings.privacy.signOut"), style: const TextStyle(color: DriverVault.danger)),
),
),
],
);
}
}
// --- Data (export / import) -------------------------------------------------
/// Export downloads the account as JSON; import adds the cars from a file
/// exported earlier. The web hands the browser a download and a file input; a
/// phone has neither, so the export is written to the app's own directory and
/// offered to whatever the phone opens JSON with, and the import goes through
/// the system file picker.
///
/// The import always creates new records — nothing is merged or overwritten —
/// so it asks first, with the number of cars the file actually holds.
class _DataSection extends StatefulWidget {
final void Function(String) snack;
const _DataSection({required this.snack});
@override
State<_DataSection> createState() => _DataSectionState();
}
class _DataSectionState extends State<_DataSection> {
bool _exporting = false;
bool _importing = false;
String? _error;
ImportResult? _result;
/// The last export written, kept so the card can offer to open it: the file
/// lands in the app's documents directory, which the user has no other route
/// to.
File? _exported;
Future<void> _export() async {
setState(() {
_exporting = true;
_error = null;
_exported = null;
});
try {
final (bytes, filename) = await apiClient.exportData();
final dir = await getApplicationDocumentsDirectory();
final file = File("${dir.path}/$filename");
await file.writeAsBytes(bytes);
if (!mounted) return;
setState(() => _exported = file);
widget.snack(t("settings.advanced.exported", params: {"file": filename}));
} catch (e) {
if (mounted) setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _exporting = false);
}
}
Future<void> _openExport() async {
final file = _exported;
if (file == null) return;
final res = await OpenFilex.open(file.path);
if (res.type != ResultType.done && mounted) {
widget.snack(t("settings.advanced.openFailed"));
}
}
Future<void> _import() async {
setState(() {
_error = null;
_result = null;
});
final file = await FilePicker.pickFile(
type: FileType.custom,
allowedExtensions: const ["json"],
);
if (file == null) return;
final bytes = await file.readAsBytes();
Map<String, dynamic> payload;
try {
payload = Map<String, dynamic>.from(jsonDecode(utf8.decode(bytes)) as Map);
} catch (_) {
setState(() => _error = t("settings.advanced.notJson"));
return;
}
final cars = payload["cars"];
if (cars is! List || cars.isEmpty) {
setState(() => _error = t("settings.advanced.notExport"));
return;
}
if (!mounted) return;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
content: Text(t("settings.advanced.confirmImport", params: {"count": cars.length})),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(t("settings.advanced.importAction")),
),
],
),
);
if (ok != true) return;
setState(() => _importing = true);
try {
final res = await apiClient.importData(payload);
if (mounted) setState(() => _result = res);
} catch (e) {
if (mounted) setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _importing = false);
}
}
@override
Widget build(BuildContext context) {
final muted = TextStyle(fontSize: 12, color: DriverVault.muted(context));
return _Card(
title: t("settings.advanced.title"),
children: [
Text(t("settings.advanced.exportTitle"),
style: const TextStyle(fontWeight: FontWeight.w600)),
Text(t("settings.advanced.exportBody"), style: muted),
const SizedBox(height: 8),
Row(
children: [
OutlinedButton.icon(
onPressed: _exporting ? null : _export,
icon: const Icon(Icons.download_outlined, size: 18),
label: Text(_exporting
? t("settings.advanced.preparing")
: t("settings.advanced.exportAction")),
),
if (_exported != null) ...[
const SizedBox(width: 8),
TextButton(onPressed: _openExport, child: Text(t("settings.advanced.open"))),
],
],
),
const Divider(height: 24),
Text(t("settings.advanced.importTitle"),
style: const TextStyle(fontWeight: FontWeight.w600)),
Text(t("settings.advanced.importBody"), style: muted),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: _importing ? null : _import,
icon: const Icon(Icons.upload_file_outlined, size: 18),
label: Text(_importing
? t("settings.advanced.importing")
: t("settings.advanced.importAction")),
),
),
if (_result != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
t("settings.advanced.imported", params: {
"cars": _result!.cars,
"services": _result!.services,
"parts": _result!.parts,
}),
style: const TextStyle(color: DriverVault.success, fontWeight: FontWeight.w600),
),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
),
],
);
}
}
// --- Danger zone (account deletion) ----------------------------------------
class _DangerSection extends StatefulWidget {
final UserProfile profile;
final Future<void> Function() onChanged;
final void Function(String) snack;
const _DangerSection({required this.profile, required this.onChanged, required this.snack});
@override
State<_DangerSection> createState() => _DangerSectionState();
}
class _DangerSectionState extends State<_DangerSection> {
final _confirmEmail = TextEditingController();
bool _showConfirm = false;
bool _busy = false;
DateTime? _eligibleAt;
@override
void dispose() {
_confirmEmail.dispose();
super.dispose();
}
bool get _pending => widget.profile.deletionPending;
DateTime? get _eligible {
if (!_pending) return null;
return _eligibleAt ??
widget.profile.deletionRequestedAt!.add(const Duration(days: 3));
}
bool get _cooldownElapsed => _eligible != null && DateTime.now().isAfter(_eligible!);
Future<void> _request() async {
if (_confirmEmail.text.trim().toLowerCase() != widget.profile.email.toLowerCase()) {
widget.snack(t("settings.danger.typeEmailPrompt"));
return;
}
setState(() => _busy = true);
try {
_eligibleAt = await apiClient.requestAccountDeletion(_confirmEmail.text.trim());
_showConfirm = false;
_confirmEmail.clear();
await widget.onChanged();
} catch (e) {
widget.snack("$e");
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _cancel() async {
try {
await apiClient.cancelAccountDeletion();
_eligibleAt = null;
await widget.onChanged();
} catch (e) {
widget.snack("$e");
}
}
Future<void> _finalize() async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t("settings.danger.finalizeTitle")),
content: Text(t("settings.danger.finalizeBody")),
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("settings.danger.delete")),
),
],
),
);
if (ok != true) return;
try {
await apiClient.finalizeAccountDeletion();
await authService.logout();
} catch (e) {
widget.snack("$e");
}
}
@override
Widget build(BuildContext context) {
final p = widget.profile;
return _Card(
title: t("settings.danger.title"),
titleColor: DriverVault.danger,
children: [
if (!_pending) ...[
Text(
t("settings.danger.body"),
style: const TextStyle(fontSize: 13),
),
const SizedBox(height: 8),
if (!_showConfirm)
OutlinedButton(
onPressed: () => setState(() => _showConfirm = true),
style: OutlinedButton.styleFrom(foregroundColor: DriverVault.danger),
child: Text(t("settings.danger.deleteAccount")),
)
else ...[
Text(t("settings.danger.typeToConfirm", params: {"email": p.email}),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
TextField(
controller: _confirmEmail,
decoration: InputDecoration(
hintText: p.email, border: const OutlineInputBorder(), isDense: true),
),
const SizedBox(height: 8),
Row(children: [
TextButton(
onPressed: () => setState(() {
_showConfirm = false;
_confirmEmail.clear();
}),
child: Text(t("common.cancel")),
),
const SizedBox(width: 8),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
onPressed: _busy ? null : _request,
child: Text(_busy ? t("settings.danger.requesting") : t("settings.danger.requestDeletion")),
),
]),
],
] else ...[
Text(
t("settings.danger.requestedOn", params: {
"date": formatDate(p.deletionRequestedAt),
"tail": _cooldownElapsed
? t("settings.danger.cooldownPassed")
: t("settings.danger.canStillCancel"),
}),
style: const TextStyle(fontSize: 13),
),
const SizedBox(height: 8),
Row(children: [
OutlinedButton(onPressed: _cancel, child: Text(t("settings.danger.cancelRequest"))),
const SizedBox(width: 8),
if (_cooldownElapsed)
FilledButton(
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
onPressed: _finalize,
child: Text(t("settings.danger.permanentlyDelete")),
),
]),
],
],
);
}
}
// --- Integrations tab ------------------------------------------------------
//
// Three foldable connector cards (Toyota, Anker Solix, Greencell) over a
// superadmin → org admin → user cascade. Each card resolves, per field, the effective value
// (secrets/inherited values masked), the caller's own-layer value, its source
// layer, and whether it's locked (set above the caller). Org admins get a
// second "org" scope to edit organization-wide defaults; superadmins manage the
// shared layer in the API Server panel, so here it is read-only.
enum _FieldType { text, number, password, select, multiselect }
/// The control modes the server still offers in a scope, and the ones this
/// organization hides from its own users. Named functions rather than closures
/// so the field specs below can stay const.
List<String> _scopeControlModes(IntegrationScope s) => s.controlModes;
List<String> _scopeHiddenControlModes(IntegrationScope s) => s.controlModesDisabled;
/// Describes one credential field within an integration card.
class _FieldSpec {
final String key;
final String labelKey;
final _FieldType type;
final List<(String, String)> options; // (value, labelKey) for select/multiselect
final String? placeholder; // literal placeholder
final String? hintKey; // shown below the field when not locked
final String defaultValue;
final bool showEffectiveWhenLocked; // controlMode isn't secret: show it locked
final int? maxLength;
/// Narrows the declared options to those the server still offers in this
/// scope — the control-mode hide-list, which is what makes a mode a superadmin
/// switched off disappear from an organization's picker, and one an
/// organization switched off disappear from its users'. Null leaves every
/// declared option standing.
final List<String> Function(IntegrationScope)? scopeOptions;
/// Where a multiselect reads its current value from — a scope list rather than
/// a field, because a hide-list is not a cascaded value: it is this layer's
/// own instruction to the layers below.
final List<String> Function(IntegrationScope)? scopeValue;
/// Only shown when editing the organization layer. A user has nobody below
/// them, so a hide-list would mean nothing there.
final bool orgOnly;
const _FieldSpec({
required this.key,
required this.labelKey,
this.type = _FieldType.text,
this.options = const [],
this.placeholder,
this.hintKey,
this.defaultValue = "",
this.showEffectiveWhenLocked = false,
this.maxLength,
this.scopeOptions,
this.scopeValue,
this.orgOnly = false,
});
}
/// Everything that distinguishes one integration from the other.
class _IntegrationConfig {
final String nameKey;
final String descKey;
final List<_FieldSpec> fields;
final bool anker; // render the OCPP control provisioning card
final Future<IntegrationView> Function() load;
final Future<IntegrationView> Function(Map<String, dynamic>) save;
final Future<IntegrationHealth> Function() test;
const _IntegrationConfig({
required this.nameKey,
required this.descKey,
required this.fields,
required this.anker,
required this.load,
required this.save,
required this.test,
});
}
/// The integration categories, mirroring the API Server panel's plugin list
/// (the Category* constants in internal/plugins/plugin.go, which both read
/// from): someone who has seen one list can read the other.
class _IntegrationsTab extends StatefulWidget {
const _IntegrationsTab();
@override
State<_IntegrationsTab> createState() => _IntegrationsTabState();
}
class _IntegrationsTabState extends State<_IntegrationsTab> {
int _group = 0; // 0 = car manufacturers, 1 = EV chargers
@override
Widget build(BuildContext context) {
final toyota = _IntegrationConfig(
nameKey: "settings.integrations.toyota",
descKey: "settings.integrations.toyotaDesc",
anker: false,
load: apiClient.getToyota,
save: apiClient.saveToyota,
test: apiClient.testToyota,
fields: const [
_FieldSpec(key: "username", labelKey: "settings.integrations.email"),
_FieldSpec(key: "password", labelKey: "settings.integrations.password", type: _FieldType.password),
_FieldSpec(
key: "brand",
labelKey: "settings.integrations.brand",
type: _FieldType.select,
options: [
("", "common.empty"),
("T", "settings.integrations.brandToyota"),
("L", "settings.integrations.brandLexus"),
],
),
],
);
final anker = _IntegrationConfig(
nameKey: "settings.integrations.ankerSolix",
descKey: "settings.integrations.ankerSolixDesc",
anker: true,
load: apiClient.getAnkerSolix,
save: apiClient.saveAnkerSolix,
test: apiClient.testAnkerSolix,
fields: const [
_FieldSpec(key: "email", labelKey: "settings.integrations.ankerEmail"),
_FieldSpec(key: "password", labelKey: "settings.integrations.ankerPassword", type: _FieldType.password),
_FieldSpec(
key: "country",
labelKey: "settings.integrations.country",
placeholder: "DE",
hintKey: "settings.integrations.countryHint",
maxLength: 2,
),
_FieldSpec(
key: "controlMode",
labelKey: "settings.integrations.controlMode",
type: _FieldType.select,
hintKey: "settings.integrations.controlModeHint",
defaultValue: "off",
showEffectiveWhenLocked: true,
scopeOptions: _scopeControlModes,
options: [
("off", "settings.integrations.controlOff"),
("mqtt", "settings.integrations.controlCloud"),
("modbus", "settings.integrations.controlModbus"),
("own", "settings.integrations.controlOwn"),
("proxy", "settings.integrations.controlProxy"),
],
),
// What this organization hides from its own users. Off is absent on
// purpose: monitoring only is the fallback, so no layer may take it away.
_FieldSpec(
key: "controlModesDisabled",
labelKey: "settings.integrations.controlModesHidden",
type: _FieldType.multiselect,
hintKey: "settings.integrations.controlModesHiddenHint",
orgOnly: true,
scopeOptions: _scopeControlModes,
scopeValue: _scopeHiddenControlModes,
options: [
("mqtt", "settings.integrations.controlCloud"),
("modbus", "settings.integrations.controlModbus"),
("own", "settings.integrations.controlOwn"),
("proxy", "settings.integrations.controlProxy"),
],
),
],
);
// Greencell reads over an MQTT broker the owner runs rather than a vendor
// cloud, so the fields describe an endpoint (host/port/TLS/credentials,
// which the server resolves as one unit) plus the charger itself.
final greencell = _IntegrationConfig(
nameKey: "settings.integrations.greencell",
descKey: "settings.integrations.greencellDesc",
anker: false,
load: apiClient.getGreencell,
save: apiClient.saveGreencell,
test: apiClient.testGreencell,
fields: const [
_FieldSpec(
key: "host",
labelKey: "settings.integrations.greencellHost",
placeholder: "10.2.1.10",
hintKey: "settings.integrations.greencellHostHint",
),
_FieldSpec(
key: "port",
labelKey: "settings.integrations.greencellPort",
type: _FieldType.number,
placeholder: "1883",
hintKey: "settings.integrations.greencellPortHint",
showEffectiveWhenLocked: true,
),
_FieldSpec(
key: "tls",
labelKey: "settings.integrations.greencellTls",
type: _FieldType.select,
defaultValue: "off",
showEffectiveWhenLocked: true,
options: [
("off", "settings.integrations.greencellTlsOff"),
("on", "settings.integrations.greencellTlsOn"),
],
),
_FieldSpec(
key: "username",
labelKey: "settings.integrations.greencellUsername",
hintKey: "settings.integrations.greencellUsernameHint",
),
_FieldSpec(
key: "password",
labelKey: "settings.integrations.greencellPassword",
type: _FieldType.password,
),
_FieldSpec(
key: "serial",
labelKey: "settings.integrations.greencellSerial",
placeholder: "EVGC021B22752405ZM0018",
hintKey: "settings.integrations.greencellSerialHint",
showEffectiveWhenLocked: true,
),
_FieldSpec(
key: "timeout",
labelKey: "settings.integrations.greencellTimeout",
type: _FieldType.number,
placeholder: "12",
hintKey: "settings.integrations.greencellTimeoutHint",
showEffectiveWhenLocked: true,
),
_FieldSpec(
key: "commandTopic",
labelKey: "settings.integrations.greencellCommandTopic",
placeholder: "/greencell/evse/{sn}/command",
hintKey: "settings.integrations.greencellCommandTopicHint",
showEffectiveWhenLocked: true,
),
],
);
// One category's integrations. No count beside the label: the tab shows
// exactly these cards a line below it, so saying how many would be saying it
// twice.
final groups = [
[toyota],
[anker, greencell],
];
final index = _group.clamp(0, groups.length - 1);
return ListView(
padding: const EdgeInsets.all(12),
children: [
_Card(
title: t("settings.integrations.title"),
children: [
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(t("settings.integrations.subtitle"),
style: TextStyle(fontSize: 13, color: DriverVault.muted(context))),
),
_SettingsTabBar(
tabs: [
t("settings.integrations.categories.vehicles"),
t("settings.integrations.categories.chargers"),
],
index: index,
onChanged: (i) => setState(() => _group = i),
),
const SizedBox(height: 12),
for (final config in groups[index])
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _IntegrationCard(config: config),
),
],
),
const SizedBox(height: 24),
],
);
}
}
class _IntegrationCard extends StatefulWidget {
final _IntegrationConfig config;
const _IntegrationCard({required this.config});
@override
State<_IntegrationCard> createState() => _IntegrationCardState();
}
class _IntegrationCardState extends State<_IntegrationCard> {
IntegrationView? _view;
String _scope = "user"; // "user" | "org" (org admins only)
bool _open = false;
bool _saving = false;
bool _saved = false;
bool _testing = false;
String? _error;
IntegrationHealth? _health;
final Map<String, TextEditingController> _controllers = {};
final Map<String, String> _selects = {};
final Map<String, Set<String>> _multi = {}; // multiselect fields (hide-lists)
_IntegrationConfig get _c => widget.config;
@override
void initState() {
super.initState();
for (final f in _c.fields) {
if (f.type != _FieldType.select && f.type != _FieldType.multiselect) {
_controllers[f.key] = TextEditingController();
}
}
_load();
}
@override
void dispose() {
for (final ctrl in _controllers.values) {
ctrl.dispose();
}
super.dispose();
}
// --- cascade helpers (mirror Settings.vue) ---
String get _scopeKey => (_view?.isSuperadmin ?? false) ? "user" : _scope;
bool get _editingOrg => _scopeKey == "org";
bool get _readOnly => _view?.isSuperadmin ?? false;
IntegrationScope get _scopeData => _view?.scope(_scopeKey) ?? const IntegrationScope();
IntegrationField _field(String k) => _scopeData.field(k);
/// The fields this scope actually shows: an org-only field (a hide-list) is
/// out of the user scope, and a field whose options the layers above have all
/// hidden has nothing left to offer.
List<_FieldSpec> get _shownFields => [
for (final f in _c.fields)
if ((!f.orgOnly || _editingOrg) && (f.options.isEmpty || _optionsFor(f).isNotEmpty)) f,
];
/// A field's options after the scope's own list has narrowed them.
List<(String, String)> _optionsFor(_FieldSpec f) {
if (f.scopeOptions == null) return f.options;
final allowed = f.scopeOptions!(_scopeData);
if (allowed.isEmpty) return f.options;
return [
for (final o in f.options)
if (allowed.contains(o.$1)) o,
];
}
bool _locked(String k) => _readOnly || _field(k).locked;
bool get _enabled => _editingOrg ? (_view?.orgEnabled ?? false) : (_view?.enabled ?? false);
String _sourceLabel(String k) {
const map = {"global": "sourceGlobal", "org": "sourceOrg", "user": "sourceUser"};
final key = map[_field(k).source] ?? "sourceGlobal";
return t("settings.integrations.inheritedFrom",
params: {"source": t("settings.integrations.$key")});
}
// Load the editable inputs from the current scope's own-layer values. Locked
// fields and the secret password are never prefilled.
void _fillForm() {
for (final f in _c.fields) {
if (f.type == _FieldType.multiselect) {
// A hide-list is this layer's own, not something inherited, so it comes
// from the scope rather than from a cascaded field.
_multi[f.key] = {...?f.scopeValue?.call(_scopeData)};
continue;
}
final field = _field(f.key);
String value;
if (f.type == _FieldType.password) {
value = "";
} else if (f.showEffectiveWhenLocked && field.locked) {
value = field.effective.isNotEmpty ? field.effective : f.defaultValue;
} else if (field.locked) {
value = f.defaultValue;
} else {
value = field.own.isNotEmpty ? field.own : f.defaultValue;
}
if (f.type == _FieldType.select) {
// A stored mode the layers above have since hidden is no longer on
// offer, so the picker starts from the default instead of showing a
// choice that would not take effect anyway.
final options = _optionsFor(f);
_selects[f.key] = options.any((o) => o.$1 == value) ? value : f.defaultValue;
} else {
_controllers[f.key]!.text = value;
}
}
}
Future<void> _load() async {
try {
final v = await _c.load();
if (!mounted) return;
setState(() {
_view = v;
if (_scope == "org" && !v.canEditOrg) _scope = "user";
});
_fillForm();
} catch (e) {
if (mounted) setState(() => _error = "$e");
}
}
void _applyView(IntegrationView v) {
setState(() {
_view = v;
if (_scope == "org" && !v.canEditOrg) _scope = "user";
});
_fillForm();
}
Future<void> _toggle(bool value) async {
setState(() => _error = null);
try {
_applyView(await _c.save({"scope": _scopeKey, "enabled": value}));
} catch (e) {
if (mounted) setState(() => _error = "$e");
}
}
Future<void> _saveSettings() async {
setState(() {
_error = null;
_saving = true;
_saved = false;
});
final config = <String, dynamic>{};
for (final f in _shownFields) {
if (_locked(f.key)) continue;
final String val;
switch (f.type) {
case _FieldType.multiselect:
// The server stores a hide-list as the same comma-separated string the
// API Server panel writes, so both layers round-trip identically.
val = _optionsFor(f).map((o) => o.$1).where(_multi[f.key]!.contains).join(",");
case _FieldType.select:
val = _selects[f.key] ?? "";
default:
val = _controllers[f.key]!.text;
}
if (f.type == _FieldType.password && val.isEmpty) continue;
config[f.key] = val;
}
try {
_applyView(await _c.save({"scope": _scopeKey, "config": config}));
if (!mounted) return;
setState(() => _saved = true);
Future.delayed(const Duration(seconds: 2), () {
if (mounted) setState(() => _saved = false);
});
} catch (e) {
if (mounted) setState(() => _error = "$e");
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _test() async {
setState(() {
_error = null;
_health = null;
_testing = true;
});
try {
final h = await _c.test();
if (mounted) setState(() => _health = h);
} catch (e) {
if (mounted) setState(() => _error = "$e");
} finally {
if (mounted) setState(() => _testing = false);
}
}
void _switchScope(String sc) {
setState(() {
_scope = sc;
_error = null;
_saved = false;
_health = null;
});
_fillForm();
}
@override
Widget build(BuildContext context) {
final view = _view;
final muted = DriverVault.muted(context);
return Container(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
),
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header — tap to fold/unfold.
InkWell(
onTap: () => setState(() => _open = !_open),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t(_c.nameKey), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 2),
Text(t(_c.descKey), style: TextStyle(fontSize: 12, color: muted)),
],
),
),
const SizedBox(width: 8),
if (view != null && !_editingOrg) ...[
_ConnBadge(connected: view.enabled),
const SizedBox(width: 8),
],
AnimatedRotation(
turns: _open ? 0.5 : 0,
duration: const Duration(milliseconds: 150),
child: Icon(Icons.keyboard_arrow_down, color: muted),
),
],
),
),
if (_open) ...[
if (view == null)
const Padding(padding: EdgeInsets.only(top: 12), child: LinearProgressIndicator())
else
..._body(view),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_error!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)),
),
],
],
),
);
}
List<Widget> _body(IntegrationView view) {
// Master / org gates.
if (!view.available) {
return [_warn(t("settings.integrations.unavailable"))];
}
if (view.orgId.isNotEmpty && !view.orgEnabled && !_editingOrg) {
return [_warn(t("settings.integrations.orgDisabled"))];
}
final muted = DriverVault.muted(context);
return [
// Scope switch (org admins only).
if (view.canEditOrg)
Padding(
padding: const EdgeInsets.only(top: 14),
child: SegmentedButton<String>(
segments: [
ButtonSegment(value: "user", label: Text(t("settings.integrations.scopeMy"))),
ButtonSegment(value: "org", label: Text(t("settings.integrations.scopeOrg"))),
],
selected: {_scope},
onSelectionChanged: (s) => _switchScope(s.first),
),
),
if (_editingOrg)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(t("settings.integrations.scopeHint"), style: TextStyle(fontSize: 12, color: muted)),
),
if (_readOnly)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(t("settings.integrations.readOnly"), style: TextStyle(fontSize: 12, color: muted)),
),
// Enable toggle.
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(children: [
SizedBox(
width: 40,
height: 40,
child: Checkbox(
value: _enabled,
onChanged: (v) => _toggle(v ?? false),
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
_editingOrg ? t("settings.integrations.enableOrg") : t("settings.integrations.enable"),
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
),
]),
),
// Credential fields.
for (final f in _shownFields) ...[
const SizedBox(height: 12),
_fieldWidget(f),
],
// Save + test.
const SizedBox(height: 16),
Row(children: [
if (!_readOnly) ...[
FilledButton(
onPressed: _saving ? null : _saveSettings,
child: Text(_saving
? t("common.saving")
: _saved
? t("settings.integrations.saved")
: t("settings.integrations.save")),
),
const SizedBox(width: 8),
],
OutlinedButton(
onPressed: _testing ? null : _test,
child: Text(_testing ? t("settings.integrations.testing") : t("settings.integrations.test")),
),
]),
if (_health != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_health!.detail.isNotEmpty ? _health!.detail : _health!.status,
style: TextStyle(
fontSize: 13,
color: _health!.status == "ok"
? DriverVault.success
: _health!.status == "degraded"
? DriverVault.warning
: DriverVault.danger,
),
),
),
// The chargers on the account. In an OCPP mode the list lives inside the
// provisioning card below, where picking one fills the serial; Modbus has
// no such card, so there it stays out here.
if (_c.anker &&
view.enabled &&
(view.controlMode == "off" ||
view.controlMode == "modbus" ||
view.controlMode == "mqtt"))
const Padding(padding: EdgeInsets.only(top: 16), child: _AnkerChargers()),
// Modbus is reached rather than provisioned: what it needs is the
// charger's own address, which is asked for beside the controls. The cloud
// mode asks for nothing beyond the account above — which is the whole
// point of it — so it says so rather than showing an empty setup card.
if (_c.anker && view.enabled && view.controlMode == "modbus")
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(t("settings.integrations.controlModbusHint"),
style: TextStyle(fontSize: 12, color: muted)),
),
if (_c.anker && view.enabled && view.controlMode == "mqtt")
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(t("settings.integrations.controlCloudHint"),
style: TextStyle(fontSize: 12, color: muted)),
),
// OCPP control provisioning (Anker only, in the modes the charger dials).
if (_c.anker && (view.controlMode == "own" || view.controlMode == "proxy"))
Padding(
padding: const EdgeInsets.only(top: 16),
child: _AnkerControlProvision(controlMode: view.controlMode),
),
];
}
Widget _warn(String text) => Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(text, style: const TextStyle(fontSize: 13, color: DriverVault.warning)),
);
Widget _fieldWidget(_FieldSpec f) {
final muted = DriverVault.muted(context);
final locked = _locked(f.key);
final field = _field(f.key);
Widget input;
final options = _optionsFor(f);
if (f.type == _FieldType.multiselect) {
final chosen = _multi[f.key] ??= {};
input = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final o in options)
InkWell(
onTap: locked
? null
: () => setState(() {
if (!chosen.remove(o.$1)) chosen.add(o.$1);
}),
child: Row(children: [
Checkbox(
value: chosen.contains(o.$1),
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: locked
? null
: (v) => setState(() {
if (v ?? false) {
chosen.add(o.$1);
} else {
chosen.remove(o.$1);
}
}),
),
const SizedBox(width: 4),
Expanded(child: Text(t(o.$2), style: const TextStyle(fontSize: 14))),
]),
),
],
);
} else if (f.type == _FieldType.select) {
// A mode hidden above is gone from the list, so a stored value naming one
// has nothing to select — fall back to the default rather than crashing
// the dropdown on a value it does not carry.
final current = _selects[f.key] ?? f.defaultValue;
final value = options.any((o) => o.$1 == current) ? current : f.defaultValue;
input = DropdownButtonFormField<String>(
initialValue: value,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: [
for (final o in options) DropdownMenuItem(value: o.$1, child: Text(t(o.$2))),
],
onChanged: locked ? null : (v) => setState(() => _selects[f.key] = v ?? ""),
);
} else {
final isPassword = f.type == _FieldType.password;
// Masked placeholder: locked non-secret shows dots; a set password too.
// A field that shows its effective value when locked has something real to
// display, so it never gets the dots.
final showDots = isPassword
? field.effective.isNotEmpty
: locked && !f.showEffectiveWhenLocked;
// What an inherited field shows when it is empty.
//
// A locked field is standing in for a value set above the caller, and the
// server has already decided which of those may be read: it sends the
// secrets back as dots and everything else in the clear. So the
// placeholder is that effective value — the thing the field will actually
// use — and the example is for the other case, an empty box waiting to be
// filled in.
//
// The example belongs only there. A country field placeholdered "DE" under
// the words "inherited from your organization" is not a hint, it is a
// wrong answer to the question the user is asking it: which country am I
// inheriting?
final placeholder = field.locked ? field.effective : f.placeholder;
input = TextField(
controller: _controllers[f.key],
obscureText: isPassword,
enabled: !locked,
autocorrect: false,
keyboardType: f.type == _FieldType.number ? TextInputType.number : null,
maxLength: f.maxLength,
decoration: InputDecoration(
border: const OutlineInputBorder(),
isDense: true,
counterText: "",
hintText: showDots ? "••••••••" : placeholder,
),
);
}
// The hint below the field: an "inherited from …" note when locked, else the
// field's own hint (and the password-keep note for password fields).
String? hint;
Color hintColor = muted;
if (field.locked) {
hint = _sourceLabel(f.key);
} else if (f.type == _FieldType.password) {
hint = t("settings.integrations.passwordKeep");
} else if (f.hintKey != null) {
hint = t(f.hintKey!);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t(f.labelKey), style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
input,
if (hint != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(hint, style: TextStyle(fontSize: 12, color: hintColor)),
),
],
);
}
}
/// The small green/amber Connected / Not connected pill in a card header.
class _ConnBadge extends StatelessWidget {
final bool connected;
const _ConnBadge({required this.connected});
@override
Widget build(BuildContext context) {
final dark = DriverVault.isDark(context);
final fg = connected ? DriverVault.success : DriverVault.warning;
final bg = connected
? (dark ? DriverVault.successSoftDark : DriverVault.successSoft)
: (dark ? DriverVault.warningSoftDark : DriverVault.warningSoft);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(999)),
child: Text(
connected ? t("settings.integrations.connected") : t("settings.integrations.notConnected"),
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: fg),
),
);
}
}
/// The chargers on the linked Anker account. It answers the first question an
/// owner has after entering their credentials — did it find my chargers? — and,
/// where a control mode is active, hands the provisioning card the serial rather
/// than asking anyone to read it off the wall.
class _AnkerChargers extends StatefulWidget {
/// Called with a serial when a charger is picked; null hides the pick action
/// (nothing to fill in when control is off).
final void Function(String sn)? onPick;
const _AnkerChargers({this.onPick});
@override
State<_AnkerChargers> createState() => _AnkerChargersState();
}
class _AnkerChargersState extends State<_AnkerChargers> {
List<AnkerCharger> _chargers = const [];
String _detail = ""; // the server's reason for an empty list
bool _loading = false;
bool _loaded = false;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_error = null;
_loading = true;
});
try {
final res = await apiClient.listAnkerChargers();
if (mounted) {
setState(() {
_chargers = res.chargers;
_detail = res.detail;
_loaded = true;
});
}
} catch (e) {
if (mounted) {
setState(() {
_chargers = const [];
_error = "$e";
});
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
/// The cloud names a state in its own words (charging, standby, …); translate
/// it, falling back to the readable slug for a state we have no wording for.
String _stateLabel(String slug) {
final key = "settings.integrations.states.$slug";
final label = t(key);
return label == key ? slug.replaceAll("_", " ") : label;
}
@override
Widget build(BuildContext context) {
final muted = DriverVault.muted(context);
final dark = DriverVault.isDark(context);
final sunken = dark ? DriverVault.darkSunken : DriverVault.ink25;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: sunken,
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Expanded(
child: Text(t("settings.integrations.chargersTitle"),
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
),
TextButton(
onPressed: _loading ? null : _load,
child: Text(_loading
? t("settings.integrations.chargersLoading")
: t("settings.integrations.chargersRefresh")),
),
]),
Text(t("settings.integrations.chargersHint"), style: TextStyle(fontSize: 12, color: muted)),
for (final c in _chargers)
Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: dark ? DriverVault.darkCard : Colors.white,
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(c.name.isNotEmpty ? c.name : t("settings.integrations.chargerUnnamed"),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
Text(
[
c.sn,
if (c.model.isNotEmpty) c.model,
if (c.firmware.isNotEmpty) c.firmware,
if (c.siteName.isNotEmpty) c.siteName,
].join(" · "),
style: DriverVault.mono(context, size: 11).copyWith(color: muted),
),
// Reachability, said either way. A charger the cloud does
// not report on at all (no online field) says nothing —
// silence there is unknown, not online.
if (c.online != null)
Text(
c.online == true
? t("settings.integrations.chargerOnline")
: t("settings.integrations.chargerOffline"),
style: TextStyle(
fontSize: 12,
color: c.online == true ? DriverVault.success : DriverVault.warning,
),
),
if (c.statusDesc.isNotEmpty)
Text(
_stateLabel(c.statusDesc),
style: TextStyle(
fontSize: 12,
color: c.statusDesc == "charging" ? DriverVault.success : muted,
),
),
],
),
),
if (widget.onPick != null)
TextButton(
onPressed: () => widget.onPick!(c.sn),
child: Text(t("settings.integrations.chargerUse")),
),
],
),
),
if (_chargers.isEmpty && _loaded)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_detail.isNotEmpty ? _detail : t("settings.integrations.chargersEmpty"),
style: TextStyle(fontSize: 13, color: muted)),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_error!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)),
),
],
),
);
}
}
/// Anker Solix OCPP control provisioning — per-charger token + connection status.
/// Only shown when a control mode (own/proxy) is active for the user.
class _AnkerControlProvision extends StatefulWidget {
final String controlMode; // "own" | "proxy"
const _AnkerControlProvision({required this.controlMode});
@override
State<_AnkerControlProvision> createState() => _AnkerControlProvisionState();
}
class _AnkerControlProvisionState extends State<_AnkerControlProvision> {
final _serial = TextEditingController();
AnkerControl? _ctl;
bool _loading = false;
String? _error;
String _newToken = ""; // freshly generated token, shown once
@override
void dispose() {
_serial.dispose();
super.dispose();
}
Future<void> _loadControl() async {
final sn = _serial.text.trim();
if (sn.isEmpty) return;
setState(() {
_error = null;
_loading = true;
});
try {
final c = await apiClient.getAnkerControl(sn);
if (mounted) setState(() => _ctl = c);
} catch (e) {
if (mounted) setState(() => _error = "$e");
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _generate() async {
final sn = _serial.text.trim();
if (sn.isEmpty) return;
setState(() {
_error = null;
_newToken = "";
});
try {
// The token is returned exactly once — capture it here to show the operator.
final token = await apiClient.ankerControlToken(sn);
if (mounted) setState(() => _newToken = token);
await _loadControl();
} catch (e) {
if (mounted) setState(() => _error = "$e");
}
}
Future<void> _revoke() async {
final sn = _serial.text.trim();
if (sn.isEmpty) return;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
content: Text(t("settings.integrations.controlRevokeConfirm")),
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("settings.integrations.controlRevoke")),
),
],
),
);
if (ok != true) return;
setState(() {
_error = null;
_newToken = "";
});
try {
await apiClient.ankerControlRevoke(sn);
await _loadControl();
} catch (e) {
if (mounted) setState(() => _error = "$e");
}
}
@override
Widget build(BuildContext context) {
final muted = DriverVault.muted(context);
final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink25;
final ctl = _ctl;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: sunken,
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t("settings.integrations.controlTitle"),
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 2),
Text(
widget.controlMode == "own"
? t("settings.integrations.controlOwnHint")
: t("settings.integrations.controlProxyHint"),
style: TextStyle(fontSize: 12, color: muted),
),
const SizedBox(height: 12),
_AnkerChargers(onPick: (sn) {
setState(() => _serial.text = sn);
_loadControl();
}),
const SizedBox(height: 12),
Text(t("settings.integrations.chargerSerial"),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
TextField(
controller: _serial,
autocorrect: false,
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
hintText: "A5191-XXXXXXXX",
),
),
const SizedBox(height: 10),
Wrap(spacing: 8, runSpacing: 8, children: [
OutlinedButton(
onPressed: _loading ? null : _loadControl,
child: Text(_loading ? t("settings.integrations.testing") : t("settings.integrations.controlCheck")),
),
FilledButton(onPressed: _generate, child: Text(t("settings.integrations.controlGenerate"))),
if (ctl != null && ctl.hasToken)
OutlinedButton(
onPressed: _revoke,
style: OutlinedButton.styleFrom(foregroundColor: DriverVault.danger),
child: Text(t("settings.integrations.controlRevoke")),
),
]),
// The token is shown exactly once, right after generation.
if (_newToken.isNotEmpty)
Container(
margin: const EdgeInsets.only(top: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: DriverVault.isDark(context) ? DriverVault.warningSoftDark : DriverVault.warningSoft,
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
border: Border.all(color: DriverVault.warning.withValues(alpha: 0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t("settings.integrations.controlTokenOnce"),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: DriverVault.warning)),
const SizedBox(height: 4),
SelectableText(_newToken, style: DriverVault.mono(context, size: 13)),
],
),
),
if (ctl != null) ...[
const SizedBox(height: 12),
_kv(t("settings.integrations.controlEndpoint"), ctl.endpoint, mono: true),
if (ctl.hasToken) _kv(t("settings.integrations.controlToken"), "••••${ctl.tokenHint}", mono: true),
const SizedBox(height: 8),
Row(children: [
_ConnBadgeControl(connected: ctl.connected),
if (ctl.connectorStatus.isNotEmpty) ...[
const SizedBox(width: 8),
Text(ctl.connectorStatus, style: TextStyle(fontSize: 13, color: muted)),
],
]),
const SizedBox(height: 8),
Text(t("settings.integrations.controlProvisionSteps"),
style: TextStyle(fontSize: 12, color: muted)),
],
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_error!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)),
),
],
),
);
}
Widget _kv(String label, String value, {bool mono = false}) {
final muted = DriverVault.muted(context);
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: RichText(
text: TextSpan(
style: TextStyle(fontSize: 13, color: muted),
children: [
TextSpan(text: "$label: "),
TextSpan(
text: value,
style: mono
? DriverVault.mono(context, size: 13)
: TextStyle(fontSize: 13, color: DefaultTextStyle.of(context).style.color),
),
],
),
),
);
}
}
/// The Connected / Not connected pill for the OCPP control backend.
class _ConnBadgeControl extends StatelessWidget {
final bool connected;
const _ConnBadgeControl({required this.connected});
@override
Widget build(BuildContext context) {
final dark = DriverVault.isDark(context);
final fg = connected ? DriverVault.success : DriverVault.warning;
final bg = connected
? (dark ? DriverVault.successSoftDark : DriverVault.successSoft)
: (dark ? DriverVault.warningSoftDark : DriverVault.warningSoft);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(999)),
child: Text(
connected
? t("settings.integrations.controlConnected")
: t("settings.integrations.controlDisconnected"),
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: fg),
),
);
}
}