Commit Graph
17 Commits
Author SHA1 Message Date
tajniak81andClaude Opus 5 181f55a849 The phone catches up with the month the web had
Twenty-eight commits landed on the web app and the API since the phone was
last touched, and the phone's own README opens by claiming full feature
parity. It was not a small drift: a whole tab, two whole cards, and the two
settings that decide how a time is read.

The scheduler arrives as the third charging tab. One list of tasks covering
every charger the account owns, where the charger's own cloud schedule is one
window inside one box. A task is a flow — start at 23:00, cap to 10 A at
01:00, stop at 06:30 — on the days and the chargers it names, and naming no
charger means all of them, including the ones imported later. The clock is the
server's, so the tab only writes tasks and reads back how each one last went,
and any step can be fired now to find out whether it will reach the charger
before the night it matters.

The RFID card comes with it: the list the account holds, a card added by its
number or by holding it against the charger's own reader, and the charger's
own list read back from the device. Both halves are written by every add and
remove and they can still come apart, so when they disagree the card says
which list each card is missing from — nothing else on the page would.

The charger settings card the phone never had at all goes in whole rather than
only its new half. Over Modbus that is the four writable registers; over the
cloud it is the charger's whole settings group in sections, drawn from the same
block table the web reads, one write per section because the charger takes a
command whole and a schedule carrying only its switch is a schedule whose times
have just been set to midnight.

The clock and the week become settings. format.dart grows formatTime, the
weekday order and the short names, with "auto" asking intl's own hour pattern
and FIRSTDAYOFWEEK rather than a table here; Settings › Appearance asks both
questions beneath the date. Flutter's own picker renders on the device locale,
which nothing in this app steers, so TimeField types four digits on whichever
clock is in force and keeps the meridiem as its own control — a box reading
13:45 beside a dial saying 01:45 PM is the disagreement the setting exists to
end.

The smaller ones travel too. The control card says which charger its buttons
drive, picture and name, because it follows a serial and not the highlighted
row; its two tiles take the names of the readings they actually hold; and the
limit slider leaves it wherever a settings card now owns that value. The list's
reachability re-asks every thirty seconds while the tab is in front, merged
rather than replaced — "we could not ask" is not an answer, and it certainly is
not "unknown". A settings frame that answers half a minute late is chased at
widening gaps and then given up on. The information card names the fields the
service sent under its own names and groups list records under their own, so
list[0].* stops being read as one alphabetical run. An inherited integration
field shows what it inherited rather than an example. The sign-in fields say
nothing until you type.

One gap stays open, and deliberately. The task form sends the phone's zone only
when Dart reports an IANA name; Android usually answers with an abbreviation
like CEST, which is not a zone, so it sends nothing and the server falls back to
its own clock. A name the server would misread is worse than no name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:11:19 +02:00
tajniak81andClaude Opus 5 641f427db4 The chargers you own, on the phone as well
The web's Charging page grew a real home half while the phone kept a demo one.
There, a charger is a record imported from a connected service; here it was a
hardcoded row called "Home charger", and the only real thing on the tab was a
single OCPP control card. Modbus had been a working transport for a while, and
the phone had no way to give it the address it needs.

The home tab is now the four cards the web shows, about whichever charger is
picked, and the list of the ones you have imported. Control acts on the charger
and offers what the transport actually has — boost on Modbus, clear-limit and
reset on OCPP. Connection asks for a serial or an address depending on which,
and falls back to a text box for a serial the account does not list. Readings
render the Modbus snapshot the way it gets asked about: the per-phase matrix,
what the charger is doing, what it is set to, what it is, and any alarm word.
Information stands without a control mode at all, because what a charger is is
known either way; beside it the service's own view of whether it is reachable,
asked for when the tab is opened rather than on every build.

Rearranging is the one place the two apps differ, for the reason the car's view
picker already differs: the web drags the tab bar and the card headings, and on
a touch screen the bar owns that gesture and a heading is the fold toggle. Both
arrangements are made in a sheet with handles instead, and still saved to
chargerTabOrder / chargerCardOrder on the profile — so an arrangement made in
either app shows up in the other. Which cards are folded stays on the device.

