Brings the Flutter app to parity with the recent Web App changes, which touched features the Phone App did not yet have — so this builds the Charging and Integrations subsystems, then applies the tab/fold structure. Charging (new nav destination): split into "Public chargers" (stylized discovery map + demo session + nearby public stations) and "Home chargers" (the real Anker Solix OCPP control card — serial refresh, connector/energy tiles, start/stop, current limit, password step-up on reset — plus the user's home charger list). Gated by the per-user control mode, degrading to a Settings hint when off. Settings: split into "Personal settings" (the existing account/appearance/ profile/security/privacy/danger sections) and "Integrations". The latter holds foldable Toyota and Anker Solix cards over the superadmin -> org -> user cascade: locked fields with "inherited from" notes, org-admin scope switch, enable toggle, save/test with health result, and Anker OCPP token provisioning. Both tabs stay mounted (IndexedStack) so in-flight edits survive a switch. Adds the integration + OCPP control endpoints to api.dart, the resolved IntegrationView/Scope/Field, IntegrationHealth and AnkerControl models, and charging.*/settings.tabs.*/nav.charging strings (en/pl/da) plus settings.integrations.* (en) — mirroring the Web App's own pl/da coverage, which leaves integrations and charger control untranslated as an English fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1832 lines
63 KiB
Dart
1832 lines
63 KiB
Dart
import "dart:typed_data";
|
|
|
|
import "package:flutter/material.dart";
|
|
import "package:image_picker/image_picker.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 (minus data export/import):
|
|
/// account (name/email/password), appearance (theme/locale/date/font), profile
|
|
/// (avatar/bio), privacy (sessions), 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);
|
|
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),
|
|
const _PrivacySection(),
|
|
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,
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- 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)),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- 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),
|
|
),
|
|
);
|
|
}
|
|
}
|