Initial commit

This commit is contained in:
tajniak81
2026-07-06 08:50:52 +02:00
commit ba3f227361
153 changed files with 18116 additions and 0 deletions
@@ -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
+249
View File
@@ -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")),
),
),
],
),
),
);
}
}
+355
View File
@@ -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")),
],
),
),
),
],
);
}
}
+110
View File
@@ -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"),
),
],
),
),
),
);
}
}
+383
View File
@@ -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)),
),
],
),
],
],
);
}
}
+61
View File
@@ -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,
),
);
}
}
+915
View File
@@ -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"),
),
]),
],
],
);
}
}