Settings groups its integrations into the same categories the API Server panel
does, says Online as well as Offline, and shows firmware in a charger's line.
Shared strings are copied out of the web's i18n files rather than retyped, per
TRANSLATIONS.md; only the arrange sheet's own three are written here.

The readings and information cards look up some sixty keys by name at render
time, so models_format_test now guards those the way it guards the car's — an
enum value is deliberately left out, since a charger may report a number this
release has never heard of and falling back to it is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 08:27:16 +02:00
tajniak81andClaude Opus 5 a203414ceb Phone App: the garage on the car's own screen
The one place a service badge is worth reading is the car it belongs to, and
that is the one place the app could not be opened: Android Auto runs no Flutter
engine, so a Flutter app is simply absent from the head unit. The same APK now
carries a second face — Car App Library templates the host draws itself, in
Kotlin under android/app/src/main/kotlin/com/drivervault/phoneapp/car/.

Two screens. The garage lists a car per row with its due badge on the second
line, worst first, because the host renders only the first handful of rows and
the car this list exists to mention is the overdue one rather than whichever was
added first. A tap opens what that car has coming: the odometer, the next
service, and the reminders the server holds for it — typed in and auto-derived
from documents and the service schedule alike, in the order it sorted them.

None of it is a second implementation of the app. VaultStore reads the session
the phone signed in with — the active server's base and token — out of
shared_preferences' own store, which both halves share, so a server switched on
the phone is the server the car reads from with nothing to keep in step; only
cc_active_base is new, because an untouched home entry carries no address of its
own, its base being kDefaultApiBase, a compile-time define nothing outside Dart
can see. CarStrings reads the same assets/i18n files by the same dot paths, so a
badge on the head unit is the string format.dart already puts on the phone, in
the language the account chose: of the 32 keys the car screens ask for, 28 are
keys a phone screen already used, and only carApp.* is theirs. CarFormat is
format.dart's twin — same date pattern and number grouping from the account's
settings, same worst-of-date-and-km service badge. A new test reads the Kotlin
for the keys it looks up and fails if any is missing from a language file, since
the analyzer's reach stops at the Dart.

It only reads. 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.

Three things the README now says out loud. The service is declared IOT, the
closest category the library defines for something that is a garage rather than
a map or a media player, which matters to a store submission and not to a
sideload. The app lock does not reach the head unit: the flag is in memory and
the credentials behind it in encrypted storage, neither readable from the car
service, and there is no fingerprint reader in a dashboard to satisfy it with.
And the home charger is not on there — the chargers endpoint relays its plugin's
payload verbatim with no shape to read, and the serial the control card is
driven by is never persisted, so the car would have nothing to name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 18:26:46 +02:00
tajniak81andClaude Opus 5 12ec10a797 Phone App: more than one server, and a session for each
The web app can be pointed at two DriverVault stacks and switch between them in
a click. The phone had one address and one session: reaching a second garage
meant retyping the API base in Server settings and signing in again, losing the
first server's token on the way — the same act, undone, every time you switched
back.

So lib/servers.dart is the web's servers.js ported rather than reinvented, down
to the storage keys: cc_servers holds the list, cc_active_server the one being
read, cc_session_<id> the token minted by that server and no other. The two apps
describe the same thing the same way, and the upgrade path falls out of it —
cc_token, cc_user and cc_server_url are read once at boot and folded onto the
home entry, so the build carrying this signs nobody out.

Home is the address the build ships with (kDefaultApiBase, still overridable per
device from the login screen) and cannot be removed: it is what a dropped session
falls back to. Any other server is added by address, with /api appended if the
path is left off, because a server a phone can reach is internet-facing already.

The part worth reading twice is which session a rejection ends. ApiClient no
longer holds a base or a token — it pins the active server's id, base and token
at the moment a request goes out, so a 401 arriving after a switch clears the
session of the server that actually refused it rather than whichever one is
active by then. The fallback is the web's: a remote server timing out drops its
own token, the app returns to home while home is still signed in, and only when
nothing is left to fall back to does the login screen come back. Log out still
clears every server at once, since leaving the app means leaving all of them.

