Initial commit
This commit is contained in:
@@ -0,0 +1,915 @@
|
||||
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<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
UserProfile? _profile;
|
||||
List<Session> _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<void> _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<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("Name saved.");
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
} finally {
|
||||
if (mounted) setState(() => _savingName = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<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,
|
||||
"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<String>(
|
||||
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<String>(
|
||||
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<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("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<String>(
|
||||
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<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("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<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 "face or fingerprint";
|
||||
if (_hasFace) return "face recognition";
|
||||
if (_hasFingerprint) return "fingerprint";
|
||||
return "biometrics";
|
||||
}
|
||||
|
||||
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("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<String?> _promptPassword() {
|
||||
final ctrl = TextEditingController();
|
||||
return showDialog<String>(
|
||||
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<Session> sessions;
|
||||
final Future<void> 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<void> _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<void> _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<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("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<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: 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"),
|
||||
),
|
||||
]),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user