From 641f427db43dcdde175dd10b8e554f4ab1e9674f Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:27:16 +0200 Subject: [PATCH] The chargers you own, on the phone as well MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Phone App/README.md | 17 +- Phone App/assets/i18n/da.json | 155 +- Phone App/assets/i18n/en.json | 137 +- Phone App/assets/i18n/pl.json | 161 +- Phone App/lib/api.dart | 85 +- Phone App/lib/models.dart | 308 ++- .../lib/screens/charger_import_sheet.dart | 376 ++++ Phone App/lib/screens/charging_screen.dart | 1849 ++++++++++++++--- Phone App/lib/screens/settings_screen.dart | 66 +- Phone App/test/charging_screen_test.dart | 100 + Phone App/test/models_format_test.dart | 46 + 11 files changed, 2961 insertions(+), 339 deletions(-) create mode 100644 Phone App/lib/screens/charger_import_sheet.dart create mode 100644 Phone App/test/charging_screen_test.dart diff --git a/Phone App/README.md b/Phone App/README.md index 6f4c396..5ea7d0c 100644 --- a/Phone App/README.md +++ b/Phone App/README.md @@ -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 ``` diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index 185caa0..4ce2604 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -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": { diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json index 5f5fbb6..92ff3c6 100644 --- a/Phone App/assets/i18n/en.json +++ b/Phone App/assets/i18n/en.json @@ -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": { diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index 074b44b..53ad77f 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -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": { diff --git a/Phone App/lib/api.dart b/Phone App/lib/api.dart index b089acf..730d502 100644 --- a/Phone App/lib/api.dart +++ b/Phone App/lib/api.dart @@ -661,11 +661,17 @@ class ApiClient { return IntegrationHealth.fromJson(Map.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 getAnkerControl(String sn) async { @@ -683,8 +689,77 @@ class ApiClient { Future 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 ankerControlAddress(String sn, String host, int port) => _send( + "PUT", + "/integrations/anker-solix/chargers/${_sn(sn)}/control/address", + body: {"host": host, "port": port}, + ); + + Future 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 ankerControlAction(String sn, String action, [Map 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> listChargerProviders() async { + final data = await _send("GET", "/charger-providers"); + final items = (data["providers"] ?? []) as List; + return items.map((e) => ChargerProvider.fromJson(Map.from(e))).toList(); + } + + /// The chargers on that account, each annotated with the DriverVault charger + /// it is already linked to. + Future 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.from(data)); + } + + /// Creates a home charger from one on the account. An empty [name] takes + /// whatever the service calls it. + Future 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.from(c)); + } + + Future> listHomeChargers() async { + final data = await _send("GET", "/home-chargers"); + final items = (data["chargers"] ?? []) as List; + return items.map((e) => HomeCharger.fromJson(Map.from(e))).toList(); + } + + /// Only the name is editable — everything else describes the hardware and + /// comes from the service the charger was imported from. + Future 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.from(c)); + } + + Future deleteHomeCharger(String id) => + _send("DELETE", "/home-chargers/${Uri.encodeComponent(id)}"); + static String _asString(dynamic v) => v == null ? "" : v.toString(); } diff --git a/Phone App/lib/models.dart b/Phone App/lib/models.dart index 626b318..7bca7e2 100644 --- a/Phone App/lib/models.dart +++ b/Phone App/lib/models.dart @@ -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 chargerTabOrder; + final List 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 j) { final status = j["status"]; - final s = status is Map ? Map.from(status) : const {}; + final s = status is Map ? Map.from(status) : const {}; + 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.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 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 get settings { + final v = raw["settings"]; + return v is Map ? Map.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 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 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 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 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 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 chargers; + final bool unavailable; + final String detail; + + const ProviderChargerList({ + this.chargers = const [], + this.unavailable = false, + this.detail = "", + }); + + factory ProviderChargerList.fromJson(Map j) { + final raw = j["chargers"]; + return ProviderChargerList( + chargers: raw is List + ? raw + .whereType() + .map((c) => ProviderCharger.fromJson(Map.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). diff --git a/Phone App/lib/screens/charger_import_sheet.dart b/Phone App/lib/screens/charger_import_sheet.dart new file mode 100644 index 0000000..807da1e --- /dev/null +++ b/Phone App/lib/screens/charger_import_sheet.dart @@ -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 showChargerImportSheet(BuildContext context) { + return showModalBottomSheet( + 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 _providers = const []; + String _provider = ""; + List _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 _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 _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 _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 _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( + 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)), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/Phone App/lib/screens/charging_screen.dart b/Phone App/lib/screens/charging_screen.dart index 9edcab8..2862261 100644 --- a/Phone App/lib/screens/charging_screen.dart +++ b/Phone App/lib/screens/charging_screen.dart @@ -1,26 +1,51 @@ import "package:flutter/material.dart"; +import "package:shared_preferences/shared_preferences.dart"; +import "../format.dart"; import "../i18n.dart"; import "../main.dart"; import "../models.dart"; import "../theme.dart"; +import "car_view_sheet.dart" show arrangeKeys; +import "charger_import_sheet.dart"; /// Charging & map screen, mirroring the web Charging.vue. Two tabs split the /// public charging network (a presentational discovery map + demo session + -/// nearby public stations) from the user's own home charger(s) and their real -/// OCPP control. +/// nearby public stations) from the user's own chargers and their real control. /// /// There is no live public-charging API yet — the session and station lists are -/// placeholders, exactly as on the web. The one real piece is the OCPP control -/// card on the Home tab, which drives a charger through the Anker Solix control -/// endpoints once the user picks Own/Proxy CSMS in Settings → Integrations. +/// placeholders, exactly as on the web. The home tab is not: those are chargers +/// the user imported from a service they connected, and the cards beside them +/// drive a real charger through the Anker Solix control endpoints once a control +/// mode is picked in Settings → Integrations. + +/// The tabs this page can show, in the order they appear when the account has no +/// arrangement of its own. Mirrors ALL_CHARGER_TABS in the web Charging.vue; the +/// server rejects nothing here, but a key outside this set is dropped on read. +const List kChargerTabKeys = ["public", "home"]; + +/// The home tab's cards, in their default order. Controls first, because acting +/// on the charger is what the page is opened for; the connection below it, +/// because it is touched once and then left alone. +const List kChargerCardKeys = ["control", "connection", "readings", "info"]; + +/// Which cards are folded away, kept on the device rather than on the profile: +/// it is a per-device reading habit, not an account setting. Keyed by card, so a +/// fold outlives switching charger. +const String _kCollapsedKey = "cc_chargingCollapsed"; + +/// The serial the control cards are pointed at. Remembered so the page comes +/// back up on the charger it was left on, the way the web does. +const String _kSerialKey = "cc_ctlSerial"; + class ChargingScreen extends StatefulWidget { const ChargingScreen({super.key}); @override State createState() => _ChargingScreenState(); } -/// A charging station, tone-coded by availability. Presentational demo data. +/// A public charging station, tone-coded by availability. Presentational demo +/// data — there is no public-network API to fill it from yet. class _Station { final String id; final String name; @@ -31,26 +56,79 @@ class _Station { final int total; final String price; final String tone; // "good" | "due" | "fault" - final bool home; const _Station(this.id, this.name, this.dist, this.kw, this.conn, this.avail, - this.total, this.price, this.tone, - {this.home = false}); + this.total, this.price, this.tone); } class _ChargingScreenState extends State { - int _tab = 0; // 0 = public, 1 = home - String _selected = "sc"; + /// The tab bar's arrangement, and the home cards'. Both live on the profile — + /// they are layout choices that should follow the account, the way the garage + /// order does — so they arrive with /me and are written back with it. + List _tabKeys = List.of(kChargerTabKeys); + List _cardKeys = List.of(kChargerCardKeys); + String? _orderError; - List<_Station> get _stations => [ - const _Station("sc", "DriverVault Supercharge", "0.4 km", 250, "CCS · NACS", 6, 8, "0,34 €", "good"), - const _Station("evgo", "EVgo · Market St", "1.2 km", 150, "CCS", 2, 6, "0,41 €", "due"), - const _Station("cp", "ChargePoint Garage", "2.1 km", 62, "J1772", 0, 4, "0,29 €", "fault"), - _Station("home", t("charging.stations.homeCharger"), "—", 11, "NACS", 1, 1, - t("charging.stations.offPeak"), "good", home: true), - ]; + int _tab = 0; + String _selectedStation = "sc"; - List<_Station> get _publicStations => _stations.where((s) => !s.home).toList(); - List<_Station> get _homeStations => _stations.where((s) => s.home).toList(); + static const List<_Station> _stations = [ + _Station("sc", "DriverVault Supercharge", "0.4 km", 250, "CCS · NACS", 6, 8, "0,34 €", "good"), + _Station("evgo", "EVgo · Market St", "1.2 km", 150, "CCS", 2, 6, "0,41 €", "due"), + _Station("cp", "ChargePoint Garage", "2.1 km", 62, "J1772", 0, 4, "0,29 €", "fault"), + ]; + + @override + void initState() { + super.initState(); + _loadArrangement(); + } + + /// The arrangement arrives with the profile. A failure is silent: the page is + /// perfectly usable in its default order, and an error banner about it would + /// be about nothing the user asked for. + Future _loadArrangement() async { + try { + final me = await apiClient.getMe(); + if (!mounted) return; + setState(() { + _tabKeys = arrangeKeys(kChargerTabKeys, me.chargerTabOrder); + _cardKeys = arrangeKeys(kChargerCardKeys, me.chargerCardOrder); + }); + } catch (_) { + // Keep the defaults. + } + } + + Future _arrange() async { + final result = await showChargingArrangeSheet( + context, + tabs: _tabKeys, + cards: _cardKeys, + ); + if (result == null || !mounted) return; + + // The tab in view is followed through the rearrangement rather than left at + // its index: moving a tab should not switch which one you are reading. + final current = _tabKeys[_tab.clamp(0, _tabKeys.length - 1)]; + setState(() { + _tabKeys = result.tabs; + _cardKeys = result.cards; + _tab = result.tabs.indexOf(current).clamp(0, result.tabs.length - 1); + _orderError = null; + }); + try { + await apiClient.updateMe({ + "chargerTabOrder": result.tabs, + "chargerCardOrder": result.cards, + }); + } catch (e) { + // It didn't stick; say so and put the stored arrangement back rather than + // leaving the page showing an order the server does not have. + if (!mounted) return; + setState(() => _orderError = "$e"); + _loadArrangement(); + } + } Color _toneColor(String tone) { switch (tone) { @@ -67,11 +145,37 @@ class _ChargingScreenState extends State { ? t("charging.stations.full") : t("charging.stations.free", params: {"avail": s.avail, "total": s.total}); + Widget _tabBody(String key, {required bool active}) { + switch (key) { + case "home": + return _HomeTab(key: const ValueKey("home"), cardKeys: _cardKeys, active: active); + default: + return _PublicTab( + key: const ValueKey("public"), + stations: _stations, + selected: _selectedStation, + onSelect: (id) => setState(() => _selectedStation = id), + toneColor: _toneColor, + statusFor: _stationStatus, + ); + } + } + @override Widget build(BuildContext context) { final muted = DriverVault.muted(context); + final index = _tab.clamp(0, _tabKeys.length - 1); return Scaffold( - appBar: AppBar(title: Text(t("charging.title"))), + appBar: AppBar( + title: Text(t("charging.title")), + actions: [ + IconButton( + icon: const Icon(Icons.swap_vert), + tooltip: t("charging.arrange.title"), + onPressed: _arrange, + ), + ], + ), body: ListView( padding: const EdgeInsets.all(12), children: [ @@ -84,32 +188,28 @@ class _ChargingScreenState extends State { style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.5)), const SizedBox(height: 16), - // Tabs: public network vs. the user's own home charger(s). + if (_orderError != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text(_orderError!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)), + ), + + // Tabs: public network vs. the user's own chargers. _TabBar( - tabs: [t("charging.tabs.public"), t("charging.tabs.home")], - index: _tab, + tabs: [for (final key in _tabKeys) t("charging.tabs.$key")], + index: index, onChanged: (i) => setState(() => _tab = i), ), const SizedBox(height: 16), // IndexedStack keeps each tab's state alive across switches (v-show parity). + // Keyed, so rearranging the bar moves the tabs rather than rebuilding + // whichever widget now sits at that index from scratch. IndexedStack( - index: _tab, + index: index, children: [ - _PublicTab( - stations: _publicStations, - selected: _selected, - onSelect: (id) => setState(() => _selected = id), - toneColor: _toneColor, - statusFor: _stationStatus, - ), - _HomeTab( - stations: _homeStations, - selected: _selected, - onSelect: (id) => setState(() => _selected = id), - toneColor: _toneColor, - statusFor: _stationStatus, - ), + for (var i = 0; i < _tabKeys.length; i++) + _tabBody(_tabKeys[i], active: i == index), ], ), const SizedBox(height: 24), @@ -119,6 +219,136 @@ class _ChargingScreenState extends State { } } +// --- Arranging the page ------------------------------------------------------ + +/// What the arrange sheet returns: the two arrangements, saved together. +class ChargingArrangement { + final List tabs; + final List cards; + const ChargingArrangement(this.tabs, this.cards); +} + +/// Rearranges the charging tabs and the home tab's cards. +/// +/// The web drags the tab bar and the card headings themselves; on a touch screen +/// the tab bar owns that gesture and a card heading is a fold toggle, so both +/// arrangements are made here instead, with a handle — exactly the trade the +/// car's view picker makes. +Future showChargingArrangeSheet( + BuildContext context, { + required List tabs, + required List cards, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _ChargingArrangeSheet(tabs: tabs, cards: cards), + ); +} + +class _ChargingArrangeSheet extends StatefulWidget { + final List tabs; + final List cards; + const _ChargingArrangeSheet({required this.tabs, required this.cards}); + @override + State<_ChargingArrangeSheet> createState() => _ChargingArrangeSheetState(); +} + +class _ChargingArrangeSheetState extends State<_ChargingArrangeSheet> { + late final List _tabs = List.of(widget.tabs); + late final List _cards = List.of(widget.cards); + + /// The card's own heading, so the sheet names each row the way the page does. + String _cardLabel(String key) => switch (key) { + "control" => t("charging.control.title"), + "connection" => t("charging.control.connectionTitle"), + "readings" => t("charging.modbus.title"), + _ => t("charging.info.title"), + }; + + @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("charging.arrange.title"), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Text(t("car.viewPicker.reorderHint"), style: TextStyle(fontSize: 12, color: muted)), + const SizedBox(height: 16), + _section(t("charging.arrange.tabs")), + _reorder(_tabs, (k) => t("charging.tabs.$k"), + (from, to) => setState(() => _tabs.insert(to, _tabs.removeAt(from)))), + const SizedBox(height: 16), + _section(t("charging.arrange.cards")), + _reorder(_cards, _cardLabel, + (from, to) => setState(() => _cards.insert(to, _cards.removeAt(from)))), + const SizedBox(height: 16), + Row(children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + child: Text(t("common.cancel")), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton( + onPressed: () => Navigator.pop(context, ChargingArrangement(_tabs, _cards)), + child: Text(t("common.save")), + ), + ), + ]), + ], + ), + ), + ); + } + + Widget _section(String label) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + ); + + /// A short reorderable list of labelled rows, each dragged by its own handle. + /// shrinkWrap because the sheet scrolls, not the list. + Widget _reorder(List keys, String Function(String) label, + void Function(int, int) onMove) { + return ReorderableListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + onReorderItem: onMove, + children: [ + for (var i = 0; i < keys.length; i++) + ListTile( + key: ValueKey(keys[i]), + dense: true, + contentPadding: EdgeInsets.zero, + title: Text(label(keys[i]), style: const TextStyle(fontSize: 14)), + trailing: ReorderableDragStartListener( + index: i, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(Icons.drag_handle, color: DriverVault.muted(context)), + ), + ), + ), + ], + ); + } +} + // --- Shared: underline tab bar (matches Settings tabs) ---------------------- class _TabBar extends StatelessWidget { @@ -186,7 +416,68 @@ class _Card extends StatelessWidget { } } -/// The green/amber/red "Connected"/"Not connected" style pill. +/// A card whose heading folds it away. The column runs long once the readings +/// are in it, and which cards are folded is remembered per device. +class _FoldCard extends StatelessWidget { + final String title; + final bool open; + final VoidCallback onToggle; + + /// Rides in the header, so it is still readable with the card folded — the + /// connection badge is the one thing worth seeing either way. + final Widget? badge; + + /// Sits beside the heading rather than inside the fold, so it can be pressed + /// without unfolding anything. + final Widget? action; + + final List children; + + const _FoldCard({ + required this.title, + required this.open, + required this.onToggle, + required this.children, + this.badge, + this.action, + }); + + @override + Widget build(BuildContext context) { + return _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onToggle, + child: Row( + children: [ + Expanded( + child: Text(title, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + ), + if (badge != null) ...[badge!, const SizedBox(width: 8)], + Icon(open ? Icons.expand_more : Icons.chevron_right, + size: 20, color: DriverVault.muted(context)), + ], + ), + ), + ), + if (action != null) action!, + ], + ), + if (open) ...children, + ], + ), + ); + } +} + +/// The green/amber "Connected"/"Not connected" style pill. class _StatusBadge extends StatelessWidget { final String label; final bool ok; @@ -207,6 +498,30 @@ class _StatusBadge extends StatelessWidget { } } +/// A neutral pill, for a fact rather than a state — which service a charger came +/// from, say. +class _NeutralBadge extends StatelessWidget { + final String label; + const _NeutralBadge({required this.label}); + + @override + Widget build(BuildContext context) { + final dark = DriverVault.isDark(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: dark ? DriverVault.darkSunken : DriverVault.ink50, + borderRadius: BorderRadius.circular(999), + ), + child: Text(label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: DriverVault.muted(context))), + ); + } +} + // --- A station row, shared by both station lists ----------------------------- class _StationTile extends StatelessWidget { @@ -275,7 +590,7 @@ class _StationTile extends StatelessWidget { } } -/// A section wrapper with an eyebrow header and a list of stations. +/// A section wrapper with an eyebrow header and a list of public stations. class _StationList extends StatelessWidget { final String heading; final List<_Station> stations; @@ -331,6 +646,7 @@ class _PublicTab extends StatefulWidget { final Color Function(String) toneColor; final String Function(_Station) statusFor; const _PublicTab({ + super.key, required this.stations, required this.selected, required this.onSelect, @@ -660,62 +976,92 @@ class _SessionCard extends StatelessWidget { } } -// --- Home tab: real OCPP control + home station list ------------------------- +// --- Home tab: the user's own chargers + real control ------------------------ -class _HomeTab extends StatelessWidget { - final List<_Station> stations; - final String selected; - final ValueChanged onSelect; - final Color Function(String) toneColor; - final String Function(_Station) statusFor; - const _HomeTab({ - required this.stations, - required this.selected, - required this.onSelect, - required this.toneColor, - required this.statusFor, - }); +/// The home half of the page: the cards that act on one charger, in whatever +/// order the account arranged them, and below them the list of chargers the user +/// has imported. +/// +/// One State for the lot, mirroring the web view: the cards all read the same +/// charger, and picking one in the list is what points the control cards at it. +class _HomeTab extends StatefulWidget { + final List cardKeys; + /// Whether this is the tab being read. Reachability costs a round trip to each + /// connected service, so it is asked for when the question is actually being + /// asked — not because an IndexedStack built both tabs. + final bool active; + + const _HomeTab({super.key, required this.cardKeys, required this.active}); @override - Widget build(BuildContext context) { - return Column( - children: [ - const _ControlCard(), - const SizedBox(height: 16), - _StationList( - heading: t("charging.stations.homeHeading"), - stations: stations, - selected: selected, - onSelect: onSelect, - toneColor: toneColor, - statusFor: statusFor, - ), - ], - ); - } + State<_HomeTab> createState() => _HomeTabState(); } -/// The real OCPP control card, gated by the per-user Anker Solix control mode. -/// When no control mode is active it degrades to a hint pointing at Settings. -class _ControlCard extends StatefulWidget { - const _ControlCard(); - @override - State<_ControlCard> createState() => _ControlCardState(); -} - -class _ControlCardState extends State<_ControlCard> { +class _HomeTabState extends State<_HomeTab> { + // --- control --- final _serial = TextEditingController(); - List _chargers = const []; // the account's chargers, when known - String _mode = "off"; // effective control mode (off | own | proxy) + final _host = TextEditingController(); + final _port = TextEditingController(text: "502"); + final _resetPassword = TextEditingController(); + + /// The chargers on the linked Anker account. With them the serial is a pick + /// from a list; without them (account not linked, or the cloud unreachable) + /// the field stays a plain text box so a serial can still be typed by hand. + List _accountChargers = const []; + + /// Typing the serial rather than picking it. A dropdown can only show a serial + /// it has an option for, so a remembered serial the account does not report — + /// a charger imported before the account was linked, one the cloud is quiet + /// about today — would render as a blank field with no way to read or fix it. + /// Falling back to the text box shows the serial that is actually in force. + bool _manualSerial = false; + + String _mode = "off"; // effective control mode (off | own | proxy | modbus) AnkerControl? _ctl; - String? _error; + String? _ctlError; String _busy = ""; // action name currently in flight double _limitAmps = 16; bool _resetPrompt = false; - final _resetPassword = TextEditingController(); + bool _savingAddress = false; + + // --- the user's own chargers --- + List _homeChargers = const []; + String? _homeError; + + /// The charger services this user could import from. Importing only makes + /// sense once one is connected, so the button appears only then — the same + /// rule the garage's import follows — and the list doubles as the id → label + /// map the information card names a charger's origin with. + List _providers = const []; + + /// Live reachability, keyed by the provider's own id for the charger. The + /// record says what a charger is; only the service it came from knows whether + /// it is reachable right now, so that half is asked for separately and held + /// beside the records rather than in them. + Map _live = const {}; + bool _liveLoading = false; + bool _liveLoaded = false; + + String _selected = ""; // the charger the cards are about + String _removing = ""; + + /// Which cards are folded, read once and written on every toggle. + Set _collapsed = {}; bool get _active => _mode != "off"; bool get _connected => _ctl?.connected == true; + bool get _isModbus => _mode == "modbus"; + bool get _canImport => _providers.any((p) => p.connected); + + bool get _serialInList => + _accountChargers.any((c) => c.sn == _serial.text.trim()); + + /// A dropdown can only show a serial it has an option for, so one the account + /// does not report — a charger picked from the list below, imported before the + /// account was linked — falls back to the text box rather than rendering as a + /// blank field with no way to read or fix it. + bool get _pickingFromList => + _accountChargers.isNotEmpty && !_manualSerial && (_serial.text.trim().isEmpty || _serialInList); @override void initState() { @@ -723,75 +1069,181 @@ class _ControlCardState extends State<_ControlCard> { _init(); } + @override + void didUpdateWidget(_HomeTab old) { + super.didUpdateWidget(old); + // Switching to this tab is the moment reachability is being asked about. + if (widget.active && !old.active) _loadLive(); + } + @override void dispose() { _serial.dispose(); + _host.dispose(); + _port.dispose(); _resetPassword.dispose(); super.dispose(); } Future _init() async { + final prefs = await SharedPreferences.getInstance(); + if (!mounted) return; + _serial.text = prefs.getString(_kSerialKey) ?? ""; + setState(() => _collapsed = (prefs.getStringList(_kCollapsedKey) ?? const []).toSet()); + + await _loadHomeChargers(); + await _loadProviders(); + if (widget.active) await _loadLive(); + await _loadMode(); + if (_active) await _loadAccountChargers(); + await _refresh(); + } + + // --- folding --- + + bool _isOpen(String id) => !_collapsed.contains(id); + + Future _toggleCard(String id) async { + setState(() { + if (_collapsed.contains(id)) { + _collapsed.remove(id); + } else { + _collapsed.add(id); + } + }); + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_kCollapsedKey, _collapsed.toList()); + } + + // --- loading --- + + Future _loadMode() async { try { final v = await apiClient.getAnkerSolix(); if (mounted) setState(() => _mode = v.controlMode); } catch (_) { if (mounted) setState(() => _mode = "off"); } - if (_active) await _loadChargers(); - await _refresh(); } - /// The account's chargers turn the serial into a pick from a list. Without - /// them (nothing linked, or the cloud unreachable) the field stays a text box - /// so a serial can still be typed in by hand. - Future _loadChargers() async { + Future _loadAccountChargers() async { try { final res = await apiClient.listAnkerChargers(); if (!mounted) return; setState(() { - _chargers = res.chargers; + _accountChargers = res.chargers; // Nothing chosen yet: start on the first charger the account reports. - if (_serial.text.trim().isEmpty && _chargers.isNotEmpty) { - _serial.text = _chargers.first.sn; + if (_serial.text.trim().isEmpty && _accountChargers.isNotEmpty) { + _serial.text = _accountChargers.first.sn; } + // Decided when the list arrives rather than on every keystroke: + // recomputing it as the serial is typed would swap the text box for a + // dropdown mid-word, the moment what had been typed happened to match. + _manualSerial = _accountChargers.isNotEmpty && !_serialInList; }); } catch (_) { - if (mounted) setState(() => _chargers = const []); + if (mounted) setState(() => _accountChargers = const []); } } + Future _loadHomeChargers() async { + try { + final list = await apiClient.listHomeChargers(); + if (!mounted) return; + setState(() { + _homeChargers = list; + _homeError = null; + if (_selected.isEmpty && list.isNotEmpty) _selected = list.first.id; + }); + } catch (e) { + if (mounted) setState(() => _homeError = "$e"); + } + } + + Future _loadProviders() async { + try { + final list = await apiClient.listChargerProviders(); + if (mounted) setState(() => _providers = list); + } catch (_) { + if (mounted) setState(() => _providers = const []); + } + } + + /// Asking costs a round trip to each connected service, so it happens when the + /// home tab is first built — the moment the question is being asked — and on + /// demand after that. + Future _loadLive({bool force = false}) async { + final connected = _providers.where((p) => p.connected).toList(); + if (_liveLoading || connected.isEmpty || !mounted) return; + if (_liveLoaded && !force) return; // switching tabs is not a new question + setState(() => _liveLoading = true); + final live = {}; + await Future.wait(connected.map((p) async { + try { + final res = await apiClient.listProviderChargers(p.id); + for (final c in res.chargers) { + live[c.id] = c; + } + } catch (_) { + // A service that will not answer leaves its chargers unknown rather + // than offline — this page cannot tell those two apart. + } + })); + if (!mounted) return; + setState(() { + _live = live; + _liveLoaded = true; + _liveLoading = false; + }); + } + Future _refresh() async { + if (!mounted) return; final sn = _serial.text.trim(); if (sn.isEmpty) { setState(() => _ctl = null); return; } - setState(() => _error = null); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kSerialKey, sn); + if (!mounted) return; + setState(() => _ctlError = null); try { final c = await apiClient.getAnkerControl(sn); - if (mounted) setState(() => _ctl = c); + if (!mounted) return; + setState(() { + _ctl = c; + // The saved address belongs to the charger, not to the form: switching + // chargers has to bring its own address along rather than leave the + // previous one sitting in the field, where saving would move it to the + // wrong charger. + _host.text = c.modbusHost; + _port.text = "${c.modbusPort}"; + }); } catch (e) { if (mounted) { setState(() { - _error = "$e"; + _ctlError = "$e"; _ctl = null; }); } } } + // --- acting on the charger --- + Future _action(String action, [Map body = const {}]) async { final sn = _serial.text.trim(); if (sn.isEmpty) return; setState(() { _busy = action; - _error = null; + _ctlError = null; }); try { await apiClient.ankerControlAction(sn, action, body); await _refresh(); } catch (e) { - if (mounted) setState(() => _error = "$e"); + if (mounted) setState(() => _ctlError = "$e"); } finally { if (mounted) setState(() => _busy = ""); } @@ -804,203 +1256,1062 @@ class _ControlCardState extends State<_ControlCard> { _resetPassword.clear(); } + /// One control for both directions, so a serial that is not on the account is + /// never a dead end and the list is never the only option. + void _toggleSerialEntry() { + final toList = !_pickingFromList; + setState(() => _manualSerial = !toList); + // Returning to a list that does not hold this serial would blank the + // dropdown again, which is the thing being fixed; land on one it does hold. + if (toList && !_serialInList && _accountChargers.isNotEmpty) { + _serial.text = _accountChargers.first.sn; + _refresh(); + } + } + + Future _saveAddress() async { + final sn = _serial.text.trim(); + final host = _host.text.trim(); + if (sn.isEmpty || host.isEmpty) return; + setState(() { + _savingAddress = true; + _ctlError = null; + }); + try { + await apiClient.ankerControlAddress(sn, host, int.tryParse(_port.text.trim()) ?? 502); + await _refresh(); + } catch (e) { + if (mounted) setState(() => _ctlError = "$e"); + } finally { + if (mounted) setState(() => _savingAddress = false); + } + } + + Future _forgetAddress() async { + final sn = _serial.text.trim(); + if (sn.isEmpty) return; + if (!await _confirm(t("charging.control.forgetAddressConfirm"))) return; + setState(() { + _savingAddress = true; + _ctlError = null; + }); + try { + await apiClient.ankerControlForgetAddress(sn); + await _refresh(); + } catch (e) { + if (mounted) setState(() => _ctlError = "$e"); + } finally { + if (mounted) setState(() => _savingAddress = false); + } + } + + Future _confirm(String message) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + content: Text(message), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), + onPressed: () => Navigator.pop(ctx, true), + child: Text(t("common.confirm")), + ), + ], + ), + ); + return ok == true; + } + + // --- the user's own chargers --- + + /// The live half for one imported charger, or null when the service it came + /// from says nothing about it (disconnected since the import, or a charger + /// added by hand). Both services we speak to id a charger by its serial, so + /// the serial is a sound fallback for a record imported before the provider + /// link was stored. + ProviderCharger? _liveFor(HomeCharger c) => + _live[c.providerChargerId] ?? _live[c.serial]; + + /// The card shows one charger: whichever is picked in the list below it. Until + /// something is picked that is the first one — a card that says nothing until + /// tapped would be a worse first impression than the charger most people have + /// only one of. + HomeCharger? get _selectedCharger { + for (final c in _homeChargers) { + if (c.id == _selected) return c; + } + return _homeChargers.isEmpty ? null : _homeChargers.first; + } + + /// A charger picked here becomes the one the control cards drive. + void _select(HomeCharger c) { + setState(() => _selected = c.id); + if (c.serial.isEmpty) return; + _serial.text = c.serial; + _refresh(); + } + + Future _import() async { + final charger = await showChargerImportSheet(context); + if (charger == null || !mounted) return; + setState(() => _homeChargers = [..._homeChargers, charger]); + _select(charger); + _loadLive(force: true); + } + + Future _remove(HomeCharger c) async { + if (!await _confirm(t("charging.home.removeConfirm", params: {"name": c.name}))) return; + setState(() { + _removing = c.id; + _homeError = null; + }); + try { + await apiClient.deleteHomeCharger(c.id); + if (mounted) { + setState(() => _homeChargers = _homeChargers.where((x) => x.id != c.id).toList()); + } + } catch (e) { + if (mounted) setState(() => _homeError = "$e"); + } finally { + if (mounted) setState(() => _removing = ""); + } + } + + String _providerLabel(String id) { + for (final p in _providers) { + if (p.id == id) return p.label; + } + return id; + } + + /// The cloud's own slug for what the charger is doing (charging, standby, …), + /// translated. The vocabulary is the integration's, so the wording lives with + /// it in Settings rather than being said twice. + String _stateLabel(String slug) { + if (slug.isEmpty) return ""; + final key = "settings.integrations.states.$slug"; + final label = t(key); + return label == key ? slug.replaceAll("_", " ") : label; + } + + /// How the charger is registered on the account, in the service's own terms. + String _sourcesLabel(List sources) => sources.map((src) { + final key = "charging.info.sourceNames.$src"; + final label = t(key); + return label == key ? src : label; + }).join(" · "); + @override Widget build(BuildContext context) { - final muted = DriverVault.muted(context); - if (!_active) { - // No control mode active — point the user at Settings to enable it. - return _Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(t("charging.control.title"), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), - const SizedBox(height: 8), - Text(t("charging.stations.noControlHint"), style: TextStyle(fontSize: 12, color: muted)), - ], - ), - ); - } + final cards = { + "control": _active && _connected ? _controlCard(context) : null, + "connection": _active ? _connectionCard(context) : null, + "readings": _isModbus && _connected ? _readingsCard(context) : null, + "info": _infoCard(context), + }; - return _Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text(t("charging.control.title"), - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), - ), - _StatusBadge( - label: _connected ? t("charging.control.connected") : t("charging.control.disconnected"), - ok: _connected, - ), - ], + return Column( + children: [ + for (final key in widget.cardKeys) + if (cards[key] != null) ...[cards[key]!, const SizedBox(height: 16)], + _chargerListCard(context), + ], + ); + } + + // --- the cards ------------------------------------------------------------- + + /// Acting on the charger. It appears only once there is a connection to act + /// over, which the card below it is where you set up. + Widget _controlCard(BuildContext context) { + final muted = DriverVault.muted(context); + return _FoldCard( + title: t("charging.control.title"), + open: _isOpen("control"), + onToggle: () => _toggleCard("control"), + children: [ + const SizedBox(height: 12), + Row(children: [ + Expanded(child: _MetricTile(value: _ctl!.statusLabel, label: t("charging.control.status"))), + const SizedBox(width: 8), + Expanded( + child: _MetricTile( + value: "${_ctl!.meterKwh.toStringAsFixed(2)} kWh", + label: t("charging.control.meter"), + ), ), - const SizedBox(height: 12), - Row(children: [ - Expanded( - child: _chargers.isNotEmpty - ? DropdownButtonFormField( - initialValue: _serial.text.trim().isEmpty ? null : _serial.text.trim(), - isExpanded: true, - decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true), - items: [ - for (final c in _chargers) - DropdownMenuItem( - value: c.sn, - child: Text(c.name.isNotEmpty ? "${c.name} · ${c.sn}" : c.sn, - overflow: TextOverflow.ellipsis), - ), - ], - onChanged: (sn) { - if (sn == null) return; - setState(() => _serial.text = sn); - _refresh(); - }, - ) - : TextField( - controller: _serial, - autocorrect: false, - decoration: InputDecoration( - border: const OutlineInputBorder(), - isDense: true, - hintText: t("charging.control.serialPlaceholder"), - ), - ), + ]), + const SizedBox(height: 12), + Row(children: [ + Expanded( + child: FilledButton( + onPressed: _busy == "start" ? null : () => _action("start"), + child: Text(t("charging.control.start")), ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + onPressed: _busy == "stop" ? null : () => _action("stop"), + child: Text(t("charging.control.stop")), + ), + ), + ]), + const SizedBox(height: 12), + Row(children: [ + Text(t("charging.control.limit"), style: TextStyle(fontSize: 13, color: muted)), + const Spacer(), + Text("${_limitAmps.round()} A", + style: DriverVault.mono(context, size: 13, weight: FontWeight.w500)), + ]), + Slider( + value: _limitAmps, + min: 6, + max: 32, + divisions: 26, + label: "${_limitAmps.round()} A", + onChanged: (v) => setState(() => _limitAmps = v), + ), + Row(children: [ + Expanded( + child: OutlinedButton( + onPressed: _busy == "limit" ? null : () => _action("limit", {"amps": _limitAmps.round()}), + child: Text(t("charging.control.applyLimit")), + ), + ), + // Clearing a limit is an OCPP command; the register map takes an + // explicit ceiling, so in Modbus mode there is nothing to clear to. + if (!_isModbus) ...[ const SizedBox(width: 8), - OutlinedButton(onPressed: _refresh, child: Text(t("charging.control.refresh"))), - ]), - if (_connected) ...[ - const SizedBox(height: 12), - Row(children: [ - Expanded(child: _MetricTile(value: _ctl?.connectorStatus.isNotEmpty == true ? _ctl!.connectorStatus : "—", label: t("charging.control.status"))), - const SizedBox(width: 8), - Expanded(child: _MetricTile(value: "${_ctl!.meterKwh.toStringAsFixed(2)} kWh", label: t("charging.control.meter"))), - ]), - const SizedBox(height: 12), - Row(children: [ - Expanded( - child: FilledButton( - onPressed: _busy == "start" ? null : () => _action("start"), - child: Text(t("charging.control.start")), - ), + Expanded( + child: OutlinedButton( + onPressed: _busy == "clear-limit" ? null : () => _action("clear-limit"), + child: Text(t("charging.control.clearLimit")), ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton( - onPressed: _busy == "stop" ? null : () => _action("stop"), - child: Text(t("charging.control.stop")), - ), - ), - ]), - const SizedBox(height: 12), - Row(children: [ - Text(t("charging.control.limit"), style: TextStyle(fontSize: 13, color: muted)), - const Spacer(), - Text("${_limitAmps.round()} A", style: DriverVault.mono(context, size: 13, weight: FontWeight.w500)), - ]), - Slider( - value: _limitAmps, - min: 6, - max: 32, - divisions: 26, - label: "${_limitAmps.round()} A", - onChanged: (v) => setState(() => _limitAmps = v), ), - Row(children: [ - Expanded( - child: OutlinedButton( - onPressed: _busy == "limit" ? null : () => _action("limit", {"amps": _limitAmps.round()}), - child: Text(t("charging.control.applyLimit")), - ), + ], + ]), + + // Boost is a Modbus command, and lasts for the current session only. + if (_isModbus) ...[ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: _busy == "boost" ? null : () => _action("boost", {"on": true}), + child: Text(t("charging.control.boost")), + ), + ), + ], + + // Reset reboots the charger over OCPP; the register map has no + // equivalent, so the button is not offered on the local path. + if (!_isModbus) ...[ + const SizedBox(height: 12), + if (!_resetPrompt) + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: _busy == "reset" + ? null + : () => setState(() { + _ctlError = null; + _resetPassword.clear(); + _resetPrompt = true; + }), + child: Text(t("charging.control.reset")), ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton( - onPressed: _busy == "clear-limit" ? null : () => _action("clear-limit"), - child: Text(t("charging.control.clearLimit")), - ), + ) + else + // Step-up: a destructive reset requires re-entering the password. + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: DriverVault.isDark(context) ? DriverVault.dangerSoftDark : DriverVault.dangerSoft, + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + border: Border.all(color: DriverVault.danger.withValues(alpha: 0.4)), ), - ]), - const SizedBox(height: 12), - if (!_resetPrompt) - SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: _busy == "reset" - ? null - : () => setState(() { - _error = null; - _resetPassword.clear(); - _resetPrompt = true; - }), - child: Text(t("charging.control.reset")), - ), - ) - else - // Step-up: destructive reset requires re-entering the password. - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: DriverVault.isDark(context) ? DriverVault.dangerSoftDark : DriverVault.dangerSoft, - borderRadius: BorderRadius.circular(DriverVault.radiusControl), - border: Border.all(color: DriverVault.danger.withValues(alpha: 0.4)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(t("charging.control.resetConfirm"), - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: DriverVault.danger)), - const SizedBox(height: 8), - TextField( - controller: _resetPassword, - obscureText: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t("charging.control.resetConfirm"), + style: const TextStyle( + fontSize: 12, fontWeight: FontWeight.w500, color: DriverVault.danger)), + const SizedBox(height: 8), + TextField( + controller: _resetPassword, + obscureText: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + isDense: true, + hintText: t("charging.control.resetPassword"), + ), + onSubmitted: (_) => _confirmReset(), + ), + const SizedBox(height: 8), + Row(children: [ + Expanded( + child: OutlinedButton( + onPressed: () => setState(() { + _resetPrompt = false; + _resetPassword.clear(); + }), + child: Text(t("common.cancel")), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton( + style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), + onPressed: _busy == "reset" ? null : _confirmReset, + child: Text(t("charging.control.reset")), + ), + ), + ]), + ], + ), + ), + ], + ], + ); + } + + /// Which charger, and how to reach it. Below the controls: it is touched once + /// and then left alone. + Widget _connectionCard(BuildContext context) { + final muted = DriverVault.muted(context); + final sn = _serial.text.trim(); + return _FoldCard( + title: t("charging.control.connectionTitle"), + open: _isOpen("connection"), + onToggle: () => _toggleCard("connection"), + // Whether the charger is reachable is the one thing worth seeing with the + // card folded, so the badge rides in the header. + badge: _StatusBadge( + label: _connected ? t("charging.control.connected") : t("charging.control.disconnected"), + ok: _connected, + ), + children: [ + const SizedBox(height: 12), + Row(children: [ + Expanded( + child: _pickingFromList + ? DropdownButtonFormField( + // initialValue is read once, so the field is rebuilt when the + // serial changes from elsewhere — picking a charger below. + key: ValueKey(sn), + initialValue: _serialInList ? sn : null, + isExpanded: true, + decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true), + items: [ + for (final c in _accountChargers) + DropdownMenuItem( + value: c.sn, + child: Text(c.name.isNotEmpty ? "${c.name} · ${c.sn}" : c.sn, + overflow: TextOverflow.ellipsis), + ), + ], + onChanged: (picked) { + if (picked == null) return; + setState(() => _serial.text = picked); + _refresh(); + }, + ) + : TextField( + controller: _serial, + autocorrect: false, + decoration: InputDecoration( + border: const OutlineInputBorder(), + isDense: true, + hintText: t("charging.control.serialPlaceholder"), + ), + onSubmitted: (_) => _refresh(), + ), + ), + const SizedBox(width: 8), + OutlinedButton(onPressed: _refresh, child: Text(t("charging.control.refresh"))), + ]), + + // Only worth offering when there is a list to switch to or from. + if (_accountChargers.isNotEmpty) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: _toggleSerialEntry, + child: Text( + _pickingFromList + ? t("charging.control.enterSerial") + : t("charging.control.pickSerial"), + style: TextStyle(fontSize: 12, color: muted), + ), + ), + ), + + // Modbus mode dials the charger, so it needs the charger's address on + // this network rather than a token installed into the charger. + if (_isModbus) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50, + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t("charging.control.address"), + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Row(children: [ + Expanded( + child: TextField( + controller: _host, + autocorrect: false, + keyboardType: TextInputType.url, decoration: InputDecoration( border: const OutlineInputBorder(), isDense: true, - hintText: t("charging.control.resetPassword"), + hintText: t("charging.control.addressPlaceholder"), ), - onSubmitted: (_) => _confirmReset(), + onChanged: (_) => setState(() {}), + onSubmitted: (_) => _saveAddress(), ), - const SizedBox(height: 8), - Row(children: [ - Expanded( - child: OutlinedButton( - onPressed: () => setState(() { - _resetPrompt = false; - _resetPassword.clear(); - }), - child: Text(t("common.cancel")), - ), - ), - const SizedBox(width: 8), - Expanded( - child: FilledButton( - style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), - onPressed: _busy == "reset" ? null : _confirmReset, - child: Text(t("charging.control.reset")), - ), + ), + const SizedBox(width: 8), + SizedBox( + width: 92, + child: TextField( + controller: _port, + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: const OutlineInputBorder(), + isDense: true, + labelText: t("charging.control.addressPort"), ), + onChanged: (_) => setState(() {}), + ), + ), + ]), + const SizedBox(height: 6), + Text(t("charging.control.addressHint"), + style: TextStyle(fontSize: 11, color: muted)), + const SizedBox(height: 8), + Row(children: [ + Expanded( + child: FilledButton( + onPressed: _host.text.trim().isEmpty || !_addressChanged || _savingAddress + ? null + : _saveAddress, + child: Text(t("charging.control.saveAddress")), + ), + ), + if ((_ctl?.modbusHost ?? "").isNotEmpty) ...[ + const SizedBox(width: 8), + OutlinedButton( + onPressed: _savingAddress ? null : _forgetAddress, + child: Text(t("charging.control.forgetAddress")), + ), + ], + ]), + ], + ), + ), + ], + + // Why there is nothing to control yet. In Modbus mode the server has + // already tried to reach the charger and says what it found, which beats + // a generic hint. + if (!_connected) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + (_ctl?.detail.isNotEmpty ?? false) + ? _ctl!.detail + : t(_isModbus ? "charging.control.modbusHint" : "charging.control.connectHint"), + style: TextStyle(fontSize: 12, color: muted), + ), + ), + if (_ctlError != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(_ctlError!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)), + ), + ], + ); + } + + /// Saving is only worth offering when there is something to save that is not + /// already saved, so the button goes quiet once the form matches the server. + bool get _addressChanged => + _host.text.trim() != (_ctl?.modbusHost ?? "") || + (int.tryParse(_port.text.trim()) ?? 502) != (_ctl?.modbusPort ?? 502); + + /// What the charger reports over Modbus. Its own card rather than a tail on + /// the control one: control is for acting on the charger, and this is a long + /// read that pushed the buttons off the screen. + Widget _readingsCard(BuildContext context) { + final s = _ctl?.modbus; + return _FoldCard( + title: t("charging.modbus.title"), + open: _isOpen("readings"), + onToggle: () => _toggleCard("readings"), + children: [ + const SizedBox(height: 12), + if (s != null) ..._readingSections(context, s), + ], + ); + } + + List _readingSections(BuildContext context, ModbusStatus s) { + final phases = _phaseRows(s); + final live = _liveRows(s); + final settings = _settingRows(s); + final device = _deviceRows(s); + final alarms = _alarmWords(s); + + return [ + if (phases.isNotEmpty) ...[ + _ReadingSection( + heading: t("charging.modbus.phases"), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Three phases against five measurements is a matrix, not a list; + // a table says that. It scrolls sideways rather than wrapping, + // which would break the columns apart. + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + children: [ + TableRow(children: [ + _th(context, t("charging.modbus.phase"), left: true), + _th(context, t("charging.modbus.voltage")), + _th(context, t("charging.modbus.current")), + _th(context, t("charging.modbus.activePower")), + _th(context, t("charging.modbus.reactivePower")), + _th(context, t("charging.modbus.apparentPower")), ]), + for (final row in phases) + TableRow(children: [ + _td(context, row[0], left: true), + for (var i = 1; i < row.length; i++) _td(context, row[i]), + ]), ], ), ), - ] else - Padding( - padding: const EdgeInsets.only(top: 12), - child: Text(t("charging.control.connectHint"), style: TextStyle(fontSize: 12, color: muted)), + if (_lineVoltages(s).isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + "${t("charging.modbus.lineToLine")}: ${_lineVoltages(s).join(" · ")}", + style: DriverVault.mono(context, size: 11, color: DriverVault.muted(context)), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + ], + if (live.isNotEmpty) ...[ + _ReadingSection(heading: t("charging.modbus.live"), child: _PairList(rows: live)), + const SizedBox(height: 8), + ], + if (settings.isNotEmpty) ...[ + _ReadingSection(heading: t("charging.modbus.settings"), child: _PairList(rows: settings)), + const SizedBox(height: 8), + ], + if (device.isNotEmpty) ...[ + _ReadingSection(heading: t("charging.modbus.device"), child: _PairList(rows: device)), + const SizedBox(height: 8), + ], + // Alarms, when any word is non-zero. Which register is set is reportable + // even though the bit list is not published. + if (alarms.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), + border: Border.all(color: DriverVault.warning.withValues(alpha: 0.4)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t("charging.modbus.alarms"), + style: DriverVault.mono(context, + size: 10, weight: FontWeight.w600, color: DriverVault.warning) + .copyWith(letterSpacing: 1.4)), + const SizedBox(height: 6), + Text(alarms.join(" "), style: DriverVault.mono(context, size: 12)), + const SizedBox(height: 4), + Text(t("charging.modbus.alarmsHint"), + style: TextStyle(fontSize: 11, color: DriverVault.muted(context))), + ], + ), + ), + ]; + } + + Widget _th(BuildContext context, String label, {bool left = false}) => Padding( + padding: const EdgeInsets.fromLTRB(0, 2, 12, 4), + child: Text(label, + textAlign: left ? TextAlign.left : TextAlign.right, + style: TextStyle( + fontSize: 11, fontWeight: FontWeight.w500, color: DriverVault.muted(context))), + ); + + Widget _td(BuildContext context, String value, {bool left = false}) => Padding( + padding: const EdgeInsets.fromLTRB(0, 3, 12, 3), + child: Text(value, + textAlign: left ? TextAlign.left : TextAlign.right, + style: DriverVault.mono(context, + size: 12, color: left ? DriverVault.muted(context) : null)), + ); + + // --- reading the snapshot --- + // + // The local path reports far more than the OCPP one: one poll carries + // metering, the control settings and the charger's identity. Shown as a flat + // list that would be a wall of forty numbers, so it is sorted the way it gets + // asked about — what the charger is doing, what it is set to, and what it is. + // + // Pairs with no value drop out: a charger on older firmware, or one that + // refused the control block, should show a shorter list rather than a column + // of dashes. + + String? _unit(num? v, int digits, String unit) => + v == null ? null : "${v.toStringAsFixed(digits)} $unit"; + + String? _yesNo(bool? v) => v == null ? null : (v ? t("common.yes") : t("common.no")); + + /// An enum the charger reports as a number, named through the catalogue so it + /// translates; an unlisted value falls back to the number rather than a blank. + String? _enumLabel(String prefix, int? v) { + if (v == null) return null; + final key = "charging.modbus.$prefix$v"; + final label = t(key); + return label == key ? "$v" : label; + } + + String? _sessionLength(int? seconds) { + if (seconds == null) return null; + final h = seconds ~/ 3600; + final m = (seconds % 3600) ~/ 60; + return h > 0 ? "$h h $m min" : "$m min"; + } + + List<(String, String)> _rows(List<(String, String?)> pairs) => [ + for (final (key, value) in pairs) + if (value != null && value.isNotEmpty) (t("charging.modbus.$key"), value), + ]; + + List<(String, String)> _liveRows(ModbusStatus s) { + final r1 = s.number("relay1TempC"); + final r2 = s.number("relay2TempC"); + return _rows([ + ("power", _unit(s.number("powerTotal"), 0, "W")), + ("sessionDuration", _sessionLength(s.integer("sessionSeconds"))), + ("cpSignal", s.text("cpSignalDesc")), + ("cpVoltage", _unit(s.number("cpVoltage"), 2, "V")), + ("phaseMode", _enumLabel("phaseMode", s.integer("phaseMode"))), + ( + "relayTemps", + r1 != null && r2 != null + ? "${r1.toStringAsFixed(1)} / ${r2.toStringAsFixed(1)} °C" + : _unit(r1, 1, "°C") + ), + ("pwm", _yesNo(s.flag("pwmEnabled"))), + ]); + } + + List<(String, String)> _settingRows(ModbusStatus s) { + final timeout = s.settingInt("timeoutSeconds"); + final led = s.integer("ledBrightness"); + return _rows([ + ("maxCurrentSet", _unit(s.setting("maxCurrentA"), 1, "A")), + ("timeout", timeout == null ? null : "$timeout s"), + ("phaseSetting", _enumLabel("phaseSet", s.settingInt("phaseSetting"))), + ("boostSet", _yesNo(s.settingFlag("boost"))), + ("lastCommand", _enumLabel("command", s.settingInt("lastCommand"))), + ("chargingMode", _enumLabel("chargingMode", s.integer("chargingMode"))), + ("loadBalancing", _yesNo(s.flag("loadBalancing"))), + ("solarBalancing", _yesNo(s.flag("solarBalancing"))), + ("ledBrightness", led == null ? null : "$led %"), + ]); + } + + List<(String, String)> _deviceRows(ModbusStatus s) { + final product = s.integer("productNumber"); + final min = s.integer("minCurrentA"); + final max = s.integer("maxCurrentA"); + return _rows([ + ("model", s.text("model")), + ("serial", s.text("serial")), + ("firmware", s.text("firmware")), + ("hardware", s.text("hardware")), + ("productNumber", product == null ? null : "$product"), + ("ratedPower", _unit(s.number("ratedPowerW"), 0, "W")), + ("currentRange", min != null && max != null ? "$min–$max A" : null), + ("ocppLink", _enumLabel("ocpp", s.integer("ocppStatus"))), + ("mqttLink", _enumLabel("mqtt", s.integer("mqttStatus"))), + ]); + } + + /// The per-phase matrix, or nothing at all when the charger reported none of + /// it. A cell with no reading is a dash, so the columns still line up. + List> _phaseRows(ModbusStatus s) { + String cell(double? v, int digits, String unit) => + v == null ? "—" : "${v.toStringAsFixed(digits)} $unit"; + final any = ["voltageL1", "currentL1", "powerL1"].any((k) => s.raw[k] != null); + if (!any) return const []; + return [ + for (final n in [1, 2, 3]) + [ + "L$n", + cell(s.number("voltageL$n"), 1, "V"), + cell(s.number("currentL$n"), 2, "A"), + cell(s.number("powerL$n"), 0, "W"), + cell(s.number("reactiveL$n"), 0, "var"), + cell(s.number("apparentL$n"), 0, "VA"), + ], + ]; + } + + /// Line-to-line voltages only mean anything on a three-phase supply, so they + /// are shown when the charger reports one rather than as three more zeroes. + List _lineVoltages(ModbusStatus s) { + final pairs = [ + ("L1–L2", s.number("voltageL1L2")), + ("L2–L3", s.number("voltageL2L3")), + ("L3–L1", s.number("voltageL3L1")), + ]; + return [ + for (final (name, v) in pairs) + if (v != null && v > 10) "$name ${v.toStringAsFixed(1)} V", + ]; + } + + List _alarmWords(ModbusStatus s) { + if (!s.alarm) return const []; + final words = s.alarms; + return [ + for (var i = 0; i < words.length; i++) + if (words[i] != 0) + "${t("charging.modbus.alarmWord", params: {"n": i + 1})} " + "0x${words[i].toRadixString(16).toUpperCase().padLeft(4, "0")}", + ]; + } + + /// Charger information: everything the record holds about the picked charger. + /// It stands on its own — control needs a control mode, but what the charger + /// *is* is known either way, so with control off this card is what fills the + /// column instead of a bare hint. + Widget _infoCard(BuildContext context) { + final muted = DriverVault.muted(context); + final charger = _selectedCharger; + return _FoldCard( + title: t("charging.info.title"), + open: _isOpen("info"), + onToggle: () => _toggleCard("info"), + action: _homeChargers.isEmpty + ? null + : TextButton( + onPressed: _liveLoading ? null : () => _loadLive(force: true), + child: Text(_liveLoading ? t("common.loading") : t("charging.info.refresh")), ), - if (_error != null) + children: [ + if (charger != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50, + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Expanded( + child: Text(charger.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + ), + // Reachability, said either way. A charger the service says + // nothing about stays silent: unknown is not offline. + if (_liveFor(charger)?.online == true) ...[ + _StatusBadge(label: t("charging.info.online"), ok: true), + const SizedBox(width: 6), + ] else if (_liveFor(charger)?.online == false) ...[ + _StatusBadge(label: t("charging.info.offline"), ok: false), + const SizedBox(width: 6), + ], + if (charger.provider.isNotEmpty) + _NeutralBadge(label: _providerLabel(charger.provider)), + ]), + const SizedBox(height: 8), + _PairList(rows: _infoRows(charger), breakLong: true), + ], + ), + ), + ], + if (_homeChargers.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(t("charging.info.empty"), style: TextStyle(fontSize: 12, color: muted)), + ), + // Control mode off: say why the card above is missing, here, where there + // is now something to read it against. + if (!_active) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(t("charging.stations.noControlHint"), + style: TextStyle(fontSize: 12, color: muted)), + ), + ], + ); + } + + /// Everything the card can say about one charger. Every row is drawn every + /// time, a field nothing supplied included: which fields a charger has an + /// answer for is itself worth seeing, and a row that comes and goes with the + /// data makes two chargers impossible to read against each other. Nothing to + /// say is said with a dash. + List<(String, String)> _infoRows(HomeCharger c) { + final live = _liveFor(c); + final pairs = <(String, String)>[ + ("vendor", c.vendor), + ("model", c.model), + ("firmware", live?.firmware ?? ""), + ("serial", c.serial), + ("site", c.siteName.isNotEmpty ? c.siteName : (live?.siteName ?? "")), + ("siteId", live?.siteId ?? ""), + ("sources", _sourcesLabel(live?.sources ?? const [])), + ("power", c.powerKw > 0 ? "${c.powerKw} kW" : ""), + ("connector", c.connector), + ("state", _stateLabel(live?.status ?? "")), + // Relayed as the service words it — the unit is upstream's, so putting one + // on it here would be inventing it. + ("chargePower", live?.power ?? ""), + ("ocpp", live?.ocppLabel ?? ""), + ("providerId", c.providerChargerId), + ("added", c.created.isEmpty ? "" : formatDateTime(DateTime.tryParse(c.created)?.toLocal())), + ]; + return [ + for (final (key, value) in pairs) + (t("charging.info.$key"), value.isEmpty ? "—" : value), + ]; + } + + /// The user's own chargers, and the import that fills the list. + Widget _chargerListCard(BuildContext context) { + final muted = DriverVault.muted(context); + return _Card( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(6, 6, 6, 4), + child: Row(children: [ + Expanded( + child: Text( + "${t("charging.stations.homeHeading")} · " + "${t("charging.home.count", params: {"n": _homeChargers.length}, n: _homeChargers.length)}", + style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted) + .copyWith(letterSpacing: 1.4), + ), + ), + if (_canImport) + TextButton(onPressed: _import, child: Text(t("charging.home.import"))), + ]), + ), + for (final c in _homeChargers) _chargerRow(context, c), + + // Nothing imported yet: say what this list is for and offer the import. + if (_homeChargers.isEmpty) Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(_error!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)), + padding: const EdgeInsets.fromLTRB(6, 4, 6, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t("charging.home.empty"), style: TextStyle(fontSize: 13, color: muted)), + const SizedBox(height: 10), + if (_canImport) + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _import, + child: Text(t("charging.home.import")), + ), + ) + else + Text(t("charging.home.connectFirst"), + style: TextStyle(fontSize: 12, color: muted)), + ], + ), + ), + + if (_homeError != null) + Padding( + padding: const EdgeInsets.fromLTRB(6, 4, 6, 8), + child: Text(_homeError!, + style: const TextStyle(fontSize: 13, color: DriverVault.danger)), ), ], ), ); } + + Widget _chargerRow(BuildContext context, HomeCharger c) { + final muted = DriverVault.muted(context); + final selected = _selectedCharger?.id == c.id; + final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50; + // The colour of the list icon: the same green and amber the badges in the + // information card use, so the list can be read at a glance without opening + // anything. A charger the service says nothing about stays muted — unknown + // is not offline, and a green bolt for it would be a claim. + final online = _liveFor(c)?.online; + final tone = online == true + ? DriverVault.success + : online == false + ? DriverVault.warning + : muted; + return Container( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Expanded( + child: InkWell( + onTap: () => _select(c), + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: selected ? DriverVault.brandTint(context) : null, + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + ), + child: Row(children: [ + Container( + width: 36, + height: 36, + decoration: + BoxDecoration(color: sunken, borderRadius: BorderRadius.circular(10)), + child: Icon(Icons.bolt, size: 18, color: tone), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(c.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + if (c.subtitle.isNotEmpty) ...[ + const SizedBox(height: 2), + Text(c.subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DriverVault.mono(context, size: 11, color: muted)), + ], + ], + ), + ), + ]), + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 18), + color: muted, + tooltip: t("charging.home.remove"), + onPressed: _removing == c.id ? null : () => _remove(c), + ), + ], + ), + ); + } +} + +/// One block of the readings card: an eyebrow heading over a sunken panel. +class _ReadingSection extends StatelessWidget { + final String heading; + final Widget child; + const _ReadingSection({required this.heading, required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50, + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(heading, + style: DriverVault.mono(context, + size: 10, weight: FontWeight.w500, color: DriverVault.muted(context)) + .copyWith(letterSpacing: 1.4)), + const SizedBox(height: 8), + child, + ], + ), + ); + } +} + +/// Label/value rows: the label muted on the left, the value right-aligned in the +/// mono face the rest of the app reads numbers in. +class _PairList extends StatelessWidget { + final List<(String, String)> rows; + + /// Let a long value wrap rather than clip. Serials and site ids are long + /// enough to need it; a reading never is. + final bool breakLong; + + const _PairList({required this.rows, this.breakLong = false}); + + @override + Widget build(BuildContext context) { + final muted = DriverVault.muted(context); + return Column( + children: [ + for (final (label, value) in rows) + Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 4, + child: Text(label, style: TextStyle(fontSize: 11, color: muted)), + ), + const SizedBox(width: 10), + Expanded( + flex: 5, + child: Text( + value, + textAlign: TextAlign.right, + maxLines: breakLong ? 3 : 1, + overflow: TextOverflow.ellipsis, + style: DriverVault.mono(context, size: 11), + ), + ), + ], + ), + ), + ], + ); + } } /// A small sunken metric tile (connector status / energy). diff --git a/Phone App/lib/screens/settings_screen.dart b/Phone App/lib/screens/settings_screen.dart index 01e1318..603430a 100644 --- a/Phone App/lib/screens/settings_screen.dart +++ b/Phone App/lib/screens/settings_screen.dart @@ -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, ), ), ], diff --git a/Phone App/test/charging_screen_test.dart b/Phone App/test/charging_screen_test.dart new file mode 100644 index 0000000..3854919 --- /dev/null +++ b/Phone App/test/charging_screen_test.dart @@ -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 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)); + }); +} diff --git a/Phone App/test/models_format_test.dart b/Phone App/test/models_format_test.dart index 09d5a64..22d3fd0 100644 --- a/Phone App/test/models_format_test.dart +++ b/Phone App/test/models_format_test.dart @@ -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", () {