Initial commit
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "../main.dart";
|
||||
import "../models.dart";
|
||||
import "../format.dart";
|
||||
import "../theme.dart";
|
||||
|
||||
/// Admin-only screen to manage user accounts: list, create, change role,
|
||||
/// reset password, delete. The API enforces the real access control and the
|
||||
/// last-admin / self-delete guards; the UI mirrors them to avoid dead actions.
|
||||
class AdminUsersScreen extends StatefulWidget {
|
||||
const AdminUsersScreen({super.key});
|
||||
@override
|
||||
State<AdminUsersScreen> createState() => _AdminUsersScreenState();
|
||||
}
|
||||
|
||||
class _AdminUsersScreenState extends State<AdminUsersScreen> {
|
||||
late Future<List<AdminUser>> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = apiClient.listUsers();
|
||||
}
|
||||
|
||||
void _reload() => setState(() => _future = apiClient.listUsers());
|
||||
|
||||
String? get _myId => authService.user?.id;
|
||||
|
||||
void _snack(String msg) =>
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
|
||||
Future<void> _changeRole(AdminUser u, String role) async {
|
||||
try {
|
||||
await apiClient.updateUser(u.id, role: role);
|
||||
_reload();
|
||||
} catch (e) {
|
||||
_snack("$e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resetPassword(AdminUser u) async {
|
||||
final controller = TextEditingController();
|
||||
final saved = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text("Reset password — ${u.email}"),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "New password (min 8)",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text("Set password")),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (saved != true) return;
|
||||
try {
|
||||
await apiClient.setUserPassword(u.id, controller.text);
|
||||
_snack("Password updated.");
|
||||
} catch (e) {
|
||||
_snack("$e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete(AdminUser u) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Delete user?"),
|
||||
content: Text("Delete ${u.name.isEmpty ? u.email : u.name}? 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 (confirmed != true) return;
|
||||
try {
|
||||
await apiClient.deleteUser(u.id);
|
||||
_reload();
|
||||
} catch (e) {
|
||||
_snack("$e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createUser() async {
|
||||
final created = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => const _CreateUserSheet(),
|
||||
);
|
||||
if (created == true) _reload();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Users")),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _createUser,
|
||||
icon: const Icon(Icons.person_add_alt_1),
|
||||
label: const Text("Add user"),
|
||||
),
|
||||
body: FutureBuilder<List<AdminUser>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(child: Text("${snap.error}"));
|
||||
}
|
||||
final users = snap.data ?? [];
|
||||
final adminCount = users.where((u) => u.role == "admin").length;
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: users.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final u = users[i];
|
||||
final isSelf = u.id == _myId;
|
||||
final lastAdmin = u.role == "admin" && adminCount <= 1;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
title: Row(
|
||||
children: [
|
||||
Flexible(child: Text(u.email, overflow: TextOverflow.ellipsis)),
|
||||
if (isSelf)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 6),
|
||||
child: Text("(you)", style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
"${u.name.isEmpty ? '—' : u.name} · ${formatDate(DateTime.tryParse(u.created))}",
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DropdownButton<String>(
|
||||
value: u.role == "admin" ? "admin" : "user",
|
||||
underline: const SizedBox.shrink(),
|
||||
// Disable demoting the last admin.
|
||||
onChanged: lastAdmin ? null : (v) => v == null ? null : _changeRole(u, v),
|
||||
items: const [
|
||||
DropdownMenuItem(value: "user", child: Text("user")),
|
||||
DropdownMenuItem(value: "admin", child: Text("admin")),
|
||||
],
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (choice) {
|
||||
if (choice == "password") _resetPassword(u);
|
||||
if (choice == "delete") _delete(u);
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(value: "password", child: Text("Reset password")),
|
||||
PopupMenuItem(
|
||||
value: "delete",
|
||||
// Can't delete yourself or the last admin.
|
||||
enabled: !isSelf && !lastAdmin,
|
||||
child: const Text("Delete", style: TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CreateUserSheet extends StatefulWidget {
|
||||
const _CreateUserSheet();
|
||||
@override
|
||||
State<_CreateUserSheet> createState() => _CreateUserSheetState();
|
||||
}
|
||||
|
||||
class _CreateUserSheetState extends State<_CreateUserSheet> {
|
||||
final _email = TextEditingController();
|
||||
final _name = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
String _role = "user";
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_email.dispose();
|
||||
_name.dispose();
|
||||
_password.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await apiClient.createUser(
|
||||
email: _email.text.trim(),
|
||||
password: _password.text,
|
||||
name: _name.text.trim(),
|
||||
role: _role,
|
||||
);
|
||||
if (mounted) Navigator.pop(context, true);
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("Add a user",
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
TextField(
|
||||
controller: _email,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder()),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _name,
|
||||
decoration: const InputDecoration(labelText: "Name", border: OutlineInputBorder()),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _password,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Password (min 8)",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
DropdownButton<String>(
|
||||
value: _role,
|
||||
onChanged: (v) => setState(() => _role = v ?? "user"),
|
||||
items: const [
|
||||
DropdownMenuItem(value: "user", child: Text("user")),
|
||||
DropdownMenuItem(value: "admin", child: Text("admin")),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: Text(_saving ? "Creating…" : "Create user"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,249 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "../main.dart";
|
||||
import "../models.dart";
|
||||
import "../format.dart";
|
||||
import "../theme.dart";
|
||||
|
||||
/// Bottom-sheet form to create or edit a car, mirroring the web CarFormModal.
|
||||
/// Pops with the saved [Car] on success, or null if cancelled.
|
||||
class CarFormSheet extends StatefulWidget {
|
||||
final Car? car; // null => create
|
||||
const CarFormSheet({super.key, this.car});
|
||||
|
||||
@override
|
||||
State<CarFormSheet> createState() => _CarFormSheetState();
|
||||
}
|
||||
|
||||
class _CarFormSheetState extends State<CarFormSheet> {
|
||||
late final Map<String, TextEditingController> _c;
|
||||
String _fuelType = "";
|
||||
DateTime? _buildDate;
|
||||
DateTime? _firstRegistrationDate;
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
bool get _isEdit => widget.car != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final car = widget.car;
|
||||
_fuelType = car?.fuelType ?? "";
|
||||
_buildDate = (car?.buildDate.isNotEmpty ?? false) ? DateTime.tryParse(car!.buildDate) : null;
|
||||
_firstRegistrationDate =
|
||||
(car?.firstRegistrationDate.isNotEmpty ?? false) ? DateTime.tryParse(car!.firstRegistrationDate) : null;
|
||||
_c = {
|
||||
"name": TextEditingController(text: car?.name ?? ""),
|
||||
"make": TextEditingController(text: car?.make ?? ""),
|
||||
"model": TextEditingController(text: car?.model ?? ""),
|
||||
"year": TextEditingController(text: (car != null && car.year > 0) ? "${car.year}" : ""),
|
||||
"registration": TextEditingController(text: car?.registration ?? ""),
|
||||
"registrationCountry": TextEditingController(text: car?.registrationCountry ?? ""),
|
||||
"vin": TextEditingController(text: car?.vin ?? ""),
|
||||
"oilSpec": TextEditingController(text: car?.oilSpec ?? ""),
|
||||
"transmissionOilSpec": TextEditingController(text: car?.transmissionOilSpec ?? ""),
|
||||
"differentialOilSpec": TextEditingController(text: car?.differentialOilSpec ?? ""),
|
||||
"brakeFluidSpec": TextEditingController(text: car?.brakeFluidSpec ?? ""),
|
||||
"coolantSpec": TextEditingController(text: car?.coolantSpec ?? ""),
|
||||
"currentKm":
|
||||
TextEditingController(text: (car != null && car.currentKm > 0) ? "${car.currentKm}" : ""),
|
||||
"serviceIntervalDays": TextEditingController(text: "${car?.serviceIntervalDays ?? 365}"),
|
||||
"serviceIntervalKm": TextEditingController(text: "${car?.serviceIntervalKm ?? 15000}"),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in _c.values) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int _int(String key, int fallback) => int.tryParse(_c[key]!.text.trim()) ?? fallback;
|
||||
|
||||
Future<void> _save() async {
|
||||
final name = _c["name"]!.text.trim();
|
||||
if (name.isEmpty) {
|
||||
setState(() => _error = "Name is required.");
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
final payload = {
|
||||
"name": name,
|
||||
"make": _c["make"]!.text.trim(),
|
||||
"model": _c["model"]!.text.trim(),
|
||||
"year": _int("year", 0),
|
||||
"registration": _c["registration"]!.text.trim(),
|
||||
"registrationCountry": _c["registrationCountry"]!.text.trim(),
|
||||
"vin": _c["vin"]!.text.trim(),
|
||||
"fuelType": _fuelType,
|
||||
"buildDate": _isoOrEmpty(_buildDate),
|
||||
"firstRegistrationDate": _isoOrEmpty(_firstRegistrationDate),
|
||||
"oilSpec": _c["oilSpec"]!.text.trim(),
|
||||
"transmissionOilSpec": _c["transmissionOilSpec"]!.text.trim(),
|
||||
"differentialOilSpec": _c["differentialOilSpec"]!.text.trim(),
|
||||
"brakeFluidSpec": _c["brakeFluidSpec"]!.text.trim(),
|
||||
"coolantSpec": _c["coolantSpec"]!.text.trim(),
|
||||
"currentKm": _int("currentKm", 0),
|
||||
"serviceIntervalDays": _int("serviceIntervalDays", 365),
|
||||
"serviceIntervalKm": _int("serviceIntervalKm", 15000),
|
||||
};
|
||||
try {
|
||||
final saved = _isEdit
|
||||
? await apiClient.updateCar(widget.car!.id, payload)
|
||||
: await apiClient.createCar(payload);
|
||||
if (mounted) Navigator.pop(context, saved);
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
static String _isoOrEmpty(DateTime? d) => d == null
|
||||
? ""
|
||||
: "${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}";
|
||||
|
||||
Widget _field(String key, String label,
|
||||
{TextInputType? keyboard, TextCapitalization caps = TextCapitalization.none}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: TextField(
|
||||
controller: _c[key],
|
||||
keyboardType: keyboard,
|
||||
textCapitalization: caps,
|
||||
decoration: InputDecoration(labelText: label, border: const OutlineInputBorder(), isDense: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A tappable read-only field that opens a date picker. Shows a clear button
|
||||
/// when a date is set, otherwise a calendar icon.
|
||||
Widget _dateField(String label, DateTime? value, ValueChanged<DateTime?> onChanged) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: value ?? DateTime.now(),
|
||||
firstDate: DateTime(1950),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
if (picked != null) setState(() => onChanged(picked));
|
||||
},
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
suffixIcon: value == null
|
||||
? const Icon(Icons.calendar_today, size: 18)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
onPressed: () => setState(() => onChanged(null)),
|
||||
),
|
||||
),
|
||||
child: Text(value == null ? "—" : formatDate(value)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_isEdit ? "Edit car" : "Add a car",
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
_field("name", "Name *", caps: TextCapitalization.words),
|
||||
Row(children: [
|
||||
Expanded(child: _field("make", "Make")),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _field("model", "Model")),
|
||||
]),
|
||||
Row(children: [
|
||||
Expanded(child: _field("year", "Year", keyboard: TextInputType.number)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _field("registration", "Registration")),
|
||||
]),
|
||||
_field("registrationCountry", "Registration country", caps: TextCapitalization.words),
|
||||
_field("vin", "VIN"),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _fuelType.isEmpty ? null : _fuelType,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Fuel type", border: OutlineInputBorder(), isDense: true),
|
||||
items: const [
|
||||
DropdownMenuItem(value: "petrol", child: Text("Petrol (gasoline)")),
|
||||
DropdownMenuItem(value: "diesel", child: Text("Diesel")),
|
||||
DropdownMenuItem(value: "hybrid", child: Text("Hybrid")),
|
||||
DropdownMenuItem(value: "electric", child: Text("Electric")),
|
||||
],
|
||||
onChanged: (v) => setState(() => _fuelType = v ?? ""),
|
||||
),
|
||||
),
|
||||
Row(children: [
|
||||
Expanded(child: _dateField("Build date", _buildDate, (v) => _buildDate = v)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _dateField("First registration", _firstRegistrationDate,
|
||||
(v) => _firstRegistrationDate = v)),
|
||||
]),
|
||||
_field("oilSpec", "Engine oil spec"),
|
||||
Row(children: [
|
||||
Expanded(child: _field("transmissionOilSpec", "Transmission oil spec")),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _field("differentialOilSpec", "Differential oil spec")),
|
||||
]),
|
||||
Row(children: [
|
||||
Expanded(child: _field("brakeFluidSpec", "Brake fluid spec")),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _field("coolantSpec", "Coolant spec")),
|
||||
]),
|
||||
_field("currentKm", "Current odometer (km)", keyboard: TextInputType.number),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: _field("serviceIntervalDays", "Service interval (days)",
|
||||
keyboard: TextInputType.number)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _field("serviceIntervalKm", "Service interval (km)",
|
||||
keyboard: TextInputType.number)),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: Text(_saving ? "Saving…" : (_isEdit ? "Save changes" : "Add car")),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "../main.dart";
|
||||
import "../models.dart";
|
||||
import "../format.dart";
|
||||
import "../theme.dart";
|
||||
import "car_detail_screen.dart";
|
||||
import "car_form_sheet.dart";
|
||||
|
||||
class _CarRow {
|
||||
final Car car;
|
||||
final ServiceRecord? latest;
|
||||
final int count;
|
||||
_CarRow(this.car, this.latest, this.count);
|
||||
}
|
||||
|
||||
class DashboardScreen extends StatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
@override
|
||||
State<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends State<DashboardScreen> {
|
||||
late Future<List<_CarRow>> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _load();
|
||||
}
|
||||
|
||||
Future<List<_CarRow>> _load() async {
|
||||
final cars = await apiClient.listCars();
|
||||
final rows = <_CarRow>[];
|
||||
for (final c in cars) {
|
||||
final services = await apiClient.listCarServices(c.id);
|
||||
rows.add(_CarRow(c, services.isNotEmpty ? services.first : null, services.length));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
final f = _load();
|
||||
setState(() => _future = f);
|
||||
await f;
|
||||
}
|
||||
|
||||
Future<void> _addCar() async {
|
||||
final created = await showModalBottomSheet<Car?>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => const CarFormSheet(),
|
||||
);
|
||||
if (created != null && mounted) {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => CarDetailScreen(carId: created.id)),
|
||||
);
|
||||
await _refresh();
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleTheme() {
|
||||
final next = Theme.of(context).brightness == Brightness.dark ? "light" : "dark";
|
||||
appSettings.patch(theme: next); // optimistic + persisted locally
|
||||
apiClient.updateMe({"theme": next}).then((_) {}).catchError((_) {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dark = DriverVault.isDark(context);
|
||||
return Scaffold(
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _addCar,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Add car"),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Home header — eyebrow + title on the left, quick actions right.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("YOUR CARS",
|
||||
style: DriverVault.mono(context,
|
||||
size: 10, weight: FontWeight.w500, color: DriverVault.muted(context))
|
||||
.copyWith(letterSpacing: 2.2)),
|
||||
const SizedBox(height: 2),
|
||||
const Text("Garage",
|
||||
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
|
||||
],
|
||||
),
|
||||
),
|
||||
_HeaderButton(
|
||||
icon: dark ? Icons.light_mode_outlined : Icons.dark_mode_outlined,
|
||||
tooltip: dark ? "Light mode" : "Dark mode",
|
||||
onTap: _toggleTheme,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_HeaderButton(
|
||||
icon: Icons.logout,
|
||||
tooltip: "Log out",
|
||||
onTap: () => authService.logout(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: FutureBuilder<List<_CarRow>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return _ErrorView(message: "${snap.error}", onRetry: _refresh);
|
||||
}
|
||||
final rows = snap.data ?? [];
|
||||
if (rows.isEmpty) {
|
||||
return ListView(children: const [
|
||||
SizedBox(height: 80),
|
||||
Center(child: Text("No cars yet.")),
|
||||
]);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 96),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) => _CarCard(row: rows[i], onChanged: _refresh),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A bordered 40×40 square icon button used in the home header (mockup style).
|
||||
class _HeaderButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback onTap;
|
||||
const _HeaderButton({required this.icon, required this.tooltip, required this.onTap});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||||
border: Border.all(color: DriverVault.isDark(context) ? DriverVault.darkBorderStrong : DriverVault.ink200),
|
||||
),
|
||||
child: Icon(icon, size: 19, color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CarCard extends StatelessWidget {
|
||||
final _CarRow row;
|
||||
final Future<void> Function() onChanged;
|
||||
const _CarCard({required this.row, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = serviceStatus(row.latest, row.car);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => CarDetailScreen(carId: row.car.id)),
|
||||
);
|
||||
await onChanged();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(row.car.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16, letterSpacing: -0.3)),
|
||||
if (row.car.subtitle.isNotEmpty)
|
||||
Text(row.car.subtitle,
|
||||
style: TextStyle(color: DriverVault.muted(context), fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
_Badge(status: status),
|
||||
],
|
||||
),
|
||||
if (!row.car.isOwner) ...[
|
||||
const SizedBox(height: 8),
|
||||
_SharedChip(readOnly: row.car.isReadOnly),
|
||||
],
|
||||
..._serviceLife(context, status),
|
||||
const SizedBox(height: 12),
|
||||
_kv(context, "Last service", formatDate(row.latest?.date)),
|
||||
_kv(context, "Current odometer", formatKm(row.car.currentKm)),
|
||||
_kv(context, "Next due", formatDate(row.latest?.nextServiceDate)),
|
||||
_kv(context, "Next due km", formatKm(row.latest?.nextServiceKm)),
|
||||
const SizedBox(height: 6),
|
||||
Text("${row.count} service record${row.count == 1 ? '' : 's'}",
|
||||
style: TextStyle(color: DriverVault.muted(context), fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kv(BuildContext context, String k, String v) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(k, style: TextStyle(color: DriverVault.muted(context), fontSize: 13)),
|
||||
Text(v, style: DriverVault.mono(context, size: 13, weight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// Service-life bar: fraction of the km interval used up (echoes the mockup's
|
||||
/// battery bar). Returns [] when there isn't enough data to compute it.
|
||||
List<Widget> _serviceLife(BuildContext context, Status status) {
|
||||
final interval = row.car.serviceIntervalKm;
|
||||
final nextKm = row.latest?.nextServiceKm ?? 0;
|
||||
final currentKm = row.car.currentKm;
|
||||
if (interval <= 0 || nextKm <= 0 || currentKm <= 0) return const [];
|
||||
final remaining = nextKm - currentKm;
|
||||
final pct = (100 * (1 - remaining / interval)).clamp(0, 100).round();
|
||||
final tone = status.fg(DriverVault.isDark(context));
|
||||
return [
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text("SERVICE LIFE",
|
||||
style: DriverVault.mono(context, size: 9, weight: FontWeight.w500,
|
||||
color: DriverVault.muted(context)).copyWith(letterSpacing: 1.6)),
|
||||
Text("$pct%",
|
||||
style: DriverVault.mono(context, size: 11, weight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
child: LinearProgressIndicator(
|
||||
value: pct / 100,
|
||||
minHeight: 7,
|
||||
backgroundColor: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(tone),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class _SharedChip extends StatelessWidget {
|
||||
final bool readOnly;
|
||||
const _SharedChip({required this.readOnly});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: DriverVault.brandTint(context),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
readOnly ? "Shared · read-only" : "Shared",
|
||||
style: TextStyle(color: DriverVault.brandOnTint(context), fontSize: 11, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Badge extends StatelessWidget {
|
||||
final Status status;
|
||||
const _Badge({required this.status});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: status.bg(DriverVault.isDark(context)), borderRadius: BorderRadius.circular(999)),
|
||||
child: Text(status.label,
|
||||
style: TextStyle(color: status.fg(DriverVault.isDark(context)), fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorView extends StatelessWidget {
|
||||
final String message;
|
||||
final Future<void> Function() onRetry;
|
||||
const _ErrorView({required this.message, required this.onRetry});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
children: [
|
||||
const SizedBox(height: 80),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: DriverVault.danger, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(onPressed: onRetry, child: const Text("Retry")),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "../biometric.dart";
|
||||
import "../main.dart";
|
||||
import "../theme.dart";
|
||||
|
||||
/// Shown when the app is authenticated but locked (biometric login is enabled
|
||||
/// and the app was just launched or resumed). The session token is still valid;
|
||||
/// a successful biometric check simply reveals the app. "Use password" drops the
|
||||
/// session and returns to the login screen.
|
||||
class LockScreen extends StatefulWidget {
|
||||
const LockScreen({super.key});
|
||||
@override
|
||||
State<LockScreen> createState() => _LockScreenState();
|
||||
}
|
||||
|
||||
class _LockScreenState extends State<LockScreen> {
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Prompt once as soon as the lock screen appears.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _unlock());
|
||||
}
|
||||
|
||||
Future<void> _unlock() async {
|
||||
if (_busy) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final creds = await biometricAuth.unlock(reason: "Unlock DriverVault");
|
||||
if (creds == null) {
|
||||
// Cancelled — leave locked; the buttons let them retry or use password.
|
||||
if (mounted) setState(() => _busy = false);
|
||||
return;
|
||||
}
|
||||
// Token from a previous session is still held by ApiClient; just reveal.
|
||||
authService.unlock();
|
||||
} on BiometricException catch (e) {
|
||||
if (mounted) setState(() => _error = e.message);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final u = authService.user;
|
||||
final name = u == null ? null : (u.name.isNotEmpty ? u.name : u.email);
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: DriverVault.brand700,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(Icons.lock_outline, color: Colors.white, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text("DriverVault is locked",
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4)),
|
||||
if (name != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(name, style: TextStyle(color: DriverVault.muted(context))),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
SizedBox(
|
||||
width: 260,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _busy ? null : _unlock,
|
||||
icon: const Icon(Icons.fingerprint),
|
||||
label: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Text(_busy ? "Unlocking…" : "Unlock"),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: _busy ? null : () => authService.logout(),
|
||||
child: const Text("Use password instead"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "../api.dart";
|
||||
import "../biometric.dart";
|
||||
import "../config.dart";
|
||||
import "../main.dart";
|
||||
import "../theme.dart";
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _email = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
final _server = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _loading = false;
|
||||
bool _showPassword = false;
|
||||
String? _error;
|
||||
String? _serverSaved;
|
||||
|
||||
// Biometric ("Face recognition" / "Fingerprint") sign-in state.
|
||||
bool _bioAvailable = false;
|
||||
bool _bioEnabled = false;
|
||||
bool _hasFace = false;
|
||||
bool _hasFingerprint = false;
|
||||
String? _bioEmail;
|
||||
bool _autoPrompted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
apiClient.serverOverride().then((v) {
|
||||
if (mounted) _server.text = v;
|
||||
});
|
||||
_initBiometrics();
|
||||
}
|
||||
|
||||
Future<void> _initBiometrics() async {
|
||||
final available = await biometricAuth.isAvailable();
|
||||
final email = await biometricAuth.enabledEmail();
|
||||
final face = available && await biometricAuth.hasFace();
|
||||
final finger = available && await biometricAuth.hasFingerprint();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_bioAvailable = available;
|
||||
_bioEnabled = email != null;
|
||||
_bioEmail = email;
|
||||
_hasFace = face;
|
||||
_hasFingerprint = finger;
|
||||
});
|
||||
// If biometric login is set up, offer it immediately (once) on screen load.
|
||||
if (_bioEnabled && _bioAvailable && !_autoPrompted) {
|
||||
_autoPrompted = true;
|
||||
_biometricLogin();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_email.dispose();
|
||||
_password.dispose();
|
||||
_server.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveServer() async {
|
||||
await apiClient.setServerUrl(_server.text);
|
||||
final current = await apiClient.serverOverride();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_server.text = current;
|
||||
_serverSaved = "Saved ✓";
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _resetServer() async {
|
||||
await apiClient.setServerUrl("");
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_server.clear();
|
||||
_serverSaved = "Reset ✓";
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
final email = _email.text.trim();
|
||||
final password = _password.text;
|
||||
try {
|
||||
await authService.login(email, password);
|
||||
// Pull the full profile so saved appearance prefs (theme/font/date) apply.
|
||||
try {
|
||||
appSettings.applyFromProfile(await apiClient.getMe());
|
||||
} catch (_) {}
|
||||
// Offer to remember these (known-good) credentials for biometric login.
|
||||
await _maybeOfferBiometricEnrollment(email, password);
|
||||
// The app root rebuilds to the dashboard via the auth listener.
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// After a successful password login, if the device supports biometrics and
|
||||
/// they aren't set up yet, ask whether to enable biometric sign-in.
|
||||
Future<void> _maybeOfferBiometricEnrollment(String email, String password) async {
|
||||
if (!_bioAvailable || _bioEnabled || !mounted) return;
|
||||
final method = _hasFace && !_hasFingerprint
|
||||
? "face recognition"
|
||||
: _hasFingerprint && !_hasFace
|
||||
? "your fingerprint"
|
||||
: "biometrics";
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Enable biometric sign-in?"),
|
||||
content: Text(
|
||||
"Next time you can sign in with $method instead of typing your password."),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Not now")),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text("Enable")),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await biometricAuth.enable(email, password);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _biometricLogin() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final creds = await biometricAuth.unlock(
|
||||
reason: _bioEmail != null ? "Sign in as $_bioEmail" : "Sign in to DriverVault",
|
||||
);
|
||||
if (creds == null) {
|
||||
// User cancelled the prompt, or nothing is stored.
|
||||
if (mounted) setState(() => _loading = false);
|
||||
return;
|
||||
}
|
||||
await authService.login(creds.$1, creds.$2);
|
||||
try {
|
||||
appSettings.applyFromProfile(await apiClient.getMe());
|
||||
} catch (_) {}
|
||||
} on BiometricException catch (e) {
|
||||
if (mounted) setState(() => _error = e.message);
|
||||
} on ApiException catch (e) {
|
||||
// Stored credentials no longer work (e.g. password changed) — turn off
|
||||
// biometric login and fall back to the password form.
|
||||
if (e.status == 400 || e.status == 401) {
|
||||
await biometricAuth.disable();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_bioEnabled = false;
|
||||
_error = "Saved sign-in is no longer valid. Please sign in with your password.";
|
||||
});
|
||||
}
|
||||
} else if (mounted) {
|
||||
setState(() => _error = e.message);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Biometric sign-in buttons, shown only once biometric login is set up on
|
||||
/// this device. Labels reflect the enrolled biometric kind(s).
|
||||
Widget _buildBiometricSection() {
|
||||
if (!_bioEnabled || !_bioAvailable) return const SizedBox.shrink();
|
||||
|
||||
final buttons = <Widget>[];
|
||||
Widget bioButton(IconData icon, String label) => Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _loading ? null : _biometricLogin,
|
||||
icon: Icon(icon),
|
||||
label: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(label),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (_hasFace) buttons.add(bioButton(Icons.face, "Sign in with face recognition"));
|
||||
if (_hasFingerprint) buttons.add(bioButton(Icons.fingerprint, "Sign in with fingerprint"));
|
||||
if (buttons.isEmpty) buttons.add(bioButton(Icons.lock_outline, "Sign in with biometrics"));
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text("or", style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
),
|
||||
Expanded(child: Divider()),
|
||||
],
|
||||
),
|
||||
),
|
||||
...buttons,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 380),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const DriverVaultLogo(markSize: 40, fontSize: 30),
|
||||
const SizedBox(height: 10),
|
||||
Text("Your car, on track.",
|
||||
style: TextStyle(color: DriverVault.muted(context))),
|
||||
const SizedBox(height: 24),
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
if (_error != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||||
),
|
||||
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _email,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder()),
|
||||
validator: (v) => (v == null || v.isEmpty) ? "Required" : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _password,
|
||||
obscureText: !_showPassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Password",
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility),
|
||||
tooltip: _showPassword ? "Hide password" : "Show password",
|
||||
onPressed: () => setState(() => _showPassword = !_showPassword),
|
||||
),
|
||||
),
|
||||
validator: (v) => (v == null || v.isEmpty) ? "Required" : null,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _loading ? null : _submit,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text(_loading ? "Signing in…" : "Sign in"),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildBiometricSection(),
|
||||
const SizedBox(height: 8),
|
||||
_ServerSettings(
|
||||
controller: _server,
|
||||
savedNote: _serverSaved,
|
||||
onSave: _saveServer,
|
||||
onReset: _resetServer,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapsible "Server settings" on the login screen: an editable API server URL
|
||||
/// override (blank = use the compile-time default). Mirrors the web app.
|
||||
class _ServerSettings extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String? savedNote;
|
||||
final Future<void> Function() onSave;
|
||||
final Future<void> Function() onReset;
|
||||
const _ServerSettings({
|
||||
required this.controller,
|
||||
required this.savedNote,
|
||||
required this.onSave,
|
||||
required this.onReset,
|
||||
});
|
||||
@override
|
||||
State<_ServerSettings> createState() => _ServerSettingsState();
|
||||
}
|
||||
|
||||
class _ServerSettingsState extends State<_ServerSettings> {
|
||||
bool _open = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => setState(() => _open = !_open),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text("Server settings",
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey)),
|
||||
Icon(_open ? Icons.expand_less : Icons.expand_more, size: 18, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_open) ...[
|
||||
TextField(
|
||||
controller: widget.controller,
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "API server URL",
|
||||
hintText: kDefaultApiBase,
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 4),
|
||||
child: Text("Leave blank to use the default.",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 11)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton(onPressed: widget.onSave, child: const Text("Save")),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(onPressed: widget.onReset, child: const Text("Reset")),
|
||||
if (widget.savedNote != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Text(widget.savedNote!,
|
||||
style: const TextStyle(color: DriverVault.success, fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "../main.dart";
|
||||
import "dashboard_screen.dart";
|
||||
import "settings_screen.dart";
|
||||
import "admin_users_screen.dart";
|
||||
|
||||
/// The app's root shell — a persistent bottom navigation bar (DriverVault mockup
|
||||
/// layout) hosting the Garage, Settings and (for admins) Users sections in an
|
||||
/// IndexedStack so each keeps its state as you switch tabs.
|
||||
class RootShell extends StatefulWidget {
|
||||
const RootShell({super.key});
|
||||
@override
|
||||
State<RootShell> createState() => _RootShellState();
|
||||
}
|
||||
|
||||
class _RootShellState extends State<RootShell> {
|
||||
int _index = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isAdmin = authService.user?.isAdmin == true;
|
||||
|
||||
final pages = <Widget>[
|
||||
const DashboardScreen(),
|
||||
const SettingsScreen(),
|
||||
if (isAdmin) const AdminUsersScreen(),
|
||||
];
|
||||
|
||||
final destinations = <NavigationDestination>[
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.grid_view_outlined),
|
||||
selectedIcon: Icon(Icons.grid_view_rounded),
|
||||
label: "Garage",
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: "Settings",
|
||||
),
|
||||
if (isAdmin)
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.group_outlined),
|
||||
selectedIcon: Icon(Icons.group),
|
||||
label: "Users",
|
||||
),
|
||||
];
|
||||
|
||||
// Guard against the index falling out of range if admin status changes.
|
||||
final index = _index.clamp(0, pages.length - 1);
|
||||
|
||||
return Scaffold(
|
||||
body: IndexedStack(index: index, children: pages),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: index,
|
||||
onDestinationSelected: (i) => setState(() => _index = i),
|
||||
destinations: destinations,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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