Switching rebuilds the shell, keyed on the active id, because record ids belong
to the server that issued them — a garage, a charging page and a settings panel
still holding the other server's rows would each have to be told to forget them
separately. The appearance prefs come across with the profile of whoever owns
the account on the server now active.

Where the picker lives is the one place the phone cannot copy the web. There is
no app rail here, so it became the first button in the Garage header, beside the
theme toggle and log out, which is that same cluster. It names the active server
once there is a choice and goes straight to adding the second when there isn't;
the eyebrow reads GARAGE · Work for the reason the rail names it — two garages
otherwise look identical. The login screen gets its own way in, because a remote
session can expire and land you there with that server still active, and a
picker reachable only from inside the app would leave nowhere to go.

One judgment call inside the sheet: saving a connected server at a new address
saves and stops, rather than falling through to the sign-in it now needs. The
token was minted by the PocketBase behind the old address and is dropped with
it, but the credentials to replace it were never asked for, so treating the save
as a login would report an empty password as the error.

The strings are copied out of Web App/web/src/i18n/ like the rest of the shared
wording. Two are not the web's: home reads "the address this app ships with"
rather than "served with this app", since the phone has no origin to be served
from, and sameOrigin has no meaning here at all and was dropped.

Biometric sign-in stays global. It was never per-server and replays its stored
credentials against whichever server is active; making it per-server is a change
of its own, and the login screen now names the server it is about to sign into.

Nothing changes on the API Server. On Android there is no origin to allow, so
the CORS list the web app has to satisfy to reach a second server doesn't enter
into it.

Verified: flutter analyze is clean and flutter test passes, 35 tests to 46. The
new ones cover the registry — a bare origin gaining its /api, a fresh install
knowing one unnamed server on the built-in address, the legacy keys landing on
home and being cleared, two servers holding their tokens apart, a rename keeping
a session where a move drops it, removing the active server falling back to a
home that is still signed in, home refusing to be removed, and a restart reading
the list, the active id and every session back.

Not verified: none of it has been run. There is no device or emulator on this
machine and no API Server to answer, so the picker, the add sheet, a real
connect, the 401 fallback and the shell rebuild on a switch exist only as code
the analyzer is happy with — the tests reach the registry, not a screen. No APK
was built. The legacy migration was exercised against mocked SharedPreferences,
which is not a phone that had the old build on it: that is the first thing to
check on a device, since the failure mode is a silent sign-out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:47:01 +02:00
tajniak81andClaude Opus 5 35e6c511b7 Changed parts: the list is the car's, not the app's
The Changed parts section offered all three parts to every car. An EV changes no
oil, and a checkbox nobody will ever tick is one more thing to read past on every
service — so which parts a car records now belongs to the car, the same way its
tabs, its Information rows and its Service history columns already do.

It works the way those three do because a fourth mechanism for the same idea
would be a fourth to keep in step: hidden_service_parts on the car, validated by
the endpoint that already does this, stored as the hidden set so a part added in
a later release is on by default, and needing write access because the choice
belongs to the car and everyone it is shared with sees it.

There is no order beside it, which is the one place this departs from the other
three. Those arrange things whose position means something — a tab bar reads left
to right, a table's columns are read across. The parts are a checkbox list inside
a single column, and moving Cabin air filter above Oil says nothing. Adding one
later is the same shape as the others if that turns out to be wrong.

A part switched off leaves the form and the history together — the chips on the
phone's cards, the web column's summary and the panel it opens. "I don't record
this" means it stops taking up room, not that it takes up room saying nothing,
which is the rule a hidden column already follows. That is the judgment call
here: a car with five years of oil changes hides them all by switching the part
off. Nothing is written to the records, so switching it back on brings every one
of those chips back, which is what makes the call safe to reverse.

