From 13f50aa9a4af603262f7d1a30e44af4811cfb18d Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:03:52 +0200 Subject: [PATCH] Phone App: add Charging screen + Settings integration tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the Flutter app to parity with the recent Web App changes, which touched features the Phone App did not yet have — so this builds the Charging and Integrations subsystems, then applies the tab/fold structure. Charging (new nav destination): split into "Public chargers" (stylized discovery map + demo session + nearby public stations) and "Home chargers" (the real Anker Solix OCPP control card — serial refresh, connector/energy tiles, start/stop, current limit, password step-up on reset — plus the user's home charger list). Gated by the per-user control mode, degrading to a Settings hint when off. Settings: split into "Personal settings" (the existing account/appearance/ profile/security/privacy/danger sections) and "Integrations". The latter holds foldable Toyota and Anker Solix cards over the superadmin -> org -> user cascade: locked fields with "inherited from" notes, org-admin scope switch, enable toggle, save/test with health result, and Anker OCPP token provisioning. Both tabs stay mounted (IndexedStack) so in-flight edits survive a switch. Adds the integration + OCPP control endpoints to api.dart, the resolved IntegrationView/Scope/Field, IntegrationHealth and AnkerControl models, and charging.*/settings.tabs.*/nav.charging strings (en/pl/da) plus settings.integrations.* (en) — mirroring the Web App's own pl/da coverage, which leaves integrations and charger control untranslated as an English fallback. Co-Authored-By: Claude Opus 4.8 --- Phone App/assets/i18n/da.json | 52 +- Phone App/assets/i18n/en.json | 115 +++ Phone App/assets/i18n/pl.json | 54 +- Phone App/lib/api.dart | 64 ++ Phone App/lib/models.dart | 150 ++++ Phone App/lib/screens/charging_screen.dart | 989 +++++++++++++++++++++ Phone App/lib/screens/root_shell.dart | 11 +- Phone App/lib/screens/settings_screen.dart | 902 ++++++++++++++++++- 8 files changed, 2286 insertions(+), 51 deletions(-) create mode 100644 Phone App/lib/screens/charging_screen.dart diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index 37f9763..444c09e 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -10,13 +10,12 @@ "confirm": "Bekræft", "empty": "—" }, - "nav": { "garage": "Garage", + "charging": "Opladning", "settings": "Indstillinger", "users": "Brugere" }, - "login": { "tagline": "Styr på din bil.", "email": "E-mail", @@ -46,7 +45,6 @@ "savedNote": "Gemt ✓", "resetNote": "Nulstillet ✓" }, - "lock": { "title": "DriverVault er låst", "reason": "Lås DriverVault op", @@ -54,7 +52,6 @@ "unlocking": "Låser op…", "usePassword": "Brug adgangskode i stedet" }, - "dashboard": { "eyebrow": "DINE BILER", "title": "Garage", @@ -75,10 +72,45 @@ "other": "{n} serviceposter" } }, - + "charging": { + "eyebrow": "OPLADNING OG KORT", + "title": "Ladere i nærheden", + "tabs": { + "public": "Offentlige ladere", + "home": "Hjemmeladere" + }, + "liveMap": "Live-kort", + "youAreHere": "Du er her", + "session": { + "chargingNow": "Oplader nu", + "rate": "Effekt", + "added": "Tilføjet", + "cost": "Pris", + "done": "Færdig", + "stop": "Stop opladning", + "idle": "Oplader ikke", + "idleHint": "Tilslut ved en station for at starte en session." + }, + "stations": { + "heading": "I nærheden", + "homeHeading": "Dine ladere", + "noControlHint": "Aktivér Own eller Proxy CSMS under Indstillinger → Integrationer for at styre din hjemmelader her.", + "count": { + "one": "{n} station", + "other": "{n} stationer" + }, + "free": "{avail} af {total} ledige", + "full": "Optaget", + "offPeak": "uden for spidsbelastning", + "homeCharger": "Hjemmelader" + } + }, "settings": { + "tabs": { + "personal": "Personlige indstillinger", + "integrations": "Integrationer" + }, "title": "Indstillinger", - "account": { "title": "Konto", "name": "Navn", @@ -99,7 +131,6 @@ "updating": "Opdaterer…", "updatePassword": "Opdater adgangskode" }, - "appearance": { "title": "Udseende", "theme": "Tema", @@ -120,7 +151,6 @@ "fontMedium": "Mellem", "fontLarge": "Stor" }, - "profile": { "title": "Profil", "working": "Arbejder…", @@ -131,7 +161,6 @@ "bioSaved": "Beskrivelse gemt.", "saveBio": "Gem beskrivelse" }, - "security": { "title": "Sikkerhed", "checkingDevice": "Tjekker enhed…", @@ -148,13 +177,11 @@ "confirmPassword": "Bekræft din adgangskode", "password": "Adgangskode" }, - "privacy": { "title": "Privatliv og sikkerhed", "body": "Tofaktorgodkendelse er ikke tilgængelig endnu. Sessioner bygger på tokens udstedt af serveren, som udløber af sig selv, så at logge ud afslutter kun sessionen på denne enhed. For at logge alle enheder ud skal du skifte din adgangskode ovenfor.", "signOut": "Log ud" }, - "danger": { "title": "Farezone", "body": "Sletning af din konto fjerner dit login og din profil. Det sletter ikke husstandens delte biler eller servicehistorik. Der er 3 dages betænkningstid, før sletningen er endelig, og du kan annullere når som helst inden da.", @@ -173,7 +200,6 @@ "delete": "Slet" } }, - "status": { "noData": "Ingen data", "serviceOverdueDays": "Service overskredet med {days} d", @@ -201,4 +227,4 @@ "warrantyEndsIn": "Garanti slutter om {days} d", "underWarranty": "Under garanti · {days} d" } -} +} \ No newline at end of file diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json index 6dc3157..d073601 100644 --- a/Phone App/assets/i18n/en.json +++ b/Phone App/assets/i18n/en.json @@ -13,6 +13,7 @@ "nav": { "garage": "Garage", + "charging": "Charging", "settings": "Settings", "users": "Users" }, @@ -76,9 +77,123 @@ } }, + "charging": { + "eyebrow": "CHARGING & MAP", + "title": "Nearby chargers", + "tabs": { + "public": "Public chargers", + "home": "Home chargers" + }, + "liveMap": "Live map", + "youAreHere": "You are here", + "session": { + "chargingNow": "Charging now", + "rate": "Rate", + "added": "Added", + "cost": "Cost", + "done": "Done", + "stop": "Stop charging", + "idle": "Not charging", + "idleHint": "Plug in at a station to start a session." + }, + "control": { + "title": "Charger control", + "connected": "Connected", + "disconnected": "Offline", + "serialPlaceholder": "Charger serial (e.g. A5191-XXXXXXXX)", + "refresh": "Refresh", + "status": "Connector", + "meter": "Energy", + "start": "Start charging", + "stop": "Stop charging", + "limit": "Current limit", + "applyLimit": "Apply limit", + "clearLimit": "Clear limit", + "reset": "Reset charger", + "resetConfirm": "Reboot this charger now? Any active charging session will be interrupted. Re-enter your password to confirm.", + "resetPassword": "Your account password", + "connectHint": "Enter your charger's serial and refresh. The charger must be connected to DriverVault's OCPP backend (set up in Settings → Integrations)." + }, + "stations": { + "heading": "Nearby", + "homeHeading": "Your chargers", + "noControlHint": "Turn on Own or Proxy CSMS in Settings → Integrations to control your home charger here.", + "count": { + "one": "{n} station", + "other": "{n} stations" + }, + "free": "{avail} of {total} free", + "full": "Full", + "offPeak": "off-peak", + "homeCharger": "Home charger" + } + }, + "settings": { "title": "Settings", + "tabs": { + "personal": "Personal settings", + "integrations": "Integrations" + }, + + "integrations": { + "title": "Integrations", + "subtitle": "Connect external services to your account.", + "toyota": "Toyota Connected (MyToyota)", + "toyotaDesc": "Sign in with your MyToyota account to pull vehicle data from Toyota Connected Europe. Europe only.", + "enable": "Enable for my account", + "enableOrg": "Enable for the organization", + "unavailable": "This integration is turned off by the administrator.", + "orgDisabled": "This integration is turned off for your organization.", + "scopeMy": "My settings", + "scopeOrg": "Organization", + "scopeHint": "Organization settings apply to everyone in your organization who leaves a field blank.", + "readOnly": "You manage this integration's shared settings in the API Server panel; here it is read-only.", + "email": "MyToyota email", + "password": "MyToyota password", + "brand": "Brand", + "brandToyota": "Toyota", + "brandLexus": "Lexus", + "inherited": "Set above your level", + "inheritedFrom": "Inherited from {source}", + "sourceGlobal": "the administrator", + "sourceOrg": "your organization", + "sourceUser": "you", + "passwordKeep": "Leave blank to keep the saved password.", + "test": "Test connection", + "testing": "Testing…", + "save": "Save", + "saved": "Saved ✓", + "connected": "Connected", + "notConnected": "Not connected", + "ankerSolix": "Anker Solix (V1 Smart EV Charger)", + "ankerSolixDesc": "Sign in with your Anker account to pull EV charger data from the Anker Solix cloud.", + "ankerEmail": "Anker account email", + "ankerPassword": "Anker account password", + "country": "Country", + "countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US).", + "controlMode": "Control mode", + "controlModeHint": "How DriverVault controls the charger over OCPP.", + "controlOff": "Off (monitoring only)", + "controlOwn": "Own CSMS (full control)", + "controlProxy": "Proxy CSMS (relay + control)", + "controlTitle": "Charger control (OCPP)", + "controlOwnHint": "The charger connects directly to DriverVault as its Central System. Point the charger's OCPP backend at the endpoint below.", + "controlProxyHint": "DriverVault relays to Anker's cloud and can inject commands. Point the charger's OCPP backend at the endpoint below.", + "controlProvisionSteps": "In the Anker app (or the charger's OCPP settings), set the OCPP backend URL to the endpoint and the authorization key to the token above.", + "chargerSerial": "Charger serial", + "controlCheck": "Check status", + "controlGenerate": "Generate token", + "controlEndpoint": "OCPP endpoint", + "controlToken": "Auth token", + "controlTokenOnce": "Copy this token now — it's shown only once and can't be retrieved later.", + "controlRevoke": "Revoke token", + "controlRevokeConfirm": "Revoke this charger's control token? It will disconnect and can't reconnect until you generate a new one.", + "controlConnected": "Connected to control backend", + "controlDisconnected": "Not connected" + }, + "account": { "title": "Account", "name": "Name", diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index 8ecff4c..eedc0cb 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -10,13 +10,12 @@ "confirm": "Potwierdź", "empty": "—" }, - "nav": { "garage": "Garaż", + "charging": "Ładowanie", "settings": "Ustawienia", "users": "Użytkownicy" }, - "login": { "tagline": "Twój samochód pod kontrolą.", "email": "E-mail", @@ -46,7 +45,6 @@ "savedNote": "Zapisano ✓", "resetNote": "Zresetowano ✓" }, - "lock": { "title": "DriverVault jest zablokowany", "reason": "Odblokuj DriverVault", @@ -54,7 +52,6 @@ "unlocking": "Odblokowywanie…", "usePassword": "Użyj hasła" }, - "dashboard": { "eyebrow": "TWOJE SAMOCHODY", "title": "Garaż", @@ -77,10 +74,47 @@ "other": "{n} wpisu serwisowego" } }, - + "charging": { + "eyebrow": "ŁADOWANIE I MAPA", + "title": "Ładowarki w pobliżu", + "tabs": { + "public": "Ładowarki publiczne", + "home": "Ładowarki domowe" + }, + "liveMap": "Mapa na żywo", + "youAreHere": "Tu jesteś", + "session": { + "chargingNow": "Ładowanie w toku", + "rate": "Moc", + "added": "Dodano", + "cost": "Koszt", + "done": "Koniec", + "stop": "Zatrzymaj ładowanie", + "idle": "Brak ładowania", + "idleHint": "Podłącz na stacji, aby rozpocząć sesję." + }, + "stations": { + "heading": "W pobliżu", + "homeHeading": "Twoje ładowarki", + "noControlHint": "Włącz tryb Own lub Proxy CSMS w Ustawienia → Integracje, aby sterować domową ładowarką.", + "count": { + "one": "{n} stacja", + "few": "{n} stacje", + "many": "{n} stacji", + "other": "{n} stacji" + }, + "free": "{avail} z {total} wolnych", + "full": "Zajęte", + "offPeak": "poza szczytem", + "homeCharger": "Ładowarka domowa" + } + }, "settings": { + "tabs": { + "personal": "Ustawienia osobiste", + "integrations": "Integracje" + }, "title": "Ustawienia", - "account": { "title": "Konto", "name": "Imię i nazwisko", @@ -101,7 +135,6 @@ "updating": "Aktualizowanie…", "updatePassword": "Zaktualizuj hasło" }, - "appearance": { "title": "Wygląd", "theme": "Motyw", @@ -122,7 +155,6 @@ "fontMedium": "Średnia", "fontLarge": "Duża" }, - "profile": { "title": "Profil", "working": "Przetwarzanie…", @@ -133,7 +165,6 @@ "bioSaved": "Zapisano opis.", "saveBio": "Zapisz opis" }, - "security": { "title": "Bezpieczeństwo", "checkingDevice": "Sprawdzanie urządzenia…", @@ -150,13 +181,11 @@ "confirmPassword": "Potwierdź hasło", "password": "Hasło" }, - "privacy": { "title": "Prywatność i bezpieczeństwo", "body": "Uwierzytelnianie dwuskładnikowe nie jest jeszcze dostępne. Sesje opierają się na tokenach wydawanych przez serwer, które wygasają samoczynnie, więc wylogowanie kończy tylko sesję na tym urządzeniu. Aby wylogować wszystkie urządzenia, zmień hasło powyżej.", "signOut": "Wyloguj się" }, - "danger": { "title": "Strefa niebezpieczna", "body": "Usunięcie konta usuwa Twój login i profil. Nie usuwa samochodów ani historii serwisowej współdzielonych w gospodarstwie domowym. Obowiązuje 3-dniowy okres karencji, zanim usunięcie stanie się ostateczne — do tego czasu możesz je anulować.", @@ -175,7 +204,6 @@ "delete": "Usuń" } }, - "status": { "noData": "Brak danych", "serviceOverdueDays": "Serwis zaległy {days} dni", @@ -203,4 +231,4 @@ "warrantyEndsIn": "Gwarancja kończy się za {days} dni", "underWarranty": "Na gwarancji · {days} dni" } -} +} \ No newline at end of file diff --git a/Phone App/lib/api.dart b/Phone App/lib/api.dart index 6303248..eac5d57 100644 --- a/Phone App/lib/api.dart +++ b/Phone App/lib/api.dart @@ -408,4 +408,68 @@ class ApiClient { Future cancelAccountDeletion() => _send("POST", "/me/delete/cancel"); Future finalizeAccountDeletion() => _send("DELETE", "/me"); + + // --- integrations (per-user plugin settings, superadmin → org → user cascade) --- + // getToyota/getAnkerSolix return the resolved view (effective/own/locked per + // field, secrets and inherited values masked); saveToyota/saveAnkerSolix write + // the caller's editable layer (scope "user" by default, "org" for org admins); + // testToyota/testAnkerSolix run a live login probe under the resolved creds. + Future getToyota() async { + final data = await _send("GET", "/integrations/toyota"); + return IntegrationView.fromJson(Map.from(data)); + } + + Future saveToyota(Map body) async { + final data = await _send("PUT", "/integrations/toyota", body: body); + return IntegrationView.fromJson(Map.from(data)); + } + + Future testToyota() async { + final data = await _send("POST", "/integrations/toyota/health"); + final h = (data is Map ? data["health"] : null) ?? {}; + return IntegrationHealth.fromJson(Map.from(h)); + } + + Future getAnkerSolix() async { + final data = await _send("GET", "/integrations/anker-solix"); + return IntegrationView.fromJson(Map.from(data)); + } + + Future saveAnkerSolix(Map body) async { + final data = await _send("PUT", "/integrations/anker-solix", body: body); + return IntegrationView.fromJson(Map.from(data)); + } + + Future testAnkerSolix() async { + final data = await _send("POST", "/integrations/anker-solix/health"); + final h = (data is Map ? data["health"] : null) ?? {}; + return IntegrationHealth.fromJson(Map.from(h)); + } + + // --- Anker Solix OCPP control (per charger) --- + // getAnkerControl returns the control mode, connection status, provisioning + // endpoint + token, and a live status snapshot; ankerControlToken (re)generates + // the per-charger token (returned exactly once); ankerControlRevoke deletes it; + // ankerControlAction issues one OCPP command (start/stop/limit/clear-limit/reset/…). + String _sn(String sn) => Uri.encodeComponent(sn); + + Future getAnkerControl(String sn) async { + final data = await _send("GET", "/integrations/anker-solix/chargers/${_sn(sn)}/control"); + return AnkerControl.fromJson(Map.from(data)); + } + + /// (Re)generates the per-charger control token; the plaintext token is returned + /// exactly once, so the caller must show it immediately. + Future ankerControlToken(String sn) async { + final data = await _send("POST", "/integrations/anker-solix/chargers/${_sn(sn)}/control/token"); + return (data is Map ? _asString(data["token"]) : ""); + } + + Future ankerControlRevoke(String sn) => + _send("DELETE", "/integrations/anker-solix/chargers/${_sn(sn)}/control/token"); + + Future ankerControlAction(String sn, String action, [Map body = const {}]) => + _send("POST", "/integrations/anker-solix/chargers/${_sn(sn)}/$action", body: body); + + static String _asString(dynamic v) => v == null ? "" : v.toString(); } diff --git a/Phone App/lib/models.dart b/Phone App/lib/models.dart index 10caf90..cb29f7a 100644 --- a/Phone App/lib/models.dart +++ b/Phone App/lib/models.dart @@ -720,3 +720,153 @@ class UserProfile { // The Session model is gone: auth now uses PocketBase's own stateless tokens, // so there is no per-device session list to show or revoke. + +// --- Integrations (Toyota / Anker Solix) ----------------------------------- +// +// The server resolves a superadmin → org admin → user cascade and returns, per +// field, the effective value (secrets/inherited values masked), the caller's +// own-layer value, its source layer, and whether it's locked (set above us). +// These mirror the resolved shapes the web Settings.vue consumes. + +/// One field within a scope: the resolved value plus provenance. +class IntegrationField { + final String effective; // resolved value (secrets masked to "") + final String own; // the caller's own-layer value (unmasked, editable) + final String source; // "global" | "org" | "user" | "unset" + final bool locked; // set above the caller's editable layer + + const IntegrationField({ + this.effective = "", + this.own = "", + this.source = "unset", + this.locked = false, + }); + + factory IntegrationField.fromJson(Map j) => IntegrationField( + effective: _asStr(j["effective"]), + own: _asStr(j["own"]), + source: _asStr(j["source"]).isEmpty ? "unset" : _asStr(j["source"]), + locked: _asBool(j["locked"]), + ); +} + +/// A cascade scope ("user" or "org") the caller may edit, with its fields. +class IntegrationScope { + final String editableLayer; // "user" | "org" + final Map fields; + + const IntegrationScope({this.editableLayer = "user", this.fields = const {}}); + + factory IntegrationScope.fromJson(Map j) { + final raw = j["fields"]; + final fields = {}; + if (raw is Map) { + raw.forEach((k, v) { + if (v is Map) fields[k.toString()] = IntegrationField.fromJson(Map.from(v)); + }); + } + return IntegrationScope( + editableLayer: _asStr(j["editableLayer"]).isEmpty ? "user" : _asStr(j["editableLayer"]), + fields: fields, + ); + } + + IntegrationField field(String key) => fields[key] ?? const IntegrationField(); +} + +/// The resolved view of one integration for the signed-in user, from +/// GET /integrations/{toyota|anker-solix}. +class IntegrationView { + final bool available; // the integration is enabled at the master layer + final bool enabled; // the user's personal opt-in + final bool orgEnabled; // the organization gate + final String orgId; // "" when the user has no organization + final bool canEditOrg; // the caller is an org admin + final bool isSuperadmin; // manages the shared layer elsewhere (read-only here) + final String controlMode; // Anker only: effective OCPP mode (off|own|proxy) + final Map scopes; // "user" and optionally "org" + + const IntegrationView({ + this.available = false, + this.enabled = false, + this.orgEnabled = false, + this.orgId = "", + this.canEditOrg = false, + this.isSuperadmin = false, + this.controlMode = "off", + this.scopes = const {}, + }); + + factory IntegrationView.fromJson(Map j) { + final raw = j["scopes"]; + final scopes = {}; + if (raw is Map) { + raw.forEach((k, v) { + if (v is Map) scopes[k.toString()] = IntegrationScope.fromJson(Map.from(v)); + }); + } + return IntegrationView( + available: _asBool(j["available"]), + enabled: _asBool(j["enabled"]), + orgEnabled: _asBool(j["orgEnabled"]), + orgId: _asStr(j["orgId"]), + canEditOrg: _asBool(j["canEditOrg"]), + isSuperadmin: _asBool(j["isSuperadmin"]), + controlMode: _asStr(j["controlMode"]).isEmpty ? "off" : _asStr(j["controlMode"]), + scopes: scopes, + ); + } + + IntegrationScope scope(String key) => scopes[key] ?? const IntegrationScope(); +} + +/// The result of a live connection probe (POST …/health). +class IntegrationHealth { + final String status; // "ok" | "error" | … + final String detail; + const IntegrationHealth({this.status = "", this.detail = ""}); + + factory IntegrationHealth.fromJson(Map j) => IntegrationHealth( + status: _asStr(j["status"]), + detail: _asStr(j["detail"]), + ); +} + +/// A charger's OCPP control view, from …/chargers/{sn}/control. Serves both the +/// Settings provisioning card (endpoint/token/connection) and the Charging home +/// tab's live control (connector status + meter). +class AnkerControl { + final String endpoint; // OCPP backend URL to point the charger at + final bool hasToken; // a per-charger token has been generated + final String tokenHint; // last chars of the token, for display + final bool connected; // the charger is connected to the control backend + final String controlMode; // off | own | proxy + final String connectorStatus; // OCPP connector status, e.g. "Charging" + final int meterWh; // last meter reading in watt-hours + + const AnkerControl({ + this.endpoint = "", + this.hasToken = false, + this.tokenHint = "", + this.connected = false, + this.controlMode = "off", + this.connectorStatus = "", + this.meterWh = 0, + }); + + factory AnkerControl.fromJson(Map j) { + final status = j["status"]; + final s = status is Map ? Map.from(status) : const {}; + return AnkerControl( + endpoint: _asStr(j["endpoint"]), + hasToken: _asBool(j["hasToken"]), + tokenHint: _asStr(j["tokenHint"]), + connected: _asBool(j["connected"]), + controlMode: _asStr(j["controlMode"]).isEmpty ? "off" : _asStr(j["controlMode"]), + connectorStatus: _asStr(s["connectorStatus"]), + meterWh: _asInt(s["meterWh"]), + ); + } + + double get meterKwh => meterWh / 1000.0; +} diff --git a/Phone App/lib/screens/charging_screen.dart b/Phone App/lib/screens/charging_screen.dart new file mode 100644 index 0000000..6e1ae4b --- /dev/null +++ b/Phone App/lib/screens/charging_screen.dart @@ -0,0 +1,989 @@ +import "package:flutter/material.dart"; + +import "../i18n.dart"; +import "../main.dart"; +import "../models.dart"; +import "../theme.dart"; + +/// Charging & map screen, mirroring the web Charging.vue. Two tabs split the +/// public charging network (a presentational discovery map + demo session + +/// nearby public stations) from the user's own home charger(s) and their real +/// OCPP control. +/// +/// There is no live public-charging API yet — the session and station lists are +/// placeholders, exactly as on the web. The one real piece is the OCPP control +/// card on the Home tab, which drives a charger through the Anker Solix control +/// endpoints once the user picks Own/Proxy CSMS in Settings → Integrations. +class ChargingScreen extends StatefulWidget { + const ChargingScreen({super.key}); + @override + State createState() => _ChargingScreenState(); +} + +/// A charging station, tone-coded by availability. Presentational demo data. +class _Station { + final String id; + final String name; + final String dist; + final int kw; + final String conn; + final int avail; + final int total; + final String price; + final String tone; // "good" | "due" | "fault" + final bool home; + const _Station(this.id, this.name, this.dist, this.kw, this.conn, this.avail, + this.total, this.price, this.tone, + {this.home = false}); +} + +class _ChargingScreenState extends State { + int _tab = 0; // 0 = public, 1 = home + String _selected = "sc"; + + List<_Station> get _stations => [ + const _Station("sc", "DriverVault Supercharge", "0.4 km", 250, "CCS · NACS", 6, 8, "0,34 €", "good"), + const _Station("evgo", "EVgo · Market St", "1.2 km", 150, "CCS", 2, 6, "0,41 €", "due"), + const _Station("cp", "ChargePoint Garage", "2.1 km", 62, "J1772", 0, 4, "0,29 €", "fault"), + _Station("home", t("charging.stations.homeCharger"), "—", 11, "NACS", 1, 1, + t("charging.stations.offPeak"), "good", home: true), + ]; + + List<_Station> get _publicStations => _stations.where((s) => !s.home).toList(); + List<_Station> get _homeStations => _stations.where((s) => s.home).toList(); + + Color _toneColor(String tone) { + switch (tone) { + case "due": + return DriverVault.warning; + case "fault": + return DriverVault.danger; + default: + return DriverVault.success; + } + } + + String _stationStatus(_Station s) => s.avail == 0 + ? t("charging.stations.full") + : t("charging.stations.free", params: {"avail": s.avail, "total": s.total}); + + @override + Widget build(BuildContext context) { + final muted = DriverVault.muted(context); + return Scaffold( + appBar: AppBar(title: Text(t("charging.title"))), + body: ListView( + padding: const EdgeInsets.all(12), + children: [ + // Eyebrow + title header, matching the web layout. + Text(t("charging.eyebrow"), + style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted) + .copyWith(letterSpacing: 2.2)), + const SizedBox(height: 2), + Text(t("charging.title"), + style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.5)), + const SizedBox(height: 16), + + // Tabs: public network vs. the user's own home charger(s). + _TabBar( + tabs: [t("charging.tabs.public"), t("charging.tabs.home")], + index: _tab, + onChanged: (i) => setState(() => _tab = i), + ), + const SizedBox(height: 16), + + // IndexedStack keeps each tab's state alive across switches (v-show parity). + IndexedStack( + index: _tab, + children: [ + _PublicTab( + stations: _publicStations, + selected: _selected, + onSelect: (id) => setState(() => _selected = id), + toneColor: _toneColor, + statusFor: _stationStatus, + ), + _HomeTab( + stations: _homeStations, + selected: _selected, + onSelect: (id) => setState(() => _selected = id), + toneColor: _toneColor, + statusFor: _stationStatus, + ), + ], + ), + const SizedBox(height: 24), + ], + ), + ); + } +} + +// --- Shared: underline tab bar (matches Settings tabs) ---------------------- + +class _TabBar extends StatelessWidget { + final List tabs; + final int index; + final ValueChanged onChanged; + const _TabBar({required this.tabs, required this.index, required this.onChanged}); + + @override + Widget build(BuildContext context) { + final accent = Theme.of(context).colorScheme.primary; + final muted = DriverVault.muted(context); + final border = DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100; + return Container( + decoration: BoxDecoration(border: Border(bottom: BorderSide(color: border))), + child: Row( + children: [ + for (var i = 0; i < tabs.length; i++) + GestureDetector( + onTap: () => onChanged(i), + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.only(right: 20, bottom: 10, top: 2), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: i == index ? accent : Colors.transparent, + width: 2, + ), + ), + ), + child: Text( + tabs[i], + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: i == index ? (DriverVault.isDark(context) ? DriverVault.darkTextStrong : DriverVault.ink900) : muted, + ), + ), + ), + ), + ], + ), + ); + } +} + +/// A titled bordered card, matching the web's dh-card. +class _Card extends StatelessWidget { + final EdgeInsetsGeometry padding; + final Widget child; + const _Card({this.padding = const EdgeInsets.all(16), required this.child}); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + side: BorderSide(color: Theme.of(context).dividerColor), + ), + child: Padding(padding: padding, child: child), + ); + } +} + +/// The green/amber/red "Connected"/"Not connected" style pill. +class _StatusBadge extends StatelessWidget { + final String label; + final bool ok; + const _StatusBadge({required this.label, required this.ok}); + + @override + Widget build(BuildContext context) { + final dark = DriverVault.isDark(context); + final fg = ok ? DriverVault.success : DriverVault.warning; + final bg = ok + ? (dark ? DriverVault.successSoftDark : DriverVault.successSoft) + : (dark ? DriverVault.warningSoftDark : DriverVault.warningSoft); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(999)), + child: Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: fg)), + ); + } +} + +// --- A station row, shared by both station lists ----------------------------- + +class _StationTile extends StatelessWidget { + final _Station station; + final bool selected; + final VoidCallback onTap; + final Color tone; + final String status; + const _StationTile({ + required this.station, + required this.selected, + required this.onTap, + required this.tone, + required this.status, + }); + + @override + Widget build(BuildContext context) { + final muted = DriverVault.muted(context); + final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: selected ? DriverVault.brandTint(context) : null, + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration(color: sunken, borderRadius: BorderRadius.circular(10)), + child: Icon(Icons.bolt, size: 18, color: tone), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(station.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 2), + Text("${station.dist} · ${station.kw} kW · ${station.conn}", + style: DriverVault.mono(context, size: 11, color: muted)), + ], + ), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(status, style: DriverVault.mono(context, size: 11, weight: FontWeight.w500, color: tone)), + const SizedBox(height: 2), + Text(station.price, style: DriverVault.mono(context, size: 11, color: muted)), + ], + ), + ], + ), + ), + ); + } +} + +/// A section wrapper with an eyebrow header and a list of stations. +class _StationList extends StatelessWidget { + final String heading; + final List<_Station> stations; + final String selected; + final ValueChanged onSelect; + final Color Function(String) toneColor; + final String Function(_Station) statusFor; + const _StationList({ + required this.heading, + required this.stations, + required this.selected, + required this.onSelect, + required this.toneColor, + required this.statusFor, + }); + + @override + Widget build(BuildContext context) { + final muted = DriverVault.muted(context); + return _Card( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(6, 6, 6, 8), + child: Text( + "$heading · ${t("charging.stations.count", params: {"n": stations.length}, n: stations.length)}", + style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted) + .copyWith(letterSpacing: 1.4), + ), + ), + for (final s in stations) + _StationTile( + station: s, + selected: selected == s.id, + onTap: () => onSelect(s.id), + tone: toneColor(s.tone), + status: statusFor(s), + ), + ], + ), + ); + } +} + +// --- Public tab: discovery map + demo session + public station list ---------- + +class _PublicTab extends StatefulWidget { + final List<_Station> stations; + final String selected; + final ValueChanged onSelect; + final Color Function(String) toneColor; + final String Function(_Station) statusFor; + const _PublicTab({ + required this.stations, + required this.selected, + required this.onSelect, + required this.toneColor, + required this.statusFor, + }); + + @override + State<_PublicTab> createState() => _PublicTabState(); +} + +class _PublicTabState extends State<_PublicTab> { + // The demo session card is presentational, so its running/stopped state is + // purely local. + bool _charging = true; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _MapPanel( + stations: widget.stations, + selected: widget.selected, + onSelect: widget.onSelect, + toneColor: widget.toneColor, + ), + const SizedBox(height: 16), + _SessionCard(charging: _charging, onStop: () => setState(() => _charging = false)), + const SizedBox(height: 16), + _StationList( + heading: t("charging.stations.heading"), + stations: widget.stations, + selected: widget.selected, + onSelect: widget.onSelect, + toneColor: widget.toneColor, + statusFor: widget.statusFor, + ), + ], + ); + } +} + +/// A stylized discovery map with a "you are here" marker and charger pins, +/// standing in for a real map the way the web's CSS grid panel does. +class _MapPanel extends StatelessWidget { + final List<_Station> stations; + final String selected; + final ValueChanged onSelect; + final Color Function(String) toneColor; + const _MapPanel({ + required this.stations, + required this.selected, + required this.onSelect, + required this.toneColor, + }); + + // Pin positions as fractions of the panel, mirroring the web's x/y percents. + static const _pos = { + "sc": Offset(0.47, 0.34), + "evgo": Offset(0.26, 0.60), + "cp": Offset(0.70, 0.64), + }; + + @override + Widget build(BuildContext context) { + final dark = DriverVault.isDark(context); + final base = dark ? DriverVault.darkSunken : DriverVault.ink25; + final grid = dark ? Colors.white.withValues(alpha: 0.04) : const Color(0x0B0F1E3D); + return ClipRRect( + borderRadius: BorderRadius.circular(DriverVault.radiusCard), + child: Container( + height: 320, + decoration: BoxDecoration( + color: base, + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(DriverVault.radiusCard), + ), + child: LayoutBuilder( + builder: (context, c) { + final w = c.maxWidth, h = c.maxHeight; + return Stack( + children: [ + // Grid lines. + CustomPaint(size: Size(w, h), painter: _GridPainter(grid)), + // Roads. + Positioned(left: 0, right: 0, top: h * 0.52, child: Container(height: 12, color: DriverVault.brand100.withValues(alpha: dark ? 0.10 : 1))), + Positioned(top: 0, bottom: 0, left: w * 0.58, child: Container(width: 12, color: DriverVault.brand100.withValues(alpha: dark ? 0.10 : 1))), + // Legend chip. + Positioned( + left: 12, + top: 12, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).cardColor.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: Theme.of(context).dividerColor), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.map_outlined, size: 13, color: DriverVault.brand600), + const SizedBox(width: 5), + Text(t("charging.liveMap"), + style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: DriverVault.muted(context)) + .copyWith(letterSpacing: 1.4)), + ]), + ), + ), + // You are here. + Positioned( + left: w * 0.5 - 20, + top: h * 0.5 - 20, + child: Tooltip( + message: t("charging.youAreHere"), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration(color: DriverVault.brand600.withValues(alpha: 0.16), shape: BoxShape.circle), + child: Center( + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: DriverVault.brand600, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ), + ), + ), + // Charger pins. + for (final s in stations) + if (_pos[s.id] != null) + Positioned( + left: w * _pos[s.id]!.dx - (selected == s.id ? 18 : 14), + top: h * _pos[s.id]!.dy - (selected == s.id ? 36 : 28), + child: _Pin( + selected: selected == s.id, + color: toneColor(s.tone), + label: "${s.avail}/${s.total} · ${s.kw} kW", + onTap: () => onSelect(s.id), + ), + ), + ], + ); + }, + ), + ), + ); + } +} + +class _Pin extends StatelessWidget { + final bool selected; + final Color color; + final String label; + final VoidCallback onTap; + const _Pin({required this.selected, required this.color, required this.label, required this.onTap}); + + @override + Widget build(BuildContext context) { + final size = selected ? 36.0 : 28.0; + return GestureDetector( + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (selected) + Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).dividerColor), + ), + child: Text(label, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600)), + ), + Container( + width: size, + height: size, + decoration: BoxDecoration( + color: color, + border: Border.all(color: Colors.white, width: 2), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(999), + topRight: Radius.circular(999), + bottomRight: Radius.circular(999), + bottomLeft: Radius.circular(3), + ), + ), + child: Icon(Icons.bolt, color: Colors.white, size: selected ? 18 : 14), + ), + ], + ), + ); + } +} + +/// Faint grid lines behind the map, echoing the web's repeating gradients. +class _GridPainter extends CustomPainter { + final Color color; + _GridPainter(this.color); + @override + void paint(Canvas canvas, Size size) { + final p = Paint() + ..color = color + ..strokeWidth = 1; + const step = 44.0; + for (double x = 0; x < size.width; x += step) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), p); + } + for (double y = 0; y < size.height; y += step) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), p); + } + } + + @override + bool shouldRepaint(covariant _GridPainter old) => old.color != color; +} + +/// The presentational "Charging now" session card (dark brand background). +class _SessionCard extends StatelessWidget { + final bool charging; + final VoidCallback onStop; + const _SessionCard({required this.charging, required this.onStop}); + + @override + Widget build(BuildContext context) { + const from = 62, to = 80; + final metrics = [ + (t("charging.session.rate"), "142 kW"), + (t("charging.session.added"), "+29 km"), + (t("charging.session.cost"), "5,80 €"), + (t("charging.session.done"), "~18 min"), + ]; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: DriverVault.brand900, + borderRadius: BorderRadius.circular(DriverVault.radiusCard), + ), + child: charging + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 6, height: 6, decoration: const BoxDecoration(color: DriverVault.success, shape: BoxShape.circle)), + const SizedBox(width: 8), + Text("${t("charging.session.chargingNow")} · Model Y", + style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: DriverVault.brand300) + .copyWith(letterSpacing: 1.4)), + ]), + const SizedBox(height: 12), + RichText( + text: TextSpan( + style: DriverVault.mono(context, size: 34, weight: FontWeight.w500, color: Colors.white), + children: [ + const TextSpan(text: "$from"), + TextSpan( + text: " % → $to%", + style: DriverVault.mono(context, size: 15, weight: FontWeight.w500, color: Colors.white.withValues(alpha: 0.7)), + ), + ], + ), + ), + const SizedBox(height: 14), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: from / 100, + minHeight: 8, + backgroundColor: Colors.white.withValues(alpha: 0.15), + valueColor: const AlwaysStoppedAnimation(DriverVault.brand400), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + for (final m in metrics) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(m.$1, + style: DriverVault.mono(context, size: 9, weight: FontWeight.w500, color: DriverVault.brand300) + .copyWith(letterSpacing: 1.2)), + const SizedBox(height: 2), + Text(m.$2, style: DriverVault.mono(context, size: 14, weight: FontWeight.w500, color: Colors.white)), + ], + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: onStop, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.white.withValues(alpha: 0.10), + side: BorderSide(color: Colors.white.withValues(alpha: 0.20)), + ), + child: Text(t("charging.session.stop")), + ), + ), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 6, height: 6, decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.4), shape: BoxShape.circle)), + const SizedBox(width: 8), + Text(t("charging.session.idle"), + style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: DriverVault.brand300) + .copyWith(letterSpacing: 1.4)), + ]), + const SizedBox(height: 12), + Text(t("charging.session.idleHint"), + style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.7))), + ], + ), + ); + } +} + +// --- Home tab: real OCPP control + home station list ------------------------- + +class _HomeTab extends StatelessWidget { + final List<_Station> stations; + final String selected; + final ValueChanged onSelect; + final Color Function(String) toneColor; + final String Function(_Station) statusFor; + const _HomeTab({ + required this.stations, + required this.selected, + required this.onSelect, + required this.toneColor, + required this.statusFor, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const _ControlCard(), + const SizedBox(height: 16), + _StationList( + heading: t("charging.stations.homeHeading"), + stations: stations, + selected: selected, + onSelect: onSelect, + toneColor: toneColor, + statusFor: statusFor, + ), + ], + ); + } +} + +/// The real OCPP control card, gated by the per-user Anker Solix control mode. +/// When no control mode is active it degrades to a hint pointing at Settings. +class _ControlCard extends StatefulWidget { + const _ControlCard(); + @override + State<_ControlCard> createState() => _ControlCardState(); +} + +class _ControlCardState extends State<_ControlCard> { + final _serial = TextEditingController(); + String _mode = "off"; // effective control mode (off | own | proxy) + AnkerControl? _ctl; + String? _error; + String _busy = ""; // action name currently in flight + double _limitAmps = 16; + bool _resetPrompt = false; + final _resetPassword = TextEditingController(); + + bool get _active => _mode != "off"; + bool get _connected => _ctl?.connected == true; + + @override + void initState() { + super.initState(); + _init(); + } + + @override + void dispose() { + _serial.dispose(); + _resetPassword.dispose(); + super.dispose(); + } + + Future _init() async { + try { + final v = await apiClient.getAnkerSolix(); + if (mounted) setState(() => _mode = v.controlMode); + } catch (_) { + if (mounted) setState(() => _mode = "off"); + } + await _refresh(); + } + + Future _refresh() async { + final sn = _serial.text.trim(); + if (sn.isEmpty) { + setState(() => _ctl = null); + return; + } + setState(() => _error = null); + try { + final c = await apiClient.getAnkerControl(sn); + if (mounted) setState(() => _ctl = c); + } catch (e) { + if (mounted) { + setState(() { + _error = "$e"; + _ctl = null; + }); + } + } + } + + Future _action(String action, [Map body = const {}]) async { + final sn = _serial.text.trim(); + if (sn.isEmpty) return; + setState(() { + _busy = action; + _error = null; + }); + try { + await apiClient.ankerControlAction(sn, action, body); + await _refresh(); + } catch (e) { + if (mounted) setState(() => _error = "$e"); + } finally { + if (mounted) setState(() => _busy = ""); + } + } + + Future _confirmReset() async { + if (_resetPassword.text.isEmpty) return; + setState(() => _resetPrompt = false); + await _action("reset", {"hard": false, "confirm": true, "password": _resetPassword.text}); + _resetPassword.clear(); + } + + @override + Widget build(BuildContext context) { + final muted = DriverVault.muted(context); + if (!_active) { + // No control mode active — point the user at Settings to enable it. + return _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t("charging.control.title"), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Text(t("charging.stations.noControlHint"), style: TextStyle(fontSize: 12, color: muted)), + ], + ), + ); + } + + return _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(t("charging.control.title"), + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + ), + _StatusBadge( + label: _connected ? t("charging.control.connected") : t("charging.control.disconnected"), + ok: _connected, + ), + ], + ), + const SizedBox(height: 12), + Row(children: [ + Expanded( + child: TextField( + controller: _serial, + autocorrect: false, + decoration: InputDecoration( + border: const OutlineInputBorder(), + isDense: true, + hintText: t("charging.control.serialPlaceholder"), + ), + ), + ), + const SizedBox(width: 8), + OutlinedButton(onPressed: _refresh, child: Text(t("charging.control.refresh"))), + ]), + if (_connected) ...[ + const SizedBox(height: 12), + Row(children: [ + Expanded(child: _MetricTile(value: _ctl?.connectorStatus.isNotEmpty == true ? _ctl!.connectorStatus : "—", label: t("charging.control.status"))), + const SizedBox(width: 8), + Expanded(child: _MetricTile(value: "${_ctl!.meterKwh.toStringAsFixed(2)} kWh", label: t("charging.control.meter"))), + ]), + const SizedBox(height: 12), + Row(children: [ + Expanded( + child: FilledButton( + onPressed: _busy == "start" ? null : () => _action("start"), + child: Text(t("charging.control.start")), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + onPressed: _busy == "stop" ? null : () => _action("stop"), + child: Text(t("charging.control.stop")), + ), + ), + ]), + const SizedBox(height: 12), + Row(children: [ + Text(t("charging.control.limit"), style: TextStyle(fontSize: 13, color: muted)), + const Spacer(), + Text("${_limitAmps.round()} A", style: DriverVault.mono(context, size: 13, weight: FontWeight.w500)), + ]), + Slider( + value: _limitAmps, + min: 6, + max: 32, + divisions: 26, + label: "${_limitAmps.round()} A", + onChanged: (v) => setState(() => _limitAmps = v), + ), + Row(children: [ + Expanded( + child: OutlinedButton( + onPressed: _busy == "limit" ? null : () => _action("limit", {"amps": _limitAmps.round()}), + child: Text(t("charging.control.applyLimit")), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + onPressed: _busy == "clear-limit" ? null : () => _action("clear-limit"), + child: Text(t("charging.control.clearLimit")), + ), + ), + ]), + const SizedBox(height: 12), + if (!_resetPrompt) + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: _busy == "reset" + ? null + : () => setState(() { + _error = null; + _resetPassword.clear(); + _resetPrompt = true; + }), + child: Text(t("charging.control.reset")), + ), + ) + else + // Step-up: destructive reset requires re-entering the password. + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: DriverVault.isDark(context) ? DriverVault.dangerSoftDark : DriverVault.dangerSoft, + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + border: Border.all(color: DriverVault.danger.withValues(alpha: 0.4)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t("charging.control.resetConfirm"), + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: DriverVault.danger)), + const SizedBox(height: 8), + TextField( + controller: _resetPassword, + obscureText: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + isDense: true, + hintText: t("charging.control.resetPassword"), + ), + onSubmitted: (_) => _confirmReset(), + ), + const SizedBox(height: 8), + Row(children: [ + Expanded( + child: OutlinedButton( + onPressed: () => setState(() { + _resetPrompt = false; + _resetPassword.clear(); + }), + child: Text(t("common.cancel")), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton( + style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), + onPressed: _busy == "reset" ? null : _confirmReset, + child: Text(t("charging.control.reset")), + ), + ), + ]), + ], + ), + ), + ] else + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(t("charging.control.connectHint"), style: TextStyle(fontSize: 12, color: muted)), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(_error!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)), + ), + ], + ), + ); + } +} + +/// A small sunken metric tile (connector status / energy). +class _MetricTile extends StatelessWidget { + final String value; + final String label; + const _MetricTile({required this.value, required this.label}); + + @override + Widget build(BuildContext context) { + final muted = DriverVault.muted(context); + final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration(color: sunken, borderRadius: BorderRadius.circular(DriverVault.radiusControl)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(value, style: DriverVault.mono(context, size: 14, weight: FontWeight.w600)), + const SizedBox(height: 2), + Text(label, style: TextStyle(fontSize: 11, color: muted)), + ], + ), + ); + } +} diff --git a/Phone App/lib/screens/root_shell.dart b/Phone App/lib/screens/root_shell.dart index 5b239e0..3023838 100644 --- a/Phone App/lib/screens/root_shell.dart +++ b/Phone App/lib/screens/root_shell.dart @@ -3,12 +3,13 @@ import "package:flutter/material.dart"; import "../i18n.dart"; import "../main.dart"; import "dashboard_screen.dart"; +import "charging_screen.dart"; import "settings_screen.dart"; import "admin_users_screen.dart"; /// The app's root shell — a persistent bottom navigation bar (DriverVault mockup -/// layout) hosting the Garage, Settings and (for admins) Users sections in an -/// IndexedStack so each keeps its state as you switch tabs. +/// layout) hosting the Garage, Charging, Settings and (for admins) Users sections +/// in an IndexedStack so each keeps its state as you switch tabs. class RootShell extends StatefulWidget { const RootShell({super.key}); @override @@ -24,6 +25,7 @@ class _RootShellState extends State { final pages = [ const DashboardScreen(), + const ChargingScreen(), const SettingsScreen(), if (isAdmin) const AdminUsersScreen(), ]; @@ -34,6 +36,11 @@ class _RootShellState extends State { selectedIcon: const Icon(Icons.grid_view_rounded), label: t("nav.garage"), ), + NavigationDestination( + icon: const Icon(Icons.ev_station_outlined), + selectedIcon: const Icon(Icons.ev_station), + label: t("nav.charging"), + ), NavigationDestination( icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings), diff --git a/Phone App/lib/screens/settings_screen.dart b/Phone App/lib/screens/settings_screen.dart index 2fd03be..412d714 100644 --- a/Phone App/lib/screens/settings_screen.dart +++ b/Phone App/lib/screens/settings_screen.dart @@ -24,6 +24,7 @@ class _SettingsScreenState extends State { Uint8List? _avatar; bool _loading = true; String? _loadError; + int _tab = 0; // 0 = personal, 1 = integrations final _name = TextEditingController(); final _bio = TextEditingController(); @@ -78,36 +79,104 @@ class _SettingsScreenState extends State { ? const Center(child: CircularProgressIndicator()) : _loadError != null ? Center(child: Text(_loadError!)) - : ListView( - padding: const EdgeInsets.all(12), + // Two tabs: personal account settings and external integrations. + // Both panels stay mounted (IndexedStack) so their loaded state and + // in-flight edits survive a tab switch. + : Column( children: [ - _AccountSection( - profile: _profile!, - nameController: _name, - onChanged: _load, - snack: _snack, + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), + child: _SettingsTabBar( + tabs: [t("settings.tabs.personal"), t("settings.tabs.integrations")], + index: _tab, + onChanged: (i) => setState(() => _tab = i), + ), ), - const SizedBox(height: 12), - _AppearanceSection(onError: _snack), - const SizedBox(height: 12), - _ProfileSection( - profile: _profile!, - bioController: _bio, - avatar: _avatar, - onChanged: _load, - snack: _snack, + Expanded( + child: IndexedStack( + index: _tab, + children: [ + _personalTab(), + const _IntegrationsTab(), + ], + ), ), - const SizedBox(height: 12), - _SecuritySection(email: _profile!.email, snack: _snack), - const SizedBox(height: 12), - const _PrivacySection(), - const SizedBox(height: 12), - _DangerSection(profile: _profile!, onChanged: _load, snack: _snack), - const SizedBox(height: 24), ], ), ); } + + Widget _personalTab() => ListView( + padding: const EdgeInsets.all(12), + children: [ + _AccountSection( + profile: _profile!, + nameController: _name, + onChanged: _load, + snack: _snack, + ), + const SizedBox(height: 12), + _AppearanceSection(onError: _snack), + const SizedBox(height: 12), + _ProfileSection( + profile: _profile!, + bioController: _bio, + avatar: _avatar, + onChanged: _load, + snack: _snack, + ), + const SizedBox(height: 12), + _SecuritySection(email: _profile!.email, snack: _snack), + const SizedBox(height: 12), + const _PrivacySection(), + const SizedBox(height: 12), + _DangerSection(profile: _profile!, onChanged: _load, snack: _snack), + const SizedBox(height: 24), + ], + ); +} + +/// The underline tab bar for the Personal / Integrations split. +class _SettingsTabBar extends StatelessWidget { + final List tabs; + final int index; + final ValueChanged onChanged; + const _SettingsTabBar({required this.tabs, required this.index, required this.onChanged}); + + @override + Widget build(BuildContext context) { + final accent = Theme.of(context).colorScheme.primary; + final muted = DriverVault.muted(context); + final strong = DriverVault.isDark(context) ? DriverVault.darkTextStrong : DriverVault.ink900; + return Container( + decoration: BoxDecoration(border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor))), + child: Row( + children: [ + for (var i = 0; i < tabs.length; i++) + GestureDetector( + onTap: () => onChanged(i), + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.only(right: 22, bottom: 10, top: 2), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: i == index ? accent : Colors.transparent, width: 2), + ), + ), + child: Text( + tabs[i], + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: i == index ? strong : muted, + ), + ), + ), + ), + ], + ), + ); + } } /// A titled card wrapper matching the web's sectioned layout. @@ -973,3 +1042,790 @@ class _DangerSectionState extends State<_DangerSection> { ); } } + +// --- Integrations tab ------------------------------------------------------ +// +// Two foldable connector cards (Toyota, Anker Solix) over a superadmin → org +// admin → user cascade. Each card resolves, per field, the effective value +// (secrets/inherited values masked), the caller's own-layer value, its source +// layer, and whether it's locked (set above the caller). Org admins get a +// second "org" scope to edit organization-wide defaults; superadmins manage the +// shared layer in the API Server panel, so here it is read-only. + +enum _FieldType { text, password, select } + +/// Describes one credential field within an integration card. +class _FieldSpec { + final String key; + final String labelKey; + final _FieldType type; + final List<(String, String)> options; // (value, labelKey) for selects + final String? placeholder; // literal placeholder + final String? hintKey; // shown below the field when not locked + final String defaultValue; + final bool showEffectiveWhenLocked; // controlMode isn't secret: show it locked + final int? maxLength; + + const _FieldSpec({ + required this.key, + required this.labelKey, + this.type = _FieldType.text, + this.options = const [], + this.placeholder, + this.hintKey, + this.defaultValue = "", + this.showEffectiveWhenLocked = false, + this.maxLength, + }); +} + +/// Everything that distinguishes one integration from the other. +class _IntegrationConfig { + final String nameKey; + final String descKey; + final List<_FieldSpec> fields; + final bool anker; // render the OCPP control provisioning card + final Future Function() load; + final Future Function(Map) save; + final Future Function() test; + + const _IntegrationConfig({ + required this.nameKey, + required this.descKey, + required this.fields, + required this.anker, + required this.load, + required this.save, + required this.test, + }); +} + +class _IntegrationsTab extends StatelessWidget { + const _IntegrationsTab(); + + @override + Widget build(BuildContext context) { + final toyota = _IntegrationConfig( + nameKey: "settings.integrations.toyota", + descKey: "settings.integrations.toyotaDesc", + anker: false, + load: apiClient.getToyota, + save: apiClient.saveToyota, + test: apiClient.testToyota, + fields: const [ + _FieldSpec(key: "username", labelKey: "settings.integrations.email"), + _FieldSpec(key: "password", labelKey: "settings.integrations.password", type: _FieldType.password), + _FieldSpec( + key: "brand", + labelKey: "settings.integrations.brand", + type: _FieldType.select, + options: [ + ("", "common.empty"), + ("T", "settings.integrations.brandToyota"), + ("L", "settings.integrations.brandLexus"), + ], + ), + ], + ); + + final anker = _IntegrationConfig( + nameKey: "settings.integrations.ankerSolix", + descKey: "settings.integrations.ankerSolixDesc", + anker: true, + load: apiClient.getAnkerSolix, + save: apiClient.saveAnkerSolix, + test: apiClient.testAnkerSolix, + fields: const [ + _FieldSpec(key: "email", labelKey: "settings.integrations.ankerEmail"), + _FieldSpec(key: "password", labelKey: "settings.integrations.ankerPassword", type: _FieldType.password), + _FieldSpec( + key: "country", + labelKey: "settings.integrations.country", + placeholder: "DE", + hintKey: "settings.integrations.countryHint", + maxLength: 2, + ), + _FieldSpec( + key: "controlMode", + labelKey: "settings.integrations.controlMode", + type: _FieldType.select, + hintKey: "settings.integrations.controlModeHint", + defaultValue: "off", + showEffectiveWhenLocked: true, + options: [ + ("off", "settings.integrations.controlOff"), + ("own", "settings.integrations.controlOwn"), + ("proxy", "settings.integrations.controlProxy"), + ], + ), + ], + ); + + return ListView( + padding: const EdgeInsets.all(12), + children: [ + _Card( + title: t("settings.integrations.title"), + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text(t("settings.integrations.subtitle"), + style: TextStyle(fontSize: 13, color: DriverVault.muted(context))), + ), + const SizedBox(height: 8), + _IntegrationCard(config: toyota), + const SizedBox(height: 12), + _IntegrationCard(config: anker), + ], + ), + const SizedBox(height: 24), + ], + ); + } +} + +class _IntegrationCard extends StatefulWidget { + final _IntegrationConfig config; + const _IntegrationCard({required this.config}); + @override + State<_IntegrationCard> createState() => _IntegrationCardState(); +} + +class _IntegrationCardState extends State<_IntegrationCard> { + IntegrationView? _view; + String _scope = "user"; // "user" | "org" (org admins only) + bool _open = false; + bool _saving = false; + bool _saved = false; + bool _testing = false; + String? _error; + IntegrationHealth? _health; + + final Map _controllers = {}; + final Map _selects = {}; + + _IntegrationConfig get _c => widget.config; + + @override + void initState() { + super.initState(); + for (final f in _c.fields) { + if (f.type != _FieldType.select) _controllers[f.key] = TextEditingController(); + } + _load(); + } + + @override + void dispose() { + for (final ctrl in _controllers.values) { + ctrl.dispose(); + } + super.dispose(); + } + + // --- cascade helpers (mirror Settings.vue) --- + String get _scopeKey => (_view?.isSuperadmin ?? false) ? "user" : _scope; + bool get _editingOrg => _scopeKey == "org"; + bool get _readOnly => _view?.isSuperadmin ?? false; + IntegrationScope get _scopeData => _view?.scope(_scopeKey) ?? const IntegrationScope(); + IntegrationField _field(String k) => _scopeData.field(k); + bool _locked(String k) => _readOnly || _field(k).locked; + bool get _enabled => _editingOrg ? (_view?.orgEnabled ?? false) : (_view?.enabled ?? false); + + String _sourceLabel(String k) { + const map = {"global": "sourceGlobal", "org": "sourceOrg", "user": "sourceUser"}; + final key = map[_field(k).source] ?? "sourceGlobal"; + return t("settings.integrations.inheritedFrom", + params: {"source": t("settings.integrations.$key")}); + } + + // Load the editable inputs from the current scope's own-layer values. Locked + // fields and the secret password are never prefilled. + void _fillForm() { + for (final f in _c.fields) { + final field = _field(f.key); + String value; + if (f.type == _FieldType.password) { + value = ""; + } else if (f.showEffectiveWhenLocked && field.locked) { + value = field.effective.isNotEmpty ? field.effective : f.defaultValue; + } else if (field.locked) { + value = f.defaultValue; + } else { + value = field.own.isNotEmpty ? field.own : f.defaultValue; + } + if (f.type == _FieldType.select) { + _selects[f.key] = value; + } else { + _controllers[f.key]!.text = value; + } + } + } + + Future _load() async { + try { + final v = await _c.load(); + if (!mounted) return; + setState(() { + _view = v; + if (_scope == "org" && !v.canEditOrg) _scope = "user"; + }); + _fillForm(); + } catch (e) { + if (mounted) setState(() => _error = "$e"); + } + } + + void _applyView(IntegrationView v) { + setState(() { + _view = v; + if (_scope == "org" && !v.canEditOrg) _scope = "user"; + }); + _fillForm(); + } + + Future _toggle(bool value) async { + setState(() => _error = null); + try { + _applyView(await _c.save({"scope": _scopeKey, "enabled": value})); + } catch (e) { + if (mounted) setState(() => _error = "$e"); + } + } + + Future _saveSettings() async { + setState(() { + _error = null; + _saving = true; + _saved = false; + }); + final config = {}; + for (final f in _c.fields) { + if (_locked(f.key)) continue; + final val = f.type == _FieldType.select ? (_selects[f.key] ?? "") : _controllers[f.key]!.text; + if (f.type == _FieldType.password && val.isEmpty) continue; + config[f.key] = val; + } + try { + _applyView(await _c.save({"scope": _scopeKey, "config": config})); + if (!mounted) return; + setState(() => _saved = true); + Future.delayed(const Duration(seconds: 2), () { + if (mounted) setState(() => _saved = false); + }); + } catch (e) { + if (mounted) setState(() => _error = "$e"); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _test() async { + setState(() { + _error = null; + _health = null; + _testing = true; + }); + try { + final h = await _c.test(); + if (mounted) setState(() => _health = h); + } catch (e) { + if (mounted) setState(() => _error = "$e"); + } finally { + if (mounted) setState(() => _testing = false); + } + } + + void _switchScope(String sc) { + setState(() { + _scope = sc; + _error = null; + _saved = false; + _health = null; + }); + _fillForm(); + } + + @override + Widget build(BuildContext context) { + final view = _view; + final muted = DriverVault.muted(context); + return Container( + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + ), + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header — tap to fold/unfold. + InkWell( + onTap: () => setState(() => _open = !_open), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t(_c.nameKey), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 2), + Text(t(_c.descKey), style: TextStyle(fontSize: 12, color: muted)), + ], + ), + ), + const SizedBox(width: 8), + if (view != null && !_editingOrg) ...[ + _ConnBadge(connected: view.enabled), + const SizedBox(width: 8), + ], + AnimatedRotation( + turns: _open ? 0.5 : 0, + duration: const Duration(milliseconds: 150), + child: Icon(Icons.keyboard_arrow_down, color: muted), + ), + ], + ), + ), + if (_open) ...[ + if (view == null) + const Padding(padding: EdgeInsets.only(top: 12), child: LinearProgressIndicator()) + else + ..._body(view), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(_error!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)), + ), + ], + ], + ), + ); + } + + List _body(IntegrationView view) { + // Master / org gates. + if (!view.available) { + return [_warn(t("settings.integrations.unavailable"))]; + } + if (view.orgId.isNotEmpty && !view.orgEnabled && !_editingOrg) { + return [_warn(t("settings.integrations.orgDisabled"))]; + } + + final muted = DriverVault.muted(context); + return [ + // Scope switch (org admins only). + if (view.canEditOrg) + Padding( + padding: const EdgeInsets.only(top: 14), + child: SegmentedButton( + segments: [ + ButtonSegment(value: "user", label: Text(t("settings.integrations.scopeMy"))), + ButtonSegment(value: "org", label: Text(t("settings.integrations.scopeOrg"))), + ], + selected: {_scope}, + onSelectionChanged: (s) => _switchScope(s.first), + ), + ), + if (_editingOrg) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(t("settings.integrations.scopeHint"), style: TextStyle(fontSize: 12, color: muted)), + ), + if (_readOnly) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(t("settings.integrations.readOnly"), style: TextStyle(fontSize: 12, color: muted)), + ), + + // Enable toggle. + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row(children: [ + SizedBox( + width: 40, + height: 40, + child: Checkbox( + value: _enabled, + onChanged: (v) => _toggle(v ?? false), + ), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + _editingOrg ? t("settings.integrations.enableOrg") : t("settings.integrations.enable"), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + ), + ]), + ), + + // Credential fields. + for (final f in _c.fields) ...[ + const SizedBox(height: 12), + _fieldWidget(f), + ], + + // Save + test. + const SizedBox(height: 16), + Row(children: [ + if (!_readOnly) ...[ + FilledButton( + onPressed: _saving ? null : _saveSettings, + child: Text(_saving + ? t("common.saving") + : _saved + ? t("settings.integrations.saved") + : t("settings.integrations.save")), + ), + const SizedBox(width: 8), + ], + OutlinedButton( + onPressed: _testing ? null : _test, + child: Text(_testing ? t("settings.integrations.testing") : t("settings.integrations.test")), + ), + ]), + + if (_health != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _health!.detail.isNotEmpty ? _health!.detail : _health!.status, + style: TextStyle( + fontSize: 13, + color: _health!.status == "ok" ? DriverVault.success : DriverVault.danger, + ), + ), + ), + + // OCPP control provisioning (Anker only, when a control mode is active). + if (_c.anker && view.controlMode != "off") + Padding( + padding: const EdgeInsets.only(top: 16), + child: _AnkerControlProvision(controlMode: view.controlMode), + ), + ]; + } + + Widget _warn(String text) => Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(text, style: const TextStyle(fontSize: 13, color: DriverVault.warning)), + ); + + Widget _fieldWidget(_FieldSpec f) { + final muted = DriverVault.muted(context); + final locked = _locked(f.key); + final field = _field(f.key); + + Widget input; + if (f.type == _FieldType.select) { + input = DropdownButtonFormField( + initialValue: _selects[f.key] ?? f.defaultValue, + decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true), + items: [ + for (final o in f.options) DropdownMenuItem(value: o.$1, child: Text(t(o.$2))), + ], + onChanged: locked ? null : (v) => setState(() => _selects[f.key] = v ?? ""), + ); + } else { + final isPassword = f.type == _FieldType.password; + // Masked placeholder: locked non-secret shows dots; a set password too. + final showDots = isPassword ? field.effective.isNotEmpty : locked; + input = TextField( + controller: _controllers[f.key], + obscureText: isPassword, + enabled: !locked, + autocorrect: false, + maxLength: f.maxLength, + decoration: InputDecoration( + border: const OutlineInputBorder(), + isDense: true, + counterText: "", + hintText: showDots ? "••••••••" : f.placeholder, + ), + ); + } + + // The hint below the field: an "inherited from …" note when locked, else the + // field's own hint (and the password-keep note for password fields). + String? hint; + Color hintColor = muted; + if (field.locked) { + hint = _sourceLabel(f.key); + } else if (f.type == _FieldType.password) { + hint = t("settings.integrations.passwordKeep"); + } else if (f.hintKey != null) { + hint = t(f.hintKey!); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t(f.labelKey), style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + const SizedBox(height: 4), + input, + if (hint != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(hint, style: TextStyle(fontSize: 12, color: hintColor)), + ), + ], + ); + } +} + +/// The small green/amber Connected / Not connected pill in a card header. +class _ConnBadge extends StatelessWidget { + final bool connected; + const _ConnBadge({required this.connected}); + + @override + Widget build(BuildContext context) { + final dark = DriverVault.isDark(context); + final fg = connected ? DriverVault.success : DriverVault.warning; + final bg = connected + ? (dark ? DriverVault.successSoftDark : DriverVault.successSoft) + : (dark ? DriverVault.warningSoftDark : DriverVault.warningSoft); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(999)), + child: Text( + connected ? t("settings.integrations.connected") : t("settings.integrations.notConnected"), + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: fg), + ), + ); + } +} + +/// Anker Solix OCPP control provisioning — per-charger token + connection status. +/// Only shown when a control mode (own/proxy) is active for the user. +class _AnkerControlProvision extends StatefulWidget { + final String controlMode; // "own" | "proxy" + const _AnkerControlProvision({required this.controlMode}); + @override + State<_AnkerControlProvision> createState() => _AnkerControlProvisionState(); +} + +class _AnkerControlProvisionState extends State<_AnkerControlProvision> { + final _serial = TextEditingController(); + AnkerControl? _ctl; + bool _loading = false; + String? _error; + String _newToken = ""; // freshly generated token, shown once + + @override + void dispose() { + _serial.dispose(); + super.dispose(); + } + + Future _loadControl() async { + final sn = _serial.text.trim(); + if (sn.isEmpty) return; + setState(() { + _error = null; + _loading = true; + }); + try { + final c = await apiClient.getAnkerControl(sn); + if (mounted) setState(() => _ctl = c); + } catch (e) { + if (mounted) setState(() => _error = "$e"); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _generate() async { + final sn = _serial.text.trim(); + if (sn.isEmpty) return; + setState(() { + _error = null; + _newToken = ""; + }); + try { + // The token is returned exactly once — capture it here to show the operator. + final token = await apiClient.ankerControlToken(sn); + if (mounted) setState(() => _newToken = token); + await _loadControl(); + } catch (e) { + if (mounted) setState(() => _error = "$e"); + } + } + + Future _revoke() async { + final sn = _serial.text.trim(); + if (sn.isEmpty) return; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + content: Text(t("settings.integrations.controlRevokeConfirm")), + 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("settings.integrations.controlRevoke")), + ), + ], + ), + ); + if (ok != true) return; + setState(() { + _error = null; + _newToken = ""; + }); + try { + await apiClient.ankerControlRevoke(sn); + await _loadControl(); + } catch (e) { + if (mounted) setState(() => _error = "$e"); + } + } + + @override + Widget build(BuildContext context) { + final muted = DriverVault.muted(context); + final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink25; + final ctl = _ctl; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: sunken, + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t("settings.integrations.controlTitle"), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 2), + Text( + widget.controlMode == "own" + ? t("settings.integrations.controlOwnHint") + : t("settings.integrations.controlProxyHint"), + style: TextStyle(fontSize: 12, color: muted), + ), + const SizedBox(height: 12), + Text(t("settings.integrations.chargerSerial"), + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + const SizedBox(height: 4), + TextField( + controller: _serial, + autocorrect: false, + decoration: const InputDecoration( + border: OutlineInputBorder(), + isDense: true, + hintText: "A5191-XXXXXXXX", + ), + ), + const SizedBox(height: 10), + Wrap(spacing: 8, runSpacing: 8, children: [ + OutlinedButton( + onPressed: _loading ? null : _loadControl, + child: Text(_loading ? t("settings.integrations.testing") : t("settings.integrations.controlCheck")), + ), + FilledButton(onPressed: _generate, child: Text(t("settings.integrations.controlGenerate"))), + if (ctl != null && ctl.hasToken) + OutlinedButton( + onPressed: _revoke, + style: OutlinedButton.styleFrom(foregroundColor: DriverVault.danger), + child: Text(t("settings.integrations.controlRevoke")), + ), + ]), + + // The token is shown exactly once, right after generation. + if (_newToken.isNotEmpty) + Container( + margin: const EdgeInsets.only(top: 12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + 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("settings.integrations.controlTokenOnce"), + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: DriverVault.warning)), + const SizedBox(height: 4), + SelectableText(_newToken, style: DriverVault.mono(context, size: 13)), + ], + ), + ), + + if (ctl != null) ...[ + const SizedBox(height: 12), + _kv(t("settings.integrations.controlEndpoint"), ctl.endpoint, mono: true), + if (ctl.hasToken) _kv(t("settings.integrations.controlToken"), "••••${ctl.tokenHint}", mono: true), + const SizedBox(height: 8), + Row(children: [ + _ConnBadgeControl(connected: ctl.connected), + if (ctl.connectorStatus.isNotEmpty) ...[ + const SizedBox(width: 8), + Text(ctl.connectorStatus, style: TextStyle(fontSize: 13, color: muted)), + ], + ]), + const SizedBox(height: 8), + Text(t("settings.integrations.controlProvisionSteps"), + style: TextStyle(fontSize: 12, color: muted)), + ], + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(_error!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)), + ), + ], + ), + ); + } + + Widget _kv(String label, String value, {bool mono = false}) { + final muted = DriverVault.muted(context); + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: RichText( + text: TextSpan( + style: TextStyle(fontSize: 13, color: muted), + children: [ + TextSpan(text: "$label: "), + TextSpan( + text: value, + style: mono + ? DriverVault.mono(context, size: 13) + : TextStyle(fontSize: 13, color: DefaultTextStyle.of(context).style.color), + ), + ], + ), + ), + ); + } +} + +/// The Connected / Not connected pill for the OCPP control backend. +class _ConnBadgeControl extends StatelessWidget { + final bool connected; + const _ConnBadgeControl({required this.connected}); + + @override + Widget build(BuildContext context) { + final dark = DriverVault.isDark(context); + final fg = connected ? DriverVault.success : DriverVault.warning; + final bg = connected + ? (dark ? DriverVault.successSoftDark : DriverVault.successSoft) + : (dark ? DriverVault.warningSoftDark : DriverVault.warningSoft); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(999)), + child: Text( + connected + ? t("settings.integrations.controlConnected") + : t("settings.integrations.controlDisconnected"), + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: fg), + ), + ); + } +}