Both READMEs claimed web parity with data export/import as the only
omission. That was three gaps out of date: the car screen had no
connected-service tab, no per-car charging-cost tab, and no way to say what
a car's page shows - all three of which the web has had since the car view
became a property of the car rather than of the browser.
The tab bar was the thing blocking the rest. It was a fixed list of eight
Tab(text: "Information") literals, so it could neither grow a tab nor read
an arrangement, and it sat outside the translation system that the rest of
the app has used since b6bb6b1. It now builds from the car's own tabOrder
and hiddenTabs, and labels come from car.tabs.* like the web's.
Rather than retype four subtrees of strings in three languages, the shared
ones - car.*, settings.advanced, forms.charging, forms.import and the
common keys the phone was missing - are copied out of the Web App's own
language files, with the phone's existing wording winning every collision.
Polish and Danish therefore arrive complete and cannot drift between the
two apps. Only five strings are genuinely phone-only: the reorder hint, the
saved-file message, the open action and two validation lines.
The connected-service tab mirrors ProviderPanel: headline readings, the
offer to take a provider odometer that is ahead of the stored one, the
vehicle record, and one collapsible card per capability, rendered from the
server's flattened key/value pairs so a provider adding a field surfaces it
without touching this app. Two deliberate differences. The raw-payload
disclosure is dropped - Toyota's eight sections are megabytes of JSON on a
phone screen, and the flattened fields carry the same content. And the
readings cannot be dragged here, though a stored metricOrder is still
honoured, so an arrangement made on the web carries over.
The view picker takes the same line on gestures. The web rearranges by
dragging the tab bar itself and the Information rows themselves; on a touch
screen that gesture belongs to the tab bar, so both arrangements are made
in the picker with a handle instead, and hiddenTabs, hiddenFields, tabOrder
and fieldOrder all save in one PUT. The key catalogues live in
car_view_sheet.dart and mirror hideableCarTabs / arrangeableCarTabs /
hideableCarFields in cars.go, because the server rejects anything else.
arrangeKeys applies a partial stored order the way the API documents it: an
unknown key is dropped and an unnamed one follows the arranged ones, which
is what puts a tab added in a later release at the end of somebody's page
rather than the middle of it.
Charging cost is the electric twin of Fuel and is built as one - the same
stats panel, tile and form shape, measured between full charges. It is the
per-car cost log, not the Charging section in the bottom bar, which remains
the charger network and OCPP control.
Export and import needed a phone answer to two browser affordances. The
export is written to the app's documents directory under the filename the
server's Content-Disposition names, and offered to whatever opens JSON via
open_filex - the same route attachments already take. The import goes
through the system file picker, validates the file locally, and confirms
with the number of cars the file actually holds, because the server always
creates new records and never merges.
The one field worth calling out on the client: _carPayload still leaves the
provider link and the view arrangement out, matching carPayload in
records.go, so saving the car form cannot silently unlink a car or undo an
arrangement.
Known gap, deliberately not closed here: the older sheets in
record_form_sheets.dart and most of car_detail_screen.dart still carry
hardcoded English. Everything added here and every tab label goes through
t(), but translating the rest of the car screen is its own change and would
have buried this one.
Verified by flutter analyze (clean), flutter test - 13 pass, 6 of them new,
covering arrangeKeys against partial, unknown and duplicate keys, the
charging models keeping uncomputed figures null rather than a plausible
zero, the new Car fields, and ProviderSnapshot parsing an unreachable
provider as an answer rather than a failure - and flutter build apk
--debug, which succeeds. The Kotlin Gradle plugin warnings in that build
are pre-existing.
Not verified: nothing was run against a live API Server or on a device, so
the new screens have not been driven end to end - only compiled, analyzed
and unit-tested.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2270 lines
78 KiB
Dart
2270 lines
78 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.user = 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,
|
|
"currency": appSettings.currency,
|
|
"fontSize": appSettings.fontSize,
|
|
};
|
|
appSettings.patch(
|
|
theme: patch["theme"],
|
|
locale: patch["locale"],
|
|
dateFormat: patch["dateFormat"],
|
|
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"],
|
|
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)),
|
|
),
|
|
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 picked = await FilePicker.platform.pickFiles(
|
|
type: FileType.custom,
|
|
allowedExtensions: const ["json"],
|
|
withData: true,
|
|
);
|
|
if (picked == null || picked.files.isEmpty) return;
|
|
final file = picked.files.first;
|
|
final bytes = file.bytes ?? (file.path != null ? await File(file.path!).readAsBytes() : null);
|
|
if (bytes == null) {
|
|
setState(() => _error = t("settings.advanced.notJson"));
|
|
return;
|
|
}
|
|
|
|
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 ------------------------------------------------------
|
|
//
|
|
// Two foldable connector cards (Toyota, Anker Solix) 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, password, select }
|
|
|
|
/// 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 selects
|
|
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;
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
/// 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,
|
|
});
|
|
}
|
|
|
|
class _IntegrationsTab extends StatelessWidget {
|
|
const _IntegrationsTab();
|
|
|
|
@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,
|
|
options: [
|
|
("off", "settings.integrations.controlOff"),
|
|
("own", "settings.integrations.controlOwn"),
|
|
("proxy", "settings.integrations.controlProxy"),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
|
|
return ListView(
|
|
padding: const EdgeInsets.all(12),
|
|
children: [
|
|
_Card(
|
|
title: t("settings.integrations.title"),
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Text(t("settings.integrations.subtitle"),
|
|
style: TextStyle(fontSize: 13, color: DriverVault.muted(context))),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_IntegrationCard(config: toyota),
|
|
const SizedBox(height: 12),
|
|
_IntegrationCard(config: anker),
|
|
],
|
|
),
|
|
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 = {};
|
|
|
|
_IntegrationConfig get _c => widget.config;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
for (final f in _c.fields) {
|
|
if (f.type != _FieldType.select) _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);
|
|
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) {
|
|
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) {
|
|
_selects[f.key] = value;
|
|
} 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 _c.fields) {
|
|
if (_locked(f.key)) continue;
|
|
final val = f.type == _FieldType.select ? (_selects[f.key] ?? "") : _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 _c.fields) ...[
|
|
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 : DriverVault.danger,
|
|
),
|
|
),
|
|
),
|
|
|
|
// OCPP control provisioning (Anker only, when a control mode is active).
|
|
if (_c.anker && view.controlMode != "off")
|
|
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;
|
|
if (f.type == _FieldType.select) {
|
|
input = DropdownButtonFormField<String>(
|
|
initialValue: _selects[f.key] ?? f.defaultValue,
|
|
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
|
items: [
|
|
for (final o in f.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.
|
|
final showDots = isPassword ? field.effective.isNotEmpty : locked;
|
|
input = TextField(
|
|
controller: _controllers[f.key],
|
|
obscureText: isPassword,
|
|
enabled: !locked,
|
|
autocorrect: false,
|
|
maxLength: f.maxLength,
|
|
decoration: InputDecoration(
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
counterText: "",
|
|
hintText: showDots ? "••••••••" : f.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),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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),
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
}
|