diff --git a/Phone App/README.md b/Phone App/README.md index 5ea7d0c..760f335 100644 --- a/Phone App/README.md +++ b/Phone App/README.md @@ -92,25 +92,55 @@ navigation bar** — Garage, Charging, Settings, and Users for admins — in an workshop visit, refill, charge, document and part (PDF or image, up to 10MB). Picked with `file_picker`, fetched back through the API Server — never a public URL — and opened with the phone's own viewer via `open_filex`. -- **Charging** — mirrors the web `Charging.vue`, split into two tabs. **Public** - is a discovery map with a demo session and nearby stations: presentational - placeholders, because there is no public-charging API yet (same as the web). - **Home** 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. +- **Charging** — mirrors the web `Charging.vue`, split into three 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** 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 six cards about the one you picked: + - **control** — start/stop, boost, skip a start delay, reboot, and the current + limit on the one transport that has nowhere else to put it. It opens with + the charger it acts on, picture and name, because the buttons drive whichever + serial is in force and that is not always the record highlighted below. + - **RFID cards** — who may start a charge without a phone. The list the + account holds, a card added by number or by holding it against the charger's + own reader (the reader opens for twenty seconds and the number arrives on its + own), and the charger's own list read back from the device, which is the half + that actually decides whether a card opens it. When the two disagree the card + says which list each card is missing from. + - **charger settings** — what the charger is set to, written back. Over Modbus + that is four registers (the current limit, phase count, boost, the control + timeout); over the Anker cloud it is the charger's whole settings group, in + sections — charging, schedule, load balancing, solar, panel and light, local + network — one write per section, because the charger takes a command whole. + - **connection** — which charger, and either its serial or its address on your + network. + - **readings** — everything the charger reports: per-phase power, its + settings, what it is, and any alarm. + - **information** — everything the record holds, with the service's live view + of whether it is reachable, the fields the service sent under its own names, + and each of the account's per-charger views read a record at a time. + + Control needs a mode picked under Settings → Integrations — Modbus TCP over + the local network, the Anker cloud, or Own/Proxy CSMS over OCPP — but the + information and RFID cards stand 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. A card added after you arranged yours appears beside + the neighbour it was written to sit under, rather than at the bottom. + + **Scheduler** is the third tab: one list of charging tasks covering every + charger you own, where the charger's own cloud schedule is one window inside + one box. A task is a whole flow — start at 23:00, cap to 10 A at 01:00, stop at + 06:30 — on the days you pick and the chargers you pick, named once and switched + on and off as one. Naming no charger means every charger you own, including the + ones you import later. The clock is the server's, so a task fires whether or + not the app is open; each row says how its last firing went, and any step can + be fired now to find out whether it will reach the charger before the night it + matters. - **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, + (theme + dark mode, **language**, **region**, date format, **time format**, + **first day of the week**, **currency**, font size), profile (avatar via `image_picker`, bio), **integrations** (Toyota, Anker Solix), **Security** (biometric toggle), **Organization** (create your own — which makes you its admin — or rename/delete the one you administer), **data export/import**, and the account-deletion state machine. Export writes diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index 15bd4e5..7558649 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -112,7 +112,8 @@ "title": "Ladere i nærheden", "tabs": { "public": "Offentlige ladere", - "home": "Hjemmeladere" + "home": "Hjemmeladere", + "scheduler": "Planlægning af hjemmelader" }, "arrange": { "title": "Indret opladningssiden", @@ -281,7 +282,29 @@ "ocpp1": "Forbinder", "ocpp2": "Forbundet", "mqtt0": "Ikke forbundet", - "mqtt1": "Forbundet" + "mqtt1": "Forbundet", + "settingsTitle": "Laderindstillinger", + "apply": "Anvend", + "turnOn": "Slå til", + "turnOff": "Slå fra", + "limitFloorHint": "{amps} A er bunden — derunder holder laderen pause i stedet for at lade langsomt.", + "boostHint": "Kun den aktuelle session; laderen rydder det, når sessionen slutter.", + "timeoutHint": "Mindst {n} sekunder. Laderen falder tilbage til sin egen strategi, hvis intet skriver inden for tiden.", + "settingsReported": "Rapporteret, kan ikke indstilles", + "settingsReportedHint": "Laderen rapporterer disse; Modbus-kortet har intet register til at skrive dem. Ret dem i Anker-appen.", + "blockCharging": "Opladning", + "blockSchedule": "Tidsplan", + "blockBalancing": "Belastningsbalancering", + "blockSolar": "Sol", + "blockPanel": "Panel og lys", + "blockLocal": "Lokalt netværk", + "windowStart": "Start", + "windowEnd": "Slut", + "reset": "Fortryd", + "cloudSettingsHint": "Laderens egne indstillinger, skrevet via Anker-skyen. Et afsnit er én kommando til laderen, så dets felter anvendes samlet.", + "cloudSettingsReportedHint": "Laderen rapporterer disse; ingen kommando skriver dem. Hvad de to tilstande og flaget vælger, er udokumenteret, så de vises som de tal, de er.", + "modbusOffWarning": "Med Modbus TCP-serveren slået fra svarer laderen ikke længere på det lokale netværk, og Modbus-styringstilstanden har intet at ringe op.", + "chargingStatus": "Ladestatus" }, "info": { "title": "Laderoplysninger", @@ -346,7 +369,46 @@ "offline": "Offline", "refresh": "Opdater", "rawTitle": "Som tjenesten melder det", - "rawHint": "Alle øvrige felter, tjenesten sendte om denne lader, under Ankers egne navne. De er udokumenterede, så de vises, som de kommer, i stedet for at blive omdøbt." + "rawHint": "Alle øvrige felter, tjenesten sendte om denne lader, under Ankers egne navne. De er udokumenterede, så de vises, som de kommer, i stedet for at blive omdøbt.", + "nickname": "Kaldenavn", + "productCode": "Produktkode", + "deviceType": "Enhedstype", + "charging": "Oplader", + "statusCode": "Statuskode", + "ocppLink": "OCPP-forbindelse", + "wifiOnline": "Wi-Fi forbundet", + "bleId": "Bluetooth-id", + "blePassword": "Bluetooth-parringskode", + "ownerId": "Ejer-id", + "fields": { + "email": "E-mail", + "serial": "Serienummer", + "memberId": "Medlems-id", + "memberType": "Medlemstype", + "userId": "Bruger-id", + "status": "Status", + "inviteLimit": "Invitationsgrænse", + "sessions": "Sessioner", + "chargeTime": "Opladningstid", + "energy": "Opladet energi", + "co2Saved": "CO2 sparet", + "cost": "Omkostning", + "costSaved": "Sparet beløb", + "currency": "Valuta", + "mileage": "Kilometertal", + "page": "Side", + "perPage": "Pr. side", + "records": "Poster", + "from": "Fra", + "source": "Kilde", + "timeZone": "Tidszone", + "updated": "Opdateret", + "added": "Tilføjet", + "address": "Adresse", + "name": "Navn", + "cardName": "Kortnavn", + "cardNumber": "Kortnummer" + } }, "home": { "count": { @@ -369,6 +431,60 @@ }, "free": "{avail} af {total} ledige", "full": "Optaget" + }, + "rfid": { + "title": "RFID-kortindstillinger", + "none": "Ingen kort er godkendt til denne lader.", + "unsupported": "Tjenesten, som denne lader kommer fra, rapporterer ikke RFID-kort.", + "add": "Tilføj kort", + "tap": "Hold kortet mod laderen", + "tapping": "Hold kortet mod læseren… {n}s", + "tapHint": "Læseren er åben. Hold kortet mod laderen.", + "tapSave": "Hold kortet mod laderen, og tilføj det", + "tapSaveHint": "Tilføjer kortet, så snart det holdes mod læseren, med navnet RFID og kortets sidste fire cifre.", + "tapNone": "Der blev ikke holdt et kort mod læseren, før den lukkede.", + "addTitle": "Tilføj et kort", + "remove": "Fjern", + "removeConfirm": "Fjern {name} fra denne lader?", + "numberPlaceholder": "Kortnummer", + "namePlaceholder": "Navn (valgfrit)", + "notAdded": "Tjenesten tog imod anmodningen, men kortet er ikke på laderen. Kontrollér nummeret, og prøv igen.", + "notRemoved": "Tjenesten tog imod anmodningen, men kortet er stadig på laderen.", + "readCharger": "Læs laderens egen liste", + "chargerTitle": "På selve laderen", + "chargerNone": "Laderen har ingen kort.", + "chargerHint": "Spurgt laderen, ikke kontoen. Den svarer kun med numre — et korts navn hører til på kontoen.", + "driftTitle": "De to lister er ikke enige", + "onlyOnCharger": "Åbner laderen, men findes ikke på kontoen: {cards}", + "onlyOnAccount": "På kontoen, men ikke på laderen, så det åbner den ikke: {cards}", + "inferred": "Anker dokumenterer hverken tilføjelse eller fjernelse. DriverVault udleder anmodningen af de felter, kortlisten svarer med, og læser derefter listen igen — det, du ser ovenfor, er det, kontoen har." + }, + "scheduler": { + "title": "Ladeopgaver", + "subtitle": "Én plan for alle dine ladere. En opgave er et forløb — start, grænse, stop — der kører på de dage du vælger, på de ladere du vælger.", + "add": "Ny opgave", + "empty": "Ingen opgaver endnu. En opgave er et helt forløb: start kl. 23:00, begræns til 10 A kl. 01:00, stop kl. 06:30.", + "needCharger": "Importér først en lader under Hjemmeladere — en opgave skal have noget at handle på.", + "serverHint": "Opgaverne kører på serveren, så de udføres uanset om denne side er åben. Tidspunkter læses i den tidszone, du skrev dem i.", + "allChargers": "Alle ladere", + "missingChargers": "Ingen lader på kontoen længere", + "everyDay": "Hver dag", + "runNow": "Kør nu", + "running": "Sender…", + "lastRun": "Sidst kørt {when}", + "noResult": "intet resultat registreret", + "toggleHint": "Om uret udløser denne opgave.", + "removeConfirm": "Slet opgaven “{name}”?", + "taskCount": { + "one": "{n} opgave", + "other": "{n} opgaver" + }, + "actions": { + "start": "Start opladning", + "stop": "Stop opladning", + "limit": "Sæt strømgrænse", + "boost": "Boost sessionen" + } } }, "settings": { @@ -415,7 +531,17 @@ "fontSize": "Skriftstørrelse", "fontSmall": "Lille", "fontMedium": "Mellem", - "fontLarge": "Stor" + "fontLarge": "Stor", + "timeFormat": "Tidsformat", + "timeAuto": "Følg regionen", + "time24": "24-timers", + "time12": "12-timers", + "timeExample": "Eksempel: {example}", + "weekStart": "Første dag i ugen", + "weekAuto": "Følg regionen", + "weekMonday": "Mandag", + "weekSunday": "Søndag", + "weekExample": "Eksempel: {example}" }, "profile": { "title": "Profil", @@ -1167,6 +1293,25 @@ "alreadyImported": "Allerede importeret", "name": "Ladernavn", "submit": "Importer lader" + }, + "chargingTask": { + "title": "Ny ladeopgave", + "editTitle": "Rediger ladeopgave", + "name": "Navn", + "namePlaceholder": "Nattakst", + "time": "Kl.", + "flow": "Forløbet", + "flowHint": "Hvert trin udføres på sit eget tidspunkt, hver dag opgaven kører. En hel nat er én opgave: start kl. 23:00, stop kl. 06:30.", + "addStep": "+ Tilføj et trin", + "removeStep": "Fjern dette trin", + "amps": "Strømgrænse", + "ampsHint": "6 A er bundgrænsen — derunder sætter laderen på pause i stedet for at lade langsomt.", + "chargers": "På disse ladere", + "allChargers": "Alle mine ladere", + "allChargersHint": "Inklusive ladere du importerer senere.", + "noChargers": "Ingen ladere på kontoen endnu.", + "days": "På disse dage", + "everyDay": "Hver dag" } }, "enums": { diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json index 6da812b..c7c20b9 100644 --- a/Phone App/assets/i18n/en.json +++ b/Phone App/assets/i18n/en.json @@ -112,7 +112,8 @@ "title": "Nearby chargers", "tabs": { "public": "Public chargers", - "home": "Home chargers" + "home": "Home chargers", + "scheduler": "Home charger scheduler" }, "arrange": { "title": "Arrange the charging page", @@ -281,7 +282,29 @@ "ocpp1": "Connecting", "ocpp2": "Connected", "mqtt0": "Not connected", - "mqtt1": "Connected" + "mqtt1": "Connected", + "settingsTitle": "Charger settings", + "apply": "Apply", + "turnOn": "Turn on", + "turnOff": "Turn off", + "limitFloorHint": "{amps} A is the floor — below it the charger pauses rather than charging slowly.", + "boostHint": "The current session only; the charger clears it when the session ends.", + "timeoutHint": "At least {n} seconds. The charger falls back to its own strategy if nothing writes within it.", + "settingsReported": "Reported, not settable", + "settingsReportedHint": "The charger reports these; the Modbus map has no register to write them. Change them in the Anker app.", + "blockCharging": "Charging", + "blockSchedule": "Schedule", + "blockBalancing": "Load balancing", + "blockSolar": "Solar", + "blockPanel": "Panel and light", + "blockLocal": "Local network", + "windowStart": "Start", + "windowEnd": "End", + "reset": "Undo", + "cloudSettingsHint": "The charger's own settings, written over the Anker cloud. A section is one command to the charger, so its fields are applied together.", + "cloudSettingsReportedHint": "The charger reports these; no command writes them. What the two modes and the flag select is undocumented, so they are shown as the numbers they are.", + "modbusOffWarning": "With the Modbus TCP server off the charger stops answering on the local network, and the Modbus control mode has nothing left to dial.", + "chargingStatus": "Charging status" }, "info": { "title": "Charger information", @@ -346,7 +369,46 @@ "offline": "Offline", "refresh": "Refresh", "rawTitle": "As the service reports it", - "rawHint": "Every other field the service sent about this charger, under its own field names. They are undocumented, so they are shown as they arrive rather than renamed." + "rawHint": "Every other field the service sent about this charger, under its own field names. They are undocumented, so they are shown as they arrive rather than renamed.", + "nickname": "Nickname", + "productCode": "Product code", + "deviceType": "Device type", + "charging": "Charging", + "statusCode": "Status code", + "ocppLink": "OCPP connection", + "wifiOnline": "Wi-Fi connected", + "bleId": "Bluetooth id", + "blePassword": "Bluetooth pairing code", + "ownerId": "Owner id", + "fields": { + "email": "Email", + "serial": "Serial", + "memberId": "Member id", + "memberType": "Member type", + "userId": "User id", + "status": "Status", + "inviteLimit": "Invite limit", + "sessions": "Sessions", + "chargeTime": "Time charging", + "energy": "Energy charged", + "co2Saved": "CO2 saved", + "cost": "Cost", + "costSaved": "Cost saved", + "currency": "Currency", + "mileage": "Mileage", + "page": "Page", + "perPage": "Per page", + "records": "Records", + "from": "From", + "source": "Source", + "timeZone": "Time zone", + "updated": "Updated", + "added": "Added", + "address": "Address", + "name": "Name", + "cardName": "Card name", + "cardNumber": "Card number" + } }, "home": { "count": { @@ -369,6 +431,60 @@ }, "free": "{avail} of {total} free", "full": "Full" + }, + "rfid": { + "title": "RFID cards settings", + "none": "No cards are authorised on this charger.", + "unsupported": "The service this charger came from does not report RFID cards.", + "add": "Add card", + "tap": "Tap card at the charger", + "tapping": "Hold the card against the reader… {n}s", + "tapHint": "The reader is open. Hold the card against the charger.", + "tapSave": "Tap card and add it", + "tapSaveHint": "Adds the card as soon as it is tapped, named RFID and its last four digits.", + "tapNone": "No card was tapped before the reader closed.", + "addTitle": "Add a card", + "remove": "Remove", + "removeConfirm": "Remove {name} from this charger?", + "numberPlaceholder": "Card number", + "namePlaceholder": "Name (optional)", + "notAdded": "The service took the request, but the card is not on the charger. Check the number and try again.", + "notRemoved": "The service took the request, but the card is still on the charger.", + "readCharger": "Read the charger's own list", + "chargerTitle": "On the charger itself", + "chargerNone": "The charger holds no cards.", + "chargerHint": "Asked of the charger, not of the account. It answers with numbers only — a card's name lives on the account.", + "driftTitle": "The two lists disagree", + "onlyOnCharger": "Opens the charger but is not on the account: {cards}", + "onlyOnAccount": "On the account but not on the charger, so it will not open it: {cards}", + "inferred": "Anker documents neither the add nor the remove endpoint. DriverVault infers the request from the fields the card list answers with, then reads the list back — what you see above is what the account holds." + }, + "scheduler": { + "title": "Charging tasks", + "subtitle": "One schedule for every charger you own. A task is a flow — start, limit, stop — running on the days you pick, on the chargers you pick.", + "add": "New task", + "empty": "No tasks yet. A task is a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30.", + "needCharger": "Import a charger under Home chargers first — a task needs something to act on.", + "serverHint": "Tasks run on the server, so they fire whether or not this page is open. Times are read in the time zone you wrote them in.", + "allChargers": "All chargers", + "missingChargers": "No charger on your account any more", + "everyDay": "Every day", + "runNow": "Run now", + "running": "Sending…", + "lastRun": "Last run {when}", + "noResult": "no result recorded", + "toggleHint": "Whether the clock fires this task.", + "removeConfirm": "Delete the task “{name}”?", + "taskCount": { + "one": "{n} task", + "other": "{n} tasks" + }, + "actions": { + "start": "Start charging", + "stop": "Stop charging", + "limit": "Set current limit", + "boost": "Boost the session" + } } }, "settings": { @@ -528,7 +644,17 @@ "fontSize": "Font size", "fontSmall": "Small", "fontMedium": "Medium", - "fontLarge": "Large" + "fontLarge": "Large", + "timeFormat": "Time format", + "timeAuto": "Follow the region", + "time24": "24-hour", + "time12": "12-hour", + "timeExample": "Example: {example}", + "weekStart": "First day of the week", + "weekAuto": "Follow the region", + "weekMonday": "Monday", + "weekSunday": "Sunday", + "weekExample": "Example: {example}" }, "profile": { "title": "Profile", @@ -1167,6 +1293,25 @@ "alreadyImported": "Already imported", "name": "Charger name", "submit": "Import charger" + }, + "chargingTask": { + "title": "New charging task", + "editTitle": "Edit charging task", + "name": "Name", + "namePlaceholder": "Night rate", + "time": "At", + "flow": "The flow", + "flowHint": "Each step fires at its own time, every day the task runs. A whole night is one task: start at 23:00, stop at 06:30.", + "addStep": "+ Add a step", + "removeStep": "Remove this step", + "amps": "Current limit", + "ampsHint": "6 A is the floor — below it the charger pauses rather than charging slowly.", + "chargers": "On these chargers", + "allChargers": "All my chargers", + "allChargersHint": "Including any charger you import later.", + "noChargers": "No chargers on your account yet.", + "days": "On these days", + "everyDay": "Every day" } }, "enums": { diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index b7f197e..afe9f00 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -114,7 +114,8 @@ "title": "Ładowarki w pobliżu", "tabs": { "public": "Ładowarki publiczne", - "home": "Ładowarki domowe" + "home": "Ładowarki domowe", + "scheduler": "Harmonogram ładowarki" }, "arrange": { "title": "Ułóż stronę ładowania", @@ -283,7 +284,29 @@ "ocpp1": "Łączenie", "ocpp2": "Połączona", "mqtt0": "Nierozłączona", - "mqtt1": "Połączona" + "mqtt1": "Połączona", + "settingsTitle": "Ustawienia ładowarki", + "apply": "Zastosuj", + "turnOn": "Włącz", + "turnOff": "Wyłącz", + "limitFloorHint": "{amps} A to dolna granica — poniżej ładowarka wstrzymuje ładowanie, zamiast ładować wolniej.", + "boostHint": "Tylko bieżąca sesja; ładowarka kasuje to po jej zakończeniu.", + "timeoutHint": "Co najmniej {n} sekund. Bez zapisu w tym czasie ładowarka wraca do własnej strategii.", + "settingsReported": "Raportowane, nieustawialne", + "settingsReportedHint": "Ładowarka je raportuje; mapa Modbus nie ma rejestru do ich zapisu. Zmień je w aplikacji Anker.", + "blockCharging": "Ładowanie", + "blockSchedule": "Harmonogram", + "blockBalancing": "Balansowanie obciążenia", + "blockSolar": "Fotowoltaika", + "blockPanel": "Panel i podświetlenie", + "blockLocal": "Sieć lokalna", + "windowStart": "Początek", + "windowEnd": "Koniec", + "reset": "Cofnij", + "cloudSettingsHint": "Własne ustawienia ładowarki, zapisywane przez chmurę Anker. Jedna sekcja to jedno polecenie do ładowarki, więc jej pola są zapisywane razem.", + "cloudSettingsReportedHint": "Ładowarka je zgłasza, ale żadne polecenie ich nie zapisuje. Nie wiadomo, co wybierają te dwa tryby i flaga, więc pokazane są jako liczby, którymi są.", + "modbusOffWarning": "Przy wyłączonym serwerze Modbus TCP ładowarka przestaje odpowiadać w sieci lokalnej, a tryb sterowania Modbus nie ma już pod co zadzwonić.", + "chargingStatus": "Status ładowania" }, "info": { "title": "Informacje o ładowarce", @@ -348,7 +371,46 @@ "offline": "Offline", "refresh": "Odśwież", "rawTitle": "Tak, jak podaje to usługa", - "rawHint": "Wszystkie pozostałe pola, które usługa przysłała o tej ładowarce, pod jej własnymi nazwami. Nie są udokumentowane, więc pokazujemy je tak, jak przychodzą, bez zmiany nazw." + "rawHint": "Wszystkie pozostałe pola, które usługa przysłała o tej ładowarce, pod jej własnymi nazwami. Nie są udokumentowane, więc pokazujemy je tak, jak przychodzą, bez zmiany nazw.", + "nickname": "Nazwa własna", + "productCode": "Kod produktu", + "deviceType": "Typ urządzenia", + "charging": "Ładowanie", + "statusCode": "Kod statusu", + "ocppLink": "Połączenie OCPP", + "wifiOnline": "Wi-Fi połączone", + "bleId": "Identyfikator Bluetooth", + "blePassword": "Kod parowania Bluetooth", + "ownerId": "Identyfikator właściciela", + "fields": { + "email": "E-mail", + "serial": "Numer seryjny", + "memberId": "Identyfikator członka", + "memberType": "Typ członka", + "userId": "Identyfikator użytkownika", + "status": "Status", + "inviteLimit": "Limit zaproszeń", + "sessions": "Sesje", + "chargeTime": "Czas ładowania", + "energy": "Naładowana energia", + "co2Saved": "Oszczędność CO2", + "cost": "Koszt", + "costSaved": "Oszczędność kosztów", + "currency": "Waluta", + "mileage": "Przebieg", + "page": "Strona", + "perPage": "Na stronę", + "records": "Rekordy", + "from": "Od", + "source": "Źródło", + "timeZone": "Strefa czasowa", + "updated": "Zaktualizowano", + "added": "Dodano", + "address": "Adres", + "name": "Nazwa", + "cardName": "Nazwa karty", + "cardNumber": "Numer karty" + } }, "home": { "count": { @@ -375,6 +437,62 @@ }, "free": "{avail} z {total} wolnych", "full": "Zajęte" + }, + "rfid": { + "title": "Ustawienia kart RFID", + "none": "Na tej ładowarce nie autoryzowano żadnej karty.", + "unsupported": "Usługa, z której pochodzi ta ładowarka, nie zgłasza kart RFID.", + "add": "Dodaj kartę", + "tap": "Przyłóż kartę do ładowarki", + "tapping": "Przytrzymaj kartę przy czytniku… {n}s", + "tapHint": "Czytnik jest otwarty. Przytrzymaj kartę przy ładowarce.", + "tapSave": "Przyłóż kartę i dodaj ją", + "tapSaveHint": "Dodaje kartę zaraz po przyłożeniu, pod nazwą RFID i cztery ostatnie znaki numeru.", + "tapNone": "Nie przyłożono karty, zanim czytnik się zamknął.", + "addTitle": "Dodaj kartę", + "remove": "Usuń", + "removeConfirm": "Usunąć {name} z tej ładowarki?", + "numberPlaceholder": "Numer karty", + "namePlaceholder": "Nazwa (opcjonalnie)", + "notAdded": "Usługa przyjęła żądanie, ale karty nie ma na ładowarce. Sprawdź numer i spróbuj ponownie.", + "notRemoved": "Usługa przyjęła żądanie, ale karta nadal jest na ładowarce.", + "readCharger": "Odczytaj własną listę ładowarki", + "chargerTitle": "Na samej ładowarce", + "chargerNone": "Ładowarka nie ma żadnych kart.", + "chargerHint": "Zapytana została ładowarka, nie konto. Odpowiada samymi numerami — nazwa karty jest po stronie konta.", + "driftTitle": "Obie listy się nie zgadzają", + "onlyOnCharger": "Otwiera ładowarkę, ale nie ma jej na koncie: {cards}", + "onlyOnAccount": "Jest na koncie, ale nie na ładowarce, więc jej nie otworzy: {cards}", + "inferred": "Anker nie dokumentuje ani dodawania, ani usuwania. DriverVault wnioskuje żądanie z pól, którymi odpowiada lista kart, a potem odczytuje listę ponownie — powyżej widzisz to, co ma konto." + }, + "scheduler": { + "title": "Zadania ładowania", + "subtitle": "Jeden harmonogram dla wszystkich Twoich ładowarek. Zadanie to przebieg — start, limit, stop — wykonywany w wybrane dni, na wybranych ładowarkach.", + "add": "Nowe zadanie", + "empty": "Brak zadań. Zadanie to cały przebieg: start o 23:00, ograniczenie do 10 A o 01:00, stop o 06:30.", + "needCharger": "Najpierw zaimportuj ładowarkę w zakładce Ładowarki domowe — zadanie musi mieć na czym działać.", + "serverHint": "Zadania działają na serwerze, więc uruchamiają się niezależnie od tego, czy ta strona jest otwarta. Godziny są odczytywane w strefie czasowej, w której je zapisano.", + "allChargers": "Wszystkie ładowarki", + "missingChargers": "Nie ma już takiej ładowarki na koncie", + "everyDay": "Codziennie", + "runNow": "Uruchom teraz", + "running": "Wysyłanie…", + "lastRun": "Ostatnio {when}", + "noResult": "brak zapisanego wyniku", + "toggleHint": "Czy zegar uruchamia to zadanie.", + "removeConfirm": "Usunąć zadanie „{name}”?", + "taskCount": { + "one": "{n} zadanie", + "few": "{n} zadania", + "many": "{n} zadań", + "other": "{n} zadania" + }, + "actions": { + "start": "Rozpocznij ładowanie", + "stop": "Zatrzymaj ładowanie", + "limit": "Ustaw limit prądu", + "boost": "Przyspiesz sesję" + } } }, "settings": { @@ -421,7 +539,17 @@ "fontSize": "Rozmiar czcionki", "fontSmall": "Mała", "fontMedium": "Średnia", - "fontLarge": "Duża" + "fontLarge": "Duża", + "timeFormat": "Format godziny", + "timeAuto": "Jak w regionie", + "time24": "24-godzinny", + "time12": "12-godzinny", + "timeExample": "Przykład: {example}", + "weekStart": "Pierwszy dzień tygodnia", + "weekAuto": "Zgodnie z regionem", + "weekMonday": "Poniedziałek", + "weekSunday": "Niedziela", + "weekExample": "Przykład: {example}" }, "profile": { "title": "Profil", @@ -1185,6 +1313,25 @@ "alreadyImported": "Już zaimportowana", "name": "Nazwa ładowarki", "submit": "Importuj ładowarkę" + }, + "chargingTask": { + "title": "Nowe zadanie ładowania", + "editTitle": "Edytuj zadanie ładowania", + "name": "Nazwa", + "namePlaceholder": "Taryfa nocna", + "time": "O godzinie", + "flow": "Przebieg", + "flowHint": "Każdy krok uruchamia się o własnej godzinie, w każdy dzień działania zadania. Cała noc to jedno zadanie: start o 23:00, stop o 06:30.", + "addStep": "+ Dodaj krok", + "removeStep": "Usuń ten krok", + "amps": "Limit prądu", + "ampsHint": "6 A to dolna granica — poniżej ładowarka wstrzymuje ładowanie, zamiast ładować wolniej.", + "chargers": "Na tych ładowarkach", + "allChargers": "Wszystkie moje ładowarki", + "allChargersHint": "Łącznie z ładowarkami zaimportowanymi później.", + "noChargers": "Na koncie nie ma jeszcze ładowarek.", + "days": "W te dni", + "everyDay": "Codziennie" } }, "enums": { diff --git a/Phone App/lib/api.dart b/Phone App/lib/api.dart index 9ec8a28..c623822 100644 --- a/Phone App/lib/api.dart +++ b/Phone App/lib/api.dart @@ -652,6 +652,50 @@ class ApiClient { return ChargerDetails.fromJson(Map.from(data)); } + // The RFID cards on one charger — the only calls in this client that change + // anything on the Anker account. Anker documents neither endpoint, so the + // server infers the request and then reads the list back: both of these answer + // with {present, cards}, and it is the list that says what happened, not the + // status code. + Future saveAnkerRfidCard(String sn, String cardNumber, String cardName) async { + final data = await _send( + "POST", + "/integrations/anker-solix/chargers/${_sn(sn)}/rfid-cards", + body: {"cardNumber": cardNumber, "cardName": cardName}, + ); + if (data is! Map) return const RfidCardWrite(); + return RfidCardWrite.fromJson(Map.from(data)); + } + + /// Opens the charger's own card reader and waits for a tap — the request is in + /// flight for the whole twenty-second window, and answers whether or not a + /// card arrived. + Future scanAnkerRfidCard(String sn) async { + final data = await _send("POST", "/integrations/anker-solix/chargers/${_sn(sn)}/rfid-cards/scan"); + if (data is! Map) return const RfidScan(); + return RfidScan.fromJson(Map.from(data)); + } + + Future deleteAnkerRfidCard(String sn, String cardNumber) async { + final data = await _send( + "DELETE", + "/integrations/anker-solix/chargers/${_sn(sn)}/rfid-cards/${Uri.encodeComponent(cardNumber)}", + ); + if (data is! Map) return const RfidCardWrite(); + return RfidCardWrite.fromJson(Map.from(data)); + } + + /// The list the charger itself holds, asked of the device rather than of the + /// account. Both are written by every add and remove, and they can still come + /// apart; this is the only call that says so. Answers with bare numbers, + /// because the device has no field for a card's name. + Future> getAnkerChargerCards(String sn) async { + final data = + await _send("GET", "/integrations/anker-solix/chargers/${_sn(sn)}/rfid-cards/charger"); + final raw = data is Map ? data["cards"] : null; + return raw is List ? raw.map(_asString).where((c) => c.isNotEmpty).toList() : const []; + } + // Greencell (HabuDen EV charger). Same cascade, but what resolves is an MQTT // broker rather than a cloud account — the charger publishes to a broker the // owner runs and the server joins it. testGreencell connects to that broker and @@ -772,5 +816,44 @@ class ApiClient { Future deleteHomeCharger(String id) => _send("DELETE", "/home-chargers/${Uri.encodeComponent(id)}"); + // --- the charging scheduler --- + // + // One list of charging tasks per user, covering every charger they own. The + // server holds the clock — a schedule that only fires while the app is open + // would be a reminder, not a schedule — so the app only writes tasks and reads + // back how each one last went. + + Future> listChargingTasks() async { + final data = await _send("GET", "/charging-tasks"); + final items = (data is Map ? data["tasks"] : null) ?? []; + return (items as List) + .whereType() + .map((e) => ChargingTask.fromJson(Map.from(e))) + .toList(); + } + + Future createChargingTask(Map body) async { + final data = await _send("POST", "/charging-tasks", body: body); + return ChargingTask.fromJson(Map.from((data is Map ? data["task"] : null) ?? {})); + } + + Future updateChargingTask(String id, Map body) async { + final data = + await _send("PATCH", "/charging-tasks/${Uri.encodeComponent(id)}", body: body); + return ChargingTask.fromJson(Map.from((data is Map ? data["task"] : null) ?? {})); + } + + Future deleteChargingTask(String id) => + _send("DELETE", "/charging-tasks/${Uri.encodeComponent(id)}"); + + /// One step of a flow, fired now: running a start and the stop that closes it + /// back to back would leave the charger where it began and prove nothing. + /// Answers with the same summary the clock's own firing would record. + Future runChargingStep(String id, int step) async { + final data = await _send( + "POST", "/charging-tasks/${Uri.encodeComponent(id)}/steps/$step/run"); + return data is Map ? _asString(data["summary"]) : ""; + } + static String _asString(dynamic v) => v == null ? "" : v.toString(); } diff --git a/Phone App/lib/app_settings.dart b/Phone App/lib/app_settings.dart index 677d970..07256ea 100644 --- a/Phone App/lib/app_settings.dart +++ b/Phone App/lib/app_settings.dart @@ -11,12 +11,25 @@ class AppSettings extends ChangeNotifier { static const _kTheme = "cc_theme"; static const _kLocale = "cc_locale"; static const _kDateFormat = "cc_dateFormat"; + static const _kTimeFormat = "cc_timeFormat"; + static const _kWeekStart = "cc_weekStart"; static const _kCurrency = "cc_currency"; static const _kFontSize = "cc_fontSize"; String theme = "system"; // light | dark | system String locale = "en-US"; // BCP-47 language-REGION String dateFormat = "YMD"; // YMD | DMY_NUM | DMY | MDY + + /// Which clock times are written on. "auto" is the chosen region's own + /// convention, which is what every time in the app read before there was a + /// setting; the other two are for the people whose region and habit disagree. + String timeFormat = "auto"; // auto | 24 | 12 + + /// The day a week is drawn as starting on, wherever weekdays are laid out in a + /// row — the charging scheduler's day picker today. See format.dart, which + /// owns the rule so every such row reads the same. + String weekStart = "auto"; // auto | monday | sunday + String currency = "USD"; // ISO 4217 code String fontSize = "medium"; // small | medium | large @@ -30,6 +43,8 @@ class AppSettings extends ChangeNotifier { theme = prefs.getString(_kTheme) ?? theme; locale = prefs.getString(_kLocale) ?? locale; dateFormat = prefs.getString(_kDateFormat) ?? dateFormat; + timeFormat = prefs.getString(_kTimeFormat) ?? timeFormat; + weekStart = prefs.getString(_kWeekStart) ?? weekStart; currency = prefs.getString(_kCurrency) ?? currency; fontSize = prefs.getString(_kFontSize) ?? fontSize; notifyListeners(); @@ -40,6 +55,8 @@ class AppSettings extends ChangeNotifier { await prefs.setString(_kTheme, theme); await prefs.setString(_kLocale, locale); await prefs.setString(_kDateFormat, dateFormat); + await prefs.setString(_kTimeFormat, timeFormat); + await prefs.setString(_kWeekStart, weekStart); await prefs.setString(_kCurrency, currency); await prefs.setString(_kFontSize, fontSize); } @@ -49,6 +66,8 @@ class AppSettings extends ChangeNotifier { theme = p.theme; locale = p.locale; dateFormat = p.dateFormat; + timeFormat = p.timeFormat; + weekStart = p.weekStart; currency = p.currency; fontSize = p.fontSize; _persist(); @@ -61,12 +80,16 @@ class AppSettings extends ChangeNotifier { String? theme, String? locale, String? dateFormat, + String? timeFormat, + String? weekStart, String? currency, String? fontSize, }) { if (theme != null) this.theme = theme; if (locale != null) this.locale = locale; if (dateFormat != null) this.dateFormat = dateFormat; + if (timeFormat != null) this.timeFormat = timeFormat; + if (weekStart != null) this.weekStart = weekStart; if (currency != null) this.currency = currency; if (fontSize != null) this.fontSize = fontSize; _persist(); diff --git a/Phone App/lib/format.dart b/Phone App/lib/format.dart index 2050528..796629f 100644 --- a/Phone App/lib/format.dart +++ b/Phone App/lib/format.dart @@ -71,7 +71,149 @@ String formatPartialDate(String iso) { /// disagree on screen. String formatDateTime(DateTime? d) { if (d == null) return "—"; - return "${formatDate(d)} ${DateFormat.Hm(_locale).format(d)}"; + return "${formatDate(d)} ${formatTime(d)}"; +} + +/// The clock alone. "auto" leaves the reading to the region, which is what every +/// time in the app said before there was a setting; the other two are for the +/// people whose region and habit disagree — plenty of Poles read 12-hour clocks +/// and plenty of Americans read 24-hour ones, and the region picker also decides +/// how money and numbers are grouped, so it is the wrong lever to reach for. +/// +/// The setting decides *which* clock; this file decides how it is punctuated. +/// A region is worth asking whether a reader expects 13:45 or 01:45 pm — that is +/// a real difference in how people tell the time. It is not worth asking whether +/// the two numbers are joined by a colon or a dot: Danish writes 13.45, and one +/// screen of DriverVault writing 13.45 while the next writes 13:45 is not local +/// colour, it is an inconsistency. So every time this app prints comes out of +/// the two lines below, the same as the web app's format.js. +/// +/// The cost is that the am/pm marker reads in English everywhere. It is the same +/// trade the setting itself makes: a 12-hour clock is not a convention most of +/// these regions use, so choosing one — or living in a region that does — is +/// choosing the clock that comes with it. +String formatTime(DateTime? d) { + if (d == null) return "—"; + final mm = d.minute.toString().padLeft(2, "0"); + if (!clockIsTwelveHour()) return "${d.hour.toString().padLeft(2, "0")}:$mm"; + // 12 for both noon and midnight, and midnight is the am one. + final h = d.hour % 12 == 0 ? 12 : d.hour % 12; + return "${h.toString().padLeft(2, "0")}:$mm ${d.hour < 12 ? "am" : "pm"}"; +} + +/// A wall-clock "HH:MM" — a schedule is a time of day, not a moment, so there is +/// no date to hand [formatTime] — read on the clock the user chose. Anything +/// that is not a time of day comes back as it arrived. +String formatClock(String hhmm) { + final m = RegExp(r"^(\d{1,2}):(\d{2})$").firstMatch(hhmm.trim()); + if (m == null) return hhmm; + final h = int.parse(m.group(1)!); + if (!clockIsTwelveHour()) return "${h.toString().padLeft(2, "0")}:${m.group(2)}"; + final twelve = h % 12 == 0 ? 12 : h % 12; + return "${twelve.toString().padLeft(2, "0")}:${m.group(2)} ${h < 12 ? "am" : "pm"}"; +} + +/// Whether times are written on a 12-hour clock right now: what the setting says +/// outright, or what the region says when it is left on auto. +/// +/// Not only [formatTime]'s business. A control that lets somebody *enter* a time +/// has to offer the same clock, and a box that reads 13:45 beside a picker that +/// says 01:45 PM is the disagreement this setting exists to end — see +/// widgets/time_field.dart. +bool clockIsTwelveHour() { + switch (appSettings.timeFormat) { + case "12": + return true; + case "24": + return false; + default: + return _regionReadsTwelveHour(); + } +} + +/// Whether the chosen region tells the time on a 12-hour clock — the one +/// question "auto" asks it. Cached because this is asked once per timestamp on a +/// page that can hold a great many, and the answer only changes with the region. +final Map _twelveHourRegions = {}; + +bool _regionReadsTwelveHour() { + return _twelveHourRegions.putIfAbsent(_locale, () { + try { + // DateFormat.j() is the locale's own preferred hour field: "h" where it is + // read on a 12-hour clock, "H" where it is not. + return DateFormat.j(_locale).pattern?.contains("h") ?? false; + } catch (_) { + // An unusable locale is not a reason to print nothing; 24-hour is the + // safer default, being the one that cannot be read as the wrong half of + // the day. + return false; + } + }); +} + +// --- Weekdays --------------------------------------------------------------- +// +// A week does not start on the same day everywhere: Monday across most of +// Europe, Sunday in the US and a good deal of Asia. A row of weekday buttons +// that always begins on Sunday reads wrong to half the people looking at it, +// and reads wrong in a way that is easy to mistap — Settings › Appearance › +// First day of the week is the answer, with "auto" following the chosen region +// the way the clock setting does. +// +// Everything that lays weekdays out in a row goes through these, so there is one +// answer to "which day comes first" rather than one per screen. Days are +// numbered the way the scheduler's stored tasks number them: 0 = Sunday … +// 6 = Saturday. + +/// Whether weeks are drawn as starting on Monday right now: what the setting +/// says outright, or what the region says when it is left on auto. +bool weekStartsOnMonday() { + switch (appSettings.weekStart) { + case "monday": + return true; + case "sunday": + return false; + default: + return _regionStartsOnMonday(); + } +} + +final Map _mondayRegions = {}; + +bool _regionStartsOnMonday() { + return _mondayRegions.putIfAbsent(_locale, () { + try { + // intl carries the region's own answer in its date symbols, numbered + // 0 = Monday … 6 = Sunday (the Closure convention its data came from). + return DateFormat.yMd(_locale).dateSymbols.FIRSTDAYOFWEEK == 0; + } catch (_) { + // Monday is the safer default: it is ISO 8601's, and the convention in + // every region this app's own currency list covers bar one. + return true; + } + }); +} + +/// The seven days in the order they should be drawn, as day numbers. +List weekdaysInOrder() => + weekStartsOnMonday() ? const [1, 2, 3, 4, 5, 6, 0] : const [0, 1, 2, 3, 4, 5, 6]; + +/// One day's short name in the user's own language, so a row reads Pn Wt Śr in +/// Polish without a table here. 2024-01-07 was a Sunday, which is where day 0 +/// sits, so the offset lands each number on its own day. +String weekdayShortName(int day) { + try { + return DateFormat.E(_locale).format(DateTime.utc(2024, 1, 7 + day)); + } catch (_) { + return "$day"; + } +} + +/// A set of days, listed in the order this account reads a week in — so the same +/// three days always come out in the same order wherever they are shown. +List sortWeekdays(Iterable days) { + final order = weekdaysInOrder(); + return days.toList()..sort((a, b) => order.indexOf(a).compareTo(order.indexOf(b))); } // 0 km is a reading — a car collected new — not a blank. See format.js. diff --git a/Phone App/lib/models.dart b/Phone App/lib/models.dart index d804b42..fc6e661 100644 --- a/Phone App/lib/models.dart +++ b/Phone App/lib/models.dart @@ -890,6 +890,12 @@ class UserProfile { final String theme; // light | dark | system final String locale; // BCP-47 language-REGION, e.g. "en-US" final String dateFormat; // YMD | DMY_NUM | DMY | MDY + final String timeFormat; // auto (the region's own) | 24 | 12 + + /// The day a week is drawn as starting on, wherever weekdays are laid out in + /// a row — the charging scheduler's day picker today. + final String weekStart; // auto (the region's own) | monday | sunday + final String currency; // ISO 4217 code, e.g. "EUR" final String fontSize; // small | medium | large final String role; // user | admin @@ -914,6 +920,8 @@ class UserProfile { required this.theme, required this.locale, required this.dateFormat, + this.timeFormat = "auto", + this.weekStart = "auto", this.currency = "USD", required this.fontSize, required this.role, @@ -934,6 +942,8 @@ class UserProfile { theme: j["theme"] == null ? "system" : _asStr(j["theme"]), locale: j["locale"] == null ? "en-US" : _asStr(j["locale"]), dateFormat: j["dateFormat"] == null ? "YMD" : _asStr(j["dateFormat"]), + timeFormat: j["timeFormat"] == null ? "auto" : _asStr(j["timeFormat"]), + weekStart: j["weekStart"] == null ? "auto" : _asStr(j["weekStart"]), currency: j["currency"] == null ? "USD" : _asStr(j["currency"]), fontSize: j["fontSize"] == null ? "medium" : _asStr(j["fontSize"]), role: j["role"] == null ? "user" : _asStr(j["role"]), @@ -1235,6 +1245,10 @@ class AnkerCharger { final String statusDesc; // charging | standby | … as the cloud names it final bool? online; // null when no view reported a connection state + /// The product shot the service holds for this model, when it sent one. A URL + /// rather than an image: it is fetched only where it is drawn. + final String imageUrl; + const AnkerCharger({ required this.sn, this.name = "", @@ -1243,6 +1257,7 @@ class AnkerCharger { this.siteName = "", this.statusDesc = "", this.online, + this.imageUrl = "", }); factory AnkerCharger.fromJson(Map j) => AnkerCharger( @@ -1253,6 +1268,7 @@ class AnkerCharger { siteName: _asStr(j["siteName"]), statusDesc: _asStr(j["statusDesc"]), online: j["online"] is bool ? j["online"] as bool : null, + imageUrl: _asStr(j["imageUrl"]), ); /// What to call the charger in a list: its name when it has one, its serial @@ -1284,6 +1300,180 @@ class AnkerChargerList { } } +// --- RFID cards (who may start a charge without a phone) -------------------- + +/// One card authorised on a charger, as the account holds it. The cloud answers +/// with its own field names — alias_name, card_number, create_time — and this is +/// the same three read out: a number to delete by, the name it was given, and +/// when it was added. +class RfidCard { + final String number; + final String name; + final String added; // as the cloud sent it: unix seconds, or "" + + const RfidCard({required this.number, this.name = "", this.added = ""}); + + factory RfidCard.fromJson(Map j) { + final number = _asStr(j["card_number"]).trim(); + final name = _asStr(j["alias_name"]).trim(); + return RfidCard( + number: number, + // A card with no name of its own is still a card somebody holds, and its + // number is the only honest thing to call it. + name: name.isEmpty ? number : name, + added: _asStr(j["create_time"]).trim(), + ); + } +} + +/// What a card write answers with: whether the account holds that card now, and +/// the whole list as it stands after the write. Anker documents neither endpoint, +/// so a 200 proves nothing on its own — it is the list that says what happened. +class RfidCardWrite { + final bool present; + final List cards; + final String detail; + + const RfidCardWrite({this.present = false, this.cards = const [], this.detail = ""}); + + factory RfidCardWrite.fromJson(Map j) => RfidCardWrite( + present: _asBool(j["present"]), + cards: j["cards"] is List + ? (j["cards"] as List) + .whereType() + .map((c) => RfidCard.fromJson(Map.from(c))) + .where((c) => c.number.isNotEmpty) + .toList() + : const [], + detail: _asStr(j["detail"]), + ); +} + +/// What a scan answers with: the card that was held against the reader, or the +/// plain fact that nothing was. A window that closed empty is an answer, not a +/// timeout, which is why [tapped] is separate from [card]. +class RfidScan { + final bool tapped; + final String card; + + const RfidScan({this.tapped = false, this.card = ""}); + + factory RfidScan.fromJson(Map j) => + RfidScan(tapped: _asBool(j["tapped"]), card: _asStr(j["card"]).trim()); +} + +// --- the charging scheduler ------------------------------------------------- +// +// The charger's own cloud schedule can say one thing — "charge between these +// hours" — and it says it inside one charger. This is a list, and each entry is +// a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30, on these +// chargers, on these days. One named thing, switched on and off as one. + +/// One command in a task's flow: what to do, and at what time of day. +class ChargingStep { + /// start, stop, limit (to [amps]) or boost. + final String action; + final double amps; + + /// A 24-hour "HH:MM", read in the task's zone. + final String time; + + const ChargingStep({required this.action, required this.time, this.amps = 0}); + + factory ChargingStep.fromJson(Map j) => ChargingStep( + action: _asStr(j["action"]), + time: _asStr(j["time"]), + amps: _asDouble(j["amps"]), + ); + + Map toJson() => {"action": action, "time": time, "amps": amps}; +} + +/// One entry in the home-charger scheduler. It belongs to the person, like the +/// chargers it acts on — one list covering every charger they own, rather than a +/// separate schedule inside each one. +class ChargingTask { + final String id; + final String name; + + /// The home-charger records this task acts on. Empty means every charger the + /// owner has, including ones imported after the task was written — "all of + /// them" is a standing wish, not the list that happened to exist that day. + final List chargers; + + /// The flow, in the order it runs. + final List steps; + + /// The IANA zone the steps' times are read in. The server's own clock is not + /// the one the user set 23:00 by. + final String zone; + + /// The weekdays it repeats on, 0=Sunday … 6=Saturday. Empty means every day. + final List days; + + final bool enabled; + + /// What happened the last time a step of it fired, so a task that has been + /// failing quietly for a week says so in the list rather than in a log nobody + /// reads. + final DateTime? lastRun; + final String lastResult; + + const ChargingTask({ + required this.id, + this.name = "", + this.chargers = const [], + this.steps = const [], + this.zone = "", + this.days = const [], + this.enabled = true, + this.lastRun, + this.lastResult = "", + }); + + factory ChargingTask.fromJson(Map j) => ChargingTask( + id: _asStr(j["id"]), + name: _asStr(j["name"]), + chargers: _asStrList(j["chargers"]), + steps: j["steps"] is List + ? (j["steps"] as List) + .whereType() + .map((e) => ChargingStep.fromJson(Map.from(e))) + .toList() + : const [], + zone: _asStr(j["zone"]), + days: j["days"] is List ? (j["days"] as List).map(_asInt).toList() : const [], + enabled: _asBool(j["enabled"]), + lastRun: j["lastRun"] == null ? null : DateTime.tryParse(_asStr(j["lastRun"]))?.toLocal(), + lastResult: _asStr(j["lastResult"]), + ); + + /// The time of day the task begins — its first step's, which is what the list + /// is ordered by, so the evening's task sits below the morning's. + String get firstTime => steps.isEmpty ? "99:99" : steps.first.time; + + /// Whether the last firing reached every charger it was aimed at. The server + /// words the outcome as the step it fired and then "n of m sent", so every + /// charger answering is the only good case; unknown until it has fired once. + bool? get lastRunOk { + if (lastRun == null) return null; + final m = RegExp(r"(\d+) of (\d+) sent$").firstMatch(lastResult); + return m != null && m.group(1) == m.group(2); + } + + ChargingTask copyWith({bool? enabled, DateTime? lastRun, String? lastResult}) => ChargingTask( + id: id, + name: name, + chargers: chargers, + steps: steps, + zone: zone, + days: days, + enabled: enabled ?? this.enabled, + lastRun: lastRun ?? this.lastRun, + lastResult: lastResult ?? this.lastResult, + ); +} + // --- home chargers (the user's own wallbox) --------------------------------- // // The garage's import, aimed at the wall: a charger on a connected service diff --git a/Phone App/lib/screens/charging_screen.dart b/Phone App/lib/screens/charging_screen.dart index c062bf5..d2222ff 100644 --- a/Phone App/lib/screens/charging_screen.dart +++ b/Phone App/lib/screens/charging_screen.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import "package:flutter/material.dart"; import "package:shared_preferences/shared_preferences.dart"; @@ -6,8 +8,10 @@ import "../i18n.dart"; import "../main.dart"; import "../models.dart"; import "../theme.dart"; +import "../widgets/time_field.dart"; import "car_view_sheet.dart" show arrangeKeys; import "charger_import_sheet.dart"; +import "charging_task_sheet.dart"; /// Charging & map screen, mirroring the web Charging.vue. Two tabs split the /// public charging network (a presentational discovery map + demo session + @@ -22,12 +26,50 @@ import "charger_import_sheet.dart"; /// 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 scheduler comes last by default: it acts on the chargers the tab before +/// it lists, so it reads as the thing you set up once the chargers are there. +const List kChargerTabKeys = ["public", "home", "scheduler"]; /// 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, +/// on the charger is what the page is opened for; the cards that may start a +/// charge without a phone directly under them, because it is the same question +/// asked of a person rather than of a button; the connection below those, /// because it is touched once and then left alone. -const List kChargerCardKeys = ["control", "connection", "readings", "info"]; +const List kChargerCardKeys = [ + "control", + "rfid", + "settings", + "connection", + "readings", + "info", +]; + +/// A card's place when the account arranged its cards before that card existed. +/// +/// [arrangeKeys] puts an unknown key at the end, which is right for a tab — a +/// new page belongs after the ones already there. It is wrong for a card added +/// between two others: somebody who has arranged their cards once should not +/// have to go looking for the new one at the bottom of the column. So a card the +/// stored order does not mention goes where the default order puts it, behind +/// the nearest neighbour that *is* mentioned. +List arrangeCards(List catalogue, List order) { + final arranged = []; + for (final key in order) { + if (catalogue.contains(key) && !arranged.contains(key)) arranged.add(key); + } + for (final key in catalogue) { + if (arranged.contains(key)) continue; + var at = 0; + for (final earlier in catalogue.sublist(0, catalogue.indexOf(key)).reversed) { + if (arranged.contains(earlier)) { + at = arranged.indexOf(earlier) + 1; + break; + } + } + arranged.insert(at, key); + } + return arranged; +} /// 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 @@ -92,7 +134,7 @@ class _ChargingScreenState extends State { if (!mounted) return; setState(() { _tabKeys = arrangeKeys(kChargerTabKeys, me.chargerTabOrder); - _cardKeys = arrangeKeys(kChargerCardKeys, me.chargerCardOrder); + _cardKeys = arrangeCards(kChargerCardKeys, me.chargerCardOrder); }); } catch (_) { // Keep the defaults. @@ -149,6 +191,8 @@ class _ChargingScreenState extends State { switch (key) { case "home": return _HomeTab(key: const ValueKey("home"), cardKeys: _cardKeys, active: active); + case "scheduler": + return _SchedulerTab(key: const ValueKey("scheduler"), active: active); default: return _PublicTab( key: const ValueKey("public"), @@ -261,6 +305,8 @@ class _ChargingArrangeSheetState extends State<_ChargingArrangeSheet> { /// 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"), + "rfid" => t("charging.rfid.title"), + "settings" => t("charging.modbus.settingsTitle"), "connection" => t("charging.control.connectionTitle"), "readings" => t("charging.modbus.title"), _ => t("charging.info.title"), @@ -991,6 +1037,847 @@ class _SessionCard extends StatelessWidget { /// /// 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. +// --- The scheduler ----------------------------------------------------------- +// +// The charger's own cloud schedule is one window inside one box: charge between +// these hours, every day, and that is the whole vocabulary. This is a list — +// each line an action, a time, the days it repeats on and the chargers it acts +// on — and one list covers the whole account rather than each charger hiding its +// own. +// +// The clock is the server's, not this app's. A schedule that only fires while +// the app is open would be a reminder; the tab writes tasks and reads back how +// each one last went. + +class _SchedulerTab extends StatefulWidget { + /// Whether this is the tab being read. Its own tasks and the chargers they act + /// on are asked for when the question is actually being asked, not because an + /// IndexedStack built every tab. + final bool active; + const _SchedulerTab({super.key, required this.active}); + @override + State<_SchedulerTab> createState() => _SchedulerTabState(); +} + +class _SchedulerTabState extends State<_SchedulerTab> { + List _tasks = const []; + List _chargers = const []; + bool _loaded = false; + String? _error; + String _running = ""; // ":" of the step being fired + String _toggling = ""; + + @override + void initState() { + super.initState(); + if (widget.active) _load(); + } + + @override + void didUpdateWidget(_SchedulerTab old) { + super.didUpdateWidget(old); + if (widget.active && !old.active && !_loaded) _load(); + } + + Future _load() async { + try { + // The tasks act on the chargers the tab before this one lists, so the + // schedule can be read against the boxes it acts on without switching back. + final chargers = await apiClient.listHomeChargers(); + final tasks = await apiClient.listChargingTasks(); + if (!mounted) return; + setState(() { + _chargers = chargers; + _tasks = tasks; + _loaded = true; + _error = null; + }); + } catch (e) { + if (mounted) setState(() => _error = "$e"); + } + } + + /// A saved task replaces its old self in place rather than the list being + /// fetched again: the row that was just edited should not move under the + /// finger that edited it, and a new one belongs where its time puts it. + void _onSaved(ChargingTask task) { + setState(() { + final i = _tasks.indexWhere((x) => x.id == task.id); + if (i >= 0) { + final next = [..._tasks]; + next[i] = task; + _tasks = next; + } else { + // Ordered by the time each task begins, which is what the server sends + // back and what the day runs them in. + _tasks = [..._tasks, task]..sort((a, b) => a.firstTime.compareTo(b.firstTime)); + } + }); + } + + Future _newTask() async { + final task = await showChargingTaskSheet(context, chargers: _chargers); + if (task != null && mounted) _onSaved(task); + } + + Future _editTask(ChargingTask task) async { + final saved = await showChargingTaskSheet(context, task: task, chargers: _chargers); + if (saved != null && mounted) _onSaved(saved); + } + + /// The switch in the row. Written straight through rather than optimistically: + /// a schedule that says it is on when the server thinks otherwise is the one + /// mistake this list must not make. + Future _toggle(ChargingTask task) async { + setState(() { + _toggling = task.id; + _error = null; + }); + try { + final saved = await apiClient.updateChargingTask(task.id, {"enabled": !task.enabled}); + if (mounted) _onSaved(saved); + } catch (e) { + if (mounted) setState(() => _error = "$e"); + } finally { + if (mounted) setState(() => _toggling = ""); + } + } + + /// Fire one step now, without waiting for its time — the only way to find out + /// whether it will actually reach the charger before the night it matters. One + /// step rather than the whole flow: running a start and the stop that closes + /// it back to back would leave the charger where it began and prove nothing. + /// + /// The server takes the same path the clock takes, so what comes back is what + /// will happen then, errors included. + Future _runStep(ChargingTask task, int index) async { + setState(() { + _running = "${task.id}:$index"; + _error = null; + }); + try { + final summary = await apiClient.runChargingStep(task.id, index); + // The row's own "last run" line is what reports this, so the answer is + // folded into the record rather than announced somewhere else. + if (mounted) _onSaved(task.copyWith(lastRun: DateTime.now(), lastResult: summary)); + } catch (e) { + if (mounted) setState(() => _error = "$e"); + } finally { + if (mounted) setState(() => _running = ""); + } + } + + Future _remove(ChargingTask task) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + content: Text(t("charging.scheduler.removeConfirm", params: {"name": task.name})), + 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")), + ), + ], + ), + ); + if (ok != true) return; + setState(() => _error = null); + try { + await apiClient.deleteChargingTask(task.id); + if (mounted) setState(() => _tasks = _tasks.where((x) => x.id != task.id).toList()); + } catch (e) { + if (mounted) setState(() => _error = "$e"); + } + } + + /// What one step of a flow does. The ceiling is part of the sentence for the + /// one action that has one — "Set current limit" alone does not say to what. + String _stepLabel(ChargingStep s) { + final label = t("charging.scheduler.actions.${s.action}"); + return s.action == "limit" ? "$label · ${s.amps.round()} A" : label; + } + + String _chargersLabel(ChargingTask task) { + if (task.chargers.isEmpty) return t("charging.scheduler.allChargers"); + final names = []; + for (final id in task.chargers) { + for (final c in _chargers) { + if (c.id == id) names.add(c.name); + } + } + // A task can outlive a charger it names — the server skips the missing one + // rather than failing, and the row says as much instead of showing a gap. + if (names.isEmpty) return t("charging.scheduler.missingChargers"); + return names.join(", "); + } + + String _daysLabel(ChargingTask task) { + if (task.days.isEmpty) return t("charging.scheduler.everyDay"); + // Listed in the order this account reads a week in, so "Mon Fri" and the + // picker that wrote it agree about which end of the week comes first. + return sortWeekdays(task.days).map(weekdayShortName).join(" "); + } + + /// Whether the last firing went through, so the row can colour it. Unknown + /// until it has fired once — a task written this afternoon has nothing to + /// report. + Color _runTone(BuildContext context, ChargingTask task) { + final ok = task.lastRunOk; + if (ok == null) return DriverVault.muted(context); + return ok ? DriverVault.success : DriverVault.warning; + } + + /// The tasks that will act on one charger — the ones that name it, plus every + /// task that names none, since those act on all of them. + int _taskCount(HomeCharger c) => + _tasks.where((task) => task.chargers.isEmpty || task.chargers.contains(c.id)).length; + + @override + Widget build(BuildContext context) { + final muted = DriverVault.muted(context); + return Column( + children: [ + _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t("charging.scheduler.title"), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Text(t("charging.scheduler.subtitle"), + style: TextStyle(fontSize: 12, color: muted)), + ], + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _chargers.isEmpty ? null : _newTask, + child: Text(t("charging.scheduler.add")), + ), + ], + ), + + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(_error!, + style: const TextStyle(fontSize: 13, color: DriverVault.danger)), + ), + + // Nothing to act on yet: a task with no charger behind it would + // only ever report that it could not send anything. + if (_chargers.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text(t("charging.scheduler.needCharger"), + style: TextStyle(fontSize: 13, color: muted)), + ) + else if (_tasks.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text(t("charging.scheduler.empty"), + style: TextStyle(fontSize: 13, color: muted)), + ) + else + for (final task in _tasks) _taskRow(context, task, muted), + + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text(t("charging.scheduler.serverHint"), + style: TextStyle(fontSize: 12, color: muted)), + ), + ], + ), + ), + const SizedBox(height: 16), + + // The same chargers the tab before this one lists, so the schedule can + // be read against the boxes it acts on. Each says how many tasks touch + // it, which is the question this list is here to answer. Read-only: + // this tab is about the schedule. + _Card( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(6, 6, 6, 4), + child: Text( + "${t("charging.stations.homeHeading")} · " + "${t("charging.home.count", params: {"n": _chargers.length}, n: _chargers.length)}", + style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted) + .copyWith(letterSpacing: 1.4), + ), + ), + for (final c in _chargers) + Padding( + padding: const EdgeInsets.all(10), + child: Row(children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: DriverVault.isDark(context) + ? DriverVault.darkSunken + : DriverVault.ink50, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.bolt, size: 18, color: muted), + ), + 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) + Text(c.subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DriverVault.mono(context, size: 11, color: muted)), + ], + ), + ), + const SizedBox(width: 8), + Text( + t("charging.scheduler.taskCount", + params: {"n": _taskCount(c)}, n: _taskCount(c)), + style: TextStyle(fontSize: 11, color: muted), + ), + ]), + ), + if (_chargers.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(6, 4, 6, 8), + child: Text(t("charging.home.empty"), + style: TextStyle(fontSize: 13, color: muted)), + ), + ], + ), + ), + ], + ); + } + + /// One row per task: what it does and when, which chargers, which days, and + /// how the last firing went. + Widget _taskRow(BuildContext context, ChargingTask task, Color muted) { + final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50; + return Opacity( + opacity: task.enabled ? 1 : 0.6, + child: Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: sunken, + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(task.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 2), + Text("${_chargersLabel(task)} · ${_daysLabel(task)}", + style: TextStyle(fontSize: 12, color: muted)), + ], + ), + ), + // The switch. It governs the whole flow: the task is one + // intention and is switched off as one. + Switch( + value: task.enabled, + onChanged: _toggling == task.id ? null : (_) => _toggle(task), + ), + ], + ), + + // The flow, a line per step. Read down, they are the night — which + // is the whole reason a task holds more than one. + for (var i = 0; i < task.steps.length; i++) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Row(children: [ + SizedBox( + width: 82, + child: Text(formatClock(task.steps[i].time), + style: DriverVault.mono(context, size: 13, weight: FontWeight.w600)), + ), + Expanded( + child: Text(_stepLabel(task.steps[i]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12)), + ), + // Per step, because a flow is not a thing that can happen at + // once: firing a start and the stop that closes it back to + // back would leave the charger where it began. + TextButton( + onPressed: _running == "${task.id}:$i" ? null : () => _runStep(task, i), + child: Text( + _running == "${task.id}:$i" + ? t("charging.scheduler.running") + : t("charging.scheduler.runNow"), + style: TextStyle(fontSize: 11, color: muted), + ), + ), + ]), + ), + + if (task.lastRun != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + "${t("charging.scheduler.lastRun", params: {"when": formatDateTime(task.lastRun)})} · " + "${task.lastResult.isEmpty ? t("charging.scheduler.noResult") : task.lastResult}", + style: TextStyle(fontSize: 11, color: _runTone(context, task)), + ), + ), + + Row(children: [ + TextButton(onPressed: () => _editTask(task), child: Text(t("common.edit"))), + TextButton( + onPressed: () => _remove(task), + child: Text(t("common.remove"), style: const TextStyle(color: DriverVault.danger)), + ), + ]), + ], + ), + ), + ); + } +} + + +// --- What the cloud transport can be told ------------------------------------ +// +// Modbus has four writable registers. The cloud has the charger's whole settings +// group: everything the Anker app can set on it short of the card list. They are +// the same names a settings write takes and the same ones the snapshot reports +// them under, so every control there is seeded from the charger, edited, and +// sent back by name. +// +// A table rather than two dozen hand-written controls, because the charger's own +// commands own *sets* of fields: a command is taken whole, and a schedule that +// arrives carrying only its switch is a schedule whose times have just been set +// to midnight. A block is one write, and that stays true as fields are added to +// it only if the blocks are data. + +/// The floor a current limit can be set to. Below it the charger pauses rather +/// than charging slowly, which is a different thing from a slow charge. +const int _kLimitFloor = 6; + +/// The shape of one control in a settings block. +enum _SetKind { slider, toggle, option, number, window } + +/// One setting, or for a window the two ends of one. +/// +/// [at] is where the value is read back from in the snapshot — the settings +/// object for most, the top level for the ones the charger reports outside it, +/// and `local` for the Modbus server switch. A null [max] means the ceiling is +/// the charger's own rating rather than a constant. +class _SetField { + final _SetKind kind; + final String key; + final String at; + final String label; + final num min; + final num? max; + final num step; + final String unit; + final String hint; + final String enumPrefix; + final List values; + final String from; + final String to; + final String atFrom; + final String atTo; + + const _SetField({ + required this.kind, + required this.label, + this.key = "", + this.at = "", + this.min = 0, + this.max, + this.step = 1, + this.unit = "", + this.hint = "", + this.enumPrefix = "", + this.values = const [], + this.from = "", + this.to = "", + this.atFrom = "", + this.atTo = "", + }); + + /// The draft keys this field owns — two for a window, one for everything else. + List get keys => kind == _SetKind.window ? [from, to] : [key]; + + /// Each of those, with the snapshot path it is read back from. + List<(String, String)> get entries => + kind == _SetKind.window ? [(from, atFrom), (to, atTo)] : [(key, at)]; +} + +class _SetBlock { + final String id; + final String title; + + /// Said next to the switch rather than after it has been thrown. + final String warning; + final List<_SetField> fields; + + const _SetBlock({ + required this.id, + required this.title, + required this.fields, + this.warning = "", + }); +} + +const List<_SetBlock> _kMqttSettingBlocks = [ + _SetBlock(id: "charging", title: "blockCharging", fields: [ + // A slider rather than a box, like the control card had and the Modbus + // settings card still has: it is the same value they set, and a ceiling is a + // thing you slide between two known ends rather than type. + _SetField( + kind: _SetKind.slider, + key: "maxCurrentA", + at: "settings.maxCurrentA", + label: "maxCurrentSet", + min: _kLimitFloor, + unit: "A", + hint: "limitFloorHint", + ), + _SetField(kind: _SetKind.toggle, key: "autoStart", at: "settings.autoStart", label: "autoStart"), + _SetField( + kind: _SetKind.toggle, key: "randomDelay", at: "settings.randomDelay", label: "randomDelay"), + _SetField(kind: _SetKind.toggle, key: "plugLock", at: "settings.plugLock", label: "plugLock"), + _SetField( + kind: _SetKind.toggle, key: "autoRestart", at: "settings.autoRestart", label: "autoRestart"), + ]), + _SetBlock(id: "schedule", title: "blockSchedule", fields: [ + _SetField( + kind: _SetKind.toggle, + key: "scheduleEnabled", + at: "settings.scheduleEnabled", + label: "scheduleEnabled"), + _SetField( + kind: _SetKind.option, + key: "scheduleMode", + at: "settings.scheduleMode", + label: "scheduleMode", + enumPrefix: "scheduleMode", + values: [0, 1], + ), + _SetField( + kind: _SetKind.window, + label: "scheduleWindow", + from: "weekStart", + to: "weekEnd", + atFrom: "settings.weekStart", + atTo: "settings.weekEnd", + ), + _SetField( + kind: _SetKind.option, + key: "weekendMode", + at: "settings.weekendMode", + label: "weekendMode", + enumPrefix: "weekendMode", + values: [1, 2], + ), + _SetField( + kind: _SetKind.window, + label: "weekendWindow", + from: "weekendStart", + to: "weekendEnd", + atFrom: "settings.weekendStart", + atTo: "settings.weekendEnd", + ), + ]), + _SetBlock(id: "balancing", title: "blockBalancing", fields: [ + _SetField( + kind: _SetKind.toggle, key: "loadBalancing", at: "loadBalancing", label: "loadBalancing"), + _SetField( + kind: _SetKind.number, + key: "mainBreakerLimitA", + at: "settings.mainBreakerLimitA", + label: "mainBreakerLimit", + min: 10, + max: 500, + unit: "A", + ), + ]), + _SetBlock(id: "solar", title: "blockSolar", fields: [ + _SetField( + kind: _SetKind.toggle, key: "solarBalancing", at: "solarBalancing", label: "solarBalancing"), + _SetField( + kind: _SetKind.option, + key: "solarChargeMode", + at: "settings.solarChargeMode", + label: "solarChargeMode", + enumPrefix: "solarMode", + values: [0, 1], + ), + // A slider, like the current limit it shares a floor with. No floor note + // under it though: this is the least a solar charge will draw, not a + // ceiling, so the limit slider's hint would be saying the wrong thing. + _SetField( + kind: _SetKind.slider, + key: "solarMinCurrentA", + at: "settings.solarMinCurrentA", + label: "solarMinCurrent", + min: _kLimitFloor, + max: 32, + unit: "A", + ), + // This command offers automatic and single-phase only. The three-phase + // setting is a Modbus register, and offering it here would be offering a + // write that comes back refused. + _SetField( + kind: _SetKind.option, + key: "phaseMode", + at: "phaseMode", + label: "phaseSetting", + enumPrefix: "phaseSet", + values: [0, 1], + ), + _SetField( + kind: _SetKind.toggle, + key: "autoPhaseSwitching", + at: "settings.autoPhaseSwitching", + label: "autoPhaseSwitching"), + ]), + _SetBlock(id: "panel", title: "blockPanel", fields: [ + // A slider, like the current limit: a brightness is a place on a range, and + // typing 70 into a box that only takes tens is a worse way to say it. + _SetField( + kind: _SetKind.slider, + key: "ledBrightness", + at: "ledBrightness", + label: "ledBrightness", + max: 100, + step: 10, + unit: "%", + ), + _SetField( + kind: _SetKind.toggle, + key: "lightOffSchedule", + at: "settings.lightOffSchedule", + label: "lightOff"), + _SetField( + kind: _SetKind.window, + label: "lightOffWindow", + from: "lightOffStart", + to: "lightOffEnd", + atFrom: "settings.lightOffStart", + atTo: "settings.lightOffEnd", + ), + _SetField( + kind: _SetKind.option, + key: "swipeUpMode", + at: "swipeUpMode", + label: "swipeUp", + enumPrefix: "gesture", + values: [0, 1, 2, 3], + ), + _SetField( + kind: _SetKind.option, + key: "swipeDownMode", + at: "swipeDownMode", + label: "swipeDown", + enumPrefix: "gesture", + values: [0, 1, 2, 3], + ), + _SetField( + kind: _SetKind.option, + key: "smartTouchMode", + at: "smartTouchMode", + label: "smartTouch", + enumPrefix: "touch", + values: [0, 1], + ), + ]), + // The one setting here that can cost you a control mode: with the server off + // the charger stops answering on the LAN, and Modbus mode has nothing left to + // dial. + _SetBlock( + id: "local", + title: "blockLocal", + warning: "modbusOffWarning", + fields: [ + _SetField( + kind: _SetKind.toggle, + key: "modbusEnabled", + at: "local.modbusEnabled", + label: "modbusServer"), + ], + ), +]; + + +// --- The service's own field names, given the names the card uses ------------ +// +// Anker documents none of these, so only the fields whose meaning is plain from +// the value are named here — each says which group it belongs in and what to +// call it. A field whose meaning would be a guess stays out and keeps its own +// key in the box below, where the key is the only honest label it has. + +class _NamedAttr { + final String group; + final String label; + + /// A flag the service sends as true/false or 1/0, read out in words. + final bool isBool; + + /// A row the card already draws, written another way: the row is dropped when + /// the two say the same thing. + final String sameAs; + + const _NamedAttr(this.group, this.label, {this.isBool = false, this.sameAs = ""}); +} + +const Map _kNamedAttrs = { + "alias_name": _NamedAttr("device", "nickname"), + "product_code": _NamedAttr("device", "productCode"), + "ms_device_type": _NamedAttr("device", "deviceType"), + "charge": _NamedAttr("status", "charging", isBool: true), + "chargerStatus": _NamedAttr("status", "statusCode"), + "ocpp_connect_status": _NamedAttr("status", "ocppLink"), + // The box on the wall is on two networks, and the service says more about both + // than the typed fields carry. + "wifi_online": _NamedAttr("network", "wifiOnline", isBool: true), + "bt_ble_id": _NamedAttr("network", "bleId", sameAs: "bleMac"), + "blue_password": _NamedAttr("network", "blePassword"), + "owner_user_id": _NamedAttr("account", "ownerId"), +}; + +/// Fields that repeat, in the service's own words, a row the card already draws: +/// the same serial, the same firmware, the same Wi-Fi, the same picture. Shown +/// twice they make the card longer without making it say more, so they are +/// dropped instead. +const Set _kEchoedAttrs = { + "deviceName", + "device_name", + "deviceSn", + "device_sn", + "device_sw_version", + "img_url", + "link_time", + "rssi", + "time_zone", + "wifi_mac", + "wifi_name", + "bt_ble_mac", +}; + +/// relate_type arrives indexed — relate_type[0], relate_type[1] — and is the +/// list the "Reachable by" row is built from. +bool _isEchoedAttr(String key) => + _kEchoedAttrs.contains(key) || key.startsWith("relate_type["); + +// --- The keys the per-charger views answer with, in the card's words --------- +// +// Anker documents none of these payloads either, but a field like page_size or +// create_time says what it is once its key is read out, and those are named here +// rather than left as keys. Keyed by the field with any list index taken out, so +// list[0].name and list[3].name are one field asked about two records. Anything +// absent from this table keeps its own key, for the same reason the box above +// does: a name invented here would be a meaning invented here. + +class _ViewField { + final String label; + + /// A unix second the cloud sent as a bare number, read as a date. + final bool time; + const _ViewField(this.label, {this.time = false}); +} + +const Map _kViewFields = { + // What the account has counted for this charger. + "total_stats.charge_count": _ViewField("sessions"), + "total_stats.charge_time": _ViewField("chargeTime"), + "total_stats.charge_total": _ViewField("energy"), + "total_stats.co2_saving": _ViewField("co2Saved"), + "total_stats.cost": _ViewField("cost"), + "total_stats.cost_saving": _ViewField("costSaved"), + "total_stats.cost_unit": _ViewField("currency"), + "total_stats.mile_age": _ViewField("mileage"), + // How much of a list the view answered with — a page of a history that is + // empty is still the answer "there is nothing to page through". + "page": _ViewField("page"), + "page_num": _ViewField("page"), + "page_size": _ViewField("perPage"), + "total": _ViewField("records"), + "total_count": _ViewField("records"), + "start_use_time": _ViewField("from", time: true), + // The backend the charger is pointed at, and when it last said so. + "source": _ViewField("source"), + "time_zone": _ViewField("timeZone"), + "timestamp": _ViewField("updated", time: true), + "create_time": _ViewField("added", time: true), + // The records a list view answers with: one OCPP endpoint, one RFID card. + "list[].address": _ViewField("address"), + "list[].name": _ViewField("name"), + "list[].source": _ViewField("source"), + "list[].alias_name": _ViewField("cardName"), + "list[].card_number": _ViewField("cardNumber"), + "list[].create_time": _ViewField("added", time: true), + // Who else the charger is shared with, one person per record. + "email": _ViewField("email"), + "device_sn": _ViewField("serial"), + "member_id": _ViewField("memberId"), + "member_type": _ViewField("memberType"), + "user_id": _ViewField("userId"), + "status": _ViewField("status"), + "max_invite_members_count": _ViewField("inviteLimit"), +}; + +/// The name a record carries for itself, in the order the views use one: an RFID +/// card is its alias, an endpoint its name, a person their address. +const List _kViewItemTitles = ["alias_name", "name", "email"]; + +/// list[0].card_number split into the list, which record, and which field. +final RegExp _kViewListKey = RegExp(r"^([A-Za-z0-9_.]+)\[(\d+)\]\.(.+)$"); + +/// One row of a view: what to call it, what it says, and whether the label is a +/// name of ours or the cloud's own key — the two are never mistaken for each +/// other because the key keeps the typeface keys are read in. +class _ViewRow { + final String label; + final String value; + final bool named; + const _ViewRow(this.label, this.value, this.named); +} + +/// One record a list view answered with, under the name it carries for itself. +class _ViewItem { + final String label; + final List<_ViewRow> rows; + const _ViewItem(this.label, this.rows); +} + class _HomeTab extends StatefulWidget { final List cardKeys; @@ -1004,7 +1891,7 @@ class _HomeTab extends StatefulWidget { State<_HomeTab> createState() => _HomeTabState(); } -class _HomeTabState extends State<_HomeTab> { +class _HomeTabState extends State<_HomeTab> with WidgetsBindingObserver { // --- control --- final _serial = TextEditingController(); final _host = TextEditingController(); @@ -1061,6 +1948,81 @@ class _HomeTabState extends State<_HomeTab> { /// Which cards are folded, read once and written on every toggle. Set _collapsed = {}; + /// Keeping the list's reachability current. + /// + /// Who is reachable changes on its own, so asking once when the tab opens left + /// the bolts saying whatever was true when it did. This re-asks while the tab + /// is actually being looked at: not while the app is in the background, not on + /// the public tab, and not the per-charger views — those are a dozen cloud + /// endpoints per charger, where reachability is one call per service. + static const Duration _kLivePoll = Duration(seconds: 30); + Timer? _livePoll; + + /// Waiting for the half of the snapshot that answers late. + /// + /// A snapshot has two halves on two messages. The telemetry comes from the + /// trigger and is there by the time the read returns; the settings come when + /// the charger gets round to answering the request for them, which on this + /// A5191 was measured at around half a minute — long after the read that asked + /// has gone. The frame is not lost: it lands in the server's state and the next + /// read carries it. But nothing here took a next read, so a settings card that + /// came up empty stayed empty until something else happened to refresh it. + /// + /// So a refresh that comes back without them queues another look, at widening + /// gaps, and then stops. Stopping matters: the message the server asks with is + /// one the reference reads as carrying an Anker bug, so a charger that never + /// answers it is a real possibility, and a page left open all day must not poll + /// one for ever. + static const List _kSettingsRetryMs = [6000, 12000, 24000, 45000]; + Timer? _settingsRetry; + int _settingsRetryAt = 0; + String _settingsRetryFor = ""; + + // --- the charger's own settings, as it reports them back --- + // + // Modbus has four writable registers; the cloud has the charger's whole + // settings group. Both are seeded from the charger, edited, and sent back. + int _draftAmps = 16; + int _draftSeconds = 120; + int _draftPhase = 0; + + /// What the cloud's controls hold, and what the charger last said, so a block + /// can tell whether it has anything to send and can be put back if it has not + /// sent it. + Map _mqttDraft = {}; + Map _mqttBase = {}; + String _mqttBusy = ""; + + /// The settings the charger has reported, kept per serial across reads. + /// + /// The two halves of a snapshot arrive on different messages, and a read can + /// land with the telemetry and not the settings — most often the first read + /// after a reconnect. Seeding the controls from that answer alone emptied the + /// card of everything the charger had already told us, which is not the state + /// of the charger; it is the state of one message. So a value the charger has + /// reported stays until it reports another. + final Map> _mqttSeen = {}; + + // --- the cards that open the charger --- + final _newCardNumber = TextEditingController(); + final _newCardName = TextEditingController(); + + /// The card list as the last write read back, per serial. Anker's write + /// endpoints are undocumented, so a 200 proves nothing on its own — it is the + /// list that says what happened, and a refresh drops this copy so the account's + /// own answer is what wins in the end. + final Map> _rfidWritten = {}; + + /// The numbers the device itself answered with, per serial. Every add and + /// remove writes both halves and they can still come apart, and no other view + /// on this page would say so. + final Map> _chargerCards = {}; + + String _rfidBusy = ""; // a card number, "new", "scan", "tapSave" or "charger" + int _rfidCountdown = 0; // seconds left of the reader's window, while it is open + Timer? _rfidTick; + String? _rfidError; + bool get _active => _mode != "off"; bool get _connected => _ctl?.connected == true; bool get _isModbus => _mode == "modbus"; @@ -1093,6 +2055,7 @@ class _HomeTabState extends State<_HomeTab> { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _init(); } @@ -1101,17 +2064,51 @@ class _HomeTabState extends State<_HomeTab> { super.didUpdateWidget(old); // Switching to this tab is the moment reachability is being asked about. if (widget.active && !old.active) _loadLive(); + _startLivePoll(); + } + + /// Coming back to an app that has been in the background asks straight away + /// rather than waiting out the rest of an interval that was never going to + /// fire — and a backgrounded app asks nothing at all. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) { + _stopLivePoll(); + return; + } + _loadLive(force: true, withDetails: false); + _startLivePoll(); } @override void dispose() { + WidgetsBinding.instance.removeObserver(this); + _stopLivePoll(); + _stopSettingsRetry(); + _rfidTick?.cancel(); _serial.dispose(); _host.dispose(); _port.dispose(); _resetPassword.dispose(); + _newCardNumber.dispose(); + _newCardName.dispose(); super.dispose(); } + void _stopLivePoll() { + _livePoll?.cancel(); + _livePoll = null; + } + + void _startLivePoll() { + _stopLivePoll(); + if (!widget.active) return; + _livePoll = Timer.periodic( + _kLivePoll, + (_) => _loadLive(force: true, withDetails: false), + ); + } + Future _init() async { final prefs = await SharedPreferences.getInstance(); if (!mounted) return; @@ -1121,6 +2118,7 @@ class _HomeTabState extends State<_HomeTab> { await _loadHomeChargers(); await _loadProviders(); if (widget.active) await _loadLive(); + _startLivePoll(); await _loadMode(); if (_active) await _loadAccountChargers(); await _refresh(); @@ -1199,18 +2197,20 @@ class _HomeTabState extends State<_HomeTab> { /// 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 { + Future _loadLive({bool force = false, bool withDetails = true}) 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 = {}; + var answered = false; 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; } + answered = true; } catch (_) { // A service that will not answer leaves its chargers unknown rather // than offline — this page cannot tell those two apart. @@ -1218,13 +2218,23 @@ class _HomeTabState extends State<_HomeTab> { })); if (!mounted) return; setState(() { - _live = live; - _liveLoaded = true; + // Merged rather than replaced, and only when something actually answered. + // A service that could not be reached used to take every charger it knows + // about grey with it and then mark the question asked, so the list sat + // colourless until somebody pressed Refresh. "We could not ask" is not an + // answer, and it is certainly not "unknown" — the last thing the service + // did say still stands, and a provider that did answer overwrites its own + // entries here. + if (answered) { + _live = {..._live, ...live}; + _liveLoaded = true; + } _liveLoading = false; }); // The card's other half: the views that answer per charger rather than per - // account. Refreshing the card refreshes both. - _loadDetails(force: force); + // account. Skipped by the poll above — those are a dozen cloud endpoints per + // charger, where reachability is one call per service. + if (withDetails) _loadDetails(force: force); } /// The per-charger views for the charger on screen. A view the account cannot @@ -1237,14 +2247,29 @@ class _HomeTabState extends State<_HomeTab> { : (c.providerChargerId.isNotEmpty ? c.providerChargerId : c.serial); if (sn.isEmpty || c!.provider != "anker-solix") return; if (_detailsLoading == sn || (_details.containsKey(sn) && !force)) return; - _detailsLoading = sn; + // Held in state rather than beside it: the RFID card's Refresh reads it to + // know it is already asking, and a field nothing rebuilds on would leave + // that button live through the whole read. + setState(() => _detailsLoading = sn); try { final res = await apiClient.getAnkerChargerDetails(sn); - if (mounted) setState(() => _details[sn] = res); + if (mounted) { + setState(() { + _details[sn] = res; + // The account has just been asked; whatever a write read back is now + // the older answer of the two. + _rfidWritten.remove(sn); + // The charger's own list was read against the account list that has + // just been replaced. Comparing it against the new one would be + // comparing two answers from different moments, so it is dropped and + // asked for again. + _chargerCards.remove(sn); + }); + } } catch (_) { // Left absent rather than shown as a failure. } finally { - _detailsLoading = ""; + if (mounted) setState(() => _detailsLoading = ""); } } @@ -1277,17 +2302,42 @@ class _HomeTabState extends State<_HomeTab> { // wrong charger. _host.text = c.modbusHost; _port.text = "${c.modbusPort}"; + _syncSettingsDraft(); }); + _chaseSettings(); } catch (e) { if (mounted) { setState(() { _ctlError = "$e"; _ctl = null; }); + _stopSettingsRetry(); } } } + void _stopSettingsRetry() { + _settingsRetry?.cancel(); + _settingsRetry = null; + } + + void _chaseSettings() { + final sn = _serial.text.trim(); + // A different charger is a different question, and gets its own patience. + if (sn != _settingsRetryFor) { + _settingsRetryFor = sn; + _settingsRetryAt = 0; + } + _stopSettingsRetry(); + if (sn.isEmpty || !_readsDevice || (_dev?.settings.isNotEmpty ?? false)) { + _settingsRetryAt = 0; + return; + } + if (_settingsRetryAt >= _kSettingsRetryMs.length) return; // the Refresh button remains + final wait = _kSettingsRetryMs[_settingsRetryAt++]; + _settingsRetry = Timer(Duration(milliseconds: wait), _refresh); + } + // --- acting on the charger --- Future _action(String action, [Map body = const {}]) async { @@ -1461,10 +2511,413 @@ class _HomeTabState extends State<_HomeTab> { return label == key ? src : label; }).join(" · "); + // --- what the charger is set to, and what can be written back -------------- + + /// The charger's own ceiling, which is what a limit slider runs up to. + int get _limitCeiling => (_dev?.number("maxCurrentA") ?? 32).round(); + + /// Seeds the Modbus card's three controls from the charger's last word, so a + /// value it clamped or refused shows as what it took rather than what was + /// asked for. Called inside the setState of a refresh. + void _syncSettingsDraft() { + final set = _dev?.settings ?? const {}; + final amps = set["maxCurrentA"]; + final seconds = set["timeoutSeconds"]; + final phase = set["phaseSetting"]; + if (amps is num) _draftAmps = amps.round(); + if (seconds is num) _draftSeconds = seconds.toInt(); + if (phase is num) _draftPhase = phase.toInt(); + _syncMqttSettings(); + } + + /// One dotted path into the charger's snapshot. + dynamic _snapshotValue(String path) { + dynamic node = _dev?.raw; + for (final part in path.split(".")) { + if (node is! Map) return null; + node = node[part]; + } + return node; + } + + void _syncMqttSettings() { + final sn = _serial.text.trim(); + final seen = {...?_mqttSeen[sn]}; + for (final block in _kMqttSettingBlocks) { + for (final f in block.fields) { + for (final (key, at) in f.entries) { + final v = _snapshotValue(at); + if (v != null && v != "") seen[key] = v; + } + } + } + _mqttSeen[sn] = seen; + _mqttDraft = {...seen}; + _mqttBase = {...seen}; + } + + /// Only the settings the charger has actually reported get a control. A value + /// it has not sent is one nothing here could seed a control from, and a blank + /// box that writes whatever it was left at is worse than no box: several of + /// these travel as siblings on one command, where an invented value is not + /// ignored but applied. + List<_SetBlock> get _mqttBlocks => [ + for (final b in _kMqttSettingBlocks) + if (b.fields.any((f) => f.keys.every(_mqttBase.containsKey))) + _SetBlock( + id: b.id, + title: b.title, + warning: b.warning, + fields: + b.fields.where((f) => f.keys.every(_mqttBase.containsKey)).toList(), + ), + ]; + + bool _blockDirty(_SetBlock block) => + block.fields.any((f) => f.keys.any((k) => _mqttDraft[k] != _mqttBase[k])); + + /// An empty number box holds null, which would travel as null and come back as + /// a parse error from the server. The block simply cannot be applied until it + /// holds a number again. + bool _blockValid(_SetBlock block) => + block.fields.every((f) => f.keys.every((k) => _mqttDraft[k] != null)); + + /// The options a select offers: the ones this command accepts, plus whatever + /// the charger actually reported if that is not among them. The phase field is + /// why — the charger reports the phase it is *running* on, which can be the + /// three-phase setting the solar command has no value for. Showing the + /// reported value keeps the select from silently reading as something the + /// charger did not say; sending it earns a refusal from the server, which is + /// the honest outcome for a value this command cannot carry. + List _fieldOptions(_SetField f) { + final reported = _mqttBase[f.key]; + if (reported is! num || f.values.contains(reported.toInt())) return f.values; + return [...f.values, reported.toInt()]; + } + + void _resetMqttBlock(_SetBlock block) { + setState(() { + for (final f in block.fields) { + for (final k in f.keys) { + _mqttDraft[k] = _mqttBase[k]; + } + } + }); + } + + /// Applying one block. The whole block goes, not only what changed: several of + /// these are one command on the wire, and the charger takes a command as the + /// new truth for every field it carries, so the siblings travel back with the + /// change. The server would refill them from the charger's last report anyway; + /// sending what is on screen means what is on screen is what gets written. + Future _applyMqttBlock(_SetBlock block) async { + final sn = _serial.text.trim(); + if (sn.isEmpty || _mqttBusy.isNotEmpty) return; + final settings = {}; + for (final f in block.fields) { + for (final k in f.keys) { + final v = _mqttDraft[k]; + if (v != null && v != "") settings[k] = v; + } + } + if (settings.isEmpty) return; + setState(() { + _mqttBusy = block.id; + _ctlError = null; + }); + try { + await apiClient.ankerControlAction(sn, "settings", {"settings": settings}); + // A refresh reseeds every control from the charger, so a value it clamped + // or refused shows as what it took rather than as what was asked for. + await _refresh(); + } catch (e) { + if (mounted) setState(() => _ctlError = "$e"); + } finally { + if (mounted) setState(() => _mqttBusy = ""); + } + } + + /// What the cloud reports in the settings group but has no command to write: + /// the meter and the monitor the two balancing features watch. The reference + /// has not pinned down what the modes and the flag select, which is also why + /// there is no control for them — a control would imply knowing what they mean. + List<(String, String)> _mqttSettingsReported(ChargerStatus s) => _rows([ + ("loadBalanceMeter", s.text("loadBalanceMonitorSN")), + ("loadBalanceMonitorMode", _plain(s.integer("loadBalanceMonitorMode"))), + ("loadBalanceMeterFlag", _plain(s.integer("loadBalanceMeterFlag"))), + ("solarMonitor", s.text("solarMonitorSN")), + ("solarMonitoringMode", _plain(s.integer("solarMonitoringMode"))), + ]); + + /// The rest of the Modbus settings block: the charger reports these, but the + /// register map has nothing to write them with. + List<(String, String)> _modbusSettingsReported(ChargerStatus s) { + final led = s.integer("ledBrightness"); + return _rows([ + ("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 %"), + ]); + } + + // --- the cards that open the charger --------------------------------------- + + /// The serial the per-charger views were asked under, which is the charger the + /// card list belongs to. + String get _detailSn { + final c = _selectedCharger; + if (c == null) return ""; + return c.providerChargerId.isNotEmpty ? c.providerChargerId : c.serial; + } + + /// The view that answers with the account's card list, when the account could + /// read it at all. + ChargerDetailView? get _rfidView { + for (final v in _details[_detailSn]?.views ?? const []) { + if (v.id == "rfid") return v; + } + return null; + } + + /// What the card draws: the list a write last read back when there is one, and + /// the account's own view of it otherwise. A refresh drops the write's copy, so + /// the server's answer is always what wins in the end. + List get _rfidCards { + final written = _rfidWritten[_detailSn]; + if (written != null) return written; + return _rfidCardsFrom(_rfidView?.attrs ?? const {}); + } + + /// The cards out of the flattened keys the view answers with: this card needs a + /// number to delete by, and a row of text is not a number. + List _rfidCardsFrom(Map attrs) { + final by = >{}; + final pattern = RegExp(r"^list\[(\d+)\]\.(alias_name|card_number|create_time)$"); + for (final entry in attrs.entries) { + final m = pattern.firstMatch(entry.key); + if (m == null) continue; + by.putIfAbsent(int.parse(m.group(1)!), () => {})[m.group(2)!] = entry.value; + } + final indices = by.keys.toList()..sort(); + return [ + for (final i in indices) + if ((by[i]!["card_number"] ?? "").toString().trim().isNotEmpty) + RfidCard.fromJson(by[i]!), + ]; + } + + /// Two numbers are the same card when they are the same hex; people and + /// services write them with spaces, dashes or colons, and the charger writes + /// them with none. The server normalizes what it stores, so this only has to + /// agree with it. + String _cardKey(String number) => + number.replaceAll(RegExp(r"[^0-9A-Za-z]"), "").toUpperCase(); + + List? get _chargerCardList => _chargerCards[_detailSn]; + + /// What the two lists disagree about, once the device has answered. Named from + /// the list each card is missing from, because that is what has to be fixed: a + /// card only on the charger opens it without the account knowing, and a card + /// only on the account is one the charger will not open for. + List get _cardsOnlyOnCharger { + final held = _chargerCardList; + if (held == null) return const []; + final account = _rfidCards.map((c) => _cardKey(c.number)).toSet(); + return held.where((n) => !account.contains(n)).toList(); + } + + List get _cardsOnlyOnAccount { + final held = _chargerCardList; + if (held == null) return const []; + return [ + for (final c in _rfidCards) + if (!held.contains(_cardKey(c.number))) c.number, + ]; + } + + /// Writing one card and then reading the list back, which is the only thing + /// that says whether the write landed. Shared by both ways of adding one, so + /// the tap and the typed number cannot end up judging their answers + /// differently. + /// + /// The name is whatever is in the box, and an empty box is not a missing name: + /// it is the server's own convention — "RFID" and the card's last four digits + /// — and leaving it to the server is what keeps the two ways of adding a card + /// from drifting into two naming conventions. + Future _writeCard(String sn, String number) async { + final res = await apiClient.saveAnkerRfidCard(sn, number, _newCardName.text.trim()); + if (!mounted) return; + setState(() { + _rfidWritten[sn] = res.cards; + if (!res.present) { + _rfidError = t("charging.rfid.notAdded"); + } else { + _newCardNumber.clear(); + _newCardName.clear(); + } + }); + } + + /// Adding a card, and then believing the list rather than the answer: Anker's + /// write endpoint is undocumented, so a 200 from it proves nothing on its own. + Future _addRfidCard() async { + final sn = _detailSn; + final number = _newCardNumber.text.trim(); + if (sn.isEmpty || number.isEmpty || _rfidBusy.isNotEmpty) return; + setState(() { + _rfidBusy = "new"; + _rfidError = null; + }); + try { + await _writeCard(sn, number); + } catch (e) { + if (mounted) setState(() => _rfidError = "$e"); + } finally { + if (mounted) setState(() => _rfidBusy = ""); + } + } + + /// The reader's own twenty seconds, as a value rather than as a side effect on + /// the form: one caller wants the number in the box, the other wants to write + /// it. The caller owns [_rfidBusy], so the tap-and-save button can hold it + /// across the write that follows and nothing re-enables between the two. + Future _readCardAtCharger(String sn) async { + setState(() => _rfidCountdown = 20); + _rfidTick?.cancel(); + _rfidTick = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() => _rfidCountdown = (_rfidCountdown - 1).clamp(0, 20)); + }); + try { + final res = await apiClient.scanAnkerRfidCard(sn); + return res.tapped ? res.card : ""; + } finally { + _rfidTick?.cancel(); + _rfidTick = null; + if (mounted) setState(() => _rfidCountdown = 0); + } + } + + /// Asking the charger to read a card, which is what the Anker app's second way + /// of adding one does: the reader opens for twenty seconds, and whatever is + /// held against it comes back as a number. Nothing is written by this — the + /// card lands in the form, and adding it is still a decision. + Future _scanRfidCard() async { + final sn = _detailSn; + if (sn.isEmpty || _rfidBusy.isNotEmpty) return; + setState(() { + _rfidBusy = "scan"; + _rfidError = null; + }); + try { + final card = await _readCardAtCharger(sn); + if (!mounted) return; + setState(() { + if (card.isNotEmpty) { + _newCardNumber.text = card; + } else { + _rfidError = t("charging.rfid.tapNone"); + } + }); + } catch (e) { + if (mounted) setState(() => _rfidError = "$e"); + } finally { + if (mounted) setState(() => _rfidBusy = ""); + } + } + + /// The same tap, carried through to the end: the reader opens, and whatever is + /// held against it is written without a second press. Enrolling a card happens + /// at the charger with the card in your hand — the walk back to the keyboard to + /// press Add was the whole cost of the two-step version. + /// + /// The number lands in the box on the way past, so a write that fails leaves + /// something to look at and retry rather than a card nobody can name. + Future _tapAndSaveRfidCard() async { + final sn = _detailSn; + if (sn.isEmpty || _rfidBusy.isNotEmpty) return; + setState(() { + _rfidBusy = "tapSave"; + _rfidError = null; + }); + try { + final card = await _readCardAtCharger(sn); + if (!mounted) return; + if (card.isEmpty) { + setState(() => _rfidError = t("charging.rfid.tapNone")); + return; + } + setState(() => _newCardNumber.text = card); + await _writeCard(sn, card); + } catch (e) { + if (mounted) setState(() => _rfidError = "$e"); + } finally { + if (mounted) setState(() => _rfidBusy = ""); + } + } + + /// The charger's own list, asked of the device rather than of the account. A + /// card the account has forgotten still opens the charger until the device is + /// told otherwise, and no other view on this page would say so. + Future _readChargerCards() async { + final sn = _detailSn; + if (sn.isEmpty || _rfidBusy.isNotEmpty) return; + setState(() { + _rfidBusy = "charger"; + _rfidError = null; + }); + try { + final cards = await apiClient.getAnkerChargerCards(sn); + // An empty answer is an answer — a charger with no cards on it — so the + // list is stored either way, and the card draws it rather than the button. + if (mounted) setState(() => _chargerCards[sn] = cards.map(_cardKey).toList()); + } catch (e) { + if (mounted) setState(() => _rfidError = "$e"); + } finally { + if (mounted) setState(() => _rfidBusy = ""); + } + } + + /// Removing one asks first — a card that is gone can only be put back by + /// whoever still has it in their hand. + Future _removeRfidCard(RfidCard card) async { + final sn = _detailSn; + if (sn.isEmpty || card.number.isEmpty || _rfidBusy.isNotEmpty) return; + if (!await _confirm(t("charging.rfid.removeConfirm", params: {"name": card.name}))) return; + setState(() { + _rfidBusy = card.number; + _rfidError = null; + }); + try { + final res = await apiClient.deleteAnkerRfidCard(sn, card.number); + if (mounted) { + setState(() { + _rfidWritten[sn] = res.cards; + if (res.present) _rfidError = t("charging.rfid.notRemoved"); + }); + } + } catch (e) { + if (mounted) setState(() => _rfidError = "$e"); + } finally { + if (mounted) setState(() => _rfidBusy = ""); + } + } + @override Widget build(BuildContext context) { + final dev = _dev; + // Both transports that read the charger can also be told things, so both get + // a settings card — but they can be told very different things. Modbus has + // four registers; the cloud has the charger's whole settings group, which is + // why that half is drawn from a table. + final hasSettings = _connected && + ((_isModbus && dev != null && _settingRows(dev).isNotEmpty) || + (_isCloud && _mqttBlocks.isNotEmpty)); final cards = { "control": _active && _connected ? _controlCard(context) : null, + "rfid": _selectedCharger == null ? null : _rfidCard(context), + "settings": hasSettings ? _settingsCard(context, dev) : null, "connection": _active ? _connectionCard(context) : null, "readings": _readsDevice && _connected ? _readingsCard(context) : null, "info": _infoCard(context), @@ -1490,14 +2943,71 @@ class _HomeTabState extends State<_HomeTab> { open: _isOpen("control"), onToggle: () => _toggleCard("control"), children: [ + // Which charger these buttons act on. The card said nothing about that + // before: the name is two cards further down, and the picture is the + // fastest way to tell two chargers on one account apart. + if (_ctlImageUrl.isNotEmpty || _ctlChargerName.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50, + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + ), + child: Row(children: [ + // A product shot is a URL from the service, and a URL can 404. The + // picture simply stops being drawn rather than leaving a hole. + if (_ctlImageUrl.isNotEmpty) ...[ + Image.network(_ctlImageUrl, + width: 44, + height: 44, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => const SizedBox.shrink()), + const SizedBox(width: 10), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_ctlChargerName.isNotEmpty) + Text(_ctlChargerName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + Text(_serial.text.trim(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DriverVault.mono(context, size: 11, color: muted)), + ], + ), + ), + ]), + ), + ], const SizedBox(height: 12), Row(children: [ - Expanded(child: _MetricTile(value: _ctl!.statusLabel, label: t("charging.control.status"))), + // And named for whichever of the two it is showing. The tile was called + // "Connector" from when OCPP was the only thing it read: an OCPP + // connector state is what that word means. Reading the charger's own + // snapshot it holds the charger's own status — the same statusDesc the + // readings card shows as "Charging status", so it takes that card's + // name for it rather than a second name for one value. The energy tile + // has the same two readings and the same problem. + Expanded( + child: _MetricTile( + value: _ctl!.statusLabel, + label: _readsDevice + ? t("charging.modbus.chargingStatus") + : t("charging.control.status"), + ), + ), const SizedBox(width: 8), Expanded( child: _MetricTile( value: "${_ctl!.meterKwh.toStringAsFixed(2)} kWh", - label: t("charging.control.meter"), + label: _readsDevice + ? t("charging.modbus.sessionEnergy") + : t("charging.control.meter"), ), ), ]), @@ -1543,32 +3053,41 @@ class _HomeTabState extends State<_HomeTab> { ), ), ]), - 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")), - ), + // The limit, only where nothing else owns it. Both transports that read + // the charger have a settings card now, and on both the limit is the + // charger's own ceiling — the same register over Modbus, the same wire + // field over the cloud. The same slider in two cards was that one value + // twice. + // + // OCPP is the exception and keeps it: a charging profile is not a + // setting the charger reports, so there is no settings card to move it + // to. Clearing it is OCPP's alone too — the cloud sets a ceiling and has + // no message for "no ceiling". + if (!_readsDevice) ...[ + 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), ), - // Clearing a limit is an OCPP command. Both of the transports that - // talk to the charger itself take an explicit ceiling, so there is - // nothing for them to clear to. - if (!_readsDevice) ...[ + Row(children: [ + Expanded( + child: OutlinedButton( + onPressed: _busy == "limit" + ? null + : () => _action("limit", {"amps": _limitAmps.round()}), + child: Text(t("charging.control.applyLimit")), + ), + ), const SizedBox(width: 8), Expanded( child: OutlinedButton( @@ -1576,8 +3095,8 @@ class _HomeTabState extends State<_HomeTab> { child: Text(t("charging.control.clearLimit")), ), ), - ], - ]), + ]), + ], // Boost lasts for the current session only, and is a command the // charger itself takes — over the register map or over the cloud, but @@ -1687,6 +3206,639 @@ class _HomeTabState extends State<_HomeTab> { ); } + /// The charger the buttons act on, as a picture and a name. + /// + /// The control card drives whichever serial is in force, which is not always + /// the record highlighted in the list beside it, so it identifies its charger + /// by that serial rather than by the selection. Two sources carry the same + /// product shot: the account's own charger list, and the live half held per + /// provider. Either will do; the account's is the one that arrives without the + /// home list having been opened. + String get _ctlImageUrl { + final sn = _serial.text.trim(); + if (sn.isEmpty) return ""; + for (final c in _accountChargers) { + if (c.sn == sn && c.imageUrl.isNotEmpty) return c.imageUrl; + } + return _live[sn]?.imageUrl ?? ""; + } + + String get _ctlChargerName { + final sn = _serial.text.trim(); + if (sn.isEmpty) return ""; + for (final c in _accountChargers) { + if (c.sn == sn && c.name.isNotEmpty) return c.name; + } + return _live[sn]?.name ?? ""; + } + + /// The cards that may start a charge without a phone. Directly under the + /// control card because it is the same question — who may use this charger — + /// asked of a person rather than of a button. + Widget _rfidCard(BuildContext context) { + final muted = DriverVault.muted(context); + final charger = _selectedCharger!; + final cards = _rfidCards; + final view = _rfidView; + final held = _chargerCardList; + return _FoldCard( + title: t("charging.rfid.title"), + open: _isOpen("rfid"), + onToggle: () => _toggleCard("rfid"), + badge: cards.isEmpty ? null : _NeutralBadge(label: "${cards.length}"), + action: TextButton( + onPressed: _detailsLoading.isNotEmpty ? null : () => _loadDetails(force: true), + child: Text(_detailsLoading.isNotEmpty ? t("common.loading") : t("charging.info.refresh")), + ), + children: [ + const SizedBox(height: 4), + Text(charger.name, style: TextStyle(fontSize: 11, color: muted)), + if (_rfidError != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(_rfidError!, + style: const TextStyle(fontSize: 12, color: DriverVault.danger)), + ), + + // A service that does not answer for cards, an account that may not read + // them, and a charger with none on it are three different answers, and + // each is said in its own words. + const SizedBox(height: 8), + if (view == null && cards.isEmpty) + Text(t("charging.rfid.unsupported"), style: TextStyle(fontSize: 12, color: muted)) + else if ((view?.error.isNotEmpty ?? false) && cards.isEmpty) + Text(view!.error, style: TextStyle(fontSize: 12, color: muted)) + else if (cards.isEmpty) + Text(t("charging.rfid.none"), style: TextStyle(fontSize: 12, color: muted)) + else + for (final card in cards) _rfidCardRow(context, card, muted), + + // What the charger itself holds. Everything above is the account's copy; + // this asks the device, which is the half that actually decides whether a + // card opens the charger. + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: _rfidBusy.isNotEmpty ? null : _readChargerCards, + child: Text(_rfidBusy == "charger" + ? t("common.loading") + : t("charging.rfid.readCharger")), + ), + ), + if (held != null) ...[ + const SizedBox(height: 8), + _ReadingSection( + heading: t("charging.rfid.chargerTitle"), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + held.isEmpty ? t("charging.rfid.chargerNone") : held.join(", "), + style: held.isEmpty + ? TextStyle(fontSize: 11, color: muted) + : DriverVault.mono(context, size: 11), + ), + // Only drawn when the two lists actually disagree: agreement is + // the ordinary case and does not need saying twice. + if (_cardsOnlyOnCharger.isNotEmpty || _cardsOnlyOnAccount.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + 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.rfid.driftTitle"), + style: DriverVault.mono(context, + size: 10, + weight: FontWeight.w600, + color: DriverVault.warning) + .copyWith(letterSpacing: 1.4)), + if (_cardsOnlyOnCharger.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + t("charging.rfid.onlyOnCharger", + params: {"cards": _cardsOnlyOnCharger.join(", ")}), + style: const TextStyle(fontSize: 11), + ), + ), + if (_cardsOnlyOnAccount.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + t("charging.rfid.onlyOnAccount", + params: {"cards": _cardsOnlyOnAccount.join(", ")}), + style: const TextStyle(fontSize: 11), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 6), + Text(t("charging.rfid.chargerHint"), + style: TextStyle(fontSize: 11, color: muted)), + ], + ), + ), + ], + + // Adding one. The number is the card itself, so it is the only field that + // is required; a card added without a name gets the one the Anker app + // would have given it. + const SizedBox(height: 10), + _ReadingSection( + heading: t("charging.rfid.addTitle"), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _newCardNumber, + autocorrect: false, + decoration: InputDecoration( + border: const OutlineInputBorder(), + isDense: true, + hintText: t("charging.rfid.numberPlaceholder"), + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + TextField( + controller: _newCardName, + decoration: InputDecoration( + border: const OutlineInputBorder(), + isDense: true, + hintText: t("charging.rfid.namePlaceholder"), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _newCardNumber.text.trim().isEmpty || _rfidBusy.isNotEmpty + ? null + : _addRfidCard, + child: Text( + _rfidBusy == "new" ? t("common.loading") : t("charging.rfid.add")), + ), + ), + // The other way to fill that field in: hold the card against the + // charger. The reader opens for twenty seconds and the number + // arrives on its own, which beats reading it off the card. + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: _rfidBusy.isNotEmpty ? null : _scanRfidCard, + child: Text(_rfidBusy == "scan" + ? t("charging.rfid.tapping", params: {"n": _rfidCountdown}) + : t("charging.rfid.tap")), + ), + ), + // The same tap without the second press. Filled, because it is the + // one somebody standing at the charger wants; the button above + // stays for the times the number is wanted without the card being + // added. + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _rfidBusy.isNotEmpty ? null : _tapAndSaveRfidCard, + child: Text(_rfidBusy == "tapSave" + ? t("charging.rfid.tapping", params: {"n": _rfidCountdown}) + : t("charging.rfid.tapSave")), + ), + ), + const SizedBox(height: 6), + Text( + _rfidBusy == "scan" || _rfidBusy == "tapSave" + ? t("charging.rfid.tapHint") + : t("charging.rfid.tapSaveHint"), + style: TextStyle(fontSize: 11, color: muted), + ), + ], + ), + ), + const SizedBox(height: 10), + Text(t("charging.rfid.inferred"), style: TextStyle(fontSize: 11, color: muted)), + ], + ); + } + + Widget _rfidCardRow(BuildContext context, RfidCard card, Color muted) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(10), + 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(card.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + ), + TextButton( + onPressed: _rfidBusy.isNotEmpty ? null : () => _removeRfidCard(card), + child: Text( + _rfidBusy == card.number ? t("common.loading") : t("charging.rfid.remove"), + style: const TextStyle(fontSize: 12, color: DriverVault.danger), + ), + ), + ]), + _PairList( + rows: [ + (t("charging.info.fields.cardNumber"), card.number), + if (card.added.isNotEmpty) + (t("charging.info.fields.added"), _viewTimeValue(card.added)), + ], + breakLong: true, + ), + ], + ), + ); + } + + /// What the charger is set to, as it reports it back. Its own card under the + /// control one: these are the values those buttons write, so they are read + /// right after pressing them. + Widget _settingsCard(BuildContext context, ChargerStatus? dev) { + final muted = DriverVault.muted(context); + return _FoldCard( + title: t("charging.modbus.settingsTitle"), + open: _isOpen("settings"), + onToggle: () => _toggleCard("settings"), + children: [ + if (_isModbus && dev != null) ..._modbusSettings(context, dev, muted), + if (_isCloud) ..._cloudSettings(context, dev, muted), + if (_ctlError != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(_ctlError!, + style: const TextStyle(fontSize: 13, color: DriverVault.danger)), + ), + ], + ); + } + + /// Modbus has four writable registers, so its half is four hand-written + /// controls rather than a table. + List _modbusSettings(BuildContext context, ChargerStatus dev, Color muted) { + final boostOn = dev.settingFlag("boost") ?? false; + final reported = _modbusSettingsReported(dev); + return [ + // Current limit. The slider says what it will do at the floor, because + // 6 A is a pause and not a slow charge. + const SizedBox(height: 12), + _ReadingSection( + heading: t("charging.modbus.maxCurrentSet"), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + const Spacer(), + Text("$_draftAmps A", + style: DriverVault.mono(context, size: 13, weight: FontWeight.w600)), + ]), + Slider( + value: _draftAmps.toDouble().clamp(_kLimitFloor.toDouble(), _limitCeiling.toDouble()), + min: _kLimitFloor.toDouble(), + max: _limitCeiling.toDouble(), + divisions: (_limitCeiling - _kLimitFloor).clamp(1, 200), + label: "$_draftAmps A", + onChanged: (v) => setState(() => _draftAmps = v.round()), + ), + Row(children: [ + Expanded( + child: Text(t("charging.modbus.limitFloorHint", params: {"amps": _kLimitFloor}), + style: TextStyle(fontSize: 11, color: muted)), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: _busy == "limit" ? null : () => _action("limit", {"amps": _draftAmps}), + child: Text(t("charging.modbus.apply")), + ), + ]), + ], + ), + ), + + // Phase count and boost both write a single register, so they are sent on + // the change itself rather than through an Apply. + const SizedBox(height: 8), + _ReadingSection( + heading: t("charging.modbus.phaseSetting"), + child: DropdownButtonFormField( + initialValue: _draftPhase, + isExpanded: true, + decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true), + items: [ + for (final v in [0, 1, 2]) + DropdownMenuItem(value: v, child: Text(t("charging.modbus.phaseSet$v"))), + ], + onChanged: _busy == "phase" + ? null + : (v) { + if (v == null) return; + setState(() => _draftPhase = v); + _action("phase", {"phase": v}); + }, + ), + ), + + const SizedBox(height: 8), + _ReadingSection( + heading: t("charging.modbus.boostSet"), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t("charging.modbus.boostHint"), style: TextStyle(fontSize: 11, color: muted)), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: boostOn + ? OutlinedButton( + onPressed: + _busy == "boost" ? null : () => _action("boost", {"on": false}), + child: Text(t("charging.modbus.turnOff")), + ) + : FilledButton( + onPressed: + _busy == "boost" ? null : () => _action("boost", {"on": true}), + child: Text(t("charging.modbus.turnOn")), + ), + ), + ], + ), + ), + + // The charger falls back to its own strategy when nothing writes within + // this, so it is a setting worth reaching. + const SizedBox(height: 8), + _ReadingSection( + heading: t("charging.modbus.timeout"), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Expanded( + child: TextFormField( + // Re-seeded only when the charger reports a different value, + // so typing is never wiped by a refresh that changed nothing. + key: ValueKey("timeout-${dev.settingInt("timeoutSeconds")}"), + initialValue: "$_draftSeconds", + keyboardType: TextInputType.number, + decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true), + onChanged: (v) => + setState(() => _draftSeconds = int.tryParse(v.trim()) ?? 0), + ), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: _busy == "timeout" || _draftSeconds < 10 + ? null + : () => _action("timeout", {"seconds": _draftSeconds}), + child: Text(t("charging.modbus.apply")), + ), + ]), + const SizedBox(height: 6), + Text(t("charging.modbus.timeoutHint", params: {"n": 10}), + style: TextStyle(fontSize: 11, color: muted)), + ], + ), + ), + + if (reported.isNotEmpty) ...[ + const SizedBox(height: 8), + _ReadingSection( + heading: t("charging.modbus.settingsReported"), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _PairList(rows: reported), + const SizedBox(height: 6), + Text(t("charging.modbus.settingsReportedHint"), + style: TextStyle(fontSize: 11, color: muted)), + ], + ), + ), + ], + ]; + } + + /// The cloud's half. One section per block, and one write per section: the + /// charger takes a command whole, so its fields are sent together and Apply is + /// per block rather than per control. + List _cloudSettings(BuildContext context, ChargerStatus? dev, Color muted) { + final reported = dev == null ? const <(String, String)>[] : _mqttSettingsReported(dev); + return [ + const SizedBox(height: 12), + Text(t("charging.modbus.cloudSettingsHint"), + style: TextStyle(fontSize: 11, color: muted)), + for (final block in _mqttBlocks) ...[ + const SizedBox(height: 8), + _ReadingSection( + heading: t("charging.modbus.${block.title}"), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final f in block.fields) _settingControl(context, f, muted), + if (block.warning.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(t("charging.modbus.${block.warning}"), + style: const TextStyle(fontSize: 11, color: DriverVault.warning)), + ), + // Nothing to apply until something differs from what the charger + // reported, so the button says so by being off. + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (_blockDirty(block)) + TextButton( + onPressed: _mqttBusy.isNotEmpty ? null : () => _resetMqttBlock(block), + child: Text(t("charging.modbus.reset"), + style: TextStyle(fontSize: 12, color: muted)), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _mqttBusy.isNotEmpty || !_blockDirty(block) || !_blockValid(block) + ? null + : () => _applyMqttBlock(block), + child: Text(_mqttBusy == block.id + ? t("common.loading") + : t("charging.modbus.apply")), + ), + ], + ), + ], + ), + ), + ], + + // The settings group's remainder: reported, and with no command to write + // them. + if (reported.isNotEmpty) ...[ + const SizedBox(height: 8), + _ReadingSection( + heading: t("charging.modbus.settingsReported"), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _PairList(rows: reported), + const SizedBox(height: 6), + Text(t("charging.modbus.cloudSettingsReportedHint"), + style: TextStyle(fontSize: 11, color: muted)), + ], + ), + ), + ], + ]; + } + + /// One control of one block, drawn for whichever kind of setting it is. + Widget _settingControl(BuildContext context, _SetField f, Color muted) { + final label = t("charging.modbus.${f.label}"); + switch (f.kind) { + // A slider needs the width, so its row stacks: the label and the value it + // is at on one line, the track under them. + case _SetKind.slider: + final max = (f.max ?? _limitCeiling).toDouble(); + final min = f.min.toDouble(); + final value = ((_mqttDraft[f.key] as num?) ?? min).toDouble().clamp(min, max); + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Expanded(child: Text(label, style: TextStyle(fontSize: 12, color: muted))), + Text("${value.round()} ${f.unit}", + style: DriverVault.mono(context, size: 12, weight: FontWeight.w600)), + ]), + Slider( + value: value, + min: min, + max: max, + divisions: ((max - min) / f.step).round().clamp(1, 200), + label: "${value.round()} ${f.unit}", + onChanged: (v) => setState(() => _mqttDraft[f.key] = v.round()), + ), + if (f.hint.isNotEmpty) + Text(t("charging.modbus.${f.hint}", params: {"amps": f.min}), + style: TextStyle(fontSize: 11, color: muted)), + ], + ), + ); + + // A switch reads as what it is set to, not as a verb: the card is a form, + // and the control says the value it will send rather than the action it + // would take. + case _SetKind.toggle: + return Row(children: [ + Expanded(child: Text(label, style: TextStyle(fontSize: 12, color: muted))), + Switch( + value: _mqttDraft[f.key] == true, + onChanged: (v) => setState(() => _mqttDraft[f.key] = v), + ), + ]); + + case _SetKind.option: + final current = (_mqttDraft[f.key] as num?)?.toInt(); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row(children: [ + Expanded(child: Text(label, style: TextStyle(fontSize: 12, color: muted))), + const SizedBox(width: 8), + SizedBox( + width: 170, + child: DropdownButtonFormField( + initialValue: current, + isExpanded: true, + decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true), + items: [ + for (final v in _fieldOptions(f)) + DropdownMenuItem( + value: v, + child: Text(_enumLabel(f.enumPrefix, v) ?? "$v", + overflow: TextOverflow.ellipsis), + ), + ], + onChanged: (v) => setState(() => _mqttDraft[f.key] = v), + ), + ), + ]), + ); + + case _SetKind.number: + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row(children: [ + Expanded(child: Text(label, style: TextStyle(fontSize: 12, color: muted))), + const SizedBox(width: 8), + SizedBox( + width: 110, + child: TextFormField( + // Re-seeded only when the charger reports a different value, so + // typing is never wiped by a refresh that changed nothing. + key: ValueKey("num-${f.key}-${_mqttBase[f.key]}"), + initialValue: "${_mqttBase[f.key] ?? ""}", + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: const OutlineInputBorder(), + isDense: true, + suffixText: f.unit, + ), + onChanged: (v) => setState(() => _mqttDraft[f.key] = int.tryParse(v.trim())), + ), + ), + ]), + ); + + case _SetKind.window: + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontSize: 12, color: muted)), + const SizedBox(height: 4), + Row(children: [ + TimeField( + value: "${_mqttDraft[f.from] ?? ""}", + label: t("charging.modbus.windowStart"), + onChanged: (v) => setState(() => _mqttDraft[f.from] = v), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text("–", style: TextStyle(fontSize: 12, color: muted)), + ), + TimeField( + value: "${_mqttDraft[f.to] ?? ""}", + label: t("charging.modbus.windowEnd"), + onChanged: (v) => setState(() => _mqttDraft[f.to] = v), + ), + ]), + ], + ), + ); + } + } + /// Which charger, and how to reach it. Below the controls: it is touched once /// and then left alone. Widget _connectionCard(BuildContext context) { @@ -2425,17 +4577,51 @@ class _HomeTabState extends State<_HomeTab> { // which is equally an answer: a standalone charger has no station // record and no site. for (final view in _detailViews(charger)) ...[ - _ReadingSection( + Builder(builder: (context) { + final (rows, items) = _viewRowGroups(view.attrs); + return _ReadingSection( heading: t("charging.info.views.${view.id}"), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (view.error.isNotEmpty) Text(view.error, style: TextStyle(fontSize: 11, color: muted)) - else if (view.rows.isEmpty) + else if (rows.isEmpty && items.isEmpty) Text(t("charging.info.viewEmpty"), style: TextStyle(fontSize: 11, color: muted)) - else - _PairList(rows: view.rows, breakLong: true), + else ...[ + // What the view says about itself. A field the card has a + // name for is drawn like every other named row; one it does + // not is drawn under its own key, in the key's own typeface, + // so the two are never mistaken for each other. + if (rows.isNotEmpty) + _PairList( + rows: [for (final r in rows) (r.label, r.value)], + rawLabels: {for (final r in rows) if (!r.named) r.label}, + breakLong: true, + ), + // And the records it answered with, a block each under the + // record's own name. + for (final item in items) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.label, + style: const TextStyle( + fontSize: 11, fontWeight: FontWeight.w600)), + _PairList( + rows: [for (final r in item.rows) (r.label, r.value)], + rawLabels: { + for (final r in item.rows) + if (!r.named) r.label + }, + breakLong: true, + ), + ], + ), + ), + ], if (view.note.isNotEmpty) ...[ const SizedBox(height: 6), Text(view.note, @@ -2443,7 +4629,8 @@ class _HomeTabState extends State<_HomeTab> { ], ], ), - ), + ); + }), const SizedBox(height: 8), ], ], @@ -2473,6 +4660,7 @@ class _HomeTabState extends State<_HomeTab> { /// each other. Nothing to say is said with a dash. List<(String, List<(String, String)>)> _infoGroups(HomeCharger c) { final live = _liveFor(c); + final attrs = live?.attrs ?? const {}; final groups = <(String, List<(String, String)>)>[ ("device", [ ("vendor", c.vendor), @@ -2521,11 +4709,48 @@ class _HomeTabState extends State<_HomeTab> { [ for (final (key, value) in pairs) (t("charging.info.$key"), value.isEmpty ? "—" : value), + // The fields the service sent under its own names that this group + // has a name for, after the ones DriverVault stores itself. + ..._namedAttrRows(id, attrs, live), ], ), ]; } + /// A flag the service sends as true/false or 1/0, read out in words. Anything + /// else is relayed as it arrived rather than forced into a yes. + String _attrBoolLabel(String v) => switch (v.toLowerCase()) { + "true" || "1" => t("common.yes"), + "false" || "0" => t("common.no"), + _ => v, + }; + + /// Two values that are the same fact written two ways: 7C:E9:13:73:C2:38 is the + /// address 7CE91373C238 with colons in it. + bool _sameAttrValue(String a, String b) { + String norm(String v) => v.toLowerCase().replaceAll(RegExp(r"[^a-z0-9]"), ""); + return norm(a).isNotEmpty && norm(a) == norm(b); + } + + /// The named fields that belong in one group, for a charger whose service sent + /// them. Unlike the rows above, a field missing here draws nothing: these are + /// one service's fields, and a row of dashes on a charger from another service + /// would say a field is absent when it was never a field at all. + List<(String, String)> _namedAttrRows( + String groupId, Map attrs, ProviderCharger? live) { + final rows = <(String, String)>[]; + for (final entry in _kNamedAttrs.entries) { + final spec = entry.value; + if (spec.group != groupId) continue; + final raw = attrs[entry.key]; + if (raw == null || raw.isEmpty) continue; + // The only row the card already draws in another form. + if (spec.sameAs == "bleMac" && _sameAttrValue(raw, live?.bleMac ?? "")) continue; + rows.add((t("charging.info.${spec.label}"), spec.isBool ? _attrBoolLabel(raw) : raw)); + } + return rows; + } + /// Everything else the service said about this charger, under its own field /// names. The rows above are the ones DriverVault has a name for; these are /// the remainder — the service documents none of them, so its own key is the @@ -2533,10 +4758,76 @@ class _HomeTabState extends State<_HomeTab> { /// it. Sorted so the same charger reads the same way on every refresh. List<(String, String)> _attrRows(HomeCharger c) { final attrs = _liveFor(c)?.attrs ?? const {}; - final keys = attrs.keys.toList()..sort(); + final keys = attrs.keys + .where((key) => !_isEchoedAttr(key) && !_kNamedAttrs.containsKey(key)) + .toList() + ..sort(); return [for (final key in keys) (key, attrs[key]!)]; } + // --- the per-charger views, read a record at a time ------------------------ + + /// A unix second the cloud sent as a bare number, read as a date. Zero is how + /// these views say "never" rather than 1970, so it is left as it arrived. + String _viewTimeValue(String v) { + final n = num.tryParse(v.trim()); + if (n == null || n <= 0) return v; + return formatDateTime( + DateTime.fromMillisecondsSinceEpoch((n * 1000).round())); + } + + /// One field of one view: named when the table above has a name for it, and + /// keyed by the cloud's own key when it does not. + _ViewRow _viewRow(String key, String lookup, String value) { + // A field inside a list is looked up under its own list first and under the + // bare field name second, so an email is an email whichever list it came in. + final spec = _kViewFields[lookup] ?? _kViewFields[lookup.split("].").last]; + return _ViewRow( + spec == null ? key : t("charging.info.fields.${spec.label}"), + spec != null && spec.time ? _viewTimeValue(value) : value, + spec != null, + ); + } + + /// One view's fields, with the lists it answered with grouped a record at a + /// time. list[0].* and list[1].* are two RFID cards, two endpoints, two + /// anything: read as one alphabetical run of indexed keys they are unreadable, + /// and as a block each — under the record's own name, when it has one — they + /// are the list the view actually sent. The record's name becomes the heading + /// rather than a row, so it is said once. + (List<_ViewRow>, List<_ViewItem>) _viewRowGroups(Map attrs) { + final rows = <_ViewRow>[]; + final items = )>{}; + final keys = attrs.keys.toList()..sort(); + for (final key in keys) { + final m = _kViewListKey.firstMatch(key); + if (m == null) { + rows.add(_viewRow(key, key, attrs[key]!)); + continue; + } + final list = m.group(1)!; + final index = int.parse(m.group(2)!); + final field = m.group(3)!; + final id = "$list[$index]"; + if (!items.containsKey(id)) { + var titleKey = ""; + for (final f in _kViewItemTitles) { + if ((attrs["$id.$f"] ?? "").isNotEmpty) { + titleKey = "$id.$f"; + break; + } + } + final label = titleKey.isEmpty ? "#${index + 1}" : attrs[titleKey]!; + items[id] = (index, label, titleKey, <_ViewRow>[]); + } + final item = items[id]!; + if (key == item.$3) continue; + item.$4.add(_viewRow(key, "$list[].$field", attrs[key]!)); + } + final ordered = items.values.toList()..sort((a, b) => a.$1.compareTo(b.$1)); + return (rows, [for (final i in ordered) _ViewItem(i.$2, i.$4)]); + } + /// The user's own chargers, and the import that fills the list. Widget _chargerListCard(BuildContext context) { final muted = DriverVault.muted(context); @@ -2707,7 +4998,12 @@ class _PairList extends StatelessWidget { /// enough to need it; a reading never is. final bool breakLong; - const _PairList({required this.rows, this.breakLong = false}); + /// Labels that are the cloud's own key rather than a name of ours, drawn in + /// the typeface the rest of the app reads keys in so the two are never + /// mistaken for each other. + final Set rawLabels; + + const _PairList({required this.rows, this.breakLong = false, this.rawLabels = const {}}); @override Widget build(BuildContext context) { @@ -2722,7 +5018,12 @@ class _PairList extends StatelessWidget { children: [ Expanded( flex: 4, - child: Text(label, style: TextStyle(fontSize: 11, color: muted)), + child: Text( + label, + style: rawLabels.contains(label) + ? DriverVault.mono(context, size: 11, color: muted) + : TextStyle(fontSize: 11, color: muted), + ), ), const SizedBox(width: 10), Expanded( diff --git a/Phone App/lib/screens/charging_task_sheet.dart b/Phone App/lib/screens/charging_task_sheet.dart new file mode 100644 index 0000000..70057b1 --- /dev/null +++ b/Phone App/lib/screens/charging_task_sheet.dart @@ -0,0 +1,410 @@ +import "package:flutter/material.dart"; + +import "../format.dart"; +import "../i18n.dart"; +import "../main.dart"; +import "../models.dart"; +import "../theme.dart"; +import "../widgets/time_field.dart"; + +/// One line of the home-charger scheduler, being written or edited. +/// +/// The charger's own cloud schedule asks four questions and asks them inside one +/// charger: on/off, mode, from, to. This asks five, and the fifth is the one that +/// makes it a scheduler rather than a second copy of that: *which* chargers. A +/// task can name one, several, or none at all — and none means every charger on +/// the account, including ones imported after the task was written, because "all +/// of them" is a standing wish rather than the list that happened to exist that +/// day. +/// +/// A task holds a flow rather than a single command: start at 23:00, ease down +/// to 10 A at 01:00, stop at 06:30. That is one intention, so it is one named +/// thing with one switch — splitting a charging window across two tasks meant +/// naming it twice and remembering to switch off both ends. +/// +/// Pops the saved [ChargingTask]. +Future showChargingTaskSheet( + BuildContext context, { + ChargingTask? task, + required List chargers, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _ChargingTaskSheet(task: task, chargers: chargers), + ); +} + +/// What the charger can actually be asked to do. Boost and the current limit +/// only reach it over the Anker cloud connection; start and stop reach it over +/// all three transports. The control mode is a Settings choice, not this form's +/// business, so all four are offered and the one that cannot be sent says so +/// when it fires — same as the buttons on the page behind this. +const List _kActions = ["start", "stop", "limit", "boost"]; + +/// One row of the flow while it is being edited. Mutable, because the form edits +/// the rows in place; [ChargingStep] is what gets sent. +class _StepDraft { + String action; + String time; + double amps; + _StepDraft(this.action, this.time, this.amps); +} + +class _ChargingTaskSheet extends StatefulWidget { + final ChargingTask? task; + final List chargers; + const _ChargingTaskSheet({this.task, required this.chargers}); + @override + State<_ChargingTaskSheet> createState() => _ChargingTaskSheetState(); +} + +class _ChargingTaskSheetState extends State<_ChargingTaskSheet> { + late final TextEditingController _name = + TextEditingController(text: widget.task?.name ?? ""); + + /// The flow, as rows the form edits in place. A new task opens with the one + /// step most schedules start from, so the common case is a name and a time + /// rather than a decision about how many rows to add. + late final List<_StepDraft> _steps = (widget.task?.steps ?? const []).isEmpty + ? [_StepDraft("start", "23:00", 16)] + : widget.task!.steps + .map((s) => _StepDraft(s.action, s.time, s.amps > 0 ? s.amps : 16)) + .toList(); + + /// The chargers this task acts on. Empty is meaningful — it means all of them + /// — so the picker has a switch of its own rather than leaving an empty list + /// looking like an unfinished form. + late bool _allChargers = widget.task == null || widget.task!.chargers.isEmpty; + late final Set _picked = {...?widget.task?.chargers}; + late bool _everyDay = widget.task == null || widget.task!.days.isEmpty; + late final Set _days = {...?widget.task?.days}; + + bool _saving = false; + String? _error; + + bool get _editing => (widget.task?.id ?? "").isNotEmpty; + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + /// A flow of one is a flow, so the last row cannot be removed — an empty task + /// would have nothing to fire and the server refuses it anyway. + void _addStep() => setState(() => _steps.add(_StepDraft("stop", "06:30", 16))); + + void _removeStep(int i) { + if (_steps.length <= 1) return; + setState(() => _steps.removeAt(i)); + } + + /// Unticking every day (or every charger) by hand is the same wish as the + /// "all" switch, so it lands there rather than leaving a task that acts on + /// nothing. + void _toggleDay(int day) { + setState(() { + _everyDay = false; + _days.contains(day) ? _days.remove(day) : _days.add(day); + if (_days.isEmpty) _everyDay = true; + }); + } + + void _toggleCharger(String id) { + setState(() { + _allChargers = false; + _picked.contains(id) ? _picked.remove(id) : _picked.add(id); + if (_picked.isEmpty) _allChargers = true; + }); + } + + /// A time still being typed is not a time — [TimeField] says so with an empty + /// value — and one unfinished row is enough to make the whole flow unsaveable, + /// because the server would otherwise refuse it with a step number the form + /// does not show. + bool get _canSave => + _name.text.trim().isNotEmpty && _steps.every((s) => s.time.isNotEmpty) && !_saving; + + Future _submit() async { + if (!_canSave) return; + setState(() { + _saving = true; + _error = null; + }); + final body = { + "name": _name.text.trim(), + // The amps ride along on every step so switching one to "limit" and back + // does not lose the number that was typed; the server keeps them for the + // same reason and ignores them on the actions that have no ceiling. + "steps": [ + for (final s in _steps) + { + "action": s.action, + "time": s.time, + "amps": s.action == "limit" ? s.amps : 0, + }, + ], + "chargers": _allChargers ? [] : _picked.toList(), + "days": _everyDay ? [] : _days.toList(), + // The time is a wall clock, and the server's is not the one it was set by. + // Sending the zone this phone is in is what keeps 23:00 at 23:00 for a + // server sitting in another country. + "zone": _deviceZone(), + }; + try { + final saved = _editing + ? await apiClient.updateChargingTask(widget.task!.id, body) + : await apiClient.createChargingTask(body); + if (mounted) Navigator.pop(context, saved); + } catch (e) { + if (mounted) setState(() => _error = "$e"); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + /// The phone's own zone name. Dart has no IANA name to hand — only an offset + /// and the platform's abbreviation — so the server is sent what it can read + /// and falls back to its own clock when it cannot: an offset is not a zone, + /// and a name that is not IANA would be worse than saying nothing. + String _deviceZone() { + final name = DateTime.now().timeZoneName; + return name.contains("/") ? name : ""; + } + + String _chargerSubtitle(HomeCharger c) => + [c.serial, c.model].where((v) => v.isNotEmpty).join(" · "); + + @override + Widget build(BuildContext context) { + final muted = DriverVault.muted(context); + final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50; + 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(_editing ? "forms.chargingTask.editTitle" : "forms.chargingTask.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)), + ), + + Text(t("forms.chargingTask.name"), + style: const TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 6), + TextField( + controller: _name, + decoration: InputDecoration( + border: const OutlineInputBorder(), + isDense: true, + hintText: t("forms.chargingTask.namePlaceholder"), + ), + onChanged: (_) => setState(() {}), + ), + + // The flow. One row per step, each an action and the time it fires — + // read down, they are the night: start at 23:00, ease off at 01:00, + // stop at 06:30. + const SizedBox(height: 16), + Text(t("forms.chargingTask.flow"), + style: const TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 6), + for (var i = 0; i < _steps.length; i++) _stepRow(context, i, sunken, muted), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: _addStep, + child: Text(t("forms.chargingTask.addStep")), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(t("forms.chargingTask.flowHint"), + style: TextStyle(fontSize: 12, color: muted)), + ), + + // Which chargers. The point of one scheduler for all of them. + const SizedBox(height: 16), + Text(t("forms.chargingTask.chargers"), + style: const TextStyle(fontWeight: FontWeight.w500)), + CheckboxListTile( + value: _allChargers, + dense: true, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text(t("forms.chargingTask.allChargers"), + style: const TextStyle(fontSize: 14)), + subtitle: _allChargers + ? Text(t("forms.chargingTask.allChargersHint"), + style: TextStyle(fontSize: 12, color: muted)) + : null, + onChanged: (on) => setState(() { + _allChargers = on ?? true; + if (_allChargers) _picked.clear(); + }), + ), + if (widget.chargers.isEmpty) + Text(t("forms.chargingTask.noChargers"), + style: TextStyle(fontSize: 12, color: muted)), + for (final c in widget.chargers) + CheckboxListTile( + value: !_allChargers && _picked.contains(c.id), + dense: true, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text(c.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14)), + subtitle: _chargerSubtitle(c).isEmpty + ? null + : Text(_chargerSubtitle(c), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DriverVault.mono(context, size: 11, color: muted)), + onChanged: (_) => _toggleCharger(c.id), + ), + + // Which days. + const SizedBox(height: 8), + Text(t("forms.chargingTask.days"), + style: const TextStyle(fontWeight: FontWeight.w500)), + CheckboxListTile( + value: _everyDay, + dense: true, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text(t("forms.chargingTask.everyDay"), + style: const TextStyle(fontSize: 14)), + onChanged: (on) => setState(() { + _everyDay = on ?? true; + if (_everyDay) _days.clear(); + }), + ), + // The row starts on whichever day this account reads a week as + // starting on — Settings › Appearance › First day of the week, + // following the region unless it was answered outright. format.dart + // owns the rule for every weekday row in the app. + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final d in weekdaysInOrder()) + ChoiceChip( + label: Text(weekdayShortName(d), style: const TextStyle(fontSize: 12)), + selected: !_everyDay && _days.contains(d), + onSelected: (_) => _toggleDay(d), + ), + ], + ), + + 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: _canSave ? _submit : null, + child: Text(_saving ? t("common.saving") : t("common.save")), + ), + ), + ]), + ], + ), + ), + ); + } + + Widget _stepRow(BuildContext context, int i, Color sunken, Color muted) { + final s = _steps[i]; + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: sunken, + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: DropdownButtonFormField( + initialValue: s.action, + isExpanded: true, + decoration: + const InputDecoration(border: OutlineInputBorder(), isDense: true), + items: [ + for (final a in _kActions) + DropdownMenuItem( + value: a, + child: Text(t("charging.scheduler.actions.$a"), + overflow: TextOverflow.ellipsis), + ), + ], + onChanged: (v) => setState(() => s.action = v ?? s.action), + ), + ), + const SizedBox(width: 8), + TimeField( + value: s.time, + onChanged: (v) => setState(() => s.time = v), + ), + // The last step cannot go: a task with no steps has nothing to + // fire, so the control is absent rather than there and refusing. + if (_steps.length > 1) + IconButton( + icon: const Icon(Icons.close, size: 18), + color: muted, + tooltip: t("forms.chargingTask.removeStep"), + onPressed: () => _removeStep(i), + ), + ], + ), + // The ceiling, under the one action that takes one. + if (s.action == "limit") ...[ + const SizedBox(height: 8), + Row(children: [ + Text(t("forms.chargingTask.amps"), + style: TextStyle(fontSize: 13, color: muted)), + const Spacer(), + Text("${s.amps.round()} A", + style: DriverVault.mono(context, size: 13, weight: FontWeight.w600)), + ]), + Slider( + value: s.amps.clamp(6, 32), + min: 6, + max: 32, + divisions: 26, + label: "${s.amps.round()} A", + onChanged: (v) => setState(() => s.amps = v), + ), + Text(t("forms.chargingTask.ampsHint"), + style: TextStyle(fontSize: 11, color: muted)), + ], + ], + ), + ); + } +} diff --git a/Phone App/lib/screens/login_screen.dart b/Phone App/lib/screens/login_screen.dart index cd85a92..4db3266 100644 --- a/Phone App/lib/screens/login_screen.dart +++ b/Phone App/lib/screens/login_screen.dart @@ -257,7 +257,11 @@ class _LoginScreenState extends State { TextFormField( controller: _email, keyboardType: TextInputType.emailAddress, - decoration: InputDecoration(labelText: t("login.email"), border: const OutlineInputBorder()), + decoration: InputDecoration( + labelText: t("login.email"), + border: const OutlineInputBorder(), + hintText: "you@example.com", + ), validator: (v) => (v == null || v.isEmpty) ? t("common.required") : null, ), const SizedBox(height: 12), @@ -267,6 +271,7 @@ class _LoginScreenState extends State { decoration: InputDecoration( labelText: t("login.password"), border: const OutlineInputBorder(), + hintText: "••••••••", suffixIcon: IconButton( icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility), tooltip: _showPassword ? t("login.hidePassword") : t("login.showPassword"), diff --git a/Phone App/lib/screens/settings_screen.dart b/Phone App/lib/screens/settings_screen.dart index 6430da7..849ce12 100644 --- a/Phone App/lib/screens/settings_screen.dart +++ b/Phone App/lib/screens/settings_screen.dart @@ -480,6 +480,8 @@ class _AppearanceSectionState extends State<_AppearanceSection> { "theme": appSettings.theme, "locale": appSettings.locale, "dateFormat": appSettings.dateFormat, + "timeFormat": appSettings.timeFormat, + "weekStart": appSettings.weekStart, "currency": appSettings.currency, "fontSize": appSettings.fontSize, }; @@ -487,6 +489,8 @@ class _AppearanceSectionState extends State<_AppearanceSection> { theme: patch["theme"], locale: patch["locale"], dateFormat: patch["dateFormat"], + timeFormat: patch["timeFormat"], + weekStart: patch["weekStart"], currency: patch["currency"], fontSize: patch["fontSize"], ); @@ -498,6 +502,8 @@ class _AppearanceSectionState extends State<_AppearanceSection> { theme: prev["theme"], locale: prev["locale"], dateFormat: prev["dateFormat"], + timeFormat: prev["timeFormat"], + weekStart: prev["weekStart"], currency: prev["currency"], fontSize: prev["fontSize"], ); @@ -615,6 +621,63 @@ class _AppearanceSectionState extends State<_AppearanceSection> { child: Text(t("settings.appearance.dateHint", params: {"example": formatDate(DateTime.now())}), style: const TextStyle(color: Colors.grey, fontSize: 12)), ), + + // Beside the date rather than under the region, because it is the same + // question asked about the other half of a timestamp. + const SizedBox(height: 16), + Text(t("settings.appearance.timeFormat"), style: const TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 6), + DropdownButtonFormField( + initialValue: appSettings.timeFormat, + decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true), + items: [ + DropdownMenuItem(value: "auto", child: Text(t("settings.appearance.timeAuto"))), + DropdownMenuItem(value: "24", child: Text(t("settings.appearance.time24"))), + DropdownMenuItem(value: "12", child: Text(t("settings.appearance.time12"))), + ], + onChanged: (v) => v == null ? null : _save({"timeFormat": v}), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + // Thirteen-something rather than now: an example at 09:00 reads the + // same in both conventions, which is the one time of day that cannot + // show the choice. + child: Text( + t("settings.appearance.timeExample", params: { + "example": formatTime(DateTime(2024, 1, 1, 13, 45)), + }), + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ), + + // Under the clock, as the last of the three questions a region is asked + // and the one it is least often asked out loud. + const SizedBox(height: 16), + Text(t("settings.appearance.weekStart"), style: const TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 6), + DropdownButtonFormField( + initialValue: appSettings.weekStart, + decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true), + items: [ + DropdownMenuItem(value: "auto", child: Text(t("settings.appearance.weekAuto"))), + DropdownMenuItem(value: "monday", child: Text(t("settings.appearance.weekMonday"))), + DropdownMenuItem(value: "sunday", child: Text(t("settings.appearance.weekSunday"))), + ], + onChanged: (v) => v == null ? null : _save({"weekStart": v}), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + // The week as this account will now see it drawn — the clearest + // possible example, because the setting has no other visible effect on + // this page. + child: Text( + t("settings.appearance.weekExample", params: { + "example": weekdaysInOrder().map(weekdayShortName).join(" "), + }), + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ), + const SizedBox(height: 16), Text(t("settings.appearance.fontSize"), style: const TextStyle(fontWeight: FontWeight.w500)), const SizedBox(height: 6), @@ -2100,6 +2163,20 @@ class _IntegrationCardState extends State<_IntegrationCard> { final showDots = isPassword ? field.effective.isNotEmpty : locked && !f.showEffectiveWhenLocked; + // What an inherited field shows when it is empty. + // + // A locked field is standing in for a value set above the caller, and the + // server has already decided which of those may be read: it sends the + // secrets back as dots and everything else in the clear. So the + // placeholder is that effective value — the thing the field will actually + // use — and the example is for the other case, an empty box waiting to be + // filled in. + // + // The example belongs only there. A country field placeholdered "DE" under + // the words "inherited from your organization" is not a hint, it is a + // wrong answer to the question the user is asking it: which country am I + // inheriting? + final placeholder = field.locked ? field.effective : f.placeholder; input = TextField( controller: _controllers[f.key], obscureText: isPassword, @@ -2111,7 +2188,7 @@ class _IntegrationCardState extends State<_IntegrationCard> { border: const OutlineInputBorder(), isDense: true, counterText: "", - hintText: showDots ? "••••••••" : f.placeholder, + hintText: showDots ? "••••••••" : placeholder, ), ); } diff --git a/Phone App/lib/widgets/time_field.dart b/Phone App/lib/widgets/time_field.dart new file mode 100644 index 0000000..7bbf37a --- /dev/null +++ b/Phone App/lib/widgets/time_field.dart @@ -0,0 +1,193 @@ +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; + +import "../format.dart"; + +/// A time box that reads on the clock the user chose. +/// +/// The same problem the web app's `components/TimeField.vue` solves, and the +/// same shape of answer. Flutter's own `showTimePicker` renders on the *device's* +/// locale, which nothing in this app steers: a Settings → Time format of 24-hour +/// still met the account with an AM/PM dial, disagreeing with the 00:00 the card +/// beside it printed. So the typing is ours — four digits, masked into the clock +/// in force, with the meridiem as its own control rather than something to be +/// spelled. +/// +/// The value in and out is always 24-hour "HH:MM", which is what the charger's +/// schedule commands take and what every caller already had. A half-typed time +/// emits "" — a half-typed time is not a time, and emitting the part of it that +/// parses would set the charger's schedule to whatever was passed through on the +/// way to the value somebody meant. +class TimeField extends StatefulWidget { + final String value; + final ValueChanged onChanged; + final bool enabled; + final String? label; + + const TimeField({ + super.key, + required this.value, + required this.onChanged, + this.enabled = true, + this.label, + }); + + @override + State createState() => _TimeFieldState(); +} + +class _TimeFieldState extends State { + final _controller = TextEditingController(); + bool _pm = false; + bool _twelve = false; + + @override + void initState() { + super.initState(); + _twelve = clockIsTwelveHour(); + _controller.text = _toText(widget.value); + _pm = _toPm(widget.value); + } + + @override + void didUpdateWidget(TimeField old) { + super.didUpdateWidget(old); + // Switching the setting elsewhere re-lays out what is already in the box, + // rather than leaving one field on the old clock. + final twelve = clockIsTwelveHour(); + if (twelve != _twelve) { + _twelve = twelve; + _controller.text = _toText(widget.value); + _pm = _toPm(widget.value); + return; + } + // Only re-render the box when the value it is showing is genuinely a + // different time. Half-typed input emits "" — there is no time yet — and + // reacting to that would wipe the very digits being typed. + if (_toValue(_controller.text, _pm) == widget.value) return; + _controller.text = _toText(widget.value); + _pm = _toPm(widget.value); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + static String _pad(int n) => n.toString().padLeft(2, "0"); + + /// "HH:MM" → its two numbers, or null for anything that is not a time of day. + static (int, int)? _parse(String value) { + final m = RegExp(r"^(\d{1,2}):(\d{2})$").firstMatch(value.trim()); + if (m == null) return null; + final h = int.parse(m.group(1)!); + final min = int.parse(m.group(2)!); + return h > 23 || min > 59 ? null : (h, min); + } + + /// The digits the box shows: the hour as this clock writes it, and the minute. + String _toText(String value) { + final p = _parse(value); + if (p == null) return ""; + final h = _twelve ? (p.$1 % 12 == 0 ? 12 : p.$1 % 12) : p.$1; + return "${_pad(h)}:${_pad(p.$2)}"; + } + + /// Whether the value sits in the afternoon. Only consulted on a 12-hour clock, + /// where the box cannot say it and the toggle has to. + bool _toPm(String value) { + final p = _parse(value); + return p != null && p.$1 >= 12; + } + + /// What the box and the toggle hold → "HH:MM", or "" while it is still half + /// typed. + String _toValue(String text, bool pm) { + final digits = text.replaceAll(RegExp(r"\D"), ""); + if (digits.length != 4) return ""; + var h = int.parse(digits.substring(0, 2)); + final min = int.parse(digits.substring(2)); + if (min > 59) return ""; + if (_twelve) { + if (h < 1 || h > 12) return ""; + h = (h % 12) + (pm ? 12 : 0); + } else if (h > 23) { + return ""; + } + return "${_pad(h)}:${_pad(min)}"; + } + + void _onChanged(String raw) { + // The formatter below has already regrouped the digits; this only reports + // what they now mean. + widget.onChanged(_toValue(raw, _pm)); + } + + void _setPm(bool pm) { + setState(() => _pm = pm); + widget.onChanged(_toValue(_controller.text, pm)); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 76, + child: TextField( + controller: _controller, + enabled: widget.enabled, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + inputFormatters: [_ClockMask()], + decoration: InputDecoration( + border: const OutlineInputBorder(), + isDense: true, + counterText: "", + hintText: "--:--", + labelText: widget.label, + ), + onChanged: _onChanged, + ), + ), + if (_twelve) ...[ + const SizedBox(width: 6), + // A toggle rather than a dropdown: two values, and the one not chosen + // is the only other answer there is. + SegmentedButton( + style: const ButtonStyle( + visualDensity: VisualDensity(horizontal: -3, vertical: -3), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + showSelectedIcon: false, + segments: const [ + ButtonSegment(value: false, label: Text("am")), + ButtonSegment(value: true, label: Text("pm")), + ], + selected: {_pm}, + onSelectionChanged: widget.enabled ? (s) => _setPm(s.first) : null, + ), + ], + ], + ); + } +} + +/// Digits regrouped as hh:mm as they are typed. No trailing colon: it appears +/// with the next digit, and adding it early only gives backspace something to +/// fight with. +class _ClockMask extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate(TextEditingValue _, TextEditingValue next) { + var digits = next.text.replaceAll(RegExp(r"\D"), ""); + if (digits.length > 4) digits = digits.substring(0, 4); + final text = + digits.length > 2 ? "${digits.substring(0, 2)}:${digits.substring(2)}" : digits; + return TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + } +} diff --git a/Phone App/test/charging_screen_test.dart b/Phone App/test/charging_screen_test.dart index 3854919..a5c7272 100644 --- a/Phone App/test/charging_screen_test.dart +++ b/Phone App/test/charging_screen_test.dart @@ -83,6 +83,8 @@ void main() { // Named by their own headings, so the sheet reads like the page. const cardLabels = { "control": "charging.control.title", + "rfid": "charging.rfid.title", + "settings": "charging.modbus.settingsTitle", "connection": "charging.control.connectionTitle", "readings": "charging.modbus.title", "info": "charging.info.title",