The part that would have been a silent data bug: the API rewrites all three
booleans from the body of a service update, so a form that simply stopped
sending a hidden part would set it false on the next edit of any old record.
Both forms therefore keep every part in their state and submit every one — only
the checkboxes are filtered. The mirror of that is a *new* record, where a hidden
part starts false rather than at its `initial`, since ticking a box nobody was
shown is not a default, it's a guess. Oil is the only part with initial: true, so
that case is live the moment anyone hides it.

Verified: go vet and go test ./... pass, with a new test covering that every part
is hideable (unlike the tabs and the columns — a service that changed nothing is
a real service), that the "parts" column key is refused as a part key and a part
key as a column key, and that no part is also a column. flutter analyze is clean
and flutter test passes 32 to 35, the new ones covering visibleParts, that a
hidden part's chips go while its stored boolean stays, and the picker's fourth
section. npm run build is clean.

Both apps were driven against throwaway stub APIs. Web: the picker saved
{"hiddenServiceParts":["oil"]}, the table's parts cell went from "Oil & Oil
filter +2" to "Engine air filter, Cabin air filter", the record whose only part
was oil went to an empty cell, the panel dropped to two rows, the add form
offered two unticked boxes where oil's initial: true would have ticked one, and
editing the three-part record sent changedOil:true back with a box that was never
on screen. Phone: the same car rendered chips "Engine air, Cabin air", "Changed
parts —" for the oil-only record, and an add sheet with exactly two unticked
boxes.

Not verified: no automated test guards the web behaviour — the web app still has
no test runner, so the above was read out of the live DOM and the outgoing
request bodies by hand. The phone's picker was checked by widget test and by
rendering, but its Save was not driven end to end. Neither app was run against
the real API Server: bootstrap appends the new field on the next start, and until
that start a client sending hiddenServiceParts takes a 400 — they deploy together
from this repo, but the server must go first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:30:08 +02:00
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
tajniak81andClaude Opus 5 dc6febf815 Phone App: finish the admin screen; translate the last English strings
Three loose ends from the last two commits, each of which was named as
deliberately-not-done and none of which is worth carrying further.

The phone's create-user sheet had no organization picker. The endpoint has
taken an `organization` since orgs existed and the Web App has offered the
choice all along, so a superadmin on the phone could only ever create
accounts in their own org - a silent restriction rather than a stated one.
The sheet now loads the orgs and offers them to a superadmin, with the same
blank "no organization" option and the same hint as the web. An admin still
gets no picker, because the server forces its own org on their members and a
picker that cannot change the outcome is a lie. The listing is manager-only
and can fail, in which case the picker offers only "no organization" rather
than blocking the form.

A locked role picker or delete action was greyed out with no reason given.
The web has explained itself in a title attribute since those guards existed,
and the sentences - admin.cantChangeOwnRole and the rest - have been sitting
translated in the phone's own language files since the screen was translated.
Hover has no touch equivalent, so the two controls take different routes: a
long-press on the role picker shows the reason as a tooltip, and the overflow
menu carries it under the action, because a disabled menu item cannot be
long-pressed and silently greying it out is the thing being fixed.

settings.integrations.* and charging.control.* were English-only in *both*
apps - 70 keys, identical text, identical key sets - so they are translated
once and land in all four language files. OCPP and CSMS are protocol names
and stay; product names (Toyota Connected, MyToyota, Anker Solix, Lexus) stay;
everything else follows the wording already in each language's file.

The Web App's files are edited as text rather than round-tripped through a
JSON dump, because they keep a blank line before every nested block and a
dump flattens it - a 900-line translation file is hard enough to read
without losing its paragraphs. Both diffs are purely additive as a result.

Both apps now have every key in all three languages: 738 in the web, and the
phone reports zero fallbacks. A new test locks that in - every key en.json
carries must exist in pl.json and da.json - and it was checked by deleting a
key and watching it fail, because a guard that cannot fire is not a guard.

Verified by flutter analyze (clean), flutter test - 21 pass, 1 of them new -
flutter build apk --debug, and npm run build for the Web App. The key checker
reports 575 static t() keys in the phone and 738 in the web resolving with no
fallbacks in either language.

