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>
This commit is contained in:
tajniak81
2026-09-02 08:27:16 +02:00
co-authored by Claude Opus 5
parent 3c64d6e84c
commit 641f427db4
11 changed files with 2961 additions and 339 deletions
+14 -3
View File
@@ -95,9 +95,19 @@ navigation bar** — Garage, Charging, Settings, and Users for admins — in an
- **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.
**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 four cards about the one you picked: **control** (start/stop, a current
limit, boost or reset depending on the transport), **connection** (which
charger, and either its serial or its address on your network), **readings**
(everything the charger reports over Modbus — per-phase power, its settings,
what it is, and any alarm), and **information** (everything the record holds,
with the service's live view of whether it is reachable). Control needs a mode
picked under Settings → Integrations — Modbus TCP over the local network, or
Own/Proxy CSMS over OCPP — but the information card stands 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.
- **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,
@@ -319,6 +329,7 @@ lib/
├── 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
```
+140 -15
View File
@@ -114,6 +114,11 @@
"public": "Offentlige ladere",
"home": "Hjemmeladere"
},
"arrange": {
"title": "Indret opladningssiden",
"tabs": "Faneblade",
"cards": "Kort"
},
"liveMap": "Live-kort",
"youAreHere": "Du er her",
"session": {
@@ -126,19 +131,6 @@
"idle": "Oplader ikke",
"idleHint": "Tilslut ved en station for at starte en session."
},
"stations": {
"heading": "I nærheden",
"homeHeading": "Dine ladere",
"noControlHint": "Aktivér Own eller Proxy CSMS under Indstillinger → Integrationer for at styre din hjemmelader her.",
"count": {
"one": "{n} station",
"other": "{n} stationer"
},
"free": "{avail} af {total} ledige",
"full": "Optaget",
"offPeak": "uden for spidsbelastning",
"homeCharger": "Hjemmelader"
},
"control": {
"title": "Laderstyring",
"connected": "Forbundet",
@@ -155,7 +147,124 @@
"reset": "Genstart laderen",
"resetConfirm": "Genstart denne lader nu? En igangværende opladning bliver afbrudt. Indtast din adgangskode igen for at bekræfte.",
"resetPassword": "Din kontoadgangskode",
"connectHint": "Indtast laderens serienummer og opdater. Laderen skal være forbundet til DriverVaults OCPP-backend (opsættes under Indstillinger → Integrationer)."
"connectHint": "Indtast laderens serienummer og opdater. Laderen skal være forbundet til DriverVaults OCPP-backend (opsættes under Indstillinger → Integrationer).",
"connectionTitle": "Laderforbindelse",
"enterSerial": "Indtast et serienummer i stedet",
"pickSerial": "Vælg blandt dine ladere",
"address": "Laderens adresse på dette netværk",
"addressPlaceholder": "IP-adresse (f.eks. 192.168.1.40)",
"addressPort": "Port",
"addressHint": "Slå Modbus TCP til i Anker-appen under Indstillinger → Integrationer; appen viser derefter denne adresse.",
"saveAddress": "Gem adresse",
"forgetAddress": "Glem",
"forgetAddressConfirm": "Glem laderens lokale adresse? Styring over det lokale netværk stopper, indtil du indtaster den igen.",
"boost": "Boost denne session",
"modbusHint": "Indtast laderens adresse på dette netværk og gem den. Laderen skal være tændt og på samme netværk som DriverVault."
},
"modbus": {
"title": "Laderaflæsninger",
"phases": "Pr. fase",
"live": "Live data",
"settings": "Indstillinger",
"device": "Enhed",
"alarms": "Alarmer",
"phase": "Fase",
"voltage": "Spænding",
"current": "Strøm",
"activePower": "Aktiv",
"reactivePower": "Reaktiv",
"apparentPower": "Tilsyneladende",
"lineToLine": "Mellem faser",
"power": "Samlet effekt",
"sessionDuration": "Sessionens længde",
"cpSignal": "Control pilot",
"cpVoltage": "Pilotspænding",
"phaseMode": "Kører på",
"relayTemps": "Relætemperaturer",
"pwm": "PWM aktiv",
"maxCurrentSet": "Strømgrænse",
"timeout": "Styringstimeout",
"phaseSetting": "Faseindstilling",
"boostSet": "Boost anmodet",
"lastCommand": "Seneste kommando",
"chargingMode": "Opladningstilstand",
"loadBalancing": "Lastbalancering",
"solarBalancing": "Solbalancering",
"ledBrightness": "LED-lysstyrke",
"model": "Model",
"serial": "Serienummer",
"firmware": "Firmware",
"hardware": "Hardware",
"productNumber": "Produktnummer",
"ratedPower": "Nominel effekt",
"currentRange": "Strømområde",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"alarmWord": "Ord {n}",
"alarmsHint": "Laderen melder en alarm. Anker offentliggør ikke, hvad de enkelte bit betyder, så ordene vises, som de kommer.",
"phaseMode1": "Enfaset",
"phaseMode3": "Trefaset",
"phaseSet0": "Automatisk",
"phaseSet1": "Fast enfaset",
"phaseSet2": "Fast trefaset",
"chargingMode0": "Sol + net",
"chargingMode1": "Kun sol",
"command0": "Ingen",
"command1": "Start",
"command2": "Stop",
"ocpp0": "Ikke forbundet",
"ocpp1": "Forbinder",
"ocpp2": "Forbundet",
"mqtt0": "Ikke forbundet",
"mqtt1": "Forbundet"
},
"info": {
"title": "Laderoplysninger",
"empty": "Intet importeret endnu — en lader, du importerer, viser alt, den melder, her.",
"vendor": "Producent",
"model": "Model",
"firmware": "Firmware",
"serial": "Serienummer",
"site": "Placering",
"siteId": "Placerings-id",
"sources": "Registreret som",
"sourceNames": {
"standalone": "Selvstændig",
"site": "I et system",
"bound": "Knyttet til kontoen"
},
"power": "Effekt",
"connector": "Stik",
"providerId": "Id hos tjenesten",
"state": "Tilstand",
"chargePower": "Ladeeffekt",
"ocpp": "OCPP-status",
"added": "Tilføjet",
"online": "Online",
"offline": "Offline",
"refresh": "Opdater"
},
"home": {
"count": {
"one": "{n} lader",
"other": "{n} ladere"
},
"import": "Importer fra tjeneste",
"remove": "Fjern",
"removeConfirm": "Fjern {name} fra dine ladere? Selve laderen røres ikke — importerer du den igen, er den tilbage.",
"empty": "Ingen ladere endnu. Importer den fra din Anker- eller Greencell-konto i stedet for at taste serienummeret.",
"connectFirst": "Tilslut en ladertjeneste under Indstillinger Integrationer for at importere."
},
"stations": {
"heading": "I nærheden",
"homeHeading": "Dine ladere",
"noControlHint": "Vælg en styringstilstand under Indstillinger → Integrationer for at styre din hjemmelader her: Modbus TCP på det lokale netværk eller Own/Proxy CSMS via OCPP.",
"count": {
"one": "{n} station",
"other": "{n} stationer"
},
"free": "{avail} af {total} ledige",
"full": "Optaget"
}
},
"settings": {
@@ -384,7 +493,12 @@
"toyota": "Toyota Connected (MyToyota)",
"brandToyota": "Toyota",
"brandLexus": "Lexus",
"ankerSolix": "Anker Solix (V1 Smart EV Charger)"
"ankerSolix": "Anker Solix (V1 Smart EV Charger)",
"categories": {
"vehicles": "Bilproducenter",
"chargers": "EV-ladere"
},
"chargerOnline": "Online"
}
},
"status": {
@@ -928,6 +1042,17 @@
"partName": "Delnavn er påkrævet.",
"description": "Beskriv, hvad der blev udført.",
"title": "Titel er påkrævet."
},
"importCharger": {
"title": "Importer en lader",
"subtitle": "Tilføj en lader fra en tjeneste, du har tilsluttet. Navn, serienummer og model følger med.",
"noProviders": "Ingen ladertjeneste er tilsluttet. Tilføj en under Indstillinger Integrationer.",
"selectCharger": "Lader",
"loadingChargers": "Indlæser dine ladere…",
"noChargers": "Ingen ladere på denne konto.",
"alreadyImported": "Allerede importeret",
"name": "Ladernavn",
"submit": "Importer lader"
}
},
"enums": {
+131 -6
View File
@@ -114,6 +114,11 @@
"public": "Public chargers",
"home": "Home chargers"
},
"arrange": {
"title": "Arrange the charging page",
"tabs": "Tabs",
"cards": "Cards"
},
"liveMap": "Live map",
"youAreHere": "You are here",
"session": {
@@ -142,20 +147,124 @@
"reset": "Reset charger",
"resetConfirm": "Reboot this charger now? Any active charging session will be interrupted. Re-enter your password to confirm.",
"resetPassword": "Your account password",
"connectHint": "Enter your charger's serial and refresh. The charger must be connected to DriverVault's OCPP backend (set up in Settings → Integrations)."
"connectHint": "Enter your charger's serial and refresh. The charger must be connected to DriverVault's OCPP backend (set up in Settings → Integrations).",
"connectionTitle": "Charger connection",
"enterSerial": "Enter a serial instead",
"pickSerial": "Choose from your chargers",
"address": "Charger address on this network",
"addressPlaceholder": "IP address (e.g. 192.168.1.40)",
"addressPort": "Port",
"addressHint": "Enable Modbus TCP in the Anker app under Settings → Integrations; the app then shows this address.",
"saveAddress": "Save address",
"forgetAddress": "Forget",
"forgetAddressConfirm": "Forget this charger's local address? Control over the local network stops until you enter it again.",
"boost": "Boost this session",
"modbusHint": "Enter the charger's address on this network and save it. The charger must be powered on and on the same network as DriverVault."
},
"modbus": {
"title": "Charger readings",
"phases": "Per phase",
"live": "Live data",
"settings": "Settings",
"device": "Device",
"alarms": "Alarms",
"phase": "Phase",
"voltage": "Voltage",
"current": "Current",
"activePower": "Active",
"reactivePower": "Reactive",
"apparentPower": "Apparent",
"lineToLine": "Line to line",
"power": "Total power",
"sessionDuration": "Session length",
"cpSignal": "Control pilot",
"cpVoltage": "Pilot voltage",
"phaseMode": "Running on",
"relayTemps": "Relay temperatures",
"pwm": "PWM active",
"maxCurrentSet": "Current limit",
"timeout": "Control timeout",
"phaseSetting": "Phase setting",
"boostSet": "Boost requested",
"lastCommand": "Last command",
"chargingMode": "Charging mode",
"loadBalancing": "Load balancing",
"solarBalancing": "Solar balancing",
"ledBrightness": "LED brightness",
"model": "Model",
"serial": "Serial",
"firmware": "Firmware",
"hardware": "Hardware",
"productNumber": "Product number",
"ratedPower": "Rated power",
"currentRange": "Current range",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"alarmWord": "Word {n}",
"alarmsHint": "The charger reports an alarm. Anker does not publish what the individual bits mean, so the words are shown as they arrive.",
"phaseMode1": "Single-phase",
"phaseMode3": "Three-phase",
"phaseSet0": "Automatic",
"phaseSet1": "Fixed single-phase",
"phaseSet2": "Fixed three-phase",
"chargingMode0": "Solar + grid",
"chargingMode1": "Solar only",
"command0": "None",
"command1": "Start",
"command2": "Stop",
"ocpp0": "Not connected",
"ocpp1": "Connecting",
"ocpp2": "Connected",
"mqtt0": "Not connected",
"mqtt1": "Connected"
},
"info": {
"title": "Charger information",
"empty": "Nothing imported yet — a charger you import shows everything it reports here.",
"vendor": "Vendor",
"model": "Model",
"firmware": "Firmware",
"serial": "Serial",
"site": "Site",
"siteId": "Site id",
"sources": "Registered as",
"sourceNames": {
"standalone": "Standalone",
"site": "In a system",
"bound": "Bound to the account"
},
"power": "Power",
"connector": "Connector",
"providerId": "Service id",
"state": "State",
"chargePower": "Charge power",
"ocpp": "OCPP status",
"added": "Added",
"online": "Online",
"offline": "Offline",
"refresh": "Refresh"
},
"home": {
"count": {
"one": "{n} charger",
"other": "{n} chargers"
},
"import": "Import from service",
"remove": "Remove",
"removeConfirm": "Remove {name} from your chargers? The charger itself is untouched — importing it again brings it back.",
"empty": "No chargers yet. Import the one on your Anker or Greencell account instead of typing its serial.",
"connectFirst": "Connect a charger service in Settings Integrations to import one."
},
"stations": {
"heading": "Nearby",
"homeHeading": "Your chargers",
"noControlHint": "Turn on Own or Proxy CSMS in Settings → Integrations to control your home charger here.",
"noControlHint": "Pick a control mode in Settings → Integrations to control your home charger here: Modbus TCP over the local network, or Own/Proxy CSMS over OCPP.",
"count": {
"one": "{n} station",
"other": "{n} stations"
},
"free": "{avail} of {total} free",
"full": "Full",
"offPeak": "off-peak",
"homeCharger": "Home charger"
"full": "Full"
}
},
"settings": {
@@ -260,7 +369,12 @@
"greencellTimeout": "Listen window (seconds)",
"greencellTimeoutHint": "How long to wait for the charger to publish. Greencell allows a device up to 30 s to answer; raise this if nothing is found.",
"greencellCommandTopic": "QUERY command topic",
"greencellCommandTopicHint": "Optional. Greencell documents a QUERY command that makes the charger publish at once, but not the topic it listens on. If you find yours, put it here and reads stop waiting for the charger's own cadence. Use {sn} for the serial."
"greencellCommandTopicHint": "Optional. Greencell documents a QUERY command that makes the charger publish at once, but not the topic it listens on. If you find yours, put it here and reads stop waiting for the charger's own cadence. Use {sn} for the serial.",
"categories": {
"vehicles": "Car manufacturers",
"chargers": "EV chargers"
},
"chargerOnline": "Online"
},
"account": {
"title": "Account",
@@ -928,6 +1042,17 @@
"partName": "Part name is required.",
"description": "Describe what was done.",
"title": "Title is required."
},
"importCharger": {
"title": "Import a charger",
"subtitle": "Add a charger from a service you have connected. Its name, serial and model come with it.",
"noProviders": "No charger service is connected. Add one in Settings Integrations.",
"selectCharger": "Charger",
"loadingChargers": "Loading your chargers…",
"noChargers": "No chargers on this account.",
"alreadyImported": "Already imported",
"name": "Charger name",
"submit": "Import charger"
}
},
"enums": {
+144 -17
View File
@@ -116,6 +116,11 @@
"public": "Ładowarki publiczne",
"home": "Ładowarki domowe"
},
"arrange": {
"title": "Ułóż stronę ładowania",
"tabs": "Zakładki",
"cards": "Karty"
},
"liveMap": "Mapa na żywo",
"youAreHere": "Tu jesteś",
"session": {
@@ -128,21 +133,6 @@
"idle": "Brak ładowania",
"idleHint": "Podłącz na stacji, aby rozpocząć sesję."
},
"stations": {
"heading": "W pobliżu",
"homeHeading": "Twoje ładowarki",
"noControlHint": "Włącz tryb Own lub Proxy CSMS w Ustawienia → Integracje, aby sterować domową ładowarką.",
"count": {
"one": "{n} stacja",
"few": "{n} stacje",
"many": "{n} stacji",
"other": "{n} stacji"
},
"free": "{avail} z {total} wolnych",
"full": "Zajęte",
"offPeak": "poza szczytem",
"homeCharger": "Ładowarka domowa"
},
"control": {
"title": "Sterowanie ładowarką",
"connected": "Połączono",
@@ -159,7 +149,128 @@
"reset": "Zrestartuj ładowarkę",
"resetConfirm": "Zrestartować teraz tę ładowarkę? Trwająca sesja ładowania zostanie przerwana. Wpisz ponownie hasło, aby potwierdzić.",
"resetPassword": "Hasło do Twojego konta",
"connectHint": "Wpisz numer seryjny ładowarki i odśwież. Ładowarka musi być połączona z backendem OCPP DriverVault (konfiguracja w Ustawienia → Integracje)."
"connectHint": "Wpisz numer seryjny ładowarki i odśwież. Ładowarka musi być połączona z backendem OCPP DriverVault (konfiguracja w Ustawienia → Integracje).",
"connectionTitle": "Połączenie z ładowarką",
"enterSerial": "Wpisz numer seryjny zamiast tego",
"pickSerial": "Wybierz ze swoich ładowarek",
"address": "Adres ładowarki w tej sieci",
"addressPlaceholder": "Adres IP (np. 192.168.1.40)",
"addressPort": "Port",
"addressHint": "Włącz Modbus TCP w aplikacji Anker w Ustawienia → Integracje; aplikacja pokaże wtedy ten adres.",
"saveAddress": "Zapisz adres",
"forgetAddress": "Zapomnij",
"forgetAddressConfirm": "Zapomnieć lokalny adres tej ładowarki? Sterowanie przez sieć lokalną przestanie działać, dopóki nie wpiszesz go ponownie.",
"boost": "Boost w tej sesji",
"modbusHint": "Podaj adres ładowarki w tej sieci i zapisz go. Ładowarka musi być włączona i w tej samej sieci co DriverVault."
},
"modbus": {
"title": "Odczyty ładowarki",
"phases": "Na fazę",
"live": "Dane na żywo",
"settings": "Ustawienia",
"device": "Urządzenie",
"alarms": "Alarmy",
"phase": "Faza",
"voltage": "Napięcie",
"current": "Prąd",
"activePower": "Czynna",
"reactivePower": "Bierna",
"apparentPower": "Pozorna",
"lineToLine": "Międzyfazowe",
"power": "Moc całkowita",
"sessionDuration": "Czas sesji",
"cpSignal": "Control pilot",
"cpVoltage": "Napięcie pilota",
"phaseMode": "Pracuje na",
"relayTemps": "Temperatury przekaźników",
"pwm": "PWM aktywny",
"maxCurrentSet": "Limit prądu",
"timeout": "Limit czasu sterowania",
"phaseSetting": "Ustawienie faz",
"boostSet": "Boost zażądany",
"lastCommand": "Ostatnie polecenie",
"chargingMode": "Tryb ładowania",
"loadBalancing": "Balansowanie obciążenia",
"solarBalancing": "Balansowanie solarne",
"ledBrightness": "Jasność LED",
"model": "Model",
"serial": "Numer seryjny",
"firmware": "Firmware",
"hardware": "Hardware",
"productNumber": "Numer produktu",
"ratedPower": "Moc znamionowa",
"currentRange": "Zakres prądu",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"alarmWord": "Słowo {n}",
"alarmsHint": "Ładowarka zgłasza alarm. Anker nie publikuje znaczenia poszczególnych bitów, więc słowa pokazane są tak, jak przychodzą.",
"phaseMode1": "Jednofazowo",
"phaseMode3": "Trójfazowo",
"phaseSet0": "Automatycznie",
"phaseSet1": "Na stałe jednofazowo",
"phaseSet2": "Na stałe trójfazowo",
"chargingMode0": "Słońce + sieć",
"chargingMode1": "Tylko słońce",
"command0": "Brak",
"command1": "Start",
"command2": "Stop",
"ocpp0": "Nierozłączona",
"ocpp1": "Łączenie",
"ocpp2": "Połączona",
"mqtt0": "Nierozłączona",
"mqtt1": "Połączona"
},
"info": {
"title": "Informacje o ładowarce",
"empty": "Nic jeszcze nie zaimportowano — zaimportowana ładowarka pokaże tutaj wszystko, co zgłasza.",
"vendor": "Producent",
"model": "Model",
"firmware": "Oprogramowanie",
"serial": "Numer seryjny",
"site": "Lokalizacja",
"siteId": "Identyfikator lokalizacji",
"sources": "Zarejestrowana jako",
"sourceNames": {
"standalone": "Samodzielna",
"site": "W systemie",
"bound": "Powiązana z kontem"
},
"power": "Moc",
"connector": "Złącze",
"providerId": "Identyfikator w usłudze",
"state": "Stan",
"chargePower": "Moc ładowania",
"ocpp": "Status OCPP",
"added": "Dodano",
"online": "Online",
"offline": "Offline",
"refresh": "Odśwież"
},
"home": {
"count": {
"one": "{n} ładowarka",
"few": "{n} ładowarki",
"many": "{n} ładowarek",
"other": "{n} ładowarki"
},
"import": "Importuj z usługi",
"remove": "Usuń",
"removeConfirm": "Usunąć {name} z Twoich ładowarek? Sama ładowarka pozostaje nietknięta — ponowny import ją przywróci.",
"empty": "Nie masz jeszcze ładowarek. Zaimportuj tę z konta Anker lub Greencell, zamiast przepisywać numer seryjny.",
"connectFirst": "Podłącz usługę ładowarki w Ustawienia Integracje, aby zaimportować."
},
"stations": {
"heading": "W pobliżu",
"homeHeading": "Twoje ładowarki",
"noControlHint": "Wybierz tryb sterowania w Ustawienia → Integracje, aby sterować domową ładowarką: Modbus TCP w sieci lokalnej albo Own/Proxy CSMS przez OCPP.",
"count": {
"one": "{n} stacja",
"few": "{n} stacje",
"many": "{n} stacji",
"other": "{n} stacji"
},
"free": "{avail} z {total} wolnych",
"full": "Zajęte"
}
},
"settings": {
@@ -388,7 +499,12 @@
"toyota": "Toyota Connected (MyToyota)",
"brandToyota": "Toyota",
"brandLexus": "Lexus",
"ankerSolix": "Anker Solix (V1 Smart EV Charger)"
"ankerSolix": "Anker Solix (V1 Smart EV Charger)",
"categories": {
"vehicles": "Producenci samochodów",
"chargers": "Ładowarki EV"
},
"chargerOnline": "Online"
}
},
"status": {
@@ -944,6 +1060,17 @@
"partName": "Nazwa części jest wymagana.",
"description": "Opisz, co zostało zrobione.",
"title": "Tytuł jest wymagany."
},
"importCharger": {
"title": "Importuj ładowarkę",
"subtitle": "Dodaj ładowarkę z podłączonej usługi. Nazwa, numer seryjny i model przychodzą razem z nią.",
"noProviders": "Nie podłączono żadnej usługi ładowarki. Dodaj ją w Ustawienia Integracje.",
"selectCharger": "Ładowarka",
"loadingChargers": "Wczytywanie ładowarek…",
"noChargers": "Brak ładowarek na tym koncie.",
"alreadyImported": "Już zaimportowana",
"name": "Nazwa ładowarki",
"submit": "Importuj ładowarkę"
}
},
"enums": {
+80 -5
View File
@@ -661,11 +661,17 @@ class ApiClient {
return IntegrationHealth.fromJson(Map<String, dynamic>.from(h));
}
// --- Anker Solix OCPP control (per charger) ---
// getAnkerControl returns the control mode, connection status, provisioning
// endpoint + token, and a live status snapshot; ankerControlToken (re)generates
// the per-charger token (returned exactly once); ankerControlRevoke deletes it;
// ankerControlAction issues one OCPP command (start/stop/limit/clear-limit/reset/…).
// --- Anker Solix control (per charger) ---
//
// getAnkerControl returns the control mode, connection status and a live
// status snapshot — an OCPP session snapshot in own/proxy mode, a Modbus
// register snapshot in modbus mode.
//
// The two modes are provisioned differently, and each has its own pair here:
// OCPP needs a token the operator installs into the charger (ankerControlToken
// / ankerControlRevoke), Modbus needs the charger's address on the local
// network (ankerControlAddress / ankerControlForgetAddress). A charger may
// hold both; setting one leaves the other alone.
String _sn(String sn) => Uri.encodeComponent(sn);
Future<AnkerControl> getAnkerControl(String sn) async {
@@ -683,8 +689,77 @@ class ApiClient {
Future<void> ankerControlRevoke(String sn) =>
_send("DELETE", "/integrations/anker-solix/chargers/${_sn(sn)}/control/token");
/// Where the charger answers on the local network. Modbus dials the charger
/// rather than waiting to be dialled, so this address is what the mode needs
/// in place of a token.
Future<void> ankerControlAddress(String sn, String host, int port) => _send(
"PUT",
"/integrations/anker-solix/chargers/${_sn(sn)}/control/address",
body: {"host": host, "port": port},
);
Future<void> ankerControlForgetAddress(String sn) =>
_send("DELETE", "/integrations/anker-solix/chargers/${_sn(sn)}/control/address");
/// One control command. Over OCPP: start, stop, limit, clear-limit,
/// availability, reset, unlock, trigger, config. Over Modbus TCP: start, stop,
/// limit, boost, phase, timeout, status.
Future<void> ankerControlAction(String sn, String action, [Map<String, dynamic> body = const {}]) =>
_send("POST", "/integrations/anker-solix/chargers/${_sn(sn)}/$action", body: body);
// --- charger providers and the user's own chargers ---
//
// The garage's import aimed at the wall: a charger on a connected service
// becomes one of the caller's own home chargers, and stays one after the
// account it came from is disconnected.
/// Every charger service, each with whether the caller can use it and, when
/// they cannot, the one sentence saying what to do about it.
Future<List<ChargerProvider>> listChargerProviders() async {
final data = await _send("GET", "/charger-providers");
final items = (data["providers"] ?? []) as List;
return items.map((e) => ChargerProvider.fromJson(Map<String, dynamic>.from(e))).toList();
}
/// The chargers on that account, each annotated with the DriverVault charger
/// it is already linked to.
Future<ProviderChargerList> listProviderChargers(String provider) async {
final data = await _send(
"GET", "/charger-providers/${Uri.encodeComponent(provider)}/chargers");
if (data is! Map) return const ProviderChargerList();
return ProviderChargerList.fromJson(Map<String, dynamic>.from(data));
}
/// Creates a home charger from one on the account. An empty [name] takes
/// whatever the service calls it.
Future<HomeCharger> importProviderCharger(String provider, String chargerId,
{String name = ""}) async {
final data = await _send(
"POST",
"/charger-providers/${Uri.encodeComponent(provider)}/import",
body: {"chargerId": chargerId, "name": name},
);
final c = (data is Map ? data["charger"] : null) ?? {};
return HomeCharger.fromJson(Map<String, dynamic>.from(c));
}
Future<List<HomeCharger>> listHomeChargers() async {
final data = await _send("GET", "/home-chargers");
final items = (data["chargers"] ?? []) as List;
return items.map((e) => HomeCharger.fromJson(Map<String, dynamic>.from(e))).toList();
}
/// Only the name is editable — everything else describes the hardware and
/// comes from the service the charger was imported from.
Future<HomeCharger> renameHomeCharger(String id, String name) async {
final data = await _send("PATCH", "/home-chargers/${Uri.encodeComponent(id)}",
body: {"name": name});
final c = (data is Map ? data["charger"] : null) ?? {};
return HomeCharger.fromJson(Map<String, dynamic>.from(c));
}
Future<void> deleteHomeCharger(String id) =>
_send("DELETE", "/home-chargers/${Uri.encodeComponent(id)}");
static String _asString(dynamic v) => v == null ? "" : v.toString();
}
+300 -8
View File
@@ -882,6 +882,13 @@ class UserProfile {
final String role; // user | admin
final String organization; // org record id ("" = belongs to no organization)
final String organizationName; // resolved name ("" when unset/unresolvable)
/// The charging page's arrangements — which order its tabs and its cards are
/// in. Kept on the profile rather than on the device because they are layout
/// choices that should follow the account, the way the garage order does.
final List<String> chargerTabOrder;
final List<String> chargerCardOrder;
final DateTime? deletionRequestedAt;
UserProfile({
@@ -899,6 +906,8 @@ class UserProfile {
required this.role,
this.organization = "",
this.organizationName = "",
this.chargerTabOrder = const [],
this.chargerCardOrder = const [],
required this.deletionRequestedAt,
});
@@ -917,6 +926,8 @@ class UserProfile {
role: j["role"] == null ? "user" : _asStr(j["role"]),
organization: _asStr(j["organization"]),
organizationName: _asStr(j["organizationName"]),
chargerTabOrder: _asStrList(j["chargerTabOrder"]),
chargerCardOrder: _asStrList(j["chargerCardOrder"]),
deletionRequestedAt: j["deletionRequestedAt"] == null
? null
: DateTime.tryParse(_asStr(j["deletionRequestedAt"]))?.toLocal(),
@@ -1041,17 +1052,37 @@ class IntegrationHealth {
);
}
/// A charger's OCPP control view, from …/chargers/{sn}/control. Serves both the
/// Settings provisioning card (endpoint/token/connection) and the Charging home
/// tab's live control (connector status + meter).
/// A charger's control view, from …/chargers/{sn}/control — over whichever
/// transport the user's control mode selects. Serves the Settings provisioning
/// card (endpoint/token/connection) and the Charging home tab's live control.
///
/// The two transports are provisioned differently and report differently. OCPP
/// waits for the charger to dial us, so what it needs is a token installed into
/// the charger and what it reports is a session snapshot counting a meter in
/// watt-hours. Modbus dials the charger, so what it needs is an address on the
/// local network and what it reports is the whole register map — including a
/// session's own energy, which is not the same number as a meter reading.
class AnkerControl {
final String endpoint; // OCPP backend URL to point the charger at
final bool hasToken; // a per-charger token has been generated
final String tokenHint; // last chars of the token, for display
final bool connected; // the charger is connected to the control backend
final bool connected; // the charger is reachable over the active transport
final String controlMode; // off | own | proxy | modbus
final String connectorStatus; // OCPP connector status, e.g. "Charging"
final int meterWh; // last meter reading in watt-hours
final int meterWh; // last OCPP meter reading in watt-hours
/// Where the charger lives on the local network (Modbus mode only). Blank
/// until an address is saved, which is what the Charging page asks for.
final String modbusHost;
final int modbusPort;
/// The server's own sentence about why there is nothing to control — no
/// address saved yet, or a charger that did not answer at the one there is.
/// Better than a generic hint, because the server has already tried.
final String detail;
/// The Modbus register snapshot, when the active transport produced one.
final ModbusStatus? modbus;
const AnkerControl({
this.endpoint = "",
@@ -1061,23 +1092,86 @@ class AnkerControl {
this.controlMode = "off",
this.connectorStatus = "",
this.meterWh = 0,
this.modbusHost = "",
this.modbusPort = 502,
this.detail = "",
this.modbus,
});
factory AnkerControl.fromJson(Map<String, dynamic> j) {
final status = j["status"];
final s = status is Map ? Map<String, dynamic>.from(status) : const {};
final s = status is Map ? Map<String, dynamic>.from(status) : const <String, dynamic>{};
final mode = _asStr(j["controlMode"]).isEmpty ? "off" : _asStr(j["controlMode"]);
final port = _asInt(j["modbusPort"]);
return AnkerControl(
endpoint: _asStr(j["endpoint"]),
hasToken: _asBool(j["hasToken"]),
tokenHint: _asStr(j["tokenHint"]),
connected: _asBool(j["connected"]),
controlMode: _asStr(j["controlMode"]).isEmpty ? "off" : _asStr(j["controlMode"]),
controlMode: mode,
connectorStatus: _asStr(s["connectorStatus"]),
meterWh: _asInt(s["meterWh"]),
modbusHost: _asStr(j["modbusHost"]),
modbusPort: port == 0 ? 502 : port,
detail: _asStr(j["detail"]),
modbus: mode == "modbus" && status is Map
? ModbusStatus(Map<String, dynamic>.from(status))
: null,
);
}
double get meterKwh => meterWh / 1000.0;
bool get isModbus => controlMode == "modbus";
/// The energy this card shows. The two transports word a charging session
/// differently — an OCPP snapshot counts a meter, a Modbus one counts the
/// session — and both land in the same tile.
double get meterKwh => (isModbus ? (modbus?.sessionWh ?? 0) : meterWh) / 1000.0;
/// What the charger says it is doing, in whichever transport's words.
String get statusLabel {
final label = isModbus ? (modbus?.statusDesc ?? "") : connectorStatus;
return label.isEmpty ? "" : label;
}
}
/// The Anker charger's Modbus register snapshot.
///
/// A wrapper over the raw JSON rather than forty declared fields: the register
/// map is the server's to describe, every value is optional (a charger on older
/// firmware answers a shorter block), and a reading added to the snapshot
/// upstream surfaces here without a change to this file. The getters name what
/// the Charging page reads, and each keeps null distinct from zero — a relay
/// that reported no temperature is not a relay at 0 °C.
class ModbusStatus {
final Map<String, dynamic> raw;
const ModbusStatus(this.raw);
double? number(String key) => _asDoubleOrNull(raw[key]);
int? integer(String key) => _asIntOrNull(raw[key]);
bool? flag(String key) => raw[key] is bool ? raw[key] as bool : null;
String text(String key) => _asStr(raw[key]);
/// The control block, which the charger answers as its own object.
Map<String, dynamic> get settings {
final v = raw["settings"];
return v is Map ? Map<String, dynamic>.from(v) : const {};
}
double? setting(String key) => _asDoubleOrNull(settings[key]);
int? settingInt(String key) => _asIntOrNull(settings[key]);
bool? settingFlag(String key) => settings[key] is bool ? settings[key] as bool : null;
String get statusDesc => text("statusDesc");
int get sessionWh => _asInt(raw["sessionWh"]);
/// The alarm words, as they arrive: the spec defers what the individual bits
/// mean to a list Anker does not publish, so which word is set is still the
/// thing to report.
bool get alarm => _asBool(raw["alarm"]);
List<int> get alarms {
final v = raw["alarms"];
return v is List ? v.map(_asInt).toList() : const [];
}
}
/// One EV charger on the linked Anker account, from …/anker-solix/chargers. The
@@ -1088,6 +1182,7 @@ class AnkerCharger {
final String sn;
final String name;
final String model;
final String firmware;
final String siteName;
final String statusDesc; // charging | standby | … as the cloud names it
final bool? online; // null when no view reported a connection state
@@ -1096,6 +1191,7 @@ class AnkerCharger {
required this.sn,
this.name = "",
this.model = "",
this.firmware = "",
this.siteName = "",
this.statusDesc = "",
this.online,
@@ -1105,6 +1201,7 @@ class AnkerCharger {
sn: _asStr(j["sn"]),
name: _asStr(j["name"]),
model: _asStr(j["model"]),
firmware: _asStr(j["firmware"]),
siteName: _asStr(j["siteName"]),
statusDesc: _asStr(j["statusDesc"]),
online: j["online"] is bool ? j["online"] as bool : null,
@@ -1139,6 +1236,201 @@ class AnkerChargerList {
}
}
// --- home chargers (the user's own wallbox) ---------------------------------
//
// The garage's import, aimed at the wall: a charger on a connected service
// becomes a record of the user's own, and stays one after the account it came
// from is disconnected. It belongs to the person rather than to a car — it
// charges whichever car is plugged into it, and it outlives any of them.
/// One charger the user owns, from GET /home-chargers.
class HomeCharger {
final String id;
final String name;
final String serial;
final String vendor; // "Anker Solix", "Greencell"
final String model; // "A5191"
final String siteName; // the system it belongs to, where it has one
final double powerKw;
final String connector;
/// The service it was imported from and that service's own id for it. Both
/// blank for a charger added by hand; set only by the import, so a rename
/// cannot break the link.
final String provider;
final String providerChargerId;
final String created;
const HomeCharger({
required this.id,
this.name = "",
this.serial = "",
this.vendor = "",
this.model = "",
this.siteName = "",
this.powerKw = 0,
this.connector = "",
this.provider = "",
this.providerChargerId = "",
this.created = "",
});
factory HomeCharger.fromJson(Map<String, dynamic> j) => HomeCharger(
id: _asStr(j["id"]),
name: _asStr(j["name"]),
serial: _asStr(j["serial"]),
vendor: _asStr(j["vendor"]),
model: _asStr(j["model"]),
siteName: _asStr(j["siteName"]),
powerKw: _asDouble(j["powerKw"]),
connector: _asStr(j["connector"]),
provider: _asStr(j["provider"]),
providerChargerId: _asStr(j["providerChargerId"]),
created: _asStr(j["created"]),
);
/// The line under the name in a list: what the charger is, in as many of the
/// three terms as the record actually holds.
String get subtitle =>
[serial, model, siteName].where((s) => s.isNotEmpty).join(" · ");
}
/// One charger service the user could import from, from GET /charger-providers.
/// [detail] says, in one sentence, what to do about a closed gate — the same
/// shape the vehicle providers use.
class ChargerProvider {
final String id;
final String label; // "Anker Solix"
final String service; // "Anker Solix cloud"
final bool connected;
final String detail;
const ChargerProvider({
required this.id,
this.label = "",
this.service = "",
this.connected = false,
this.detail = "",
});
factory ChargerProvider.fromJson(Map<String, dynamic> j) => ChargerProvider(
id: _asStr(j["id"]),
label: _asStr(j["label"]),
service: _asStr(j["service"]),
connected: _asBool(j["connected"]),
detail: _asStr(j["detail"]),
);
}
/// One charger as the service that has it describes it, from
/// /charger-providers/{provider}/chargers.
///
/// This is the live half: a record says what a charger *is*, and only the
/// service it came from knows whether it is reachable right now. The import
/// sheet lists these to pick from; the Charging page holds them beside the
/// imported records to say which are online.
class ProviderCharger {
final String id; // the provider's own id — the serial, for both services
final String name;
final String vendor;
final String model;
final String firmware;
final String siteId;
final String siteName;
final String status; // the service's own word for its state
final bool? online; // null when the service reported no connection state
/// How the charger is registered on the account — standalone, inside a
/// system, or merely bound to it. A charger can be several at once.
final List<String> sources;
/// The charge power as the service words it. The unit is upstream's, so it is
/// relayed verbatim rather than given one here.
final String power;
final int? ocppStatus;
final String ocppStatusDesc;
/// Set when this charger is already in DriverVault, so the import never
/// offers the same one twice.
final String linkedChargerId;
const ProviderCharger({
required this.id,
this.name = "",
this.vendor = "",
this.model = "",
this.firmware = "",
this.siteId = "",
this.siteName = "",
this.status = "",
this.online,
this.sources = const [],
this.power = "",
this.ocppStatus,
this.ocppStatusDesc = "",
this.linkedChargerId = "",
});
factory ProviderCharger.fromJson(Map<String, dynamic> j) => ProviderCharger(
id: _asStr(j["id"]),
name: _asStr(j["name"]),
vendor: _asStr(j["vendor"]),
model: _asStr(j["model"]),
firmware: _asStr(j["firmware"]),
siteId: _asStr(j["siteId"]),
siteName: _asStr(j["siteName"]),
status: _asStr(j["status"]),
online: j["online"] is bool ? j["online"] as bool : null,
sources: _asStrList(j["sources"]),
power: _asStr(j["power"]),
ocppStatus: _asIntOrNull(j["ocppStatus"]),
ocppStatusDesc: _asStr(j["ocppStatusDesc"]),
linkedChargerId: _asStr(j["linkedChargerId"]),
);
String get subtitle =>
[vendor, model, siteName].where((s) => s.isNotEmpty).join(" · ");
/// The OCPP connector state as the *service* sees it — the cloud's own
/// reading, not our CSMS's. It words it when it can and numbers it when it
/// cannot.
String get ocppLabel {
if (ocppStatusDesc.isNotEmpty) return ocppStatusDesc;
return ocppStatus == null ? "" : "$ocppStatus";
}
}
/// The chargers on one provider account, plus the reason the list may be empty:
/// a gate that is off answers 200 with nothing and a sentence, so the UI can say
/// "connect this in Settings" rather than show a blank panel.
class ProviderChargerList {
final List<ProviderCharger> chargers;
final bool unavailable;
final String detail;
const ProviderChargerList({
this.chargers = const [],
this.unavailable = false,
this.detail = "",
});
factory ProviderChargerList.fromJson(Map<String, dynamic> j) {
final raw = j["chargers"];
return ProviderChargerList(
chargers: raw is List
? raw
.whereType<Map>()
.map((c) => ProviderCharger.fromJson(Map<String, dynamic>.from(c)))
.where((c) => c.id.isNotEmpty)
.toList()
: const [],
unavailable: _asBool(j["unavailable"]),
detail: _asStr(j["detail"]),
);
}
}
// --- vehicle providers (the connected-service tab) ---------------------------
//
// A provider is a manufacturer service a car can be linked to (MyToyota today).
@@ -0,0 +1,376 @@
import "package:flutter/material.dart";
import "../i18n.dart";
import "../main.dart";
import "../models.dart";
import "../theme.dart";
/// Create a home charger from one on a connected charger service (Anker Solix or
/// Greencell today; the endpoints are generic, so the next service needs no
/// changes here).
///
/// Shorter than the car import on purpose: a charger is a name, a serial and the
/// hardware behind it, and the service's list already carries all three. There is
/// nothing to choose about what to pull, so the whole sheet is pick one, press
/// Import. Pops the created [HomeCharger] when it saved.
Future<HomeCharger?> showChargerImportSheet(BuildContext context) {
return showModalBottomSheet<HomeCharger>(
context: context,
isScrollControlled: true,
builder: (_) => const _ChargerImportSheet(),
);
}
class _ChargerImportSheet extends StatefulWidget {
const _ChargerImportSheet();
@override
State<_ChargerImportSheet> createState() => _ChargerImportSheetState();
}
class _ChargerImportSheetState extends State<_ChargerImportSheet> {
final _name = TextEditingController();
List<ChargerProvider> _providers = const [];
String _provider = "";
List<ProviderCharger> _chargers = const [];
String _selectedId = "";
bool _loading = true;
bool _loadingChargers = false;
bool _importing = false;
String? _error;
/// Why the provider can't be used right now (not connected, an org switch off,
/// …). The server phrases this; the sheet just shows it.
String _detail = "";
/// The name field tracks the selected charger until the user types their own.
bool _nameEdited = false;
@override
void initState() {
super.initState();
_init();
}
@override
void dispose() {
_name.dispose();
super.dispose();
}
ChargerProvider? get _current {
for (final p in _providers) {
if (p.id == _provider) return p;
}
return null;
}
ProviderCharger? get _selected {
for (final c in _chargers) {
if (c.id == _selectedId) return c;
}
return null;
}
bool get _canImport =>
_selected != null && _selected!.linkedChargerId.isEmpty && !_importing;
Future<void> _init() async {
try {
final list = await apiClient.listChargerProviders();
if (!mounted) return;
setState(() {
_providers = list;
final connected = list.where((p) => p.connected);
_provider = connected.isNotEmpty
? connected.first.id
: (list.isNotEmpty ? list.first.id : "");
});
} catch (e) {
if (mounted) setState(() => _error = "$e");
} finally {
if (mounted) setState(() => _loading = false);
}
await _loadChargers();
}
Future<void> _loadChargers() async {
setState(() {
_chargers = const [];
_selectedId = "";
_detail = "";
});
if (_provider.isEmpty) return;
// A provider that is not connected has nothing to list; say what to do about
// it instead of asking the server a question it has already answered.
if (_current?.connected != true) {
setState(() => _detail = _current?.detail ?? "");
return;
}
setState(() {
_loadingChargers = true;
_error = null;
});
try {
final res = await apiClient.listProviderChargers(_provider);
if (!mounted) return;
// Preselect the first charger that isn't here already — with one charger
// on the account that is the whole selection step.
var pick = "";
for (final c in res.chargers) {
if (c.linkedChargerId.isEmpty) {
pick = c.id;
break;
}
}
setState(() {
_chargers = res.chargers;
_detail = res.unavailable ? res.detail : "";
_selectedId = pick;
});
_syncName();
} catch (e) {
if (mounted) setState(() => _error = "$e");
} finally {
if (mounted) setState(() => _loadingChargers = false);
}
}
void _syncName() {
if (_nameEdited) return;
_name.text = _selected?.name ?? "";
}
Future<void> _submit() async {
if (!_canImport) return;
setState(() {
_importing = true;
_error = null;
});
try {
final charger = await apiClient.importProviderCharger(
_provider,
_selected!.id,
name: _name.text.trim(),
);
if (mounted) Navigator.pop(context, charger);
} catch (e) {
if (mounted) setState(() => _error = "$e");
} finally {
if (mounted) setState(() => _importing = false);
}
}
@override
Widget build(BuildContext context) {
final muted = DriverVault.muted(context);
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: DriverVault.sheetBottomInset(context),
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t("forms.importCharger.title"),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
),
if (_loading)
Text(t("common.loading"), style: TextStyle(fontSize: 13, color: muted))
else if (_providers.isEmpty)
Text(t("forms.importCharger.noProviders"),
style: TextStyle(fontSize: 13, color: muted))
else
..._form(context, muted),
const SizedBox(height: 12),
Row(children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
child: Text(t("common.cancel")),
),
),
if (!_loading && _providers.isNotEmpty) ...[
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: _canImport ? _submit : null,
child: Text(_importing
? t("forms.import.importing")
: t("forms.importCharger.submit")),
),
),
],
]),
],
),
),
);
}
List<Widget> _form(BuildContext context, Color muted) {
return [
Text(t("forms.importCharger.subtitle"), style: TextStyle(fontSize: 13, color: muted)),
const SizedBox(height: 12),
// Service picker. Hidden while there is only one to pick.
if (_providers.length > 1) ...[
DropdownButtonFormField<String>(
initialValue: _provider.isEmpty ? null : _provider,
isExpanded: true,
decoration: InputDecoration(
labelText: t("forms.import.service"),
border: const OutlineInputBorder(),
isDense: true,
),
items: [
for (final p in _providers)
DropdownMenuItem(
value: p.id,
enabled: p.connected,
child: Text(
p.connected ? p.label : "${p.label}${t("forms.import.notConnected")}",
overflow: TextOverflow.ellipsis,
),
),
],
onChanged: (id) {
if (id == null || id == _provider) return;
setState(() => _provider = id);
_loadChargers();
},
),
const SizedBox(height: 12),
],
if (_detail.isNotEmpty) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: DriverVault.isDark(context)
? DriverVault.warningSoftDark
: DriverVault.warningSoft,
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
),
child: Text(_detail,
style: const TextStyle(fontSize: 13, color: DriverVault.warning)),
),
const SizedBox(height: 12),
],
Text(t("forms.importCharger.selectCharger"),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
if (_loadingChargers)
Text(t("forms.importCharger.loadingChargers"),
style: TextStyle(fontSize: 13, color: muted))
else if (_chargers.isEmpty && _detail.isEmpty)
Text(t("forms.importCharger.noChargers"), style: TextStyle(fontSize: 13, color: muted))
else
for (final c in _chargers) _chargerRow(context, c, muted),
if (_selected != null) ...[
const SizedBox(height: 12),
TextField(
controller: _name,
decoration: InputDecoration(
labelText: t("forms.importCharger.name"),
border: const OutlineInputBorder(),
isDense: true,
),
onChanged: (_) => _nameEdited = true,
),
],
];
}
Widget _chargerRow(BuildContext context, ProviderCharger c, Color muted) {
final imported = c.linkedChargerId.isNotEmpty;
final picked = _selectedId == c.id;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
onTap: imported
? null
: () {
setState(() => _selectedId = c.id);
_syncName();
},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: picked ? DriverVault.brandTint(context) : null,
border: Border.all(
color: picked
? Theme.of(context).colorScheme.primary
: Theme.of(context).dividerColor,
),
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(right: 10, top: 2),
child: Icon(
picked ? Icons.radio_button_checked : Icons.radio_button_unchecked,
size: 20,
color: imported
? muted
: picked
? Theme.of(context).colorScheme.primary
: muted,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(c.name.isNotEmpty ? c.name : c.id,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
if (c.subtitle.isNotEmpty)
Text(c.subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: muted)),
Text(c.id,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DriverVault.mono(context, size: 11).copyWith(color: muted)),
if (imported)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(t("forms.importCharger.alreadyImported"),
style: TextStyle(fontSize: 12, color: muted)),
)
else if (c.online == false)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(t("settings.integrations.chargerOffline"),
style: const TextStyle(fontSize: 12, color: DriverVault.warning)),
),
],
),
),
],
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+50 -16
View File
@@ -1532,8 +1532,17 @@ class _IntegrationConfig {
});
}
class _IntegrationsTab extends StatelessWidget {
/// The integration categories, mirroring the API Server panel's plugin list
/// (the Category* constants in internal/plugins/plugin.go, which both read
/// from): someone who has seen one list can read the other.
class _IntegrationsTab extends StatefulWidget {
const _IntegrationsTab();
@override
State<_IntegrationsTab> createState() => _IntegrationsTabState();
}
class _IntegrationsTabState extends State<_IntegrationsTab> {
int _group = 0; // 0 = car manufacturers, 1 = EV chargers
@override
Widget build(BuildContext context) {
@@ -1665,6 +1674,15 @@ class _IntegrationsTab extends StatelessWidget {
],
);
// One category's integrations. No count beside the label: the tab shows
// exactly these cards a line below it, so saying how many would be saying it
// twice.
final groups = [
[toyota],
[anker, greencell],
];
final index = _group.clamp(0, groups.length - 1);
return ListView(
padding: const EdgeInsets.all(12),
children: [
@@ -1672,16 +1690,24 @@ class _IntegrationsTab extends StatelessWidget {
title: t("settings.integrations.title"),
children: [
Padding(
padding: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.only(bottom: 10),
child: Text(t("settings.integrations.subtitle"),
style: TextStyle(fontSize: 13, color: DriverVault.muted(context))),
),
const SizedBox(height: 8),
_IntegrationCard(config: toyota),
_SettingsTabBar(
tabs: [
t("settings.integrations.categories.vehicles"),
t("settings.integrations.categories.chargers"),
],
index: index,
onChanged: (i) => setState(() => _group = i),
),
const SizedBox(height: 12),
_IntegrationCard(config: anker),
const SizedBox(height: 12),
_IntegrationCard(config: greencell),
for (final config in groups[index])
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _IntegrationCard(config: config),
),
],
),
const SizedBox(height: 24),
@@ -2237,22 +2263,30 @@ class _AnkerChargersState extends State<_AnkerChargers> {
[
c.sn,
if (c.model.isNotEmpty) c.model,
if (c.firmware.isNotEmpty) c.firmware,
if (c.siteName.isNotEmpty) c.siteName,
].join(" · "),
style: DriverVault.mono(context, size: 11).copyWith(color: muted),
),
if (c.statusDesc.isNotEmpty || c.online == false)
// Reachability, said either way. A charger the cloud does
// not report on at all (no online field) says nothing —
// silence there is unknown, not online.
if (c.online != null)
Text(
c.online == false
? t("settings.integrations.chargerOffline")
: _stateLabel(c.statusDesc),
c.online == true
? t("settings.integrations.chargerOnline")
: t("settings.integrations.chargerOffline"),
style: TextStyle(
fontSize: 12,
color: c.online == false
? DriverVault.warning
: c.statusDesc == "charging"
? DriverVault.success
: muted,
color: c.online == true ? DriverVault.success : DriverVault.warning,
),
),
if (c.statusDesc.isNotEmpty)
Text(
_stateLabel(c.statusDesc),
style: TextStyle(
fontSize: 12,
color: c.statusDesc == "charging" ? DriverVault.success : muted,
),
),
],
+100
View File
@@ -0,0 +1,100 @@
// The charging page's home half.
//
// Nothing here can talk to a server, so what is worth guarding is what the page
// says without one: that both tabs render, that the home tab degrades to the
// "import one" invitation rather than a blank column, and that the arrange sheet
// offers exactly the tabs and cards the profile can store — those key sets are
// shared with the web app and the server, so a card added here and forgotten
// there would arrange into nothing.
import "package:flutter/material.dart";
import "package:flutter_test/flutter_test.dart";
import "package:intl/date_symbol_data_local.dart";
import "package:shared_preferences/shared_preferences.dart";
import "package:drivervault_phone/i18n.dart";
import "package:drivervault_phone/main.dart";
import "package:drivervault_phone/screens/charging_screen.dart";
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async {
await initializeDateFormatting();
await loadTranslations();
appSettings.locale = "en-GB";
appSettings.dateFormat = "DMY_NUM";
});
setUp(() {
// The page reads its folded cards and its last serial from here.
SharedPreferences.setMockInitialValues({});
});
// A surface tall enough that the page's lazy list builds the home cards as
// well as the header above them.
Future<void> pump(WidgetTester tester) async {
tester.view.physicalSize = const Size(800, 3000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(const MaterialApp(home: ChargingScreen()));
await tester.pumpAndSettle();
}
testWidgets("both tabs render, and the home half invites an import", (tester) async {
await pump(tester);
expect(find.text(t("charging.tabs.public")), findsWidgets);
expect(find.text(t("charging.tabs.home")), findsWidgets);
// The page opens on the public half; the tab that is not shown is not built,
// so the home cards are only findable once it is.
expect(find.textContaining(t("charging.stations.heading")), findsWidgets);
await tester.tap(find.text(t("charging.tabs.home")));
await tester.pumpAndSettle();
// With no chargers and no control mode, the home tab is the information card
// saying there is nothing yet plus the list saying what it is for.
expect(find.text(t("charging.info.title")), findsOneWidget);
expect(find.text(t("charging.info.empty")), findsOneWidget);
expect(find.text(t("charging.home.empty")), findsOneWidget);
// No charger service answered, so importing is not on offer — the hint is.
expect(find.text(t("charging.home.connectFirst")), findsOneWidget);
expect(find.text(t("charging.home.import")), findsNothing);
// Control is off, so the cards that act on a charger are absent and the
// information card says why.
expect(find.text(t("charging.control.title")), findsNothing);
expect(find.text(t("charging.control.connectionTitle")), findsNothing);
expect(find.text(t("charging.modbus.title")), findsNothing);
expect(find.text(t("charging.stations.noControlHint")), findsOneWidget);
});
testWidgets("the arrange sheet offers every tab and every card", (tester) async {
await pump(tester);
await tester.tap(find.byIcon(Icons.swap_vert));
await tester.pumpAndSettle();
expect(find.text(t("charging.arrange.title")), findsOneWidget);
for (final key in kChargerTabKeys) {
expect(find.text(t("charging.tabs.$key")), findsWidgets,
reason: "the $key tab is not on offer");
}
// Named by their own headings, so the sheet reads like the page.
const cardLabels = {
"control": "charging.control.title",
"connection": "charging.control.connectionTitle",
"readings": "charging.modbus.title",
"info": "charging.info.title",
};
expect(cardLabels.keys.toSet(), kChargerCardKeys.toSet());
for (final key in kChargerCardKeys) {
expect(find.text(t(cardLabels[key]!)), findsWidgets,
reason: "the $key card is not on offer");
}
// One drag handle per row: both lists rearrange, not just the first.
expect(find.byIcon(Icons.drag_handle),
findsNWidgets(kChargerTabKeys.length + kChargerCardKeys.length));
});
}
+46
View File
@@ -17,6 +17,7 @@ import "package:drivervault_phone/main.dart";
import "package:drivervault_phone/models.dart";
import "package:drivervault_phone/screens/car_form_sheet.dart";
import "package:drivervault_phone/screens/car_view_sheet.dart";
import "package:drivervault_phone/screens/charging_screen.dart";
import "package:drivervault_phone/service_parts.dart";
void main() {
@@ -498,6 +499,51 @@ void main() {
expectLabelled("car.provider.metrics.$key");
}
});
test("the charging page's tabs and cards", () {
for (final key in kChargerTabKeys) {
expectLabelled("charging.tabs.$key");
}
// The cards are named by their own headings rather than by their keys, so
// the arrange sheet reads like the page. Those headings are the labels.
for (final key in ["charging.control.title", "charging.control.connectionTitle",
"charging.modbus.title", "charging.info.title"]) {
expectLabelled(key);
}
});
test("everything the charger information card names", () {
// Every row is drawn every time, so a missing label is a raw key path in
// the middle of the card rather than a row that quietly stays away.
for (final key in ["vendor", "model", "firmware", "serial", "site", "siteId", "sources",
"power", "connector", "state", "chargePower", "ocpp", "providerId",
"added", "online", "offline", "refresh", "title", "empty"]) {
expectLabelled("charging.info.$key");
}
// The service's own word for how a charger is registered. Mirrors the
// sources a chargerSource reports in the API's chargerproviders.go; an
// unlisted one falls back to the slug, which is why these are named.
for (final key in ["standalone", "site", "bound"]) {
expectLabelled("charging.info.sourceNames.$key");
}
});
test("every Modbus reading the card can show", () {
// Mirrors the fields of ModbusStatus in the API's ankersolix/modbus.go
// that the readings card asks for by name. The enum values below it are
// deliberately not here: an unlisted number falls back to itself, because
// the charger may report one this release has never heard of.
for (final key in ["title", "phases", "live", "settings", "device", "alarms", "phase",
"voltage", "current", "activePower", "reactivePower", "apparentPower",
"lineToLine", "power", "sessionDuration", "cpSignal", "cpVoltage",
"phaseMode", "relayTemps", "pwm", "maxCurrentSet", "timeout",
"phaseSetting", "boostSet", "lastCommand", "chargingMode",
"loadBalancing", "solarBalancing", "ledBrightness", "model", "serial",
"firmware", "hardware", "productNumber", "ratedPower", "currentRange",
"ocppLink", "mqttLink", "alarmsHint"]) {
expectLabelled("charging.modbus.$key");
}
});
});
test("no key is left behind in a translation", () {