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 createState() => _SettingsScreenState(); } class _SettingsScreenState extends State { UserProfile? _profile; Uint8List? _avatar; bool _loading = true; String? _loadError; final _name = TextEditingController(); final _bio = TextEditingController(); @override void initState() { super.initState(); _load(); } @override void dispose() { _name.dispose(); _bio.dispose(); super.dispose(); } Future _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!)) : 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), ], ), ); } } /// A titled card wrapper matching the web's sectioned layout. class _Card extends StatelessWidget { final String title; final Color? titleColor; final List 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 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 _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 _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 _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 _save(Map 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 _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( 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( 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( 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( 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( 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( 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 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 _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 _removeAvatar() async { setState(() => _avatarBusy = true); try { await apiClient.deleteAvatar(); await widget.onChanged(); } catch (e) { widget.snack("$e"); } finally { if (mounted) setState(() => _avatarBusy = false); } } Future _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 _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 _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 _promptPassword() { final ctrl = TextEditingController(); return showDialog( 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 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 _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 _cancel() async { try { await apiClient.cancelAccountDeletion(); _eligibleAt = null; await widget.onChanged(); } catch (e) { widget.snack("$e"); } } Future _finalize() async { final ok = await showDialog( 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")), ), ]), ], ], ); } }