Not verified: still nothing run against a live API Server or on a device. In
particular the organization picker's happy path - a superadmin creating an
account into a chosen org - has not been exercised end to end; it is the one
piece here that touches the API rather than only the language files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:13:37 +02:00
tajniak81andClaude Opus 5 4372b870ac Phone App: take the admin users screen off hardcoded English
The last screen the phone rendered in English regardless of the language
picker. Its strings are the web AdminUsers.vue's, which have been translated
since b6bb6b1, so the admin.* subtree is copied across the same way the rest
were and Polish and Danish arrive complete.

Two strings are the phone's own, and only because the two UIs confirm
differently: the web deletes behind a browser confirm(), which supplies its
own title, and reports a password reset by closing the modal. The phone has a
dialog title and a snackbar to fill, so admin.deleteTitle and
admin.passwordUpdated are new.

The role picker printed its values raw - Text(r) over "user"/"admin"/
"superadmin" - which happened to read as English because the API's enum is
English. It goes through admin.roles.* now, like the web's, and the new
lookup is covered by the catalogue test alongside the other dynamic ones.

The Web App needed nothing: AdminUsers.vue was already fully translated,
including the role options and the tooltips explaining why a locked row
cannot be edited. Checked rather than assumed - it has 31 t() calls and no
bare text nodes in the template.

Two differences from the web turned up while reading them side by side, both
feature gaps rather than translation ones, and both left alone here: the
phone's create-user sheet has no organization picker for a superadmin (the
server puts the account in the creator's org), and the phone disables a
locked role dropdown or delete action without saying why, where the web
explains it in a title attribute. The strings for that explanation are now
sitting in the phone's language files, so it is a small change if wanted.

Verified by flutter analyze (clean), flutter test - 20 pass, 1 of them new -
and flutter build apk --debug. The key checker reports all 568 static t()
keys resolving in en.json.

Not verified: still nothing run against a live API Server or on a device.

TRANSLATIONS.md and the Phone App README both listed this screen as
outstanding; the only gap either records now is settings.integrations.* /
charging.control.*, which is the web app's too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:58:00 +02:00
tajniak81andClaude Opus 5 a2d9efec7e Phone App: take the car screen off hardcoded English
The previous commit left the car screen half translated: its tab labels went
through t(), and everything underneath them did not. A Polish user opening a
car got translated tabs over English tiles, English forms and English
dialogs, which is worse than either extreme because it reads as a bug rather
than as a missing translation.

So the whole screen and everything it opens now reads from the language
files: the record tiles, the share and delete-car dialogs, the service and
part sheets it hosts, record_form_sheets.dart, car_form_sheet.dart, and the
attachment field whose buttons surface inside all of them.

Almost none of these strings are new. The Web App has said all of this in
three languages since b6bb6b1, so forms.*, enums.*, attachment.* and errors.*
are copied out of its language files the same way car.* was, and Polish and
Danish arrive complete. What is written here is only what the phone alone
needs, and the categories are worth naming because they are the reason the
two apps' files are not identical: tooltips, because the web labels its
buttons; the tiles' running prose, because the web lays the same data out as
table columns; client-side validation, because the web leans on the browser's
`required`; and the snackbars.

Three things changed shape rather than just wording.

The per-record delete prompts were one template with a noun slotted in -
"Delete this $what?" - which does not survive translation into a language
that inflects the noun. Each collection now names its own confirmation
string, which is what the web already had.

The delete-car dialog counted with a hand-rolled `"$n $noun${n == 1 ? '' :
's'}"`. Polish has three plural forms, so that could not be translated at
all; it now goes through the CLDR plurals in car.delete.*. It also only ever
named service records and parts, while the cascade takes maintenance, fuel,
charges and documents too - the translated body names all six, so it is now
passed the whole data set rather than two counts.

