Files
DriverVault/Phone App/README.md
T
tajniak81andClaude Opus 5 6b7abb4b84 Phone App: a service card laid out by the car's own columns
The Service history columns became a property of the car two commits ago, and
the phone was left out of it on the grounds that it has no table to arrange.
But the arrangement is not the table's — it belongs to the car, and everyone it
is shared with sees it. A reader who switched Oil off on the web still had it
on every card here, which makes the setting look broken rather than absent.

A card is not a table, so the columns cannot be cells. Consecutive short ones
share a wrapping line, which flows left to right and then down and so keeps the
arrangement intact; the parts, the notes and the file each take a line of their
own. That means the grouping follows the car's order rather than the
catalogue's — move Notes between Km and Next date and the short columns split
around it — which is the part a hand-written card gets wrong by collecting the
short columns first and appending the blocks after them, quietly undoing the
arrangement it was asked to honour. serviceColumnRuns is a function for exactly
that reason: it is the piece worth a test.

The date carries no heading where every other column does. A card list is read
down its dates, and "Date" in front of one says nothing the date doesn't — the
same judgment the server makes by refusing to hide it. It is offered in the
picker anyway, ticked and locked, because a row missing from that list is a row
nothing on this screen can drag; the web drags the column headings themselves,
which on a touch screen is the scroll's gesture. A column that is on but empty
says so ("Notes —") rather than vanishing: it was switched on deliberately, and
a card that silently drops it reads as a record that failed to load.

Changed parts arrives with it. Every part shares the one column — they are a
growing list and a column apiece would widen the web's table without end — and
lib/service_parts.dart is the twin of the web's lib/serviceParts.js, so the
form's checkboxes and the card's chips come from one list and adding a part is
one entry plus its boolean on service_records. The chips keep their own shorter
wording; the form keeps the web's, which is what stops the two apps naming the
same part differently.

Verified: flutter analyze is clean and flutter test passes, 22 tests to 32 —
the new ones cover that the arrangeable set is the hideable one plus the date,
that a field key is not a column key, that adding a part adds no column, the
run grouping, and a widget test of the picker showing the date's is the only
locked checkbox. The screen itself was driven against a throwaway stub API: a
default car renders date, Km, Next date, Next km, chips, notes and file in that
order, and a car hiding km and file with the order [notes, date, nextKm, parts,
km, nextDate] rendered exactly that — notes first, both hidden columns gone, the
short columns split around the chips. updateCarView round-trips both new fields
under the names records.go decodes.

Not verified: the picker's own Save button — the tap landed in the harness but
the request never reached the stub, which reads as the fire-and-forget future
being cut off at teardown, since the same call made directly worked. Drag was
exercised through the reorder callback, not by a finger. Nothing here needs the
API Server to change: both fields already ship, and a phone running against an
older one simply reads empty lists and shows every column.

Two commits needed nothing: the web's masked date box answers <input
type="date"> rendering in the browser's locale, which a picker-only field
cannot have, and the garage card's width answers a badge that wrapped, which
this badge cannot. car.services.next goes, its prose replaced by the columns
that now say it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:32:45 +02:00

215 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 to override the API base URL on-device.
- **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.
- **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 and
Service history columns on or off, and drag any of the three lists by its
handle to reorder it. (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`.
- **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 two 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** carries the one real piece — an OCPP control card that drives your
own charger through the Anker Solix control endpoints, once you pick Own/Proxy
CSMS under Settings → Integrations.
- **Settings** — account (name / email verification / password), appearance
(theme + dark mode, **language**, **region**, date format, **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.
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`.
## 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.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 (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
├── 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)
└── charging_screen.dart settings_screen.dart admin_users_screen.dart
```