Initial commit

This commit is contained in:
tajniak81
2026-07-06 08:50:52 +02:00
commit ba3f227361
153 changed files with 18116 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# This is a generated file; do not edit or check into version control.
path_provider_linux=C:\\Users\\jania\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_linux-2.2.1\\
path_provider_windows=C:\\Users\\jania\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_windows-2.3.0\\
shared_preferences=C:\\Users\\jania\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences-2.5.3\\
shared_preferences_android=C:\\Users\\jania\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences_android-2.4.11\\
shared_preferences_foundation=C:\\Users\\jania\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences_foundation-2.5.4\\
shared_preferences_linux=C:\\Users\\jania\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences_linux-2.4.1\\
shared_preferences_web=C:\\Users\\jania\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences_web-2.4.3\\
shared_preferences_windows=C:\\Users\\jania\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences_windows-2.4.1\\
+45
View File
@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+33
View File
@@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "ad70ec4617166f1c38e5d2bfd388af71fda14f06"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: android
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: web
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+102
View File
@@ -0,0 +1,102 @@
# Car Control — Phone App (Flutter)
A Flutter client for the Car Control maintenance tracker. Talks **only** to the
API Server (same contract and JWT auth as the web app). At full feature parity
with the web app (data export/import is the only deliberate omission).
Project name `carcontrol_phone`, package id `com.carcontrole.carcontrol_phone`.
**Android is the supported target** — the older Flutter-web build path is
deprecated.
## Features
- **Login** — email/password against `/api/auth/login`, password show/hide, and a
collapsible **Server settings** section to override the API base URL on-device.
- **Biometric / face sign-in + app lock** — see the dedicated section below.
- **Dashboard** — car list with next-due status badges (date + km, worst-of),
a "shared" chip on cars owned by someone else, pull-to-refresh, **Add car**
FAB, Settings gear, and an admin action (admins only).
- **Car detail** — all spec fields (incl. VIN and transmission / differential /
brake / coolant specs), tabs for **Service history** and **Parts catalog**,
edit car, add/edit/delete service records and parts, a **share** sheet
(owner only), quick odometer update, and delete car (type-to-confirm; cascades).
Actions are gated by the caller's access level (read-only vs write vs owner).
- **Settings** — account (name / email verification / password), appearance
(theme + dark mode, locale, date format, font size), profile (avatar via
`image_picker`, bio), **Security** (biometric toggle), active sessions with
remote logout, and the account-deletion state machine.
- **Admin** — user management screen (list / create / role / reset password /
delete), gated by the admin role.
Sharing/ownership: `Car.access` drives `isOwner` / `canWrite` / `isReadOnly`
getters that gate the UI, mirroring the server's access checks.
## Biometric / face sign-in & app lock
Fingerprint and face-recognition sign-in via `local_auth`, with credentials kept
in Android Keystorebacked secure storage (`flutter_secure_storage`).
- After a successful password login the app offers to **enable biometric login**;
the entered (known-good) credentials are stored securely.
- The login screen then shows **"Sign in with face recognition / fingerprint"**
buttons (labels reflect the enrolled biometric kinds) and auto-prompts once.
On success the stored credentials are replayed against the normal login API, so
each biometric sign-in mints a fresh session. Stale credentials (e.g. after a
password change) auto-disable biometric login.
- **App lock** — the JWT persists, so a valid session normally restores silently.
When biometric login is enabled the app instead starts **locked** (and re-locks
when backgrounded) and shows a lock screen requiring a biometric unlock. A
**30-second grace period** means quick app-switches don't re-lock; a full app
close (process kill) always locks on next launch. "Use password instead" on the
lock screen logs out and returns to the login form.
- Manage it under **Settings → Security** (enabling re-confirms the password).
Android host requirements (already configured, don't revert):
`MainActivity` extends **`FlutterFragmentActivity`** (required by `local_auth`),
and `AndroidManifest.xml` declares `android.permission.USE_BIOMETRIC`.
## Configure the API endpoint
The app talks to `kDefaultApiBase` (see `lib/config.dart`), default
`http://localhost:8080/api`. Override at build time with `--dart-define`, or at
runtime from the login screen's **Server settings** (persisted as `cc_server_url`).
## Run & build
```bash
flutter pub get
# run on a connected device against a LAN server
flutter run -d <device> --dart-define=API_BASE=http://10.2.1.101:8080/api
# build a debug APK for a real phone on the LAN
flutter build apk --debug --dart-define=API_BASE=http://10.2.1.101:8080/api
adb install -r build/app/outputs/flutter-apk/app-debug.apk
adb shell monkey -p com.carcontrole.carcontrol_phone -c android.intent.category.LAUNCHER 1
```
Notes:
- `android/gradle.properties` sets `kotlin.incremental=false` — required because
the project lives on drive `E:` while Gradle/Kotlin caches are on `C:` (the
incremental compiler can't compute cross-root relative paths on Windows).
- `AndroidManifest.xml` sets `android:usesCleartextTraffic="true"` because the API
base is a plain-HTTP LAN URL.
- The API Server must be running and reachable at the configured URL.
## Structure
```
lib/
├── config.dart # default API base URL (kDefaultApiBase)
├── models.dart # Car (+ access getters), ServiceRecord, Part, AuthUser, UserProfile, Session
├── api.dart # ApiClient — the only thing that calls the API Server
├── auth.dart # AuthService (token persistence, app-lock flag, ChangeNotifier)
├── biometric.dart # BiometricAuth — local_auth + secure storage; biometricAuth singleton
├── app_settings.dart # AppSettings (theme/locale/date/font), persisted; drives MaterialApp
├── format.dart # date/km formatting + next-service status (worst-of date/km)
├── main.dart # app root; routes Login / Lock / Dashboard; lifecycle-based re-lock
└── screens/
├── login_screen.dart dashboard_screen.dart car_detail_screen.dart
├── car_form_sheet.dart settings_screen.dart admin_users_screen.dart
└── lock_screen.dart
```
+5
View File
@@ -0,0 +1,5 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_const_constructors: true
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+45
View File
@@ -0,0 +1,45 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.carcontrole.carcontrol_phone"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.carcontrole.carcontrol_phone"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,48 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Biometric (fingerprint / face) sign-in via local_auth. -->
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<application
android:label="DriverVault"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,7 @@
package com.carcontrole.carcontrol_phone
import io.flutter.embedding.android.FlutterFragmentActivity
// local_auth requires a FragmentActivity host (not the default FlutterActivity)
// so the system BiometricPrompt can attach to the activity's fragment manager.
class MainActivity : FlutterFragmentActivity()
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1E40AF</color>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+10
View File
@@ -0,0 +1,10 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
# Kotlin's incremental compiler computes relative paths between the project
# (drive E:) and the Gradle/Kotlin caches (drive C:), which throws
# "this and base files have different roots" on Windows. Disable it.
kotlin.incremental=false
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+254
View File
@@ -0,0 +1,254 @@
import "dart:convert";
import "package:http/http.dart" as http;
import "package:shared_preferences/shared_preferences.dart";
import "config.dart";
import "models.dart";
/// Thrown when the API Server returns a non-2xx response.
class ApiException implements Exception {
final int status;
final String message;
ApiException(this.status, this.message);
@override
String toString() => message;
}
/// The single client for the Car Control API Server. Holds the bearer token and
/// attaches it to every request. On 401 it calls [onUnauthorized] so the app can
/// route back to login.
class ApiClient {
String? token;
void Function()? onUnauthorized;
static const _serverKey = "cc_server_url";
/// The effective API base URL. Defaults to [kDefaultApiBase]; a saved override
/// (login screen "Server settings") replaces it via [loadServerUrl].
String baseUrl = kDefaultApiBase;
/// Loads a saved server-URL override, if any. Call before the first request.
Future<void> loadServerUrl() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_serverKey);
if (saved != null && saved.isNotEmpty) baseUrl = saved;
}
/// The current override URL, or "" when using the default.
Future<String> serverOverride() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_serverKey) ?? "";
}
/// Persists a server-URL override. Blank clears it (reverts to the default).
/// Trailing slashes are trimmed.
Future<void> setServerUrl(String url) async {
final prefs = await SharedPreferences.getInstance();
final trimmed = url.trim().replaceAll(RegExp(r"/+$"), "");
if (trimmed.isEmpty) {
await prefs.remove(_serverKey);
baseUrl = kDefaultApiBase;
} else {
await prefs.setString(_serverKey, trimmed);
baseUrl = trimmed;
}
}
Map<String, String> get _headers => {
"Content-Type": "application/json",
if (token != null) "Authorization": "Bearer $token",
};
Uri _uri(String path) => Uri.parse("$baseUrl$path");
Future<dynamic> _send(String method, String path, {Object? body}) async {
final req = http.Request(method, _uri(path))..headers.addAll(_headers);
if (body != null) req.body = jsonEncode(body);
final streamed = await http.Client().send(req);
final res = await http.Response.fromStream(streamed);
if (res.statusCode == 401 && path != "/auth/login") {
onUnauthorized?.call();
throw ApiException(401, "Session expired — please log in again.");
}
if (res.statusCode == 204 || res.body.isEmpty) return null;
final data = jsonDecode(res.body);
if (res.statusCode < 200 || res.statusCode >= 300) {
final msg = data is Map && data["error"] != null ? data["error"].toString() : res.reasonPhrase;
throw ApiException(res.statusCode, msg ?? "Request failed");
}
return data;
}
// --- auth ---
Future<(String, AuthUser)> login(String email, String password) async {
final data = await _send("POST", "/auth/login", body: {"email": email, "password": password});
return (data["token"] as String, AuthUser.fromJson(Map<String, dynamic>.from(data["user"])));
}
// --- cars ---
Future<List<Car>> listCars() async {
final data = await _send("GET", "/cars") as List;
return data.map((e) => Car.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<Car> getCar(String id) async {
final data = await _send("GET", "/cars/$id");
return Car.fromJson(Map<String, dynamic>.from(data));
}
Future<Car> createCar(Map<String, dynamic> body) async {
final data = await _send("POST", "/cars", body: body);
return Car.fromJson(Map<String, dynamic>.from(data));
}
Future<Car> updateCar(String id, Map<String, dynamic> body) async {
final data = await _send("PATCH", "/cars/$id", body: body);
return Car.fromJson(Map<String, dynamic>.from(data));
}
Future<void> deleteCar(String id) => _send("DELETE", "/cars/$id");
// --- sharing (owner-only) ---
Future<List<CarShare>> listCarShares(String carId) async {
final data = await _send("GET", "/cars/$carId/shares") as List;
return data.map((e) => CarShare.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<CarShare> addCarShare(String carId, String email, String permission) async {
final data = await _send("POST", "/cars/$carId/shares",
body: {"email": email, "permission": permission});
return CarShare.fromJson(Map<String, dynamic>.from(data));
}
Future<void> removeCarShare(String carId, String userId) =>
_send("DELETE", "/cars/$carId/shares/$userId");
// --- admin: user management (admin role only) ---
Future<List<AdminUser>> listUsers() async {
final data = await _send("GET", "/admin/users") as List;
return data.map((e) => AdminUser.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<AdminUser> createUser(
{required String email, required String password, String? name, String role = "user"}) async {
final data = await _send("POST", "/admin/users",
body: {"email": email, "password": password, "name": name ?? "", "role": role});
return AdminUser.fromJson(Map<String, dynamic>.from(data));
}
Future<AdminUser> updateUser(String id, {String? name, String? role}) async {
final body = <String, dynamic>{};
if (name != null) body["name"] = name;
if (role != null) body["role"] = role;
final data = await _send("PATCH", "/admin/users/$id", body: body);
return AdminUser.fromJson(Map<String, dynamic>.from(data));
}
Future<void> setUserPassword(String id, String newPassword) =>
_send("POST", "/admin/users/$id/password", body: {"newPassword": newPassword});
Future<void> deleteUser(String id) => _send("DELETE", "/admin/users/$id");
// --- service records ---
Future<List<ServiceRecord>> listCarServices(String carId) async {
final data = await _send("GET", "/cars/$carId/service-records") as List;
return data.map((e) => ServiceRecord.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<ServiceRecord> createService(Map<String, dynamic> body) async {
final data = await _send("POST", "/service-records", body: body);
return ServiceRecord.fromJson(Map<String, dynamic>.from(data));
}
Future<ServiceRecord> updateService(String id, Map<String, dynamic> body) async {
final data = await _send("PATCH", "/service-records/$id", body: body);
return ServiceRecord.fromJson(Map<String, dynamic>.from(data));
}
Future<void> deleteService(String id) => _send("DELETE", "/service-records/$id");
// --- parts ---
Future<List<Part>> listCarParts(String carId) async {
final data = await _send("GET", "/cars/$carId/parts") as List;
return data.map((e) => Part.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<Part> createPart(Map<String, dynamic> body) async {
final data = await _send("POST", "/parts", body: body);
return Part.fromJson(Map<String, dynamic>.from(data));
}
Future<Part> updatePart(String id, Map<String, dynamic> body) async {
final data = await _send("PATCH", "/parts/$id", body: body);
return Part.fromJson(Map<String, dynamic>.from(data));
}
Future<void> deletePart(String id) => _send("DELETE", "/parts/$id");
// --- settings: profile / account ---
Future<UserProfile> getMe() async {
final data = await _send("GET", "/me");
return UserProfile.fromJson(Map<String, dynamic>.from(data));
}
Future<UserProfile> updateMe(Map<String, dynamic> patch) async {
final data = await _send("PATCH", "/me", body: patch);
return UserProfile.fromJson(Map<String, dynamic>.from(data));
}
Future<void> changePassword(String oldPassword, String newPassword) => _send(
"POST",
"/me/password",
body: {"oldPassword": oldPassword, "newPassword": newPassword},
);
Future<void> requestVerification() => _send("POST", "/me/verify/request");
// --- settings: avatar ---
Future<UserProfile> uploadAvatar(List<int> bytes, String filename) async {
final req = http.MultipartRequest("POST", _uri("/me/avatar"));
if (token != null) req.headers["Authorization"] = "Bearer $token";
req.files.add(http.MultipartFile.fromBytes("avatar", bytes, filename: filename));
final res = await http.Response.fromStream(await req.send());
if (res.statusCode == 401) {
onUnauthorized?.call();
throw ApiException(401, "Session expired — please log in again.");
}
final data = jsonDecode(res.body);
if (res.statusCode < 200 || res.statusCode >= 300) {
final msg = data is Map && data["error"] != null ? data["error"].toString() : res.reasonPhrase;
throw ApiException(res.statusCode, msg ?? "Upload failed");
}
return UserProfile.fromJson(Map<String, dynamic>.from(data));
}
Future<List<int>?> getAvatarBytes() async {
final res = await http.get(_uri("/me/avatar"),
headers: {if (token != null) "Authorization": "Bearer $token"});
if (res.statusCode == 200) return res.bodyBytes;
return null;
}
Future<void> deleteAvatar() => _send("DELETE", "/me/avatar");
// --- settings: sessions ---
Future<List<Session>> listSessions() async {
final data = await _send("GET", "/sessions") as List;
return data.map((e) => Session.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<void> revokeSession(String id) => _send("DELETE", "/sessions/$id");
Future<void> revokeOtherSessions() => _send("DELETE", "/sessions");
// --- settings: account deletion ---
Future<DateTime?> requestAccountDeletion(String confirmEmail) async {
final data = await _send("POST", "/me/delete", body: {"confirmEmail": confirmEmail});
final at = (data is Map) ? data["eligibleAt"] : null;
return at == null ? null : DateTime.tryParse(at.toString());
}
Future<void> cancelAccountDeletion() => _send("POST", "/me/delete/cancel");
Future<void> finalizeAccountDeletion() => _send("DELETE", "/me");
}
+70
View File
@@ -0,0 +1,70 @@
import "package:flutter/material.dart";
import "package:shared_preferences/shared_preferences.dart";
import "models.dart";
/// App-wide appearance preferences (theme / locale / date format / font size),
/// mirroring the web app's prefs.js. Persisted to SharedPreferences so the
/// chosen theme survives a restart before /api/me loads, and exposed as a
/// ChangeNotifier so MaterialApp and date formatting react to changes.
class AppSettings extends ChangeNotifier {
static const _kTheme = "cc_theme";
static const _kLocale = "cc_locale";
static const _kDateFormat = "cc_dateFormat";
static const _kFontSize = "cc_fontSize";
String theme = "system"; // light | dark | system
String locale = "en-US";
String dateFormat = "YMD"; // YMD | DMY_NUM | DMY | MDY
String fontSize = "medium"; // small | medium | large
Future<void> loadFromStorage() async {
final prefs = await SharedPreferences.getInstance();
theme = prefs.getString(_kTheme) ?? theme;
locale = prefs.getString(_kLocale) ?? locale;
dateFormat = prefs.getString(_kDateFormat) ?? dateFormat;
fontSize = prefs.getString(_kFontSize) ?? fontSize;
notifyListeners();
}
Future<void> _persist() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kTheme, theme);
await prefs.setString(_kLocale, locale);
await prefs.setString(_kDateFormat, dateFormat);
await prefs.setString(_kFontSize, fontSize);
}
/// Sync from a freshly fetched profile (login, boot, settings saves).
void applyFromProfile(UserProfile p) {
theme = p.theme;
locale = p.locale;
dateFormat = p.dateFormat;
fontSize = p.fontSize;
_persist();
notifyListeners();
}
/// Optimistically apply a single changed field (used by Settings so the UI
/// reacts instantly while the PATCH is in flight).
void patch({String? theme, String? locale, String? dateFormat, String? fontSize}) {
if (theme != null) this.theme = theme;
if (locale != null) this.locale = locale;
if (dateFormat != null) this.dateFormat = dateFormat;
if (fontSize != null) this.fontSize = fontSize;
_persist();
notifyListeners();
}
ThemeMode get themeMode => switch (theme) {
"light" => ThemeMode.light,
"dark" => ThemeMode.dark,
_ => ThemeMode.system,
};
double get textScale => switch (fontSize) {
"small" => 0.9375,
"large" => 1.125,
_ => 1.0,
};
}
+74
View File
@@ -0,0 +1,74 @@
import "dart:convert";
import "package:flutter/foundation.dart";
import "package:shared_preferences/shared_preferences.dart";
import "api.dart";
import "models.dart";
const _tokenKey = "cc_token";
const _userKey = "cc_user";
/// Holds session state and persists the token across app restarts.
class AuthService extends ChangeNotifier {
final ApiClient api;
AuthUser? user;
bool ready = false;
/// In-memory (never persisted) app-lock flag. When biometric login is enabled
/// the app starts/returns locked: the token is still valid but the UI hides
/// behind a biometric unlock instead of jumping straight to the dashboard.
bool locked = false;
AuthService(this.api) {
api.onUnauthorized = () => logout();
}
bool get isAuthenticated => api.token != null;
void lock() {
if (!locked && isAuthenticated) {
locked = true;
notifyListeners();
}
}
void unlock() {
if (locked) {
locked = false;
notifyListeners();
}
}
Future<void> loadFromStorage() async {
final prefs = await SharedPreferences.getInstance();
final t = prefs.getString(_tokenKey);
final u = prefs.getString(_userKey);
if (t != null) {
api.token = t;
if (u != null) user = AuthUser.fromJson(jsonDecode(u));
}
ready = true;
notifyListeners();
}
Future<void> login(String email, String password) async {
final (token, u) = await api.login(email, password);
api.token = token;
user = u;
locked = false;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
await prefs.setString(_userKey, jsonEncode(u.toJson()));
notifyListeners();
}
Future<void> logout() async {
api.token = null;
user = null;
locked = false;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_userKey);
notifyListeners();
}
}
+114
View File
@@ -0,0 +1,114 @@
import "package:flutter/services.dart";
import "package:flutter_secure_storage/flutter_secure_storage.dart";
import "package:local_auth/local_auth.dart";
import "package:local_auth_android/local_auth_android.dart";
/// Biometric ("Face recognition" / "Fingerprint") sign-in.
///
/// The app already persists a JWT in SharedPreferences, so a valid session
/// auto-restores on boot and the login screen is only shown after logout or
/// token expiry. Biometric login covers that case: the user's credentials are
/// kept in Android Keystore-backed secure storage and released only after the
/// system BiometricPrompt succeeds, then replayed against the normal login API
/// (so each biometric sign-in mints a fresh session/token).
class BiometricAuth {
final LocalAuthentication _auth = LocalAuthentication();
final FlutterSecureStorage _store = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
static const _emailKey = "cc_bio_email";
static const _passwordKey = "cc_bio_password";
/// Synchronous mirror of [isEnabled], kept fresh by the async calls below so
/// the app-lifecycle handler (which can't await) can decide whether to lock.
bool enabledCached = false;
/// Whether the device has usable biometric hardware with something enrolled.
Future<bool> isAvailable() async {
try {
if (!await _auth.isDeviceSupported()) return false;
return await _auth.canCheckBiometrics;
} on PlatformException {
return false;
}
}
/// Which biometric kinds are enrolled (used to label the sign-in buttons).
Future<List<BiometricType>> availableTypes() async {
try {
return await _auth.getAvailableBiometrics();
} on PlatformException {
return const [];
}
}
Future<bool> hasFace() async =>
(await availableTypes()).contains(BiometricType.face);
Future<bool> hasFingerprint() async =>
(await availableTypes()).contains(BiometricType.fingerprint);
/// The email biometric login was enabled for, or null if it's off.
Future<String?> enabledEmail() async {
final v = await _store.read(key: _emailKey);
enabledCached = v != null;
return v;
}
Future<bool> isEnabled() async => (await enabledEmail()) != null;
/// Remember credentials for biometric sign-in. Caller should only invoke this
/// after a successful password login so the stored creds are known-good.
Future<void> enable(String email, String password) async {
await _store.write(key: _emailKey, value: email);
await _store.write(key: _passwordKey, value: password);
enabledCached = true;
}
Future<void> disable() async {
await _store.delete(key: _emailKey);
await _store.delete(key: _passwordKey);
enabledCached = false;
}
/// Prompt for biometrics and, on success, return the stored (email, password).
/// Returns null if the user cancels or nothing is stored; throws
/// [BiometricException] with a friendly message on a hard error.
Future<(String, String)?> unlock({String? reason}) async {
bool ok;
try {
ok = await _auth.authenticate(
localizedReason: reason ?? "Sign in to Car Control",
options: const AuthenticationOptions(
biometricOnly: true,
stickyAuth: true,
),
authMessages: const [
AndroidAuthMessages(
signInTitle: "Car Control sign-in",
biometricHint: "",
cancelButton: "Use password",
),
],
);
} on PlatformException catch (e) {
throw BiometricException(e.message ?? "Biometric authentication failed.");
}
if (!ok) return null;
final email = await _store.read(key: _emailKey);
final password = await _store.read(key: _passwordKey);
if (email == null || password == null) return null;
return (email, password);
}
}
class BiometricException implements Exception {
final String message;
BiometricException(this.message);
@override
String toString() => message;
}
/// App-wide singleton (mirrors the other services in main.dart).
final biometricAuth = BiometricAuth();
+14
View File
@@ -0,0 +1,14 @@
// Default base URL of the Car Control API Server. The phone app talks ONLY to
// the API Server (never to PocketBase directly).
//
// This is only the DEFAULT — the user can override it at runtime from the login
// screen's "Server settings" (persisted to SharedPreferences). The compile-time
// value here is used when there's no saved override.
//
// - Flutter web build on this PC: http://localhost:8080/api works.
// - Real phone on the LAN: set it in Server settings (or at build time via
// flutter run --dart-define=API_BASE=http://10.2.1.10:8080/api)
const String kDefaultApiBase = String.fromEnvironment(
"API_BASE",
defaultValue: "http://localhost:8080/api",
);
+85
View File
@@ -0,0 +1,85 @@
import "package:flutter/material.dart";
import "package:intl/intl.dart";
import "main.dart";
import "models.dart";
import "theme.dart";
final _numFmt = NumberFormat.decimalPattern();
/// Formats a date per the signed-in user's chosen date format (appSettings),
/// mirroring the web app's format.js.
String formatDate(DateTime? d) {
if (d == null) return "";
final pattern = switch (appSettings.dateFormat) {
"DMY_NUM" => "dd-MM-yyyy",
"DMY" => "dd MMM yyyy",
"MDY" => "MMM dd, yyyy",
_ => "yyyy-MM-dd", // YMD
};
return DateFormat(pattern).format(d);
}
String formatKm(int? km) => (km == null || km == 0) ? "" : "${_numFmt.format(km)} km";
const int _kmSoon = 1000;
enum StatusKey { unknown, ok, soon, overdue }
class Status {
final StatusKey key;
final String label;
const Status(this.key, this.label);
/// Soft tint background for the status pill — DriverVault semantic colours,
/// re-cut for dark surfaces.
Color bg(bool dark) => switch (key) {
StatusKey.overdue => dark ? DriverVault.dangerSoftDark : DriverVault.dangerSoft,
StatusKey.soon => dark ? DriverVault.warningSoftDark : DriverVault.warningSoft,
StatusKey.ok => dark ? DriverVault.successSoftDark : DriverVault.successSoft,
StatusKey.unknown => dark ? DriverVault.darkSunken : DriverVault.ink50,
};
Color fg(bool dark) => switch (key) {
StatusKey.overdue => DriverVault.danger,
StatusKey.soon => DriverVault.warning,
StatusKey.ok => DriverVault.success,
StatusKey.unknown => dark ? DriverVault.darkTextMuted : DriverVault.ink500,
};
}
int _rank(StatusKey k) => switch (k) {
StatusKey.unknown => 0,
StatusKey.ok => 1,
StatusKey.soon => 2,
StatusKey.overdue => 3,
};
Status _dateSignal(DateTime? nextDate) {
if (nextDate == null) return const Status(StatusKey.unknown, "No data");
final today = DateTime.now();
final days = DateTime(nextDate.year, nextDate.month, nextDate.day)
.difference(DateTime(today.year, today.month, today.day))
.inDays;
if (days < 0) return Status(StatusKey.overdue, "Service Overdue ${days.abs()}d");
if (days <= 30) return Status(StatusKey.soon, "Due in ${days}d");
return Status(StatusKey.ok, "OK · ${days}d");
}
Status _kmSignal(int currentKm, int? nextKm) {
if (currentKm == 0 || nextKm == null) return const Status(StatusKey.unknown, "No km");
final remaining = nextKm - currentKm;
if (remaining < 0) return Status(StatusKey.overdue, "Service Overdue ${_numFmt.format(remaining.abs())} km");
if (remaining <= _kmSoon) return Status(StatusKey.soon, "In ${_numFmt.format(remaining)} km");
return Status(StatusKey.ok, "${_numFmt.format(remaining)} km left");
}
/// Combines the date- and km-based signals, returning the worse of the two —
/// the same logic as the web app and the spreadsheet idea.
Status serviceStatus(ServiceRecord? latest, Car car) {
final date = _dateSignal(latest?.nextServiceDate);
final km = _kmSignal(car.currentKm, latest?.nextServiceKm);
final worse = _rank(km.key) > _rank(date.key) ? km : date;
if (date.key == StatusKey.unknown && km.key != StatusKey.unknown) return km;
if (km.key == StatusKey.unknown && date.key != StatusKey.unknown) return date;
return worse;
}
+109
View File
@@ -0,0 +1,109 @@
import "package:flutter/material.dart";
import "api.dart";
import "app_settings.dart";
import "auth.dart";
import "biometric.dart";
import "theme.dart";
import "screens/lock_screen.dart";
import "screens/login_screen.dart";
import "screens/root_shell.dart";
// Simple app-wide singletons (small app — no DI framework needed).
final apiClient = ApiClient();
final authService = AuthService(apiClient);
final appSettings = AppSettings();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await apiClient.loadServerUrl();
await appSettings.loadFromStorage();
await authService.loadFromStorage();
// Prime the biometric-enabled cache; if a session survived from a previous
// run and biometric login is on, start locked so the app requires an unlock
// instead of jumping straight to the dashboard.
final bioEnabled = await biometricAuth.isEnabled();
if (authService.isAuthenticated && bioEnabled) {
authService.lock();
}
// If a token survived from a previous run, refresh the profile so the saved
// appearance prefs (theme/font/date format) apply on boot.
if (authService.isAuthenticated) {
apiClient.getMe().then((p) => appSettings.applyFromProfile(p)).catchError((_) {});
}
runApp(const CarControlApp());
}
class CarControlApp extends StatefulWidget {
const CarControlApp({super.key});
@override
State<CarControlApp> createState() => _CarControlAppState();
}
class _CarControlAppState extends State<CarControlApp> with WidgetsBindingObserver {
// Grace period: a quick app-switch (returning within this window) does NOT
// re-lock; staying in the background longer does.
static const _lockGrace = Duration(seconds: 30);
DateTime? _backgroundedAt;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Re-lock when the app returns from the background, but only if it was away
// longer than the grace period — so quick app-switches don't re-lock.
// Handle only 'paused'/'resumed' (not 'inactive', which also fires
// transiently while the OS biometric prompt is showing).
if (state == AppLifecycleState.paused) {
_backgroundedAt = DateTime.now();
} else if (state == AppLifecycleState.resumed) {
final since = _backgroundedAt;
_backgroundedAt = null;
if (since != null &&
DateTime.now().difference(since) > _lockGrace &&
authService.isAuthenticated &&
biometricAuth.enabledCached) {
authService.lock();
}
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: appSettings,
builder: (context, _) => MaterialApp(
title: "DriverVault",
debugShowCheckedModeBanner: false,
theme: DriverVault.theme(Brightness.light),
darkTheme: DriverVault.theme(Brightness.dark),
themeMode: appSettings.themeMode,
// Apply the user's font-size preference app-wide.
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(appSettings.textScale),
),
child: child!,
),
home: ListenableBuilder(
listenable: authService,
builder: (context, _) {
if (!authService.isAuthenticated) return const LoginScreen();
if (authService.locked) return const LockScreen();
return const RootShell();
},
),
),
);
}
}
+300
View File
@@ -0,0 +1,300 @@
// Domain models mirroring the API Server JSON (which mirrors Car Service.xlsx).
int _asInt(dynamic v) => v is int ? v : (v is num ? v.toInt() : 0);
String _asStr(dynamic v) => v == null ? "" : v.toString();
bool _asBool(dynamic v) => v == true;
class Car {
final String id;
final String name;
final String make;
final String model;
final int year;
final String registration;
final String registrationCountry;
final String vin;
final String oilSpec;
final String transmissionOilSpec;
final String differentialOilSpec;
final String brakeFluidSpec;
final String coolantSpec;
final String fuelType; // petrol | diesel | hybrid | electric
final String buildDate; // ISO YYYY-MM-DD (date-only)
final String firstRegistrationDate; // ISO YYYY-MM-DD (date-only)
final int serviceIntervalDays;
final int serviceIntervalKm;
final int currentKm;
/// The requesting user's permission on this car: "owner", "write", or "read".
/// The API sets it on every car read. Defaults to "owner" so older responses
/// (or any code that constructs a Car without it) stay fully editable.
final String access;
Car({
required this.id,
required this.name,
required this.make,
required this.model,
required this.year,
required this.registration,
this.registrationCountry = "",
required this.vin,
required this.oilSpec,
required this.transmissionOilSpec,
required this.differentialOilSpec,
required this.brakeFluidSpec,
required this.coolantSpec,
this.fuelType = "",
this.buildDate = "",
this.firstRegistrationDate = "",
required this.serviceIntervalDays,
required this.serviceIntervalKm,
required this.currentKm,
this.access = "owner",
});
factory Car.fromJson(Map<String, dynamic> j) => Car(
id: _asStr(j["id"]),
name: _asStr(j["name"]),
make: _asStr(j["make"]),
model: _asStr(j["model"]),
year: _asInt(j["year"]),
registration: _asStr(j["registration"]),
registrationCountry: _asStr(j["registrationCountry"]),
vin: _asStr(j["vin"]),
oilSpec: _asStr(j["oilSpec"]),
transmissionOilSpec: _asStr(j["transmissionOilSpec"]),
differentialOilSpec: _asStr(j["differentialOilSpec"]),
brakeFluidSpec: _asStr(j["brakeFluidSpec"]),
coolantSpec: _asStr(j["coolantSpec"]),
fuelType: _asStr(j["fuelType"]),
buildDate: _asStr(j["buildDate"]),
firstRegistrationDate: _asStr(j["firstRegistrationDate"]),
serviceIntervalDays: _asInt(j["serviceIntervalDays"]),
serviceIntervalKm: _asInt(j["serviceIntervalKm"]),
currentKm: _asInt(j["currentKm"]),
access: j["access"] == null ? "owner" : _asStr(j["access"]),
);
bool get isOwner => access == "owner";
bool get canWrite => access == "owner" || access == "write";
bool get isReadOnly => access == "read";
String get subtitle =>
[make, model, year > 0 ? "$year" : ""].where((s) => s.isNotEmpty).join(" ");
}
class ServiceRecord {
final String id;
final String car;
final DateTime? date;
final int km;
final bool changedOil;
final bool changedEngineAirFilter;
final bool changedCabinAirFilter;
final String notes;
final DateTime? nextServiceDate;
final int? nextServiceKm;
ServiceRecord({
required this.id,
required this.car,
required this.date,
required this.km,
required this.changedOil,
required this.changedEngineAirFilter,
required this.changedCabinAirFilter,
required this.notes,
required this.nextServiceDate,
required this.nextServiceKm,
});
factory ServiceRecord.fromJson(Map<String, dynamic> j) => ServiceRecord(
id: _asStr(j["id"]),
car: _asStr(j["car"]),
date: DateTime.tryParse(_asStr(j["date"]))?.toLocal(),
km: _asInt(j["km"]),
changedOil: _asBool(j["changedOil"]),
changedEngineAirFilter: _asBool(j["changedEngineAirFilter"]),
changedCabinAirFilter: _asBool(j["changedCabinAirFilter"]),
notes: _asStr(j["notes"]),
nextServiceDate: j["nextServiceDate"] != null
? DateTime.tryParse(_asStr(j["nextServiceDate"]))?.toLocal()
: null,
nextServiceKm: j["nextServiceKm"] == null ? null : _asInt(j["nextServiceKm"]),
);
}
class Part {
final String id;
final String car;
final String name;
final String partNumber;
final String category;
Part({
required this.id,
required this.car,
required this.name,
required this.partNumber,
this.category = "",
});
factory Part.fromJson(Map<String, dynamic> j) => Part(
id: _asStr(j["id"]),
car: _asStr(j["car"]),
name: _asStr(j["name"]),
partNumber: _asStr(j["partNumber"]),
category: _asStr(j["category"]),
);
}
/// A sharing grant: another user's access to one of your cars.
class CarShare {
final String userId;
final String email;
final String name;
final String permission; // "read" | "write"
CarShare({
required this.userId,
required this.email,
required this.name,
required this.permission,
});
factory CarShare.fromJson(Map<String, dynamic> j) {
final user = Map<String, dynamic>.from(j["user"] ?? {});
return CarShare(
userId: _asStr(user["id"]),
email: _asStr(user["email"]),
name: _asStr(user["name"]),
permission: _asStr(j["permission"]),
);
}
String get label => name.isNotEmpty ? name : email;
}
class AuthUser {
final String id;
final String email;
final String name;
final String role; // "user" | "admin"
AuthUser({required this.id, required this.email, required this.name, this.role = "user"});
factory AuthUser.fromJson(Map<String, dynamic> j) => AuthUser(
id: _asStr(j["id"]),
email: _asStr(j["email"]),
name: _asStr(j["name"]),
role: j["role"] == null ? "user" : _asStr(j["role"]),
);
bool get isAdmin => role == "admin";
Map<String, dynamic> toJson() => {"id": id, "email": email, "name": name, "role": role};
}
/// A user record as returned by the admin user-management endpoints.
class AdminUser {
final String id;
final String email;
final String name;
final String role;
final String created;
AdminUser({
required this.id,
required this.email,
required this.name,
required this.role,
required this.created,
});
factory AdminUser.fromJson(Map<String, dynamic> j) => AdminUser(
id: _asStr(j["id"]),
email: _asStr(j["email"]),
name: _asStr(j["name"]),
role: _asStr(j["role"]),
created: _asStr(j["created"]),
);
}
/// The full authenticated profile (Settings panel), mirroring /api/me.
class UserProfile {
final String id;
final String email;
final bool verified;
final String name;
final String bio;
final bool hasAvatar;
final String theme; // light | dark | system
final String locale;
final String dateFormat; // YMD | DMY_NUM | DMY | MDY
final String fontSize; // small | medium | large
final String role; // user | admin
final DateTime? deletionRequestedAt;
UserProfile({
required this.id,
required this.email,
required this.verified,
required this.name,
required this.bio,
required this.hasAvatar,
required this.theme,
required this.locale,
required this.dateFormat,
required this.fontSize,
required this.role,
required this.deletionRequestedAt,
});
factory UserProfile.fromJson(Map<String, dynamic> j) => UserProfile(
id: _asStr(j["id"]),
email: _asStr(j["email"]),
verified: _asBool(j["verified"]),
name: _asStr(j["name"]),
bio: _asStr(j["bio"]),
hasAvatar: _asBool(j["hasAvatar"]),
theme: j["theme"] == null ? "system" : _asStr(j["theme"]),
locale: j["locale"] == null ? "en-US" : _asStr(j["locale"]),
dateFormat: j["dateFormat"] == null ? "YMD" : _asStr(j["dateFormat"]),
fontSize: j["fontSize"] == null ? "medium" : _asStr(j["fontSize"]),
role: j["role"] == null ? "user" : _asStr(j["role"]),
deletionRequestedAt: j["deletionRequestedAt"] == null
? null
: DateTime.tryParse(_asStr(j["deletionRequestedAt"]))?.toLocal(),
);
bool get isAdmin => role == "admin";
bool get deletionPending => deletionRequestedAt != null;
}
/// One active login session (device), mirroring /api/sessions.
class Session {
final String id;
final String deviceLabel;
final String ip;
final bool current;
final DateTime? created;
final DateTime? expiresAt;
Session({
required this.id,
required this.deviceLabel,
required this.ip,
required this.current,
required this.created,
required this.expiresAt,
});
factory Session.fromJson(Map<String, dynamic> j) => Session(
id: _asStr(j["id"]),
deviceLabel: _asStr(j["deviceLabel"]),
ip: _asStr(j["ip"]),
current: _asBool(j["current"]),
created: DateTime.tryParse(_asStr(j["created"]))?.toLocal(),
expiresAt: DateTime.tryParse(_asStr(j["expiresAt"]))?.toLocal(),
);
}
@@ -0,0 +1,296 @@
import "package:flutter/material.dart";
import "../main.dart";
import "../models.dart";
import "../format.dart";
import "../theme.dart";
/// Admin-only screen to manage user accounts: list, create, change role,
/// reset password, delete. The API enforces the real access control and the
/// last-admin / self-delete guards; the UI mirrors them to avoid dead actions.
class AdminUsersScreen extends StatefulWidget {
const AdminUsersScreen({super.key});
@override
State<AdminUsersScreen> createState() => _AdminUsersScreenState();
}
class _AdminUsersScreenState extends State<AdminUsersScreen> {
late Future<List<AdminUser>> _future;
@override
void initState() {
super.initState();
_future = apiClient.listUsers();
}
void _reload() => setState(() => _future = apiClient.listUsers());
String? get _myId => authService.user?.id;
void _snack(String msg) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
Future<void> _changeRole(AdminUser u, String role) async {
try {
await apiClient.updateUser(u.id, role: role);
_reload();
} catch (e) {
_snack("$e");
}
}
Future<void> _resetPassword(AdminUser u) async {
final controller = TextEditingController();
final saved = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text("Reset password — ${u.email}"),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
labelText: "New password (min 8)",
border: OutlineInputBorder(),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text("Set password")),
],
),
);
if (saved != true) return;
try {
await apiClient.setUserPassword(u.id, controller.text);
_snack("Password updated.");
} catch (e) {
_snack("$e");
}
}
Future<void> _delete(AdminUser u) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Delete user?"),
content: Text("Delete ${u.name.isEmpty ? u.email : u.name}? This cannot be undone."),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
onPressed: () => Navigator.pop(ctx, true),
child: const Text("Delete"),
),
],
),
);
if (confirmed != true) return;
try {
await apiClient.deleteUser(u.id);
_reload();
} catch (e) {
_snack("$e");
}
}
Future<void> _createUser() async {
final created = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => const _CreateUserSheet(),
);
if (created == true) _reload();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Users")),
floatingActionButton: FloatingActionButton.extended(
onPressed: _createUser,
icon: const Icon(Icons.person_add_alt_1),
label: const Text("Add user"),
),
body: FutureBuilder<List<AdminUser>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(child: Text("${snap.error}"));
}
final users = snap.data ?? [];
final adminCount = users.where((u) => u.role == "admin").length;
return ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: users.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) {
final u = users[i];
final isSelf = u.id == _myId;
final lastAdmin = u.role == "admin" && adminCount <= 1;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: Row(
children: [
Flexible(child: Text(u.email, overflow: TextOverflow.ellipsis)),
if (isSelf)
const Padding(
padding: EdgeInsets.only(left: 6),
child: Text("(you)", style: TextStyle(color: Colors.grey, fontSize: 12)),
),
],
),
subtitle: Text(
"${u.name.isEmpty ? '' : u.name} · ${formatDate(DateTime.tryParse(u.created))}",
style: const TextStyle(fontSize: 12),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButton<String>(
value: u.role == "admin" ? "admin" : "user",
underline: const SizedBox.shrink(),
// Disable demoting the last admin.
onChanged: lastAdmin ? null : (v) => v == null ? null : _changeRole(u, v),
items: const [
DropdownMenuItem(value: "user", child: Text("user")),
DropdownMenuItem(value: "admin", child: Text("admin")),
],
),
PopupMenuButton<String>(
onSelected: (choice) {
if (choice == "password") _resetPassword(u);
if (choice == "delete") _delete(u);
},
itemBuilder: (_) => [
const PopupMenuItem(value: "password", child: Text("Reset password")),
PopupMenuItem(
value: "delete",
// Can't delete yourself or the last admin.
enabled: !isSelf && !lastAdmin,
child: const Text("Delete", style: TextStyle(color: DriverVault.danger)),
),
],
),
],
),
);
},
);
},
),
);
}
}
class _CreateUserSheet extends StatefulWidget {
const _CreateUserSheet();
@override
State<_CreateUserSheet> createState() => _CreateUserSheetState();
}
class _CreateUserSheetState extends State<_CreateUserSheet> {
final _email = TextEditingController();
final _name = TextEditingController();
final _password = TextEditingController();
String _role = "user";
bool _saving = false;
String? _error;
@override
void dispose() {
_email.dispose();
_name.dispose();
_password.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() {
_saving = true;
_error = null;
});
try {
await apiClient.createUser(
email: _email.text.trim(),
password: _password.text,
name: _name.text.trim(),
role: _role,
);
if (mounted) Navigator.pop(context, true);
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Add a user",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
),
TextField(
controller: _email,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder()),
),
const SizedBox(height: 8),
TextField(
controller: _name,
decoration: const InputDecoration(labelText: "Name", border: OutlineInputBorder()),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _password,
decoration: const InputDecoration(
labelText: "Password (min 8)",
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
DropdownButton<String>(
value: _role,
onChanged: (v) => setState(() => _role = v ?? "user"),
items: const [
DropdownMenuItem(value: "user", child: Text("user")),
DropdownMenuItem(value: "admin", child: Text("admin")),
],
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _save,
child: Text(_saving ? "Creating…" : "Create user"),
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
+249
View File
@@ -0,0 +1,249 @@
import "package:flutter/material.dart";
import "../main.dart";
import "../models.dart";
import "../format.dart";
import "../theme.dart";
/// Bottom-sheet form to create or edit a car, mirroring the web CarFormModal.
/// Pops with the saved [Car] on success, or null if cancelled.
class CarFormSheet extends StatefulWidget {
final Car? car; // null => create
const CarFormSheet({super.key, this.car});
@override
State<CarFormSheet> createState() => _CarFormSheetState();
}
class _CarFormSheetState extends State<CarFormSheet> {
late final Map<String, TextEditingController> _c;
String _fuelType = "";
DateTime? _buildDate;
DateTime? _firstRegistrationDate;
bool _saving = false;
String? _error;
bool get _isEdit => widget.car != null;
@override
void initState() {
super.initState();
final car = widget.car;
_fuelType = car?.fuelType ?? "";
_buildDate = (car?.buildDate.isNotEmpty ?? false) ? DateTime.tryParse(car!.buildDate) : null;
_firstRegistrationDate =
(car?.firstRegistrationDate.isNotEmpty ?? false) ? DateTime.tryParse(car!.firstRegistrationDate) : null;
_c = {
"name": TextEditingController(text: car?.name ?? ""),
"make": TextEditingController(text: car?.make ?? ""),
"model": TextEditingController(text: car?.model ?? ""),
"year": TextEditingController(text: (car != null && car.year > 0) ? "${car.year}" : ""),
"registration": TextEditingController(text: car?.registration ?? ""),
"registrationCountry": TextEditingController(text: car?.registrationCountry ?? ""),
"vin": TextEditingController(text: car?.vin ?? ""),
"oilSpec": TextEditingController(text: car?.oilSpec ?? ""),
"transmissionOilSpec": TextEditingController(text: car?.transmissionOilSpec ?? ""),
"differentialOilSpec": TextEditingController(text: car?.differentialOilSpec ?? ""),
"brakeFluidSpec": TextEditingController(text: car?.brakeFluidSpec ?? ""),
"coolantSpec": TextEditingController(text: car?.coolantSpec ?? ""),
"currentKm":
TextEditingController(text: (car != null && car.currentKm > 0) ? "${car.currentKm}" : ""),
"serviceIntervalDays": TextEditingController(text: "${car?.serviceIntervalDays ?? 365}"),
"serviceIntervalKm": TextEditingController(text: "${car?.serviceIntervalKm ?? 15000}"),
};
}
@override
void dispose() {
for (final c in _c.values) {
c.dispose();
}
super.dispose();
}
int _int(String key, int fallback) => int.tryParse(_c[key]!.text.trim()) ?? fallback;
Future<void> _save() async {
final name = _c["name"]!.text.trim();
if (name.isEmpty) {
setState(() => _error = "Name is required.");
return;
}
setState(() {
_saving = true;
_error = null;
});
final payload = {
"name": name,
"make": _c["make"]!.text.trim(),
"model": _c["model"]!.text.trim(),
"year": _int("year", 0),
"registration": _c["registration"]!.text.trim(),
"registrationCountry": _c["registrationCountry"]!.text.trim(),
"vin": _c["vin"]!.text.trim(),
"fuelType": _fuelType,
"buildDate": _isoOrEmpty(_buildDate),
"firstRegistrationDate": _isoOrEmpty(_firstRegistrationDate),
"oilSpec": _c["oilSpec"]!.text.trim(),
"transmissionOilSpec": _c["transmissionOilSpec"]!.text.trim(),
"differentialOilSpec": _c["differentialOilSpec"]!.text.trim(),
"brakeFluidSpec": _c["brakeFluidSpec"]!.text.trim(),
"coolantSpec": _c["coolantSpec"]!.text.trim(),
"currentKm": _int("currentKm", 0),
"serviceIntervalDays": _int("serviceIntervalDays", 365),
"serviceIntervalKm": _int("serviceIntervalKm", 15000),
};
try {
final saved = _isEdit
? await apiClient.updateCar(widget.car!.id, payload)
: await apiClient.createCar(payload);
if (mounted) Navigator.pop(context, saved);
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _saving = false);
}
}
static String _isoOrEmpty(DateTime? d) => d == null
? ""
: "${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}";
Widget _field(String key, String label,
{TextInputType? keyboard, TextCapitalization caps = TextCapitalization.none}) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: TextField(
controller: _c[key],
keyboardType: keyboard,
textCapitalization: caps,
decoration: InputDecoration(labelText: label, border: const OutlineInputBorder(), isDense: true),
),
);
}
/// A tappable read-only field that opens a date picker. Shows a clear button
/// when a date is set, otherwise a calendar icon.
Widget _dateField(String label, DateTime? value, ValueChanged<DateTime?> onChanged) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: value ?? DateTime.now(),
firstDate: DateTime(1950),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) setState(() => onChanged(picked));
},
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
isDense: true,
suffixIcon: value == null
? const Icon(Icons.calendar_today, size: 18)
: IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () => setState(() => onChanged(null)),
),
),
child: Text(value == null ? "" : formatDate(value)),
),
),
);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_isEdit ? "Edit car" : "Add a car",
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
),
_field("name", "Name *", caps: TextCapitalization.words),
Row(children: [
Expanded(child: _field("make", "Make")),
const SizedBox(width: 8),
Expanded(child: _field("model", "Model")),
]),
Row(children: [
Expanded(child: _field("year", "Year", keyboard: TextInputType.number)),
const SizedBox(width: 8),
Expanded(child: _field("registration", "Registration")),
]),
_field("registrationCountry", "Registration country", caps: TextCapitalization.words),
_field("vin", "VIN"),
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: DropdownButtonFormField<String>(
initialValue: _fuelType.isEmpty ? null : _fuelType,
decoration: const InputDecoration(
labelText: "Fuel type", border: OutlineInputBorder(), isDense: true),
items: const [
DropdownMenuItem(value: "petrol", child: Text("Petrol (gasoline)")),
DropdownMenuItem(value: "diesel", child: Text("Diesel")),
DropdownMenuItem(value: "hybrid", child: Text("Hybrid")),
DropdownMenuItem(value: "electric", child: Text("Electric")),
],
onChanged: (v) => setState(() => _fuelType = v ?? ""),
),
),
Row(children: [
Expanded(child: _dateField("Build date", _buildDate, (v) => _buildDate = v)),
const SizedBox(width: 8),
Expanded(
child: _dateField("First registration", _firstRegistrationDate,
(v) => _firstRegistrationDate = v)),
]),
_field("oilSpec", "Engine oil spec"),
Row(children: [
Expanded(child: _field("transmissionOilSpec", "Transmission oil spec")),
const SizedBox(width: 8),
Expanded(child: _field("differentialOilSpec", "Differential oil spec")),
]),
Row(children: [
Expanded(child: _field("brakeFluidSpec", "Brake fluid spec")),
const SizedBox(width: 8),
Expanded(child: _field("coolantSpec", "Coolant spec")),
]),
_field("currentKm", "Current odometer (km)", keyboard: TextInputType.number),
Row(children: [
Expanded(
child: _field("serviceIntervalDays", "Service interval (days)",
keyboard: TextInputType.number)),
const SizedBox(width: 8),
Expanded(
child: _field("serviceIntervalKm", "Service interval (km)",
keyboard: TextInputType.number)),
]),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _save,
child: Text(_saving ? "Saving…" : (_isEdit ? "Save changes" : "Add car")),
),
),
],
),
),
);
}
}
+355
View File
@@ -0,0 +1,355 @@
import "package:flutter/material.dart";
import "../main.dart";
import "../models.dart";
import "../format.dart";
import "../theme.dart";
import "car_detail_screen.dart";
import "car_form_sheet.dart";
class _CarRow {
final Car car;
final ServiceRecord? latest;
final int count;
_CarRow(this.car, this.latest, this.count);
}
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
late Future<List<_CarRow>> _future;
@override
void initState() {
super.initState();
_future = _load();
}
Future<List<_CarRow>> _load() async {
final cars = await apiClient.listCars();
final rows = <_CarRow>[];
for (final c in cars) {
final services = await apiClient.listCarServices(c.id);
rows.add(_CarRow(c, services.isNotEmpty ? services.first : null, services.length));
}
return rows;
}
Future<void> _refresh() async {
final f = _load();
setState(() => _future = f);
await f;
}
Future<void> _addCar() async {
final created = await showModalBottomSheet<Car?>(
context: context,
isScrollControlled: true,
builder: (_) => const CarFormSheet(),
);
if (created != null && mounted) {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => CarDetailScreen(carId: created.id)),
);
await _refresh();
}
}
void _toggleTheme() {
final next = Theme.of(context).brightness == Brightness.dark ? "light" : "dark";
appSettings.patch(theme: next); // optimistic + persisted locally
apiClient.updateMe({"theme": next}).then((_) {}).catchError((_) {});
}
@override
Widget build(BuildContext context) {
final dark = DriverVault.isDark(context);
return Scaffold(
floatingActionButton: FloatingActionButton.extended(
onPressed: _addCar,
icon: const Icon(Icons.add),
label: const Text("Add car"),
),
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Home header — eyebrow + title on the left, quick actions right.
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 16, 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("YOUR CARS",
style: DriverVault.mono(context,
size: 10, weight: FontWeight.w500, color: DriverVault.muted(context))
.copyWith(letterSpacing: 2.2)),
const SizedBox(height: 2),
const Text("Garage",
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
],
),
),
_HeaderButton(
icon: dark ? Icons.light_mode_outlined : Icons.dark_mode_outlined,
tooltip: dark ? "Light mode" : "Dark mode",
onTap: _toggleTheme,
),
const SizedBox(width: 8),
_HeaderButton(
icon: Icons.logout,
tooltip: "Log out",
onTap: () => authService.logout(),
),
],
),
),
Expanded(
child: RefreshIndicator(
onRefresh: _refresh,
child: FutureBuilder<List<_CarRow>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return _ErrorView(message: "${snap.error}", onRetry: _refresh);
}
final rows = snap.data ?? [];
if (rows.isEmpty) {
return ListView(children: const [
SizedBox(height: 80),
Center(child: Text("No cars yet.")),
]);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 96),
itemCount: rows.length,
itemBuilder: (context, i) => _CarCard(row: rows[i], onChanged: _refresh),
);
},
),
),
),
],
),
),
);
}
}
/// A bordered 40×40 square icon button used in the home header (mockup style).
class _HeaderButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback onTap;
const _HeaderButton({required this.icon, required this.tooltip, required this.onTap});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: Material(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
child: InkWell(
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
onTap: onTap,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
border: Border.all(color: DriverVault.isDark(context) ? DriverVault.darkBorderStrong : DriverVault.ink200),
),
child: Icon(icon, size: 19, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
),
),
);
}
}
class _CarCard extends StatelessWidget {
final _CarRow row;
final Future<void> Function() onChanged;
const _CarCard({required this.row, required this.onChanged});
@override
Widget build(BuildContext context) {
final status = serviceStatus(row.latest, row.car);
return Card(
margin: const EdgeInsets.only(bottom: 10),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100),
),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => CarDetailScreen(carId: row.car.id)),
);
await onChanged();
},
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(row.car.name,
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16, letterSpacing: -0.3)),
if (row.car.subtitle.isNotEmpty)
Text(row.car.subtitle,
style: TextStyle(color: DriverVault.muted(context), fontSize: 12)),
],
),
),
_Badge(status: status),
],
),
if (!row.car.isOwner) ...[
const SizedBox(height: 8),
_SharedChip(readOnly: row.car.isReadOnly),
],
..._serviceLife(context, status),
const SizedBox(height: 12),
_kv(context, "Last service", formatDate(row.latest?.date)),
_kv(context, "Current odometer", formatKm(row.car.currentKm)),
_kv(context, "Next due", formatDate(row.latest?.nextServiceDate)),
_kv(context, "Next due km", formatKm(row.latest?.nextServiceKm)),
const SizedBox(height: 6),
Text("${row.count} service record${row.count == 1 ? '' : 's'}",
style: TextStyle(color: DriverVault.muted(context), fontSize: 11)),
],
),
),
),
);
}
Widget _kv(BuildContext context, String k, String v) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(k, style: TextStyle(color: DriverVault.muted(context), fontSize: 13)),
Text(v, style: DriverVault.mono(context, size: 13, weight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface)),
],
),
);
/// Service-life bar: fraction of the km interval used up (echoes the mockup's
/// battery bar). Returns [] when there isn't enough data to compute it.
List<Widget> _serviceLife(BuildContext context, Status status) {
final interval = row.car.serviceIntervalKm;
final nextKm = row.latest?.nextServiceKm ?? 0;
final currentKm = row.car.currentKm;
if (interval <= 0 || nextKm <= 0 || currentKm <= 0) return const [];
final remaining = nextKm - currentKm;
final pct = (100 * (1 - remaining / interval)).clamp(0, 100).round();
final tone = status.fg(DriverVault.isDark(context));
return [
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("SERVICE LIFE",
style: DriverVault.mono(context, size: 9, weight: FontWeight.w500,
color: DriverVault.muted(context)).copyWith(letterSpacing: 1.6)),
Text("$pct%",
style: DriverVault.mono(context, size: 11, weight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface)),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(99),
child: LinearProgressIndicator(
value: pct / 100,
minHeight: 7,
backgroundColor: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink100,
valueColor: AlwaysStoppedAnimation<Color>(tone),
),
),
];
}
}
class _SharedChip extends StatelessWidget {
final bool readOnly;
const _SharedChip({required this.readOnly});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: DriverVault.brandTint(context),
borderRadius: BorderRadius.circular(999),
),
child: Text(
readOnly ? "Shared · read-only" : "Shared",
style: TextStyle(color: DriverVault.brandOnTint(context), fontSize: 11, fontWeight: FontWeight.w600),
),
);
}
}
class _Badge extends StatelessWidget {
final Status status;
const _Badge({required this.status});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: status.bg(DriverVault.isDark(context)), borderRadius: BorderRadius.circular(999)),
child: Text(status.label,
style: TextStyle(color: status.fg(DriverVault.isDark(context)), fontSize: 12, fontWeight: FontWeight.w600)),
);
}
}
class _ErrorView extends StatelessWidget {
final String message;
final Future<void> Function() onRetry;
const _ErrorView({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
return ListView(
children: [
const SizedBox(height: 80),
Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
const Icon(Icons.error_outline, color: DriverVault.danger, size: 40),
const SizedBox(height: 12),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton(onPressed: onRetry, child: const Text("Retry")),
],
),
),
),
],
);
}
}
+110
View File
@@ -0,0 +1,110 @@
import "package:flutter/material.dart";
import "../biometric.dart";
import "../main.dart";
import "../theme.dart";
/// Shown when the app is authenticated but locked (biometric login is enabled
/// and the app was just launched or resumed). The session token is still valid;
/// a successful biometric check simply reveals the app. "Use password" drops the
/// session and returns to the login screen.
class LockScreen extends StatefulWidget {
const LockScreen({super.key});
@override
State<LockScreen> createState() => _LockScreenState();
}
class _LockScreenState extends State<LockScreen> {
bool _busy = false;
String? _error;
@override
void initState() {
super.initState();
// Prompt once as soon as the lock screen appears.
WidgetsBinding.instance.addPostFrameCallback((_) => _unlock());
}
Future<void> _unlock() async {
if (_busy) return;
setState(() {
_busy = true;
_error = null;
});
try {
final creds = await biometricAuth.unlock(reason: "Unlock DriverVault");
if (creds == null) {
// Cancelled — leave locked; the buttons let them retry or use password.
if (mounted) setState(() => _busy = false);
return;
}
// Token from a previous session is still held by ApiClient; just reveal.
authService.unlock();
} on BiometricException catch (e) {
if (mounted) setState(() => _error = e.message);
} catch (e) {
if (mounted) setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final u = authService.user;
final name = u == null ? null : (u.name.isNotEmpty ? u.name : u.email);
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: DriverVault.brand700,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.lock_outline, color: Colors.white, size: 32),
),
const SizedBox(height: 16),
const Text("DriverVault is locked",
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4)),
if (name != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(name, style: TextStyle(color: DriverVault.muted(context))),
),
const SizedBox(height: 24),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(_error!,
textAlign: TextAlign.center,
style: const TextStyle(color: DriverVault.danger)),
),
SizedBox(
width: 260,
child: FilledButton.icon(
onPressed: _busy ? null : _unlock,
icon: const Icon(Icons.fingerprint),
label: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Text(_busy ? "Unlocking…" : "Unlock"),
),
),
),
const SizedBox(height: 8),
TextButton(
onPressed: _busy ? null : () => authService.logout(),
child: const Text("Use password instead"),
),
],
),
),
),
);
}
}
+383
View File
@@ -0,0 +1,383 @@
import "package:flutter/material.dart";
import "../api.dart";
import "../biometric.dart";
import "../config.dart";
import "../main.dart";
import "../theme.dart";
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _email = TextEditingController();
final _password = TextEditingController();
final _server = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _loading = false;
bool _showPassword = false;
String? _error;
String? _serverSaved;
// Biometric ("Face recognition" / "Fingerprint") sign-in state.
bool _bioAvailable = false;
bool _bioEnabled = false;
bool _hasFace = false;
bool _hasFingerprint = false;
String? _bioEmail;
bool _autoPrompted = false;
@override
void initState() {
super.initState();
apiClient.serverOverride().then((v) {
if (mounted) _server.text = v;
});
_initBiometrics();
}
Future<void> _initBiometrics() async {
final available = await biometricAuth.isAvailable();
final email = await biometricAuth.enabledEmail();
final face = available && await biometricAuth.hasFace();
final finger = available && await biometricAuth.hasFingerprint();
if (!mounted) return;
setState(() {
_bioAvailable = available;
_bioEnabled = email != null;
_bioEmail = email;
_hasFace = face;
_hasFingerprint = finger;
});
// If biometric login is set up, offer it immediately (once) on screen load.
if (_bioEnabled && _bioAvailable && !_autoPrompted) {
_autoPrompted = true;
_biometricLogin();
}
}
@override
void dispose() {
_email.dispose();
_password.dispose();
_server.dispose();
super.dispose();
}
Future<void> _saveServer() async {
await apiClient.setServerUrl(_server.text);
final current = await apiClient.serverOverride();
if (!mounted) return;
setState(() {
_server.text = current;
_serverSaved = "Saved ✓";
});
}
Future<void> _resetServer() async {
await apiClient.setServerUrl("");
if (!mounted) return;
setState(() {
_server.clear();
_serverSaved = "Reset ✓";
});
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_loading = true;
_error = null;
});
final email = _email.text.trim();
final password = _password.text;
try {
await authService.login(email, password);
// Pull the full profile so saved appearance prefs (theme/font/date) apply.
try {
appSettings.applyFromProfile(await apiClient.getMe());
} catch (_) {}
// Offer to remember these (known-good) credentials for biometric login.
await _maybeOfferBiometricEnrollment(email, password);
// The app root rebuilds to the dashboard via the auth listener.
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
/// After a successful password login, if the device supports biometrics and
/// they aren't set up yet, ask whether to enable biometric sign-in.
Future<void> _maybeOfferBiometricEnrollment(String email, String password) async {
if (!_bioAvailable || _bioEnabled || !mounted) return;
final method = _hasFace && !_hasFingerprint
? "face recognition"
: _hasFingerprint && !_hasFace
? "your fingerprint"
: "biometrics";
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Enable biometric sign-in?"),
content: Text(
"Next time you can sign in with $method instead of typing your password."),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Not now")),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text("Enable")),
],
),
);
if (ok == true) {
await biometricAuth.enable(email, password);
}
}
Future<void> _biometricLogin() async {
setState(() {
_loading = true;
_error = null;
});
try {
final creds = await biometricAuth.unlock(
reason: _bioEmail != null ? "Sign in as $_bioEmail" : "Sign in to DriverVault",
);
if (creds == null) {
// User cancelled the prompt, or nothing is stored.
if (mounted) setState(() => _loading = false);
return;
}
await authService.login(creds.$1, creds.$2);
try {
appSettings.applyFromProfile(await apiClient.getMe());
} catch (_) {}
} on BiometricException catch (e) {
if (mounted) setState(() => _error = e.message);
} on ApiException catch (e) {
// Stored credentials no longer work (e.g. password changed) — turn off
// biometric login and fall back to the password form.
if (e.status == 400 || e.status == 401) {
await biometricAuth.disable();
if (mounted) {
setState(() {
_bioEnabled = false;
_error = "Saved sign-in is no longer valid. Please sign in with your password.";
});
}
} else if (mounted) {
setState(() => _error = e.message);
}
} catch (e) {
if (mounted) setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
/// Biometric sign-in buttons, shown only once biometric login is set up on
/// this device. Labels reflect the enrolled biometric kind(s).
Widget _buildBiometricSection() {
if (!_bioEnabled || !_bioAvailable) return const SizedBox.shrink();
final buttons = <Widget>[];
Widget bioButton(IconData icon, String label) => Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _loading ? null : _biometricLogin,
icon: Icon(icon),
label: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(label),
),
),
),
);
if (_hasFace) buttons.add(bioButton(Icons.face, "Sign in with face recognition"));
if (_hasFingerprint) buttons.add(bioButton(Icons.fingerprint, "Sign in with fingerprint"));
if (buttons.isEmpty) buttons.add(bioButton(Icons.lock_outline, "Sign in with biometrics"));
return Column(
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
Expanded(child: Divider()),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text("or", style: TextStyle(color: Colors.grey, fontSize: 12)),
),
Expanded(child: Divider()),
],
),
),
...buttons,
],
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const DriverVaultLogo(markSize: 40, fontSize: 30),
const SizedBox(height: 10),
Text("Your car, on track.",
style: TextStyle(color: DriverVault.muted(context))),
const SizedBox(height: 24),
Form(
key: _formKey,
child: Column(
children: [
if (_error != null)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
),
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
),
TextFormField(
controller: _email,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder()),
validator: (v) => (v == null || v.isEmpty) ? "Required" : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _password,
obscureText: !_showPassword,
decoration: InputDecoration(
labelText: "Password",
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility),
tooltip: _showPassword ? "Hide password" : "Show password",
onPressed: () => setState(() => _showPassword = !_showPassword),
),
),
validator: (v) => (v == null || v.isEmpty) ? "Required" : null,
onFieldSubmitted: (_) => _submit(),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _loading ? null : _submit,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(_loading ? "Signing in…" : "Sign in"),
),
),
),
],
),
),
_buildBiometricSection(),
const SizedBox(height: 8),
_ServerSettings(
controller: _server,
savedNote: _serverSaved,
onSave: _saveServer,
onReset: _resetServer,
),
],
),
),
),
),
);
}
}
/// Collapsible "Server settings" on the login screen: an editable API server URL
/// override (blank = use the compile-time default). Mirrors the web app.
class _ServerSettings extends StatefulWidget {
final TextEditingController controller;
final String? savedNote;
final Future<void> Function() onSave;
final Future<void> Function() onReset;
const _ServerSettings({
required this.controller,
required this.savedNote,
required this.onSave,
required this.onReset,
});
@override
State<_ServerSettings> createState() => _ServerSettingsState();
}
class _ServerSettingsState extends State<_ServerSettings> {
bool _open = false;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => setState(() => _open = !_open),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Server settings",
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey)),
Icon(_open ? Icons.expand_less : Icons.expand_more, size: 18, color: Colors.grey),
],
),
),
),
if (_open) ...[
TextField(
controller: widget.controller,
keyboardType: TextInputType.url,
autocorrect: false,
decoration: const InputDecoration(
labelText: "API server URL",
hintText: kDefaultApiBase,
border: OutlineInputBorder(),
isDense: true,
),
),
const Padding(
padding: EdgeInsets.only(top: 4),
child: Text("Leave blank to use the default.",
style: TextStyle(color: Colors.grey, fontSize: 11)),
),
const SizedBox(height: 4),
Row(
children: [
OutlinedButton(onPressed: widget.onSave, child: const Text("Save")),
const SizedBox(width: 8),
TextButton(onPressed: widget.onReset, child: const Text("Reset")),
if (widget.savedNote != null)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(widget.savedNote!,
style: const TextStyle(color: DriverVault.success, fontSize: 12)),
),
],
),
],
],
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import "package:flutter/material.dart";
import "../main.dart";
import "dashboard_screen.dart";
import "settings_screen.dart";
import "admin_users_screen.dart";
/// The app's root shell — a persistent bottom navigation bar (DriverVault mockup
/// layout) hosting the Garage, Settings and (for admins) Users sections in an
/// IndexedStack so each keeps its state as you switch tabs.
class RootShell extends StatefulWidget {
const RootShell({super.key});
@override
State<RootShell> createState() => _RootShellState();
}
class _RootShellState extends State<RootShell> {
int _index = 0;
@override
Widget build(BuildContext context) {
final isAdmin = authService.user?.isAdmin == true;
final pages = <Widget>[
const DashboardScreen(),
const SettingsScreen(),
if (isAdmin) const AdminUsersScreen(),
];
final destinations = <NavigationDestination>[
const NavigationDestination(
icon: Icon(Icons.grid_view_outlined),
selectedIcon: Icon(Icons.grid_view_rounded),
label: "Garage",
),
const NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: "Settings",
),
if (isAdmin)
const NavigationDestination(
icon: Icon(Icons.group_outlined),
selectedIcon: Icon(Icons.group),
label: "Users",
),
];
// Guard against the index falling out of range if admin status changes.
final index = _index.clamp(0, pages.length - 1);
return Scaffold(
body: IndexedStack(index: index, children: pages),
bottomNavigationBar: NavigationBar(
selectedIndex: index,
onDestinationSelected: (i) => setState(() => _index = i),
destinations: destinations,
),
);
}
}
+915
View File
@@ -0,0 +1,915 @@
import "dart:typed_data";
import "package:flutter/material.dart";
import "package:image_picker/image_picker.dart";
import "../biometric.dart";
import "../main.dart";
import "../models.dart";
import "../format.dart";
import "../theme.dart";
/// Full settings panel, mirroring the web Settings.vue (minus data export/import):
/// account (name/email/password), appearance (theme/locale/date/font), profile
/// (avatar/bio), privacy (sessions), and account deletion.
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
UserProfile? _profile;
List<Session> _sessions = [];
Uint8List? _avatar;
bool _loading = true;
String? _loadError;
final _name = TextEditingController();
final _bio = TextEditingController();
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_name.dispose();
_bio.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() {
_loading = true;
_loadError = null;
});
try {
final p = await apiClient.getMe();
appSettings.applyFromProfile(p);
final sessions = await apiClient.listSessions();
Uint8List? avatar;
if (p.hasAvatar) {
final bytes = await apiClient.getAvatarBytes();
if (bytes != null) avatar = Uint8List.fromList(bytes);
}
_name.text = p.name;
_bio.text = p.bio;
setState(() {
_profile = p;
_sessions = sessions;
_avatar = avatar;
});
} catch (e) {
setState(() => _loadError = e.toString());
} finally {
setState(() => _loading = false);
}
}
void _snack(String msg) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Settings")),
body: _loading
? const Center(child: CircularProgressIndicator())
: _loadError != null
? Center(child: Text(_loadError!))
: ListView(
padding: const EdgeInsets.all(12),
children: [
_AccountSection(
profile: _profile!,
nameController: _name,
onChanged: _load,
snack: _snack,
),
const SizedBox(height: 12),
_AppearanceSection(onError: _snack),
const SizedBox(height: 12),
_ProfileSection(
profile: _profile!,
bioController: _bio,
avatar: _avatar,
onChanged: _load,
snack: _snack,
),
const SizedBox(height: 12),
_SecuritySection(email: _profile!.email, snack: _snack),
const SizedBox(height: 12),
_SessionsSection(sessions: _sessions, onChanged: _load, snack: _snack),
const SizedBox(height: 12),
_DangerSection(profile: _profile!, onChanged: _load, snack: _snack),
const SizedBox(height: 24),
],
),
);
}
}
/// A titled card wrapper matching the web's sectioned layout.
class _Card extends StatelessWidget {
final String title;
final Color? titleColor;
final List<Widget> children;
const _Card({required this.title, this.titleColor, required this.children});
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Theme.of(context).dividerColor),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600, color: titleColor)),
const SizedBox(height: 12),
...children,
],
),
),
);
}
}
// --- Account ---------------------------------------------------------------
class _AccountSection extends StatefulWidget {
final UserProfile profile;
final TextEditingController nameController;
final Future<void> Function() onChanged;
final void Function(String) snack;
const _AccountSection({
required this.profile,
required this.nameController,
required this.onChanged,
required this.snack,
});
@override
State<_AccountSection> createState() => _AccountSectionState();
}
class _AccountSectionState extends State<_AccountSection> {
bool _savingName = false;
bool _verifying = false;
final _oldPw = TextEditingController();
final _newPw = TextEditingController();
final _confirmPw = TextEditingController();
bool _savingPw = false;
String? _pwError;
@override
void dispose() {
_oldPw.dispose();
_newPw.dispose();
_confirmPw.dispose();
super.dispose();
}
Future<void> _saveName() async {
setState(() => _savingName = true);
try {
final updated = await apiClient.updateMe({"name": widget.nameController.text.trim()});
authService.user = AuthUser(
id: updated.id,
email: updated.email,
name: updated.name,
role: updated.role,
);
widget.snack("Name saved.");
} catch (e) {
widget.snack("$e");
} finally {
if (mounted) setState(() => _savingName = false);
}
}
Future<void> _sendVerification() async {
setState(() => _verifying = true);
try {
await apiClient.requestVerification();
widget.snack("Verification email requested.");
} catch (e) {
widget.snack("$e");
} finally {
if (mounted) setState(() => _verifying = false);
}
}
Future<void> _savePassword() async {
setState(() => _pwError = null);
if (_newPw.text.length < 8) {
setState(() => _pwError = "New password must be at least 8 characters.");
return;
}
if (_newPw.text != _confirmPw.text) {
setState(() => _pwError = "New password and confirmation don't match.");
return;
}
setState(() => _savingPw = true);
try {
await apiClient.changePassword(_oldPw.text, _newPw.text);
_oldPw.clear();
_newPw.clear();
_confirmPw.clear();
widget.snack("Password updated.");
} catch (e) {
setState(() => _pwError = e.toString());
} finally {
if (mounted) setState(() => _savingPw = false);
}
}
@override
Widget build(BuildContext context) {
final p = widget.profile;
return _Card(
title: "Account",
children: [
const Text("Name", style: TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
Row(children: [
Expanded(
child: TextField(
controller: widget.nameController,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _savingName ? null : _saveName,
child: Text(_savingName ? "Saving…" : "Save"),
),
]),
const SizedBox(height: 16),
const Text("Email", style: TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
Row(children: [
Expanded(child: Text(p.email)),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: p.verified
? (DriverVault.isDark(context) ? DriverVault.successSoftDark : DriverVault.successSoft)
: (DriverVault.isDark(context) ? DriverVault.warningSoftDark : DriverVault.warningSoft),
borderRadius: BorderRadius.circular(999),
),
child: Text(p.verified ? "Verified" : "Not verified",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: p.verified ? DriverVault.success : DriverVault.warning)),
),
]),
if (!p.verified)
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: _verifying ? null : _sendVerification,
child: Text(_verifying ? "Sending…" : "Resend verification email"),
),
),
const Divider(height: 24),
const Text("Change password", style: TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
TextField(
controller: _oldPw,
obscureText: true,
decoration: const InputDecoration(
labelText: "Current password", border: OutlineInputBorder(), isDense: true),
),
const SizedBox(height: 8),
TextField(
controller: _newPw,
obscureText: true,
decoration: const InputDecoration(
labelText: "New password (min 8)", border: OutlineInputBorder(), isDense: true),
),
const SizedBox(height: 8),
TextField(
controller: _confirmPw,
obscureText: true,
decoration: const InputDecoration(
labelText: "Confirm new password", border: OutlineInputBorder(), isDense: true),
),
if (_pwError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(_pwError!, style: const TextStyle(color: DriverVault.danger, fontSize: 13)),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton(
onPressed: _savingPw ? null : _savePassword,
child: Text(_savingPw ? "Updating…" : "Update password"),
),
),
],
);
}
}
// --- Appearance ------------------------------------------------------------
class _AppearanceSection extends StatefulWidget {
final void Function(String) onError;
const _AppearanceSection({required this.onError});
@override
State<_AppearanceSection> createState() => _AppearanceSectionState();
}
class _AppearanceSectionState extends State<_AppearanceSection> {
Future<void> _save(Map<String, dynamic> patch) async {
// Optimistic: apply locally first, roll back on failure.
final prev = {
"theme": appSettings.theme,
"locale": appSettings.locale,
"dateFormat": appSettings.dateFormat,
"fontSize": appSettings.fontSize,
};
appSettings.patch(
theme: patch["theme"],
locale: patch["locale"],
dateFormat: patch["dateFormat"],
fontSize: patch["fontSize"],
);
setState(() {});
try {
await apiClient.updateMe(patch);
} catch (e) {
appSettings.patch(
theme: prev["theme"],
locale: prev["locale"],
dateFormat: prev["dateFormat"],
fontSize: prev["fontSize"],
);
setState(() {});
widget.onError("$e");
}
}
@override
Widget build(BuildContext context) {
return _Card(
title: "Appearance",
children: [
const Text("Theme", style: TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
SegmentedButton<String>(
segments: const [
ButtonSegment(value: "light", label: Text("Light")),
ButtonSegment(value: "dark", label: Text("Dark")),
ButtonSegment(value: "system", label: Text("System")),
],
selected: {appSettings.theme},
onSelectionChanged: (s) => _save({"theme": s.first}),
),
const SizedBox(height: 16),
const Text("Language & region", style: TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: appSettings.locale,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: const [
DropdownMenuItem(value: "en-US", child: Text("English (US)")),
DropdownMenuItem(value: "en-GB", child: Text("English (UK)")),
DropdownMenuItem(value: "pl-PL", child: Text("Polski")),
DropdownMenuItem(value: "de-DE", child: Text("Deutsch")),
DropdownMenuItem(value: "fr-FR", child: Text("Français")),
DropdownMenuItem(value: "es-ES", child: Text("Español")),
],
onChanged: (v) => v == null ? null : _save({"locale": v}),
),
const SizedBox(height: 16),
const Text("Date format", style: TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: appSettings.dateFormat,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: const [
DropdownMenuItem(value: "YMD", child: Text("YYYY-MM-DD")),
DropdownMenuItem(value: "DMY_NUM", child: Text("DD-MM-YYYY")),
DropdownMenuItem(value: "DMY", child: Text("DD Mon YYYY")),
DropdownMenuItem(value: "MDY", child: Text("Mon DD, YYYY")),
],
onChanged: (v) => v == null ? null : _save({"dateFormat": v}),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text("Example: ${formatDate(DateTime.now())}",
style: const TextStyle(color: Colors.grey, fontSize: 12)),
),
const SizedBox(height: 16),
const Text("Font size", style: TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
SegmentedButton<String>(
segments: const [
ButtonSegment(value: "small", label: Text("Small")),
ButtonSegment(value: "medium", label: Text("Medium")),
ButtonSegment(value: "large", label: Text("Large")),
],
selected: {appSettings.fontSize},
onSelectionChanged: (s) => _save({"fontSize": s.first}),
),
],
);
}
}
// --- Profile (avatar + bio) ------------------------------------------------
class _ProfileSection extends StatefulWidget {
final UserProfile profile;
final TextEditingController bioController;
final Uint8List? avatar;
final Future<void> Function() onChanged;
final void Function(String) snack;
const _ProfileSection({
required this.profile,
required this.bioController,
required this.avatar,
required this.onChanged,
required this.snack,
});
@override
State<_ProfileSection> createState() => _ProfileSectionState();
}
class _ProfileSectionState extends State<_ProfileSection> {
bool _avatarBusy = false;
bool _savingBio = false;
Future<void> _pickAvatar() async {
final picker = ImagePicker();
final file = await picker.pickImage(source: ImageSource.gallery, maxWidth: 1024);
if (file == null) return;
setState(() => _avatarBusy = true);
try {
final bytes = await file.readAsBytes();
await apiClient.uploadAvatar(bytes, file.name);
await widget.onChanged();
} catch (e) {
widget.snack("$e");
} finally {
if (mounted) setState(() => _avatarBusy = false);
}
}
Future<void> _removeAvatar() async {
setState(() => _avatarBusy = true);
try {
await apiClient.deleteAvatar();
await widget.onChanged();
} catch (e) {
widget.snack("$e");
} finally {
if (mounted) setState(() => _avatarBusy = false);
}
}
Future<void> _saveBio() async {
setState(() => _savingBio = true);
try {
await apiClient.updateMe({"bio": widget.bioController.text});
widget.snack("Bio saved.");
} catch (e) {
widget.snack("$e");
} finally {
if (mounted) setState(() => _savingBio = false);
}
}
@override
Widget build(BuildContext context) {
final p = widget.profile;
final initial = (p.name.isNotEmpty ? p.name : p.email).characters.first.toUpperCase();
return _Card(
title: "Profile",
children: [
Row(children: [
CircleAvatar(
radius: 32,
backgroundColor: DriverVault.brandTint(context),
backgroundImage: widget.avatar != null ? MemoryImage(widget.avatar!) : null,
child: widget.avatar == null
? Text(initial,
style: TextStyle(
fontSize: 22, fontWeight: FontWeight.w700, color: DriverVault.brandOnTint(context)))
: null,
),
const SizedBox(width: 16),
Wrap(spacing: 8, children: [
OutlinedButton(
onPressed: _avatarBusy ? null : _pickAvatar,
child: Text(_avatarBusy ? "Working…" : "Upload photo"),
),
if (p.hasAvatar)
OutlinedButton(
onPressed: _avatarBusy ? null : _removeAvatar,
child: const Text("Remove"),
),
]),
]),
const SizedBox(height: 16),
const Text("Bio", style: TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
TextField(
controller: widget.bioController,
maxLines: 3,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: "A short note visible to other people in your household.",
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton(
onPressed: _savingBio ? null : _saveBio,
child: Text(_savingBio ? "Saving…" : "Save bio"),
),
),
],
);
}
}
// --- Security (biometric sign-in) ------------------------------------------
class _SecuritySection extends StatefulWidget {
final String email;
final void Function(String) snack;
const _SecuritySection({required this.email, required this.snack});
@override
State<_SecuritySection> createState() => _SecuritySectionState();
}
class _SecuritySectionState extends State<_SecuritySection> {
bool _available = false;
bool _enabled = false;
bool _hasFace = false;
bool _hasFingerprint = false;
bool _busy = false;
bool _loaded = false;
@override
void initState() {
super.initState();
_refresh();
}
Future<void> _refresh() async {
final available = await biometricAuth.isAvailable();
final enabled = await biometricAuth.isEnabled();
final face = available && await biometricAuth.hasFace();
final finger = available && await biometricAuth.hasFingerprint();
if (!mounted) return;
setState(() {
_available = available;
_enabled = enabled;
_hasFace = face;
_hasFingerprint = finger;
_loaded = true;
});
}
String get _methodLabel {
if (_hasFace && _hasFingerprint) return "face or fingerprint";
if (_hasFace) return "face recognition";
if (_hasFingerprint) return "fingerprint";
return "biometrics";
}
Future<void> _toggle(bool value) async {
if (_busy) return;
if (!value) {
setState(() => _busy = true);
await biometricAuth.disable();
if (mounted) setState(() { _enabled = false; _busy = false; });
widget.snack("Biometric sign-in turned off");
return;
}
// Enabling requires re-confirming the password so we store known-good creds.
final pw = await _promptPassword();
if (pw == null || pw.isEmpty) return;
setState(() => _busy = true);
try {
// Verify the password (and refresh the session) before storing it.
await authService.login(widget.email, pw);
await biometricAuth.enable(widget.email, pw);
if (mounted) setState(() { _enabled = true; _busy = false; });
widget.snack("Biometric sign-in enabled");
} catch (e) {
if (mounted) setState(() => _busy = false);
widget.snack("Could not enable: ${e.toString()}");
}
}
Future<String?> _promptPassword() {
final ctrl = TextEditingController();
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Confirm your password"),
content: TextField(
controller: ctrl,
obscureText: true,
autofocus: true,
decoration: const InputDecoration(
labelText: "Password",
border: OutlineInputBorder(),
),
onSubmitted: (v) => Navigator.pop(ctx, v),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancel")),
FilledButton(onPressed: () => Navigator.pop(ctx, ctrl.text), child: const Text("Confirm")),
],
),
);
}
@override
Widget build(BuildContext context) {
return _Card(
title: "Security",
children: [
if (!_loaded)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text("Checking device…", style: TextStyle(color: Colors.grey)),
)
else ...[
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text("Biometric sign-in"),
subtitle: Text(_available
? "Sign in with $_methodLabel instead of your password."
: "No biometrics are enrolled on this device."),
value: _enabled,
onChanged: (!_available || _busy) ? null : _toggle,
),
],
],
);
}
}
// --- Privacy & security (sessions) -----------------------------------------
class _SessionsSection extends StatefulWidget {
final List<Session> sessions;
final Future<void> Function() onChanged;
final void Function(String) snack;
const _SessionsSection({required this.sessions, required this.onChanged, required this.snack});
@override
State<_SessionsSection> createState() => _SessionsSectionState();
}
class _SessionsSectionState extends State<_SessionsSection> {
Future<void> _revoke(Session s) async {
try {
await apiClient.revokeSession(s.id);
if (s.current) {
await authService.logout();
return;
}
await widget.onChanged();
} catch (e) {
widget.snack("$e");
}
}
Future<void> _revokeOthers() async {
try {
await apiClient.revokeOtherSessions();
await widget.onChanged();
} catch (e) {
widget.snack("$e");
}
}
@override
Widget build(BuildContext context) {
return _Card(
title: "Privacy & security",
children: [
const Text(
"Two-factor authentication isn't available yet. Active sessions below reflect every device currently signed in.",
style: TextStyle(color: Colors.grey, fontSize: 13),
),
if (widget.sessions.length > 1)
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: _revokeOthers,
child: const Text("Log out all other devices"),
),
),
...widget.sessions.map((s) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Flexible(
child: Text(s.deviceLabel,
style: const TextStyle(fontWeight: FontWeight.w500))),
if (s.current)
Container(
margin: const EdgeInsets.only(left: 6),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: DriverVault.brandTint(context),
borderRadius: BorderRadius.circular(999),
),
child: Text("This device",
style: TextStyle(fontSize: 11, color: DriverVault.brandOnTint(context))),
),
]),
Text("${s.ip} · signed in ${formatDate(s.created)}",
style: const TextStyle(color: Colors.grey, fontSize: 12)),
],
),
),
TextButton(
onPressed: () => _revoke(s),
child: const Text("Log out", style: TextStyle(color: DriverVault.danger)),
),
],
),
)),
],
);
}
}
// --- Danger zone (account deletion) ----------------------------------------
class _DangerSection extends StatefulWidget {
final UserProfile profile;
final Future<void> Function() onChanged;
final void Function(String) snack;
const _DangerSection({required this.profile, required this.onChanged, required this.snack});
@override
State<_DangerSection> createState() => _DangerSectionState();
}
class _DangerSectionState extends State<_DangerSection> {
final _confirmEmail = TextEditingController();
bool _showConfirm = false;
bool _busy = false;
DateTime? _eligibleAt;
@override
void dispose() {
_confirmEmail.dispose();
super.dispose();
}
bool get _pending => widget.profile.deletionPending;
DateTime? get _eligible {
if (!_pending) return null;
return _eligibleAt ??
widget.profile.deletionRequestedAt!.add(const Duration(days: 3));
}
bool get _cooldownElapsed => _eligible != null && DateTime.now().isAfter(_eligible!);
Future<void> _request() async {
if (_confirmEmail.text.trim().toLowerCase() != widget.profile.email.toLowerCase()) {
widget.snack("Type your email to confirm.");
return;
}
setState(() => _busy = true);
try {
_eligibleAt = await apiClient.requestAccountDeletion(_confirmEmail.text.trim());
_showConfirm = false;
_confirmEmail.clear();
await widget.onChanged();
} catch (e) {
widget.snack("$e");
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _cancel() async {
try {
await apiClient.cancelAccountDeletion();
_eligibleAt = null;
await widget.onChanged();
} catch (e) {
widget.snack("$e");
}
}
Future<void> _finalize() async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Delete account?"),
content: const Text("This permanently deletes your account. This cannot be undone."),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
onPressed: () => Navigator.pop(ctx, true),
child: const Text("Delete"),
),
],
),
);
if (ok != true) return;
try {
await apiClient.finalizeAccountDeletion();
await authService.logout();
} catch (e) {
widget.snack("$e");
}
}
@override
Widget build(BuildContext context) {
final p = widget.profile;
return _Card(
title: "Danger zone",
titleColor: DriverVault.danger,
children: [
if (!_pending) ...[
const Text(
"Deleting your account removes your login and profile. It does not delete your household's shared cars or service history. There's a 3-day cooldown before deletion is final, and you can cancel any time before then.",
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 8),
if (!_showConfirm)
OutlinedButton(
onPressed: () => setState(() => _showConfirm = true),
style: OutlinedButton.styleFrom(foregroundColor: DriverVault.danger),
child: const Text("Delete my account"),
)
else ...[
Text("Type ${p.email} to confirm",
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
TextField(
controller: _confirmEmail,
decoration: InputDecoration(
hintText: p.email, border: const OutlineInputBorder(), isDense: true),
),
const SizedBox(height: 8),
Row(children: [
TextButton(
onPressed: () => setState(() {
_showConfirm = false;
_confirmEmail.clear();
}),
child: const Text("Cancel"),
),
const SizedBox(width: 8),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
onPressed: _busy ? null : _request,
child: Text(_busy ? "Requesting…" : "Request deletion"),
),
]),
],
] else ...[
Text(
"Account deletion requested on ${formatDate(p.deletionRequestedAt)}. "
"${_cooldownElapsed ? 'The cooldown has passed. You can now finalize the deletion.' : 'You can still cancel — it becomes permanent after the 3-day cooldown.'}",
style: const TextStyle(fontSize: 13),
),
const SizedBox(height: 8),
Row(children: [
OutlinedButton(onPressed: _cancel, child: const Text("Cancel deletion request")),
const SizedBox(width: 8),
if (_cooldownElapsed)
FilledButton(
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
onPressed: _finalize,
child: const Text("Permanently delete"),
),
]),
],
],
);
}
}
+283
View File
@@ -0,0 +1,283 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
/// DriverVault design tokens + Material theme, mirroring the web app's design
/// system (blue-led cool palette, Archivo + DM Mono type, 22px cards, soft
/// cool shadows). Every screen styles through this so light/dark theme for free.
class DriverVault {
DriverVault._();
// ---- Brand blue ramp (from the DriverVault mark) ----
static const brand900 = Color(0xFF0B1730);
static const brand800 = Color(0xFF0F1E3D);
static const brand700 = Color(0xFF1E40AF);
static const brand600 = Color(0xFF2563EB);
static const brand500 = Color(0xFF3B82F6);
static const brand400 = Color(0xFF60A5FA);
static const brand300 = Color(0xFF93C5FD);
static const brand100 = Color(0xFFE8F0FD);
// ---- Cool neutrals ----
static const ink900 = Color(0xFF0F1E3D);
static const ink600 = Color(0xFF3E4E68);
static const ink500 = Color(0xFF5C6B85);
static const ink400 = Color(0xFF7A8AA6);
static const ink200 = Color(0xFFD6DEEA);
static const ink100 = Color(0xFFE4E9F2);
static const ink50 = Color(0xFFEEF2F8);
static const ink25 = Color(0xFFF7F9FC);
// ---- Semantic status (car: OK / due / fault) ----
static const success = Color(0xFF1F8A5B);
static const warning = Color(0xFFD9822B);
static const danger = Color(0xFFDC2A45);
static const info = Color(0xFF2563EB);
// Light status tints
static const successSoft = Color(0xFFE1F3EA);
static const warningSoft = Color(0xFFFBEDDD);
static const dangerSoft = Color(0xFFFBE3E7);
static const infoSoft = Color(0xFFE8F0FD);
// Dark status tints (re-cut for dark surfaces)
static const successSoftDark = Color(0xFF12352A);
static const warningSoftDark = Color(0xFF3A2A16);
static const dangerSoftDark = Color(0xFF3A1620);
static const infoSoftDark = Color(0xFF122A4D);
// ---- Dark surfaces / text / borders ----
static const darkPage = Color(0xFF0B1730);
static const darkCard = Color(0xFF13233F);
static const darkSunken = Color(0xFF0F1E38);
static const darkBorder = Color(0xFF21324F);
static const darkBorderStrong = Color(0xFF2C3F5E);
static const darkTextStrong = Color(0xFFF2F6FC);
static const darkTextBody = Color(0xFFB7C4D9);
static const darkTextMuted = Color(0xFF7C8CA8);
static const darkAccent = Color(0xFF3B82F6);
static const radiusControl = 12.0;
static const radiusCard = 22.0;
static bool isDark(BuildContext c) => Theme.of(c).brightness == Brightness.dark;
// Brand tint used for avatars / info chips (adapts to theme).
static Color brandTint(BuildContext c) => isDark(c) ? const Color(0xFF17294A) : brand100;
static Color brandOnTint(BuildContext c) => isDark(c) ? brand300 : brand700;
/// Muted secondary text colour (replaces ad-hoc Colors.grey).
static Color muted(BuildContext c) => isDark(c) ? darkTextMuted : ink400;
/// DM Mono style for data / units / labels.
static TextStyle mono(BuildContext c, {double size = 13, FontWeight weight = FontWeight.w500, Color? color}) =>
GoogleFonts.dmMono(
fontSize: size,
fontWeight: weight,
letterSpacing: 0.2,
color: color ?? Theme.of(c).textTheme.bodyMedium?.color,
);
static ThemeData theme(Brightness brightness) {
final dark = brightness == Brightness.dark;
final scheme = ColorScheme(
brightness: brightness,
primary: dark ? darkAccent : brand600,
onPrimary: Colors.white,
primaryContainer: dark ? const Color(0xFF17294A) : brand100,
onPrimaryContainer: dark ? brand300 : brand700,
secondary: dark ? brand400 : brand700,
onSecondary: Colors.white,
surface: dark ? darkCard : Colors.white,
onSurface: dark ? darkTextStrong : ink900,
surfaceContainerHighest: dark ? darkSunken : ink50,
onSurfaceVariant: dark ? darkTextBody : ink600,
outline: dark ? darkBorderStrong : ink200,
outlineVariant: dark ? darkBorder : ink100,
error: danger,
onError: Colors.white,
errorContainer: dark ? dangerSoftDark : dangerSoft,
onErrorContainer: danger,
);
final baseText = dark ? ThemeData.dark().textTheme : ThemeData.light().textTheme;
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: scheme,
scaffoldBackgroundColor: dark ? darkPage : ink25,
textTheme: GoogleFonts.archivoTextTheme(baseText).apply(
bodyColor: dark ? darkTextBody : ink600,
displayColor: dark ? darkTextStrong : ink900,
),
appBarTheme: AppBarTheme(
backgroundColor: dark ? darkCard : Colors.white,
foregroundColor: dark ? darkTextStrong : ink900,
elevation: 0,
scrolledUnderElevation: 0.5,
centerTitle: false,
titleTextStyle: GoogleFonts.archivo(
fontSize: 20,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
color: dark ? darkTextStrong : ink900,
),
),
cardTheme: CardThemeData(
color: dark ? darkCard : Colors.white,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusCard),
side: BorderSide(color: dark ? darkBorder : ink100),
),
),
dividerColor: dark ? darkBorder : ink100,
dividerTheme: DividerThemeData(color: dark ? darkBorder : ink100, thickness: 1),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: dark ? darkSunken : Colors.white,
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusControl),
borderSide: BorderSide(color: dark ? darkBorderStrong : ink200),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusControl),
borderSide: BorderSide(color: dark ? darkBorderStrong : ink200),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusControl),
borderSide: BorderSide(color: dark ? darkAccent : brand600, width: 2),
),
labelStyle: TextStyle(color: dark ? darkTextMuted : ink500),
floatingLabelStyle: TextStyle(color: dark ? darkAccent : brand600),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: dark ? darkAccent : brand600,
foregroundColor: Colors.white,
textStyle: GoogleFonts.archivo(fontWeight: FontWeight.w600, fontSize: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusControl)),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: dark ? darkTextBody : ink600,
side: BorderSide(color: dark ? darkBorderStrong : ink200),
textStyle: GoogleFonts.archivo(fontWeight: FontWeight.w600, fontSize: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusControl)),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: dark ? brand300 : brand700,
textStyle: GoogleFonts.archivo(fontWeight: FontWeight.w600, fontSize: 14),
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: dark ? darkAccent : brand600,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusControl)),
),
chipTheme: ChipThemeData(
backgroundColor: dark ? darkSunken : ink50,
side: BorderSide(color: dark ? darkBorder : ink100),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: dark ? darkCard : Colors.white,
surfaceTintColor: Colors.transparent,
indicatorColor: dark ? const Color(0xFF17294A) : brand100,
elevation: 0,
height: 66,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final on = states.contains(WidgetState.selected);
return GoogleFonts.archivo(
fontSize: 11,
fontWeight: on ? FontWeight.w600 : FontWeight.w500,
color: on ? (dark ? brand300 : brand700) : (dark ? darkTextMuted : ink400),
);
}),
iconTheme: WidgetStateProperty.resolveWith((states) {
final on = states.contains(WidgetState.selected);
return IconThemeData(
size: 24,
color: on ? (dark ? brand300 : brand700) : (dark ? darkTextMuted : ink400),
);
}),
),
snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating),
);
}
}
/// The DriverVault "Fast Forward" mark — three forward-leaning rounded bars of
/// increasing height (skewX(-13°)), reading as acceleration / momentum.
class DriverVaultMark extends StatelessWidget {
final double size;
const DriverVaultMark({super.key, this.size = 32});
@override
Widget build(BuildContext context) {
final u = size / 40; // scale factor
Widget bar(double h, Color c) => Container(
width: 5 * u,
height: h * u,
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(3 * u)),
);
return SizedBox(
width: size,
height: size,
child: Transform(
alignment: Alignment.center,
transform: Matrix4.skewX(-0.23),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
bar(18, DriverVault.brand700),
SizedBox(width: 3.5 * u),
bar(28, DriverVault.brand500),
SizedBox(width: 3.5 * u),
bar(36, DriverVault.brand400),
],
),
),
);
}
}
/// Full DriverVault lockup: the mark + the Archivo italic-800 wordmark
/// ("Driver" in strong ink, "Vault" in brand blue).
class DriverVaultLogo extends StatelessWidget {
final double markSize;
final double fontSize;
const DriverVaultLogo({super.key, this.markSize = 32, this.fontSize = 24});
@override
Widget build(BuildContext context) {
final strong = DriverVault.isDark(context) ? DriverVault.darkTextStrong : DriverVault.ink900;
final brand = DriverVault.isDark(context) ? DriverVault.brand400 : DriverVault.brand700;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
DriverVaultMark(size: markSize),
SizedBox(width: markSize * 0.32),
Text.rich(
TextSpan(children: [
TextSpan(text: "Driver", style: TextStyle(color: strong)),
TextSpan(text: "Vault", style: TextStyle(color: brand)),
]),
style: GoogleFonts.archivo(
fontSize: fontSize,
fontWeight: FontWeight.w800,
fontStyle: FontStyle.italic,
letterSpacing: -0.5,
),
),
],
);
}
}
+754
View File
@@ -0,0 +1,754 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
url: "https://pub.dev"
source: hosted
version: "2.11.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51"
url: "https://pub.dev"
source: hosted
version: "0.3.5+4"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
url: "https://pub.dev"
source: hosted
version: "1.0.8"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.35"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
url: "https://pub.dev"
source: hosted
version: "9.2.4"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
url: "https://pub.dev"
source: hosted
version: "1.2.3"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
url: "https://pub.dev"
source: hosted
version: "1.2.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
url: "https://pub.dev"
source: hosted
version: "6.3.3"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
url: "https://pub.dev"
source: hosted
version: "4.9.1"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667
url: "https://pub.dev"
source: hosted
version: "1.2.3"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a"
url: "https://pub.dev"
source: hosted
version: "0.8.13+19"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://pub.dev"
source: hosted
version: "0.8.13+6"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
intl:
dependency: "direct main"
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.dev"
source: hosted
version: "0.19.0"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.6.7"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
local_auth:
dependency: "direct main"
description:
name: local_auth
sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
local_auth_android:
dependency: "direct main"
description:
name: local_auth_android
sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467
url: "https://pub.dev"
source: hosted
version: "1.0.56"
local_auth_darwin:
dependency: transitive
description:
name: local_auth_darwin
sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49"
url: "https://pub.dev"
source: hosted
version: "1.6.1"
local_auth_platform_interface:
dependency: transitive
description:
name: local_auth_platform_interface
sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122
url: "https://pub.dev"
source: hosted
version: "1.1.0"
local_auth_windows:
dependency: transitive
description:
name: local_auth_windows
sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
url: "https://pub.dev"
source: hosted
version: "1.0.11"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e"
url: "https://pub.dev"
source: hosted
version: "2.4.11"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
url: "https://pub.dev"
source: hosted
version: "1.10.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
url: "https://pub.dev"
source: hosted
version: "1.2.1"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
url: "https://pub.dev"
source: hosted
version: "14.3.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba"
url: "https://pub.dev"
source: hosted
version: "5.13.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0"
+38
View File
@@ -0,0 +1,38 @@
name: carcontrol_phone
description: "DriverVault — car control & service-tracking phone app."
publish_to: "none"
version: 0.1.0+1
environment:
sdk: ">=3.4.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
shared_preferences: ^2.2.0
intl: ^0.19.0
cupertino_icons: ^1.0.8
image_picker: ^1.1.2
local_auth: ^2.3.0
local_auth_android: ^1.0.46
flutter_secure_storage: ^9.2.2
google_fonts: ^6.2.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
flutter_launcher_icons: ^0.14.1
flutter:
uses-material-design: true
# DriverVault launcher icon — regenerate with:
# flutter pub run flutter_launcher_icons
flutter_launcher_icons:
android: true
image_path: "assets/icon/drivervault.png"
min_sdk_android: 21
adaptive_icon_background: "#1E40AF"
adaptive_icon_foreground: "assets/icon/drivervault-fg.png"
Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="carcontrol_phone">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>carcontrol_phone</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<!--
You can customize the "flutter_bootstrap.js" script.
This is useful to provide a custom configuration to the Flutter loader
or to give the user feedback during the initialization process.
For more details:
* https://docs.flutter.dev/platform-integration/web/initialization
-->
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "carcontrol_phone",
"short_name": "carcontrol_phone",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}