The enum labels (fuel types, maintenance type/status, document and reminder
types) were four const maps duplicated between the tiles and the pickers.
They are one lookup against enums.* now, with an unknown value falling back
to the raw key rather than a blank - the server owns that enum, and a value
added there should stay legible in an app that has not caught up.

Found and fixed while testing: the view picker rendered the literal string
"car.tabs.provider" as a row label on an unlinked car. That key does not
exist by design - a linked car's tab is named after the service, an unlinked
one falls back to car.tabs.connected - and the picker was the one caller that
did not know it.

Verified by flutter analyze (clean), flutter test - 19 pass, 7 of them new -
and flutter build apk --debug. The new tests cover what the analyzer cannot
see: the lookups built from a key at render time (car.tabs.$key,
enums.fuelType.$v, the delete dialog's plural counts, the connected service's
readings) are checked to have a real label in all three languages, so a
catalogue entry with no translation fails a test instead of reaching a screen
as a raw key path. That is the check that caught the bug above. A one-off
script also confirmed all 550 static t() keys resolve in en.json.

Not verified: still nothing run against a live API Server or on a device.

Known gaps, deliberately left: admin_users_screen.dart is still English, and
settings.integrations.* / charging.control.* exist in en.json only. The
second one is not the phone's alone - the Web App has exactly the same gap,
so translating that OCPP and connector vocabulary belongs to both apps in one
pass rather than letting the phone run ahead of the app the strings are
copied from. Both are now recorded in TRANSLATIONS.md, which had claimed the
car screen as untranslated and the web app as complete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:49:48 +02:00
tajniak81andClaude Opus 5 e249c2f4d8 Phone App: connected service, charging cost, a tab picker and data export
Both READMEs claimed web parity with data export/import as the only
omission. That was three gaps out of date: the car screen had no
connected-service tab, no per-car charging-cost tab, and no way to say what
a car's page shows - all three of which the web has had since the car view
became a property of the car rather than of the browser.

The tab bar was the thing blocking the rest. It was a fixed list of eight
Tab(text: "Information") literals, so it could neither grow a tab nor read
an arrangement, and it sat outside the translation system that the rest of
the app has used since b6bb6b1. It now builds from the car's own tabOrder
and hiddenTabs, and labels come from car.tabs.* like the web's.

Rather than retype four subtrees of strings in three languages, the shared
ones - car.*, settings.advanced, forms.charging, forms.import and the
common keys the phone was missing - are copied out of the Web App's own
language files, with the phone's existing wording winning every collision.
Polish and Danish therefore arrive complete and cannot drift between the
two apps. Only five strings are genuinely phone-only: the reorder hint, the
saved-file message, the open action and two validation lines.

The connected-service tab mirrors ProviderPanel: headline readings, the
offer to take a provider odometer that is ahead of the stored one, the
vehicle record, and one collapsible card per capability, rendered from the
server's flattened key/value pairs so a provider adding a field surfaces it
without touching this app. Two deliberate differences. The raw-payload
disclosure is dropped - Toyota's eight sections are megabytes of JSON on a
phone screen, and the flattened fields carry the same content. And the
readings cannot be dragged here, though a stored metricOrder is still
honoured, so an arrangement made on the web carries over.

The view picker takes the same line on gestures. The web rearranges by
dragging the tab bar itself and the Information rows themselves; on a touch
screen that gesture belongs to the tab bar, so both arrangements are made
in the picker with a handle instead, and hiddenTabs, hiddenFields, tabOrder
and fieldOrder all save in one PUT. The key catalogues live in
car_view_sheet.dart and mirror hideableCarTabs / arrangeableCarTabs /
hideableCarFields in cars.go, because the server rejects anything else.
arrangeKeys applies a partial stored order the way the API documents it: an
unknown key is dropped and an unnamed one follows the arranged ones, which
is what puts a tab added in a later release at the end of somebody's page
rather than the middle of it.

Charging cost is the electric twin of Fuel and is built as one - the same
stats panel, tile and form shape, measured between full charges. It is the
per-car cost log, not the Charging section in the bottom bar, which remains
the charger network and OCPP control.

