Four rounds of web-app features never reached the phone: fuel, maintenance, document and reminder tracking; attachments; the currency setting and the locale split; and technical check history. The README claimed full parity throughout, so the gap was invisible. Catch the phone up, mirroring the web components field for field. Car detail grows the web app's tabs, in its order: technical checks, maintenance, fuel (with the summary panel), documents and reminders, beside the existing service and parts lists. The derived figures are the server's and are rendered as "—" wherever it sent null — a window with a missed fill has no consumption, and a plausible-looking 0.0 there would be a lie. Attachments hang off service records, technical checks, workshop visits, refills, documents and parts on identical terms, so one field and one apply helper cover all six rather than being copied per form. As on the web, the form only collects intent: the file endpoints address a record that must already exist, so a create-with-file is two calls, and a failure on the second reports as an attachment error because the metadata is committed. Two bugs fixed on the way: - _carPayload omitted technicalCheckIntervalDays. The API rewrites every column from the body, so any car edit — including the one-tap odometer update — silently zeroed the car's inspection interval. - main() never called initializeDateFormatting, so month names ignored the chosen language that the new Language picker exists to set. Luxembourgish and Romansh are deliberately left off the language list: intl ships no symbols for them and throws rather than falling back, which would take out every date on screen. The browser has full ICU data and has no such limit, so the web app can offer them. The server only validates a locale's shape, so an unrenderable tag can still arrive from the web; format.dart resolves through a supported-language check and falls back to en-US. Labels for the language/region/currency lists are hand-kept because Dart has no Intl.DisplayNames. The lists mirror validCurrencies in me.go. file_picker is pinned to ^10: v8 compiles against android-34, which no longer builds against the other plugins' compileSdk requirement of 36. Adds the project's first test, covering the parts that fail silently rather than loudly — null derived fields, the badge wording, and the locale guard. The phone was not authorized over ADB, so the UI was not exercised on a device: this is analyzer-, test- and build-clean, and every JSON field name and route was cross-checked against models.go and server.go. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
969 lines
34 KiB
Dart
969 lines
34 KiB
Dart
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;
|
|
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);
|
|
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: 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),
|
|
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<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 ------------------------------------------------------------
|
|
|
|
// 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: "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", style: 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),
|
|
),
|
|
const Padding(
|
|
padding: EdgeInsets.only(top: 4),
|
|
child: Text("Names of months and days.",
|
|
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Text("Region", style: 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),
|
|
),
|
|
const Padding(
|
|
padding: EdgeInsets.only(top: 4),
|
|
child: Text("Number and currency layout.",
|
|
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Text("Currency", style: 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(
|
|
"Example: ${formatMoney(1234.5)}. Amounts are display-only — nothing is converted.",
|
|
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
|
),
|
|
),
|
|
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 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: "Privacy & security",
|
|
children: [
|
|
const Text(
|
|
"Two-factor authentication isn't available yet. Sessions are held as "
|
|
"server-issued tokens that expire on their own, so signing out ends "
|
|
"this device's session only. To lock out every device, change your "
|
|
"password above.",
|
|
style: TextStyle(color: Colors.grey, fontSize: 13),
|
|
),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: TextButton(
|
|
onPressed: () => authService.logout(),
|
|
child: const Text("Sign 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"),
|
|
),
|
|
]),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|