Two changes, both about showing what is actually known rather than a tidier version of it. The build date asked for a day. A car's build date is often only a year, or a month and a year - the VIN plate is stamped with a month, the papers carry a day, a grey import neither - so a field insisting on all three is answered either with an invented day or with nothing, and both throw away what the owner did know. The field now picks its own precision: a full date, a month and year, or a year, each with the control that suits it. A year is typed rather than picked, because a date picker that makes you walk back to 1998 is worse than four keystrokes. Stored as the ISO prefix - "2015", "2015-03", "2015-03-10" - which is ISO 8601 reduced precision, and printed back at exactly that precision. The three shapes sort and compare as strings in date order, which is why the prefix is stored rather than a date with a precision field beside it. The formatter takes the string apart rather than parsing it: "2015-03" read as a UTC instant and printed in local time hands back February west of Greenwich. Narrowing the precision keeps what is still true, so a day dropped from "2015-03-10" leaves "2015-03". Widening clears the field. That is the awkward half of the control and it is deliberate: there is nothing to widen a year with, and leaving "2015" behind an empty month box would store a date the screen is not showing. The column was free text with no validation at all, which was tolerable while only a date picker could write it and is not now that three shapes are legal. normalizeBuildDate parses rather than pattern-matches, so "2015-13" and "2015-02-31" are refused instead of stored as something no reader can print. The phone needed changing to avoid destroying this. It parsed buildDate with DateTime.tryParse, which returns null for "2015" - so a half-known date would have shown as a dash, and saving the car from the phone would have written "" back over it. It holds both date fields as the string they arrived as now, prints them at their own precision, and hands back anything it cannot set. Its picker still only makes full dates; a precision control there is a separate job. Separately: an empty cell of the service table had three different looks in one row. The dash under Notes was body-coloured, as though it were content; the one under File was 12px, having borrowed the size of the Download button that would otherwise be there; the one under Changed parts was muted at 14px. They are one constant now, muted at the row's own size, which is what Next date and Next km already did for a missing value. The Download link keeps its own styling - it is an action, not a value. Verified in a browser: a stored "2015-03" loads as month precision in a month picker, month to year narrows to "2015", year to day clears, "19x98abc" typed into the year box sanitises to "1998", saving sends buildDate:"1998" and the Information tab then reads "1998" - while a full first-registration date beside it still reads 06-08-2026. All five empty cells across the three columns now compute to the same size, colour and weight, with the filled ones unchanged. go vet and go test ./... pass with a new test over the three valid shapes and six rejects; flutter analyze is clean and 22 tests pass, one new, covering a half-known date in two date formats and the time zone that could shift it; npm run build is clean. Not verified: First registration still demands a full date. The same argument applies to it and the field is now a reusable component, but it was not asked for and is one line away. The web formatter's month-name paths - the DMY and MDY formats, which spell the month out - are covered only by the phone's mirror of the logic, the web app still having no test runner. A car created through the Toyota import bypasses the new validation; it only ever produces full dates, so nothing invalid gets in that way, but it is not guarded. Both apps need redeploying before any of this is visible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 and Information rows on or off, and drag either list by its handle to reorder it. (The web rearranges by dragging the tab bar itself; on a touch screen that gesture is the tab bar's, so both arrangements are made in the picker instead.) Information cannot be switched off — a page with no tabs left would be a dead end — but it can 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.
- 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 viaopen_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.intlships 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.
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
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.propertiessetskotlin.incremental=false— required because the project lives on driveE:while Gradle/Kotlin caches are onC:(the incremental compiler can't compute cross-root relative paths on Windows).AndroidManifest.xmlsetsandroid: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)
├── 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 a car shows + the key catalogue
├── provider_tab.dart # the connected-service tab (MyToyota)
└── charging_screen.dart settings_screen.dart admin_users_screen.dart