Export and import needed a phone answer to two browser affordances. The
export is written to the app's documents directory under the filename the
server's Content-Disposition names, and offered to whatever opens JSON via
open_filex - the same route attachments already take. The import goes
through the system file picker, validates the file locally, and confirms
with the number of cars the file actually holds, because the server always
creates new records and never merges.

The one field worth calling out on the client: _carPayload still leaves the
provider link and the view arrangement out, matching carPayload in
records.go, so saving the car form cannot silently unlink a car or undo an
arrangement.

Known gap, deliberately not closed here: the older sheets in
record_form_sheets.dart and most of car_detail_screen.dart still carry
hardcoded English. Everything added here and every tab label goes through
t(), but translating the rest of the car screen is its own change and would
have buried this one.

Verified by flutter analyze (clean), flutter test - 13 pass, 6 of them new,
covering arrangeKeys against partial, unknown and duplicate keys, the
charging models keeping uncomputed figures null rather than a plausible
zero, the new Car fields, and ProviderSnapshot parsing an unreachable
provider as an answer rather than a failure - and flutter build apk
--debug, which succeeds. The Kotlin Gradle plugin warnings in that build
are pre-existing.

Not verified: nothing was run against a live API Server or on a device, so
the new screens have not been driven end to end - only compiled, analyzed
and unit-tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:33:58 +02:00
tajniak81andClaude Opus 5 cd16d4383f Orgs: let any user create an organization and become its admin
Organization writes were superadmin-only, so standing up a tenant needed
an out-of-band superadmin. Creating one is now self-service, and an admin
manages the org they belong to.

- POST /api/orgs is open to any authenticated user. A creator who isn't a
  superadmin must have no organization yet (a single-valued membership
  relation means a second one would abandon the first), and is promoted to
  the new org's admin and first member in the same request. If that
  promotion fails the org is rolled back, so it is never left stranded
  with nobody able to administer it. Superadmins still create tenants
  without joining them.
- PATCH/DELETE are manager-gated and scope an admin to their own org. An
  admin deletes theirs only as its sole member: they are detached and
  demoted to a plain user before the record goes, so the org is empty when
  it is removed. Other members still block deletion with a 409.
- /api/me now carries organization + organizationName, which the clients
  need to tell "no org yet" from "org you administer".

The panel, Web App (new OrgManager.vue in Settings) and Phone App (new
_OrganizationSection) all mirror the server's gates rather than
re-deciding them. The Phone App cached its role at login and gates the
Users tab on it, so AuthService.adoptRole refreshes that from the profile
instead of making a freshly promoted admin sign in again.

Covered by orgs_test.go, which drives the real handler + middleware chain
against a stand-in PocketBase: promotion, the already-a-member refusal,
superadmin staying unattached, the rollback, own-org scoping, the
detach-and-demote, and the blocking-member 409.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:54:02 +02:00
tajniak81andClaude Opus 4.8 a0eb5e4e9d Docs: refresh every README against the current code
Verified each documented command, path, port and env var against what the
code actually does, and corrected the drift.

Phone App. Was still titled Car Control. The navigation description was
also stale: the app moved to a RootShell bottom nav (Garage, Charging,
Settings, and Users for admins), so the Settings gear and admin action
the dashboard bullet described no longer exist. Adds the Charging screen,
noting its public tab is placeholder data and only the Home tab's OCPP
control is real, and rebuilds the lib/ tree, which had lost i18n.dart,
theme.dart, widgets/ and three screens.

Web App. Node 18+ was wrong. The installed Vite is 8.1.2, whose engines
field is ^20.19.0 || >=22.12.0 - Node 18 is EOL and cannot build this.

API Server. The config table gained OCPP_REQUIRE_TLS, OCPP_PUBLIC_URL,
PB_BOOTSTRAP and DRIVERVAULT_SUPERADMIN_*, plus a note that PLUGINS_FILE
and the panel-written .env resolve against the working directory (a
volume, in Docker).

