import "dart:typed_data"; import "package:flutter/material.dart"; import "package:image_picker/image_picker.dart"; import "../biometric.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; List _sessions = []; 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); final sessions = await apiClient.listSessions(); 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; _sessions = sessions; _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: const Text("Settings")), 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), _SessionsSection(sessions: _sessions, onChanged: _load, snack: _snack), 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("Name saved."); } catch (e) { widget.snack("$e"); } finally { if (mounted) setState(() => _savingName = false); } } Future _sendVerification() async { setState(() => _verifying = true); try { await apiClient.requestVerification(); widget.snack("Verification email requested."); } catch (e) { widget.snack("$e"); } finally { if (mounted) setState(() => _verifying = false); } } Future _savePassword() async { setState(() => _pwError = null); if (_newPw.text.length < 8) { setState(() => _pwError = "New password must be at least 8 characters."); return; } if (_newPw.text != _confirmPw.text) { setState(() => _pwError = "New password and confirmation don't match."); return; } setState(() => _savingPw = true); try { await apiClient.changePassword(_oldPw.text, _newPw.text); _oldPw.clear(); _newPw.clear(); _confirmPw.clear(); widget.snack("Password updated."); } catch (e) { setState(() => _pwError = e.toString()); } finally { if (mounted) setState(() => _savingPw = false); } } @override Widget build(BuildContext context) { final p = widget.profile; return _Card( title: "Account", children: [ const Text("Name", style: 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 ? "Saving…" : "Save"), ), ]), const SizedBox(height: 16), const Text("Email", style: 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 ? "Verified" : "Not verified", 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 ? "Sending…" : "Resend verification email"), ), ), const Divider(height: 24), const Text("Change password", style: TextStyle(fontWeight: FontWeight.w500)), const SizedBox(height: 8), TextField( controller: _oldPw, obscureText: true, decoration: const InputDecoration( labelText: "Current password", border: OutlineInputBorder(), isDense: true), ), const SizedBox(height: 8), TextField( controller: _newPw, obscureText: true, decoration: const InputDecoration( labelText: "New password (min 8)", border: OutlineInputBorder(), isDense: true), ), const SizedBox(height: 8), TextField( controller: _confirmPw, obscureText: true, decoration: const InputDecoration( labelText: "Confirm new password", border: 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 ? "Updating…" : "Update password"), ), ), ], ); } } // --- Appearance ------------------------------------------------------------ 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, "fontSize": appSettings.fontSize, }; appSettings.patch( theme: patch["theme"], locale: patch["locale"], dateFormat: patch["dateFormat"], fontSize: patch["fontSize"], ); setState(() {}); try { await apiClient.updateMe(patch); } catch (e) { appSettings.patch( theme: prev["theme"], locale: prev["locale"], dateFormat: prev["dateFormat"], fontSize: prev["fontSize"], ); setState(() {}); widget.onError("$e"); } } @override Widget build(BuildContext context) { return _Card( title: "Appearance", children: [ const Text("Theme", style: TextStyle(fontWeight: FontWeight.w500)), const SizedBox(height: 6), SegmentedButton( segments: const [ ButtonSegment(value: "light", label: Text("Light")), ButtonSegment(value: "dark", label: Text("Dark")), ButtonSegment(value: "system", label: Text("System")), ], selected: {appSettings.theme}, onSelectionChanged: (s) => _save({"theme": s.first}), ), const SizedBox(height: 16), const Text("Language & region", style: TextStyle(fontWeight: FontWeight.w500)), const SizedBox(height: 6), DropdownButtonFormField( initialValue: appSettings.locale, decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true), items: const [ DropdownMenuItem(value: "en-US", child: Text("English (US)")), DropdownMenuItem(value: "en-GB", child: Text("English (UK)")), DropdownMenuItem(value: "pl-PL", child: Text("Polski")), DropdownMenuItem(value: "de-DE", child: Text("Deutsch")), DropdownMenuItem(value: "fr-FR", child: Text("Français")), DropdownMenuItem(value: "es-ES", child: Text("Español")), ], onChanged: (v) => v == null ? null : _save({"locale": v}), ), const SizedBox(height: 16), const Text("Date format", style: 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("Example: ${formatDate(DateTime.now())}", style: const TextStyle(color: Colors.grey, fontSize: 12)), ), const SizedBox(height: 16), const Text("Font size", style: TextStyle(fontWeight: FontWeight.w500)), const SizedBox(height: 6), SegmentedButton( segments: const [ ButtonSegment(value: "small", label: Text("Small")), ButtonSegment(value: "medium", label: Text("Medium")), ButtonSegment(value: "large", label: Text("Large")), ], 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("Bio saved."); } 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: "Profile", 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 ? "Working…" : "Upload photo"), ), if (p.hasAvatar) OutlinedButton( onPressed: _avatarBusy ? null : _removeAvatar, child: const Text("Remove"), ), ]), ]), const SizedBox(height: 16), const Text("Bio", style: TextStyle(fontWeight: FontWeight.w500)), const SizedBox(height: 4), TextField( controller: widget.bioController, maxLines: 3, decoration: const InputDecoration( border: OutlineInputBorder(), hintText: "A short note visible to other people in your household.", ), ), const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, child: OutlinedButton( onPressed: _savingBio ? null : _saveBio, child: Text(_savingBio ? "Saving…" : "Save bio"), ), ), ], ); } } // --- 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 "face or fingerprint"; if (_hasFace) return "face recognition"; if (_hasFingerprint) return "fingerprint"; return "biometrics"; } 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("Biometric sign-in turned off"); 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("Biometric sign-in enabled"); } catch (e) { if (mounted) setState(() => _busy = false); widget.snack("Could not enable: ${e.toString()}"); } } Future _promptPassword() { final ctrl = TextEditingController(); return showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text("Confirm your password"), content: TextField( controller: ctrl, obscureText: true, autofocus: true, decoration: const InputDecoration( labelText: "Password", border: OutlineInputBorder(), ), onSubmitted: (v) => Navigator.pop(ctx, v), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancel")), FilledButton(onPressed: () => Navigator.pop(ctx, ctrl.text), child: const Text("Confirm")), ], ), ); } @override Widget build(BuildContext context) { return _Card( title: "Security", children: [ if (!_loaded) const Padding( padding: EdgeInsets.symmetric(vertical: 8), child: Text("Checking device…", style: TextStyle(color: Colors.grey)), ) else ...[ SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text("Biometric sign-in"), subtitle: Text(_available ? "Sign in with $_methodLabel instead of your password." : "No biometrics are enrolled on this device."), value: _enabled, onChanged: (!_available || _busy) ? null : _toggle, ), ], ], ); } } // --- Privacy & security (sessions) ----------------------------------------- class _SessionsSection extends StatefulWidget { final List sessions; final Future Function() onChanged; final void Function(String) snack; const _SessionsSection({required this.sessions, required this.onChanged, required this.snack}); @override State<_SessionsSection> createState() => _SessionsSectionState(); } class _SessionsSectionState extends State<_SessionsSection> { Future _revoke(Session s) async { try { await apiClient.revokeSession(s.id); if (s.current) { await authService.logout(); return; } await widget.onChanged(); } catch (e) { widget.snack("$e"); } } Future _revokeOthers() async { try { await apiClient.revokeOtherSessions(); await widget.onChanged(); } catch (e) { widget.snack("$e"); } } @override Widget build(BuildContext context) { return _Card( title: "Privacy & security", children: [ const Text( "Two-factor authentication isn't available yet. Active sessions below reflect every device currently signed in.", style: TextStyle(color: Colors.grey, fontSize: 13), ), if (widget.sessions.length > 1) Align( alignment: Alignment.centerLeft, child: TextButton( onPressed: _revokeOthers, child: const Text("Log out all other devices"), ), ), ...widget.sessions.map((s) => Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Flexible( child: Text(s.deviceLabel, style: const TextStyle(fontWeight: FontWeight.w500))), if (s.current) Container( margin: const EdgeInsets.only(left: 6), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: DriverVault.brandTint(context), borderRadius: BorderRadius.circular(999), ), child: Text("This device", style: TextStyle(fontSize: 11, color: DriverVault.brandOnTint(context))), ), ]), Text("${s.ip} · signed in ${formatDate(s.created)}", style: const TextStyle(color: Colors.grey, fontSize: 12)), ], ), ), TextButton( onPressed: () => _revoke(s), child: const Text("Log out", style: 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("Type your email to confirm."); 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: const Text("Delete account?"), content: const Text("This permanently deletes your account. This cannot be undone."), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")), FilledButton( style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), onPressed: () => Navigator.pop(ctx, true), child: const Text("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: "Danger zone", titleColor: DriverVault.danger, children: [ if (!_pending) ...[ const Text( "Deleting your account removes your login and profile. It does not delete your household's shared cars or service history. There's a 3-day cooldown before deletion is final, and you can cancel any time before then.", style: TextStyle(fontSize: 13), ), const SizedBox(height: 8), if (!_showConfirm) OutlinedButton( onPressed: () => setState(() => _showConfirm = true), style: OutlinedButton.styleFrom(foregroundColor: DriverVault.danger), child: const Text("Delete my account"), ) else ...[ Text("Type ${p.email} to confirm", 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: const Text("Cancel"), ), const SizedBox(width: 8), FilledButton( style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), onPressed: _busy ? null : _request, child: Text(_busy ? "Requesting…" : "Request deletion"), ), ]), ], ] else ...[ Text( "Account deletion requested on ${formatDate(p.deletionRequestedAt)}. " "${_cooldownElapsed ? 'The cooldown has passed. You can now finalize the deletion.' : 'You can still cancel — it becomes permanent after the 3-day cooldown.'}", style: const TextStyle(fontSize: 13), ), const SizedBox(height: 8), Row(children: [ OutlinedButton(onPressed: _cancel, child: const Text("Cancel deletion request")), const SizedBox(width: 8), if (_cooldownElapsed) FilledButton( style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), onPressed: _finalize, child: const Text("Permanently delete"), ), ]), ], ], ); } }