Introduce a hand-rolled i18n layer across all three UIs, each reading its
text from per-language JSON files (English base + Polish + Danish). Nothing
in the converted screens hardcodes English any more.
- Web App (Vue): src/i18n/{en,pl,da}.json + index.js exposing t()/tSplit(),
reactive to the signed-in profile locale. Every view, component, form and
the status labels in lib/format.js go through t().
- API Server panel (Vue): src/i18n/ with its own localStorage-persisted
language (the panel has no user profile) and a header language picker.
Chrome, cards, login and API section titles translated; endpoint reference
descriptions intentionally kept in English. Rebuilt embedded dist.
- Phone App (Flutter): assets/i18n/ + lib/i18n.dart loaded at startup,
driven by AppSettings.locale. Nav, login, lock, dashboard, the full
Settings panel (incl. language picker) and format.dart status labels
translated; remaining detail screens fall back to English.
Language = the language half of the existing BCP-47 locale; the region half
still drives date/number/currency formatting. Missing keys fall back to
English, and plurals use Intl.PluralRules / Intl.plural so Polish gets the
correct one/few/many forms. Settings flags languages without a translation.
Tests updated to assert the localized (Polish) status wording; all pass.
See TRANSLATIONS.md for the format and how to add a language.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
976 lines
35 KiB
Dart
976 lines
35 KiB
Dart
import "dart:typed_data";
|
|
|
|
import "package:flutter/material.dart";
|
|
import "package:image_picker/image_picker.dart";
|
|
|
|
import "../biometric.dart";
|
|
import "../i18n.dart";
|
|
import "../main.dart";
|
|
import "../models.dart";
|
|
import "../format.dart";
|
|
import "../theme.dart";
|
|
|
|
/// Full settings panel, mirroring the web Settings.vue (minus data export/import):
|
|
/// account (name/email/password), appearance (theme/locale/date/font), profile
|
|
/// (avatar/bio), privacy (sessions), and account deletion.
|
|
class SettingsScreen extends StatefulWidget {
|
|
const SettingsScreen({super.key});
|
|
@override
|
|
State<SettingsScreen> createState() => _SettingsScreenState();
|
|
}
|
|
|
|
class _SettingsScreenState extends State<SettingsScreen> {
|
|
UserProfile? _profile;
|
|
Uint8List? _avatar;
|
|
bool _loading = true;
|
|
String? _loadError;
|
|
|
|
final _name = TextEditingController();
|
|
final _bio = TextEditingController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_name.dispose();
|
|
_bio.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
setState(() {
|
|
_loading = true;
|
|
_loadError = null;
|
|
});
|
|
try {
|
|
final p = await apiClient.getMe();
|
|
appSettings.applyFromProfile(p);
|
|
Uint8List? avatar;
|
|
if (p.hasAvatar) {
|
|
final bytes = await apiClient.getAvatarBytes();
|
|
if (bytes != null) avatar = Uint8List.fromList(bytes);
|
|
}
|
|
_name.text = p.name;
|
|
_bio.text = p.bio;
|
|
setState(() {
|
|
_profile = p;
|
|
_avatar = avatar;
|
|
});
|
|
} catch (e) {
|
|
setState(() => _loadError = e.toString());
|
|
} finally {
|
|
setState(() => _loading = false);
|
|
}
|
|
}
|
|
|
|
void _snack(String msg) =>
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(t("settings.title"))),
|
|
body: _loading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: _loadError != null
|
|
? Center(child: Text(_loadError!))
|
|
: 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(t("settings.account.nameSaved"));
|
|
} catch (e) {
|
|
widget.snack("$e");
|
|
} finally {
|
|
if (mounted) setState(() => _savingName = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _sendVerification() async {
|
|
setState(() => _verifying = true);
|
|
try {
|
|
await apiClient.requestVerification();
|
|
widget.snack(t("settings.account.verificationRequested"));
|
|
} catch (e) {
|
|
widget.snack("$e");
|
|
} finally {
|
|
if (mounted) setState(() => _verifying = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _savePassword() async {
|
|
setState(() => _pwError = null);
|
|
if (_newPw.text.length < 8) {
|
|
setState(() => _pwError = t("settings.account.tooShort"));
|
|
return;
|
|
}
|
|
if (_newPw.text != _confirmPw.text) {
|
|
setState(() => _pwError = t("settings.account.mismatch"));
|
|
return;
|
|
}
|
|
setState(() => _savingPw = true);
|
|
try {
|
|
await apiClient.changePassword(_oldPw.text, _newPw.text);
|
|
_oldPw.clear();
|
|
_newPw.clear();
|
|
_confirmPw.clear();
|
|
widget.snack(t("settings.account.passwordUpdated"));
|
|
} catch (e) {
|
|
setState(() => _pwError = e.toString());
|
|
} finally {
|
|
if (mounted) setState(() => _savingPw = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final p = widget.profile;
|
|
return _Card(
|
|
title: t("settings.account.title"),
|
|
children: [
|
|
Text(t("settings.account.name"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
|
const SizedBox(height: 4),
|
|
Row(children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: widget.nameController,
|
|
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
FilledButton(
|
|
onPressed: _savingName ? null : _saveName,
|
|
child: Text(_savingName ? t("common.saving") : t("common.save")),
|
|
),
|
|
]),
|
|
const SizedBox(height: 16),
|
|
Text(t("settings.account.email"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
|
const SizedBox(height: 4),
|
|
Row(children: [
|
|
Expanded(child: Text(p.email)),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: p.verified
|
|
? (DriverVault.isDark(context) ? DriverVault.successSoftDark : DriverVault.successSoft)
|
|
: (DriverVault.isDark(context) ? DriverVault.warningSoftDark : DriverVault.warningSoft),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Text(p.verified ? t("settings.account.verified") : t("settings.account.notVerified"),
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: p.verified ? DriverVault.success : DriverVault.warning)),
|
|
),
|
|
]),
|
|
if (!p.verified)
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: TextButton(
|
|
onPressed: _verifying ? null : _sendVerification,
|
|
child: Text(_verifying ? t("settings.account.sending") : t("settings.account.resendVerification")),
|
|
),
|
|
),
|
|
const Divider(height: 24),
|
|
Text(t("settings.account.changePassword"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
controller: _oldPw,
|
|
obscureText: true,
|
|
decoration: InputDecoration(
|
|
labelText: t("settings.account.currentPassword"), border: const OutlineInputBorder(), isDense: true),
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
controller: _newPw,
|
|
obscureText: true,
|
|
decoration: InputDecoration(
|
|
labelText: t("settings.account.newPassword"), border: const OutlineInputBorder(), isDense: true),
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
controller: _confirmPw,
|
|
obscureText: true,
|
|
decoration: InputDecoration(
|
|
labelText: t("settings.account.confirmNewPassword"), border: const OutlineInputBorder(), isDense: true),
|
|
),
|
|
if (_pwError != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 6),
|
|
child: Text(_pwError!, style: const TextStyle(color: DriverVault.danger, fontSize: 13)),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: OutlinedButton(
|
|
onPressed: _savingPw ? null : _savePassword,
|
|
child: Text(_savingPw ? t("settings.account.updating") : t("settings.account.updatePassword")),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- Appearance ------------------------------------------------------------
|
|
|
|
// Language and region are two controls over the one stored BCP-47 tag, so the
|
|
// pair can be mixed freely (English in Poland, say) rather than being limited to
|
|
// the handful of combinations a single list could offer.
|
|
//
|
|
// Europe here means the sovereign states of the Council of Europe, plus Belarus,
|
|
// Russia, Vatican City and Kosovo — geographically European but not members.
|
|
// Dependencies (Gibraltar, Faroes, Åland) are left out: they are not countries.
|
|
// US stays on the region list because it was there before this became a Europe
|
|
// list. The lists mirror the web app's, minus Luxembourgish and Romansh: intl
|
|
// ships no symbols for those two and throws rather than falling back, so
|
|
// offering them would break every date on screen. The browser has full ICU data
|
|
// behind it and does not have that limit.
|
|
//
|
|
// Labels are hand-kept because Dart has no Intl.DisplayNames — languages read as
|
|
// endonyms (the name a speaker would recognise regardless of the current UI
|
|
// language), regions and currencies in English.
|
|
const _languages = [
|
|
("sq", "Shqip"), ("hy", "Հայերեն"), ("az", "Azərbaycan"), ("eu", "Euskara"),
|
|
("be", "Беларуская"), ("bs", "Bosanski"), ("bg", "Български"), ("ca", "Català"),
|
|
("hr", "Hrvatski"), ("cs", "Čeština"), ("da", "Dansk"), ("nl", "Nederlands"),
|
|
("en", "English"), ("et", "Eesti"), ("fi", "Suomi"), ("fr", "Français"),
|
|
("gl", "Galego"), ("ka", "ქართული"), ("de", "Deutsch"), ("el", "Ελληνικά"),
|
|
("hu", "Magyar"), ("is", "Íslenska"), ("ga", "Gaeilge"), ("it", "Italiano"),
|
|
("lv", "Latviešu"), ("lt", "Lietuvių"), ("mk", "Македонски"), ("mt", "Malti"),
|
|
("no", "Norsk"), ("pl", "Polski"), ("pt", "Português"), ("ro", "Română"),
|
|
("ru", "Русский"), ("sr", "Српски"), ("sk", "Slovenčina"), ("sl", "Slovenščina"),
|
|
("es", "Español"), ("sv", "Svenska"), ("tr", "Türkçe"), ("uk", "Українська"),
|
|
("cy", "Cymraeg"),
|
|
];
|
|
|
|
const _regions = [
|
|
("AD", "Andorra"), ("AL", "Albania"), ("AM", "Armenia"), ("AT", "Austria"),
|
|
("AZ", "Azerbaijan"), ("BA", "Bosnia & Herzegovina"), ("BE", "Belgium"),
|
|
("BG", "Bulgaria"), ("BY", "Belarus"), ("CH", "Switzerland"), ("CY", "Cyprus"),
|
|
("CZ", "Czechia"), ("DE", "Germany"), ("DK", "Denmark"), ("EE", "Estonia"),
|
|
("ES", "Spain"), ("FI", "Finland"), ("FR", "France"), ("GB", "United Kingdom"),
|
|
("GE", "Georgia"), ("GR", "Greece"), ("HR", "Croatia"), ("HU", "Hungary"),
|
|
("IE", "Ireland"), ("IS", "Iceland"), ("IT", "Italy"), ("LI", "Liechtenstein"),
|
|
("LT", "Lithuania"), ("LU", "Luxembourg"), ("LV", "Latvia"), ("MC", "Monaco"),
|
|
("MD", "Moldova"), ("ME", "Montenegro"), ("MK", "North Macedonia"), ("MT", "Malta"),
|
|
("NL", "Netherlands"), ("NO", "Norway"), ("PL", "Poland"), ("PT", "Portugal"),
|
|
("RO", "Romania"), ("RS", "Serbia"), ("RU", "Russia"), ("SE", "Sweden"),
|
|
("SI", "Slovenia"), ("SK", "Slovakia"), ("SM", "San Marino"), ("TR", "Türkiye"),
|
|
("UA", "Ukraine"), ("US", "United States"), ("VA", "Vatican City"), ("XK", "Kosovo"),
|
|
];
|
|
|
|
/// Mirrors validCurrencies in the API's me.go and the users.currency select in
|
|
/// setup-pocketbase.mjs — all three have to list the same codes.
|
|
const _currencies = [
|
|
("EUR", "Euro"), ("GBP", "British pound"), ("CHF", "Swiss franc"),
|
|
("PLN", "Polish złoty"), ("CZK", "Czech koruna"), ("HUF", "Hungarian forint"),
|
|
("RON", "Romanian leu"), ("BGN", "Bulgarian lev"), ("DKK", "Danish krone"),
|
|
("SEK", "Swedish krona"), ("NOK", "Norwegian krone"), ("ISK", "Icelandic króna"),
|
|
("ALL", "Albanian lek"), ("AMD", "Armenian dram"), ("AZN", "Azerbaijani manat"),
|
|
("BAM", "Bosnia-Herzegovina mark"), ("BYN", "Belarusian ruble"),
|
|
("GEL", "Georgian lari"), ("MDL", "Moldovan leu"), ("MKD", "Macedonian denar"),
|
|
("RSD", "Serbian dinar"), ("RUB", "Russian ruble"), ("TRY", "Turkish lira"),
|
|
("UAH", "Ukrainian hryvnia"), ("USD", "US dollar"), ("CAD", "Canadian dollar"),
|
|
("AUD", "Australian dollar"), ("JPY", "Japanese yen"),
|
|
];
|
|
|
|
class _AppearanceSection extends StatefulWidget {
|
|
final void Function(String) onError;
|
|
const _AppearanceSection({required this.onError});
|
|
@override
|
|
State<_AppearanceSection> createState() => _AppearanceSectionState();
|
|
}
|
|
|
|
class _AppearanceSectionState extends State<_AppearanceSection> {
|
|
Future<void> _save(Map<String, dynamic> patch) async {
|
|
// Optimistic: apply locally first, roll back on failure.
|
|
final prev = {
|
|
"theme": appSettings.theme,
|
|
"locale": appSettings.locale,
|
|
"dateFormat": appSettings.dateFormat,
|
|
"currency": appSettings.currency,
|
|
"fontSize": appSettings.fontSize,
|
|
};
|
|
appSettings.patch(
|
|
theme: patch["theme"],
|
|
locale: patch["locale"],
|
|
dateFormat: patch["dateFormat"],
|
|
currency: patch["currency"],
|
|
fontSize: patch["fontSize"],
|
|
);
|
|
setState(() {});
|
|
try {
|
|
await apiClient.updateMe(patch);
|
|
} catch (e) {
|
|
appSettings.patch(
|
|
theme: prev["theme"],
|
|
locale: prev["locale"],
|
|
dateFormat: prev["dateFormat"],
|
|
currency: prev["currency"],
|
|
fontSize: prev["fontSize"],
|
|
);
|
|
setState(() {});
|
|
widget.onError("$e");
|
|
}
|
|
}
|
|
|
|
/// Language and region write the one locale field, so either control sends the
|
|
/// joined tag. The half the user did not touch is read back from the pickers
|
|
/// rather than from the raw locale, so changing only one of them cannot save a
|
|
/// code the other picker is not showing.
|
|
Future<void> _saveLocale({String? lang, String? region}) => _save({
|
|
"locale": "${lang ?? _language}-${region ?? _region}",
|
|
});
|
|
|
|
String get _language => _knownOr(_languages, appSettings.language, "en");
|
|
String get _region => _knownOr(_regions, appSettings.region, "US");
|
|
|
|
/// A value the picker cannot show would render blank and silently reset on the
|
|
/// next save, so an unknown code (a locale set from the web, whose lists are
|
|
/// wider than these) falls back to the list's default rather than being
|
|
/// dropped.
|
|
static String _knownOr(List<(String, String)> options, String value, String fallback) =>
|
|
options.any((o) => o.$1 == value) ? value : fallback;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _Card(
|
|
title: t("settings.appearance.title"),
|
|
children: [
|
|
Text(t("settings.appearance.theme"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
|
const SizedBox(height: 6),
|
|
SegmentedButton<String>(
|
|
segments: [
|
|
ButtonSegment(value: "light", label: Text(t("settings.appearance.themeLight"))),
|
|
ButtonSegment(value: "dark", label: Text(t("settings.appearance.themeDark"))),
|
|
ButtonSegment(value: "system", label: Text(t("settings.appearance.themeSystem"))),
|
|
],
|
|
selected: {appSettings.theme},
|
|
onSelectionChanged: (s) => _save({"theme": s.first}),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(t("settings.appearance.language"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
|
const SizedBox(height: 6),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: _language,
|
|
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
|
items: [
|
|
for (final l in _languages) DropdownMenuItem(value: l.$1, child: Text(l.$2)),
|
|
],
|
|
onChanged: (v) => v == null ? null : _saveLocale(lang: v),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(
|
|
languageTranslated
|
|
? t("settings.appearance.languageHint")
|
|
: t("settings.appearance.languageFallbackHint"),
|
|
style: TextStyle(
|
|
color: languageTranslated ? Colors.grey : DriverVault.warning, fontSize: 12),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(t("settings.appearance.region"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
|
const SizedBox(height: 6),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: _region,
|
|
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
|
items: [
|
|
for (final r in _regions) DropdownMenuItem(value: r.$1, child: Text(r.$2)),
|
|
],
|
|
onChanged: (v) => v == null ? null : _saveLocale(region: v),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(t("settings.appearance.regionHint"),
|
|
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(t("settings.appearance.currency"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
|
const SizedBox(height: 6),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: _knownOr(_currencies, appSettings.currency, "USD"),
|
|
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
|
items: [
|
|
for (final c in _currencies)
|
|
DropdownMenuItem(value: c.$1, child: Text("${c.$2} (${c.$1})")),
|
|
],
|
|
onChanged: (v) => v == null ? null : _save({"currency": v}),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(
|
|
t("settings.appearance.currencyHint", params: {"example": formatMoney(1234.5)}),
|
|
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(t("settings.appearance.dateFormat"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
|
const SizedBox(height: 6),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: appSettings.dateFormat,
|
|
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
|
items: const [
|
|
DropdownMenuItem(value: "YMD", child: Text("YYYY-MM-DD")),
|
|
DropdownMenuItem(value: "DMY_NUM", child: Text("DD-MM-YYYY")),
|
|
DropdownMenuItem(value: "DMY", child: Text("DD Mon YYYY")),
|
|
DropdownMenuItem(value: "MDY", child: Text("Mon DD, YYYY")),
|
|
],
|
|
onChanged: (v) => v == null ? null : _save({"dateFormat": v}),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(t("settings.appearance.dateHint", params: {"example": formatDate(DateTime.now())}),
|
|
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(t("settings.appearance.fontSize"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
|
const SizedBox(height: 6),
|
|
SegmentedButton<String>(
|
|
segments: [
|
|
ButtonSegment(value: "small", label: Text(t("settings.appearance.fontSmall"))),
|
|
ButtonSegment(value: "medium", label: Text(t("settings.appearance.fontMedium"))),
|
|
ButtonSegment(value: "large", label: Text(t("settings.appearance.fontLarge"))),
|
|
],
|
|
selected: {appSettings.fontSize},
|
|
onSelectionChanged: (s) => _save({"fontSize": s.first}),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- Profile (avatar + bio) ------------------------------------------------
|
|
|
|
class _ProfileSection extends StatefulWidget {
|
|
final UserProfile profile;
|
|
final TextEditingController bioController;
|
|
final Uint8List? avatar;
|
|
final Future<void> Function() onChanged;
|
|
final void Function(String) snack;
|
|
const _ProfileSection({
|
|
required this.profile,
|
|
required this.bioController,
|
|
required this.avatar,
|
|
required this.onChanged,
|
|
required this.snack,
|
|
});
|
|
@override
|
|
State<_ProfileSection> createState() => _ProfileSectionState();
|
|
}
|
|
|
|
class _ProfileSectionState extends State<_ProfileSection> {
|
|
bool _avatarBusy = false;
|
|
bool _savingBio = false;
|
|
|
|
Future<void> _pickAvatar() async {
|
|
final picker = ImagePicker();
|
|
final file = await picker.pickImage(source: ImageSource.gallery, maxWidth: 1024);
|
|
if (file == null) return;
|
|
setState(() => _avatarBusy = true);
|
|
try {
|
|
final bytes = await file.readAsBytes();
|
|
await apiClient.uploadAvatar(bytes, file.name);
|
|
await widget.onChanged();
|
|
} catch (e) {
|
|
widget.snack("$e");
|
|
} finally {
|
|
if (mounted) setState(() => _avatarBusy = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _removeAvatar() async {
|
|
setState(() => _avatarBusy = true);
|
|
try {
|
|
await apiClient.deleteAvatar();
|
|
await widget.onChanged();
|
|
} catch (e) {
|
|
widget.snack("$e");
|
|
} finally {
|
|
if (mounted) setState(() => _avatarBusy = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _saveBio() async {
|
|
setState(() => _savingBio = true);
|
|
try {
|
|
await apiClient.updateMe({"bio": widget.bioController.text});
|
|
widget.snack(t("settings.profile.bioSaved"));
|
|
} catch (e) {
|
|
widget.snack("$e");
|
|
} finally {
|
|
if (mounted) setState(() => _savingBio = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final p = widget.profile;
|
|
final initial = (p.name.isNotEmpty ? p.name : p.email).characters.first.toUpperCase();
|
|
return _Card(
|
|
title: t("settings.profile.title"),
|
|
children: [
|
|
Row(children: [
|
|
CircleAvatar(
|
|
radius: 32,
|
|
backgroundColor: DriverVault.brandTint(context),
|
|
backgroundImage: widget.avatar != null ? MemoryImage(widget.avatar!) : null,
|
|
child: widget.avatar == null
|
|
? Text(initial,
|
|
style: TextStyle(
|
|
fontSize: 22, fontWeight: FontWeight.w700, color: DriverVault.brandOnTint(context)))
|
|
: null,
|
|
),
|
|
const SizedBox(width: 16),
|
|
Wrap(spacing: 8, children: [
|
|
OutlinedButton(
|
|
onPressed: _avatarBusy ? null : _pickAvatar,
|
|
child: Text(_avatarBusy ? t("settings.profile.working") : t("settings.profile.uploadPhoto")),
|
|
),
|
|
if (p.hasAvatar)
|
|
OutlinedButton(
|
|
onPressed: _avatarBusy ? null : _removeAvatar,
|
|
child: Text(t("settings.profile.remove")),
|
|
),
|
|
]),
|
|
]),
|
|
const SizedBox(height: 16),
|
|
Text(t("settings.profile.bio"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
|
const SizedBox(height: 4),
|
|
TextField(
|
|
controller: widget.bioController,
|
|
maxLines: 3,
|
|
decoration: InputDecoration(
|
|
border: const OutlineInputBorder(),
|
|
hintText: t("settings.profile.bioHint"),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: OutlinedButton(
|
|
onPressed: _savingBio ? null : _saveBio,
|
|
child: Text(_savingBio ? t("common.saving") : t("settings.profile.saveBio")),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- Security (biometric sign-in) ------------------------------------------
|
|
|
|
class _SecuritySection extends StatefulWidget {
|
|
final String email;
|
|
final void Function(String) snack;
|
|
const _SecuritySection({required this.email, required this.snack});
|
|
@override
|
|
State<_SecuritySection> createState() => _SecuritySectionState();
|
|
}
|
|
|
|
class _SecuritySectionState extends State<_SecuritySection> {
|
|
bool _available = false;
|
|
bool _enabled = false;
|
|
bool _hasFace = false;
|
|
bool _hasFingerprint = false;
|
|
bool _busy = false;
|
|
bool _loaded = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_refresh();
|
|
}
|
|
|
|
Future<void> _refresh() async {
|
|
final available = await biometricAuth.isAvailable();
|
|
final enabled = await biometricAuth.isEnabled();
|
|
final face = available && await biometricAuth.hasFace();
|
|
final finger = available && await biometricAuth.hasFingerprint();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_available = available;
|
|
_enabled = enabled;
|
|
_hasFace = face;
|
|
_hasFingerprint = finger;
|
|
_loaded = true;
|
|
});
|
|
}
|
|
|
|
String get _methodLabel {
|
|
if (_hasFace && _hasFingerprint) return t("settings.security.methodFaceOrFingerprint");
|
|
if (_hasFace) return t("settings.security.methodFace");
|
|
if (_hasFingerprint) return t("settings.security.methodFingerprint");
|
|
return t("settings.security.methodBiometrics");
|
|
}
|
|
|
|
Future<void> _toggle(bool value) async {
|
|
if (_busy) return;
|
|
if (!value) {
|
|
setState(() => _busy = true);
|
|
await biometricAuth.disable();
|
|
if (mounted) setState(() { _enabled = false; _busy = false; });
|
|
widget.snack(t("settings.security.turnedOff"));
|
|
return;
|
|
}
|
|
// Enabling requires re-confirming the password so we store known-good creds.
|
|
final pw = await _promptPassword();
|
|
if (pw == null || pw.isEmpty) return;
|
|
setState(() => _busy = true);
|
|
try {
|
|
// Verify the password (and refresh the session) before storing it.
|
|
await authService.login(widget.email, pw);
|
|
await biometricAuth.enable(widget.email, pw);
|
|
if (mounted) setState(() { _enabled = true; _busy = false; });
|
|
widget.snack(t("settings.security.enabled"));
|
|
} catch (e) {
|
|
if (mounted) setState(() => _busy = false);
|
|
widget.snack(t("settings.security.couldNotEnable", params: {"error": e.toString()}));
|
|
}
|
|
}
|
|
|
|
Future<String?> _promptPassword() {
|
|
final ctrl = TextEditingController();
|
|
return showDialog<String>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(t("settings.security.confirmPassword")),
|
|
content: TextField(
|
|
controller: ctrl,
|
|
obscureText: true,
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
labelText: t("settings.security.password"),
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
onSubmitted: (v) => Navigator.pop(ctx, v),
|
|
),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(t("common.cancel"))),
|
|
FilledButton(onPressed: () => Navigator.pop(ctx, ctrl.text), child: Text(t("common.confirm"))),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _Card(
|
|
title: t("settings.security.title"),
|
|
children: [
|
|
if (!_loaded)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Text(t("settings.security.checkingDevice"), style: const TextStyle(color: Colors.grey)),
|
|
)
|
|
else ...[
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(t("settings.security.biometricSignIn")),
|
|
subtitle: Text(_available
|
|
? t("settings.security.biometricAvailable", params: {"method": _methodLabel})
|
|
: t("settings.security.biometricUnavailable")),
|
|
value: _enabled,
|
|
onChanged: (!_available || _busy) ? null : _toggle,
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- Privacy & security -----------------------------------------------------
|
|
|
|
/// Sessions are PocketBase's own stateless tokens, so there is no per-device
|
|
/// list to show or revoke — this card explains what "sign out" actually does.
|
|
class _PrivacySection extends StatelessWidget {
|
|
const _PrivacySection();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _Card(
|
|
title: t("settings.privacy.title"),
|
|
children: [
|
|
Text(
|
|
t("settings.privacy.body"),
|
|
style: const TextStyle(color: Colors.grey, fontSize: 13),
|
|
),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: TextButton(
|
|
onPressed: () => authService.logout(),
|
|
child: Text(t("settings.privacy.signOut"), style: const TextStyle(color: DriverVault.danger)),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- Danger zone (account deletion) ----------------------------------------
|
|
|
|
class _DangerSection extends StatefulWidget {
|
|
final UserProfile profile;
|
|
final Future<void> Function() onChanged;
|
|
final void Function(String) snack;
|
|
const _DangerSection({required this.profile, required this.onChanged, required this.snack});
|
|
@override
|
|
State<_DangerSection> createState() => _DangerSectionState();
|
|
}
|
|
|
|
class _DangerSectionState extends State<_DangerSection> {
|
|
final _confirmEmail = TextEditingController();
|
|
bool _showConfirm = false;
|
|
bool _busy = false;
|
|
DateTime? _eligibleAt;
|
|
|
|
@override
|
|
void dispose() {
|
|
_confirmEmail.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
bool get _pending => widget.profile.deletionPending;
|
|
DateTime? get _eligible {
|
|
if (!_pending) return null;
|
|
return _eligibleAt ??
|
|
widget.profile.deletionRequestedAt!.add(const Duration(days: 3));
|
|
}
|
|
|
|
bool get _cooldownElapsed => _eligible != null && DateTime.now().isAfter(_eligible!);
|
|
|
|
Future<void> _request() async {
|
|
if (_confirmEmail.text.trim().toLowerCase() != widget.profile.email.toLowerCase()) {
|
|
widget.snack(t("settings.danger.typeEmailPrompt"));
|
|
return;
|
|
}
|
|
setState(() => _busy = true);
|
|
try {
|
|
_eligibleAt = await apiClient.requestAccountDeletion(_confirmEmail.text.trim());
|
|
_showConfirm = false;
|
|
_confirmEmail.clear();
|
|
await widget.onChanged();
|
|
} catch (e) {
|
|
widget.snack("$e");
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _cancel() async {
|
|
try {
|
|
await apiClient.cancelAccountDeletion();
|
|
_eligibleAt = null;
|
|
await widget.onChanged();
|
|
} catch (e) {
|
|
widget.snack("$e");
|
|
}
|
|
}
|
|
|
|
Future<void> _finalize() async {
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(t("settings.danger.finalizeTitle")),
|
|
content: Text(t("settings.danger.finalizeBody")),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
|
|
FilledButton(
|
|
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: Text(t("settings.danger.delete")),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (ok != true) return;
|
|
try {
|
|
await apiClient.finalizeAccountDeletion();
|
|
await authService.logout();
|
|
} catch (e) {
|
|
widget.snack("$e");
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final p = widget.profile;
|
|
return _Card(
|
|
title: t("settings.danger.title"),
|
|
titleColor: DriverVault.danger,
|
|
children: [
|
|
if (!_pending) ...[
|
|
Text(
|
|
t("settings.danger.body"),
|
|
style: const TextStyle(fontSize: 13),
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (!_showConfirm)
|
|
OutlinedButton(
|
|
onPressed: () => setState(() => _showConfirm = true),
|
|
style: OutlinedButton.styleFrom(foregroundColor: DriverVault.danger),
|
|
child: Text(t("settings.danger.deleteAccount")),
|
|
)
|
|
else ...[
|
|
Text(t("settings.danger.typeToConfirm", params: {"email": p.email}),
|
|
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
|
|
const SizedBox(height: 6),
|
|
TextField(
|
|
controller: _confirmEmail,
|
|
decoration: InputDecoration(
|
|
hintText: p.email, border: const OutlineInputBorder(), isDense: true),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(children: [
|
|
TextButton(
|
|
onPressed: () => setState(() {
|
|
_showConfirm = false;
|
|
_confirmEmail.clear();
|
|
}),
|
|
child: Text(t("common.cancel")),
|
|
),
|
|
const SizedBox(width: 8),
|
|
FilledButton(
|
|
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
|
|
onPressed: _busy ? null : _request,
|
|
child: Text(_busy ? t("settings.danger.requesting") : t("settings.danger.requestDeletion")),
|
|
),
|
|
]),
|
|
],
|
|
] else ...[
|
|
Text(
|
|
t("settings.danger.requestedOn", params: {
|
|
"date": formatDate(p.deletionRequestedAt),
|
|
"tail": _cooldownElapsed
|
|
? t("settings.danger.cooldownPassed")
|
|
: t("settings.danger.canStillCancel"),
|
|
}),
|
|
style: const TextStyle(fontSize: 13),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(children: [
|
|
OutlinedButton(onPressed: _cancel, child: Text(t("settings.danger.cancelRequest"))),
|
|
const SizedBox(width: 8),
|
|
if (_cooldownElapsed)
|
|
FilledButton(
|
|
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
|
|
onPressed: _finalize,
|
|
child: Text(t("settings.danger.permanentlyDelete")),
|
|
),
|
|
]),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|