Plugins. Per-tenant credentials sat under "not yet implemented", but
/api/integrations/* has done exactly that for both built-ins for a while.
Narrowed the roadmap item to the genuinely missing generic version.

New Docker/README.md and Docker AIO/README.md: the root README's
component table linked those directories as documentation but neither had
any. The root README now points at them.

All 8 markdown files pass a relative-link check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:46:21 +02:00
tajniak81andClaude Opus 4.8 8f876db19c Phone App: rename Dart package to drivervault_phone
Follows the Android package rename: updates the pubspec name, the
package: imports in the tests, the web title/manifest strings, and the
project name in the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:29:46 +02:00
tajniak81andClaude Opus 4.8 fee4fda974 Phone App: rename Android package to com.drivervault.phoneapp
Updates the Gradle namespace/applicationId, moves MainActivity.kt to the
matching source directory, and refreshes the package id in the README.

The Dart package name in pubspec.yaml is unchanged. Since the application
id changed, existing installs will not upgrade in place and their local
secure-storage/prefs data does not carry over.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:03:16 +02:00
tajniak81andClaude Opus 4.8 1e76c2b7f9 Refresh docs and fix Docker builds for current layout
READMEs: correct the auth model (PocketBase token relay, not JWT/sessions),
document the full feature set (technical checks, fuel, maintenance, documents,
reminders, attachments, integrations, OCPP charging control), the shipping
built-in connectors (toyota, anker-solix), and the current endpoint surface.

Docker: build against the current repo layout — Go 1.26, cmd/server entry
point, Web App source under web/. Add the missing Web App Dockerfile (Go BFF)
and .dockerignore, drop the obsolete AUTH_SECRET, modernise CORS var naming,
and standardise on drivervault-* naming.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 11:05:46 +02:00
tajniak81andClaude Opus 4.8 ee28b522c7 Bring the Phone App up to parity with the web app
Four rounds of web-app features never reached the phone: fuel, maintenance,
document and reminder tracking; attachments; the currency setting and the
locale split; and technical check history. The README claimed full parity
throughout, so the gap was invisible. Catch the phone up, mirroring the web
components field for field.

Car detail grows the web app's tabs, in its order: technical checks,
maintenance, fuel (with the summary panel), documents and reminders, beside
the existing service and parts lists. The derived figures are the server's
and are rendered as "—" wherever it sent null — a window with a missed fill
has no consumption, and a plausible-looking 0.0 there would be a lie.

Attachments hang off service records, technical checks, workshop visits,
refills, documents and parts on identical terms, so one field and one apply
helper cover all six rather than being copied per form. As on the web, the
form only collects intent: the file endpoints address a record that must
already exist, so a create-with-file is two calls, and a failure on the
second reports as an attachment error because the metadata is committed.

Two bugs fixed on the way:

- _carPayload omitted technicalCheckIntervalDays. The API rewrites every
  column from the body, so any car edit — including the one-tap odometer
  update — silently zeroed the car's inspection interval.
- main() never called initializeDateFormatting, so month names ignored the
  chosen language that the new Language picker exists to set.

Luxembourgish and Romansh are deliberately left off the language list: intl
ships no symbols for them and throws rather than falling back, which would
take out every date on screen. The browser has full ICU data and has no such
limit, so the web app can offer them. The server only validates a locale's
shape, so an unrenderable tag can still arrive from the web; format.dart
resolves through a supported-language check and falls back to en-US.

Labels for the language/region/currency lists are hand-kept because Dart has
no Intl.DisplayNames. The lists mirror validCurrencies in me.go.

file_picker is pinned to ^10: v8 compiles against android-34, which no longer
builds against the other plugins' compileSdk requirement of 36.

Adds the project's first test, covering the parts that fail silently rather
than loudly — null derived fields, the badge wording, and the locale guard.

The phone was not authorized over ADB, so the UI was not exercised on a
device: this is analyzer-, test- and build-clean, and every JSON field name
and route was cross-checked against models.go and server.go.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:15:01 +02:00
tajniak81 ba3f227361 Initial commit 2026-07-06 08:50:52 +02:00