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