Android Auto's Unknown sources setting covers media, messaging and parked apps; it has never covered the Car App Library, so the README's advice to turn it on could not have worked. A head unit lists DriverVault only when Play was the installer — an internal track is enough — and the Desktop Head Unit is what takes a sideloaded build. Say that instead, and say which APK the DHU wants, since only a debug build allows its host. minCarApiLevel also claimed a floor lower than the screens stand on: GarageScreen asks for ConstraintManager, which is level 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
389 lines
24 KiB
Markdown
389 lines
24 KiB
Markdown
# DriverVault — Phone App (Flutter)
|
||
|
||
A Flutter client for the DriverVault maintenance tracker. Talks **only** to the
|
||
API Server (same contract and auth as the web app — a PocketBase token relayed by
|
||
the server, not a JWT the server mints). At full feature parity with the web app.
|
||
|
||
Project name `drivervault_phone`, package id `com.drivervault.phoneapp`.
|
||
**Android is the supported target** — the older Flutter-web build path is
|
||
deprecated.
|
||
|
||
## Features
|
||
|
||
Once signed in, `RootShell` hosts the app behind a persistent **bottom
|
||
navigation bar** — Garage, Charging, Settings, and Users for admins — in an
|
||
`IndexedStack`, so each section keeps its state as you switch tabs.
|
||
|
||
- **Login** — email/password against `/api/auth/login`, password show/hide, and a
|
||
collapsible **Server settings** section holding the address of the server the
|
||
form signs into — plus the way back to the others once more than one has been
|
||
added (see *More than one server*).
|
||
- **Biometric / face sign-in + app lock** — see the dedicated section below.
|
||
- **Garage (dashboard)** — car list with next-due status badges (date + km,
|
||
worst-of), a "shared" chip on cars owned by someone else, pull-to-refresh and
|
||
an **Add car** FAB. The header carries the **server picker** (see below), the
|
||
theme toggle and log out.
|
||
- **Car detail** — all spec fields (incl. VIN and transmission / differential /
|
||
brake / coolant specs), a **share** sheet (owner only), quick odometer update,
|
||
edit car, and delete car (type-to-confirm; cascades). Actions are gated by the
|
||
caller's access level (read-only vs write vs owner).
|
||
|
||
**Which tabs a car shows, and in what order, belongs to the car** — the same
|
||
arrangement the web app reads, so everyone it is shared with sees the same
|
||
page. Edit it under the **tune** icon: switch tabs, Information rows, Service
|
||
history columns and the parts a service can change on or off, and drag any of
|
||
the first three lists by its handle to reorder it. (The parts have no order —
|
||
they are a checkbox list inside one column, and their position says nothing a
|
||
tab's or a column's does.) (The web rearranges by dragging the tab bar and the
|
||
column headings themselves; on a touch screen those gestures belong to the tab
|
||
bar and the scroll, so every arrangement is made in the picker instead.) Two
|
||
things cannot be switched off: Information — a page with no tabs left would be
|
||
a dead end — and the service Date, since a history with the day taken out
|
||
stops being one. Both can still be moved. The tabs, in their default order:
|
||
- **Connected service** — everything the manufacturer's own app knows about
|
||
this car, read live under *your* account: headline readings (odometer, fuel
|
||
or battery, range, charging state, position), the vehicle record, and one
|
||
card per capability the plugin exposes, rendered from the server's flattened
|
||
key/value pairs so a provider adding a field surfaces it without an app
|
||
change. Offers to take the provider's odometer when it is ahead of the
|
||
stored one. A car with no link yet gets a **connect** card instead, and the
|
||
tab is hidden entirely for a user with no manufacturer account connected.
|
||
Because the read runs under your own credentials, a car shared from someone
|
||
else shows data here only if that vehicle is on your account too — it says
|
||
so rather than failing.
|
||
- **Service history** — date/odometer plus which parts were changed, with the
|
||
next-due date/km derived by the server. The web lays those out as table
|
||
columns; a phone has no width for a seven-column table, so a card renders the
|
||
same columns as pieces — consecutive short ones share a wrapping line, and
|
||
the parts, the notes and the file each take a line of their own. It reads the
|
||
car's own hidden set and order, so a column switched off on either app is off
|
||
on both, and the arrangement decides the grouping: move Notes between Km and
|
||
Next date and the short columns split around it. Every part a service can
|
||
have changed shares the one **parts** column — they are a growing list, and a
|
||
column apiece would widen the web's table without end — and both that column
|
||
and the form's Changed parts section come from `lib/service_parts.dart`, the
|
||
twin of the web's `lib/serviceParts.js`. Which of them this car records is
|
||
the car's own choice as well: a part switched off leaves the form's
|
||
checkboxes and the cards' chips together, and nothing is written to the
|
||
records, so switching it back on brings the old chips back.
|
||
- **Technical check history** — the mandatory roadworthiness inspections
|
||
(przegląd techniczny, MOT, TÜV). Result, cost, station, and the certificate's
|
||
valid-until, which overrides the car's interval when present. A failed check
|
||
derives no next date.
|
||
- **Maintenance** — workshop visits and repairs outside the routine schedule:
|
||
type/status, workshop, parts used, labour + parts cost, invoice number and a
|
||
warranty-until badge.
|
||
- **Fuel** — refills with a summary panel (average / best / worst consumption,
|
||
cost per km, price per litre). Consumption is measured between full tanks, so
|
||
partial fills roll into the next full one and a flagged missed fill leaves its
|
||
window uncomputed rather than reporting a fictional figure.
|
||
- **Charging cost** — charges with a summary panel (average / best / worst
|
||
kWh/100km, cost per km, price per kWh). The electric twin of Fuel, and
|
||
derived the same way: consumption is measured between full charges, so a
|
||
top-up rolls into the next full one. Distinct from the **Charging** section
|
||
in the bottom bar, which is the charger network and OCPP control.
|
||
- **Documents** — insurance, registration, road tax and the rest, with a
|
||
renewal badge driven by the server's expiry assessment.
|
||
- **Parts catalog** — the per-car parts list.
|
||
- **Reminders** — date- and/or odometer-triggered, one-off or recurring, with a
|
||
**Mark done** action. Reminders the server derived from a document or service
|
||
record are shown read-only.
|
||
- **Attachments** — one optional file per service record, technical check,
|
||
workshop visit, refill, charge, document and part (PDF or image, up to 10MB). Picked
|
||
with `file_picker`, fetched back through the API Server — never a public URL —
|
||
and opened with the phone's own viewer via `open_filex`.
|
||
- **Charging** — mirrors the web `Charging.vue`, split into three tabs.
|
||
**Public** is a discovery map with a demo session and nearby stations:
|
||
presentational placeholders, because there is no public-charging API yet (same
|
||
as the web). **Home** is not a placeholder. It lists the chargers you own —
|
||
imported from a service you connected, the same move the garage makes for a
|
||
car — and above them six cards about the one you picked:
|
||
- **control** — start/stop, boost, skip a start delay, reboot, and the current
|
||
limit on the one transport that has nowhere else to put it. It opens with
|
||
the charger it acts on, picture and name, because the buttons drive whichever
|
||
serial is in force and that is not always the record highlighted below.
|
||
- **RFID cards** — who may start a charge without a phone. The list the
|
||
account holds, a card added by number or by holding it against the charger's
|
||
own reader (the reader opens for twenty seconds and the number arrives on its
|
||
own), and the charger's own list read back from the device, which is the half
|
||
that actually decides whether a card opens it. When the two disagree the card
|
||
says which list each card is missing from.
|
||
- **charger settings** — what the charger is set to, written back. Over Modbus
|
||
that is four registers (the current limit, phase count, boost, the control
|
||
timeout); over the Anker cloud it is the charger's whole settings group, in
|
||
sections — charging, schedule, load balancing, solar, panel and light, local
|
||
network — one write per section, because the charger takes a command whole.
|
||
- **connection** — which charger, and either its serial or its address on your
|
||
network.
|
||
- **readings** — everything the charger reports: per-phase power, its
|
||
settings, what it is, and any alarm.
|
||
- **information** — everything the record holds, with the service's live view
|
||
of whether it is reachable, the fields the service sent under its own names,
|
||
and each of the account's per-charger views read a record at a time.
|
||
|
||
Control needs a mode picked under Settings → Integrations — Modbus TCP over
|
||
the local network, the Anker cloud, or Own/Proxy CSMS over OCPP — but the
|
||
information and RFID cards stand without one. Each card folds away, remembered
|
||
per device; the tabs and the cards rearrange from the ⇅ button in the app bar,
|
||
and that arrangement is saved on your profile, so it follows the account the
|
||
way the garage order does. A card added after you arranged yours appears beside
|
||
the neighbour it was written to sit under, rather than at the bottom.
|
||
|
||
**Scheduler** is the third tab: one list of charging tasks covering every
|
||
charger you own, where the charger's own cloud schedule is one window inside
|
||
one box. A task is a whole flow — start at 23:00, cap to 10 A at 01:00, stop at
|
||
06:30 — on the days you pick and the chargers you pick, named once and switched
|
||
on and off as one. Naming no charger means every charger you own, including the
|
||
ones you import later. The clock is the server's, so a task fires whether or
|
||
not the app is open; each row says how its last firing went, and any step can
|
||
be fired now to find out whether it will reach the charger before the night it
|
||
matters.
|
||
- **Settings** — account (name / email verification / password), appearance
|
||
(theme + dark mode, **language**, **region**, date format, **time format**,
|
||
**first day of the week**, **currency**, font size), profile (avatar via `image_picker`, bio), **integrations** (Toyota,
|
||
Anker Solix), **Security** (biometric toggle), **Organization** (create your
|
||
own — which makes you its admin — or rename/delete the one you administer),
|
||
**data export/import**, and the account-deletion state machine. Export writes
|
||
the account JSON into the app's own documents directory and offers to open it;
|
||
import picks a previously exported file and always creates new records, so it
|
||
asks first, with the number of cars the file actually holds. Auth relays PocketBase's own stateless
|
||
tokens, so there is no per-device session list to show or revoke.
|
||
- **Users (admin)** — user management tab (list / create / role / reset password
|
||
/ delete), shown only for the admin role. A blocked control says why rather
|
||
than only greying out: long-press a locked role picker, and the overflow menu
|
||
carries the reason under the action. Superadmins also choose which
|
||
organization a new account lands in (or none at all); an admin gets no picker,
|
||
because the server puts their members in their own organization regardless.
|
||
|
||
- **Android Auto** — the garage on the car's own screen, read-only: which car
|
||
is overdue, and what it has coming. See *In the car* below.
|
||
|
||
Sharing/ownership: `Car.access` drives `isOwner` / `canWrite` / `isReadOnly`
|
||
getters that gate the UI, mirroring the server's access checks.
|
||
|
||
## Language, region & currency
|
||
|
||
Language and region are two pickers over the one stored BCP-47 tag (`en-US`), so
|
||
the pair can be mixed — English in Poland, say. Currency is display-only: nothing
|
||
is converted, so changing it reinterprets existing amounts rather than
|
||
recalculating them. All three lists mirror the API's (`validCurrencies` and the
|
||
locale pattern in `internal/api/me.go`), with two deliberate differences from the
|
||
web app:
|
||
|
||
- **Luxembourgish (`lb`) and Romansh (`rm`) are not offered.** `intl` ships no
|
||
date/number symbols for them and *throws* rather than falling back, which would
|
||
take out every date on screen. The browser has full ICU data behind it and has
|
||
no such limit, so the web app can list them.
|
||
- **Labels are hand-kept** (endonyms for languages, English for regions and
|
||
currencies) because Dart has no `Intl.DisplayNames`.
|
||
|
||
Because the server only validates a locale's *shape* (`^[a-z]{2}-[A-Z]{2}$`), a
|
||
tag the phone cannot render can still arrive from the web. `format.dart` resolves
|
||
through a supported-language check and falls back to `en-US` instead of throwing;
|
||
`test/models_format_test.dart` covers it.
|
||
|
||
Every screen reads its text from the language files, and `flutter test` fails if
|
||
a key exists in English but not in Polish or Danish. See
|
||
[TRANSLATIONS.md](../TRANSLATIONS.md).
|
||
|
||
## 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 token 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`.
|
||
|
||
## More than one server
|
||
|
||
Two sites, two full DriverVault stacks — and one app. The server picker is the
|
||
first button in the Garage header: it names the server you are reading right
|
||
now, and switches between them in a tap. With only one server known there is
|
||
nothing to pick between, so it goes straight to adding the second.
|
||
|
||
- **The home server** is the address this build ships with (`kDefaultApiBase`,
|
||
overridable per-device from the login screen's **Server settings**). It is
|
||
always in the list and can't be removed.
|
||
- **Any other server** is added by address — `https://garage.example.com`; the
|
||
`/api` is appended for you if you leave the path off — and has to be reachable
|
||
from wherever the phone is.
|
||
- **A session per server.** Each server is its own PocketBase with its own users,
|
||
so a token can't be carried across: you sign into each one once, and after that
|
||
switching needs no password. Sessions live in `SharedPreferences` under
|
||
`cc_session_<id>`, the list under `cc_servers`, the active one under
|
||
`cc_active_server`.
|
||
- **Switching rebuilds the shell**, because record ids belong to the server that
|
||
issued them — the garage, the charging page and the settings all re-read from
|
||
the one now active, and its owner's appearance prefs come with it.
|
||
- **An expiring remote session doesn't sign you out of the app**: that server's
|
||
token is dropped, the app falls back to the home server, and the entry stays in
|
||
the list to sign into again. Only *Log out* clears every server at once.
|
||
|
||
Upgrading from a single-server build carries what was there onto the home entry
|
||
— the saved session (`cc_token` / `cc_user`) and the address it was pointed at
|
||
(`cc_server_url`) — so nobody is signed out by the update.
|
||
|
||
## In the car — Android Auto
|
||
|
||
The same APK is an Android Auto app. Plug the phone in and DriverVault is on the
|
||
head unit: a **garage list**, one row per car with its due badge under the name,
|
||
and a tap opens what that car has coming — the odometer, the next service, and
|
||
the reminders the server holds for it, stored and auto-derived alike.
|
||
|
||
There is no Flutter engine on a head unit. The car screens are
|
||
[Car App Library](https://developer.android.com/training/cars/apps) templates the
|
||
host draws itself, written in Kotlin under
|
||
`android/app/src/main/kotlin/com/drivervault/phoneapp/car/`, and they take three
|
||
things from the phone app rather than growing their own:
|
||
|
||
- **The session.** `VaultStore` reads what the phone has already signed in with —
|
||
the active server's base URL and its token — out of shared_preferences' own
|
||
store, which both halves of the app share. Switch server on the phone and the
|
||
car reads the new one; there is nothing to keep in step. A head unit is the
|
||
last place to type a password, so if nothing is signed in the car says so and
|
||
points at the phone. The one key added for this is `cc_active_base`, the
|
||
*resolved* base URL: an untouched home entry carries no address of its own, its
|
||
base being `kDefaultApiBase` — a compile-time define nothing outside Dart can
|
||
read.
|
||
- **The words.** `CarStrings` reads the same `assets/i18n/{lang}.json` files, by
|
||
the same dot-path keys, in the language the account chose. A due badge on the
|
||
head unit is the string `lib/format.dart` puts on the phone, not a second
|
||
translation of it. Only four keys are the car's own (`carApp.*`): what to say
|
||
when nobody is signed in, when the token has been refused, when the server
|
||
can't be reached, and the Refresh button.
|
||
- **The figures.** `CarFormat` is the twin of `lib/format.dart`: same date format
|
||
and number grouping from the account's settings, same worst-of-date-and-km
|
||
service badge, same wording for a reminder's status.
|
||
|
||
Two things are deliberately unlike the phone:
|
||
|
||
- **It only reads.** No car is edited, no odometer updated, no reminder marked
|
||
done. A screen you cannot type into is a poor place to edit a car and a driver
|
||
is a poor person to ask, so `VaultApi` has no write in it to reach for by
|
||
accident.
|
||
- **Worst first.** The host shows only the first few rows of any list, so the
|
||
garage is sorted by badge rather than kept in the phone's order — the car this
|
||
list exists to mention is the overdue one, not whichever was added first.
|
||
|
||
One thing to know before turning it on: **the app lock does not reach the head
|
||
unit.** Biometric login keeps the phone's own UI behind a fingerprint (see above),
|
||
but that flag lives in memory and the credentials behind it in encrypted storage,
|
||
neither of which the car service can read — and a head unit has no fingerprint
|
||
reader to satisfy it with anyway. So a phone that is paired to a car shows the
|
||
garage there, read-only, whether or not the phone itself is locked. Everything on
|
||
those screens is maintenance data, and the phone still has to be the one plugged
|
||
in, but it is the one place the lock stops short.
|
||
|
||
Running it on your own car:
|
||
|
||
- The service is declared under `androidx.car.app.category.IOT`. It is the
|
||
closest of the categories the library defines — DriverVault is a garage, not a
|
||
map, a media player or a parking service — and a Play Store submission would
|
||
be reviewed against it. Getting it onto a car at all is the next line.
|
||
- **A sideloaded build never appears on a real head unit**, and no setting on the
|
||
phone changes that. Android Auto's **Unknown sources** developer option is the
|
||
one everybody reaches for here, but it covers media, messaging-notification and
|
||
parked apps only — [Google's testing
|
||
guide](https://developer.android.com/training/cars/testing) says in as many
|
||
words that it does not apply to apps built with the Car App Library. A car will
|
||
list DriverVault only if the APK was installed *from Play*: an internal testing
|
||
track, or internal app sharing, is enough — no public listing, no review.
|
||
- To try it without Play — which is how these screens were written — run Google's
|
||
[Desktop Head Unit](https://developer.android.com/training/cars/testing/dhu),
|
||
which does take a sideloaded build. In the **Android Auto** settings on the
|
||
phone, tap the version ten times to unlock **Developer settings**, then
|
||
**Start head unit server**; on the machine, `adb forward tcp:5277 tcp:5277` and
|
||
run `desktop-head-unit`. Install the **debug** APK for this: a debug build
|
||
accepts any host so the DHU can connect, while a release build only accepts the
|
||
signed hosts the library ships an allowlist for.
|
||
|
||
## 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**, which edits the address of
|
||
the active server — on a fresh install, the home one (persisted in `cc_servers`).
|
||
|
||
## 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.drivervault.phoneapp -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), the record types, integrations, profile
|
||
├── api.dart # ApiClient — the only thing that calls the API Server
|
||
├── auth.dart # AuthService — the active server's session, app-lock flag
|
||
├── servers.dart # ServerRegistry — the servers, their sessions, the active one
|
||
├── biometric.dart # BiometricAuth — local_auth + secure storage; biometricAuth singleton
|
||
├── app_settings.dart # AppSettings (theme/locale/date/font), persisted; drives MaterialApp
|
||
├── i18n.dart # translation lookup — t("key"); en/pl/da with en fallback
|
||
├── theme.dart # shared colours/tones (status badges, charging tiles)
|
||
├── format.dart # date/km formatting + next-service status (worst-of date/km)
|
||
├── service_parts.dart # the parts a service can change, in one list
|
||
├── main.dart # app root; routes Login / Lock / RootShell; lifecycle re-lock
|
||
├── widgets/
|
||
│ └── attachment_field.dart # pick / view / clear a record's attached file
|
||
└── screens/
|
||
├── root_shell.dart # bottom-nav shell: Garage, Charging, Settings, Users
|
||
├── login_screen.dart lock_screen.dart dashboard_screen.dart
|
||
├── car_detail_screen.dart car_form_sheet.dart record_form_sheets.dart
|
||
├── car_view_sheet.dart # which tabs/rows/columns a car shows + the catalogues
|
||
├── provider_tab.dart # the connected-service tab (MyToyota)
|
||
├── servers_sheet.dart # the server picker + the add / edit / sign-in sheet
|
||
├── charger_import_sheet.dart # a charger on a connected service becomes yours
|
||
└── charging_screen.dart settings_screen.dart admin_users_screen.dart
|
||
```
|
||
|
||
The Android Auto half is Kotlin, because the head unit draws its own templates:
|
||
|
||
```
|
||
android/app/src/main/kotlin/com/drivervault/phoneapp/
|
||
├── MainActivity.kt # the Flutter host (FlutterFragmentActivity, for local_auth)
|
||
└── car/
|
||
├── DriverVaultCarAppService.kt # what Android Auto binds to; the root screen + host validator
|
||
├── VaultStore.kt # the phone's session, read from shared_preferences' own store
|
||
├── VaultApi.kt # the reads the car screens make — no writes exist here
|
||
├── CarStrings.kt # t("key") over the bundled assets/i18n files
|
||
├── CarFormat.kt # dates, km and the due badges — the twin of lib/format.dart
|
||
├── VaultScreen.kt # load / loading / failed-with-a-Refresh, shared by both screens
|
||
├── GarageScreen.kt # the cars, worst badge first
|
||
└── CarDueScreen.kt # one car: odometer, next service, reminders
|
||
```
|