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
+254
View File
@@ -0,0 +1,254 @@
import "dart:convert";
import "package:http/http.dart" as http;
import "package:shared_preferences/shared_preferences.dart";
import "config.dart";
import "models.dart";
/// Thrown when the API Server returns a non-2xx response.
class ApiException implements Exception {
final int status;
final String message;
ApiException(this.status, this.message);
@override
String toString() => message;
}
/// The single client for the Car Control API Server. Holds the bearer token and
/// attaches it to every request. On 401 it calls [onUnauthorized] so the app can
/// route back to login.
class ApiClient {
String? token;
void Function()? onUnauthorized;
static const _serverKey = "cc_server_url";
/// The effective API base URL. Defaults to [kDefaultApiBase]; a saved override
/// (login screen "Server settings") replaces it via [loadServerUrl].
String baseUrl = kDefaultApiBase;
/// Loads a saved server-URL override, if any. Call before the first request.
Future<void> loadServerUrl() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_serverKey);
if (saved != null && saved.isNotEmpty) baseUrl = saved;
}
/// The current override URL, or "" when using the default.
Future<String> serverOverride() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_serverKey) ?? "";
}
/// Persists a server-URL override. Blank clears it (reverts to the default).
/// Trailing slashes are trimmed.
Future<void> setServerUrl(String url) async {
final prefs = await SharedPreferences.getInstance();
final trimmed = url.trim().replaceAll(RegExp(r"/+$"), "");
if (trimmed.isEmpty) {
await prefs.remove(_serverKey);
baseUrl = kDefaultApiBase;
} else {
await prefs.setString(_serverKey, trimmed);
baseUrl = trimmed;
}
}
Map<String, String> get _headers => {
"Content-Type": "application/json",
if (token != null) "Authorization": "Bearer $token",
};
Uri _uri(String path) => Uri.parse("$baseUrl$path");
Future<dynamic> _send(String method, String path, {Object? body}) async {
final req = http.Request(method, _uri(path))..headers.addAll(_headers);
if (body != null) req.body = jsonEncode(body);
final streamed = await http.Client().send(req);
final res = await http.Response.fromStream(streamed);
if (res.statusCode == 401 && path != "/auth/login") {
onUnauthorized?.call();
throw ApiException(401, "Session expired — please log in again.");
}
if (res.statusCode == 204 || res.body.isEmpty) return null;
final data = jsonDecode(res.body);
if (res.statusCode < 200 || res.statusCode >= 300) {
final msg = data is Map && data["error"] != null ? data["error"].toString() : res.reasonPhrase;
throw ApiException(res.statusCode, msg ?? "Request failed");
}
return data;
}
// --- auth ---
Future<(String, AuthUser)> login(String email, String password) async {
final data = await _send("POST", "/auth/login", body: {"email": email, "password": password});
return (data["token"] as String, AuthUser.fromJson(Map<String, dynamic>.from(data["user"])));
}
// --- cars ---
Future<List<Car>> listCars() async {
final data = await _send("GET", "/cars") as List;
return data.map((e) => Car.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<Car> getCar(String id) async {
final data = await _send("GET", "/cars/$id");
return Car.fromJson(Map<String, dynamic>.from(data));
}
Future<Car> createCar(Map<String, dynamic> body) async {
final data = await _send("POST", "/cars", body: body);
return Car.fromJson(Map<String, dynamic>.from(data));
}
Future<Car> updateCar(String id, Map<String, dynamic> body) async {
final data = await _send("PATCH", "/cars/$id", body: body);
return Car.fromJson(Map<String, dynamic>.from(data));
}
Future<void> deleteCar(String id) => _send("DELETE", "/cars/$id");
// --- sharing (owner-only) ---
Future<List<CarShare>> listCarShares(String carId) async {
final data = await _send("GET", "/cars/$carId/shares") as List;
return data.map((e) => CarShare.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<CarShare> addCarShare(String carId, String email, String permission) async {
final data = await _send("POST", "/cars/$carId/shares",
body: {"email": email, "permission": permission});
return CarShare.fromJson(Map<String, dynamic>.from(data));
}
Future<void> removeCarShare(String carId, String userId) =>
_send("DELETE", "/cars/$carId/shares/$userId");
// --- admin: user management (admin role only) ---
Future<List<AdminUser>> listUsers() async {
final data = await _send("GET", "/admin/users") as List;
return data.map((e) => AdminUser.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<AdminUser> createUser(
{required String email, required String password, String? name, String role = "user"}) async {
final data = await _send("POST", "/admin/users",
body: {"email": email, "password": password, "name": name ?? "", "role": role});
return AdminUser.fromJson(Map<String, dynamic>.from(data));
}
Future<AdminUser> updateUser(String id, {String? name, String? role}) async {
final body = <String, dynamic>{};
if (name != null) body["name"] = name;
if (role != null) body["role"] = role;
final data = await _send("PATCH", "/admin/users/$id", body: body);
return AdminUser.fromJson(Map<String, dynamic>.from(data));
}
Future<void> setUserPassword(String id, String newPassword) =>
_send("POST", "/admin/users/$id/password", body: {"newPassword": newPassword});
Future<void> deleteUser(String id) => _send("DELETE", "/admin/users/$id");
// --- service records ---
Future<List<ServiceRecord>> listCarServices(String carId) async {
final data = await _send("GET", "/cars/$carId/service-records") as List;
return data.map((e) => ServiceRecord.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<ServiceRecord> createService(Map<String, dynamic> body) async {
final data = await _send("POST", "/service-records", body: body);
return ServiceRecord.fromJson(Map<String, dynamic>.from(data));
}
Future<ServiceRecord> updateService(String id, Map<String, dynamic> body) async {
final data = await _send("PATCH", "/service-records/$id", body: body);
return ServiceRecord.fromJson(Map<String, dynamic>.from(data));
}
Future<void> deleteService(String id) => _send("DELETE", "/service-records/$id");
// --- parts ---
Future<List<Part>> listCarParts(String carId) async {
final data = await _send("GET", "/cars/$carId/parts") as List;
return data.map((e) => Part.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<Part> createPart(Map<String, dynamic> body) async {
final data = await _send("POST", "/parts", body: body);
return Part.fromJson(Map<String, dynamic>.from(data));
}
Future<Part> updatePart(String id, Map<String, dynamic> body) async {
final data = await _send("PATCH", "/parts/$id", body: body);
return Part.fromJson(Map<String, dynamic>.from(data));
}
Future<void> deletePart(String id) => _send("DELETE", "/parts/$id");
// --- settings: profile / account ---
Future<UserProfile> getMe() async {
final data = await _send("GET", "/me");
return UserProfile.fromJson(Map<String, dynamic>.from(data));
}
Future<UserProfile> updateMe(Map<String, dynamic> patch) async {
final data = await _send("PATCH", "/me", body: patch);
return UserProfile.fromJson(Map<String, dynamic>.from(data));
}
Future<void> changePassword(String oldPassword, String newPassword) => _send(
"POST",
"/me/password",
body: {"oldPassword": oldPassword, "newPassword": newPassword},
);
Future<void> requestVerification() => _send("POST", "/me/verify/request");
// --- settings: avatar ---
Future<UserProfile> uploadAvatar(List<int> bytes, String filename) async {
final req = http.MultipartRequest("POST", _uri("/me/avatar"));
if (token != null) req.headers["Authorization"] = "Bearer $token";
req.files.add(http.MultipartFile.fromBytes("avatar", bytes, filename: filename));
final res = await http.Response.fromStream(await req.send());
if (res.statusCode == 401) {
onUnauthorized?.call();
throw ApiException(401, "Session expired — please log in again.");
}
final data = jsonDecode(res.body);
if (res.statusCode < 200 || res.statusCode >= 300) {
final msg = data is Map && data["error"] != null ? data["error"].toString() : res.reasonPhrase;
throw ApiException(res.statusCode, msg ?? "Upload failed");
}
return UserProfile.fromJson(Map<String, dynamic>.from(data));
}
Future<List<int>?> getAvatarBytes() async {
final res = await http.get(_uri("/me/avatar"),
headers: {if (token != null) "Authorization": "Bearer $token"});
if (res.statusCode == 200) return res.bodyBytes;
return null;
}
Future<void> deleteAvatar() => _send("DELETE", "/me/avatar");
// --- settings: sessions ---
Future<List<Session>> listSessions() async {
final data = await _send("GET", "/sessions") as List;
return data.map((e) => Session.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<void> revokeSession(String id) => _send("DELETE", "/sessions/$id");
Future<void> revokeOtherSessions() => _send("DELETE", "/sessions");
// --- settings: account deletion ---
Future<DateTime?> requestAccountDeletion(String confirmEmail) async {
final data = await _send("POST", "/me/delete", body: {"confirmEmail": confirmEmail});
final at = (data is Map) ? data["eligibleAt"] : null;
return at == null ? null : DateTime.tryParse(at.toString());
}
Future<void> cancelAccountDeletion() => _send("POST", "/me/delete/cancel");
Future<void> finalizeAccountDeletion() => _send("DELETE", "/me");
}
+70
View File
@@ -0,0 +1,70 @@
import "package:flutter/material.dart";
import "package:shared_preferences/shared_preferences.dart";
import "models.dart";
/// App-wide appearance preferences (theme / locale / date format / font size),
/// mirroring the web app's prefs.js. Persisted to SharedPreferences so the
/// chosen theme survives a restart before /api/me loads, and exposed as a
/// ChangeNotifier so MaterialApp and date formatting react to changes.
class AppSettings extends ChangeNotifier {
static const _kTheme = "cc_theme";
static const _kLocale = "cc_locale";
static const _kDateFormat = "cc_dateFormat";
static const _kFontSize = "cc_fontSize";
String theme = "system"; // light | dark | system
String locale = "en-US";
String dateFormat = "YMD"; // YMD | DMY_NUM | DMY | MDY
String fontSize = "medium"; // small | medium | large
Future<void> loadFromStorage() async {
final prefs = await SharedPreferences.getInstance();
theme = prefs.getString(_kTheme) ?? theme;
locale = prefs.getString(_kLocale) ?? locale;
dateFormat = prefs.getString(_kDateFormat) ?? dateFormat;
fontSize = prefs.getString(_kFontSize) ?? fontSize;
notifyListeners();
}
Future<void> _persist() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kTheme, theme);
await prefs.setString(_kLocale, locale);
await prefs.setString(_kDateFormat, dateFormat);
await prefs.setString(_kFontSize, fontSize);
}
/// Sync from a freshly fetched profile (login, boot, settings saves).
void applyFromProfile(UserProfile p) {
theme = p.theme;
locale = p.locale;
dateFormat = p.dateFormat;
fontSize = p.fontSize;
_persist();
notifyListeners();
}
/// Optimistically apply a single changed field (used by Settings so the UI
/// reacts instantly while the PATCH is in flight).
void patch({String? theme, String? locale, String? dateFormat, String? fontSize}) {
if (theme != null) this.theme = theme;
if (locale != null) this.locale = locale;
if (dateFormat != null) this.dateFormat = dateFormat;
if (fontSize != null) this.fontSize = fontSize;
_persist();
notifyListeners();
}
ThemeMode get themeMode => switch (theme) {
"light" => ThemeMode.light,
"dark" => ThemeMode.dark,
_ => ThemeMode.system,
};
double get textScale => switch (fontSize) {
"small" => 0.9375,
"large" => 1.125,
_ => 1.0,
};
}
+74
View File
@@ -0,0 +1,74 @@
import "dart:convert";
import "package:flutter/foundation.dart";
import "package:shared_preferences/shared_preferences.dart";
import "api.dart";
import "models.dart";
const _tokenKey = "cc_token";
const _userKey = "cc_user";
/// Holds session state and persists the token across app restarts.
class AuthService extends ChangeNotifier {
final ApiClient api;
AuthUser? user;
bool ready = false;
/// In-memory (never persisted) app-lock flag. When biometric login is enabled
/// the app starts/returns locked: the token is still valid but the UI hides
/// behind a biometric unlock instead of jumping straight to the dashboard.
bool locked = false;
AuthService(this.api) {
api.onUnauthorized = () => logout();
}
bool get isAuthenticated => api.token != null;
void lock() {
if (!locked && isAuthenticated) {
locked = true;
notifyListeners();
}
}
void unlock() {
if (locked) {
locked = false;
notifyListeners();
}
}
Future<void> loadFromStorage() async {
final prefs = await SharedPreferences.getInstance();
final t = prefs.getString(_tokenKey);
final u = prefs.getString(_userKey);
if (t != null) {
api.token = t;
if (u != null) user = AuthUser.fromJson(jsonDecode(u));
}
ready = true;
notifyListeners();
}
Future<void> login(String email, String password) async {
final (token, u) = await api.login(email, password);
api.token = token;
user = u;
locked = false;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
await prefs.setString(_userKey, jsonEncode(u.toJson()));
notifyListeners();
}
Future<void> logout() async {
api.token = null;
user = null;
locked = false;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_userKey);
notifyListeners();
}
}
+114
View File
@@ -0,0 +1,114 @@
import "package:flutter/services.dart";
import "package:flutter_secure_storage/flutter_secure_storage.dart";
import "package:local_auth/local_auth.dart";
import "package:local_auth_android/local_auth_android.dart";
/// Biometric ("Face recognition" / "Fingerprint") sign-in.
///
/// The app already persists a JWT in SharedPreferences, so a valid session
/// auto-restores on boot and the login screen is only shown after logout or
/// token expiry. Biometric login covers that case: the user's credentials are
/// kept in Android Keystore-backed secure storage and released only after the
/// system BiometricPrompt succeeds, then replayed against the normal login API
/// (so each biometric sign-in mints a fresh session/token).
class BiometricAuth {
final LocalAuthentication _auth = LocalAuthentication();
final FlutterSecureStorage _store = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
static const _emailKey = "cc_bio_email";
static const _passwordKey = "cc_bio_password";
/// Synchronous mirror of [isEnabled], kept fresh by the async calls below so
/// the app-lifecycle handler (which can't await) can decide whether to lock.
bool enabledCached = false;
/// Whether the device has usable biometric hardware with something enrolled.
Future<bool> isAvailable() async {
try {
if (!await _auth.isDeviceSupported()) return false;
return await _auth.canCheckBiometrics;
} on PlatformException {
return false;
}
}
/// Which biometric kinds are enrolled (used to label the sign-in buttons).
Future<List<BiometricType>> availableTypes() async {
try {
return await _auth.getAvailableBiometrics();
} on PlatformException {
return const [];
}
}
Future<bool> hasFace() async =>
(await availableTypes()).contains(BiometricType.face);
Future<bool> hasFingerprint() async =>
(await availableTypes()).contains(BiometricType.fingerprint);
/// The email biometric login was enabled for, or null if it's off.
Future<String?> enabledEmail() async {
final v = await _store.read(key: _emailKey);
enabledCached = v != null;
return v;
}
Future<bool> isEnabled() async => (await enabledEmail()) != null;
/// Remember credentials for biometric sign-in. Caller should only invoke this
/// after a successful password login so the stored creds are known-good.
Future<void> enable(String email, String password) async {
await _store.write(key: _emailKey, value: email);
await _store.write(key: _passwordKey, value: password);
enabledCached = true;
}
Future<void> disable() async {
await _store.delete(key: _emailKey);
await _store.delete(key: _passwordKey);
enabledCached = false;
}
/// Prompt for biometrics and, on success, return the stored (email, password).
/// Returns null if the user cancels or nothing is stored; throws
/// [BiometricException] with a friendly message on a hard error.
Future<(String, String)?> unlock({String? reason}) async {
bool ok;
try {
ok = await _auth.authenticate(
localizedReason: reason ?? "Sign in to Car Control",
options: const AuthenticationOptions(
biometricOnly: true,
stickyAuth: true,
),
authMessages: const [
AndroidAuthMessages(
signInTitle: "Car Control sign-in",
biometricHint: "",
cancelButton: "Use password",
),
],
);
} on PlatformException catch (e) {
throw BiometricException(e.message ?? "Biometric authentication failed.");
}
if (!ok) return null;
final email = await _store.read(key: _emailKey);
final password = await _store.read(key: _passwordKey);
if (email == null || password == null) return null;
return (email, password);
}
}
class BiometricException implements Exception {
final String message;
BiometricException(this.message);
@override
String toString() => message;
}
/// App-wide singleton (mirrors the other services in main.dart).
final biometricAuth = BiometricAuth();
+14
View File
@@ -0,0 +1,14 @@
// Default base URL of the Car Control API Server. The phone app talks ONLY to
// the API Server (never to PocketBase directly).
//
// This is only the DEFAULT — the user can override it at runtime from the login
// screen's "Server settings" (persisted to SharedPreferences). The compile-time
// value here is used when there's no saved override.
//
// - Flutter web build on this PC: http://localhost:8080/api works.
// - Real phone on the LAN: set it in Server settings (or at build time via
// flutter run --dart-define=API_BASE=http://10.2.1.10:8080/api)
const String kDefaultApiBase = String.fromEnvironment(
"API_BASE",
defaultValue: "http://localhost:8080/api",
);
+85
View File
@@ -0,0 +1,85 @@
import "package:flutter/material.dart";
import "package:intl/intl.dart";
import "main.dart";
import "models.dart";
import "theme.dart";
final _numFmt = NumberFormat.decimalPattern();
/// Formats a date per the signed-in user's chosen date format (appSettings),
/// mirroring the web app's format.js.
String formatDate(DateTime? d) {
if (d == null) return "";
final pattern = switch (appSettings.dateFormat) {
"DMY_NUM" => "dd-MM-yyyy",
"DMY" => "dd MMM yyyy",
"MDY" => "MMM dd, yyyy",
_ => "yyyy-MM-dd", // YMD
};
return DateFormat(pattern).format(d);
}
String formatKm(int? km) => (km == null || km == 0) ? "" : "${_numFmt.format(km)} km";
const int _kmSoon = 1000;
enum StatusKey { unknown, ok, soon, overdue }
class Status {
final StatusKey key;
final String label;
const Status(this.key, this.label);
/// Soft tint background for the status pill — DriverVault semantic colours,
/// re-cut for dark surfaces.
Color bg(bool dark) => switch (key) {
StatusKey.overdue => dark ? DriverVault.dangerSoftDark : DriverVault.dangerSoft,
StatusKey.soon => dark ? DriverVault.warningSoftDark : DriverVault.warningSoft,
StatusKey.ok => dark ? DriverVault.successSoftDark : DriverVault.successSoft,
StatusKey.unknown => dark ? DriverVault.darkSunken : DriverVault.ink50,
};
Color fg(bool dark) => switch (key) {
StatusKey.overdue => DriverVault.danger,
StatusKey.soon => DriverVault.warning,
StatusKey.ok => DriverVault.success,
StatusKey.unknown => dark ? DriverVault.darkTextMuted : DriverVault.ink500,
};
}
int _rank(StatusKey k) => switch (k) {
StatusKey.unknown => 0,
StatusKey.ok => 1,
StatusKey.soon => 2,
StatusKey.overdue => 3,
};
Status _dateSignal(DateTime? nextDate) {
if (nextDate == null) return const Status(StatusKey.unknown, "No data");
final today = DateTime.now();
final days = DateTime(nextDate.year, nextDate.month, nextDate.day)
.difference(DateTime(today.year, today.month, today.day))
.inDays;
if (days < 0) return Status(StatusKey.overdue, "Service Overdue ${days.abs()}d");
if (days <= 30) return Status(StatusKey.soon, "Due in ${days}d");
return Status(StatusKey.ok, "OK · ${days}d");
}
Status _kmSignal(int currentKm, int? nextKm) {
if (currentKm == 0 || nextKm == null) return const Status(StatusKey.unknown, "No km");
final remaining = nextKm - currentKm;
if (remaining < 0) return Status(StatusKey.overdue, "Service Overdue ${_numFmt.format(remaining.abs())} km");
if (remaining <= _kmSoon) return Status(StatusKey.soon, "In ${_numFmt.format(remaining)} km");
return Status(StatusKey.ok, "${_numFmt.format(remaining)} km left");
}
/// Combines the date- and km-based signals, returning the worse of the two —
/// the same logic as the web app and the spreadsheet idea.
Status serviceStatus(ServiceRecord? latest, Car car) {
final date = _dateSignal(latest?.nextServiceDate);
final km = _kmSignal(car.currentKm, latest?.nextServiceKm);
final worse = _rank(km.key) > _rank(date.key) ? km : date;
if (date.key == StatusKey.unknown && km.key != StatusKey.unknown) return km;
if (km.key == StatusKey.unknown && date.key != StatusKey.unknown) return date;
return worse;
}
+109
View File
@@ -0,0 +1,109 @@
import "package:flutter/material.dart";
import "api.dart";
import "app_settings.dart";
import "auth.dart";
import "biometric.dart";
import "theme.dart";
import "screens/lock_screen.dart";
import "screens/login_screen.dart";
import "screens/root_shell.dart";
// Simple app-wide singletons (small app — no DI framework needed).
final apiClient = ApiClient();
final authService = AuthService(apiClient);
final appSettings = AppSettings();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await apiClient.loadServerUrl();
await appSettings.loadFromStorage();
await authService.loadFromStorage();
// Prime the biometric-enabled cache; if a session survived from a previous
// run and biometric login is on, start locked so the app requires an unlock
// instead of jumping straight to the dashboard.
final bioEnabled = await biometricAuth.isEnabled();
if (authService.isAuthenticated && bioEnabled) {
authService.lock();
}
// If a token survived from a previous run, refresh the profile so the saved
// appearance prefs (theme/font/date format) apply on boot.
if (authService.isAuthenticated) {
apiClient.getMe().then((p) => appSettings.applyFromProfile(p)).catchError((_) {});
}
runApp(const CarControlApp());
}
class CarControlApp extends StatefulWidget {
const CarControlApp({super.key});
@override
State<CarControlApp> createState() => _CarControlAppState();
}
class _CarControlAppState extends State<CarControlApp> with WidgetsBindingObserver {
// Grace period: a quick app-switch (returning within this window) does NOT
// re-lock; staying in the background longer does.
static const _lockGrace = Duration(seconds: 30);
DateTime? _backgroundedAt;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Re-lock when the app returns from the background, but only if it was away
// longer than the grace period — so quick app-switches don't re-lock.
// Handle only 'paused'/'resumed' (not 'inactive', which also fires
// transiently while the OS biometric prompt is showing).
if (state == AppLifecycleState.paused) {
_backgroundedAt = DateTime.now();
} else if (state == AppLifecycleState.resumed) {
final since = _backgroundedAt;
_backgroundedAt = null;
if (since != null &&
DateTime.now().difference(since) > _lockGrace &&
authService.isAuthenticated &&
biometricAuth.enabledCached) {
authService.lock();
}
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: appSettings,
builder: (context, _) => MaterialApp(
title: "DriverVault",
debugShowCheckedModeBanner: false,
theme: DriverVault.theme(Brightness.light),
darkTheme: DriverVault.theme(Brightness.dark),
themeMode: appSettings.themeMode,
// Apply the user's font-size preference app-wide.
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(appSettings.textScale),
),
child: child!,
),
home: ListenableBuilder(
listenable: authService,
builder: (context, _) {
if (!authService.isAuthenticated) return const LoginScreen();
if (authService.locked) return const LockScreen();
return const RootShell();
},
),
),
);
}
}
+300
View File
@@ -0,0 +1,300 @@
// Domain models mirroring the API Server JSON (which mirrors Car Service.xlsx).
int _asInt(dynamic v) => v is int ? v : (v is num ? v.toInt() : 0);
String _asStr(dynamic v) => v == null ? "" : v.toString();
bool _asBool(dynamic v) => v == true;
class Car {
final String id;
final String name;
final String make;
final String model;
final int year;
final String registration;
final String registrationCountry;
final String vin;
final String oilSpec;
final String transmissionOilSpec;
final String differentialOilSpec;
final String brakeFluidSpec;
final String coolantSpec;
final String fuelType; // petrol | diesel | hybrid | electric
final String buildDate; // ISO YYYY-MM-DD (date-only)
final String firstRegistrationDate; // ISO YYYY-MM-DD (date-only)
final int serviceIntervalDays;
final int serviceIntervalKm;
final int currentKm;
/// The requesting user's permission on this car: "owner", "write", or "read".
/// The API sets it on every car read. Defaults to "owner" so older responses
/// (or any code that constructs a Car without it) stay fully editable.
final String access;
Car({
required this.id,
required this.name,
required this.make,
required this.model,
required this.year,
required this.registration,
this.registrationCountry = "",
required this.vin,
required this.oilSpec,
required this.transmissionOilSpec,
required this.differentialOilSpec,
required this.brakeFluidSpec,
required this.coolantSpec,
this.fuelType = "",
this.buildDate = "",
this.firstRegistrationDate = "",
required this.serviceIntervalDays,
required this.serviceIntervalKm,
required this.currentKm,
this.access = "owner",
});
factory Car.fromJson(Map<String, dynamic> j) => Car(
id: _asStr(j["id"]),
name: _asStr(j["name"]),
make: _asStr(j["make"]),
model: _asStr(j["model"]),
year: _asInt(j["year"]),
registration: _asStr(j["registration"]),
registrationCountry: _asStr(j["registrationCountry"]),
vin: _asStr(j["vin"]),
oilSpec: _asStr(j["oilSpec"]),
transmissionOilSpec: _asStr(j["transmissionOilSpec"]),
differentialOilSpec: _asStr(j["differentialOilSpec"]),
brakeFluidSpec: _asStr(j["brakeFluidSpec"]),
coolantSpec: _asStr(j["coolantSpec"]),
fuelType: _asStr(j["fuelType"]),
buildDate: _asStr(j["buildDate"]),
firstRegistrationDate: _asStr(j["firstRegistrationDate"]),
serviceIntervalDays: _asInt(j["serviceIntervalDays"]),
serviceIntervalKm: _asInt(j["serviceIntervalKm"]),
currentKm: _asInt(j["currentKm"]),
access: j["access"] == null ? "owner" : _asStr(j["access"]),
);
bool get isOwner => access == "owner";
bool get canWrite => access == "owner" || access == "write";
bool get isReadOnly => access == "read";
String get subtitle =>
[make, model, year > 0 ? "$year" : ""].where((s) => s.isNotEmpty).join(" ");
}
class ServiceRecord {
final String id;
final String car;
final DateTime? date;
final int km;
final bool changedOil;
final bool changedEngineAirFilter;
final bool changedCabinAirFilter;
final String notes;
final DateTime? nextServiceDate;
final int? nextServiceKm;
ServiceRecord({
required this.id,
required this.car,
required this.date,
required this.km,
required this.changedOil,
required this.changedEngineAirFilter,
required this.changedCabinAirFilter,
required this.notes,
required this.nextServiceDate,
required this.nextServiceKm,
});
factory ServiceRecord.fromJson(Map<String, dynamic> j) => ServiceRecord(
id: _asStr(j["id"]),
car: _asStr(j["car"]),
date: DateTime.tryParse(_asStr(j["date"]))?.toLocal(),
km: _asInt(j["km"]),
changedOil: _asBool(j["changedOil"]),
changedEngineAirFilter: _asBool(j["changedEngineAirFilter"]),
changedCabinAirFilter: _asBool(j["changedCabinAirFilter"]),
notes: _asStr(j["notes"]),
nextServiceDate: j["nextServiceDate"] != null
? DateTime.tryParse(_asStr(j["nextServiceDate"]))?.toLocal()
: null,
nextServiceKm: j["nextServiceKm"] == null ? null : _asInt(j["nextServiceKm"]),
);
}
class Part {
final String id;
final String car;
final String name;
final String partNumber;
final String category;
Part({
required this.id,
required this.car,
required this.name,
required this.partNumber,
this.category = "",
});
factory Part.fromJson(Map<String, dynamic> j) => Part(
id: _asStr(j["id"]),
car: _asStr(j["car"]),
name: _asStr(j["name"]),
partNumber: _asStr(j["partNumber"]),
category: _asStr(j["category"]),
);
}
/// A sharing grant: another user's access to one of your cars.
class CarShare {
final String userId;
final String email;
final String name;
final String permission; // "read" | "write"
CarShare({
required this.userId,
required this.email,
required this.name,
required this.permission,
});
factory CarShare.fromJson(Map<String, dynamic> j) {
final user = Map<String, dynamic>.from(j["user"] ?? {});
return CarShare(
userId: _asStr(user["id"]),
email: _asStr(user["email"]),
name: _asStr(user["name"]),
permission: _asStr(j["permission"]),
);
}
String get label => name.isNotEmpty ? name : email;
}
class AuthUser {
final String id;
final String email;
final String name;
final String role; // "user" | "admin"
AuthUser({required this.id, required this.email, required this.name, this.role = "user"});
factory AuthUser.fromJson(Map<String, dynamic> j) => AuthUser(
id: _asStr(j["id"]),
email: _asStr(j["email"]),
name: _asStr(j["name"]),
role: j["role"] == null ? "user" : _asStr(j["role"]),
);
bool get isAdmin => role == "admin";
Map<String, dynamic> toJson() => {"id": id, "email": email, "name": name, "role": role};
}
/// A user record as returned by the admin user-management endpoints.
class AdminUser {
final String id;
final String email;
final String name;
final String role;
final String created;
AdminUser({
required this.id,
required this.email,
required this.name,
required this.role,
required this.created,
});
factory AdminUser.fromJson(Map<String, dynamic> j) => AdminUser(
id: _asStr(j["id"]),
email: _asStr(j["email"]),
name: _asStr(j["name"]),
role: _asStr(j["role"]),
created: _asStr(j["created"]),
);
}
/// The full authenticated profile (Settings panel), mirroring /api/me.
class UserProfile {
final String id;
final String email;
final bool verified;
final String name;
final String bio;
final bool hasAvatar;
final String theme; // light | dark | system
final String locale;
final String dateFormat; // YMD | DMY_NUM | DMY | MDY
final String fontSize; // small | medium | large
final String role; // user | admin
final DateTime? deletionRequestedAt;
UserProfile({
required this.id,
required this.email,
required this.verified,
required this.name,
required this.bio,
required this.hasAvatar,
required this.theme,
required this.locale,
required this.dateFormat,
required this.fontSize,
required this.role,
required this.deletionRequestedAt,
});
factory UserProfile.fromJson(Map<String, dynamic> j) => UserProfile(
id: _asStr(j["id"]),
email: _asStr(j["email"]),
verified: _asBool(j["verified"]),
name: _asStr(j["name"]),
bio: _asStr(j["bio"]),
hasAvatar: _asBool(j["hasAvatar"]),
theme: j["theme"] == null ? "system" : _asStr(j["theme"]),
locale: j["locale"] == null ? "en-US" : _asStr(j["locale"]),
dateFormat: j["dateFormat"] == null ? "YMD" : _asStr(j["dateFormat"]),
fontSize: j["fontSize"] == null ? "medium" : _asStr(j["fontSize"]),
role: j["role"] == null ? "user" : _asStr(j["role"]),
deletionRequestedAt: j["deletionRequestedAt"] == null
? null
: DateTime.tryParse(_asStr(j["deletionRequestedAt"]))?.toLocal(),
);
bool get isAdmin => role == "admin";
bool get deletionPending => deletionRequestedAt != null;
}
/// One active login session (device), mirroring /api/sessions.
class Session {
final String id;
final String deviceLabel;
final String ip;
final bool current;
final DateTime? created;
final DateTime? expiresAt;
Session({
required this.id,
required this.deviceLabel,
required this.ip,
required this.current,
required this.created,
required this.expiresAt,
});
factory Session.fromJson(Map<String, dynamic> j) => Session(
id: _asStr(j["id"]),
deviceLabel: _asStr(j["deviceLabel"]),
ip: _asStr(j["ip"]),
current: _asBool(j["current"]),
created: DateTime.tryParse(_asStr(j["created"]))?.toLocal(),
expiresAt: DateTime.tryParse(_asStr(j["expiresAt"]))?.toLocal(),
);
}
@@ -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"),
),
]),
],
],
);
}
}
+283
View File
@@ -0,0 +1,283 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
/// DriverVault design tokens + Material theme, mirroring the web app's design
/// system (blue-led cool palette, Archivo + DM Mono type, 22px cards, soft
/// cool shadows). Every screen styles through this so light/dark theme for free.
class DriverVault {
DriverVault._();
// ---- Brand blue ramp (from the DriverVault mark) ----
static const brand900 = Color(0xFF0B1730);
static const brand800 = Color(0xFF0F1E3D);
static const brand700 = Color(0xFF1E40AF);
static const brand600 = Color(0xFF2563EB);
static const brand500 = Color(0xFF3B82F6);
static const brand400 = Color(0xFF60A5FA);
static const brand300 = Color(0xFF93C5FD);
static const brand100 = Color(0xFFE8F0FD);
// ---- Cool neutrals ----
static const ink900 = Color(0xFF0F1E3D);
static const ink600 = Color(0xFF3E4E68);
static const ink500 = Color(0xFF5C6B85);
static const ink400 = Color(0xFF7A8AA6);
static const ink200 = Color(0xFFD6DEEA);
static const ink100 = Color(0xFFE4E9F2);
static const ink50 = Color(0xFFEEF2F8);
static const ink25 = Color(0xFFF7F9FC);
// ---- Semantic status (car: OK / due / fault) ----
static const success = Color(0xFF1F8A5B);
static const warning = Color(0xFFD9822B);
static const danger = Color(0xFFDC2A45);
static const info = Color(0xFF2563EB);
// Light status tints
static const successSoft = Color(0xFFE1F3EA);
static const warningSoft = Color(0xFFFBEDDD);
static const dangerSoft = Color(0xFFFBE3E7);
static const infoSoft = Color(0xFFE8F0FD);
// Dark status tints (re-cut for dark surfaces)
static const successSoftDark = Color(0xFF12352A);
static const warningSoftDark = Color(0xFF3A2A16);
static const dangerSoftDark = Color(0xFF3A1620);
static const infoSoftDark = Color(0xFF122A4D);
// ---- Dark surfaces / text / borders ----
static const darkPage = Color(0xFF0B1730);
static const darkCard = Color(0xFF13233F);
static const darkSunken = Color(0xFF0F1E38);
static const darkBorder = Color(0xFF21324F);
static const darkBorderStrong = Color(0xFF2C3F5E);
static const darkTextStrong = Color(0xFFF2F6FC);
static const darkTextBody = Color(0xFFB7C4D9);
static const darkTextMuted = Color(0xFF7C8CA8);
static const darkAccent = Color(0xFF3B82F6);
static const radiusControl = 12.0;
static const radiusCard = 22.0;
static bool isDark(BuildContext c) => Theme.of(c).brightness == Brightness.dark;
// Brand tint used for avatars / info chips (adapts to theme).
static Color brandTint(BuildContext c) => isDark(c) ? const Color(0xFF17294A) : brand100;
static Color brandOnTint(BuildContext c) => isDark(c) ? brand300 : brand700;
/// Muted secondary text colour (replaces ad-hoc Colors.grey).
static Color muted(BuildContext c) => isDark(c) ? darkTextMuted : ink400;
/// DM Mono style for data / units / labels.
static TextStyle mono(BuildContext c, {double size = 13, FontWeight weight = FontWeight.w500, Color? color}) =>
GoogleFonts.dmMono(
fontSize: size,
fontWeight: weight,
letterSpacing: 0.2,
color: color ?? Theme.of(c).textTheme.bodyMedium?.color,
);
static ThemeData theme(Brightness brightness) {
final dark = brightness == Brightness.dark;
final scheme = ColorScheme(
brightness: brightness,
primary: dark ? darkAccent : brand600,
onPrimary: Colors.white,
primaryContainer: dark ? const Color(0xFF17294A) : brand100,
onPrimaryContainer: dark ? brand300 : brand700,
secondary: dark ? brand400 : brand700,
onSecondary: Colors.white,
surface: dark ? darkCard : Colors.white,
onSurface: dark ? darkTextStrong : ink900,
surfaceContainerHighest: dark ? darkSunken : ink50,
onSurfaceVariant: dark ? darkTextBody : ink600,
outline: dark ? darkBorderStrong : ink200,
outlineVariant: dark ? darkBorder : ink100,
error: danger,
onError: Colors.white,
errorContainer: dark ? dangerSoftDark : dangerSoft,
onErrorContainer: danger,
);
final baseText = dark ? ThemeData.dark().textTheme : ThemeData.light().textTheme;
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: scheme,
scaffoldBackgroundColor: dark ? darkPage : ink25,
textTheme: GoogleFonts.archivoTextTheme(baseText).apply(
bodyColor: dark ? darkTextBody : ink600,
displayColor: dark ? darkTextStrong : ink900,
),
appBarTheme: AppBarTheme(
backgroundColor: dark ? darkCard : Colors.white,
foregroundColor: dark ? darkTextStrong : ink900,
elevation: 0,
scrolledUnderElevation: 0.5,
centerTitle: false,
titleTextStyle: GoogleFonts.archivo(
fontSize: 20,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
color: dark ? darkTextStrong : ink900,
),
),
cardTheme: CardThemeData(
color: dark ? darkCard : Colors.white,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusCard),
side: BorderSide(color: dark ? darkBorder : ink100),
),
),
dividerColor: dark ? darkBorder : ink100,
dividerTheme: DividerThemeData(color: dark ? darkBorder : ink100, thickness: 1),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: dark ? darkSunken : Colors.white,
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusControl),
borderSide: BorderSide(color: dark ? darkBorderStrong : ink200),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusControl),
borderSide: BorderSide(color: dark ? darkBorderStrong : ink200),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusControl),
borderSide: BorderSide(color: dark ? darkAccent : brand600, width: 2),
),
labelStyle: TextStyle(color: dark ? darkTextMuted : ink500),
floatingLabelStyle: TextStyle(color: dark ? darkAccent : brand600),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: dark ? darkAccent : brand600,
foregroundColor: Colors.white,
textStyle: GoogleFonts.archivo(fontWeight: FontWeight.w600, fontSize: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusControl)),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: dark ? darkTextBody : ink600,
side: BorderSide(color: dark ? darkBorderStrong : ink200),
textStyle: GoogleFonts.archivo(fontWeight: FontWeight.w600, fontSize: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusControl)),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: dark ? brand300 : brand700,
textStyle: GoogleFonts.archivo(fontWeight: FontWeight.w600, fontSize: 14),
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: dark ? darkAccent : brand600,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusControl)),
),
chipTheme: ChipThemeData(
backgroundColor: dark ? darkSunken : ink50,
side: BorderSide(color: dark ? darkBorder : ink100),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: dark ? darkCard : Colors.white,
surfaceTintColor: Colors.transparent,
indicatorColor: dark ? const Color(0xFF17294A) : brand100,
elevation: 0,
height: 66,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final on = states.contains(WidgetState.selected);
return GoogleFonts.archivo(
fontSize: 11,
fontWeight: on ? FontWeight.w600 : FontWeight.w500,
color: on ? (dark ? brand300 : brand700) : (dark ? darkTextMuted : ink400),
);
}),
iconTheme: WidgetStateProperty.resolveWith((states) {
final on = states.contains(WidgetState.selected);
return IconThemeData(
size: 24,
color: on ? (dark ? brand300 : brand700) : (dark ? darkTextMuted : ink400),
);
}),
),
snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating),
);
}
}
/// The DriverVault "Fast Forward" mark — three forward-leaning rounded bars of
/// increasing height (skewX(-13°)), reading as acceleration / momentum.
class DriverVaultMark extends StatelessWidget {
final double size;
const DriverVaultMark({super.key, this.size = 32});
@override
Widget build(BuildContext context) {
final u = size / 40; // scale factor
Widget bar(double h, Color c) => Container(
width: 5 * u,
height: h * u,
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(3 * u)),
);
return SizedBox(
width: size,
height: size,
child: Transform(
alignment: Alignment.center,
transform: Matrix4.skewX(-0.23),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
bar(18, DriverVault.brand700),
SizedBox(width: 3.5 * u),
bar(28, DriverVault.brand500),
SizedBox(width: 3.5 * u),
bar(36, DriverVault.brand400),
],
),
),
);
}
}
/// Full DriverVault lockup: the mark + the Archivo italic-800 wordmark
/// ("Driver" in strong ink, "Vault" in brand blue).
class DriverVaultLogo extends StatelessWidget {
final double markSize;
final double fontSize;
const DriverVaultLogo({super.key, this.markSize = 32, this.fontSize = 24});
@override
Widget build(BuildContext context) {
final strong = DriverVault.isDark(context) ? DriverVault.darkTextStrong : DriverVault.ink900;
final brand = DriverVault.isDark(context) ? DriverVault.brand400 : DriverVault.brand700;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
DriverVaultMark(size: markSize),
SizedBox(width: markSize * 0.32),
Text.rich(
TextSpan(children: [
TextSpan(text: "Driver", style: TextStyle(color: strong)),
TextSpan(text: "Vault", style: TextStyle(color: brand)),
]),
style: GoogleFonts.archivo(
fontSize: fontSize,
fontWeight: FontWeight.w800,
fontStyle: FontStyle.italic,
letterSpacing: -0.5,
),
),
],
);
}
}