From 181f55a849375b4559411f35900d2f1e06a2c270 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:11:19 +0200 Subject: [PATCH] The phone catches up with the month the web had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-eight commits landed on the web app and the API since the phone was last touched, and the phone's own README opens by claiming full feature parity. It was not a small drift: a whole tab, two whole cards, and the two settings that decide how a time is read. The scheduler arrives as the third charging tab. One list of tasks covering every charger the account owns, where the charger's own cloud schedule is one window inside one box. A task is a flow — start at 23:00, cap to 10 A at 01:00, stop at 06:30 — on the days and the chargers it names, and naming no charger means all of them, including the ones imported later. The clock is the server's, so the tab only writes tasks and reads back how each one last went, and any step can be fired now to find out whether it will reach the charger before the night it matters. The RFID card comes with it: the list the account holds, a card added by its number or by holding it against the charger's own reader, and the charger's own list read back from the device. Both halves are written by every add and remove and they can still come apart, so when they disagree the card says which list each card is missing from — nothing else on the page would. The charger settings card the phone never had at all goes in whole rather than only its new half. Over Modbus that is the four writable registers; over the cloud it is the charger's whole settings group in sections, drawn from the same block table the web reads, one write per section because the charger takes a command whole and a schedule carrying only its switch is a schedule whose times have just been set to midnight. The clock and the week become settings. format.dart grows formatTime, the weekday order and the short names, with "auto" asking intl's own hour pattern and FIRSTDAYOFWEEK rather than a table here; Settings › Appearance asks both questions beneath the date. Flutter's own picker renders on the device locale, which nothing in this app steers, so TimeField types four digits on whichever clock is in force and keeps the meridiem as its own control — a box reading 13:45 beside a dial saying 01:45 PM is the disagreement the setting exists to end. The smaller ones travel too. The control card says which charger its buttons drive, picture and name, because it follows a serial and not the highlighted row; its two tiles take the names of the readings they actually hold; and the limit slider leaves it wherever a settings card now owns that value. The list's reachability re-asks every thirty seconds while the tab is in front, merged rather than replaced — "we could not ask" is not an answer, and it certainly is not "unknown". A settings frame that answers half a minute late is chased at widening gaps and then given up on. The information card names the fields the service sent under its own names and groups list records under their own, so list[0].* stops being read as one alphabetical run. An inherited integration field shows what it inherited rather than an example. The sign-in fields say nothing until you type. One gap stays open, and deliberately. The task form sends the phone's zone only when Dart reports an IANA name; Android usually answers with an abbreviation like CEST, which is not a zone, so it sends nothing and the server falls back to its own clock. A name the server would misread is worse than no name. Co-Authored-By: Claude Opus 5 --- Phone App/README.md | 66 +- Phone App/assets/i18n/da.json | 153 +- Phone App/assets/i18n/en.json | 153 +- Phone App/assets/i18n/pl.json | 155 +- Phone App/lib/api.dart | 83 + Phone App/lib/app_settings.dart | 23 + Phone App/lib/format.dart | 144 +- Phone App/lib/models.dart | 190 ++ Phone App/lib/screens/charging_screen.dart | 2401 ++++++++++++++++- .../lib/screens/charging_task_sheet.dart | 410 +++ Phone App/lib/screens/login_screen.dart | 7 +- Phone App/lib/screens/settings_screen.dart | 79 +- Phone App/lib/widgets/time_field.dart | 193 ++ Phone App/test/charging_screen_test.dart | 2 + 14 files changed, 3976 insertions(+), 83 deletions(-) create mode 100644 Phone App/lib/screens/charging_task_sheet.dart create mode 100644 Phone App/lib/widgets/time_field.dart 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",