The phone catches up with the month the web had
Twenty-eight commits landed on the web app and the API since the phone was last touched, and the phone's own README opens by claiming full feature parity. It was not a small drift: a whole tab, two whole cards, and the two settings that decide how a time is read. The scheduler arrives as the third charging tab. One list of tasks covering every charger the account owns, where the charger's own cloud schedule is one window inside one box. A task is a flow — start at 23:00, cap to 10 A at 01:00, stop at 06:30 — on the days and the chargers it names, and naming no charger means all of them, including the ones imported later. The clock is the server's, so the tab only writes tasks and reads back how each one last went, and any step can be fired now to find out whether it will reach the charger before the night it matters. The RFID card comes with it: the list the account holds, a card added by its number or by holding it against the charger's own reader, and the charger's own list read back from the device. Both halves are written by every add and remove and they can still come apart, so when they disagree the card says which list each card is missing from — nothing else on the page would. The charger settings card the phone never had at all goes in whole rather than only its new half. Over Modbus that is the four writable registers; over the cloud it is the charger's whole settings group in sections, drawn from the same block table the web reads, one write per section because the charger takes a command whole and a schedule carrying only its switch is a schedule whose times have just been set to midnight. The clock and the week become settings. format.dart grows formatTime, the weekday order and the short names, with "auto" asking intl's own hour pattern and FIRSTDAYOFWEEK rather than a table here; Settings › Appearance asks both questions beneath the date. Flutter's own picker renders on the device locale, which nothing in this app steers, so TimeField types four digits on whichever clock is in force and keeps the meridiem as its own control — a box reading 13:45 beside a dial saying 01:45 PM is the disagreement the setting exists to end. The smaller ones travel too. The control card says which charger its buttons drive, picture and name, because it follows a serial and not the highlighted row; its two tiles take the names of the readings they actually hold; and the limit slider leaves it wherever a settings card now owns that value. The list's reachability re-asks every thirty seconds while the tab is in front, merged rather than replaced — "we could not ask" is not an answer, and it certainly is not "unknown". A settings frame that answers half a minute late is chased at widening gaps and then given up on. The information card names the fields the service sent under its own names and groups list records under their own, so list[0].* stops being read as one alphabetical run. An inherited integration field shows what it inherited rather than an example. The sign-in fields say nothing until you type. One gap stays open, and deliberately. The task form sends the phone's zone only when Dart reports an IANA name; Android usually answers with an abbreviation like CEST, which is not a zone, so it sends nothing and the server falls back to its own clock. A name the server would misread is worse than no name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2b4f4f034d
commit
181f55a849
+48
-18
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -652,6 +652,50 @@ class ApiClient {
|
||||
return ChargerDetails.fromJson(Map<String, dynamic>.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<RfidCardWrite> 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<String, dynamic>.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<RfidScan> 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<String, dynamic>.from(data));
|
||||
}
|
||||
|
||||
Future<RfidCardWrite> 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<String, dynamic>.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<List<String>> 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<void> 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<List<ChargingTask>> listChargingTasks() async {
|
||||
final data = await _send("GET", "/charging-tasks");
|
||||
final items = (data is Map ? data["tasks"] : null) ?? [];
|
||||
return (items as List)
|
||||
.whereType<Map>()
|
||||
.map((e) => ChargingTask.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<ChargingTask> createChargingTask(Map<String, dynamic> body) async {
|
||||
final data = await _send("POST", "/charging-tasks", body: body);
|
||||
return ChargingTask.fromJson(Map<String, dynamic>.from((data is Map ? data["task"] : null) ?? {}));
|
||||
}
|
||||
|
||||
Future<ChargingTask> updateChargingTask(String id, Map<String, dynamic> body) async {
|
||||
final data =
|
||||
await _send("PATCH", "/charging-tasks/${Uri.encodeComponent(id)}", body: body);
|
||||
return ChargingTask.fromJson(Map<String, dynamic>.from((data is Map ? data["task"] : null) ?? {}));
|
||||
}
|
||||
|
||||
Future<void> 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<String> 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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
+143
-1
@@ -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<String, bool> _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<String, bool> _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<int> 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<int> sortWeekdays(Iterable<int> 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.
|
||||
|
||||
@@ -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<String, dynamic> 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<String, dynamic> 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<RfidCard> cards;
|
||||
final String detail;
|
||||
|
||||
const RfidCardWrite({this.present = false, this.cards = const [], this.detail = ""});
|
||||
|
||||
factory RfidCardWrite.fromJson(Map<String, dynamic> j) => RfidCardWrite(
|
||||
present: _asBool(j["present"]),
|
||||
cards: j["cards"] is List
|
||||
? (j["cards"] as List)
|
||||
.whereType<Map>()
|
||||
.map((c) => RfidCard.fromJson(Map<String, dynamic>.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<String, dynamic> 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<String, dynamic> j) => ChargingStep(
|
||||
action: _asStr(j["action"]),
|
||||
time: _asStr(j["time"]),
|
||||
amps: _asDouble(j["amps"]),
|
||||
);
|
||||
|
||||
Map<String, dynamic> 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<String> chargers;
|
||||
|
||||
/// The flow, in the order it runs.
|
||||
final List<ChargingStep> 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<int> 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<String, dynamic> 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>()
|
||||
.map((e) => ChargingStep.fromJson(Map<String, dynamic>.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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<ChargingTask?> showChargingTaskSheet(
|
||||
BuildContext context, {
|
||||
ChargingTask? task,
|
||||
required List<HomeCharger> chargers,
|
||||
}) {
|
||||
return showModalBottomSheet<ChargingTask>(
|
||||
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<String> _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<HomeCharger> 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<String> _picked = {...?widget.task?.chargers};
|
||||
late bool _everyDay = widget.task == null || widget.task!.days.isEmpty;
|
||||
late final Set<int> _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<void> _submit() async {
|
||||
if (!_canSave) return;
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
final body = <String, dynamic>{
|
||||
"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 ? <String>[] : _picked.toList(),
|
||||
"days": _everyDay ? <int>[] : _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<String>(
|
||||
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)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -257,7 +257,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
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<LoginScreen> {
|
||||
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"),
|
||||
|
||||
@@ -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<String>(
|
||||
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<String>(
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<String> onChanged;
|
||||
final bool enabled;
|
||||
final String? label;
|
||||
|
||||
const TimeField({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.enabled = true,
|
||||
this.label,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TimeField> createState() => _TimeFieldState();
|
||||
}
|
||||
|
||||
class _TimeFieldState extends State<TimeField> {
|
||||
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<bool>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user