Initial commit
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,249 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "../main.dart";
|
||||
import "../models.dart";
|
||||
import "../format.dart";
|
||||
import "../theme.dart";
|
||||
|
||||
/// Bottom-sheet form to create or edit a car, mirroring the web CarFormModal.
|
||||
/// Pops with the saved [Car] on success, or null if cancelled.
|
||||
class CarFormSheet extends StatefulWidget {
|
||||
final Car? car; // null => create
|
||||
const CarFormSheet({super.key, this.car});
|
||||
|
||||
@override
|
||||
State<CarFormSheet> createState() => _CarFormSheetState();
|
||||
}
|
||||
|
||||
class _CarFormSheetState extends State<CarFormSheet> {
|
||||
late final Map<String, TextEditingController> _c;
|
||||
String _fuelType = "";
|
||||
DateTime? _buildDate;
|
||||
DateTime? _firstRegistrationDate;
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
bool get _isEdit => widget.car != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final car = widget.car;
|
||||
_fuelType = car?.fuelType ?? "";
|
||||
_buildDate = (car?.buildDate.isNotEmpty ?? false) ? DateTime.tryParse(car!.buildDate) : null;
|
||||
_firstRegistrationDate =
|
||||
(car?.firstRegistrationDate.isNotEmpty ?? false) ? DateTime.tryParse(car!.firstRegistrationDate) : null;
|
||||
_c = {
|
||||
"name": TextEditingController(text: car?.name ?? ""),
|
||||
"make": TextEditingController(text: car?.make ?? ""),
|
||||
"model": TextEditingController(text: car?.model ?? ""),
|
||||
"year": TextEditingController(text: (car != null && car.year > 0) ? "${car.year}" : ""),
|
||||
"registration": TextEditingController(text: car?.registration ?? ""),
|
||||
"registrationCountry": TextEditingController(text: car?.registrationCountry ?? ""),
|
||||
"vin": TextEditingController(text: car?.vin ?? ""),
|
||||
"oilSpec": TextEditingController(text: car?.oilSpec ?? ""),
|
||||
"transmissionOilSpec": TextEditingController(text: car?.transmissionOilSpec ?? ""),
|
||||
"differentialOilSpec": TextEditingController(text: car?.differentialOilSpec ?? ""),
|
||||
"brakeFluidSpec": TextEditingController(text: car?.brakeFluidSpec ?? ""),
|
||||
"coolantSpec": TextEditingController(text: car?.coolantSpec ?? ""),
|
||||
"currentKm":
|
||||
TextEditingController(text: (car != null && car.currentKm > 0) ? "${car.currentKm}" : ""),
|
||||
"serviceIntervalDays": TextEditingController(text: "${car?.serviceIntervalDays ?? 365}"),
|
||||
"serviceIntervalKm": TextEditingController(text: "${car?.serviceIntervalKm ?? 15000}"),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in _c.values) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int _int(String key, int fallback) => int.tryParse(_c[key]!.text.trim()) ?? fallback;
|
||||
|
||||
Future<void> _save() async {
|
||||
final name = _c["name"]!.text.trim();
|
||||
if (name.isEmpty) {
|
||||
setState(() => _error = "Name is required.");
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
final payload = {
|
||||
"name": name,
|
||||
"make": _c["make"]!.text.trim(),
|
||||
"model": _c["model"]!.text.trim(),
|
||||
"year": _int("year", 0),
|
||||
"registration": _c["registration"]!.text.trim(),
|
||||
"registrationCountry": _c["registrationCountry"]!.text.trim(),
|
||||
"vin": _c["vin"]!.text.trim(),
|
||||
"fuelType": _fuelType,
|
||||
"buildDate": _isoOrEmpty(_buildDate),
|
||||
"firstRegistrationDate": _isoOrEmpty(_firstRegistrationDate),
|
||||
"oilSpec": _c["oilSpec"]!.text.trim(),
|
||||
"transmissionOilSpec": _c["transmissionOilSpec"]!.text.trim(),
|
||||
"differentialOilSpec": _c["differentialOilSpec"]!.text.trim(),
|
||||
"brakeFluidSpec": _c["brakeFluidSpec"]!.text.trim(),
|
||||
"coolantSpec": _c["coolantSpec"]!.text.trim(),
|
||||
"currentKm": _int("currentKm", 0),
|
||||
"serviceIntervalDays": _int("serviceIntervalDays", 365),
|
||||
"serviceIntervalKm": _int("serviceIntervalKm", 15000),
|
||||
};
|
||||
try {
|
||||
final saved = _isEdit
|
||||
? await apiClient.updateCar(widget.car!.id, payload)
|
||||
: await apiClient.createCar(payload);
|
||||
if (mounted) Navigator.pop(context, saved);
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
static String _isoOrEmpty(DateTime? d) => d == null
|
||||
? ""
|
||||
: "${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}";
|
||||
|
||||
Widget _field(String key, String label,
|
||||
{TextInputType? keyboard, TextCapitalization caps = TextCapitalization.none}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: TextField(
|
||||
controller: _c[key],
|
||||
keyboardType: keyboard,
|
||||
textCapitalization: caps,
|
||||
decoration: InputDecoration(labelText: label, border: const OutlineInputBorder(), isDense: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A tappable read-only field that opens a date picker. Shows a clear button
|
||||
/// when a date is set, otherwise a calendar icon.
|
||||
Widget _dateField(String label, DateTime? value, ValueChanged<DateTime?> onChanged) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: value ?? DateTime.now(),
|
||||
firstDate: DateTime(1950),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
if (picked != null) setState(() => onChanged(picked));
|
||||
},
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
suffixIcon: value == null
|
||||
? const Icon(Icons.calendar_today, size: 18)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
onPressed: () => setState(() => onChanged(null)),
|
||||
),
|
||||
),
|
||||
child: Text(value == null ? "—" : formatDate(value)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_isEdit ? "Edit car" : "Add a car",
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
_field("name", "Name *", caps: TextCapitalization.words),
|
||||
Row(children: [
|
||||
Expanded(child: _field("make", "Make")),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _field("model", "Model")),
|
||||
]),
|
||||
Row(children: [
|
||||
Expanded(child: _field("year", "Year", keyboard: TextInputType.number)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _field("registration", "Registration")),
|
||||
]),
|
||||
_field("registrationCountry", "Registration country", caps: TextCapitalization.words),
|
||||
_field("vin", "VIN"),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _fuelType.isEmpty ? null : _fuelType,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Fuel type", border: OutlineInputBorder(), isDense: true),
|
||||
items: const [
|
||||
DropdownMenuItem(value: "petrol", child: Text("Petrol (gasoline)")),
|
||||
DropdownMenuItem(value: "diesel", child: Text("Diesel")),
|
||||
DropdownMenuItem(value: "hybrid", child: Text("Hybrid")),
|
||||
DropdownMenuItem(value: "electric", child: Text("Electric")),
|
||||
],
|
||||
onChanged: (v) => setState(() => _fuelType = v ?? ""),
|
||||
),
|
||||
),
|
||||
Row(children: [
|
||||
Expanded(child: _dateField("Build date", _buildDate, (v) => _buildDate = v)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _dateField("First registration", _firstRegistrationDate,
|
||||
(v) => _firstRegistrationDate = v)),
|
||||
]),
|
||||
_field("oilSpec", "Engine oil spec"),
|
||||
Row(children: [
|
||||
Expanded(child: _field("transmissionOilSpec", "Transmission oil spec")),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _field("differentialOilSpec", "Differential oil spec")),
|
||||
]),
|
||||
Row(children: [
|
||||
Expanded(child: _field("brakeFluidSpec", "Brake fluid spec")),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _field("coolantSpec", "Coolant spec")),
|
||||
]),
|
||||
_field("currentKm", "Current odometer (km)", keyboard: TextInputType.number),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: _field("serviceIntervalDays", "Service interval (days)",
|
||||
keyboard: TextInputType.number)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _field("serviceIntervalKm", "Service interval (km)",
|
||||
keyboard: TextInputType.number)),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: Text(_saving ? "Saving…" : (_isEdit ? "Save changes" : "Add car")),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "../main.dart";
|
||||
import "../models.dart";
|
||||
import "../format.dart";
|
||||
import "../theme.dart";
|
||||
import "car_detail_screen.dart";
|
||||
import "car_form_sheet.dart";
|
||||
|
||||
class _CarRow {
|
||||
final Car car;
|
||||
final ServiceRecord? latest;
|
||||
final int count;
|
||||
_CarRow(this.car, this.latest, this.count);
|
||||
}
|
||||
|
||||
class DashboardScreen extends StatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
@override
|
||||
State<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends State<DashboardScreen> {
|
||||
late Future<List<_CarRow>> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _load();
|
||||
}
|
||||
|
||||
Future<List<_CarRow>> _load() async {
|
||||
final cars = await apiClient.listCars();
|
||||
final rows = <_CarRow>[];
|
||||
for (final c in cars) {
|
||||
final services = await apiClient.listCarServices(c.id);
|
||||
rows.add(_CarRow(c, services.isNotEmpty ? services.first : null, services.length));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
final f = _load();
|
||||
setState(() => _future = f);
|
||||
await f;
|
||||
}
|
||||
|
||||
Future<void> _addCar() async {
|
||||
final created = await showModalBottomSheet<Car?>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => const CarFormSheet(),
|
||||
);
|
||||
if (created != null && mounted) {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => CarDetailScreen(carId: created.id)),
|
||||
);
|
||||
await _refresh();
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleTheme() {
|
||||
final next = Theme.of(context).brightness == Brightness.dark ? "light" : "dark";
|
||||
appSettings.patch(theme: next); // optimistic + persisted locally
|
||||
apiClient.updateMe({"theme": next}).then((_) {}).catchError((_) {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dark = DriverVault.isDark(context);
|
||||
return Scaffold(
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _addCar,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Add car"),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Home header — eyebrow + title on the left, quick actions right.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("YOUR CARS",
|
||||
style: DriverVault.mono(context,
|
||||
size: 10, weight: FontWeight.w500, color: DriverVault.muted(context))
|
||||
.copyWith(letterSpacing: 2.2)),
|
||||
const SizedBox(height: 2),
|
||||
const Text("Garage",
|
||||
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
|
||||
],
|
||||
),
|
||||
),
|
||||
_HeaderButton(
|
||||
icon: dark ? Icons.light_mode_outlined : Icons.dark_mode_outlined,
|
||||
tooltip: dark ? "Light mode" : "Dark mode",
|
||||
onTap: _toggleTheme,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_HeaderButton(
|
||||
icon: Icons.logout,
|
||||
tooltip: "Log out",
|
||||
onTap: () => authService.logout(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: FutureBuilder<List<_CarRow>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return _ErrorView(message: "${snap.error}", onRetry: _refresh);
|
||||
}
|
||||
final rows = snap.data ?? [];
|
||||
if (rows.isEmpty) {
|
||||
return ListView(children: const [
|
||||
SizedBox(height: 80),
|
||||
Center(child: Text("No cars yet.")),
|
||||
]);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 96),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) => _CarCard(row: rows[i], onChanged: _refresh),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A bordered 40×40 square icon button used in the home header (mockup style).
|
||||
class _HeaderButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback onTap;
|
||||
const _HeaderButton({required this.icon, required this.tooltip, required this.onTap});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||||
border: Border.all(color: DriverVault.isDark(context) ? DriverVault.darkBorderStrong : DriverVault.ink200),
|
||||
),
|
||||
child: Icon(icon, size: 19, color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CarCard extends StatelessWidget {
|
||||
final _CarRow row;
|
||||
final Future<void> Function() onChanged;
|
||||
const _CarCard({required this.row, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = serviceStatus(row.latest, row.car);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => CarDetailScreen(carId: row.car.id)),
|
||||
);
|
||||
await onChanged();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(row.car.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16, letterSpacing: -0.3)),
|
||||
if (row.car.subtitle.isNotEmpty)
|
||||
Text(row.car.subtitle,
|
||||
style: TextStyle(color: DriverVault.muted(context), fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
_Badge(status: status),
|
||||
],
|
||||
),
|
||||
if (!row.car.isOwner) ...[
|
||||
const SizedBox(height: 8),
|
||||
_SharedChip(readOnly: row.car.isReadOnly),
|
||||
],
|
||||
..._serviceLife(context, status),
|
||||
const SizedBox(height: 12),
|
||||
_kv(context, "Last service", formatDate(row.latest?.date)),
|
||||
_kv(context, "Current odometer", formatKm(row.car.currentKm)),
|
||||
_kv(context, "Next due", formatDate(row.latest?.nextServiceDate)),
|
||||
_kv(context, "Next due km", formatKm(row.latest?.nextServiceKm)),
|
||||
const SizedBox(height: 6),
|
||||
Text("${row.count} service record${row.count == 1 ? '' : 's'}",
|
||||
style: TextStyle(color: DriverVault.muted(context), fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kv(BuildContext context, String k, String v) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(k, style: TextStyle(color: DriverVault.muted(context), fontSize: 13)),
|
||||
Text(v, style: DriverVault.mono(context, size: 13, weight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// Service-life bar: fraction of the km interval used up (echoes the mockup's
|
||||
/// battery bar). Returns [] when there isn't enough data to compute it.
|
||||
List<Widget> _serviceLife(BuildContext context, Status status) {
|
||||
final interval = row.car.serviceIntervalKm;
|
||||
final nextKm = row.latest?.nextServiceKm ?? 0;
|
||||
final currentKm = row.car.currentKm;
|
||||
if (interval <= 0 || nextKm <= 0 || currentKm <= 0) return const [];
|
||||
final remaining = nextKm - currentKm;
|
||||
final pct = (100 * (1 - remaining / interval)).clamp(0, 100).round();
|
||||
final tone = status.fg(DriverVault.isDark(context));
|
||||
return [
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text("SERVICE LIFE",
|
||||
style: DriverVault.mono(context, size: 9, weight: FontWeight.w500,
|
||||
color: DriverVault.muted(context)).copyWith(letterSpacing: 1.6)),
|
||||
Text("$pct%",
|
||||
style: DriverVault.mono(context, size: 11, weight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
child: LinearProgressIndicator(
|
||||
value: pct / 100,
|
||||
minHeight: 7,
|
||||
backgroundColor: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(tone),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class _SharedChip extends StatelessWidget {
|
||||
final bool readOnly;
|
||||
const _SharedChip({required this.readOnly});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: DriverVault.brandTint(context),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
readOnly ? "Shared · read-only" : "Shared",
|
||||
style: TextStyle(color: DriverVault.brandOnTint(context), fontSize: 11, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Badge extends StatelessWidget {
|
||||
final Status status;
|
||||
const _Badge({required this.status});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: status.bg(DriverVault.isDark(context)), borderRadius: BorderRadius.circular(999)),
|
||||
child: Text(status.label,
|
||||
style: TextStyle(color: status.fg(DriverVault.isDark(context)), fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorView extends StatelessWidget {
|
||||
final String message;
|
||||
final Future<void> Function() onRetry;
|
||||
const _ErrorView({required this.message, required this.onRetry});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
children: [
|
||||
const SizedBox(height: 80),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: DriverVault.danger, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(onPressed: onRetry, child: const Text("Retry")),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "../biometric.dart";
|
||||
import "../main.dart";
|
||||
import "../theme.dart";
|
||||
|
||||
/// Shown when the app is authenticated but locked (biometric login is enabled
|
||||
/// and the app was just launched or resumed). The session token is still valid;
|
||||
/// a successful biometric check simply reveals the app. "Use password" drops the
|
||||
/// session and returns to the login screen.
|
||||
class LockScreen extends StatefulWidget {
|
||||
const LockScreen({super.key});
|
||||
@override
|
||||
State<LockScreen> createState() => _LockScreenState();
|
||||
}
|
||||
|
||||
class _LockScreenState extends State<LockScreen> {
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Prompt once as soon as the lock screen appears.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _unlock());
|
||||
}
|
||||
|
||||
Future<void> _unlock() async {
|
||||
if (_busy) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final creds = await biometricAuth.unlock(reason: "Unlock DriverVault");
|
||||
if (creds == null) {
|
||||
// Cancelled — leave locked; the buttons let them retry or use password.
|
||||
if (mounted) setState(() => _busy = false);
|
||||
return;
|
||||
}
|
||||
// Token from a previous session is still held by ApiClient; just reveal.
|
||||
authService.unlock();
|
||||
} on BiometricException catch (e) {
|
||||
if (mounted) setState(() => _error = e.message);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final u = authService.user;
|
||||
final name = u == null ? null : (u.name.isNotEmpty ? u.name : u.email);
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: DriverVault.brand700,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(Icons.lock_outline, color: Colors.white, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text("DriverVault is locked",
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4)),
|
||||
if (name != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(name, style: TextStyle(color: DriverVault.muted(context))),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
SizedBox(
|
||||
width: 260,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _busy ? null : _unlock,
|
||||
icon: const Icon(Icons.fingerprint),
|
||||
label: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Text(_busy ? "Unlocking…" : "Unlock"),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: _busy ? null : () => authService.logout(),
|
||||
child: const Text("Use password instead"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "../api.dart";
|
||||
import "../biometric.dart";
|
||||
import "../config.dart";
|
||||
import "../main.dart";
|
||||
import "../theme.dart";
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _email = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
final _server = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _loading = false;
|
||||
bool _showPassword = false;
|
||||
String? _error;
|
||||
String? _serverSaved;
|
||||
|
||||
// Biometric ("Face recognition" / "Fingerprint") sign-in state.
|
||||
bool _bioAvailable = false;
|
||||
bool _bioEnabled = false;
|
||||
bool _hasFace = false;
|
||||
bool _hasFingerprint = false;
|
||||
String? _bioEmail;
|
||||
bool _autoPrompted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
apiClient.serverOverride().then((v) {
|
||||
if (mounted) _server.text = v;
|
||||
});
|
||||
_initBiometrics();
|
||||
}
|
||||
|
||||
Future<void> _initBiometrics() async {
|
||||
final available = await biometricAuth.isAvailable();
|
||||
final email = await biometricAuth.enabledEmail();
|
||||
final face = available && await biometricAuth.hasFace();
|
||||
final finger = available && await biometricAuth.hasFingerprint();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_bioAvailable = available;
|
||||
_bioEnabled = email != null;
|
||||
_bioEmail = email;
|
||||
_hasFace = face;
|
||||
_hasFingerprint = finger;
|
||||
});
|
||||
// If biometric login is set up, offer it immediately (once) on screen load.
|
||||
if (_bioEnabled && _bioAvailable && !_autoPrompted) {
|
||||
_autoPrompted = true;
|
||||
_biometricLogin();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_email.dispose();
|
||||
_password.dispose();
|
||||
_server.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveServer() async {
|
||||
await apiClient.setServerUrl(_server.text);
|
||||
final current = await apiClient.serverOverride();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_server.text = current;
|
||||
_serverSaved = "Saved ✓";
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _resetServer() async {
|
||||
await apiClient.setServerUrl("");
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_server.clear();
|
||||
_serverSaved = "Reset ✓";
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
final email = _email.text.trim();
|
||||
final password = _password.text;
|
||||
try {
|
||||
await authService.login(email, password);
|
||||
// Pull the full profile so saved appearance prefs (theme/font/date) apply.
|
||||
try {
|
||||
appSettings.applyFromProfile(await apiClient.getMe());
|
||||
} catch (_) {}
|
||||
// Offer to remember these (known-good) credentials for biometric login.
|
||||
await _maybeOfferBiometricEnrollment(email, password);
|
||||
// The app root rebuilds to the dashboard via the auth listener.
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// After a successful password login, if the device supports biometrics and
|
||||
/// they aren't set up yet, ask whether to enable biometric sign-in.
|
||||
Future<void> _maybeOfferBiometricEnrollment(String email, String password) async {
|
||||
if (!_bioAvailable || _bioEnabled || !mounted) return;
|
||||
final method = _hasFace && !_hasFingerprint
|
||||
? "face recognition"
|
||||
: _hasFingerprint && !_hasFace
|
||||
? "your fingerprint"
|
||||
: "biometrics";
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Enable biometric sign-in?"),
|
||||
content: Text(
|
||||
"Next time you can sign in with $method instead of typing your password."),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Not now")),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text("Enable")),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await biometricAuth.enable(email, password);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _biometricLogin() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final creds = await biometricAuth.unlock(
|
||||
reason: _bioEmail != null ? "Sign in as $_bioEmail" : "Sign in to DriverVault",
|
||||
);
|
||||
if (creds == null) {
|
||||
// User cancelled the prompt, or nothing is stored.
|
||||
if (mounted) setState(() => _loading = false);
|
||||
return;
|
||||
}
|
||||
await authService.login(creds.$1, creds.$2);
|
||||
try {
|
||||
appSettings.applyFromProfile(await apiClient.getMe());
|
||||
} catch (_) {}
|
||||
} on BiometricException catch (e) {
|
||||
if (mounted) setState(() => _error = e.message);
|
||||
} on ApiException catch (e) {
|
||||
// Stored credentials no longer work (e.g. password changed) — turn off
|
||||
// biometric login and fall back to the password form.
|
||||
if (e.status == 400 || e.status == 401) {
|
||||
await biometricAuth.disable();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_bioEnabled = false;
|
||||
_error = "Saved sign-in is no longer valid. Please sign in with your password.";
|
||||
});
|
||||
}
|
||||
} else if (mounted) {
|
||||
setState(() => _error = e.message);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Biometric sign-in buttons, shown only once biometric login is set up on
|
||||
/// this device. Labels reflect the enrolled biometric kind(s).
|
||||
Widget _buildBiometricSection() {
|
||||
if (!_bioEnabled || !_bioAvailable) return const SizedBox.shrink();
|
||||
|
||||
final buttons = <Widget>[];
|
||||
Widget bioButton(IconData icon, String label) => Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _loading ? null : _biometricLogin,
|
||||
icon: Icon(icon),
|
||||
label: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(label),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (_hasFace) buttons.add(bioButton(Icons.face, "Sign in with face recognition"));
|
||||
if (_hasFingerprint) buttons.add(bioButton(Icons.fingerprint, "Sign in with fingerprint"));
|
||||
if (buttons.isEmpty) buttons.add(bioButton(Icons.lock_outline, "Sign in with biometrics"));
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text("or", style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
),
|
||||
Expanded(child: Divider()),
|
||||
],
|
||||
),
|
||||
),
|
||||
...buttons,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 380),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const DriverVaultLogo(markSize: 40, fontSize: 30),
|
||||
const SizedBox(height: 10),
|
||||
Text("Your car, on track.",
|
||||
style: TextStyle(color: DriverVault.muted(context))),
|
||||
const SizedBox(height: 24),
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
if (_error != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||||
),
|
||||
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _email,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder()),
|
||||
validator: (v) => (v == null || v.isEmpty) ? "Required" : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _password,
|
||||
obscureText: !_showPassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Password",
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility),
|
||||
tooltip: _showPassword ? "Hide password" : "Show password",
|
||||
onPressed: () => setState(() => _showPassword = !_showPassword),
|
||||
),
|
||||
),
|
||||
validator: (v) => (v == null || v.isEmpty) ? "Required" : null,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _loading ? null : _submit,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text(_loading ? "Signing in…" : "Sign in"),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildBiometricSection(),
|
||||
const SizedBox(height: 8),
|
||||
_ServerSettings(
|
||||
controller: _server,
|
||||
savedNote: _serverSaved,
|
||||
onSave: _saveServer,
|
||||
onReset: _resetServer,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapsible "Server settings" on the login screen: an editable API server URL
|
||||
/// override (blank = use the compile-time default). Mirrors the web app.
|
||||
class _ServerSettings extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String? savedNote;
|
||||
final Future<void> Function() onSave;
|
||||
final Future<void> Function() onReset;
|
||||
const _ServerSettings({
|
||||
required this.controller,
|
||||
required this.savedNote,
|
||||
required this.onSave,
|
||||
required this.onReset,
|
||||
});
|
||||
@override
|
||||
State<_ServerSettings> createState() => _ServerSettingsState();
|
||||
}
|
||||
|
||||
class _ServerSettingsState extends State<_ServerSettings> {
|
||||
bool _open = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => setState(() => _open = !_open),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text("Server settings",
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey)),
|
||||
Icon(_open ? Icons.expand_less : Icons.expand_more, size: 18, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_open) ...[
|
||||
TextField(
|
||||
controller: widget.controller,
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "API server URL",
|
||||
hintText: kDefaultApiBase,
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 4),
|
||||
child: Text("Leave blank to use the default.",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 11)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton(onPressed: widget.onSave, child: const Text("Save")),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(onPressed: widget.onReset, child: const Text("Reset")),
|
||||
if (widget.savedNote != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Text(widget.savedNote!,
|
||||
style: const TextStyle(color: DriverVault.success, fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "../main.dart";
|
||||
import "dashboard_screen.dart";
|
||||
import "settings_screen.dart";
|
||||
import "admin_users_screen.dart";
|
||||
|
||||
/// The app's root shell — a persistent bottom navigation bar (DriverVault mockup
|
||||
/// layout) hosting the Garage, Settings and (for admins) Users sections in an
|
||||
/// IndexedStack so each keeps its state as you switch tabs.
|
||||
class RootShell extends StatefulWidget {
|
||||
const RootShell({super.key});
|
||||
@override
|
||||
State<RootShell> createState() => _RootShellState();
|
||||
}
|
||||
|
||||
class _RootShellState extends State<RootShell> {
|
||||
int _index = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isAdmin = authService.user?.isAdmin == true;
|
||||
|
||||
final pages = <Widget>[
|
||||
const DashboardScreen(),
|
||||
const SettingsScreen(),
|
||||
if (isAdmin) const AdminUsersScreen(),
|
||||
];
|
||||
|
||||
final destinations = <NavigationDestination>[
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.grid_view_outlined),
|
||||
selectedIcon: Icon(Icons.grid_view_rounded),
|
||||
label: "Garage",
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: "Settings",
|
||||
),
|
||||
if (isAdmin)
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.group_outlined),
|
||||
selectedIcon: Icon(Icons.group),
|
||||
label: "Users",
|
||||
),
|
||||
];
|
||||
|
||||
// Guard against the index falling out of range if admin status changes.
|
||||
final index = _index.clamp(0, pages.length - 1);
|
||||
|
||||
return Scaffold(
|
||||
body: IndexedStack(index: index, children: pages),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: index,
|
||||
onDestinationSelected: (i) => setState(() => _index = i),
|
||||
destinations: destinations,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,915 @@
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:flutter/material.dart";
|
||||
import "package:image_picker/image_picker.dart";
|
||||
|
||||
import "../biometric.dart";
|
||||
import "../main.dart";
|
||||
import "../models.dart";
|
||||
import "../format.dart";
|
||||
import "../theme.dart";
|
||||
|
||||
/// Full settings panel, mirroring the web Settings.vue (minus data export/import):
|
||||
/// account (name/email/password), appearance (theme/locale/date/font), profile
|
||||
/// (avatar/bio), privacy (sessions), and account deletion.
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
UserProfile? _profile;
|
||||
List<Session> _sessions = [];
|
||||
Uint8List? _avatar;
|
||||
bool _loading = true;
|
||||
String? _loadError;
|
||||
|
||||
final _name = TextEditingController();
|
||||
final _bio = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
_bio.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_loadError = null;
|
||||
});
|
||||
try {
|
||||
final p = await apiClient.getMe();
|
||||
appSettings.applyFromProfile(p);
|
||||
final sessions = await apiClient.listSessions();
|
||||
Uint8List? avatar;
|
||||
if (p.hasAvatar) {
|
||||
final bytes = await apiClient.getAvatarBytes();
|
||||
if (bytes != null) avatar = Uint8List.fromList(bytes);
|
||||
}
|
||||
_name.text = p.name;
|
||||
_bio.text = p.bio;
|
||||
setState(() {
|
||||
_profile = p;
|
||||
_sessions = sessions;
|
||||
_avatar = avatar;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _loadError = e.toString());
|
||||
} finally {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _snack(String msg) =>
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Settings")),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _loadError != null
|
||||
? Center(child: Text(_loadError!))
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
_AccountSection(
|
||||
profile: _profile!,
|
||||
nameController: _name,
|
||||
onChanged: _load,
|
||||
snack: _snack,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_AppearanceSection(onError: _snack),
|
||||
const SizedBox(height: 12),
|
||||
_ProfileSection(
|
||||
profile: _profile!,
|
||||
bioController: _bio,
|
||||
avatar: _avatar,
|
||||
onChanged: _load,
|
||||
snack: _snack,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SecuritySection(email: _profile!.email, snack: _snack),
|
||||
const SizedBox(height: 12),
|
||||
_SessionsSection(sessions: _sessions, onChanged: _load, snack: _snack),
|
||||
const SizedBox(height: 12),
|
||||
_DangerSection(profile: _profile!, onChanged: _load, snack: _snack),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A titled card wrapper matching the web's sectioned layout.
|
||||
class _Card extends StatelessWidget {
|
||||
final String title;
|
||||
final Color? titleColor;
|
||||
final List<Widget> children;
|
||||
const _Card({required this.title, this.titleColor, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600, color: titleColor)),
|
||||
const SizedBox(height: 12),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Account ---------------------------------------------------------------
|
||||
|
||||
class _AccountSection extends StatefulWidget {
|
||||
final UserProfile profile;
|
||||
final TextEditingController nameController;
|
||||
final Future<void> Function() onChanged;
|
||||
final void Function(String) snack;
|
||||
const _AccountSection({
|
||||
required this.profile,
|
||||
required this.nameController,
|
||||
required this.onChanged,
|
||||
required this.snack,
|
||||
});
|
||||
@override
|
||||
State<_AccountSection> createState() => _AccountSectionState();
|
||||
}
|
||||
|
||||
class _AccountSectionState extends State<_AccountSection> {
|
||||
bool _savingName = false;
|
||||
bool _verifying = false;
|
||||
|
||||
final _oldPw = TextEditingController();
|
||||
final _newPw = TextEditingController();
|
||||
final _confirmPw = TextEditingController();
|
||||
bool _savingPw = false;
|
||||
String? _pwError;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_oldPw.dispose();
|
||||
_newPw.dispose();
|
||||
_confirmPw.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveName() async {
|
||||
setState(() => _savingName = true);
|
||||
try {
|
||||
final updated = await apiClient.updateMe({"name": widget.nameController.text.trim()});
|
||||
authService.user = AuthUser(
|
||||
id: updated.id,
|
||||
email: updated.email,
|
||||
name: updated.name,
|
||||
role: updated.role,
|
||||
);
|
||||
widget.snack("Name saved.");
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
} finally {
|
||||
if (mounted) setState(() => _savingName = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendVerification() async {
|
||||
setState(() => _verifying = true);
|
||||
try {
|
||||
await apiClient.requestVerification();
|
||||
widget.snack("Verification email requested.");
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
} finally {
|
||||
if (mounted) setState(() => _verifying = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _savePassword() async {
|
||||
setState(() => _pwError = null);
|
||||
if (_newPw.text.length < 8) {
|
||||
setState(() => _pwError = "New password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
if (_newPw.text != _confirmPw.text) {
|
||||
setState(() => _pwError = "New password and confirmation don't match.");
|
||||
return;
|
||||
}
|
||||
setState(() => _savingPw = true);
|
||||
try {
|
||||
await apiClient.changePassword(_oldPw.text, _newPw.text);
|
||||
_oldPw.clear();
|
||||
_newPw.clear();
|
||||
_confirmPw.clear();
|
||||
widget.snack("Password updated.");
|
||||
} catch (e) {
|
||||
setState(() => _pwError = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _savingPw = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = widget.profile;
|
||||
return _Card(
|
||||
title: "Account",
|
||||
children: [
|
||||
const Text("Name", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 4),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: widget.nameController,
|
||||
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: _savingName ? null : _saveName,
|
||||
child: Text(_savingName ? "Saving…" : "Save"),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
const Text("Email", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 4),
|
||||
Row(children: [
|
||||
Expanded(child: Text(p.email)),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: p.verified
|
||||
? (DriverVault.isDark(context) ? DriverVault.successSoftDark : DriverVault.successSoft)
|
||||
: (DriverVault.isDark(context) ? DriverVault.warningSoftDark : DriverVault.warningSoft),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(p.verified ? "Verified" : "Not verified",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: p.verified ? DriverVault.success : DriverVault.warning)),
|
||||
),
|
||||
]),
|
||||
if (!p.verified)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: _verifying ? null : _sendVerification,
|
||||
child: Text(_verifying ? "Sending…" : "Resend verification email"),
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
const Text("Change password", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _oldPw,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Current password", border: OutlineInputBorder(), isDense: true),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _newPw,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "New password (min 8)", border: OutlineInputBorder(), isDense: true),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _confirmPw,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Confirm new password", border: OutlineInputBorder(), isDense: true),
|
||||
),
|
||||
if (_pwError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(_pwError!, style: const TextStyle(color: DriverVault.danger, fontSize: 13)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton(
|
||||
onPressed: _savingPw ? null : _savePassword,
|
||||
child: Text(_savingPw ? "Updating…" : "Update password"),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Appearance ------------------------------------------------------------
|
||||
|
||||
class _AppearanceSection extends StatefulWidget {
|
||||
final void Function(String) onError;
|
||||
const _AppearanceSection({required this.onError});
|
||||
@override
|
||||
State<_AppearanceSection> createState() => _AppearanceSectionState();
|
||||
}
|
||||
|
||||
class _AppearanceSectionState extends State<_AppearanceSection> {
|
||||
Future<void> _save(Map<String, dynamic> patch) async {
|
||||
// Optimistic: apply locally first, roll back on failure.
|
||||
final prev = {
|
||||
"theme": appSettings.theme,
|
||||
"locale": appSettings.locale,
|
||||
"dateFormat": appSettings.dateFormat,
|
||||
"fontSize": appSettings.fontSize,
|
||||
};
|
||||
appSettings.patch(
|
||||
theme: patch["theme"],
|
||||
locale: patch["locale"],
|
||||
dateFormat: patch["dateFormat"],
|
||||
fontSize: patch["fontSize"],
|
||||
);
|
||||
setState(() {});
|
||||
try {
|
||||
await apiClient.updateMe(patch);
|
||||
} catch (e) {
|
||||
appSettings.patch(
|
||||
theme: prev["theme"],
|
||||
locale: prev["locale"],
|
||||
dateFormat: prev["dateFormat"],
|
||||
fontSize: prev["fontSize"],
|
||||
);
|
||||
setState(() {});
|
||||
widget.onError("$e");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _Card(
|
||||
title: "Appearance",
|
||||
children: [
|
||||
const Text("Theme", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
SegmentedButton<String>(
|
||||
segments: const [
|
||||
ButtonSegment(value: "light", label: Text("Light")),
|
||||
ButtonSegment(value: "dark", label: Text("Dark")),
|
||||
ButtonSegment(value: "system", label: Text("System")),
|
||||
],
|
||||
selected: {appSettings.theme},
|
||||
onSelectionChanged: (s) => _save({"theme": s.first}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text("Language & region", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: appSettings.locale,
|
||||
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
||||
items: const [
|
||||
DropdownMenuItem(value: "en-US", child: Text("English (US)")),
|
||||
DropdownMenuItem(value: "en-GB", child: Text("English (UK)")),
|
||||
DropdownMenuItem(value: "pl-PL", child: Text("Polski")),
|
||||
DropdownMenuItem(value: "de-DE", child: Text("Deutsch")),
|
||||
DropdownMenuItem(value: "fr-FR", child: Text("Français")),
|
||||
DropdownMenuItem(value: "es-ES", child: Text("Español")),
|
||||
],
|
||||
onChanged: (v) => v == null ? null : _save({"locale": v}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text("Date format", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: appSettings.dateFormat,
|
||||
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
||||
items: const [
|
||||
DropdownMenuItem(value: "YMD", child: Text("YYYY-MM-DD")),
|
||||
DropdownMenuItem(value: "DMY_NUM", child: Text("DD-MM-YYYY")),
|
||||
DropdownMenuItem(value: "DMY", child: Text("DD Mon YYYY")),
|
||||
DropdownMenuItem(value: "MDY", child: Text("Mon DD, YYYY")),
|
||||
],
|
||||
onChanged: (v) => v == null ? null : _save({"dateFormat": v}),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text("Example: ${formatDate(DateTime.now())}",
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text("Font size", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
SegmentedButton<String>(
|
||||
segments: const [
|
||||
ButtonSegment(value: "small", label: Text("Small")),
|
||||
ButtonSegment(value: "medium", label: Text("Medium")),
|
||||
ButtonSegment(value: "large", label: Text("Large")),
|
||||
],
|
||||
selected: {appSettings.fontSize},
|
||||
onSelectionChanged: (s) => _save({"fontSize": s.first}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Profile (avatar + bio) ------------------------------------------------
|
||||
|
||||
class _ProfileSection extends StatefulWidget {
|
||||
final UserProfile profile;
|
||||
final TextEditingController bioController;
|
||||
final Uint8List? avatar;
|
||||
final Future<void> Function() onChanged;
|
||||
final void Function(String) snack;
|
||||
const _ProfileSection({
|
||||
required this.profile,
|
||||
required this.bioController,
|
||||
required this.avatar,
|
||||
required this.onChanged,
|
||||
required this.snack,
|
||||
});
|
||||
@override
|
||||
State<_ProfileSection> createState() => _ProfileSectionState();
|
||||
}
|
||||
|
||||
class _ProfileSectionState extends State<_ProfileSection> {
|
||||
bool _avatarBusy = false;
|
||||
bool _savingBio = false;
|
||||
|
||||
Future<void> _pickAvatar() async {
|
||||
final picker = ImagePicker();
|
||||
final file = await picker.pickImage(source: ImageSource.gallery, maxWidth: 1024);
|
||||
if (file == null) return;
|
||||
setState(() => _avatarBusy = true);
|
||||
try {
|
||||
final bytes = await file.readAsBytes();
|
||||
await apiClient.uploadAvatar(bytes, file.name);
|
||||
await widget.onChanged();
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
} finally {
|
||||
if (mounted) setState(() => _avatarBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeAvatar() async {
|
||||
setState(() => _avatarBusy = true);
|
||||
try {
|
||||
await apiClient.deleteAvatar();
|
||||
await widget.onChanged();
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
} finally {
|
||||
if (mounted) setState(() => _avatarBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveBio() async {
|
||||
setState(() => _savingBio = true);
|
||||
try {
|
||||
await apiClient.updateMe({"bio": widget.bioController.text});
|
||||
widget.snack("Bio saved.");
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
} finally {
|
||||
if (mounted) setState(() => _savingBio = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = widget.profile;
|
||||
final initial = (p.name.isNotEmpty ? p.name : p.email).characters.first.toUpperCase();
|
||||
return _Card(
|
||||
title: "Profile",
|
||||
children: [
|
||||
Row(children: [
|
||||
CircleAvatar(
|
||||
radius: 32,
|
||||
backgroundColor: DriverVault.brandTint(context),
|
||||
backgroundImage: widget.avatar != null ? MemoryImage(widget.avatar!) : null,
|
||||
child: widget.avatar == null
|
||||
? Text(initial,
|
||||
style: TextStyle(
|
||||
fontSize: 22, fontWeight: FontWeight.w700, color: DriverVault.brandOnTint(context)))
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Wrap(spacing: 8, children: [
|
||||
OutlinedButton(
|
||||
onPressed: _avatarBusy ? null : _pickAvatar,
|
||||
child: Text(_avatarBusy ? "Working…" : "Upload photo"),
|
||||
),
|
||||
if (p.hasAvatar)
|
||||
OutlinedButton(
|
||||
onPressed: _avatarBusy ? null : _removeAvatar,
|
||||
child: const Text("Remove"),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
const Text("Bio", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 4),
|
||||
TextField(
|
||||
controller: widget.bioController,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: "A short note visible to other people in your household.",
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton(
|
||||
onPressed: _savingBio ? null : _saveBio,
|
||||
child: Text(_savingBio ? "Saving…" : "Save bio"),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Security (biometric sign-in) ------------------------------------------
|
||||
|
||||
class _SecuritySection extends StatefulWidget {
|
||||
final String email;
|
||||
final void Function(String) snack;
|
||||
const _SecuritySection({required this.email, required this.snack});
|
||||
@override
|
||||
State<_SecuritySection> createState() => _SecuritySectionState();
|
||||
}
|
||||
|
||||
class _SecuritySectionState extends State<_SecuritySection> {
|
||||
bool _available = false;
|
||||
bool _enabled = false;
|
||||
bool _hasFace = false;
|
||||
bool _hasFingerprint = false;
|
||||
bool _busy = false;
|
||||
bool _loaded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refresh();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
final available = await biometricAuth.isAvailable();
|
||||
final enabled = await biometricAuth.isEnabled();
|
||||
final face = available && await biometricAuth.hasFace();
|
||||
final finger = available && await biometricAuth.hasFingerprint();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_available = available;
|
||||
_enabled = enabled;
|
||||
_hasFace = face;
|
||||
_hasFingerprint = finger;
|
||||
_loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
String get _methodLabel {
|
||||
if (_hasFace && _hasFingerprint) return "face or fingerprint";
|
||||
if (_hasFace) return "face recognition";
|
||||
if (_hasFingerprint) return "fingerprint";
|
||||
return "biometrics";
|
||||
}
|
||||
|
||||
Future<void> _toggle(bool value) async {
|
||||
if (_busy) return;
|
||||
if (!value) {
|
||||
setState(() => _busy = true);
|
||||
await biometricAuth.disable();
|
||||
if (mounted) setState(() { _enabled = false; _busy = false; });
|
||||
widget.snack("Biometric sign-in turned off");
|
||||
return;
|
||||
}
|
||||
// Enabling requires re-confirming the password so we store known-good creds.
|
||||
final pw = await _promptPassword();
|
||||
if (pw == null || pw.isEmpty) return;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
// Verify the password (and refresh the session) before storing it.
|
||||
await authService.login(widget.email, pw);
|
||||
await biometricAuth.enable(widget.email, pw);
|
||||
if (mounted) setState(() { _enabled = true; _busy = false; });
|
||||
widget.snack("Biometric sign-in enabled");
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
widget.snack("Could not enable: ${e.toString()}");
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _promptPassword() {
|
||||
final ctrl = TextEditingController();
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Confirm your password"),
|
||||
content: TextField(
|
||||
controller: ctrl,
|
||||
obscureText: true,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Password",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (v) => Navigator.pop(ctx, v),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancel")),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, ctrl.text), child: const Text("Confirm")),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _Card(
|
||||
title: "Security",
|
||||
children: [
|
||||
if (!_loaded)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text("Checking device…", style: TextStyle(color: Colors.grey)),
|
||||
)
|
||||
else ...[
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text("Biometric sign-in"),
|
||||
subtitle: Text(_available
|
||||
? "Sign in with $_methodLabel instead of your password."
|
||||
: "No biometrics are enrolled on this device."),
|
||||
value: _enabled,
|
||||
onChanged: (!_available || _busy) ? null : _toggle,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Privacy & security (sessions) -----------------------------------------
|
||||
|
||||
class _SessionsSection extends StatefulWidget {
|
||||
final List<Session> sessions;
|
||||
final Future<void> Function() onChanged;
|
||||
final void Function(String) snack;
|
||||
const _SessionsSection({required this.sessions, required this.onChanged, required this.snack});
|
||||
@override
|
||||
State<_SessionsSection> createState() => _SessionsSectionState();
|
||||
}
|
||||
|
||||
class _SessionsSectionState extends State<_SessionsSection> {
|
||||
Future<void> _revoke(Session s) async {
|
||||
try {
|
||||
await apiClient.revokeSession(s.id);
|
||||
if (s.current) {
|
||||
await authService.logout();
|
||||
return;
|
||||
}
|
||||
await widget.onChanged();
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _revokeOthers() async {
|
||||
try {
|
||||
await apiClient.revokeOtherSessions();
|
||||
await widget.onChanged();
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _Card(
|
||||
title: "Privacy & security",
|
||||
children: [
|
||||
const Text(
|
||||
"Two-factor authentication isn't available yet. Active sessions below reflect every device currently signed in.",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13),
|
||||
),
|
||||
if (widget.sessions.length > 1)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: _revokeOthers,
|
||||
child: const Text("Log out all other devices"),
|
||||
),
|
||||
),
|
||||
...widget.sessions.map((s) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Flexible(
|
||||
child: Text(s.deviceLabel,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||
if (s.current)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(left: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: DriverVault.brandTint(context),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text("This device",
|
||||
style: TextStyle(fontSize: 11, color: DriverVault.brandOnTint(context))),
|
||||
),
|
||||
]),
|
||||
Text("${s.ip} · signed in ${formatDate(s.created)}",
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _revoke(s),
|
||||
child: const Text("Log out", style: TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Danger zone (account deletion) ----------------------------------------
|
||||
|
||||
class _DangerSection extends StatefulWidget {
|
||||
final UserProfile profile;
|
||||
final Future<void> Function() onChanged;
|
||||
final void Function(String) snack;
|
||||
const _DangerSection({required this.profile, required this.onChanged, required this.snack});
|
||||
@override
|
||||
State<_DangerSection> createState() => _DangerSectionState();
|
||||
}
|
||||
|
||||
class _DangerSectionState extends State<_DangerSection> {
|
||||
final _confirmEmail = TextEditingController();
|
||||
bool _showConfirm = false;
|
||||
bool _busy = false;
|
||||
DateTime? _eligibleAt;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_confirmEmail.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _pending => widget.profile.deletionPending;
|
||||
DateTime? get _eligible {
|
||||
if (!_pending) return null;
|
||||
return _eligibleAt ??
|
||||
widget.profile.deletionRequestedAt!.add(const Duration(days: 3));
|
||||
}
|
||||
|
||||
bool get _cooldownElapsed => _eligible != null && DateTime.now().isAfter(_eligible!);
|
||||
|
||||
Future<void> _request() async {
|
||||
if (_confirmEmail.text.trim().toLowerCase() != widget.profile.email.toLowerCase()) {
|
||||
widget.snack("Type your email to confirm.");
|
||||
return;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
_eligibleAt = await apiClient.requestAccountDeletion(_confirmEmail.text.trim());
|
||||
_showConfirm = false;
|
||||
_confirmEmail.clear();
|
||||
await widget.onChanged();
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cancel() async {
|
||||
try {
|
||||
await apiClient.cancelAccountDeletion();
|
||||
_eligibleAt = null;
|
||||
await widget.onChanged();
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _finalize() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Delete account?"),
|
||||
content: const Text("This permanently deletes your account. This cannot be undone."),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text("Delete"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
try {
|
||||
await apiClient.finalizeAccountDeletion();
|
||||
await authService.logout();
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = widget.profile;
|
||||
return _Card(
|
||||
title: "Danger zone",
|
||||
titleColor: DriverVault.danger,
|
||||
children: [
|
||||
if (!_pending) ...[
|
||||
const Text(
|
||||
"Deleting your account removes your login and profile. It does not delete your household's shared cars or service history. There's a 3-day cooldown before deletion is final, and you can cancel any time before then.",
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (!_showConfirm)
|
||||
OutlinedButton(
|
||||
onPressed: () => setState(() => _showConfirm = true),
|
||||
style: OutlinedButton.styleFrom(foregroundColor: DriverVault.danger),
|
||||
child: const Text("Delete my account"),
|
||||
)
|
||||
else ...[
|
||||
Text("Type ${p.email} to confirm",
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: _confirmEmail,
|
||||
decoration: InputDecoration(
|
||||
hintText: p.email, border: const OutlineInputBorder(), isDense: true),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(children: [
|
||||
TextButton(
|
||||
onPressed: () => setState(() {
|
||||
_showConfirm = false;
|
||||
_confirmEmail.clear();
|
||||
}),
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
|
||||
onPressed: _busy ? null : _request,
|
||||
child: Text(_busy ? "Requesting…" : "Request deletion"),
|
||||
),
|
||||
]),
|
||||
],
|
||||
] else ...[
|
||||
Text(
|
||||
"Account deletion requested on ${formatDate(p.deletionRequestedAt)}. "
|
||||
"${_cooldownElapsed ? 'The cooldown has passed. You can now finalize the deletion.' : 'You can still cancel — it becomes permanent after the 3-day cooldown.'}",
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(children: [
|
||||
OutlinedButton(onPressed: _cancel, child: const Text("Cancel deletion request")),
|
||||
const SizedBox(width: 8),
|
||||
if (_cooldownElapsed)
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
|
||||
onPressed: _finalize,
|
||||
child: const Text("Permanently delete"),
|
||||
),
|
||||
]),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user