From 244d51b4bb1f888086b8fea5aece5549ac0a4d50 Mon Sep 17 00:00:00 2001 From: Alexandre JARDON <28548335+webalexeu@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:55:20 +0000 Subject: [PATCH 1/8] feat: Android home-screen widgets (Jetpack Glance) Adds loadpoint and forecast home-screen widgets built with Jetpack Glance: a widget configuration activity for picking server + loadpoint, real chart rendering for forecast data, per-instance widget config with immediate refresh on server change, and widget package name derived from app config so fork builds can coexist with the official app install. Co-Authored-By: Claude Sonnet 5 --- app.config.ts | 1 + modules/evcc-widget/android/build.gradle | 18 + .../android/src/main/AndroidManifest.xml | 2 + .../modules/evccwidget/EvccWidgetModule.kt | 48 +++ modules/evcc-widget/expo-module.config.json | 6 + modules/evcc-widget/package.json | 7 + package-lock.json | 9 + package.json | 1 + scripts/androidWidget/withAndroidWidget.ts | 318 ++++++++++++++++++ targets/android-widget/README.md | 63 ++++ targets/android-widget/kotlin/ApiClient.kt | 119 +++++++ .../android-widget/kotlin/ChartRenderer.kt | 99 ++++++ .../android-widget/kotlin/ForecastWidget.kt | 245 ++++++++++++++ .../kotlin/ForecastWidgetConfigActivity.kt | 142 ++++++++ targets/android-widget/kotlin/Format.kt | 78 +++++ .../android-widget/kotlin/LoadpointWidget.kt | 151 +++++++++ .../kotlin/LoadpointWidgetConfigActivity.kt | 156 +++++++++ targets/android-widget/kotlin/SharedStore.kt | 64 ++++ targets/android-widget/kotlin/Theme.kt | 22 ++ targets/android-widget/kotlin/WidgetConfig.kt | 93 +++++ utils/widgetRefresh.ts | 31 ++ utils/widgetSync.ts | 48 ++- 22 files changed, 1716 insertions(+), 5 deletions(-) create mode 100644 modules/evcc-widget/android/build.gradle create mode 100644 modules/evcc-widget/android/src/main/AndroidManifest.xml create mode 100644 modules/evcc-widget/android/src/main/java/expo/modules/evccwidget/EvccWidgetModule.kt create mode 100644 modules/evcc-widget/expo-module.config.json create mode 100644 modules/evcc-widget/package.json create mode 100644 scripts/androidWidget/withAndroidWidget.ts create mode 100644 targets/android-widget/README.md create mode 100644 targets/android-widget/kotlin/ApiClient.kt create mode 100644 targets/android-widget/kotlin/ChartRenderer.kt create mode 100644 targets/android-widget/kotlin/ForecastWidget.kt create mode 100644 targets/android-widget/kotlin/ForecastWidgetConfigActivity.kt create mode 100644 targets/android-widget/kotlin/Format.kt create mode 100644 targets/android-widget/kotlin/LoadpointWidget.kt create mode 100644 targets/android-widget/kotlin/LoadpointWidgetConfigActivity.kt create mode 100644 targets/android-widget/kotlin/SharedStore.kt create mode 100644 targets/android-widget/kotlin/Theme.kt create mode 100644 targets/android-widget/kotlin/WidgetConfig.kt create mode 100644 utils/widgetRefresh.ts diff --git a/app.config.ts b/app.config.ts index a461ecd..adf23d4 100644 --- a/app.config.ts +++ b/app.config.ts @@ -60,6 +60,7 @@ export default ({ config }: ConfigContext) => }, plugins: [ "@bacons/apple-targets", + ["./scripts/androidWidget/withAndroidWidget.ts"], ["./scripts/fdroid/configureFdroid.ts"], [ "./scripts/detox/configureDetox.ts", diff --git a/modules/evcc-widget/android/build.gradle b/modules/evcc-widget/android/build.gradle new file mode 100644 index 0000000..6931e25 --- /dev/null +++ b/modules/evcc-widget/android/build.gradle @@ -0,0 +1,18 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'expo.modules.evccwidget' +version = '0.1.0' + +android { + namespace "expo.modules.evccwidget" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } + lintOptions { + abortOnError false + } +} diff --git a/modules/evcc-widget/android/src/main/AndroidManifest.xml b/modules/evcc-widget/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bdae66c --- /dev/null +++ b/modules/evcc-widget/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/modules/evcc-widget/android/src/main/java/expo/modules/evccwidget/EvccWidgetModule.kt b/modules/evcc-widget/android/src/main/java/expo/modules/evccwidget/EvccWidgetModule.kt new file mode 100644 index 0000000..d40a4d9 --- /dev/null +++ b/modules/evcc-widget/android/src/main/java/expo/modules/evccwidget/EvccWidgetModule.kt @@ -0,0 +1,48 @@ +package expo.modules.evccwidget + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Intent +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class EvccWidgetModule : Module() { + // Glance widget receivers injected by scripts/androidWidget/withAndroidWidget.ts. + // Names are stable; the package is resolved at runtime so dev/prod app ids both work. + private val receivers = listOf( + "EvccLoadpointWidgetReceiver", + "EvccSolarWidgetReceiver", + "EvccPriceWidgetReceiver", + "EvccCo2WidgetReceiver", + "EvccFeedinWidgetReceiver", + ) + + override fun definition() = ModuleDefinition { + Name("EvccWidget") + + // Force an immediate redraw of the home-screen widgets. Sends each receiver a + // targeted APPWIDGET_UPDATE broadcast; GlanceAppWidgetReceiver re-runs + // provideGlance on receipt, so the widgets re-read the synced server list. + Function("refresh") { + val context = appContext.reactContext?.applicationContext ?: return@Function Unit + val manager = AppWidgetManager.getInstance(context) + val pkg = context.packageName + + for (name in receivers) { + val cn = ComponentName(pkg, "$pkg.widget.$name") + val ids = try { + manager.getAppWidgetIds(cn) + } catch (e: Exception) { + continue // provider not present (e.g. widget type never placed) + } + if (ids.isNotEmpty()) { + val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE).apply { + component = cn + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) + } + context.sendBroadcast(intent) + } + } + } + } +} diff --git a/modules/evcc-widget/expo-module.config.json b/modules/evcc-widget/expo-module.config.json new file mode 100644 index 0000000..869c957 --- /dev/null +++ b/modules/evcc-widget/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["expo.modules.evccwidget.EvccWidgetModule"] + } +} diff --git a/modules/evcc-widget/package.json b/modules/evcc-widget/package.json new file mode 100644 index 0000000..ad965c3 --- /dev/null +++ b/modules/evcc-widget/package.json @@ -0,0 +1,7 @@ +{ + "name": "evcc-widget", + "version": "0.1.0", + "description": "Native Android widget refresh module for evcc", + "license": "MIT", + "platforms": ["android"] +} diff --git a/package-lock.json b/package-lock.json index 2e71bd1..3d35ee5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/react": "~19.2.10", "axios": "^1.16.0", "base-64": "^1.0.0", + "evcc-widget": "file:./modules/evcc-widget", "expo": "^57.0.0", "expo-build-properties": "~57.0.7", "expo-camera": "~57.0.3", @@ -72,6 +73,10 @@ "typescript": "^6.0.3" } }, + "modules/evcc-widget": { + "version": "0.1.0", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -8542,6 +8547,10 @@ "node": ">= 0.6" } }, + "node_modules/evcc-widget": { + "resolved": "modules/evcc-widget", + "link": true + }, "node_modules/event-pubsub": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/event-pubsub/-/event-pubsub-4.3.0.tgz", diff --git a/package.json b/package.json index 0088d9c..b5557fb 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@types/react": "~19.2.10", "axios": "^1.16.0", "base-64": "^1.0.0", + "evcc-widget": "file:./modules/evcc-widget", "expo": "^57.0.0", "expo-build-properties": "~57.0.7", "expo-camera": "~57.0.3", diff --git a/scripts/androidWidget/withAndroidWidget.ts b/scripts/androidWidget/withAndroidWidget.ts new file mode 100644 index 0000000..3c541a5 --- /dev/null +++ b/scripts/androidWidget/withAndroidWidget.ts @@ -0,0 +1,318 @@ +import { + ConfigPlugin, + withAppBuildGradle, + withProjectBuildGradle, + withAndroidManifest, + withDangerousMod, + AndroidConfig, +} from "expo/config-plugins"; +import fs from "fs"; +import path from "path"; + +// Injects the Jetpack Glance loadpoint widget into the prebuilt Android +// project. Android counterpart of the @bacons/apple-targets iOS widget. +// +// ⚠ The Glance/Compose gradle wiring below is the #1 thing to verify after +// `expo prebuild`: the Compose compiler must match the project's Kotlin +// version. See targets/android-widget/README.md. + +const PACKAGE = "io.evcc.android"; +const WIDGET_SUBDIR = "widget"; // io.evcc.android.widget +const KOTLIN_SRC = "targets/android-widget/kotlin"; + +const GLANCE_VERSION = "1.1.1"; +// Compose compiler is versioned with Kotlin (2.0+). Must match the project's +// Kotlin version — check `android/build.gradle` after prebuild if it changes. +const COMPOSE_KOTLIN = "2.1.20"; + +// Root build.gradle: put the Compose compiler gradle plugin on the buildscript +// classpath so app/build.gradle can `apply plugin`. Without this the build +// fails with "Plugin with id 'org.jetbrains.kotlin.plugin.compose' not found". +const withComposeClasspath: ConfigPlugin = (config) => + withProjectBuildGradle(config, (config) => { + if (!config.modResults.contents.includes("compose-compiler-gradle-plugin")) { + const cp = `classpath("org.jetbrains.kotlin:compose-compiler-gradle-plugin:${COMPOSE_KOTLIN}")`; + config.modResults.contents = config.modResults.contents.replace( + /(classpath\(["']com\.android\.tools\.build:gradle["'][^\n]*\)\n)/, + (m) => `${m} ${cp}\n`, + ); + } + return config; + }); + +const withGlanceGradle: ConfigPlugin = (config) => + withAppBuildGradle(config, (config) => { + let src = config.modResults.contents; + + if (!src.includes("glance-appwidget")) { + // Glance brings a compatible Compose runtime transitively, so no BOM needed. + const deps = [ + ` implementation("androidx.glance:glance-appwidget:${GLANCE_VERSION}")`, + ` implementation("androidx.glance:glance-material3:${GLANCE_VERSION}")`, + ].join("\n"); + src = src.replace(/dependencies\s*\{/, (m) => `${m}\n${deps}`); + } + + // enable Compose for the app module (needed to compile Glance @Composable) + if (!src.includes("buildFeatures") || !/compose\s+true/.test(src)) { + src = src.replace( + /android\s*\{/, + (m) => `${m}\n buildFeatures {\n compose true\n }`, + ); + } + + config.modResults.contents = src; + return config; + }); + +const withGlanceComposeCompiler: ConfigPlugin = (config) => + // Kotlin 2.0+: the Compose compiler is a gradle plugin. Apply it to the app + // module. (For Kotlin < 2.0 use composeOptions.kotlinCompilerExtensionVersion + // instead — see README.) + withAppBuildGradle(config, (config) => { + const apply = `apply plugin: "org.jetbrains.kotlin.plugin.compose"`; + if (!config.modResults.contents.includes(apply)) { + config.modResults.contents = `${apply}\n${config.modResults.contents}`; + } + return config; + }); + +// The forecast widgets (Solar/Price/CO₂/Feed-in) share one appwidget-provider +// XML, but each gets a distinct android:label so the widget picker names them. +const FORECAST_RECEIVERS = [ + { name: "EvccSolarWidgetReceiver", label: "Solar" }, + { name: "EvccPriceWidgetReceiver", label: "Price" }, + { name: "EvccCo2WidgetReceiver", label: "CO₂" }, + { name: "EvccFeedinWidgetReceiver", label: "Feed-in" }, +]; + +const pushWidgetReceiver = (app: any, shortName: string, infoResource: string, label: string) => { + app.receiver = app.receiver ?? []; + const name = `.${WIDGET_SUBDIR}.${shortName}`; + if (app.receiver.some((r: any) => r.$["android:name"] === name)) return; + app.receiver.push({ + $: { "android:name": name, "android:exported": "false", "android:label": label }, + "intent-filter": [ + { action: [{ $: { "android:name": "android.appwidget.action.APPWIDGET_UPDATE" } }] }, + ], + "meta-data": [ + { + $: { + "android:name": "android.appwidget.provider", + "android:resource": `@xml/${infoResource}`, + }, + }, + ], + }); +}; + +const withWidgetReceiver: ConfigPlugin = (config) => + withAndroidManifest(config, (config) => { + const app = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults); + + pushWidgetReceiver(app, "EvccLoadpointWidgetReceiver", "loadpoint_widget_info", "Loadpoint"); + for (const r of FORECAST_RECEIVERS) pushWidgetReceiver(app, r.name, "forecast_widget_info", r.label); + + // widget placement configuration Activities (loadpoint picker; forecast server + solar toggle) + app.activity = app.activity ?? []; + for (const actName of [ + `.${WIDGET_SUBDIR}.LoadpointWidgetConfigActivity`, + `.${WIDGET_SUBDIR}.ForecastWidgetConfigActivity`, + ]) { + if (app.activity.some((a) => a.$["android:name"] === actName)) continue; + app.activity.push({ + $: { "android:name": actName, "android:exported": "true" }, + "intent-filter": [ + { + action: [{ $: { "android:name": "android.appwidget.action.APPWIDGET_CONFIGURE" } }], + }, + ], + } as any); + } + return config; + }); + +const widgetInfoXml = (pkg: string) => ` + +`; + +// Static preview image (vector) for the widget picker. Many OEM launchers +// (Xiaomi/MIUI, Nova, …) ignore android:previewLayout and only honour a +// previewImage drawable, so provide both. +const vBar = (x: number, h: number, w = 16) => + ``; +const previewImageVector = ` + + + + + ${[ + [14, 22], + [35, 40], + [56, 58], + [77, 78], + [98, 96], + [119, 100], + [140, 82], + [161, 60], + [182, 42], + [203, 26], + [224, 16], + ] + .map(([x, h]) => vBar(x, h)) + .join("\n ")} + +`; + +// Loadpoint preview image: a card with title / status / power lines and a row +// of mode "pills" (one highlighted), so it reads as a loadpoint, not a chart. +const loadpointPreviewImageVector = ` + + + + + + + + + + +`; + +// Forecast widgets: medium size, no configuration Activity. +const forecastInfoXml = (pkg: string) => ` + +`; + +// Static preview layouts shown in the widget picker (the Glance content only +// renders once placed). Kept representative of the real widgets. +const loadpointPreviewXml = ` + + + + + + + + + + + +`; + +// A green bar in the preview sparkline (fixed height, equal weight). +const bar = (h: number) => + ``; + +const forecastPreviewXml = ` + + + + + + ${[6, 10, 16, 24, 34, 40, 44, 38, 30, 20, 12, 6].map(bar).join("\n ")} + + +`; + +const withWidgetFiles: ConfigPlugin = (config) => + withDangerousMod(config, [ + "android", + (config) => { + const root = config.modRequest.platformProjectRoot; // android/ + const main = path.join(root, "app", "src", "main"); + + // derive the package from config so a fork test build can use a distinct + // applicationId (e.g. io.evcc.android.dev) and coexist with the official app. + const pkg = config.android?.package ?? PACKAGE; + const widgetPkg = `${pkg}.${WIDGET_SUBDIR}`; + + // 1. Kotlin sources → java//widget/, rewriting the package declaration + // from the source's io.evcc.android.widget to match the actual app package. + const dest = path.join(main, "java", ...pkg.split("."), WIDGET_SUBDIR); + fs.mkdirSync(dest, { recursive: true }); + const srcDir = path.join(config.modRequest.projectRoot, KOTLIN_SRC); + for (const f of fs.readdirSync(srcDir).filter((f) => f.endsWith(".kt"))) { + const src = fs + .readFileSync(path.join(srcDir, f), "utf8") + .replace(`package ${PACKAGE}.${WIDGET_SUBDIR}`, `package ${widgetPkg}`); + fs.writeFileSync(path.join(dest, f), src); + } + + // 2. res/xml widget info + a minimal preview layout + const xmlDir = path.join(main, "res", "xml"); + fs.mkdirSync(xmlDir, { recursive: true }); + fs.writeFileSync(path.join(xmlDir, "loadpoint_widget_info.xml"), widgetInfoXml(pkg)); + fs.writeFileSync(path.join(xmlDir, "forecast_widget_info.xml"), forecastInfoXml(pkg)); + + const layoutDir = path.join(main, "res", "layout"); + fs.mkdirSync(layoutDir, { recursive: true }); + fs.writeFileSync(path.join(layoutDir, "loadpoint_widget_preview.xml"), loadpointPreviewXml); + fs.writeFileSync(path.join(layoutDir, "forecast_widget_preview.xml"), forecastPreviewXml); + + const drawableDir = path.join(main, "res", "drawable"); + fs.mkdirSync(drawableDir, { recursive: true }); + fs.writeFileSync(path.join(drawableDir, "widget_preview.xml"), previewImageVector); + fs.writeFileSync(path.join(drawableDir, "widget_preview_loadpoint.xml"), loadpointPreviewImageVector); + + return config; + }, + ]); + +const withAndroidWidget: ConfigPlugin = (config) => { + config = withComposeClasspath(config); + config = withGlanceGradle(config); + config = withGlanceComposeCompiler(config); + config = withWidgetReceiver(config); + config = withWidgetFiles(config); + return config; +}; + +export default withAndroidWidget; diff --git a/targets/android-widget/README.md b/targets/android-widget/README.md new file mode 100644 index 0000000..2b7320e --- /dev/null +++ b/targets/android-widget/README.md @@ -0,0 +1,63 @@ +# Android widgets (Jetpack Glance) + +Android counterpart of the iOS WidgetKit widgets in `targets/widget/`. Home-screen +widgets are native on both platforms — no code is shared with the Swift widgets; +this is a Kotlin/Glance reimplementation of the same contracts. + +## Status + +This is a **pipeline spike**: one interactive **Loadpoint** widget, end-to-end. +It has **not been compiled** yet — it needs `expo prebuild` + a real Android +build to verify (see below). Treat it as a foundation to iterate on. + +Done: + +- `utils/widgetSync.ts` — writes the server list to a JSON file the widget reads + (Android has no App Group; the widget is in the same package, so a file in the + app's `filesDir` works). +- `kotlin/SharedStore.kt` — reads that file (mirrors `SharedStore.swift`). +- `kotlin/ApiClient.kt` — GET `/api/state?jq=…` + basic auth + POST actions, plus + the `Loadpoint` model (mirrors `ApiClient.swift` / `Loadpoint.swift`). +- `kotlin/LoadpointWidget.kt` — Glance widget + interactive mode buttons. +- `kotlin/Theme.kt` — brand colors / text styles. +- `scripts/androidWidget/withAndroidWidget.ts` — Expo config plugin: injects the + Kotlin, the `res/xml` widget info, the manifest ``, and the + Glance/Compose gradle wiring. Registered in `app.config.ts`. + +Not done yet (follow-ups for parity with iOS): + +- **Per-instance config** (pick server + loadpoint). iOS uses App Intents; Android + needs a widget **configuration Activity**. The spike uses the default server and + `loadpoints[0]`. +- **The other 5 widgets** (Solar / Price / CO₂ / Feed-in forecasts). +- **Immediate refresh on config change** — `widgetSync.ts` only writes the file; + pushing an instant update from RN needs a tiny native module calling + `LoadpointWidget().updateAll(context)`. Today the widget refreshes on its own + schedule (`updatePeriodMillis`, 30 min floor) / after a mode change. +- Localization (`.xcstrings` → `strings.xml`), size variants, full visual parity. + +## Build / test + +``` +npm install +npx expo prebuild --platform android --clean +npx expo run:android # or open android/ in Android Studio +``` + +Then long-press the home screen → Widgets → evcc → Loadpoint. + +## ⚠ Known integration risks (verify these first) + +1. **Compose compiler vs Kotlin version.** Glance needs the Compose compiler. + The plugin applies `org.jetbrains.kotlin.plugin.compose` (Kotlin 2.0+). After + prebuild, check the project's Kotlin version: + - Kotlin **2.0+**: the compose plugin must also be on the classpath. If the + build complains, add it to the root `build.gradle` `plugins`/`classpath`. + - Kotlin **< 2.0**: drop the plugin and instead set + `composeOptions { kotlinCompilerExtensionVersion "…" }` in `withGlanceGradle`. + - Align `COMPOSE_BOM` / `GLANCE_VERSION` in the plugin accordingly. +2. **Manifest receiver** — confirm `` landed with the + `APPWIDGET_UPDATE` filter and `@xml/loadpoint_widget_info` meta-data. +3. **File path** — `Paths.document` (RN) must resolve to `context.filesDir` + (Kotlin). Verify the written file appears at + `/data/user/0/io.evcc.android/files/evcc-widget-servers.json`. diff --git a/targets/android-widget/kotlin/ApiClient.kt b/targets/android-widget/kotlin/ApiClient.kt new file mode 100644 index 0000000..dcc3a13 --- /dev/null +++ b/targets/android-widget/kotlin/ApiClient.kt @@ -0,0 +1,119 @@ +package io.evcc.android.widget + +import android.util.Base64 +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +sealed interface FetchOutcome { + data class Success(val json: String) : FetchOutcome + object NoData : FetchOutcome // reachable, but the jq slice is null / empty + object Failure : FetchOutcome // network or auth error +} + +/** + * Minimal evcc API client: GET jq slices of /api/state and POST actions, with + * optional basic auth. Android counterpart of ApiClient.swift. Runs on a + * background thread (call from a coroutine / worker). + */ +object ApiClient { + private const val TIMEOUT_MS = 15_000 + + private fun base(server: StoredServer): String = server.url.trimEnd('/') + + private fun authorize(conn: HttpURLConnection, server: StoredServer) { + if (server.authRequired && !server.username.isNullOrEmpty() && server.password != null) { + val token = Base64.encodeToString( + "${server.username}:${server.password}".toByteArray(), + Base64.NO_WRAP, + ) + conn.setRequestProperty("Authorization", "Basic $token") + } + } + + /** GET /api/state?jq=. Returns the raw response body on success. */ + fun fetch(server: StoredServer, jq: String): FetchOutcome { + val url = "${base(server)}/api/state?jq=" + URLEncoder.encode(jq, "UTF-8") + return runCatching { + val conn = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + requestMethod = "GET" + authorize(this, server) + } + try { + if (conn.responseCode !in 200..299) return FetchOutcome.Failure + val body = conn.inputStream.bufferedReader().use { it.readText() }.trim() + if (body.isEmpty() || body == "null" || body == "[]" || body == "{}") { + FetchOutcome.NoData + } else { + FetchOutcome.Success(body) + } + } finally { + conn.disconnect() + } + }.getOrDefault(FetchOutcome.Failure) + } + + /** Loadpoint titles for the widget config picker, index-aligned to .loadpoints[]. */ + fun loadpointTitles(server: StoredServer): List { + val out = fetch(server, "[.loadpoints[].title]") + if (out !is FetchOutcome.Success) return emptyList() + return runCatching { + val arr = org.json.JSONArray(out.json) + (0 until arr.length()).map { i -> + arr.optString(i).takeIf { it.isNotEmpty() && it != "null" } ?: "Loadpoint ${i + 1}" + } + }.getOrDefault(emptyList()) + } + + /** POST to an API path, e.g. "/api/loadpoints/1/mode/pv". Returns success. */ + fun post(server: StoredServer, path: String): Boolean { + val p = if (path.startsWith("/")) path else "/$path" + return runCatching { + val conn = (URL(base(server) + p).openConnection() as HttpURLConnection).apply { + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + requestMethod = "POST" + authorize(this, server) + } + try { + conn.responseCode in 200..299 + } finally { + conn.disconnect() + } + }.getOrDefault(false) + } +} + +/** Subset of /api/state .loadpoints[] used by the widget (see Loadpoint.swift). */ +data class Loadpoint( + val title: String?, + val vehicleTitle: String?, + val vehicleSoc: Double?, + val effectiveLimitSoc: Double?, + val chargePower: Double?, + val mode: String?, + val charging: Boolean, + val connected: Boolean, + val enabled: Boolean, +) { + companion object { + fun parse(json: String): Loadpoint? = runCatching { + val o = JSONObject(json) + fun d(k: String) = if (o.has(k) && !o.isNull(k)) o.optDouble(k) else null + Loadpoint( + title = o.optString("title").takeIf { it.isNotEmpty() }, + vehicleTitle = o.optString("vehicleTitle").takeIf { it.isNotEmpty() }, + vehicleSoc = d("vehicleSoc"), + effectiveLimitSoc = d("effectiveLimitSoc"), + chargePower = d("chargePower"), + mode = o.optString("mode").takeIf { it.isNotEmpty() }, + charging = o.optBoolean("charging", false), + connected = o.optBoolean("connected", false), + enabled = o.optBoolean("enabled", false), + ) + }.getOrNull() + } +} diff --git a/targets/android-widget/kotlin/ChartRenderer.kt b/targets/android-widget/kotlin/ChartRenderer.kt new file mode 100644 index 0000000..0f119ef --- /dev/null +++ b/targets/android-widget/kotlin/ChartRenderer.kt @@ -0,0 +1,99 @@ +package io.evcc.android.widget + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +/** + * Renders a forecast series to a Bitmap (line + translucent area fill, with + * local-midnight day dividers and weekday labels), shown in the widget via a + * Glance Image. Glance has no chart primitive, so this Canvas bitmap is how we + * approximate the iOS Swift Charts look. + */ +object ChartRenderer { + private const val W = 720 + private const val H = 240 + + private val green = 0xFF0FDE41.toInt() + private val fill = 0x330FDE41.toInt() + private val divider = 0x33FFFFFF.toInt() + private val labelColor = 0x99FFFFFF.toInt() + + /** values and times must be index-aligned; times in epoch millis (may be empty). */ + fun render(values: List, times: List): Bitmap { + val bmp = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + if (values.size < 2) return bmp + + val minV = values.min() + val maxV = values.max() + val span = (maxV - minV).let { if (it <= 0.0) 1.0 else it } + + val padTop = 18f + val padBottom = 30f + val plotW = W.toFloat() + val plotH = H - padTop - padBottom + + fun x(i: Int) = plotW * (i.toFloat() / (values.size - 1)) + fun y(v: Double) = padTop + plotH * (1f - ((v - minV) / span).toFloat()) + + // day dividers + weekday labels (drawn first, behind the series) + if (times.size == values.size) { + val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = divider + strokeWidth = 1.5f + } + val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = labelColor + textSize = 22f + } + val weekday = SimpleDateFormat("EEE", Locale.getDefault()) + val cal = Calendar.getInstance() + var lastDay = -1 + for (i in values.indices) { + cal.timeInMillis = times[i] + val day = cal.get(Calendar.DAY_OF_YEAR) + if (day != lastDay) { + if (lastDay != -1) { + val xx = x(i) + canvas.drawLine(xx, padTop, xx, padTop + plotH, dividerPaint) + canvas.drawText(weekday.format(Date(times[i])), xx + 6f, H - 8f, textPaint) + } + lastDay = day + } + } + } + + // area fill under the line + val area = Path().apply { + moveTo(x(0), padTop + plotH) + for (i in values.indices) lineTo(x(i), y(values[i])) + lineTo(x(values.size - 1), padTop + plotH) + close() + } + canvas.drawPath(area, Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = fill + }) + + // series line + val line = Path().apply { + moveTo(x(0), y(values[0])) + for (i in 1 until values.size) lineTo(x(i), y(values[i])) + } + canvas.drawPath(line, Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 4f + color = green + strokeJoin = Paint.Join.ROUND + strokeCap = Paint.Cap.ROUND + }) + + return bmp + } +} diff --git a/targets/android-widget/kotlin/ForecastWidget.kt b/targets/android-widget/kotlin/ForecastWidget.kt new file mode 100644 index 0000000..a9c99f6 --- /dev/null +++ b/targets/android-widget/kotlin/ForecastWidget.kt @@ -0,0 +1,245 @@ +package io.evcc.android.widget + +import android.content.Context +import android.graphics.Bitmap +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.text.Text +import androidx.glance.unit.ColorProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject + +/** + * Forecast home-screen widgets (Android counterpart of ForecastWidget.swift): + * Solar, Price, CO₂ and Feed-in. Glance has no chart primitive, so the 48h + * series is rendered to a Bitmap (see [ChartRenderer]) and shown via Image, + * alongside a current value + summary. + * + * Per-instance config (server, and for Solar the "adjust to real production" + * toggle) is set by ForecastWidgetConfigActivity, keyed by appWidgetId. + */ +// non-private: the abstract ForecastWidget's constructor (public, since the +// concrete widget subclasses are) takes it as a parameter. +enum class ForecastKind(val title: String) { + SOLAR("Solar"), PRICE("Price"), CO2("CO₂"), FEEDIN("Feed-in") +} + +private sealed interface ForecastState { + data class Data( + val header: String, + val summary: String, + val chart: Bitmap, + ) : ForecastState + object NoData : ForecastState + object Unreachable : ForecastState + object NotConfigured : ForecastState +} + +private const val WINDOW_MS = 48L * 3600 * 1000 +private const val MAX_POINTS = 200 + +/** Cap the number of chart points by striding, keeping value/time index-aligned. */ +private fun downsampleIndices(size: Int): List { + if (size <= MAX_POINTS) return (0 until size).toList() + val step = size.toDouble() / MAX_POINTS + return (0 until MAX_POINTS).map { (it * step).toInt() } +} + +abstract class ForecastWidget(private val kind: ForecastKind) : GlanceAppWidget() { + override suspend fun provideGlance(context: Context, id: GlanceId) { + val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) + val serverId = WidgetConfig.forecastServerId(context, appWidgetId) + val adjust = WidgetConfig.forecastAdjust(context, appWidgetId) + val state = load(context, serverId, adjust) + provideContent { Content(state) } + } + + private suspend fun load(context: Context, serverId: String?, adjust: Boolean): ForecastState = + withContext(Dispatchers.IO) { + val server = SharedStore.server(context, serverId) ?: return@withContext ForecastState.NotConfigured + when (kind) { + ForecastKind.SOLAR -> solar(server, adjust) + ForecastKind.PRICE -> series(server, "{currency:.currency,slots:.forecast.grid}") + ForecastKind.FEEDIN -> series(server, "{currency:.currency,slots:.forecast.feedin}") + ForecastKind.CO2 -> co2(server) + } + } + + private fun chart(values: List, times: List): Bitmap { + val idx = downsampleIndices(values.size) + return ChartRenderer.render(idx.map { values[it] }, idx.map { times[it] }) + } + + private fun solar(server: StoredServer, adjust: Boolean): ForecastState { + val out = ApiClient.fetch(server, ".forecast.solar") + if (out is FetchOutcome.NoData) return ForecastState.NoData + if (out !is FetchOutcome.Success) return ForecastState.Unreachable + return runCatching { + val o = JSONObject(out.json) + val rawScale = if (o.has("scale") && !o.isNull("scale")) o.optDouble("scale") else 1.0 + val scale = if (adjust) rawScale else 1.0 + val ts = o.optJSONArray("timeseries") ?: return@runCatching ForecastState.NoData + val now = System.currentTimeMillis() + val end = now + WINDOW_MS + val values = ArrayList() + val times = ArrayList() + var currentW: Double? = null + for (i in 0 until ts.length()) { + // entries are [ts, val] tuples, unix seconds (see timeseries.MarshalJSON in evcc) + val p = ts.optJSONArray(i) ?: continue + if (p.length() < 2) continue + val t = (p.optDouble(0) * 1000).toLong() + val v = p.optDouble(1) * scale + if (t in now..end) { + values.add(v) + times.add(t) + } + if (t <= now) currentW = v // last slot at/behind now wins + } + if (values.isEmpty()) return@runCatching ForecastState.NoData + val today = o.optJSONObject("today")?.optDouble("energy") ?: 0.0 + val tomorrow = o.optJSONObject("tomorrow")?.optDouble("energy") ?: 0.0 + ForecastState.Data( + header = Format.fmtW(currentW ?: values.first()), + summary = "today ${Format.fmtWh(today * scale)} · tom. ${Format.fmtWh(tomorrow * scale)}", + chart = chart(values, times), + ) + }.getOrDefault(ForecastState.NoData) + } + + private fun series(server: StoredServer, jq: String): ForecastState { + val out = ApiClient.fetch(server, jq) + if (out is FetchOutcome.NoData) return ForecastState.NoData + if (out !is FetchOutcome.Success) return ForecastState.Unreachable + return runCatching { + val o = JSONObject(out.json) + val currency = o.optString("currency").takeIf { it.isNotEmpty() && it != "null" } ?: "EUR" + val slots = o.optJSONArray("slots") ?: return@runCatching ForecastState.NoData + val stat = windowStats(slots) ?: return@runCatching ForecastState.NoData + ForecastState.Data( + header = Format.fmtPricePerKWh(stat.current, currency), + summary = "avg ${Format.fmtPricePerKWh(stat.avg, currency, withUnit = false)}" + + " · ${Format.fmtPricePerKWh(stat.min, currency, withUnit = false)}–" + + Format.fmtPricePerKWh(stat.max, currency), + chart = chart(stat.values, stat.times), + ) + }.getOrDefault(ForecastState.NoData) + } + + private fun co2(server: StoredServer): ForecastState { + val out = ApiClient.fetch(server, ".forecast.co2") + if (out is FetchOutcome.NoData) return ForecastState.NoData + if (out !is FetchOutcome.Success) return ForecastState.Unreachable + return runCatching { + val slots = org.json.JSONArray(out.json) + val stat = windowStats(slots) ?: return@runCatching ForecastState.NoData + ForecastState.Data( + header = Format.fmtCo2(stat.current), + summary = "avg ${Format.fmtNumber(stat.avg, 0)} · " + + "${Format.fmtNumber(stat.min, 0)}–${Format.fmtNumber(stat.max, 0)} g", + chart = chart(stat.values, stat.times), + ) + }.getOrDefault(ForecastState.NoData) + } + + private data class Stats( + val values: List, val times: List, + val current: Double, val min: Double, val max: Double, val avg: Double, + ) + + /** Trim slots ([start, end, value] tuples, unix seconds) to the 48h window and reduce to stats + series. */ + private fun windowStats(slots: org.json.JSONArray): Stats? { + val now = System.currentTimeMillis() + val end = now + WINDOW_MS + val values = ArrayList() + val times = ArrayList() + var current: Double? = null + for (i in 0 until slots.length()) { + val s = slots.optJSONArray(i) ?: continue + if (s.length() < 3) continue + val start = (s.optDouble(0) * 1000).toLong() + val slotEnd = (s.optDouble(1) * 1000).toLong() + val v = s.optDouble(2) + if (start in now..end) { + values.add(v) + times.add(start) + } + if (current == null && now < slotEnd) current = v // first slot ending after now + } + if (values.isEmpty()) return null + return Stats( + values = values, + times = times, + current = current ?: values.first(), + min = values.min(), + max = values.max(), + avg = values.average(), + ) + } + + @Composable + private fun Content(state: ForecastState) { + Column( + modifier = GlanceModifier.fillMaxSize().background(ColorProvider(Color(0xFF1B1B1B))).padding(12.dp), + verticalAlignment = Alignment.Vertical.Top, + ) { + Text(kind.title, style = subtle) + when (state) { + is ForecastState.Data -> { + Text(state.header, style = titleStyle) + Text(state.summary, style = subtle) + Spacer(GlanceModifier.height(6.dp)) + Image( + provider = ImageProvider(state.chart), + contentDescription = null, + modifier = GlanceModifier.fillMaxWidth().height(64.dp), + contentScale = ContentScale.FillBounds, + ) + } + ForecastState.NoData -> Text("No data", style = subtle) + ForecastState.Unreachable -> Text("Unreachable", style = subtle) + ForecastState.NotConfigured -> Text("Open the app to set up", style = subtle) + } + } + } +} + +class SolarWidget : ForecastWidget(ForecastKind.SOLAR) +class PriceWidget : ForecastWidget(ForecastKind.PRICE) +class Co2Widget : ForecastWidget(ForecastKind.CO2) +class FeedinWidget : ForecastWidget(ForecastKind.FEEDIN) + +class EvccSolarWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = SolarWidget() +} + +class EvccPriceWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = PriceWidget() +} + +class EvccCo2WidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = Co2Widget() +} + +class EvccFeedinWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = FeedinWidget() +} diff --git a/targets/android-widget/kotlin/ForecastWidgetConfigActivity.kt b/targets/android-widget/kotlin/ForecastWidgetConfigActivity.kt new file mode 100644 index 0000000..276b97a --- /dev/null +++ b/targets/android-widget/kotlin/ForecastWidgetConfigActivity.kt @@ -0,0 +1,142 @@ +package io.evcc.android.widget + +import android.app.Activity +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.graphics.Color +import android.graphics.Typeface +import android.os.Bundle +import android.util.TypedValue +import android.view.View +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView + +/** + * Config for the forecast widgets: pick a server, and (Solar only) whether to + * adjust the forecast to real production. Stored per appWidgetId in WidgetConfig. + * Shared by all four forecast types; the Solar toggle is shown only when the + * widget being configured is the Solar provider. + */ +class ForecastWidgetConfigActivity : Activity() { + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + private var isSolar = false + + private lateinit var titleView: TextView + private lateinit var container: LinearLayout // holds the tappable rows + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setResult(RESULT_CANCELED) + + appWidgetId = intent?.extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + + val provider = AppWidgetManager.getInstance(this).getAppWidgetInfo(appWidgetId)?.provider + isSolar = provider?.className?.endsWith("EvccSolarWidgetReceiver") == true + + setContentView(buildLayout()) + showServers() + } + + // --- UI helpers (mirrors LoadpointWidgetConfigActivity) --- + + private fun dp(v: Int): Int = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, v.toFloat(), resources.displayMetrics, + ).toInt() + + private fun buildLayout(): View { + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, dp(24), 0, 0) + } + titleView = TextView(this).apply { + setPadding(dp(20), dp(8), dp(20), dp(16)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f) + setTypeface(typeface, Typeface.BOLD) + } + container = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + val scroll = ScrollView(this).apply { + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f) + addView(container) + } + root.addView(titleView) + root.addView(scroll) + return root + } + + private fun setRows(items: List, onClick: ((Int) -> Unit)?) { + container.removeAllViews() + items.forEachIndexed { index, label -> + val row = TextView(this).apply { + text = label + setPadding(dp(20), dp(18), dp(20), dp(18)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) + setTextColor(if (onClick != null) textColor() else Color.GRAY) + if (onClick != null) { + isClickable = true + setBackgroundResource(selectableItemBackground()) + setOnClickListener { onClick(index) } + } + } + container.addView(row) + } + } + + private fun selectableItemBackground(): Int { + val tv = TypedValue() + theme.resolveAttribute(android.R.attr.selectableItemBackground, tv, true) + return tv.resourceId + } + + private fun textColor(): Int { + val tv = TypedValue() + return if (theme.resolveAttribute(android.R.attr.textColorPrimary, tv, true)) { + resources.getColor(tv.resourceId, theme) + } else { + Color.DKGRAY + } + } + + // --- flow --- + + private fun showServers() { + titleView.text = "Choose server" + val servers = SharedStore.servers(this) + when { + servers.isEmpty() -> setRows(listOf("No servers — add one in the app first"), null) + servers.size == 1 -> onServer(servers[0].id) + else -> setRows(servers.map { it.displayTitle }) { index -> onServer(servers[index].id) } + } + } + + private fun onServer(serverId: String) { + if (isSolar) showAdjust(serverId) else save(serverId, adjust = true) + } + + private fun showAdjust(serverId: String) { + titleView.text = "Adjust to real production?" + setRows(listOf("Yes (recommended)", "No")) { index -> save(serverId, adjust = index == 0) } + } + + private fun save(serverId: String, adjust: Boolean) { + WidgetConfig.saveForecast(this, appWidgetId, serverId, adjust) + // ask the just-configured widget to render now + AppWidgetManager.getInstance(this).getAppWidgetInfo(appWidgetId)?.provider?.let { provider -> + sendBroadcast( + Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE).apply { + component = provider + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(appWidgetId)) + }, + ) + } + setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)) + finish() + } +} diff --git a/targets/android-widget/kotlin/Format.kt b/targets/android-widget/kotlin/Format.kt new file mode 100644 index 0000000..bc6bbed --- /dev/null +++ b/targets/android-widget/kotlin/Format.kt @@ -0,0 +1,78 @@ +package io.evcc.android.widget + +import java.util.Locale + +/** + * Kotlin port of a SUBSET of evcc's web formatter (mirrors iOS Format.swift): + * https://github.com/evcc-io/evcc/blob/master/assets/js/mixins/formatter.ts + * Numbers use the device locale. + */ +object Format { + private val CURRENCY_SYMBOLS = mapOf( + "AUD" to "$", "BGN" to "лв", "BRL" to "R$", "CAD" to "$", "CHF" to "Fr.", "CNY" to "¥", + "CZK" to "Kč", "EUR" to "€", "GBP" to "£", "HUF" to "Ft", "ILS" to "₪", "JPY" to "¥", + "NZD" to "$", "NOK" to "kr", "PLN" to "zł", "RON" to "lei", "USD" to "$", "DKK" to "kr", + "SEK" to "kr", "ZAR" to "R", "TRY" to "₺", "MYR" to "RM", + ) + + // currencies where the energy price is shown in subunits (factor 100) + private val ENERGY_PRICE_IN_SUBUNIT = mapOf( + "AUD" to "c", "BGN" to "st", "BRL" to "¢", "CAD" to "¢", "EUR" to "ct", "GBP" to "p", + "ILS" to "ag", "NZD" to "c", "NOK" to "øre", "PLN" to "gr", "USD" to "¢", "DKK" to "øre", + "SEK" to "öre", "ZAR" to "c", "TRY" to "krş", + ) + + private fun number(value: Double, decimals: Int, max: Int = decimals): String { + // format with up to `max` fraction digits, at least `decimals`, in the device locale + val s = String.format(Locale.getDefault(), "%.${max}f", value) + if (max <= decimals) return s + // trim trailing zeros down to `decimals` + val sep = java.text.DecimalFormatSymbols.getInstance().decimalSeparator + val dot = s.indexOf(sep) + if (dot < 0) return s + var end = s.length + while (end > dot + 1 + decimals && s[end - 1] == '0') end-- + if (end == dot + 1) end = dot // no fraction left + return s.substring(0, end) + } + + private fun energyPriceSubunit(currency: String): String? { + if (currency == "CHF") { + return if (Locale.getDefault().language == "de") "Rp." else "ct." + } + return ENERGY_PRICE_IN_SUBUNIT[currency] + } + + fun fmtNumber(value: Double, decimals: Int): String = number(value, decimals) + + /** Power in W, auto-scaled to W/kW/MW. */ + fun fmtW(watt: Double, withUnit: Boolean = true): String { + val unit: String + val value: Double + val digits: Int + when { + watt >= 10_000_000 -> { unit = "MW"; value = watt / 1_000_000; digits = 1 } + watt >= 1000 || watt == 0.0 -> { unit = "kW"; value = watt / 1000; digits = 1 } + else -> { unit = "W"; value = watt; digits = 0 } + } + return number(value, digits) + if (withUnit) " $unit" else "" + } + + fun fmtWh(watt: Double): String = fmtW(watt) + "h" + + fun fmtCo2(grams: Double): String = "${number(grams, 0)} g/kWh" + + fun pricePerKWhDisplayFactor(currency: String): Double = + if (energyPriceSubunit(currency) != null) 100.0 else 1.0 + + fun pricePerKWhUnit(currency: String): String { + val unit = energyPriceSubunit(currency) ?: CURRENCY_SYMBOLS[currency] ?: currency + return "$unit/kWh" + } + + fun fmtPricePerKWh(amount: Double, currency: String = "EUR", withUnit: Boolean = true): String { + val value = amount * pricePerKWhDisplayFactor(currency) + val price = number(value, 1, max = if (energyPriceSubunit(currency) != null) 1 else 3) + return if (withUnit) "$price ${pricePerKWhUnit(currency)}" else price + } +} diff --git a/targets/android-widget/kotlin/LoadpointWidget.kt b/targets/android-widget/kotlin/LoadpointWidget.kt new file mode 100644 index 0000000..657288a --- /dev/null +++ b/targets/android-widget/kotlin/LoadpointWidget.kt @@ -0,0 +1,151 @@ +package io.evcc.android.widget + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.action.ActionParameters +import androidx.glance.action.actionParametersOf +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.provideContent +import androidx.glance.appwidget.updateAll +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.text.Text +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Loadpoint home-screen widget (Android counterpart of LoadpointWidget.swift). + * + * Spike scope: uses the default server and the first loadpoint. Per-instance + * configuration (server + loadpoint picker) is a follow-up via a widget + * configuration Activity — see targets/android-widget/README.md. + */ +private sealed interface LoadpointState { + data class Data(val lp: Loadpoint, val serverId: String, val lpIndex: Int) : LoadpointState + object NoData : LoadpointState + object Unreachable : LoadpointState + object NotConfigured : LoadpointState +} + +class LoadpointWidget : GlanceAppWidget() { + override suspend fun provideGlance(context: Context, id: GlanceId) { + // per-instance config (server + loadpoint) written by LoadpointWidgetConfigActivity, + // keyed by the appWidgetId this glanceId maps to. + val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) + val resolved = WidgetConfig.resolve(context, appWidgetId) + val state = if (resolved == null) { + LoadpointState.NotConfigured + } else { + val (serverId, lpIndex) = resolved + load(context, serverId, lpIndex) + } + provideContent { Content(state) } + } + + private suspend fun load(context: Context, serverId: String?, lpIndex: Int): LoadpointState = withContext(Dispatchers.IO) { + val server = SharedStore.server(context, serverId) ?: return@withContext LoadpointState.NotConfigured + when (val out = ApiClient.fetch(server, ".loadpoints[$lpIndex]")) { + is FetchOutcome.Success -> + Loadpoint.parse(out.json)?.let { LoadpointState.Data(it, server.id, lpIndex) } + ?: LoadpointState.NoData + FetchOutcome.NoData -> LoadpointState.NoData + FetchOutcome.Failure -> LoadpointState.Unreachable + } + } + + @Composable + private fun Content(state: LoadpointState) { + Column( + modifier = GlanceModifier.fillMaxSize().padding(12.dp), + verticalAlignment = Alignment.Vertical.Top, + ) { + when (state) { + is LoadpointState.Data -> LoadpointBody(state) + LoadpointState.NoData -> Text("No data", style = subtle) + LoadpointState.Unreachable -> Text("Unreachable", style = subtle) + LoadpointState.NotConfigured -> Text("Open the app to set up", style = subtle) + } + } + } + + @Composable + private fun LoadpointBody(state: LoadpointState.Data) { + val lp = state.lp + Text(lp.title ?: lp.vehicleTitle ?: "Loadpoint", style = titleStyle) + val soc = lp.vehicleSoc?.let { "${it.toInt()}%" } + val power = lp.chargePower?.let { formatPower(it) } + Text(listOfNotNull(statusLabel(lp), soc).joinToString(" · "), style = subtle) + if (power != null) Text(power, style = titleStyle) + + // interactive mode buttons (charging control), like the iOS widget + Row(modifier = GlanceModifier.padding(top = 8.dp)) { + for (mode in listOf("off", "pv", "minpv", "now")) { + ModeButton(mode = mode, current = lp.mode, serverId = state.serverId, lpIndex = state.lpIndex) + } + } + } + + @Composable + private fun ModeButton(mode: String, current: String?, serverId: String, lpIndex: Int) { + val selected = mode == current + Text( + text = mode, + style = if (selected) titleStyle else subtle, + modifier = GlanceModifier + .padding(horizontal = 6.dp, vertical = 4.dp) + .clickable( + actionRunCallback( + actionParametersOf( + ModeAction.serverKey to serverId, + ModeAction.lpKey to (lpIndex + 1), // API is 1-based + ModeAction.modeKey to mode, + ), + ), + ), + ) + } + + private fun statusLabel(lp: Loadpoint): String = when { + lp.charging -> "Charging" + lp.connected -> "Connected" + else -> "Disconnected" + } + + private fun formatPower(w: Double): String = + if (w >= 1000) String.format("%.1f kW", w / 1000) else "${w.toInt()} W" +} + +/** Applies a charge mode from a widget button, then refreshes the widget. */ +class ModeAction : ActionCallback { + override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { + val serverId = parameters[serverKey] + val lp = parameters[lpKey] ?: return + val mode = parameters[modeKey] ?: return + val server = SharedStore.server(context, serverId) ?: return + withContext(Dispatchers.IO) { + ApiClient.post(server, "/api/loadpoints/$lp/mode/$mode") + } + LoadpointWidget().updateAll(context) + } + + companion object { + val serverKey = ActionParameters.Key("serverId") + val lpKey = ActionParameters.Key("lp") + val modeKey = ActionParameters.Key("mode") + } +} + +class EvccLoadpointWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = LoadpointWidget() +} diff --git a/targets/android-widget/kotlin/LoadpointWidgetConfigActivity.kt b/targets/android-widget/kotlin/LoadpointWidgetConfigActivity.kt new file mode 100644 index 0000000..6474bdd --- /dev/null +++ b/targets/android-widget/kotlin/LoadpointWidgetConfigActivity.kt @@ -0,0 +1,156 @@ +package io.evcc.android.widget + +import android.app.Activity +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.graphics.Color +import android.graphics.Typeface +import android.os.Bundle +import android.util.TypedValue +import android.view.View +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.glance.appwidget.GlanceAppWidgetManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Widget placement configuration: pick a server, then a loadpoint. Stores the + * choice in the widget's per-instance config (read back by LoadpointWidget). + * + * Uses classic Views (not Compose) so it needs no dependencies beyond Glance - + * the RN app is not otherwise a Compose app. Rows are built explicitly (not via + * a ListView) so each row's click captures its own index directly - a ListView + * adapter's onItemClick position is unreliable around the async loadpoint load. + */ +class LoadpointWidgetConfigActivity : Activity() { + private val scope = MainScope() + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + + private lateinit var titleView: TextView + private lateinit var container: LinearLayout // holds the tappable rows + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // backing out without a choice must cancel the placement + setResult(RESULT_CANCELED) + + appWidgetId = intent?.extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + + setContentView(buildLayout()) + showServers() + } + + private fun dp(v: Int): Int = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, v.toFloat(), resources.displayMetrics, + ).toInt() + + private fun buildLayout(): View { + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, dp(24), 0, 0) + } + titleView = TextView(this).apply { + setPadding(dp(20), dp(8), dp(20), dp(16)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f) + setTypeface(typeface, Typeface.BOLD) + } + container = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + val scroll = ScrollView(this).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f, + ) + addView(container) + } + root.addView(titleView) + root.addView(scroll) + return root + } + + /** + * Rebuild the row list. Each row owns a click listener that captures its + * index via the loop, so a tap always maps to the item it visually is - + * unlike a shared ListView adapter whose reported position can be stale. + */ + private fun setRows(items: List, onClick: ((Int) -> Unit)?) { + container.removeAllViews() + items.forEachIndexed { index, label -> + val row = TextView(this).apply { + text = label + setPadding(dp(20), dp(18), dp(20), dp(18)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) + setTextColor(if (onClick != null) textColor() else Color.GRAY) + if (onClick != null) { + isClickable = true + setBackgroundResource(selectableItemBackground()) + setOnClickListener { onClick(index) } + } + } + container.addView(row) + } + } + + private fun selectableItemBackground(): Int { + val tv = TypedValue() + theme.resolveAttribute(android.R.attr.selectableItemBackground, tv, true) + return tv.resourceId + } + + private fun textColor(): Int { + val tv = TypedValue() + return if (theme.resolveAttribute(android.R.attr.textColorPrimary, tv, true)) { + resources.getColor(tv.resourceId, theme) + } else { + Color.DKGRAY + } + } + + private fun showServers() { + titleView.text = "Choose server" + val servers = SharedStore.servers(this) + when { + servers.isEmpty() -> + setRows(listOf("No servers — add one in the app first"), null) + // only one server: nothing to choose, go straight to its loadpoints + servers.size == 1 -> showLoadpoints(servers[0]) + else -> setRows(servers.map { it.displayTitle }) { index -> showLoadpoints(servers[index]) } + } + } + + private fun showLoadpoints(server: StoredServer) { + titleView.text = "Choose loadpoint" + setRows(listOf("Loading…"), null) + scope.launch { + val titles = withContext(Dispatchers.IO) { ApiClient.loadpointTitles(server) } + if (titles.isEmpty()) { + setRows(listOf("No loadpoints reachable"), null) + return@launch + } + setRows(titles) { index -> save(server.id, index) } + } + } + + private fun save(serverId: String, lpIndex: Int) { + // write synchronously (plain SharedPreferences) before the widget renders + WidgetConfig.save(this, appWidgetId, serverId, lpIndex) + scope.launch { + val glanceId = GlanceAppWidgetManager(applicationContext).getGlanceIdBy(appWidgetId) + LoadpointWidget().update(applicationContext, glanceId) + setResult( + RESULT_OK, + Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId), + ) + finish() + } + } +} diff --git a/targets/android-widget/kotlin/SharedStore.kt b/targets/android-widget/kotlin/SharedStore.kt new file mode 100644 index 0000000..7315c12 --- /dev/null +++ b/targets/android-widget/kotlin/SharedStore.kt @@ -0,0 +1,64 @@ +package io.evcc.android.widget + +import android.content.Context +import org.json.JSONObject +import java.io.File + +/** One server, mirrored from the React Native app (utils/widgetSync.ts). */ +data class StoredServer( + val id: String, + val title: String?, + val url: String, + val username: String?, + val password: String?, + val authRequired: Boolean, +) { + val displayTitle: String + get() = title?.takeIf { it.isNotBlank() } ?: url +} + +/** + * Reads the server list the RN app writes to the app's document directory. + * Android counterpart of the iOS SharedStore.swift (App Group). Same package + * as the app, so no App Group / native module is needed — a plain file works. + */ +object SharedStore { + // keep in sync with widgetSync.ts ANDROID_SERVERS_FILE + private const val FILE_NAME = "evcc-widget-servers.json" + + private fun readJson(context: Context): JSONObject? { + val f = File(context.filesDir, FILE_NAME) + if (!f.exists()) return null + return runCatching { JSONObject(f.readText()) }.getOrNull() + } + + fun servers(context: Context): List { + val arr = readJson(context)?.optJSONArray("servers") ?: return emptyList() + return (0 until arr.length()).mapNotNull { i -> + val o = arr.optJSONObject(i) ?: return@mapNotNull null + StoredServer( + id = o.optString("id"), + title = o.optString("title").takeIf { it.isNotEmpty() }, + url = o.optString("url"), + username = o.optString("username").takeIf { it.isNotEmpty() }, + password = o.optString("password").takeIf { it.isNotEmpty() }, + authRequired = o.optBoolean("authRequired", false), + ) + } + } + + private fun activeServerId(context: Context): String? = + readJson(context)?.optString("activeServerId")?.takeIf { it.isNotEmpty() } + + fun defaultServer(context: Context): StoredServer? { + val all = servers(context) + val id = activeServerId(context) + return all.firstOrNull { it.id == id } ?: all.firstOrNull() + } + + /** Resolve the server selected in the widget config, else the active one. */ + fun server(context: Context, id: String?): StoredServer? { + if (id.isNullOrEmpty()) return defaultServer(context) + return servers(context).firstOrNull { it.id == id } ?: defaultServer(context) + } +} diff --git a/targets/android-widget/kotlin/Theme.kt b/targets/android-widget/kotlin/Theme.kt new file mode 100644 index 0000000..aaaf5ff --- /dev/null +++ b/targets/android-widget/kotlin/Theme.kt @@ -0,0 +1,22 @@ +package io.evcc.android.widget + +import androidx.compose.ui.graphics.Color +import androidx.glance.text.FontWeight +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider + +// evcc brand green, mirrors the iOS widget Colors.swift / themes.json +private val evccGreen = Color(0xFF0FDE41) +private val onSurface = Color(0xFFFFFFFF) +private val onSurfaceMuted = Color(0xB3FFFFFF) // 70% white + +val titleStyle = TextStyle( + color = ColorProvider(onSurface), + fontWeight = FontWeight.Medium, +) + +val subtle = TextStyle( + color = ColorProvider(onSurfaceMuted), +) + +val accent = ColorProvider(evccGreen) diff --git a/targets/android-widget/kotlin/WidgetConfig.kt b/targets/android-widget/kotlin/WidgetConfig.kt new file mode 100644 index 0000000..24b8f37 --- /dev/null +++ b/targets/android-widget/kotlin/WidgetConfig.kt @@ -0,0 +1,93 @@ +package io.evcc.android.widget + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject + +/** + * Per-widget-instance config (server + loadpoint), keyed by appWidgetId in plain + * SharedPreferences, written synchronously so it survives aggressive process + * kills (MIUI et al.). + * + * Some launchers (notably MIUI) hand the configuration Activity a different + * appWidgetId than the one the widget is finally bound with, so per-id keying + * alone can't correlate the two. To cover that, each selection is also pushed + * onto a FIFO "pending" queue; a freshly-bound widget with no config of its own + * dequeues the oldest entry on first render (see [resolve]). A queue (rather + * than a single overwritable slot) matters because configuring a second widget + * before the first one has rendered must not steal the first widget's pending + * selection. + */ +object WidgetConfig { + private const val PREFS = "evcc_widget_config" + private const val PENDING_QUEUE = "pending_queue" + + private fun prefs(context: Context) = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + fun save(context: Context, appWidgetId: Int, serverId: String?, lpIndex: Int) { + val p = prefs(context) + val queue = JSONArray(p.getString(PENDING_QUEUE, "[]")) + queue.put(JSONObject().put("server", serverId ?: JSONObject.NULL).put("lp", lpIndex)) + p.edit() + .putString("server_$appWidgetId", serverId) + .putInt("lp_$appWidgetId", lpIndex) + .putString(PENDING_QUEUE, queue.toString()) + .commit() // synchronous — must be on disk before the Activity finishes + } + + /** + * Resolve (serverId, lpIndex) for a widget instance. Uses this instance's own + * config if present; otherwise dequeues the oldest pending selection (and + * binds it to this appWidgetId), covering the configure/bind id mismatch. + * Returns null when the widget hasn't been configured at all yet (e.g. the + * OS renders it once before the configure Activity's async save lands) - + * callers must treat that as "not configured", not as "use defaults", since + * a null serverId elsewhere means "use the default server". + */ + fun resolve(context: Context, appWidgetId: Int): Pair? { + val p = prefs(context) + if (p.contains("lp_$appWidgetId")) { + return p.getString("server_$appWidgetId", null) to p.getInt("lp_$appWidgetId", 0) + } + val queue = JSONArray(p.getString(PENDING_QUEUE, "[]")) + if (queue.length() == 0) return null + val entry = queue.getJSONObject(0) + val serverId = entry.optString("server").takeIf { entry.has("server") && !entry.isNull("server") } + val lpIndex = entry.optInt("lp", 0) + val rest = JSONArray() + for (i in 1 until queue.length()) rest.put(queue.get(i)) + p.edit() + .putString("server_$appWidgetId", serverId) + .putInt("lp_$appWidgetId", lpIndex) + .putString(PENDING_QUEUE, rest.toString()) + .commit() + return serverId to lpIndex + } + + // --- forecast widgets (server + solar "adjust to real production" toggle) --- + // These share the server_ key with the loadpoint config but have no + // loadpoint index, so they don't use the pending/resolve mechanism. + + fun saveForecast(context: Context, appWidgetId: Int, serverId: String?, adjust: Boolean) { + prefs(context).edit() + .putString("server_$appWidgetId", serverId) + .putBoolean("adjust_$appWidgetId", adjust) + .commit() + } + + fun forecastServerId(context: Context, appWidgetId: Int): String? = + prefs(context).getString("server_$appWidgetId", null) + + /** Solar "adjust to real production" toggle; defaults to on (apply scale). */ + fun forecastAdjust(context: Context, appWidgetId: Int): Boolean = + prefs(context).getBoolean("adjust_$appWidgetId", true) + + fun clear(context: Context, appWidgetId: Int) { + prefs(context).edit() + .remove("server_$appWidgetId") + .remove("lp_$appWidgetId") + .remove("adjust_$appWidgetId") + .apply() + } +} diff --git a/utils/widgetRefresh.ts b/utils/widgetRefresh.ts new file mode 100644 index 0000000..93ca802 --- /dev/null +++ b/utils/widgetRefresh.ts @@ -0,0 +1,31 @@ +import { Platform } from "react-native"; + +interface EvccWidgetNativeModule { + refresh(): void; +} + +// The native module (modules/evcc-widget) is autolinked and registered under +// the name "EvccWidget"; resolve it by name rather than importing the local +// module's source, which Metro's local-module autolinking does not allow. +let native: EvccWidgetNativeModule | null = null; +if (Platform.OS === "android") { + try { + const { requireNativeModule } = require("expo"); + native = requireNativeModule("EvccWidget"); + } catch { + native = null; + } +} + +/** + * Force an immediate redraw of the Android home-screen widgets, so a changed + * server list is reflected without waiting for their periodic refresh. No-op on + * other platforms (iOS uses WidgetKit's reloadWidget from widgetSync). + */ +export function refreshWidgets(): void { + try { + native?.refresh(); + } catch { + // best-effort; widget refresh is non-critical + } +} diff --git a/utils/widgetSync.ts b/utils/widgetSync.ts index b7aebaf..cb45a0f 100644 --- a/utils/widgetSync.ts +++ b/utils/widgetSync.ts @@ -1,8 +1,15 @@ import { Platform } from "react-native"; +import { File, Paths } from "expo-file-system"; import { Server } from "types"; +import { refreshWidgets } from "./widgetRefresh"; const APP_GROUP = "group.io.evcc.app"; +// Android: the Glance widget is part of the same app package, so it can read a +// plain JSON file from the app's document directory directly (no App Group / +// native module needed). Keep this filename in sync with SharedStore.kt. +const ANDROID_SERVERS_FILE = "evcc-widget-servers.json"; + enum WidgetStorageKeys { SERVERS = "servers", ACTIVE_SERVER_ID = "activeServerId", @@ -50,6 +57,19 @@ function getExtensionStorage() { * then ask WidgetKit to reload. Best-effort: failures are swallowed. */ export function syncWidgetServers(servers: Server[], activeServer?: Server): void { + const activeIndex = activeServer + ? servers.findIndex((s) => s.url === activeServer.url) + : -1; + const activeServerId = activeIndex >= 0 ? widgetServerId(activeIndex) : undefined; + + if (Platform.OS === "android") { + syncAndroidWidgetServers(servers, activeServerId); + return; + } + syncIosWidgetServers(servers, activeServerId); +} + +function syncIosWidgetServers(servers: Server[], activeServerId?: string): void { const mod = getExtensionStorage(); if (!mod) return; try { @@ -58,11 +78,8 @@ export function syncWidgetServers(servers: Server[], activeServer?: Server): voi WidgetStorageKeys.SERVERS, JSON.stringify(servers.map(toWidgetServer)), ); - const activeIndex = activeServer - ? servers.findIndex((s) => s.url === activeServer.url) - : -1; - if (activeIndex >= 0) { - storage.set(WidgetStorageKeys.ACTIVE_SERVER_ID, widgetServerId(activeIndex)); + if (activeServerId !== undefined) { + storage.set(WidgetStorageKeys.ACTIVE_SERVER_ID, activeServerId); } else { storage.remove(WidgetStorageKeys.ACTIVE_SERVER_ID); } @@ -71,3 +88,24 @@ export function syncWidgetServers(servers: Server[], activeServer?: Server): voi // widget sync is non-critical } } + +/** + * Android: write the server list to a JSON file the Glance widget reads, then + * ask the widgets to redraw immediately (via the local evcc-widget module) so a + * changed server list shows up without waiting for the periodic WorkManager tick. + */ +function syncAndroidWidgetServers(servers: Server[], activeServerId?: string): void { + try { + const payload = { + [WidgetStorageKeys.SERVERS]: servers.map(toWidgetServer), + [WidgetStorageKeys.ACTIVE_SERVER_ID]: activeServerId ?? null, + }; + const file = new File(Paths.document, ANDROID_SERVERS_FILE); + if (file.exists) file.delete(); + file.create(); + file.write(JSON.stringify(payload)); + refreshWidgets(); + } catch { + // widget sync is non-critical + } +} From 6a2393cdb1499036ff610846a29813501a477528 Mon Sep 17 00:00:00 2001 From: Alexandre JARDON <28548335+webalexeu@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:14:18 +0000 Subject: [PATCH 2/8] fix: type the manifest config plugin without any, update widget README lint was failing on 3 @typescript-eslint/no-explicit-any errors in withAndroidWidget.ts; typed the receiver/activity manifest nodes against AndroidConfig.Manifest instead. Also brings the README status section up to date - it still described the pipeline-spike state (one widget, no config Activity, no instant refresh) even though all 5 widgets, per-instance config, and instant refresh are implemented. --- scripts/androidWidget/withAndroidWidget.ts | 25 ++++++++++++---- targets/android-widget/README.md | 34 +++++++++++++--------- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/scripts/androidWidget/withAndroidWidget.ts b/scripts/androidWidget/withAndroidWidget.ts index 3c541a5..f2c9aea 100644 --- a/scripts/androidWidget/withAndroidWidget.ts +++ b/scripts/androidWidget/withAndroidWidget.ts @@ -86,11 +86,25 @@ const FORECAST_RECEIVERS = [ { name: "EvccFeedinWidgetReceiver", label: "Feed-in" }, ]; -const pushWidgetReceiver = (app: any, shortName: string, infoResource: string, label: string) => { +// The manifest types don't model android:label or on +// (Expo's typings only add them for activities/applications), though the +// manifest XML writer accepts both fine. +type ManifestReceiver = NonNullable[number]; +type LabeledManifestReceiver = ManifestReceiver & { + $: ManifestReceiver["$"] & { "android:label"?: string }; + "meta-data"?: AndroidConfig.Manifest.ManifestMetaData[]; +}; + +const pushWidgetReceiver = ( + app: AndroidConfig.Manifest.ManifestApplication, + shortName: string, + infoResource: string, + label: string, +) => { app.receiver = app.receiver ?? []; const name = `.${WIDGET_SUBDIR}.${shortName}`; - if (app.receiver.some((r: any) => r.$["android:name"] === name)) return; - app.receiver.push({ + if (app.receiver.some((r) => r.$["android:name"] === name)) return; + const receiver: LabeledManifestReceiver = { $: { "android:name": name, "android:exported": "false", "android:label": label }, "intent-filter": [ { action: [{ $: { "android:name": "android.appwidget.action.APPWIDGET_UPDATE" } }] }, @@ -103,7 +117,8 @@ const pushWidgetReceiver = (app: any, shortName: string, infoResource: string, l }, }, ], - }); + }; + app.receiver.push(receiver); }; const withWidgetReceiver: ConfigPlugin = (config) => @@ -127,7 +142,7 @@ const withWidgetReceiver: ConfigPlugin = (config) => action: [{ $: { "android:name": "android.appwidget.action.APPWIDGET_CONFIGURE" } }], }, ], - } as any); + }); } return config; }); diff --git a/targets/android-widget/README.md b/targets/android-widget/README.md index 2b7320e..b2844c8 100644 --- a/targets/android-widget/README.md +++ b/targets/android-widget/README.md @@ -6,9 +6,10 @@ this is a Kotlin/Glance reimplementation of the same contracts. ## Status -This is a **pipeline spike**: one interactive **Loadpoint** widget, end-to-end. -It has **not been compiled** yet — it needs `expo prebuild` + a real Android -build to verify (see below). Treat it as a foundation to iterate on. +Five interactive home-screen widgets, end-to-end, with per-instance +configuration and instant refresh: **Loadpoint**, and forecast widgets for +**Solar / Price / CO₂ / Feed-in**. Verified with `expo prebuild` + a real local +Android build (`./gradlew assembleDebug` / `assembleRelease`). Done: @@ -19,22 +20,27 @@ Done: - `kotlin/ApiClient.kt` — GET `/api/state?jq=…` + basic auth + POST actions, plus the `Loadpoint` model (mirrors `ApiClient.swift` / `Loadpoint.swift`). - `kotlin/LoadpointWidget.kt` — Glance widget + interactive mode buttons. +- `kotlin/ForecastWidget.kt` / `ChartRenderer.kt` — the four forecast widgets, + with a Canvas-drawn chart (mirrors `ForecastWidget.swift`). +- **Per-instance config**: `LoadpointWidgetConfigActivity.kt` (pick server, then + loadpoint) and `ForecastWidgetConfigActivity.kt` (pick server; Solar also gets + an "adjust to real production" toggle). Selections persist per `appWidgetId` in + `WidgetConfig.kt`, including a fallback queue for launchers (e.g. MIUI) that + hand the configure Activity a different id than the one the widget binds with. +- **Immediate refresh on config/server change**: `modules/evcc-widget` (a small + local Expo native module) exposes `refresh()`, called from + `utils/widgetRefresh.ts` after `widgetSync.ts` writes the file — no need to + wait for the periodic `updatePeriodMillis` tick. - `kotlin/Theme.kt` — brand colors / text styles. - `scripts/androidWidget/withAndroidWidget.ts` — Expo config plugin: injects the - Kotlin, the `res/xml` widget info, the manifest ``, and the - Glance/Compose gradle wiring. Registered in `app.config.ts`. + Kotlin, the `res/xml` widget info, the manifest ``/`` + entries, and the Glance/Compose gradle wiring. Registered in `app.config.ts`. Not done yet (follow-ups for parity with iOS): -- **Per-instance config** (pick server + loadpoint). iOS uses App Intents; Android - needs a widget **configuration Activity**. The spike uses the default server and - `loadpoints[0]`. -- **The other 5 widgets** (Solar / Price / CO₂ / Feed-in forecasts). -- **Immediate refresh on config change** — `widgetSync.ts` only writes the file; - pushing an instant update from RN needs a tiny native module calling - `LoadpointWidget().updateAll(context)`. Today the widget refreshes on its own - schedule (`updatePeriodMillis`, 30 min floor) / after a mode change. -- Localization (`.xcstrings` → `strings.xml`), size variants, full visual parity. +- Localization (`.xcstrings` → Android string resources) — widget text is + currently hardcoded English in the Kotlin. +- Size variants, full visual parity with the iOS widgets. ## Build / test From cbc9db50ad2538d02fcddb05b337ad9b718b264b Mon Sep 17 00:00:00 2001 From: Alexandre JARDON <28548335+webalexeu@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:00:06 +0000 Subject: [PATCH 3/8] feat: Android widget visual parity with iOS + live config preview Ports LoadpointVM's full status/metric logic (heating, finished/ waitForVehicle, kWh fallback), status dot + color coding, a canvas-rendered progress bar (Glance has no fractional-width modifier), chip-style mode buttons, a two-column forecast header, a chart Y-axis + step-vs-area modes + per-type color (previously always a flat green line), bold/colored footer stats, and day/night theming throughout - all mirrored from LoadpointViews.swift / Views.swift / Theme.swift. Both widget config Activities now fetch real data for the tapped choice and show an actual preview of the widget (plain Views reusing the same chart/ progress-bar bitmaps, since a live Glance render would need pulling in the full Compose UI stack) before committing via a new "Use this" button, instead of committing immediately on tap with no preview. Verified with expo prebuild + local assembleDebug/assembleRelease builds. --- targets/android-widget/README.md | 27 +- targets/android-widget/kotlin/ApiClient.kt | 17 + .../android-widget/kotlin/ChartRenderer.kt | 147 +++++-- .../android-widget/kotlin/ForecastWidget.kt | 372 +++++++++++------- .../kotlin/ForecastWidgetConfigActivity.kt | 85 +++- targets/android-widget/kotlin/Format.kt | 7 + .../android-widget/kotlin/LoadpointWidget.kt | 209 ++++++++-- .../kotlin/LoadpointWidgetConfigActivity.kt | 55 ++- .../kotlin/ProgressBarRenderer.kt | 54 +++ targets/android-widget/kotlin/Theme.kt | 159 +++++++- .../android-widget/kotlin/WidgetPreview.kt | 197 ++++++++++ 11 files changed, 1091 insertions(+), 238 deletions(-) create mode 100644 targets/android-widget/kotlin/ProgressBarRenderer.kt create mode 100644 targets/android-widget/kotlin/WidgetPreview.kt diff --git a/targets/android-widget/README.md b/targets/android-widget/README.md index b2844c8..1748bd1 100644 --- a/targets/android-widget/README.md +++ b/targets/android-widget/README.md @@ -31,16 +31,39 @@ Done: local Expo native module) exposes `refresh()`, called from `utils/widgetRefresh.ts` after `widgetSync.ts` writes the file — no need to wait for the periodic `updatePeriodMillis` tick. -- `kotlin/Theme.kt` — brand colors / text styles. +- `kotlin/Theme.kt` — day/night colors (mirrors iOS's `scheme == .dark` + branches), full typography scale, per-forecast-type palette (mirrors + `Theme.swift`'s `Palette.make`). - `scripts/androidWidget/withAndroidWidget.ts` — Expo config plugin: injects the Kotlin, the `res/xml` widget info, the manifest ``/`` entries, and the Glance/Compose gradle wiring. Registered in `app.config.ts`. +- **Visual parity with iOS** (mirrors `LoadpointViews.swift`/`Views.swift`): + status dot + color-coded status text, a rounded/striped progress bar + (`ProgressBarRenderer.kt`, since Glance has no fractional-width layout + modifier), chip-style mode buttons with a selected-state fill, full + heating/finished/waitForVehicle status + kWh-fallback metric logic ported + from `LoadpointVM.build`, a two-column forecast header, a Y-axis + + step-vs-area chart modes + per-type color in `ChartRenderer.kt` (previously + always a flat green area line regardless of data type), bold/colored footer + stats, and light/dark card backgrounds throughout. Deliberately not ported: + size variants (`systemMedium`'s mode-selector column - the mode chips are + always shown inline instead), the reload button, deep links, and Swift + Charts' `.monotone` spline smoothing (straight line segments instead). +- **Live preview when configuring**: both config Activities now fetch real + data for the tapped server/loadpoint/toggle and render an actual preview of + the widget (`WidgetPreview.kt`) before committing via a new "Use this" + button - previously the pick-a-row tap committed immediately with no + preview. Built with plain Views (reusing `ChartRenderer`/`ProgressBarRenderer` + bitmaps) rather than a live Glance render, since embedding real Glance + content in a classic-Views Activity needs the full Compose UI stack plus an + unpublished/experimental Google API - see the "Live preview" discussion this + was scoped from for the trade-off. Not done yet (follow-ups for parity with iOS): - Localization (`.xcstrings` → Android string resources) — widget text is currently hardcoded English in the Kotlin. -- Size variants, full visual parity with the iOS widgets. +- Size variants (see above). ## Build / test diff --git a/targets/android-widget/kotlin/ApiClient.kt b/targets/android-widget/kotlin/ApiClient.kt index dcc3a13..7daafed 100644 --- a/targets/android-widget/kotlin/ApiClient.kt +++ b/targets/android-widget/kotlin/ApiClient.kt @@ -87,6 +87,9 @@ object ApiClient { } } +/** Subset of /api/state .loadpoints[].ui used by the widget (see Loadpoint.swift). */ +data class LoadpointUi(val minTemp: Double?, val maxTemp: Double?) + /** Subset of /api/state .loadpoints[] used by the widget (see Loadpoint.swift). */ data class Loadpoint( val title: String?, @@ -94,25 +97,39 @@ data class Loadpoint( val vehicleSoc: Double?, val effectiveLimitSoc: Double?, val chargePower: Double?, + val sessionEnergy: Double?, + val chargedEnergy: Double?, val mode: String?, val charging: Boolean, val connected: Boolean, val enabled: Boolean, + val chargerFeatureHeating: Boolean, + val chargerFeatureSwitchDevice: Boolean, + val ui: LoadpointUi?, ) { companion object { fun parse(json: String): Loadpoint? = runCatching { val o = JSONObject(json) fun d(k: String) = if (o.has(k) && !o.isNull(k)) o.optDouble(k) else null + val ui = o.optJSONObject("ui")?.let { + fun uiD(k: String) = if (it.has(k) && !it.isNull(k)) it.optDouble(k) else null + LoadpointUi(minTemp = uiD("minTemp"), maxTemp = uiD("maxTemp")) + } Loadpoint( title = o.optString("title").takeIf { it.isNotEmpty() }, vehicleTitle = o.optString("vehicleTitle").takeIf { it.isNotEmpty() }, vehicleSoc = d("vehicleSoc"), effectiveLimitSoc = d("effectiveLimitSoc"), chargePower = d("chargePower"), + sessionEnergy = d("sessionEnergy"), + chargedEnergy = d("chargedEnergy"), mode = o.optString("mode").takeIf { it.isNotEmpty() }, charging = o.optBoolean("charging", false), connected = o.optBoolean("connected", false), enabled = o.optBoolean("enabled", false), + chargerFeatureHeating = o.optBoolean("chargerFeatureHeating", false), + chargerFeatureSwitchDevice = o.optBoolean("chargerFeatureSwitchDevice", false), + ui = ui, ) }.getOrNull() } diff --git a/targets/android-widget/kotlin/ChartRenderer.kt b/targets/android-widget/kotlin/ChartRenderer.kt index 0f119ef..75a1b97 100644 --- a/targets/android-widget/kotlin/ChartRenderer.kt +++ b/targets/android-widget/kotlin/ChartRenderer.kt @@ -1,51 +1,82 @@ package io.evcc.android.widget +import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint import android.graphics.Path +import androidx.glance.color.isNightMode import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale +import kotlin.math.ceil + +/** Mirrors ChartKind in Views.swift: area = solar (monotone-ish line + fill), + * step = price/CO2 (stepEnd line, no fill), stepArea = feed-in (stepEnd + fill). */ +enum class ChartKind { AREA, STEP, STEP_AREA } /** - * Renders a forecast series to a Bitmap (line + translucent area fill, with - * local-midnight day dividers and weekday labels), shown in the widget via a + * Renders a forecast series to a Bitmap (line + optional area fill, a Y axis, + * local-midnight day dividers, and weekday labels), shown in the widget via a * Glance Image. Glance has no chart primitive, so this Canvas bitmap is how we - * approximate the iOS Swift Charts look. + * approximate the iOS Swift Charts look (see ForecastChart in Views.swift) - + * straight line segments rather than Swift Charts' `.monotone` spline + * smoothing is a known simplification. */ object ChartRenderer { private const val W = 720 private const val H = 240 - - private val green = 0xFF0FDE41.toInt() - private val fill = 0x330FDE41.toInt() - private val divider = 0x33FFFFFF.toInt() - private val labelColor = 0x99FFFFFF.toInt() + private const val PAD_TOP = 18f + private const val PAD_BOTTOM = 30f + private const val PAD_LEFT = 32f /** values and times must be index-aligned; times in epoch millis (may be empty). */ - fun render(values: List, times: List): Bitmap { + fun render( + context: Context, + values: List, + times: List, + kind: ChartKind, + accentDay: Int, + accentNight: Int, + ): Bitmap { val bmp = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888) val canvas = Canvas(bmp) if (values.size < 2) return bmp - val minV = values.min() - val maxV = values.max() - val span = (maxV - minV).let { if (it <= 0.0) 1.0 else it } + val dark = context.isNightMode + val accent = if (dark) accentNight else accentDay + val fillColor = (accent and 0x00FFFFFF) or 0x33000000 + val dividerColor = if (dark) 0x33FFFFFF.toInt() else 0x22000000.toInt() + val labelColor = if (dark) 0x99FFFFFF.toInt() else 0x99000000.toInt() + val zeroLineColor = if (dark) 0x40FFFFFF.toInt() else 0x33000000.toInt() - val padTop = 18f - val padBottom = 30f - val plotW = W.toFloat() - val plotH = H - padTop - padBottom + val axisBottom = minOf(0.0, values.min()) + val axisTop = axisTop(values) + val span = (axisTop - axisBottom).let { if (it <= 0.0) 1.0 else it } - fun x(i: Int) = plotW * (i.toFloat() / (values.size - 1)) - fun y(v: Double) = padTop + plotH * (1f - ((v - minV) / span).toFloat()) + val plotW = W - PAD_LEFT + val plotH = H - PAD_TOP - PAD_BOTTOM + + fun x(i: Int) = PAD_LEFT + plotW * (i.toFloat() / (values.size - 1)) + fun y(v: Double) = PAD_TOP + plotH * (1f - ((v - axisBottom) / span).toFloat()) + + // Y axis: 0 line + min/max labels (mirrors chartYAxis in Views.swift) + val axisLabelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = labelColor + textSize = 18f + } + canvas.drawLine(PAD_LEFT, y(0.0), W.toFloat(), y(0.0), Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = zeroLineColor + strokeWidth = 1f + }) + canvas.drawText(axisLabel(0.0), 2f, y(0.0) + 6f, axisLabelPaint) + canvas.drawText(axisLabel(axisTop), 2f, y(axisTop) + 6f, axisLabelPaint) // day dividers + weekday labels (drawn first, behind the series) if (times.size == values.size) { val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = divider + color = dividerColor strokeWidth = 1.5f } val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { @@ -61,7 +92,7 @@ object ChartRenderer { if (day != lastDay) { if (lastDay != -1) { val xx = x(i) - canvas.drawLine(xx, padTop, xx, padTop + plotH, dividerPaint) + canvas.drawLine(xx, PAD_TOP, xx, PAD_TOP + plotH, dividerPaint) canvas.drawText(weekday.format(Date(times[i])), xx + 6f, H - 8f, textPaint) } lastDay = day @@ -69,31 +100,75 @@ object ChartRenderer { } } - // area fill under the line - val area = Path().apply { - moveTo(x(0), padTop + plotH) - for (i in values.indices) lineTo(x(i), y(values[i])) - lineTo(x(values.size - 1), padTop + plotH) - close() + // area fill under the line (skipped for pure STEP, like iOS's `if kind != .step`) + if (kind != ChartKind.STEP) { + val area = if (kind == ChartKind.AREA) { + areaPath(::x, ::y, values, y(axisBottom)) + } else { + stepAreaPath(::x, ::y, values, y(axisBottom)) + } + canvas.drawPath(area, Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = fillColor + }) } - canvas.drawPath(area, Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL - color = fill - }) // series line - val line = Path().apply { - moveTo(x(0), y(values[0])) - for (i in 1 until values.size) lineTo(x(i), y(values[i])) - } + val line = if (kind == ChartKind.AREA) linePath(::x, ::y, values) else stepLinePath(::x, ::y, values) canvas.drawPath(line, Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE - strokeWidth = 4f - color = green + strokeWidth = if (kind == ChartKind.AREA) 4.4f else 4f + color = accent strokeJoin = Paint.Join.ROUND strokeCap = Paint.Cap.ROUND }) return bmp } + + // labels: 0 + max, ceil to next integer; fractional (<1) series ceil to 0.1 + // so sub-unit currencies aren't flattened. Mirrors axisTop/axisBottom in + // ForecastChart (Views.swift). + private fun axisTop(values: List): Double { + val m = values.maxOrNull() ?: 0.0 + if (m <= 0.0) return 1.0 + return if (m < 1.0) ceil(m * 10) / 10 else ceil(m) + } + + private fun axisLabel(v: Double): String = + if (v == v.toLong().toDouble()) v.toLong().toString() else String.format(Locale.getDefault(), "%.1f", v) + + private fun linePath(x: (Int) -> Float, y: (Double) -> Float, values: List): Path = Path().apply { + moveTo(x(0), y(values[0])) + for (i in 1 until values.size) lineTo(x(i), y(values[i])) + } + + private fun areaPath(x: (Int) -> Float, y: (Double) -> Float, values: List, baselineY: Float): Path = + Path().apply { + moveTo(x(0), baselineY) + for (i in values.indices) lineTo(x(i), y(values[i])) + lineTo(x(values.size - 1), baselineY) + close() + } + + /** Staircase: horizontal to the next x at the current value, then a vertical jump. */ + private fun stepLinePath(x: (Int) -> Float, y: (Double) -> Float, values: List): Path = Path().apply { + moveTo(x(0), y(values[0])) + for (i in 1 until values.size) { + lineTo(x(i), y(values[i - 1])) + lineTo(x(i), y(values[i])) + } + } + + private fun stepAreaPath(x: (Int) -> Float, y: (Double) -> Float, values: List, baselineY: Float): Path = + Path().apply { + moveTo(x(0), baselineY) + lineTo(x(0), y(values[0])) + for (i in 1 until values.size) { + lineTo(x(i), y(values[i - 1])) + lineTo(x(i), y(values[i])) + } + lineTo(x(values.size - 1), baselineY) + close() + } } diff --git a/targets/android-widget/kotlin/ForecastWidget.kt b/targets/android-widget/kotlin/ForecastWidget.kt index a9c99f6..78d88aa 100644 --- a/targets/android-widget/kotlin/ForecastWidget.kt +++ b/targets/android-widget/kotlin/ForecastWidget.kt @@ -3,7 +3,7 @@ package io.evcc.android.widget import android.content.Context import android.graphics.Bitmap import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.dp import androidx.glance.GlanceId import androidx.glance.GlanceModifier @@ -17,6 +17,7 @@ import androidx.glance.background import androidx.glance.layout.Alignment import androidx.glance.layout.Column import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row import androidx.glance.layout.Spacer import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxWidth @@ -29,25 +30,32 @@ import kotlinx.coroutines.withContext import org.json.JSONObject /** - * Forecast home-screen widgets (Android counterpart of ForecastWidget.swift): - * Solar, Price, CO₂ and Feed-in. Glance has no chart primitive, so the 48h - * series is rendered to a Bitmap (see [ChartRenderer]) and shown via Image, - * alongside a current value + summary. + * Forecast home-screen widgets (Android counterpart of ForecastWidget.swift / + * Views.swift's SolarCard/SeriesCard): Solar, Price, CO₂ and Feed-in. Glance + * has no chart primitive, so the 48h series is rendered to a Bitmap (see + * [ChartRenderer]) and shown via Image, alongside a two-column header and a + * colored/bold footer. * * Per-instance config (server, and for Solar the "adjust to real production" - * toggle) is set by ForecastWidgetConfigActivity, keyed by appWidgetId. + * toggle) is set by ForecastWidgetConfigActivity, keyed by appWidgetId. The + * data-fetching functions below (loadForecastState and friends) are top-level, + * not GlanceAppWidget instance methods, so ForecastWidgetConfigActivity can + * reuse them for its live preview without duplicating the parsing logic. */ -// non-private: the abstract ForecastWidget's constructor (public, since the -// concrete widget subclasses are) takes it as a parameter. +// Titles mirror evcc's own forecast.type.*/widget.type.* strings (see Configuration.swift). enum class ForecastKind(val title: String) { - SOLAR("Solar"), PRICE("Price"), CO2("CO₂"), FEEDIN("Feed-in") + SOLAR("Solar Production"), PRICE("Grid import price"), CO2("CO₂ Emissions"), FEEDIN("Grid export price") } -private sealed interface ForecastState { +data class FooterSide(val prefix: String? = null, val emphasis: String, val label: String? = null) + +sealed interface ForecastState { data class Data( - val header: String, - val summary: String, + val value: String, + val unit: String, val chart: Bitmap, + val footerLeft: FooterSide, + val footerRight: FooterSide, ) : ForecastState object NoData : ForecastState object Unreachable : ForecastState @@ -64,161 +72,245 @@ private fun downsampleIndices(size: Int): List { return (0 until MAX_POINTS).map { (it * step).toInt() } } +private fun chart(context: Context, kind: ForecastKind, values: List, times: List, chartKind: ChartKind): Bitmap { + val idx = downsampleIndices(values.size) + val p = palette(kind) + return ChartRenderer.render( + context, idx.map { values[it] }, idx.map { times[it] }, chartKind, + p.accentDay.toArgb(), p.accentNight.toArgb(), + ) +} + +/** Fetches and parses the forecast state for `kind` from `server`. Runs network I/O - call off the main thread. */ +fun loadForecastState(context: Context, kind: ForecastKind, server: StoredServer, adjust: Boolean): ForecastState = when (kind) { + ForecastKind.SOLAR -> solar(context, server, adjust) + ForecastKind.PRICE -> series(context, kind, server, "{currency:.currency,slots:.forecast.grid}") + ForecastKind.FEEDIN -> series(context, kind, server, "{currency:.currency,slots:.forecast.feedin}") + ForecastKind.CO2 -> co2(context, server) +} + +private fun solar(context: Context, server: StoredServer, adjust: Boolean): ForecastState { + val out = ApiClient.fetch(server, ".forecast.solar") + if (out is FetchOutcome.NoData) return ForecastState.NoData + if (out !is FetchOutcome.Success) return ForecastState.Unreachable + return runCatching { + val o = JSONObject(out.json) + val rawScale = if (o.has("scale") && !o.isNull("scale")) o.optDouble("scale") else 1.0 + val scale = if (adjust) rawScale else 1.0 + val ts = o.optJSONArray("timeseries") ?: return@runCatching ForecastState.NoData + val now = System.currentTimeMillis() + val end = now + WINDOW_MS + val values = ArrayList() + val times = ArrayList() + var currentW: Double? = null + for (i in 0 until ts.length()) { + // entries are [ts, val] tuples, unix seconds (see timeseries.MarshalJSON in evcc) + val p = ts.optJSONArray(i) ?: continue + if (p.length() < 2) continue + val t = (p.optDouble(0) * 1000).toLong() + val v = p.optDouble(1) * scale + if (t in now..end) { + values.add(v) + times.add(t) + } + if (t <= now) currentW = v // last slot at/behind now wins + } + if (values.isEmpty()) return@runCatching ForecastState.NoData + val today = o.optJSONObject("today")?.optDouble("energy") ?: 0.0 + val tomorrow = o.optJSONObject("tomorrow")?.optDouble("energy") ?: 0.0 + val (value, unit) = splitValueUnit(Format.fmtW(currentW ?: values.first())) + ForecastState.Data( + value = value, + unit = unit, + chart = chart(context, ForecastKind.SOLAR, values, times, ChartKind.AREA), + footerLeft = FooterSide(emphasis = Format.fmtWh(today * scale), label = "remaining"), + footerRight = FooterSide(emphasis = Format.fmtWh(tomorrow * scale), label = "Tomorrow"), + ) + }.getOrDefault(ForecastState.NoData) +} + +private fun series(context: Context, kind: ForecastKind, server: StoredServer, jq: String): ForecastState { + val out = ApiClient.fetch(server, jq) + if (out is FetchOutcome.NoData) return ForecastState.NoData + if (out !is FetchOutcome.Success) return ForecastState.Unreachable + return runCatching { + val o = JSONObject(out.json) + val currency = o.optString("currency").takeIf { it.isNotEmpty() && it != "null" } ?: "EUR" + val slots = o.optJSONArray("slots") ?: return@runCatching ForecastState.NoData + val stat = windowStats(slots) ?: return@runCatching ForecastState.NoData + val (value, unit) = splitValueUnit(Format.fmtPricePerKWh(stat.current, currency)) + ForecastState.Data( + value = value, + unit = unit, + chart = chart(context, kind, stat.values, stat.times, ChartKind.STEP_AREA), + footerLeft = FooterSide( + emphasis = "${Format.fmtPricePerKWh(stat.min, currency, withUnit = false)}–" + + Format.fmtPricePerKWh(stat.max, currency, withUnit = false), + label = Format.pricePerKWhUnit(currency), + ), + footerRight = FooterSide( + prefix = "ø ", + emphasis = Format.fmtPricePerKWh(stat.avg, currency), + ), + ) + }.getOrDefault(ForecastState.NoData) +} + +private fun co2(context: Context, server: StoredServer): ForecastState { + val out = ApiClient.fetch(server, ".forecast.co2") + if (out is FetchOutcome.NoData) return ForecastState.NoData + if (out !is FetchOutcome.Success) return ForecastState.Unreachable + return runCatching { + val slots = org.json.JSONArray(out.json) + val stat = windowStats(slots) ?: return@runCatching ForecastState.NoData + val (value, unit) = splitValueUnit(Format.fmtCo2(stat.current)) + ForecastState.Data( + value = value, + unit = unit, + chart = chart(context, ForecastKind.CO2, stat.values, stat.times, ChartKind.STEP), + footerLeft = FooterSide( + emphasis = "${Format.fmtNumber(stat.min, 0)}–${Format.fmtNumber(stat.max, 0)}", + label = "g", + ), + footerRight = FooterSide(prefix = "ø ", emphasis = "${Format.fmtNumber(stat.avg, 0)} g"), + ) + }.getOrDefault(ForecastState.NoData) +} + +private data class Stats( + val values: List, val times: List, + val current: Double, val min: Double, val max: Double, val avg: Double, +) + +/** Trim slots ([start, end, value] tuples, unix seconds) to the 48h window and reduce to stats + series. */ +private fun windowStats(slots: org.json.JSONArray): Stats? { + val now = System.currentTimeMillis() + val end = now + WINDOW_MS + val values = ArrayList() + val times = ArrayList() + var current: Double? = null + for (i in 0 until slots.length()) { + val s = slots.optJSONArray(i) ?: continue + if (s.length() < 3) continue + val start = (s.optDouble(0) * 1000).toLong() + val slotEnd = (s.optDouble(1) * 1000).toLong() + val v = s.optDouble(2) + if (start in now..end) { + values.add(v) + times.add(start) + } + if (current == null && now < slotEnd) current = v // first slot ending after now + } + if (values.isEmpty()) return null + return Stats( + values = values, + times = times, + current = current ?: values.first(), + min = values.min(), + max = values.max(), + avg = values.average(), + ) +} + abstract class ForecastWidget(private val kind: ForecastKind) : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) val serverId = WidgetConfig.forecastServerId(context, appWidgetId) val adjust = WidgetConfig.forecastAdjust(context, appWidgetId) - val state = load(context, serverId, adjust) + val state = withContext(Dispatchers.IO) { + val server = SharedStore.server(context, serverId) ?: return@withContext ForecastState.NotConfigured + loadForecastState(context, kind, server, adjust) + } provideContent { Content(state) } } - private suspend fun load(context: Context, serverId: String?, adjust: Boolean): ForecastState = - withContext(Dispatchers.IO) { - val server = SharedStore.server(context, serverId) ?: return@withContext ForecastState.NotConfigured - when (kind) { - ForecastKind.SOLAR -> solar(server, adjust) - ForecastKind.PRICE -> series(server, "{currency:.currency,slots:.forecast.grid}") - ForecastKind.FEEDIN -> series(server, "{currency:.currency,slots:.forecast.feedin}") - ForecastKind.CO2 -> co2(server) + @Composable + private fun Content(state: ForecastState) { + val notConfigured = state == ForecastState.NotConfigured + Column( + modifier = GlanceModifier.fillMaxSize() + .background(if (notConfigured) notConfiguredBackground else cardBackground) + .padding(12.dp), + verticalAlignment = Alignment.Vertical.Top, + ) { + when (state) { + is ForecastState.Data -> DataBody(state) + ForecastState.NoData -> MessageBody("No data", "This server has no data of this type.") + ForecastState.Unreachable -> MessageBody("Server unreachable", "Could not load the evcc instance.") + ForecastState.NotConfigured -> NotConfiguredBody() } } + } - private fun chart(values: List, times: List): Bitmap { - val idx = downsampleIndices(values.size) - return ChartRenderer.render(idx.map { values[it] }, idx.map { times[it] }) + @Composable + private fun DataBody(state: ForecastState.Data) { + val p = palette(kind) + Header(p, state.value, state.unit) + Spacer(GlanceModifier.height(4.dp)) + Image( + provider = ImageProvider(state.chart), + contentDescription = null, + modifier = GlanceModifier.fillMaxWidth().height(64.dp), + contentScale = ContentScale.FillBounds, + ) + Spacer(GlanceModifier.height(5.dp)) + Footer(p, state.footerLeft, state.footerRight) } - private fun solar(server: StoredServer, adjust: Boolean): ForecastState { - val out = ApiClient.fetch(server, ".forecast.solar") - if (out is FetchOutcome.NoData) return ForecastState.NoData - if (out !is FetchOutcome.Success) return ForecastState.Unreachable - return runCatching { - val o = JSONObject(out.json) - val rawScale = if (o.has("scale") && !o.isNull("scale")) o.optDouble("scale") else 1.0 - val scale = if (adjust) rawScale else 1.0 - val ts = o.optJSONArray("timeseries") ?: return@runCatching ForecastState.NoData - val now = System.currentTimeMillis() - val end = now + WINDOW_MS - val values = ArrayList() - val times = ArrayList() - var currentW: Double? = null - for (i in 0 until ts.length()) { - // entries are [ts, val] tuples, unix seconds (see timeseries.MarshalJSON in evcc) - val p = ts.optJSONArray(i) ?: continue - if (p.length() < 2) continue - val t = (p.optDouble(0) * 1000).toLong() - val v = p.optDouble(1) * scale - if (t in now..end) { - values.add(v) - times.add(t) + @Composable + private fun Header(p: Palette, value: String, unit: String) { + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.Vertical.Bottom) { + Text(kind.title, style = headerHeadlineStyle.copy(color = p.headline)) + Spacer(GlanceModifier.defaultWeight()) + Column(horizontalAlignment = Alignment.Horizontal.End) { + Row { + Text(value, style = headerHeadlineStyle.copy(color = p.headline)) + Text(" $unit", style = headerHeadlineUnitStyle.copy(color = p.headline)) } - if (t <= now) currentW = v // last slot at/behind now wins + Text("now", style = headerSubStyle) } - if (values.isEmpty()) return@runCatching ForecastState.NoData - val today = o.optJSONObject("today")?.optDouble("energy") ?: 0.0 - val tomorrow = o.optJSONObject("tomorrow")?.optDouble("energy") ?: 0.0 - ForecastState.Data( - header = Format.fmtW(currentW ?: values.first()), - summary = "today ${Format.fmtWh(today * scale)} · tom. ${Format.fmtWh(tomorrow * scale)}", - chart = chart(values, times), - ) - }.getOrDefault(ForecastState.NoData) + } } - private fun series(server: StoredServer, jq: String): ForecastState { - val out = ApiClient.fetch(server, jq) - if (out is FetchOutcome.NoData) return ForecastState.NoData - if (out !is FetchOutcome.Success) return ForecastState.Unreachable - return runCatching { - val o = JSONObject(out.json) - val currency = o.optString("currency").takeIf { it.isNotEmpty() && it != "null" } ?: "EUR" - val slots = o.optJSONArray("slots") ?: return@runCatching ForecastState.NoData - val stat = windowStats(slots) ?: return@runCatching ForecastState.NoData - ForecastState.Data( - header = Format.fmtPricePerKWh(stat.current, currency), - summary = "avg ${Format.fmtPricePerKWh(stat.avg, currency, withUnit = false)}" + - " · ${Format.fmtPricePerKWh(stat.min, currency, withUnit = false)}–" + - Format.fmtPricePerKWh(stat.max, currency), - chart = chart(stat.values, stat.times), - ) - }.getOrDefault(ForecastState.NoData) + @Composable + private fun Footer(p: Palette, left: FooterSide, right: FooterSide) { + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.Vertical.CenterVertically) { + FooterText(left, p.headline) + Spacer(GlanceModifier.defaultWeight()) + FooterText(right, textPrimary) + } } - private fun co2(server: StoredServer): ForecastState { - val out = ApiClient.fetch(server, ".forecast.co2") - if (out is FetchOutcome.NoData) return ForecastState.NoData - if (out !is FetchOutcome.Success) return ForecastState.Unreachable - return runCatching { - val slots = org.json.JSONArray(out.json) - val stat = windowStats(slots) ?: return@runCatching ForecastState.NoData - ForecastState.Data( - header = Format.fmtCo2(stat.current), - summary = "avg ${Format.fmtNumber(stat.avg, 0)} · " + - "${Format.fmtNumber(stat.min, 0)}–${Format.fmtNumber(stat.max, 0)} g", - chart = chart(stat.values, stat.times), - ) - }.getOrDefault(ForecastState.NoData) + @Composable + private fun FooterText(side: FooterSide, emphasisColor: ColorProvider) { + Row { + if (side.prefix != null) Text(side.prefix, style = footerStyle) + Text(side.emphasis, style = footerEmphasisStyle.copy(color = emphasisColor)) + if (side.label != null) Text(" ${side.label}", style = footerStyle) + } } - private data class Stats( - val values: List, val times: List, - val current: Double, val min: Double, val max: Double, val avg: Double, - ) - - /** Trim slots ([start, end, value] tuples, unix seconds) to the 48h window and reduce to stats + series. */ - private fun windowStats(slots: org.json.JSONArray): Stats? { - val now = System.currentTimeMillis() - val end = now + WINDOW_MS - val values = ArrayList() - val times = ArrayList() - var current: Double? = null - for (i in 0 until slots.length()) { - val s = slots.optJSONArray(i) ?: continue - if (s.length() < 3) continue - val start = (s.optDouble(0) * 1000).toLong() - val slotEnd = (s.optDouble(1) * 1000).toLong() - val v = s.optDouble(2) - if (start in now..end) { - values.add(v) - times.add(start) - } - if (current == null && now < slotEnd) current = v // first slot ending after now + @Composable + private fun MessageBody(title: String, message: String) { + Column( + modifier = GlanceModifier.fillMaxSize(), + verticalAlignment = Alignment.Vertical.CenterVertically, + horizontalAlignment = Alignment.Horizontal.CenterHorizontally, + ) { + Text(title, style = messageTitleStyle) + Text(message, style = messageBodyStyle) } - if (values.isEmpty()) return null - return Stats( - values = values, - times = times, - current = current ?: values.first(), - min = values.min(), - max = values.max(), - avg = values.average(), - ) } @Composable - private fun Content(state: ForecastState) { + private fun NotConfiguredBody() { Column( - modifier = GlanceModifier.fillMaxSize().background(ColorProvider(Color(0xFF1B1B1B))).padding(12.dp), - verticalAlignment = Alignment.Vertical.Top, + modifier = GlanceModifier.fillMaxSize(), + verticalAlignment = Alignment.Vertical.CenterVertically, + horizontalAlignment = Alignment.Horizontal.CenterHorizontally, ) { - Text(kind.title, style = subtle) - when (state) { - is ForecastState.Data -> { - Text(state.header, style = titleStyle) - Text(state.summary, style = subtle) - Spacer(GlanceModifier.height(6.dp)) - Image( - provider = ImageProvider(state.chart), - contentDescription = null, - modifier = GlanceModifier.fillMaxWidth().height(64.dp), - contentScale = ContentScale.FillBounds, - ) - } - ForecastState.NoData -> Text("No data", style = subtle) - ForecastState.Unreachable -> Text("Unreachable", style = subtle) - ForecastState.NotConfigured -> Text("Open the app to set up", style = subtle) - } + Text("Set up evcc", style = notConfiguredTitleStyle) + Text("Tap to connect a server and pick a data type.", style = notConfiguredBodyStyle) } } } diff --git a/targets/android-widget/kotlin/ForecastWidgetConfigActivity.kt b/targets/android-widget/kotlin/ForecastWidgetConfigActivity.kt index 276b97a..99dc143 100644 --- a/targets/android-widget/kotlin/ForecastWidgetConfigActivity.kt +++ b/targets/android-widget/kotlin/ForecastWidgetConfigActivity.kt @@ -3,27 +3,43 @@ package io.evcc.android.widget import android.app.Activity import android.appwidget.AppWidgetManager import android.content.Intent +import android.content.res.Configuration import android.graphics.Color import android.graphics.Typeface import android.os.Bundle import android.util.TypedValue +import android.view.Gravity import android.view.View +import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Config for the forecast widgets: pick a server, and (Solar only) whether to - * adjust the forecast to real production. Stored per appWidgetId in WidgetConfig. - * Shared by all four forecast types; the Solar toggle is shown only when the - * widget being configured is the Solar provider. + * adjust the forecast to real production. Stored per appWidgetId in + * WidgetConfig. Shared by all four forecast types; the Solar toggle is shown + * only when the widget being configured is the Solar provider. Each choice + * fetches live data and shows a preview of the actual widget (see + * WidgetPreview) before committing via the "Use this" button. */ class ForecastWidgetConfigActivity : Activity() { + private val scope = MainScope() private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID - private var isSolar = false + private lateinit var kind: ForecastKind + private var pending: Pair? = null // (serverId, adjust) shown in the preview, ready to confirm private lateinit var titleView: TextView + private lateinit var previewContainer: FrameLayout private lateinit var container: LinearLayout // holds the tappable rows + private lateinit var confirmButton: TextView + + private val dark: Boolean + get() = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -38,8 +54,13 @@ class ForecastWidgetConfigActivity : Activity() { return } - val provider = AppWidgetManager.getInstance(this).getAppWidgetInfo(appWidgetId)?.provider - isSolar = provider?.className?.endsWith("EvccSolarWidgetReceiver") == true + val className = AppWidgetManager.getInstance(this).getAppWidgetInfo(appWidgetId)?.provider?.className + kind = when { + className?.endsWith("EvccSolarWidgetReceiver") == true -> ForecastKind.SOLAR + className?.endsWith("EvccPriceWidgetReceiver") == true -> ForecastKind.PRICE + className?.endsWith("EvccCo2WidgetReceiver") == true -> ForecastKind.CO2 + else -> ForecastKind.FEEDIN + } setContentView(buildLayout()) showServers() @@ -61,13 +82,30 @@ class ForecastWidgetConfigActivity : Activity() { setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f) setTypeface(typeface, Typeface.BOLD) } + previewContainer = FrameLayout(this).apply { + setPadding(dp(20), 0, dp(20), dp(4)) + visibility = View.GONE + } container = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } val scroll = ScrollView(this).apply { layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f) addView(container) } + confirmButton = TextView(this).apply { + text = "Use this" + setTextColor(Color.WHITE) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + setTypeface(typeface, Typeface.BOLD) + gravity = Gravity.CENTER + setPadding(dp(20), dp(16), dp(20), dp(16)) + setBackgroundColor(0xFF0FDE41.toInt()) + visibility = View.GONE + setOnClickListener { pending?.let { (serverId, adjust) -> save(serverId, adjust) } } + } root.addView(titleView) + root.addView(previewContainer) root.addView(scroll) + root.addView(confirmButton) return root } @@ -111,18 +149,41 @@ class ForecastWidgetConfigActivity : Activity() { val servers = SharedStore.servers(this) when { servers.isEmpty() -> setRows(listOf("No servers — add one in the app first"), null) - servers.size == 1 -> onServer(servers[0].id) - else -> setRows(servers.map { it.displayTitle }) { index -> onServer(servers[index].id) } + servers.size == 1 -> onServer(servers[0]) + else -> setRows(servers.map { it.displayTitle }) { index -> onServer(servers[index]) } } } - private fun onServer(serverId: String) { - if (isSolar) showAdjust(serverId) else save(serverId, adjust = true) + private fun onServer(server: StoredServer) { + if (kind == ForecastKind.SOLAR) showAdjust(server) else preview(server, adjust = true) } - private fun showAdjust(serverId: String) { + private fun showAdjust(server: StoredServer) { titleView.text = "Adjust to real production?" - setRows(listOf("Yes (recommended)", "No")) { index -> save(serverId, adjust = index == 0) } + setRows(listOf("Yes (recommended)", "No")) { index -> preview(server, adjust = index == 0) } + } + + /** Fetches live data for the chosen server (+ adjust setting) and shows a preview of the real widget. */ + private fun preview(server: StoredServer, adjust: Boolean) { + pending = null + confirmButton.visibility = View.GONE + showPreview(WidgetPreview.message(this, "Loading preview…", dark)) + scope.launch { + val state = withContext(Dispatchers.IO) { loadForecastState(this@ForecastWidgetConfigActivity, kind, server, adjust) } + if (state !is ForecastState.Data) { + showPreview(WidgetPreview.message(this@ForecastWidgetConfigActivity, "Couldn't load a preview", dark)) + return@launch + } + showPreview(WidgetPreview.forecast(this@ForecastWidgetConfigActivity, kind, state, dark)) + pending = server.id to adjust + confirmButton.visibility = View.VISIBLE + } + } + + private fun showPreview(view: View) { + previewContainer.removeAllViews() + previewContainer.addView(view) + previewContainer.visibility = View.VISIBLE } private fun save(serverId: String, adjust: Boolean) { diff --git a/targets/android-widget/kotlin/Format.kt b/targets/android-widget/kotlin/Format.kt index bc6bbed..71d070e 100644 --- a/targets/android-widget/kotlin/Format.kt +++ b/targets/android-widget/kotlin/Format.kt @@ -76,3 +76,10 @@ object Format { return if (withUnit) "$price ${pricePerKWhUnit(currency)}" else price } } + +/** Splits a " " string (e.g. "8.4 kW") so a header can render the + * value large and the unit small. Mirrors splitValueUnit in Format.swift. */ +fun splitValueUnit(s: String): Pair { + val i = s.indexOf(' ') + return if (i < 0) s to "" else s.substring(0, i) to s.substring(i + 1) +} diff --git a/targets/android-widget/kotlin/LoadpointWidget.kt b/targets/android-widget/kotlin/LoadpointWidget.kt index 657288a..40bc753 100644 --- a/targets/android-widget/kotlin/LoadpointWidget.kt +++ b/targets/android-widget/kotlin/LoadpointWidget.kt @@ -2,8 +2,11 @@ package io.evcc.android.widget import android.content.Context import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp import androidx.glance.GlanceId import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider import androidx.glance.action.ActionParameters import androidx.glance.action.actionParametersOf import androidx.glance.action.clickable @@ -12,25 +15,45 @@ import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.updateAll +import androidx.glance.background +import androidx.glance.color.isNightMode import androidx.glance.layout.Alignment +import androidx.glance.layout.Box import androidx.glance.layout.Column import androidx.glance.layout.Row +import androidx.glance.layout.Spacer import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height import androidx.glance.layout.padding +import androidx.glance.layout.width import androidx.glance.text.Text -import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext /** - * Loadpoint home-screen widget (Android counterpart of LoadpointWidget.swift). + * Loadpoint home-screen widget (Android counterpart of LoadpointWidget.swift / + * LoadpointViews.swift's LoadpointCard). Uses the default server and the first + * loadpoint; per-instance configuration (server + loadpoint picker) is set by + * LoadpointWidgetConfigActivity. * - * Spike scope: uses the default server and the first loadpoint. Per-instance - * configuration (server + loadpoint picker) is a follow-up via a widget - * configuration Activity — see targets/android-widget/README.md. + * Deliberate simplifications vs. iOS: single compact layout (no systemMedium + * mode-selector column - the mode chips are always shown inline instead, which + * keeps the interactive mode-switching that a medium-only selector would drop + * for this widget's only size), no reload button, no deep link. */ +// visible (not private) so the config activities can reuse them for previews +val MODE_LABELS = mapOf("off" to "Off", "pv" to "Solar", "minpv" to "Min+Solar", "now" to "Fast") + +enum class LpStatus(val active: Boolean) { + DISCONNECTED(false), CONNECTED(false), WAIT_FOR_VEHICLE(false), FINISHED(false), CHARGING(true), HEATING(true), +} + +data class Metric(val value: String, val unit: String, val fill: Double?) + private sealed interface LoadpointState { data class Data(val lp: Loadpoint, val serverId: String, val lpIndex: Int) : LoadpointState object NoData : LoadpointState @@ -38,6 +61,57 @@ private sealed interface LoadpointState { object NotConfigured : LoadpointState } +// mirrors LoadpointVM.build's status derivation in Loadpoint.swift +fun status(lp: Loadpoint): LpStatus { + val heating = lp.chargerFeatureHeating + val soc = lp.vehicleSoc ?: 0.0 + val limit = lp.effectiveLimitSoc ?: 0.0 + return when { + !lp.connected -> LpStatus.DISCONNECTED + lp.charging -> if (heating) LpStatus.HEATING else LpStatus.CHARGING + lp.enabled -> if (limit > 0 && soc >= limit) LpStatus.FINISHED else LpStatus.WAIT_FOR_VEHICLE + else -> LpStatus.CONNECTED + } +} + +// mirrors LoadpointStatus.labelKey(heating:) resolved against evcc's own +// main.vehicleStatus.* / main.heatingStatus.* English strings +fun statusLabel(s: LpStatus, heating: Boolean): String = when (s) { + LpStatus.DISCONNECTED -> "Disconnected." + LpStatus.CONNECTED -> if (heating) "Standby." else "Connected." + LpStatus.WAIT_FOR_VEHICLE -> if (heating) "Ready to heat…" else "Ready. Waiting for vehicle…" + LpStatus.FINISHED -> "Finished." + LpStatus.CHARGING -> "Charging…" + LpStatus.HEATING -> "Heating…" +} + +// mirrors LoadpointVM.build's metricValue/metricUnit/fill derivation +fun metric(lp: Loadpoint): Metric { + val heating = lp.chargerFeatureHeating + val soc = lp.vehicleSoc ?: 0.0 + return when { + heating -> { + val minT = lp.ui?.minTemp ?: 0.0 + val maxT = lp.ui?.maxTemp ?: 100.0 + val fill = if (maxT > minT) ((soc - minT) / (maxT - minT)).coerceIn(0.0, 1.0) else null + Metric(Format.fmtNumber(soc, 1), "°C", fill) + } + soc > 0 -> Metric(Format.fmtNumber(soc, 0), "%", (soc / 100).coerceIn(0.0, 1.0)) + else -> { + val kWh = ((lp.chargedEnergy ?: lp.sessionEnergy ?: 0.0)) / 1000 + Metric(Format.fmtNumber(kWh, 1), "kWh", null) + } + } +} + +fun title(lp: Loadpoint): String { + val vt = lp.vehicleTitle?.trim().orEmpty() + return vt.ifEmpty { lp.title ?: "Loadpoint" } +} + +fun modes(lp: Loadpoint): List = + if (lp.chargerFeatureSwitchDevice) listOf("off", "pv", "now") else listOf("off", "pv", "minpv", "now") + class LoadpointWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { // per-instance config (server + loadpoint) written by LoadpointWidgetConfigActivity, @@ -50,7 +124,7 @@ class LoadpointWidget : GlanceAppWidget() { val (serverId, lpIndex) = resolved load(context, serverId, lpIndex) } - provideContent { Content(state) } + provideContent { Content(context, state) } } private suspend fun load(context: Context, serverId: String?, lpIndex: Int): LoadpointState = withContext(Dispatchers.IO) { @@ -65,45 +139,90 @@ class LoadpointWidget : GlanceAppWidget() { } @Composable - private fun Content(state: LoadpointState) { + private fun Content(context: Context, state: LoadpointState) { + val notConfigured = state == LoadpointState.NotConfigured Column( - modifier = GlanceModifier.fillMaxSize().padding(12.dp), + modifier = GlanceModifier.fillMaxSize() + .background(if (notConfigured) notConfiguredBackground else cardBackground) + .padding(12.dp), verticalAlignment = Alignment.Vertical.Top, ) { when (state) { - is LoadpointState.Data -> LoadpointBody(state) - LoadpointState.NoData -> Text("No data", style = subtle) - LoadpointState.Unreachable -> Text("Unreachable", style = subtle) - LoadpointState.NotConfigured -> Text("Open the app to set up", style = subtle) + is LoadpointState.Data -> LoadpointBody(context, state) + LoadpointState.NoData -> MessageBody("No data", "This server has no data of this type.") + LoadpointState.Unreachable -> MessageBody("Server unreachable", "Could not load the evcc instance.") + LoadpointState.NotConfigured -> NotConfiguredBody() } } } @Composable - private fun LoadpointBody(state: LoadpointState.Data) { + private fun LoadpointBody(context: Context, state: LoadpointState.Data) { val lp = state.lp - Text(lp.title ?: lp.vehicleTitle ?: "Loadpoint", style = titleStyle) - val soc = lp.vehicleSoc?.let { "${it.toInt()}%" } - val power = lp.chargePower?.let { formatPower(it) } - Text(listOfNotNull(statusLabel(lp), soc).joinToString(" · "), style = subtle) - if (power != null) Text(power, style = titleStyle) - - // interactive mode buttons (charging control), like the iOS widget - Row(modifier = GlanceModifier.padding(top = 8.dp)) { - for (mode in listOf("off", "pv", "minpv", "now")) { - ModeButton(mode = mode, current = lp.mode, serverId = state.serverId, lpIndex = state.lpIndex) + val s = status(lp) + val m = metric(lp) + val heating = lp.chargerFeatureHeating + + Text(title(lp), style = titleStyle) + + Row(modifier = GlanceModifier.padding(top = 3.dp), verticalAlignment = Alignment.Vertical.CenterVertically) { + Box(modifier = GlanceModifier.width(7.dp).height(7.dp).background(statusColor(s.active, heating)).cornerRadius(4.dp)) {} + Spacer(GlanceModifier.width(5.dp)) + Text(statusLabel(s, heating), style = statusStyle.copy(color = statusColor(s.active, heating))) + } + + Spacer(GlanceModifier.height(6.dp)) + + Row(verticalAlignment = Alignment.Vertical.Bottom) { + Text(m.value, style = metricStyle) + Text(" ${m.unit}", style = metricUnitStyle) + } + + if (m.fill != null) { + Spacer(GlanceModifier.height(6.dp)) + val dark = context.isNightMode + Image( + provider = ImageProvider( + ProgressBarRenderer.render( + fraction = m.fill, + fillColor = barFillColor(lp.connected, heating), + trackColor = barTrackColor(dark), + striped = s.active, + stripeColor = barStripeColor(heating), + ), + ), + contentDescription = null, + modifier = GlanceModifier.fillMaxWidth().height(6.dp), + ) + } + + Spacer(GlanceModifier.height(6.dp)) + + val power = lp.chargePower?.let { Format.fmtW(it) } ?: "–" + val (powerValue, powerUnit) = splitValueUnit(power) + Row { + Text(powerValue, style = powerStyle) + Text(" $powerUnit", style = powerUnitStyle) + } + + Spacer(GlanceModifier.height(8.dp)) + + Row { + modes(lp).forEachIndexed { i, mode -> + if (i > 0) Spacer(GlanceModifier.width(4.dp)) + ModeChip(mode = mode, current = lp.mode, serverId = state.serverId, lpIndex = state.lpIndex) } } } @Composable - private fun ModeButton(mode: String, current: String?, serverId: String, lpIndex: Int) { + private fun ModeChip(mode: String, current: String?, serverId: String, lpIndex: Int) { val selected = mode == current - Text( - text = mode, - style = if (selected) titleStyle else subtle, + Box( modifier = GlanceModifier - .padding(horizontal = 6.dp, vertical = 4.dp) + .background(if (selected) modeSelectedBackground else modeUnselectedBackground) + .cornerRadius(9.dp) + .padding(horizontal = 8.dp, vertical = 5.dp) .clickable( actionRunCallback( actionParametersOf( @@ -113,17 +232,37 @@ class LoadpointWidget : GlanceAppWidget() { ), ), ), - ) + ) { + Text( + text = MODE_LABELS[mode] ?: mode, + style = modeChipStyle.copy(color = if (selected) modeSelectedText else modeUnselectedText), + ) + } } - private fun statusLabel(lp: Loadpoint): String = when { - lp.charging -> "Charging" - lp.connected -> "Connected" - else -> "Disconnected" + @Composable + private fun MessageBody(title: String, message: String) { + Column( + modifier = GlanceModifier.fillMaxSize(), + verticalAlignment = Alignment.Vertical.CenterVertically, + horizontalAlignment = Alignment.Horizontal.CenterHorizontally, + ) { + Text(title, style = messageTitleStyle) + Text(message, style = messageBodyStyle) + } } - private fun formatPower(w: Double): String = - if (w >= 1000) String.format("%.1f kW", w / 1000) else "${w.toInt()} W" + @Composable + private fun NotConfiguredBody() { + Column( + modifier = GlanceModifier.fillMaxSize(), + verticalAlignment = Alignment.Vertical.CenterVertically, + horizontalAlignment = Alignment.Horizontal.CenterHorizontally, + ) { + Text("Set up evcc", style = notConfiguredTitleStyle) + Text("Tap to connect a server and pick a data type.", style = notConfiguredBodyStyle) + } + } } /** Applies a charge mode from a widget button, then refreshes the widget. */ diff --git a/targets/android-widget/kotlin/LoadpointWidgetConfigActivity.kt b/targets/android-widget/kotlin/LoadpointWidgetConfigActivity.kt index 6474bdd..b821639 100644 --- a/targets/android-widget/kotlin/LoadpointWidgetConfigActivity.kt +++ b/targets/android-widget/kotlin/LoadpointWidgetConfigActivity.kt @@ -3,11 +3,13 @@ package io.evcc.android.widget import android.app.Activity import android.appwidget.AppWidgetManager import android.content.Intent +import android.content.res.Configuration import android.graphics.Color import android.graphics.Typeface import android.os.Bundle import android.util.TypedValue import android.view.View +import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView @@ -20,6 +22,9 @@ import kotlinx.coroutines.withContext /** * Widget placement configuration: pick a server, then a loadpoint. Stores the * choice in the widget's per-instance config (read back by LoadpointWidget). + * Tapping a loadpoint fetches its live data and shows a preview of the actual + * widget (see WidgetPreview) before committing via the "Use this loadpoint" + * button, so placement is a pick-and-confirm flow rather than pick-and-commit. * * Uses classic Views (not Compose) so it needs no dependencies beyond Glance - * the RN app is not otherwise a Compose app. Rows are built explicitly (not via @@ -29,9 +34,15 @@ import kotlinx.coroutines.withContext class LoadpointWidgetConfigActivity : Activity() { private val scope = MainScope() private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + private var pending: Pair? = null // (serverId, lpIndex) shown in the preview, ready to confirm private lateinit var titleView: TextView + private lateinit var previewContainer: FrameLayout private lateinit var container: LinearLayout // holds the tappable rows + private lateinit var confirmButton: TextView + + private val dark: Boolean + get() = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -65,6 +76,10 @@ class LoadpointWidgetConfigActivity : Activity() { setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f) setTypeface(typeface, Typeface.BOLD) } + previewContainer = FrameLayout(this).apply { + setPadding(dp(20), 0, dp(20), dp(4)) + visibility = View.GONE + } container = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } val scroll = ScrollView(this).apply { layoutParams = LinearLayout.LayoutParams( @@ -72,8 +87,21 @@ class LoadpointWidgetConfigActivity : Activity() { ) addView(container) } + confirmButton = TextView(this).apply { + text = "Use this loadpoint" + setTextColor(Color.WHITE) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + setTypeface(typeface, Typeface.BOLD) + gravity = android.view.Gravity.CENTER + setPadding(dp(20), dp(16), dp(20), dp(16)) + setBackgroundColor(0xFF0FDE41.toInt()) + visibility = View.GONE + setOnClickListener { pending?.let { (serverId, lpIndex) -> save(serverId, lpIndex) } } + } root.addView(titleView) + root.addView(previewContainer) root.addView(scroll) + root.addView(confirmButton) return root } @@ -136,10 +164,35 @@ class LoadpointWidgetConfigActivity : Activity() { setRows(listOf("No loadpoints reachable"), null) return@launch } - setRows(titles) { index -> save(server.id, index) } + setRows(titles) { index -> preview(server, index) } } } + /** Fetches the tapped loadpoint's live data and shows a preview of the real widget. */ + private fun preview(server: StoredServer, lpIndex: Int) { + pending = null + confirmButton.visibility = View.GONE + showPreview(WidgetPreview.message(this, "Loading preview…", dark)) + scope.launch { + val lp = withContext(Dispatchers.IO) { + (ApiClient.fetch(server, ".loadpoints[$lpIndex]") as? FetchOutcome.Success)?.json?.let { Loadpoint.parse(it) } + } + if (lp == null) { + showPreview(WidgetPreview.message(this@LoadpointWidgetConfigActivity, "Couldn't load a preview", dark)) + return@launch + } + showPreview(WidgetPreview.loadpoint(this@LoadpointWidgetConfigActivity, lp, dark)) + pending = server.id to lpIndex + confirmButton.visibility = View.VISIBLE + } + } + + private fun showPreview(view: View) { + previewContainer.removeAllViews() + previewContainer.addView(view) + previewContainer.visibility = View.VISIBLE + } + private fun save(serverId: String, lpIndex: Int) { // write synchronously (plain SharedPreferences) before the widget renders WidgetConfig.save(this, appWidgetId, serverId, lpIndex) diff --git a/targets/android-widget/kotlin/ProgressBarRenderer.kt b/targets/android-widget/kotlin/ProgressBarRenderer.kt new file mode 100644 index 0000000..50e09aa --- /dev/null +++ b/targets/android-widget/kotlin/ProgressBarRenderer.kt @@ -0,0 +1,54 @@ +package io.evcc.android.widget + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF + +/** + * Renders a rounded, optionally diagonally-striped progress bar to a Bitmap, + * shown via a Glance Image - Glance has no fractional-width layout modifier, + * so this is how the fill gets a precise width. Mirrors ProgressBar in + * LoadpointViews.swift (a SwiftUI Canvas closure drawing the same stripe + * pattern). + */ +object ProgressBarRenderer { + private const val W = 300 + private const val H = 24 + + fun render(fraction: Double, fillColor: Int, trackColor: Int, striped: Boolean, stripeColor: Int): Bitmap { + val bmp = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + val r = H / 2f + val track = RectF(0f, 0f, W.toFloat(), H.toFloat()) + + canvas.drawRoundRect(track, r, r, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = trackColor }) + + val fillW = (W * fraction.coerceIn(0.0, 1.0)).toFloat() + if (fillW <= 0f) return bmp + + canvas.save() + canvas.clipPath(Path().apply { addRoundRect(track, r, r, Path.Direction.CW) }) + canvas.drawRect(RectF(0f, 0f, fillW, H.toFloat()), Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fillColor }) + + if (striped) { + val stripePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = stripeColor } + val band = H * 0.8f + var x = -H.toFloat() + while (x < fillW) { + val p = Path().apply { + moveTo(x, H.toFloat()) + lineTo(x + H, 0f) + lineTo(x + H + band, 0f) + lineTo(x + band, H.toFloat()) + close() + } + canvas.drawPath(p, stripePaint) + x += band * 2 + } + } + canvas.restore() + return bmp + } +} diff --git a/targets/android-widget/kotlin/Theme.kt b/targets/android-widget/kotlin/Theme.kt index aaaf5ff..bd4928c 100644 --- a/targets/android-widget/kotlin/Theme.kt +++ b/targets/android-widget/kotlin/Theme.kt @@ -1,22 +1,157 @@ package io.evcc.android.widget import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.sp +import androidx.glance.color.ColorProvider import androidx.glance.text.FontWeight import androidx.glance.text.TextStyle import androidx.glance.unit.ColorProvider -// evcc brand green, mirrors the iOS widget Colors.swift / themes.json -private val evccGreen = Color(0xFF0FDE41) -private val onSurface = Color(0xFFFFFFFF) -private val onSurfaceMuted = Color(0xB3FFFFFF) // 70% white +// evcc brand + energy tokens, mirror targets/widget/Colors.swift (and evcc's +// web tokens, assets/css/app.css). Day/night pairs below mirror the `scheme == +// .dark` branches in LoadpointViews.swift/Views.swift/Theme.swift. +private val evccDarkGreen = Color(0xFF0FDE41) +private val evccDarkerGreen = Color(0xFF0BA631) +private val evccYellow = Color(0xFFFAF000) +private val evccDarkYellow = Color(0xFFF6BB0F) +private val evccOrange = Color(0xFFFF9000) +private val evccPrice = Color(0xFFFF912F) +private val evccCo2 = Color(0xFF00916E) +private val co2Dark = Color(0xFF1BB88F) -val titleStyle = TextStyle( - color = ColorProvider(onSurface), - fontWeight = FontWeight.Medium, -) +private val bsGrayMedium = Color(0xFF93949E) +private val bsGrayDeep = Color(0xFF010322) -val subtle = TextStyle( - color = ColorProvider(onSurfaceMuted), -) +private val widgetCardDark = Color(0xFF1C1C1E) +private val onGreen = Color(0xFF0A2912) +private val onGreenSoft = Color(0xFF0A3D18) +private val progressTrackLight = Color(0xFFECEEF0) +private val modeBgLight = Color(0xFFF0F1F3) +private val modeBgDark = Color(0xFF1A1B2E) +private val modeTextLight = Color(0xFF7C7D8A) +private val modeTextDark = Color(0xFF9A9BAB) -val accent = ColorProvider(evccGreen) +// approximates SwiftUI's semantic .primary / .secondary on each background +private val textPrimaryDay = Color(0xFF1C1C1E) +private val textSecondaryDay = Color(0x991C1C1E) // 60% ink +private val textSecondaryNight = Color(0xB3FFFFFF) // 70% white + +// -- card chrome -- + +val cardBackground: ColorProvider = ColorProvider(day = Color.White, night = widgetCardDark) +val notConfiguredBackground: ColorProvider = ColorProvider(evccDarkGreen) + +// -- typography (point sizes mirror LoadpointViews.swift / Views.swift) -- + +val textPrimary: ColorProvider = ColorProvider(day = textPrimaryDay, night = Color.White) +val textSecondary: ColorProvider = ColorProvider(day = textSecondaryDay, night = textSecondaryNight) + +val titleStyle = TextStyle(color = textPrimary, fontSize = 14.sp, fontWeight = FontWeight.Bold) +val subtle = TextStyle(color = textSecondary, fontSize = 11.sp) +val metricStyle = TextStyle(color = textPrimary, fontSize = 30.sp, fontWeight = FontWeight.Bold) +val metricUnitStyle = TextStyle(color = textSecondary, fontSize = 14.sp, fontWeight = FontWeight.Bold) +val powerStyle = TextStyle(color = textPrimary, fontSize = 15.sp, fontWeight = FontWeight.Bold) +val powerUnitStyle = TextStyle(color = textSecondary, fontSize = 11.sp, fontWeight = FontWeight.Bold) +val statusStyle = TextStyle(fontSize = 10.sp, fontWeight = FontWeight.Bold) +val modeChipStyle = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Bold) + +val headerHeadlineStyle = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold) +val headerHeadlineUnitStyle = TextStyle(fontSize = 11.sp, fontWeight = FontWeight.Bold) +val headerSubStyle = TextStyle(color = textSecondary, fontSize = 10.sp, fontWeight = FontWeight.Medium) +val footerStyle = TextStyle(color = textSecondary, fontSize = 11.sp, fontWeight = FontWeight.Medium) +val footerEmphasisStyle = TextStyle(fontSize = 11.sp, fontWeight = FontWeight.Bold) +val messageTitleStyle = TextStyle(color = textPrimary, fontSize = 13.sp, fontWeight = FontWeight.Bold) +val messageBodyStyle = TextStyle(color = textSecondary, fontSize = 11.sp) +val notConfiguredTitleStyle = TextStyle(color = ColorProvider(onGreen), fontSize = 15.sp, fontWeight = FontWeight.Bold) +val notConfiguredBodyStyle = TextStyle(color = ColorProvider(onGreenSoft), fontSize = 11.sp, fontWeight = FontWeight.Medium) + +// -- loadpoint status / mode chip colors -- + +/** gray unless active; brand green (darker in light mode) unless heating, then orange. */ +fun statusColor(active: Boolean, heating: Boolean): ColorProvider = when { + !active -> ColorProvider(bsGrayMedium) + heating -> ColorProvider(evccOrange) + else -> ColorProvider(day = evccDarkerGreen, night = evccDarkGreen) +} + +// progress bar fill/stripe/track as raw ARGB ints, rendered via ProgressBarRenderer +// (Glance has no fractional-width modifier, so the bar is a small canvas bitmap +// like the chart) - mirrors ProgressBar in LoadpointViews.swift. +private val evccDarkGreenArgb = evccDarkGreen.toArgb() +private val evccDarkerGreenArgb = evccDarkerGreen.toArgb() +private val evccOrangeArgb = evccOrange.toArgb() +private val orangeStripeArgb = Color(0xFFCC7400).toArgb() +private val bsGrayMediumArgb = bsGrayMedium.toArgb() +private val progressTrackLightArgb = progressTrackLight.toArgb() +private val bsGrayDeepArgb = bsGrayDeep.toArgb() + +fun barFillColor(connected: Boolean, heating: Boolean): Int = when { + !connected -> bsGrayMediumArgb + heating -> evccOrangeArgb + else -> evccDarkGreenArgb +} + +fun barStripeColor(heating: Boolean): Int = if (heating) orangeStripeArgb else evccDarkerGreenArgb + +fun barTrackColor(dark: Boolean): Int = if (dark) bsGrayDeepArgb else progressTrackLightArgb + +// selected chip inverts against the card (like a filled/primary button); mirrors +// AnyShapeStyle(.primary) in LoadpointViews.swift's modeSelector. +val modeSelectedBackground: ColorProvider = ColorProvider(day = Color.Black, night = Color.White) +val modeSelectedText: ColorProvider = ColorProvider(day = Color.White, night = Color.Black) +val modeUnselectedBackground: ColorProvider = ColorProvider(day = modeBgLight, night = modeBgDark) +val modeUnselectedText: ColorProvider = ColorProvider(day = modeTextLight, night = modeTextDark) + +// -- same colors as raw ARGB ints, for the plain-Views config-screen preview +// (WidgetPreview.kt) - it can't use Glance's day/night ColorProvider directly. -- + +private val modeBgLightArgb = modeBgLight.toArgb() +private val modeBgDarkArgb = modeBgDark.toArgb() +private val modeTextLightArgb = modeTextLight.toArgb() +private val modeTextDarkArgb = modeTextDark.toArgb() +private val widgetCardDarkArgb = widgetCardDark.toArgb() +private val textPrimaryDayArgb = textPrimaryDay.toArgb() +private val textSecondaryDayArgb = textSecondaryDay.toArgb() +private val textSecondaryNightArgb = textSecondaryNight.toArgb() + +fun cardBackgroundArgb(dark: Boolean): Int = if (dark) widgetCardDarkArgb else Color.White.toArgb() +fun textPrimaryArgb(dark: Boolean): Int = if (dark) Color.White.toArgb() else textPrimaryDayArgb +fun textSecondaryArgb(dark: Boolean): Int = if (dark) textSecondaryNightArgb else textSecondaryDayArgb + +fun statusColorArgb(active: Boolean, heating: Boolean, dark: Boolean): Int = when { + !active -> bsGrayMediumArgb + heating -> evccOrangeArgb + else -> if (dark) evccDarkGreenArgb else evccDarkerGreenArgb +} + +fun modeSelectedBackgroundArgb(dark: Boolean): Int = if (dark) Color.White.toArgb() else Color.Black.toArgb() +fun modeSelectedTextArgb(dark: Boolean): Int = if (dark) Color.Black.toArgb() else Color.White.toArgb() +fun modeUnselectedBackgroundArgb(dark: Boolean): Int = if (dark) modeBgDarkArgb else modeBgLightArgb +fun modeUnselectedTextArgb(dark: Boolean): Int = if (dark) modeTextDarkArgb else modeTextLightArgb + +// -- forecast per-type palette (mirrors Theme.swift's Palette.make) -- + +data class Palette(val accent: ColorProvider, val headline: ColorProvider, val accentDay: Color, val accentNight: Color) + +fun palette(kind: ForecastKind): Palette = when (kind) { + ForecastKind.SOLAR -> Palette( + accent = ColorProvider(evccDarkGreen), + headline = ColorProvider(day = evccDarkerGreen, night = evccDarkGreen), + accentDay = evccDarkerGreen, accentNight = evccDarkGreen, + ) + ForecastKind.PRICE -> Palette( + accent = ColorProvider(evccPrice), headline = ColorProvider(evccPrice), + accentDay = evccPrice, accentNight = evccPrice, + ) + ForecastKind.CO2 -> Palette( + accent = ColorProvider(day = evccCo2, night = co2Dark), + headline = ColorProvider(day = evccCo2, night = co2Dark), + accentDay = evccCo2, accentNight = co2Dark, + ) + ForecastKind.FEEDIN -> Palette( + accent = ColorProvider(day = evccDarkYellow, night = evccYellow), + headline = ColorProvider(day = evccDarkYellow, night = evccYellow), + accentDay = evccDarkYellow, accentNight = evccYellow, + ) +} diff --git a/targets/android-widget/kotlin/WidgetPreview.kt b/targets/android-widget/kotlin/WidgetPreview.kt new file mode 100644 index 0000000..5bec5a4 --- /dev/null +++ b/targets/android-widget/kotlin/WidgetPreview.kt @@ -0,0 +1,197 @@ +package io.evcc.android.widget + +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.compose.ui.graphics.toArgb + +/** + * Builds a plain-Views mock of the Loadpoint/Forecast widgets for the config + * screens' live preview. Glance content can't be embedded in a classic-Views + * Activity without pulling in the full Compose UI stack (this repo is + * deliberately Compose-free outside Glance itself), so this reuses the same + * data plus the same ChartRenderer/ProgressBarRenderer bitmaps to approximate + * the real widget closely rather than rendering it exactly. + */ +object WidgetPreview { + private fun dp(context: Context, v: Int): Int = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, v.toFloat(), context.resources.displayMetrics, + ).toInt() + + private fun card(context: Context, bgColor: Int): LinearLayout { + val d = { v: Int -> dp(context, v) } + return LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(d(14), d(14), d(14), d(14)) + background = GradientDrawable().apply { + cornerRadius = d(16).toFloat() + setColor(bgColor) + } + } + } + + private fun text(context: Context, str: String, sizeSp: Float, color: Int, bold: Boolean = false): TextView = + TextView(context).apply { + text = str + setTextColor(color) + setTextSize(TypedValue.COMPLEX_UNIT_SP, sizeSp) + if (bold) setTypeface(typeface, Typeface.BOLD) + } + + private fun spacer(context: Context, h: Int): View = + View(context).apply { layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(context, h)) } + + private fun chip(context: Context, label: String, selected: Boolean, dark: Boolean): TextView { + val d = { v: Int -> dp(context, v) } + return text( + context, label, 10f, + if (selected) modeSelectedTextArgb(dark) else modeUnselectedTextArgb(dark), + bold = true, + ).apply { + setPadding(d(8), d(5), d(8), d(5)) + background = GradientDrawable().apply { + cornerRadius = d(9).toFloat() + setColor(if (selected) modeSelectedBackgroundArgb(dark) else modeUnselectedBackgroundArgb(dark)) + } + } + } + + /** Loading/placeholder state shown while the first fetch for a candidate is in flight. */ + fun message(context: Context, title: String, dark: Boolean): View { + val root = card(context, cardBackgroundArgb(dark)) + root.gravity = Gravity.CENTER + root.addView(text(context, title, 12f, textSecondaryArgb(dark)).apply { gravity = Gravity.CENTER }) + return root + } + + fun loadpoint(context: Context, lp: Loadpoint, dark: Boolean): View { + val d = { v: Int -> dp(context, v) } + val primary = textPrimaryArgb(dark) + val secondary = textSecondaryArgb(dark) + val s = status(lp) + val m = metric(lp) + val heating = lp.chargerFeatureHeating + val dotColor = statusColorArgb(s.active, heating, dark) + + val root = card(context, cardBackgroundArgb(dark)) + root.addView(text(context, title(lp), 14f, primary, bold = true)) + + val statusRow = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(0, d(4), 0, 0) + } + statusRow.addView( + View(context).apply { + layoutParams = LinearLayout.LayoutParams(d(7), d(7)) + background = GradientDrawable().apply { shape = GradientDrawable.OVAL; setColor(dotColor) } + }, + ) + statusRow.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(d(5), 1) }) + statusRow.addView(text(context, statusLabel(s, heating), 10f, dotColor, bold = true)) + root.addView(statusRow) + + root.addView(spacer(context, 6)) + + val metricRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.BOTTOM } + metricRow.addView(text(context, m.value, 24f, primary, bold = true)) + metricRow.addView(text(context, " ${m.unit}", 12f, secondary, bold = true)) + root.addView(metricRow) + + if (m.fill != null) { + root.addView(spacer(context, 6)) + root.addView( + ImageView(context).apply { + setImageBitmap( + ProgressBarRenderer.render( + fraction = m.fill, + fillColor = barFillColor(lp.connected, heating), + trackColor = barTrackColor(dark), + striped = s.active, + stripeColor = barStripeColor(heating), + ), + ) + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, d(6)) + }, + ) + } + + root.addView(spacer(context, 6)) + val (powerValue, powerUnit) = splitValueUnit(lp.chargePower?.let { Format.fmtW(it) } ?: "–") + val powerRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + powerRow.addView(text(context, powerValue, 13f, primary, bold = true)) + powerRow.addView(text(context, " $powerUnit", 10f, secondary, bold = true)) + root.addView(powerRow) + + root.addView(spacer(context, 8)) + val chipsRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + modes(lp).forEachIndexed { i, mode -> + if (i > 0) chipsRow.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(d(4), 1) }) + chipsRow.addView(chip(context, MODE_LABELS[mode] ?: mode, mode == lp.mode, dark)) + } + root.addView(chipsRow) + + return root + } + + private fun footerSide(context: Context, side: FooterSide, emphasisColor: Int, secondary: Int): LinearLayout { + val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + if (side.prefix != null) row.addView(text(context, side.prefix, 10f, secondary)) + row.addView(text(context, side.emphasis, 10f, emphasisColor, bold = true)) + if (side.label != null) row.addView(text(context, " ${side.label}", 10f, secondary)) + return row + } + + fun forecast(context: Context, kind: ForecastKind, data: ForecastState.Data, dark: Boolean): View { + val d = { v: Int -> dp(context, v) } + val p = palette(kind) + val headlineArgb = (if (dark) p.accentNight else p.accentDay).toArgb() + val secondary = textSecondaryArgb(dark) + + val root = card(context, cardBackgroundArgb(dark)) + + val headerRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.BOTTOM } + headerRow.addView( + text(context, kind.title, 15f, headlineArgb, bold = true).apply { + layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) + }, + ) + val valueCol = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL; gravity = Gravity.END } + val valueRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + valueRow.addView(text(context, data.value, 15f, headlineArgb, bold = true)) + valueRow.addView(text(context, " ${data.unit}", 10f, headlineArgb, bold = true)) + valueCol.addView(valueRow) + valueCol.addView(text(context, "now", 9f, secondary)) + headerRow.addView(valueCol) + root.addView(headerRow) + + root.addView(spacer(context, 4)) + root.addView( + ImageView(context).apply { + setImageBitmap(data.chart) + scaleType = ImageView.ScaleType.FIT_XY + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, d(52)) + }, + ) + root.addView(spacer(context, 5)) + + val footerRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + footerRow.addView( + footerSide(context, data.footerLeft, headlineArgb, secondary).apply { + layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) + }, + ) + footerRow.addView(footerSide(context, data.footerRight, textPrimaryArgb(dark), secondary)) + root.addView(footerRow) + + return root + } +} From cc85ffe540c0133283db1888195a8a8b03960cda Mon Sep 17 00:00:00 2001 From: Alexandre JARDON <28548335+webalexeu@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:18:43 +0000 Subject: [PATCH 4/8] feat: localize Android widget strings The Android widgets and their config Activities had hardcoded English text. Extend build-widget-strings.mts (already generating the iOS .xcstrings catalog from evcc's + this app's Weblate translations) to also emit Android string resources under targets/android-widget/res/values(-b+)/, and have withAndroidWidget.ts merge them into the prebuilt project's values/strings.xml (which already has app_name etc.) instead of overwriting it. Replaced every hardcoded string in the widget/config-Activity Kotlin with R.string.* lookups. The config Activities' picker/live-preview flow has no iOS equivalent, so those strings are new additions to this app's own i18n (en.json source + de.json, matching this repo's i18n conventions) rather than reuses of existing evcc/app keys. Also generalized the config plugin's package-rewrite from a single `package` line replace to a whole-file regex, since the widget Kotlin now needs `import io.evcc.android.R` to resolve correctly for the LOCAL-ONLY dev fork build too (io.evcc.android.dev). Verified with expo prebuild + local assembleDebug (confirmed the manifest merge keeps app_name, and R resolves for the fork package). --- i18n/de.json | 14 + i18n/en.json | 14 + scripts/androidWidget/withAndroidWidget.ts | 38 +- scripts/build-widget-strings.mts | 113 +- .../android-widget/kotlin/ForecastWidget.kt | 58 +- .../kotlin/ForecastWidgetConfigActivity.kt | 26 +- .../android-widget/kotlin/LoadpointWidget.kt | 62 +- .../kotlin/LoadpointWidgetConfigActivity.kt | 23 +- .../android-widget/kotlin/WidgetPreview.kt | 11 +- .../res/values-b+ar/strings.xml | 54 + .../res/values-b+bs/strings.xml | 54 + .../res/values-b+cs/strings.xml | 54 + .../res/values-b+da/strings.xml | 54 + .../res/values-b+de/strings.xml | 54 + .../res/values-b+el/strings.xml | 54 + .../res/values-b+et/strings.xml | 54 + .../res/values-b+fi/strings.xml | 54 + .../res/values-b+fr/strings.xml | 54 + .../res/values-b+hr/strings.xml | 54 + .../res/values-b+hu/strings.xml | 54 + .../res/values-b+it/strings.xml | 54 + .../res/values-b+ja/strings.xml | 54 + .../res/values-b+lb/strings.xml | 54 + .../res/values-b+lt/strings.xml | 54 + .../res/values-b+nb+NO/strings.xml | 54 + .../res/values-b+nl/strings.xml | 54 + .../res/values-b+pl/strings.xml | 54 + .../res/values-b+pt/strings.xml | 54 + .../res/values-b+sk/strings.xml | 54 + .../res/values-b+sl/strings.xml | 54 + .../res/values-b+sv/strings.xml | 54 + .../res/values-b+ta/strings.xml | 54 + .../res/values-b+tr/strings.xml | 54 + .../res/values-b+uk/strings.xml | 54 + .../res/values-b+zh+Hans/strings.xml | 54 + targets/android-widget/res/values/strings.xml | 54 + targets/widget/Localizable.xcstrings | 2004 +++++++++++++++++ 37 files changed, 3742 insertions(+), 79 deletions(-) create mode 100644 targets/android-widget/res/values-b+ar/strings.xml create mode 100644 targets/android-widget/res/values-b+bs/strings.xml create mode 100644 targets/android-widget/res/values-b+cs/strings.xml create mode 100644 targets/android-widget/res/values-b+da/strings.xml create mode 100644 targets/android-widget/res/values-b+de/strings.xml create mode 100644 targets/android-widget/res/values-b+el/strings.xml create mode 100644 targets/android-widget/res/values-b+et/strings.xml create mode 100644 targets/android-widget/res/values-b+fi/strings.xml create mode 100644 targets/android-widget/res/values-b+fr/strings.xml create mode 100644 targets/android-widget/res/values-b+hr/strings.xml create mode 100644 targets/android-widget/res/values-b+hu/strings.xml create mode 100644 targets/android-widget/res/values-b+it/strings.xml create mode 100644 targets/android-widget/res/values-b+ja/strings.xml create mode 100644 targets/android-widget/res/values-b+lb/strings.xml create mode 100644 targets/android-widget/res/values-b+lt/strings.xml create mode 100644 targets/android-widget/res/values-b+nb+NO/strings.xml create mode 100644 targets/android-widget/res/values-b+nl/strings.xml create mode 100644 targets/android-widget/res/values-b+pl/strings.xml create mode 100644 targets/android-widget/res/values-b+pt/strings.xml create mode 100644 targets/android-widget/res/values-b+sk/strings.xml create mode 100644 targets/android-widget/res/values-b+sl/strings.xml create mode 100644 targets/android-widget/res/values-b+sv/strings.xml create mode 100644 targets/android-widget/res/values-b+ta/strings.xml create mode 100644 targets/android-widget/res/values-b+tr/strings.xml create mode 100644 targets/android-widget/res/values-b+uk/strings.xml create mode 100644 targets/android-widget/res/values-b+zh+Hans/strings.xml create mode 100644 targets/android-widget/res/values/strings.xml diff --git a/i18n/de.json b/i18n/de.json index 2bbb87b..375c41b 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -75,6 +75,20 @@ "server": "Server", "loadpoint": "Ladepunkt" }, + "androidConfig": { + "chooseServer": "Server auswählen", + "chooseLoadpoint": "Ladepunkt auswählen", + "noServers": "Keine Server — füge zuerst einen in der App hinzu", + "noLoadpoints": "Keine Ladepunkte erreichbar", + "loading": "Lädt…", + "loadingPreview": "Vorschau wird geladen…", + "previewError": "Vorschau konnte nicht geladen werden", + "useThisLoadpoint": "Diesen Ladepunkt verwenden", + "useThis": "Diesen verwenden", + "adjustQuestion": "An reale Erzeugung anpassen?", + "yesRecommended": "Ja (empfohlen)", + "no": "Nein" + }, "loadpoint": { "name": "Ladepunkt", "description": "Ladepunkt-Status und Lademodus." diff --git a/i18n/en.json b/i18n/en.json index 98370f3..f577e77 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -75,6 +75,20 @@ "server": "Server", "loadpoint": "Loadpoint" }, + "androidConfig": { + "chooseServer": "Choose server", + "chooseLoadpoint": "Choose loadpoint", + "noServers": "No servers — add one in the app first", + "noLoadpoints": "No loadpoints reachable", + "loading": "Loading…", + "loadingPreview": "Loading preview…", + "previewError": "Couldn't load a preview", + "useThisLoadpoint": "Use this loadpoint", + "useThis": "Use this", + "adjustQuestion": "Adjust to real production?", + "yesRecommended": "Yes (recommended)", + "no": "No" + }, "loadpoint": { "name": "Loadpoint", "description": "Loadpoint status and charge mode." diff --git a/scripts/androidWidget/withAndroidWidget.ts b/scripts/androidWidget/withAndroidWidget.ts index f2c9aea..b6cbeeb 100644 --- a/scripts/androidWidget/withAndroidWidget.ts +++ b/scripts/androidWidget/withAndroidWidget.ts @@ -19,6 +19,9 @@ import path from "path"; const PACKAGE = "io.evcc.android"; const WIDGET_SUBDIR = "widget"; // io.evcc.android.widget const KOTLIN_SRC = "targets/android-widget/kotlin"; +// generated by `npm run widget:strings` (scripts/build-widget-strings.mts) - a +// values/ + values-b+/ tree of strings.xml, copied into res/ verbatim. +const STRINGS_RES_SRC = "targets/android-widget/res"; const GLANCE_VERSION = "1.1.1"; // Compose compiler is versioned with Kotlin (2.0+). Must match the project's @@ -287,17 +290,17 @@ const withWidgetFiles: ConfigPlugin = (config) => // derive the package from config so a fork test build can use a distinct // applicationId (e.g. io.evcc.android.dev) and coexist with the official app. const pkg = config.android?.package ?? PACKAGE; - const widgetPkg = `${pkg}.${WIDGET_SUBDIR}`; - // 1. Kotlin sources → java//widget/, rewriting the package declaration - // from the source's io.evcc.android.widget to match the actual app package. + // 1. Kotlin sources → java//widget/, rewriting every occurrence of the + // placeholder package (not just the `package` line, since files also + // `import io.evcc.android.R` for string resources) to the app's actual + // package - so a fork test build (io.evcc.android.dev) works too. const dest = path.join(main, "java", ...pkg.split("."), WIDGET_SUBDIR); fs.mkdirSync(dest, { recursive: true }); const srcDir = path.join(config.modRequest.projectRoot, KOTLIN_SRC); + const packageRe = new RegExp(PACKAGE.replace(/\./g, "\\."), "g"); for (const f of fs.readdirSync(srcDir).filter((f) => f.endsWith(".kt"))) { - const src = fs - .readFileSync(path.join(srcDir, f), "utf8") - .replace(`package ${PACKAGE}.${WIDGET_SUBDIR}`, `package ${widgetPkg}`); + const src = fs.readFileSync(path.join(srcDir, f), "utf8").replace(packageRe, pkg); fs.writeFileSync(path.join(dest, f), src); } @@ -317,6 +320,29 @@ const withWidgetFiles: ConfigPlugin = (config) => fs.writeFileSync(path.join(drawableDir, "widget_preview.xml"), previewImageVector); fs.writeFileSync(path.join(drawableDir, "widget_preview_loadpoint.xml"), loadpointPreviewImageVector); + // 3. localized strings.xml per locale (generated by `npm run widget:strings`). + // The default values/strings.xml already exists (app_name etc. from the + // Expo prebuild) - merge our entries into it rather than + // overwriting; the locale-qualified dirs don't exist yet, so those are a + // plain copy. + const stringsResDir = path.join(config.modRequest.projectRoot, STRINGS_RES_SRC); + if (fs.existsSync(stringsResDir)) { + for (const localeDir of fs.readdirSync(stringsResDir)) { + const destDir = path.join(main, "res", localeDir); + fs.mkdirSync(destDir, { recursive: true }); + const destFile = path.join(destDir, "strings.xml"); + const widgetXml = fs.readFileSync(path.join(stringsResDir, localeDir, "strings.xml"), "utf8"); + + if (fs.existsSync(destFile)) { + const widgetEntries = widgetXml.match(/^\s*\s*$/gm)?.join("\n") ?? ""; + const existing = fs.readFileSync(destFile, "utf8"); + fs.writeFileSync(destFile, existing.replace("", `${widgetEntries}\n`)); + } else { + fs.writeFileSync(destFile, widgetXml); + } + } + } + return config; }, ]); diff --git a/scripts/build-widget-strings.mts b/scripts/build-widget-strings.mts index 81d19b2..e9f0e6b 100644 --- a/scripts/build-widget-strings.mts +++ b/scripts/build-widget-strings.mts @@ -1,13 +1,16 @@ /** - * Generates targets/widget/Localizable.xcstrings (a String Catalog) for the iOS - * widget extension by compiling strings from TWO community-translated sources: + * Generates targets/widget/Localizable.xcstrings (a String Catalog, for the iOS + * widget extension) and Android string resources under + * targets/android-widget/res/values(-b+)/strings.xml (for the Glance + * widgets + config Activities) by compiling strings from TWO + * community-translated sources: * * - ../evcc/i18n — the evcc daemon's translations (forecast type labels etc.) * - ./i18n — this app's translations (widget-only strings) * - * Both are maintained on Weblate. The .xcstrings is fully autogenerated and - * committed (CI builds the widget without ../evcc present). Re-run after wording - * changes: npm run widget:strings + * Both are maintained on Weblate. Both outputs are fully autogenerated and + * committed (CI builds the widgets without ../evcc present). Re-run after + * wording changes: npm run widget:strings * * Requires the evcc repo checked out next to this one (../evcc). */ @@ -17,6 +20,7 @@ import path from "path"; const APP_I18N = "./i18n"; const EVCC_I18N = "../evcc/i18n"; const OUT = "./targets/widget/Localizable.xcstrings"; +const ANDROID_RES_OUT = "./targets/android-widget/res"; const SOURCE_LANG = "en"; // iOS string key → { repo, dotted source key } @@ -55,6 +59,20 @@ const KEYS: Record = { "widget.config.desc": { repo: "app", key: "widget.config.desc" }, "widget.config.server": { repo: "app", key: "widget.config.server" }, "widget.config.loadpoint": { repo: "app", key: "widget.config.loadpoint" }, + // Android-only: the config Activities' picker/preview flow has no iOS equivalent + // (App Intents don't render a live widget preview during configuration) + "widget.androidConfig.chooseServer": { repo: "app", key: "widget.androidConfig.chooseServer" }, + "widget.androidConfig.chooseLoadpoint": { repo: "app", key: "widget.androidConfig.chooseLoadpoint" }, + "widget.androidConfig.noServers": { repo: "app", key: "widget.androidConfig.noServers" }, + "widget.androidConfig.noLoadpoints": { repo: "app", key: "widget.androidConfig.noLoadpoints" }, + "widget.androidConfig.loading": { repo: "app", key: "widget.androidConfig.loading" }, + "widget.androidConfig.loadingPreview": { repo: "app", key: "widget.androidConfig.loadingPreview" }, + "widget.androidConfig.previewError": { repo: "app", key: "widget.androidConfig.previewError" }, + "widget.androidConfig.useThisLoadpoint": { repo: "app", key: "widget.androidConfig.useThisLoadpoint" }, + "widget.androidConfig.useThis": { repo: "app", key: "widget.androidConfig.useThis" }, + "widget.androidConfig.adjustQuestion": { repo: "app", key: "widget.androidConfig.adjustQuestion" }, + "widget.androidConfig.yesRecommended": { repo: "app", key: "widget.androidConfig.yesRecommended" }, + "widget.androidConfig.no": { repo: "app", key: "widget.androidConfig.no" }, "widget.loadpoint.name": { repo: "app", key: "widget.loadpoint.name" }, "widget.loadpoint.description": { repo: "app", key: "widget.loadpoint.description" }, "widget.name.solar": { repo: "app", key: "widget.name.solar" }, @@ -163,21 +181,21 @@ const locales = fs .map((f) => path.basename(f, ".json")) .sort(); -const strings: Record = {}; +// key → locale → resolved value (source-language fallback already applied) +const translations: Record> = {}; const missing: string[] = []; -for (const [iosKey, src] of Object.entries(KEYS)) { +for (const [key, src] of Object.entries(KEYS)) { const en = lookup(src.repo, src.key, SOURCE_LANG); if (en === undefined) { - missing.push(`${iosKey} (${src.repo}:${src.key})`); + missing.push(`${key} (${src.repo}:${src.key})`); continue; } - const localizations: Record = {}; + const byLocale: Record = {}; for (const locale of locales) { - const value = lookup(src.repo, src.key, locale) ?? en; - localizations[locale] = { stringUnit: { state: "translated", value } }; + byLocale[locale] = lookup(src.repo, src.key, locale) ?? en; } - strings[iosKey] = { extractionState: "manual", localizations }; + translations[key] = byLocale; } for (const [iosKey, frozen] of Object.entries(FROZEN)) { @@ -194,8 +212,79 @@ if (missing.length) { process.exit(1); } +// --- iOS: targets/widget/Localizable.xcstrings --- + +const strings: Record = {}; +for (const [key, byLocale] of Object.entries(translations)) { + const localizations: Record = {}; + for (const [locale, value] of Object.entries(byLocale)) { + localizations[locale] = { stringUnit: { state: "translated", value } }; + } + strings[key] = { extractionState: "manual", localizations }; +} const catalog = { sourceLanguage: SOURCE_LANG, strings, version: "1.0" }; fs.writeFileSync(OUT, JSON.stringify(catalog, null, 2) + "\n"); console.log( `[widget:strings] wrote ${OUT} — ${Object.keys(strings).length} keys × ${locales.length} locales`, ); + +// --- Android: targets/android-widget/res/values*/strings.xml --- + +// "widget.mode.off" -> "widget_mode_off" (Android resource names are identifiers) +function androidName(key: string): string { + return key.replace(/\./g, "_"); +} + +// Android string resources: escape XML entities plus the characters Android's +// own resource parser treats specially (apostrophe, quote, @/? at the start). +function escapeAndroid(value: string): string { + let s = value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/'/g, "\\'") + .replace(/"/g, "\\\""); + if (/^[@?]/.test(s)) s = `\\${s}`; + return s; +} + +// BCP-47 resource qualifier: "de" -> "b+de", "nb-NO" -> "b+nb+NO" (the modern +// `b+` syntax handles arbitrary language/region/script tags without needing to +// map each one to the older `xx-rYY` qualifier form). +function androidLocaleDir(locale: string): string { + return `values-b+${locale.split("-").join("+")}`; +} + +function writeAndroidStrings(dir: string, keys: [string, string][]) { + fs.mkdirSync(dir, { recursive: true }); + const body = keys + .map(([name, value]) => ` ${escapeAndroid(value)}`) + .join("\n"); + const xml = `\n\n${body}\n\n`; + fs.writeFileSync(path.join(dir, "strings.xml"), xml); +} + +// clear previously generated locale dirs so removed locales don't linger +if (fs.existsSync(ANDROID_RES_OUT)) { + for (const entry of fs.readdirSync(ANDROID_RES_OUT)) { + if (entry === "values" || entry.startsWith("values-b+")) { + fs.rmSync(path.join(ANDROID_RES_OUT, entry), { recursive: true, force: true }); + } + } +} + +const keyNames = Object.keys(translations).sort(); +writeAndroidStrings( + path.join(ANDROID_RES_OUT, "values"), + keyNames.map((key) => [androidName(key), translations[key][SOURCE_LANG]]), +); +for (const locale of locales) { + if (locale === SOURCE_LANG) continue; + writeAndroidStrings( + path.join(ANDROID_RES_OUT, androidLocaleDir(locale)), + keyNames.map((key) => [androidName(key), translations[key][locale]]), + ); +} +console.log( + `[widget:strings] wrote ${ANDROID_RES_OUT}/values*/strings.xml — ${keyNames.length} keys × ${locales.length} locales`, +); diff --git a/targets/android-widget/kotlin/ForecastWidget.kt b/targets/android-widget/kotlin/ForecastWidget.kt index 78d88aa..15dbeef 100644 --- a/targets/android-widget/kotlin/ForecastWidget.kt +++ b/targets/android-widget/kotlin/ForecastWidget.kt @@ -25,6 +25,7 @@ import androidx.glance.layout.height import androidx.glance.layout.padding import androidx.glance.text.Text import androidx.glance.unit.ColorProvider +import io.evcc.android.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject @@ -43,8 +44,17 @@ import org.json.JSONObject * reuse them for its live preview without duplicating the parsing logic. */ // Titles mirror evcc's own forecast.type.*/widget.type.* strings (see Configuration.swift). -enum class ForecastKind(val title: String) { - SOLAR("Solar Production"), PRICE("Grid import price"), CO2("CO₂ Emissions"), FEEDIN("Grid export price") +enum class ForecastKind { + SOLAR, PRICE, CO2, FEEDIN; + + fun title(context: Context): String = context.getString( + when (this) { + SOLAR -> R.string.widget_type_solar + PRICE -> R.string.widget_type_price + CO2 -> R.string.widget_type_co2 + FEEDIN -> R.string.widget_type_feedin + }, + ) } data class FooterSide(val prefix: String? = null, val emphasis: String, val label: String? = null) @@ -123,8 +133,14 @@ private fun solar(context: Context, server: StoredServer, adjust: Boolean): Fore value = value, unit = unit, chart = chart(context, ForecastKind.SOLAR, values, times, ChartKind.AREA), - footerLeft = FooterSide(emphasis = Format.fmtWh(today * scale), label = "remaining"), - footerRight = FooterSide(emphasis = Format.fmtWh(tomorrow * scale), label = "Tomorrow"), + footerLeft = FooterSide( + emphasis = Format.fmtWh(today * scale), + label = context.getString(R.string.widget_solar_remaining), + ), + footerRight = FooterSide( + emphasis = Format.fmtWh(tomorrow * scale), + label = context.getString(R.string.widget_solar_tomorrow), + ), ) }.getOrDefault(ForecastState.NoData) } @@ -221,11 +237,11 @@ abstract class ForecastWidget(private val kind: ForecastKind) : GlanceAppWidget( val server = SharedStore.server(context, serverId) ?: return@withContext ForecastState.NotConfigured loadForecastState(context, kind, server, adjust) } - provideContent { Content(state) } + provideContent { Content(context, state) } } @Composable - private fun Content(state: ForecastState) { + private fun Content(context: Context, state: ForecastState) { val notConfigured = state == ForecastState.NotConfigured Column( modifier = GlanceModifier.fillMaxSize() @@ -234,18 +250,24 @@ abstract class ForecastWidget(private val kind: ForecastKind) : GlanceAppWidget( verticalAlignment = Alignment.Vertical.Top, ) { when (state) { - is ForecastState.Data -> DataBody(state) - ForecastState.NoData -> MessageBody("No data", "This server has no data of this type.") - ForecastState.Unreachable -> MessageBody("Server unreachable", "Could not load the evcc instance.") - ForecastState.NotConfigured -> NotConfiguredBody() + is ForecastState.Data -> DataBody(context, state) + ForecastState.NoData -> MessageBody( + context.getString(R.string.widget_noData_title), + context.getString(R.string.widget_noData_body), + ) + ForecastState.Unreachable -> MessageBody( + context.getString(R.string.widget_unreachable_title), + context.getString(R.string.widget_unreachable_body), + ) + ForecastState.NotConfigured -> NotConfiguredBody(context) } } } @Composable - private fun DataBody(state: ForecastState.Data) { + private fun DataBody(context: Context, state: ForecastState.Data) { val p = palette(kind) - Header(p, state.value, state.unit) + Header(context, p, state.value, state.unit) Spacer(GlanceModifier.height(4.dp)) Image( provider = ImageProvider(state.chart), @@ -258,16 +280,16 @@ abstract class ForecastWidget(private val kind: ForecastKind) : GlanceAppWidget( } @Composable - private fun Header(p: Palette, value: String, unit: String) { + private fun Header(context: Context, p: Palette, value: String, unit: String) { Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.Vertical.Bottom) { - Text(kind.title, style = headerHeadlineStyle.copy(color = p.headline)) + Text(kind.title(context), style = headerHeadlineStyle.copy(color = p.headline)) Spacer(GlanceModifier.defaultWeight()) Column(horizontalAlignment = Alignment.Horizontal.End) { Row { Text(value, style = headerHeadlineStyle.copy(color = p.headline)) Text(" $unit", style = headerHeadlineUnitStyle.copy(color = p.headline)) } - Text("now", style = headerSubStyle) + Text(context.getString(R.string.widget_now), style = headerSubStyle) } } } @@ -303,14 +325,14 @@ abstract class ForecastWidget(private val kind: ForecastKind) : GlanceAppWidget( } @Composable - private fun NotConfiguredBody() { + private fun NotConfiguredBody(context: Context) { Column( modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.Vertical.CenterVertically, horizontalAlignment = Alignment.Horizontal.CenterHorizontally, ) { - Text("Set up evcc", style = notConfiguredTitleStyle) - Text("Tap to connect a server and pick a data type.", style = notConfiguredBodyStyle) + Text(context.getString(R.string.widget_setup_title), style = notConfiguredTitleStyle) + Text(context.getString(R.string.widget_setup_body), style = notConfiguredBodyStyle) } } } diff --git a/targets/android-widget/kotlin/ForecastWidgetConfigActivity.kt b/targets/android-widget/kotlin/ForecastWidgetConfigActivity.kt index 99dc143..362aa05 100644 --- a/targets/android-widget/kotlin/ForecastWidgetConfigActivity.kt +++ b/targets/android-widget/kotlin/ForecastWidgetConfigActivity.kt @@ -14,6 +14,7 @@ import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView +import io.evcc.android.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch @@ -92,7 +93,7 @@ class ForecastWidgetConfigActivity : Activity() { addView(container) } confirmButton = TextView(this).apply { - text = "Use this" + text = getString(R.string.widget_androidConfig_useThis) setTextColor(Color.WHITE) setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) setTypeface(typeface, Typeface.BOLD) @@ -145,10 +146,10 @@ class ForecastWidgetConfigActivity : Activity() { // --- flow --- private fun showServers() { - titleView.text = "Choose server" + titleView.text = getString(R.string.widget_androidConfig_chooseServer) val servers = SharedStore.servers(this) when { - servers.isEmpty() -> setRows(listOf("No servers — add one in the app first"), null) + servers.isEmpty() -> setRows(listOf(getString(R.string.widget_androidConfig_noServers)), null) servers.size == 1 -> onServer(servers[0]) else -> setRows(servers.map { it.displayTitle }) { index -> onServer(servers[index]) } } @@ -159,19 +160,30 @@ class ForecastWidgetConfigActivity : Activity() { } private fun showAdjust(server: StoredServer) { - titleView.text = "Adjust to real production?" - setRows(listOf("Yes (recommended)", "No")) { index -> preview(server, adjust = index == 0) } + titleView.text = getString(R.string.widget_androidConfig_adjustQuestion) + setRows( + listOf( + getString(R.string.widget_androidConfig_yesRecommended), + getString(R.string.widget_androidConfig_no), + ), + ) { index -> preview(server, adjust = index == 0) } } /** Fetches live data for the chosen server (+ adjust setting) and shows a preview of the real widget. */ private fun preview(server: StoredServer, adjust: Boolean) { pending = null confirmButton.visibility = View.GONE - showPreview(WidgetPreview.message(this, "Loading preview…", dark)) + showPreview(WidgetPreview.message(this, getString(R.string.widget_androidConfig_loadingPreview), dark)) scope.launch { val state = withContext(Dispatchers.IO) { loadForecastState(this@ForecastWidgetConfigActivity, kind, server, adjust) } if (state !is ForecastState.Data) { - showPreview(WidgetPreview.message(this@ForecastWidgetConfigActivity, "Couldn't load a preview", dark)) + showPreview( + WidgetPreview.message( + this@ForecastWidgetConfigActivity, + getString(R.string.widget_androidConfig_previewError), + dark, + ), + ) return@launch } showPreview(WidgetPreview.forecast(this@ForecastWidgetConfigActivity, kind, state, dark)) diff --git a/targets/android-widget/kotlin/LoadpointWidget.kt b/targets/android-widget/kotlin/LoadpointWidget.kt index 40bc753..2b122f0 100644 --- a/targets/android-widget/kotlin/LoadpointWidget.kt +++ b/targets/android-widget/kotlin/LoadpointWidget.kt @@ -31,6 +31,7 @@ import androidx.glance.layout.height import androidx.glance.layout.padding import androidx.glance.layout.width import androidx.glance.text.Text +import io.evcc.android.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -46,7 +47,13 @@ import kotlinx.coroutines.withContext * for this widget's only size), no reload button, no deep link. */ // visible (not private) so the config activities can reuse them for previews -val MODE_LABELS = mapOf("off" to "Off", "pv" to "Solar", "minpv" to "Min+Solar", "now" to "Fast") +fun modeLabel(context: Context, mode: String): String = when (mode) { + "off" -> context.getString(R.string.widget_mode_off) + "pv" -> context.getString(R.string.widget_mode_pv) + "minpv" -> context.getString(R.string.widget_mode_minpv) + "now" -> context.getString(R.string.widget_mode_now) + else -> mode +} enum class LpStatus(val active: Boolean) { DISCONNECTED(false), CONNECTED(false), WAIT_FOR_VEHICLE(false), FINISHED(false), CHARGING(true), HEATING(true), @@ -75,15 +82,18 @@ fun status(lp: Loadpoint): LpStatus { } // mirrors LoadpointStatus.labelKey(heating:) resolved against evcc's own -// main.vehicleStatus.* / main.heatingStatus.* English strings -fun statusLabel(s: LpStatus, heating: Boolean): String = when (s) { - LpStatus.DISCONNECTED -> "Disconnected." - LpStatus.CONNECTED -> if (heating) "Standby." else "Connected." - LpStatus.WAIT_FOR_VEHICLE -> if (heating) "Ready to heat…" else "Ready. Waiting for vehicle…" - LpStatus.FINISHED -> "Finished." - LpStatus.CHARGING -> "Charging…" - LpStatus.HEATING -> "Heating…" -} +// main.vehicleStatus.* / main.heatingStatus.* translations +fun statusLabel(context: Context, s: LpStatus, heating: Boolean): String = context.getString( + when (s) { + LpStatus.DISCONNECTED -> R.string.widget_lpstatus_disconnected + LpStatus.CONNECTED -> if (heating) R.string.widget_lpheat_connected else R.string.widget_lpstatus_connected + LpStatus.WAIT_FOR_VEHICLE -> + if (heating) R.string.widget_lpheat_waitForVehicle else R.string.widget_lpstatus_waitForVehicle + LpStatus.FINISHED -> R.string.widget_lpstatus_finished + LpStatus.CHARGING -> R.string.widget_lpstatus_charging + LpStatus.HEATING -> R.string.widget_lpheat_charging + }, +) // mirrors LoadpointVM.build's metricValue/metricUnit/fill derivation fun metric(lp: Loadpoint): Metric { @@ -104,9 +114,9 @@ fun metric(lp: Loadpoint): Metric { } } -fun title(lp: Loadpoint): String { +fun title(context: Context, lp: Loadpoint): String { val vt = lp.vehicleTitle?.trim().orEmpty() - return vt.ifEmpty { lp.title ?: "Loadpoint" } + return vt.ifEmpty { lp.title ?: context.getString(R.string.widget_loadpoint_name) } } fun modes(lp: Loadpoint): List = @@ -149,9 +159,15 @@ class LoadpointWidget : GlanceAppWidget() { ) { when (state) { is LoadpointState.Data -> LoadpointBody(context, state) - LoadpointState.NoData -> MessageBody("No data", "This server has no data of this type.") - LoadpointState.Unreachable -> MessageBody("Server unreachable", "Could not load the evcc instance.") - LoadpointState.NotConfigured -> NotConfiguredBody() + LoadpointState.NoData -> MessageBody( + context.getString(R.string.widget_noData_title), + context.getString(R.string.widget_noData_body), + ) + LoadpointState.Unreachable -> MessageBody( + context.getString(R.string.widget_unreachable_title), + context.getString(R.string.widget_unreachable_body), + ) + LoadpointState.NotConfigured -> NotConfiguredBody(context) } } } @@ -163,12 +179,12 @@ class LoadpointWidget : GlanceAppWidget() { val m = metric(lp) val heating = lp.chargerFeatureHeating - Text(title(lp), style = titleStyle) + Text(title(context, lp), style = titleStyle) Row(modifier = GlanceModifier.padding(top = 3.dp), verticalAlignment = Alignment.Vertical.CenterVertically) { Box(modifier = GlanceModifier.width(7.dp).height(7.dp).background(statusColor(s.active, heating)).cornerRadius(4.dp)) {} Spacer(GlanceModifier.width(5.dp)) - Text(statusLabel(s, heating), style = statusStyle.copy(color = statusColor(s.active, heating))) + Text(statusLabel(context, s, heating), style = statusStyle.copy(color = statusColor(s.active, heating))) } Spacer(GlanceModifier.height(6.dp)) @@ -210,13 +226,13 @@ class LoadpointWidget : GlanceAppWidget() { Row { modes(lp).forEachIndexed { i, mode -> if (i > 0) Spacer(GlanceModifier.width(4.dp)) - ModeChip(mode = mode, current = lp.mode, serverId = state.serverId, lpIndex = state.lpIndex) + ModeChip(context, mode = mode, current = lp.mode, serverId = state.serverId, lpIndex = state.lpIndex) } } } @Composable - private fun ModeChip(mode: String, current: String?, serverId: String, lpIndex: Int) { + private fun ModeChip(context: Context, mode: String, current: String?, serverId: String, lpIndex: Int) { val selected = mode == current Box( modifier = GlanceModifier @@ -234,7 +250,7 @@ class LoadpointWidget : GlanceAppWidget() { ), ) { Text( - text = MODE_LABELS[mode] ?: mode, + text = modeLabel(context, mode), style = modeChipStyle.copy(color = if (selected) modeSelectedText else modeUnselectedText), ) } @@ -253,14 +269,14 @@ class LoadpointWidget : GlanceAppWidget() { } @Composable - private fun NotConfiguredBody() { + private fun NotConfiguredBody(context: Context) { Column( modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.Vertical.CenterVertically, horizontalAlignment = Alignment.Horizontal.CenterHorizontally, ) { - Text("Set up evcc", style = notConfiguredTitleStyle) - Text("Tap to connect a server and pick a data type.", style = notConfiguredBodyStyle) + Text(context.getString(R.string.widget_setup_title), style = notConfiguredTitleStyle) + Text(context.getString(R.string.widget_setup_body), style = notConfiguredBodyStyle) } } } diff --git a/targets/android-widget/kotlin/LoadpointWidgetConfigActivity.kt b/targets/android-widget/kotlin/LoadpointWidgetConfigActivity.kt index b821639..6764fd6 100644 --- a/targets/android-widget/kotlin/LoadpointWidgetConfigActivity.kt +++ b/targets/android-widget/kotlin/LoadpointWidgetConfigActivity.kt @@ -14,6 +14,7 @@ import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView import androidx.glance.appwidget.GlanceAppWidgetManager +import io.evcc.android.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch @@ -88,7 +89,7 @@ class LoadpointWidgetConfigActivity : Activity() { addView(container) } confirmButton = TextView(this).apply { - text = "Use this loadpoint" + text = getString(R.string.widget_androidConfig_useThisLoadpoint) setTextColor(Color.WHITE) setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) setTypeface(typeface, Typeface.BOLD) @@ -144,11 +145,11 @@ class LoadpointWidgetConfigActivity : Activity() { } private fun showServers() { - titleView.text = "Choose server" + titleView.text = getString(R.string.widget_androidConfig_chooseServer) val servers = SharedStore.servers(this) when { servers.isEmpty() -> - setRows(listOf("No servers — add one in the app first"), null) + setRows(listOf(getString(R.string.widget_androidConfig_noServers)), null) // only one server: nothing to choose, go straight to its loadpoints servers.size == 1 -> showLoadpoints(servers[0]) else -> setRows(servers.map { it.displayTitle }) { index -> showLoadpoints(servers[index]) } @@ -156,12 +157,12 @@ class LoadpointWidgetConfigActivity : Activity() { } private fun showLoadpoints(server: StoredServer) { - titleView.text = "Choose loadpoint" - setRows(listOf("Loading…"), null) + titleView.text = getString(R.string.widget_androidConfig_chooseLoadpoint) + setRows(listOf(getString(R.string.widget_androidConfig_loading)), null) scope.launch { val titles = withContext(Dispatchers.IO) { ApiClient.loadpointTitles(server) } if (titles.isEmpty()) { - setRows(listOf("No loadpoints reachable"), null) + setRows(listOf(getString(R.string.widget_androidConfig_noLoadpoints)), null) return@launch } setRows(titles) { index -> preview(server, index) } @@ -172,13 +173,19 @@ class LoadpointWidgetConfigActivity : Activity() { private fun preview(server: StoredServer, lpIndex: Int) { pending = null confirmButton.visibility = View.GONE - showPreview(WidgetPreview.message(this, "Loading preview…", dark)) + showPreview(WidgetPreview.message(this, getString(R.string.widget_androidConfig_loadingPreview), dark)) scope.launch { val lp = withContext(Dispatchers.IO) { (ApiClient.fetch(server, ".loadpoints[$lpIndex]") as? FetchOutcome.Success)?.json?.let { Loadpoint.parse(it) } } if (lp == null) { - showPreview(WidgetPreview.message(this@LoadpointWidgetConfigActivity, "Couldn't load a preview", dark)) + showPreview( + WidgetPreview.message( + this@LoadpointWidgetConfigActivity, + getString(R.string.widget_androidConfig_previewError), + dark, + ), + ) return@launch } showPreview(WidgetPreview.loadpoint(this@LoadpointWidgetConfigActivity, lp, dark)) diff --git a/targets/android-widget/kotlin/WidgetPreview.kt b/targets/android-widget/kotlin/WidgetPreview.kt index 5bec5a4..11827c4 100644 --- a/targets/android-widget/kotlin/WidgetPreview.kt +++ b/targets/android-widget/kotlin/WidgetPreview.kt @@ -12,6 +12,7 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import androidx.compose.ui.graphics.toArgb +import io.evcc.android.R /** * Builds a plain-Views mock of the Loadpoint/Forecast widgets for the config @@ -82,7 +83,7 @@ object WidgetPreview { val dotColor = statusColorArgb(s.active, heating, dark) val root = card(context, cardBackgroundArgb(dark)) - root.addView(text(context, title(lp), 14f, primary, bold = true)) + root.addView(text(context, title(context, lp), 14f, primary, bold = true)) val statusRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL @@ -96,7 +97,7 @@ object WidgetPreview { }, ) statusRow.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(d(5), 1) }) - statusRow.addView(text(context, statusLabel(s, heating), 10f, dotColor, bold = true)) + statusRow.addView(text(context, statusLabel(context, s, heating), 10f, dotColor, bold = true)) root.addView(statusRow) root.addView(spacer(context, 6)) @@ -135,7 +136,7 @@ object WidgetPreview { val chipsRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } modes(lp).forEachIndexed { i, mode -> if (i > 0) chipsRow.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(d(4), 1) }) - chipsRow.addView(chip(context, MODE_LABELS[mode] ?: mode, mode == lp.mode, dark)) + chipsRow.addView(chip(context, modeLabel(context, mode), mode == lp.mode, dark)) } root.addView(chipsRow) @@ -160,7 +161,7 @@ object WidgetPreview { val headerRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.BOTTOM } headerRow.addView( - text(context, kind.title, 15f, headlineArgb, bold = true).apply { + text(context, kind.title(context), 15f, headlineArgb, bold = true).apply { layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) }, ) @@ -169,7 +170,7 @@ object WidgetPreview { valueRow.addView(text(context, data.value, 15f, headlineArgb, bold = true)) valueRow.addView(text(context, " ${data.unit}", 10f, headlineArgb, bold = true)) valueCol.addView(valueRow) - valueCol.addView(text(context, "now", 9f, secondary)) + valueCol.addView(text(context, context.getString(R.string.widget_now), 9f, secondary)) headerRow.addView(valueCol) root.addView(headerRow) diff --git a/targets/android-widget/res/values-b+ar/strings.xml b/targets/android-widget/res/values-b+ar/strings.xml new file mode 100644 index 0000000..8d9581f --- /dev/null +++ b/targets/android-widget/res/values-b+ar/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Heating… + Standby. + Ready to heat… + Charging… + Connected. + Disconnected. + Finished. + Ready. Waiting for vehicle… + Min+Solar + Fast + Off + Solar + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + remaining + Tomorrow + CO₂ Emissions + Grid export price + Grid import price + Solar Production + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+bs/strings.xml b/targets/android-widget/res/values-b+bs/strings.xml new file mode 100644 index 0000000..8d9581f --- /dev/null +++ b/targets/android-widget/res/values-b+bs/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Heating… + Standby. + Ready to heat… + Charging… + Connected. + Disconnected. + Finished. + Ready. Waiting for vehicle… + Min+Solar + Fast + Off + Solar + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + remaining + Tomorrow + CO₂ Emissions + Grid export price + Grid import price + Solar Production + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+cs/strings.xml b/targets/android-widget/res/values-b+cs/strings.xml new file mode 100644 index 0000000..d50401a --- /dev/null +++ b/targets/android-widget/res/values-b+cs/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Ohřívání… + Pohotovostní režim. + Připraven k vytápění… + Nabíjení… + Připojeno. + Odpojeno. + Dokončeno. + Připraveno. Čekám na vozidlo… + Min+Solar + Rychlé + Vypnuto + Solár + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + zbývající + Zítra + CO₂ emise + Grid export price + Grid import price + Solární výroba + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+da/strings.xml b/targets/android-widget/res/values-b+da/strings.xml new file mode 100644 index 0000000..7ba75b2 --- /dev/null +++ b/targets/android-widget/res/values-b+da/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Opvarmer… + Standby. + Klar til at varme… + Oplader… + Forbundet. + Afbrudt. + Færdig. + Parat. Venter på køretøj… + Min+Sol + Hurtig + Fra + Sol + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + resterende + I morgen + CO₂ Emissioner + Grid export price + Grid import price + Solenergiproduktion + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+de/strings.xml b/targets/android-widget/res/values-b+de/strings.xml new file mode 100644 index 0000000..a790ea6 --- /dev/null +++ b/targets/android-widget/res/values-b+de/strings.xml @@ -0,0 +1,54 @@ + + + An reale Erzeugung anpassen? + Ladepunkt auswählen + Server auswählen + Lädt… + Vorschau wird geladen… + Nein + Keine Ladepunkte erreichbar + Keine Server — füge zuerst einen in der App hinzu + Vorschau konnte nicht geladen werden + Diesen verwenden + Diesen Ladepunkt verwenden + Ja (empfohlen) + Server der Vorhersage. + Ladepunkt + Server + evcc Vorhersage + Vorhergesagte CO₂-Emissionen. + Vorhergesagte Einspeisevergütung. + Vorhergesagter Netzbezugspreis. + Vorhergesagte Solarproduktion. + Ladepunkt-Status und Lademodus. + Ladepunkt + Heize … + Standby. + Bereit zum Heizen … + Ladevorgang aktiv … + Verbunden. + Nicht verbunden. + Abgeschlossen. + Ladebereit. Warte auf Fahrzeug … + Min+PV + Schnell + Aus + PV + CO₂ + Einspeisevergütung + Netzbezugspreis + Solarprognose + Dieser Server liefert keine Daten dieses Typs. + Keine Daten + jetzt + Tippen, um Server zu verbinden und einen Datentyp zu wählen. + evcc einrichten + verbleibend + Morgen + CO₂-Emissionen + Einspeisevergütung + Netzbezugspreis + Solarproduktion + evcc-Instanz konnte nicht geladen werden. + Server nicht erreichbar + diff --git a/targets/android-widget/res/values-b+el/strings.xml b/targets/android-widget/res/values-b+el/strings.xml new file mode 100644 index 0000000..8e41511 --- /dev/null +++ b/targets/android-widget/res/values-b+el/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Θερμαίνεται… + Αναμονή. + Έτοιμο. Αναμονή για θερμαντήρα… + Φορτίζει… + Συνδέθηκε. + Αποσυνδεδεμένο. + Τελείωσε. + Έτοιμο. Αναμονή για όχημα… + Ελαχ+Φ/Β + Ταχύ + Κλειστό + Φ/Β + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + Απομένει + Αύριο + CO₂ + Grid export price + Grid import price + Ηλιακή + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+et/strings.xml b/targets/android-widget/res/values-b+et/strings.xml new file mode 100644 index 0000000..8d9581f --- /dev/null +++ b/targets/android-widget/res/values-b+et/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Heating… + Standby. + Ready to heat… + Charging… + Connected. + Disconnected. + Finished. + Ready. Waiting for vehicle… + Min+Solar + Fast + Off + Solar + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + remaining + Tomorrow + CO₂ Emissions + Grid export price + Grid import price + Solar Production + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+fi/strings.xml b/targets/android-widget/res/values-b+fi/strings.xml new file mode 100644 index 0000000..8d21c8e --- /dev/null +++ b/targets/android-widget/res/values-b+fi/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Lämmitys… + Valmiustila. + Lämmitystä aloitetaan… + Lataa… + Yhdistetty. + Irroitettu. + Valmis. + Valmiina. Odotetaan ajoneuvoa… + Min+PV + Välitön + Seis + PV + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + jäljellä + Huomenna + CO₂-päästöt + Grid export price + Grid import price + Aurinkotuotanto + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+fr/strings.xml b/targets/android-widget/res/values-b+fr/strings.xml new file mode 100644 index 0000000..9356640 --- /dev/null +++ b/targets/android-widget/res/values-b+fr/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Chauffage… + En attente. + Prêt à chauffer… + En charge… + Connecté. + Déconnecté. + Terminée. + Prêt. Attente du véhicule… + Min+Solaire + Rapide + Arrêté + Solaire + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + restant + Demain + Émissions de CO₂ + Grid export price + Grid import price + Production solaire + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+hr/strings.xml b/targets/android-widget/res/values-b+hr/strings.xml new file mode 100644 index 0000000..9458beb --- /dev/null +++ b/targets/android-widget/res/values-b+hr/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Grijanje… + Mirovanje. + Spremno. Čeka se grijač… + Punjenje… + Povezano. + Nepovezano. + Završeno. + Spremno. Čeka se vozilo … + Min+Solarno + Brzo + Isključeno + Solarno + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + preostalo + Sutra + CO₂ + Grid export price + Grid import price + Solarno + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+hu/strings.xml b/targets/android-widget/res/values-b+hu/strings.xml new file mode 100644 index 0000000..47afde3 --- /dev/null +++ b/targets/android-widget/res/values-b+hu/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Fűtés… + Készenlét. + Üzemkész. Fűtésre várakozás… + Töltés… + Csatlakoztatva. + Lecsatlakoztatva. + Befejezve. + Üzemkész. Járműre várakozás… + Min+Szolár + Gyors + Ki + Szolár + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + hátralévő + Holnap + CO₂ + Grid export price + Grid import price + Szolár + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+it/strings.xml b/targets/android-widget/res/values-b+it/strings.xml new file mode 100644 index 0000000..f0d457a --- /dev/null +++ b/targets/android-widget/res/values-b+it/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Scaldando… + In attesa. + Pronto. In attesa del calorifero… + In carica… + Collegato. + Disconnesso. + Finito. + Pronto. In attesa del veicolo… + Min+Solare + Veloce + Off + Solare + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + rimanenti + Domani + Emissioni CO₂ + Grid export price + Grid import price + Produzione solare + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+ja/strings.xml b/targets/android-widget/res/values-b+ja/strings.xml new file mode 100644 index 0000000..0d29d5a --- /dev/null +++ b/targets/android-widget/res/values-b+ja/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + 加熱中… + 待機中。 + 加熱開始を待機中… + 充電中… + 接続済み。 + 切断済み。 + 充電完了。 + 準備完了。車両の応答を待っています… + Min+太陽光 + 高速 + オフ + 太陽光 + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + 残り + 明日 + CO₂排出量 + Grid export price + Grid import price + 太陽光発電量 + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+lb/strings.xml b/targets/android-widget/res/values-b+lb/strings.xml new file mode 100644 index 0000000..063cfa7 --- /dev/null +++ b/targets/android-widget/res/values-b+lb/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Wiermen… + Standby. + Prett fir ze Heizen… + Luet… + Verbonne. + Deconnectéiert. + Ofgeschloss. + Prett. Waarden op d\'Gefier… + Min+PV + Schnell + Aus + PV + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + verbleiwend + Muer + CO₂-Emissiounen + Grid export price + Grid import price + Solar-Productioun + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+lt/strings.xml b/targets/android-widget/res/values-b+lt/strings.xml new file mode 100644 index 0000000..69ce886 --- /dev/null +++ b/targets/android-widget/res/values-b+lt/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Šildoma… + Budėjimo režimas. + Paruošta šildyti… + Įkraunama… + Prijungtas. + Neprijungtas. + Baigta. + Paruošta. Laukiama automobilio… + Min+Saulė + Greitas + Stop + Saulė + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + likę + Rytoj + CO₂ Išmetimai + Grid export price + Grid import price + Saulės Gamyba + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+nb+NO/strings.xml b/targets/android-widget/res/values-b+nb+NO/strings.xml new file mode 100644 index 0000000..8d9581f --- /dev/null +++ b/targets/android-widget/res/values-b+nb+NO/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Heating… + Standby. + Ready to heat… + Charging… + Connected. + Disconnected. + Finished. + Ready. Waiting for vehicle… + Min+Solar + Fast + Off + Solar + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + remaining + Tomorrow + CO₂ Emissions + Grid export price + Grid import price + Solar Production + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+nl/strings.xml b/targets/android-widget/res/values-b+nl/strings.xml new file mode 100644 index 0000000..0e25531 --- /dev/null +++ b/targets/android-widget/res/values-b+nl/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Bezig met verwarmen… + Stand-by. + Klaar om te verwarmen… + Opladen… + Verbonden. + Niet verbonden. + Beëindigd. + Klaar. Wachten op voertuig… + Min+PV + Snel + Uit + PV + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + resterend + Morgen + CO₂ Uitstoot + Grid export price + Grid import price + Zonne-energie opbrengst + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+pl/strings.xml b/targets/android-widget/res/values-b+pl/strings.xml new file mode 100644 index 0000000..e6c2571 --- /dev/null +++ b/targets/android-widget/res/values-b+pl/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Grzeje… + Oczekuje. + Gotowe do grzania… + Ładuje się… + Połączony. + Rozłączony. + Zakończone. + Gotowe. Czekam na pojazd… + Min+Słońce + Szybko + Stop + Słońce + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + pozostało + Jutro + CO₂ + Grid export price + Grid import price + Słońce + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+pt/strings.xml b/targets/android-widget/res/values-b+pt/strings.xml new file mode 100644 index 0000000..f895e56 --- /dev/null +++ b/targets/android-widget/res/values-b+pt/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + A aquecer… + Standby. + Pronto para aquecer… + A carregar… + Ligado. + Desligado. + Concluído. + Pronto. A aguardar veículo… + Min+Solar + Rápido + Off + Solar + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + restante + Amanhã + Emissões de CO₂ + Grid export price + Grid import price + Produção solar + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+sk/strings.xml b/targets/android-widget/res/values-b+sk/strings.xml new file mode 100644 index 0000000..38f6b87 --- /dev/null +++ b/targets/android-widget/res/values-b+sk/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Vyhrievanie… + Pohotovostný režim. + Pripravený na ohrev… + Nabíja sa… + Pripojené. + Odpojené. + Dokončené. + Pripravené. Čakám na vozidlo… + Min+Solár + Rýchlo + Vypnuté + Solár + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + zostáva + Zajtra + CO₂ Emisie + Grid export price + Grid import price + Solárna výroba + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+sl/strings.xml b/targets/android-widget/res/values-b+sl/strings.xml new file mode 100644 index 0000000..b1f3289 --- /dev/null +++ b/targets/android-widget/res/values-b+sl/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Gretje… + V stanju pripravljenosti. + Pripravljen. Čakam na grelec… + Polnjenje… + Povezan. + Odklopljen. + Končano. + Pripravljen. Čakam na vozilo… + Min+Sonce + Hitro + Izklop + Sonce + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + preostalo + Jutri + CO₂ + Grid export price + Grid import price + Sončna energija + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+sv/strings.xml b/targets/android-widget/res/values-b+sv/strings.xml new file mode 100644 index 0000000..90d2aa7 --- /dev/null +++ b/targets/android-widget/res/values-b+sv/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Värmer… + Standby. + Redo att värma… + Laddar… + Inkopplad. + Frånkopplad. + Färdig. + Redo. Väntar på fordon… + Min+Sol + Snabbt + Av + Sol + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + återstående + i morgon + CO₂-utsläpp + Grid export price + Grid import price + Solenergi produktion + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+ta/strings.xml b/targets/android-widget/res/values-b+ta/strings.xml new file mode 100644 index 0000000..19d89e4 --- /dev/null +++ b/targets/android-widget/res/values-b+ta/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + வெப்பமாக்கல்… + காத்திருப்பு. + சூடாக்க தயார்… + சார்சிங்… + இணைக்கப்பட்டுள்ளது. + துண்டிக்கப்பட்டது. + முடிந்தது. + ஆயத்தம். வாகனத்திற்காக காத்திருக்கிறது… + குறை+ஞாயிறு + வேகமாக + அணை + ஞாயிறு + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + மீதமுள்ள + நாளை + CO₂ உமிழ்வுகள் + Grid export price + Grid import price + சூரிய விளைவாக்கம் + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+tr/strings.xml b/targets/android-widget/res/values-b+tr/strings.xml new file mode 100644 index 0000000..f097b31 --- /dev/null +++ b/targets/android-widget/res/values-b+tr/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Isıtılıyor… + Beklemede. + Isıtmaya hazır… + doluyor… + Bağlı. + Bağlantı kesildi. + Tamamlandı. + Doldurmaya hazır. Araç bekleniyor… + Asg.+GES + Hızlı + Kapalı + GES + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + “kalan” + “Yarın” + CO₂ salımları + Grid export price + Grid import price + Güneş Enerjisi Üretimi + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+uk/strings.xml b/targets/android-widget/res/values-b+uk/strings.xml new file mode 100644 index 0000000..0c3fdf0 --- /dev/null +++ b/targets/android-widget/res/values-b+uk/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Опалення… + Режим очікування. + Готовий. Очікування обігрівача… + Зарядка… + Підключено. + Відключено. + Готово. + Готовий. Очікування на транспортний засіб… + Мін+Сонце + Швидко + Вимк. + Сонячна + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + залишилося + Завтра + CO₂ + Grid export price + Grid import price + Сонячна + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values-b+zh+Hans/strings.xml b/targets/android-widget/res/values-b+zh+Hans/strings.xml new file mode 100644 index 0000000..ac6adc9 --- /dev/null +++ b/targets/android-widget/res/values-b+zh+Hans/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + 加热中… + 待机。 + 准备就绪。等待加热器启动… + 充电中… + 已连接。 + 已断开连接。 + 已完成。 + 准备就绪。等待车辆连接… + 最少+太阳能 + 快速 + 关闭 + 太阳能 + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + 剩余 + 明天 + CO₂ + Grid export price + Grid import price + 太阳能 + Could not load the evcc instance. + Server unreachable + diff --git a/targets/android-widget/res/values/strings.xml b/targets/android-widget/res/values/strings.xml new file mode 100644 index 0000000..8d9581f --- /dev/null +++ b/targets/android-widget/res/values/strings.xml @@ -0,0 +1,54 @@ + + + Adjust to real production? + Choose loadpoint + Choose server + Loading… + Loading preview… + No + No loadpoints reachable + No servers — add one in the app first + Couldn\'t load a preview + Use this + Use this loadpoint + Yes (recommended) + Server for the forecast. + Loadpoint + Server + evcc Forecast + Forecast CO₂ emissions. + Forecast grid export price. + Forecast grid import price. + Forecast solar production. + Loadpoint status and charge mode. + Loadpoint + Heating… + Standby. + Ready to heat… + Charging… + Connected. + Disconnected. + Finished. + Ready. Waiting for vehicle… + Min+Solar + Fast + Off + Solar + CO₂ + Grid export price + Grid import price + Solar forecast + This server has no data of this type. + No data + now + Tap to connect a server and pick a data type. + Set up evcc + remaining + Tomorrow + CO₂ Emissions + Grid export price + Grid import price + Solar Production + Could not load the evcc instance. + Server unreachable + diff --git a/targets/widget/Localizable.xcstrings b/targets/widget/Localizable.xcstrings index 1fa0e9d..cde4fc5 100644 --- a/targets/widget/Localizable.xcstrings +++ b/targets/widget/Localizable.xcstrings @@ -5178,6 +5178,2010 @@ } } }, + "widget.androidConfig.chooseServer": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Server auswählen" + } + }, + "el": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "hr": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "lb": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "lt": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "nb-NO": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "sk": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "sl": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Choose server" + } + } + } + }, + "widget.androidConfig.chooseLoadpoint": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladepunkt auswählen" + } + }, + "el": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "hr": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "lb": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "lt": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "nb-NO": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "sk": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "sl": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Choose loadpoint" + } + } + } + }, + "widget.androidConfig.noServers": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Server — füge zuerst einen in der App hinzu" + } + }, + "el": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "hr": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "lb": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "lt": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "nb-NO": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "sk": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "sl": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "No servers — add one in the app first" + } + } + } + }, + "widget.androidConfig.noLoadpoints": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Ladepunkte erreichbar" + } + }, + "el": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "hr": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "lb": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "lt": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "nb-NO": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "sk": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "sl": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "No loadpoints reachable" + } + } + } + }, + "widget.androidConfig.loading": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lädt…" + } + }, + "el": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "hr": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "lb": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "lt": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "nb-NO": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "sk": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "sl": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + } + } + }, + "widget.androidConfig.loadingPreview": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschau wird geladen…" + } + }, + "el": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "hr": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "lb": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "lt": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "nb-NO": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "sk": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "sl": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Loading preview…" + } + } + } + }, + "widget.androidConfig.previewError": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschau konnte nicht geladen werden" + } + }, + "el": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "hr": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "lb": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "lt": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "nb-NO": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "sk": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "sl": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load a preview" + } + } + } + }, + "widget.androidConfig.useThisLoadpoint": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diesen Ladepunkt verwenden" + } + }, + "el": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "hr": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "lb": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "lt": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "nb-NO": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "sk": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "sl": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Use this loadpoint" + } + } + } + }, + "widget.androidConfig.useThis": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diesen verwenden" + } + }, + "el": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "hr": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "lb": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "lt": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "nb-NO": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "sk": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "sl": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Use this" + } + } + } + }, + "widget.androidConfig.adjustQuestion": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "An reale Erzeugung anpassen?" + } + }, + "el": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "hr": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "lb": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "lt": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "nb-NO": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "sk": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "sl": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Adjust to real production?" + } + } + } + }, + "widget.androidConfig.yesRecommended": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ja (empfohlen)" + } + }, + "el": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "hr": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "lb": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "lt": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "nb-NO": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "sk": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "sl": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Yes (recommended)" + } + } + } + }, + "widget.androidConfig.no": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nein" + } + }, + "el": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "hr": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "lb": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "lt": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "nb-NO": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "nl": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "sk": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "sl": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "No" + } + } + } + }, "widget.loadpoint.name": { "extractionState": "manual", "localizations": { From 3a58d31c6d3cca33adde457c4c99d08408a27695 Mon Sep 17 00:00:00 2001 From: Alexandre JARDON <28548335+webalexeu@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:32:39 +0000 Subject: [PATCH 5/8] feat: Loadpoint widget size variant (systemMedium-style mode selector) Adds the last open follow-up from the parity work: a second widget size, mirroring LoadpointCard's HStack { left; modeSelector } on iOS. Declares SizeMode.Responsive(setOf(SMALL_SIZE, WIDE_SIZE)) and reads LocalSize to branch layout - compact keeps the inline mode-chip row (deliberately kept interactive, unlike iOS's compact size which drops to a plain-text mode label), wide adds a vertical mode-selector column alongside. No manifest change needed since the widget was already resizable; this just makes the wider size render differently once a user resizes it. Forecast widgets have no iOS size-variant precedent, so they stay single-size. Verified with expo prebuild + local assembleDebug. --- targets/android-widget/README.md | 30 +++++-- .../android-widget/kotlin/LoadpointWidget.kt | 81 ++++++++++++++++--- 2 files changed, 93 insertions(+), 18 deletions(-) diff --git a/targets/android-widget/README.md b/targets/android-widget/README.md index 1748bd1..d2e9b52 100644 --- a/targets/android-widget/README.md +++ b/targets/android-widget/README.md @@ -46,9 +46,8 @@ Done: step-vs-area chart modes + per-type color in `ChartRenderer.kt` (previously always a flat green area line regardless of data type), bold/colored footer stats, and light/dark card backgrounds throughout. Deliberately not ported: - size variants (`systemMedium`'s mode-selector column - the mode chips are - always shown inline instead), the reload button, deep links, and Swift - Charts' `.monotone` spline smoothing (straight line segments instead). + the reload button, deep links, and Swift Charts' `.monotone` spline + smoothing (straight line segments instead). - **Live preview when configuring**: both config Activities now fetch real data for the tapped server/loadpoint/toggle and render an actual preview of the widget (`WidgetPreview.kt`) before committing via a new "Use this" @@ -58,12 +57,27 @@ Done: content in a classic-Views Activity needs the full Compose UI stack plus an unpublished/experimental Google API - see the "Live preview" discussion this was scoped from for the trade-off. +- **Localization**: `scripts/build-widget-strings.mts` now also generates + Android string resources (`res/values(-b+)/strings.xml`) alongside + the iOS `.xcstrings` catalog, from the same evcc-daemon + this-app Weblate + translations. Every widget/config-Activity string reads from `R.string.*` + now - none are hardcoded. The config Activities' picker/live-preview flow + has no iOS equivalent, so those strings are new additions to this app's own + `i18n/en.json`/`de.json` (`widget.androidConfig.*`) rather than reuses. +- **Size variants**: `LoadpointWidget` now declares + `SizeMode.Responsive(setOf(SMALL_SIZE, WIDE_SIZE))` and reads `LocalSize` + to branch layout - compact stays the inline mode-chip row below the metric + (deliberately kept interactive, unlike iOS's compact size which drops to a + plain-text mode label instead of buttons), wide adds a vertical + mode-selector column alongside, mirroring `LoadpointCard`'s + `HStack { left; modeSelector }`. No manifest change needed - the widget was + already resizable (`resizeMode="horizontal|vertical"`); this just makes the + wider layout actually render something different once resized. The forecast + widgets don't have an iOS size-variant precedent, so they stay single-size. -Not done yet (follow-ups for parity with iOS): - -- Localization (`.xcstrings` → Android string resources) — widget text is - currently hardcoded English in the Kotlin. -- Size variants (see above). +Not done yet (follow-ups for parity with iOS): none currently tracked - +remaining gaps (reload button, deep links, spline chart smoothing) are +documented as deliberate simplifications above, not open TODOs. ## Build / test diff --git a/targets/android-widget/kotlin/LoadpointWidget.kt b/targets/android-widget/kotlin/LoadpointWidget.kt index 2b122f0..ff3cb03 100644 --- a/targets/android-widget/kotlin/LoadpointWidget.kt +++ b/targets/android-widget/kotlin/LoadpointWidget.kt @@ -2,6 +2,7 @@ package io.evcc.android.widget import android.content.Context import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.glance.GlanceId import androidx.glance.GlanceModifier @@ -15,6 +16,8 @@ import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.LocalSize +import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.updateAll @@ -23,8 +26,10 @@ import androidx.glance.color.isNightMode import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.Column +import androidx.glance.layout.ColumnScope import androidx.glance.layout.Row import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.height @@ -41,11 +46,20 @@ import kotlinx.coroutines.withContext * loadpoint; per-instance configuration (server + loadpoint picker) is set by * LoadpointWidgetConfigActivity. * - * Deliberate simplifications vs. iOS: single compact layout (no systemMedium - * mode-selector column - the mode chips are always shown inline instead, which - * keeps the interactive mode-switching that a medium-only selector would drop - * for this widget's only size), no reload button, no deep link. + * Two sizes, like iOS's systemSmall/systemMedium: compact shows the mode chips + * inline below the metric (unlike iOS's compact layout, which only shows the + * current mode as text - keeping the chips interactive here instead of + * dropping mode-switching entirely for the smallest size); wide shows a + * vertical mode-selector column alongside, mirroring LoadpointCard's + * `HStack { left; modeSelector }`. Deliberate simplifications vs. iOS: no + * reload button, no deep link. */ +private val SMALL_SIZE = DpSize(180.dp, 110.dp) +private val WIDE_SIZE = DpSize(340.dp, 110.dp) + +// Row measurements are unreliable right at a boundary on some launchers, so +// the switch-over threshold sits well below WIDE_SIZE's width. +private val WIDE_THRESHOLD = 260.dp // visible (not private) so the config activities can reuse them for previews fun modeLabel(context: Context, mode: String): String = when (mode) { "off" -> context.getString(R.string.widget_mode_off) @@ -123,6 +137,8 @@ fun modes(lp: Loadpoint): List = if (lp.chargerFeatureSwitchDevice) listOf("off", "pv", "now") else listOf("off", "pv", "minpv", "now") class LoadpointWidget : GlanceAppWidget() { + override val sizeMode = SizeMode.Responsive(setOf(SMALL_SIZE, WIDE_SIZE)) + override suspend fun provideGlance(context: Context, id: GlanceId) { // per-instance config (server + loadpoint) written by LoadpointWidgetConfigActivity, // keyed by the appWidgetId this glanceId maps to. @@ -174,6 +190,27 @@ class LoadpointWidget : GlanceAppWidget() { @Composable private fun LoadpointBody(context: Context, state: LoadpointState.Data) { + val wide = LocalSize.current.width >= WIDE_THRESHOLD + if (wide) { + Row(modifier = GlanceModifier.fillMaxSize()) { + Column(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) { + LoadpointInfo(context, state) + } + Spacer(GlanceModifier.width(14.dp)) + Column(modifier = GlanceModifier.width(116.dp).fillMaxHeight()) { + ModeSelectorColumn(context, state) + } + } + } else { + LoadpointInfo(context, state) + Spacer(GlanceModifier.height(8.dp)) + ModeChipsRow(context, state) + } + } + + // main status/metric/power column, shared by both sizes (mirrors LoadpointCard's `left`) + @Composable + private fun LoadpointInfo(context: Context, state: LoadpointState.Data) { val lp = state.lp val s = status(lp) val m = metric(lp) @@ -220,22 +257,45 @@ class LoadpointWidget : GlanceAppWidget() { Text(powerValue, style = powerStyle) Text(" $powerUnit", style = powerUnitStyle) } + } - Spacer(GlanceModifier.height(8.dp)) - + // compact size: chips in a row below the metric (see class doc for why this + // stays interactive here, unlike iOS's compact-size plain-text mode label) + @Composable + private fun ModeChipsRow(context: Context, state: LoadpointState.Data) { Row { - modes(lp).forEachIndexed { i, mode -> + modes(state.lp).forEachIndexed { i, mode -> if (i > 0) Spacer(GlanceModifier.width(4.dp)) - ModeChip(context, mode = mode, current = lp.mode, serverId = state.serverId, lpIndex = state.lpIndex) + ModeChip(context, mode = mode, current = state.lp.mode, serverId = state.serverId, lpIndex = state.lpIndex) } } } + // wide size: a vertical column of full-width chips (mirrors modeSelector in LoadpointViews.swift). + // A ColumnScope extension (not just called within one) so defaultWeight() resolves below. + @Composable + private fun ColumnScope.ModeSelectorColumn(context: Context, state: LoadpointState.Data) { + modes(state.lp).forEachIndexed { i, mode -> + if (i > 0) Spacer(GlanceModifier.height(6.dp)) + ModeChip( + context, mode = mode, current = state.lp.mode, serverId = state.serverId, lpIndex = state.lpIndex, + modifier = GlanceModifier.fillMaxWidth().defaultWeight(), + ) + } + } + @Composable - private fun ModeChip(context: Context, mode: String, current: String?, serverId: String, lpIndex: Int) { + private fun ModeChip( + context: Context, + mode: String, + current: String?, + serverId: String, + lpIndex: Int, + modifier: GlanceModifier = GlanceModifier, + ) { val selected = mode == current Box( - modifier = GlanceModifier + modifier = modifier .background(if (selected) modeSelectedBackground else modeUnselectedBackground) .cornerRadius(9.dp) .padding(horizontal = 8.dp, vertical = 5.dp) @@ -248,6 +308,7 @@ class LoadpointWidget : GlanceAppWidget() { ), ), ), + contentAlignment = Alignment.Center, ) { Text( text = modeLabel(context, mode), From 223ae7600d02f1b983955db6a7769fa72a543713 Mon Sep 17 00:00:00 2001 From: Alexandre JARDON <28548335+webalexeu@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:53:47 +0000 Subject: [PATCH 6/8] feat: Android widget support for smart mode redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pairs with evcc-io/evcc#32490 and mirrors targets/widget/Loadpoint.swift's handling from evcc-io/app#246, which this branch picked up via a rebase onto main. Detects smart-mode servers via loadpoints[].alwaysCharge, switching the selector to off/smart/now (with device-class labels for continuous heat pumps and switchable devices) while old servers keep off/pv/minpv/now unchanged. A read-only "∞" marks the Smart chip when Always charge is on/once, no toggle in the widget yet, matching iOS. Also fixes a bug the rebase's auto-merge introduced: the frozen pv/minpv legacy-label loop wrote straight into `strings`, a variable that in this branch is now built later from an intermediate `translations` map (added here to share resolved strings between the iOS and Android generators) - it was referencing `strings` before its declaration. --- scripts/build-widget-strings.mts | 9 ++- targets/android-widget/README.md | 9 +++ targets/android-widget/kotlin/ApiClient.kt | 4 ++ .../android-widget/kotlin/LoadpointWidget.kt | 56 ++++++++++++++----- .../android-widget/kotlin/WidgetPreview.kt | 2 +- .../res/values-b+ar/strings.xml | 4 ++ .../res/values-b+bs/strings.xml | 4 ++ .../res/values-b+cs/strings.xml | 4 ++ .../res/values-b+da/strings.xml | 4 ++ .../res/values-b+de/strings.xml | 4 ++ .../res/values-b+el/strings.xml | 4 ++ .../res/values-b+et/strings.xml | 4 ++ .../res/values-b+fi/strings.xml | 4 ++ .../res/values-b+fr/strings.xml | 4 ++ .../res/values-b+hr/strings.xml | 4 ++ .../res/values-b+hu/strings.xml | 4 ++ .../res/values-b+it/strings.xml | 4 ++ .../res/values-b+ja/strings.xml | 4 ++ .../res/values-b+lb/strings.xml | 4 ++ .../res/values-b+lt/strings.xml | 4 ++ .../res/values-b+nb+NO/strings.xml | 4 ++ .../res/values-b+nl/strings.xml | 4 ++ .../res/values-b+pl/strings.xml | 4 ++ .../res/values-b+pt/strings.xml | 4 ++ .../res/values-b+sk/strings.xml | 4 ++ .../res/values-b+sl/strings.xml | 4 ++ .../res/values-b+sv/strings.xml | 4 ++ .../res/values-b+ta/strings.xml | 4 ++ .../res/values-b+tr/strings.xml | 4 ++ .../res/values-b+uk/strings.xml | 4 ++ .../res/values-b+zh+Hans/strings.xml | 4 ++ targets/android-widget/res/values/strings.xml | 4 ++ 32 files changed, 168 insertions(+), 20 deletions(-) diff --git a/scripts/build-widget-strings.mts b/scripts/build-widget-strings.mts index e9f0e6b..c6b7b9e 100644 --- a/scripts/build-widget-strings.mts +++ b/scripts/build-widget-strings.mts @@ -198,13 +198,12 @@ for (const [key, src] of Object.entries(KEYS)) { translations[key] = byLocale; } -for (const [iosKey, frozen] of Object.entries(FROZEN)) { - const localizations: Record = {}; +for (const [key, frozen] of Object.entries(FROZEN)) { + const byLocale: Record = {}; for (const locale of locales) { - const value = frozen[locale] ?? frozen[locale.split("-")[0]] ?? frozen[SOURCE_LANG]; - localizations[locale] = { stringUnit: { state: "translated", value } }; + byLocale[locale] = frozen[locale] ?? frozen[locale.split("-")[0]] ?? frozen[SOURCE_LANG]; } - strings[iosKey] = { extractionState: "manual", localizations }; + translations[key] = byLocale; } if (missing.length) { diff --git a/targets/android-widget/README.md b/targets/android-widget/README.md index d2e9b52..ff3065e 100644 --- a/targets/android-widget/README.md +++ b/targets/android-widget/README.md @@ -74,6 +74,15 @@ Done: already resizable (`resizeMode="horizontal|vertical"`); this just makes the wider layout actually render something different once resized. The forecast widgets don't have an iOS size-variant precedent, so they stay single-size. +- **Smart mode redesign** (mirrors iOS's `Loadpoint.swift`/#246): `Loadpoint` + gained `alwaysCharge`/`chargerFeatureContinuous`. Its presence detects + smart-mode servers (`off/smart/now`, with per-device-class labels - e.g. + continuous heat pumps get Normal/Boost, switchable devices get On) vs. old + servers (`off/pv/minpv/now`, unchanged). `modeChipLabel()` appends a + read-only "∞" to the Smart chip when Always charge is on/once - no toggle in + the widget, matching iOS. `widget.mode.pv`/`widget.mode.minpv` stay in the + strings script as frozen (non-Weblate) translations since evcc removed them + from its own i18n once the redesign shipped. Not done yet (follow-ups for parity with iOS): none currently tracked - remaining gaps (reload button, deep links, spline chart smoothing) are diff --git a/targets/android-widget/kotlin/ApiClient.kt b/targets/android-widget/kotlin/ApiClient.kt index 7daafed..39f6934 100644 --- a/targets/android-widget/kotlin/ApiClient.kt +++ b/targets/android-widget/kotlin/ApiClient.kt @@ -100,11 +100,13 @@ data class Loadpoint( val sessionEnergy: Double?, val chargedEnergy: Double?, val mode: String?, + val alwaysCharge: String?, val charging: Boolean, val connected: Boolean, val enabled: Boolean, val chargerFeatureHeating: Boolean, val chargerFeatureSwitchDevice: Boolean, + val chargerFeatureContinuous: Boolean, val ui: LoadpointUi?, ) { companion object { @@ -124,11 +126,13 @@ data class Loadpoint( sessionEnergy = d("sessionEnergy"), chargedEnergy = d("chargedEnergy"), mode = o.optString("mode").takeIf { it.isNotEmpty() }, + alwaysCharge = if (o.has("alwaysCharge") && !o.isNull("alwaysCharge")) o.optString("alwaysCharge") else null, charging = o.optBoolean("charging", false), connected = o.optBoolean("connected", false), enabled = o.optBoolean("enabled", false), chargerFeatureHeating = o.optBoolean("chargerFeatureHeating", false), chargerFeatureSwitchDevice = o.optBoolean("chargerFeatureSwitchDevice", false), + chargerFeatureContinuous = o.optBoolean("chargerFeatureContinuous", false), ui = ui, ) }.getOrNull() diff --git a/targets/android-widget/kotlin/LoadpointWidget.kt b/targets/android-widget/kotlin/LoadpointWidget.kt index ff3cb03..89b0440 100644 --- a/targets/android-widget/kotlin/LoadpointWidget.kt +++ b/targets/android-widget/kotlin/LoadpointWidget.kt @@ -60,13 +60,38 @@ private val WIDE_SIZE = DpSize(340.dp, 110.dp) // Row measurements are unreliable right at a boundary on some launchers, so // the switch-over threshold sits well below WIDE_SIZE's width. private val WIDE_THRESHOLD = 260.dp -// visible (not private) so the config activities can reuse them for previews -fun modeLabel(context: Context, mode: String): String = when (mode) { - "off" -> context.getString(R.string.widget_mode_off) - "pv" -> context.getString(R.string.widget_mode_pv) - "minpv" -> context.getString(R.string.widget_mode_minpv) - "now" -> context.getString(R.string.widget_mode_now) - else -> mode +// visible (not private) so the config activities can reuse them for previews. +// mirrors Loadpoint.swift's `item()` closure: on smart-mode servers (detected +// by the presence of `alwaysCharge`) the same raw mode can carry a +// device-class-specific label (off->Normal, now->Boost/On) for continuous +// heat pumps / switchable devices. +fun modeLabel(context: Context, lp: Loadpoint, mode: String): String { + val smartModeServer = lp.alwaysCharge != null + if (smartModeServer) { + if (mode == "off" && lp.chargerFeatureContinuous) return context.getString(R.string.widget_mode_normal) + if (mode == "now") { + if (lp.chargerFeatureContinuous) return context.getString(R.string.widget_mode_boost) + if (lp.chargerFeatureSwitchDevice) return context.getString(R.string.widget_mode_on) + } + } + return when (mode) { + "off" -> context.getString(R.string.widget_mode_off) + "smart" -> context.getString(R.string.widget_mode_smart) + "pv" -> context.getString(R.string.widget_mode_pv) + "minpv" -> context.getString(R.string.widget_mode_minpv) + "now" -> context.getString(R.string.widget_mode_now) + else -> mode + } +} + +fun alwaysChargeActive(lp: Loadpoint): Boolean = lp.alwaysCharge == "on" || lp.alwaysCharge == "once" + +// label plus a read-only "∞" marker on the Smart chip when Always charge is +// on/once (mirrors the SF Symbol "infinity" shown next to Smart in +// LoadpointViews.swift; no toggle in the widget for now, matches iOS) +fun modeChipLabel(context: Context, lp: Loadpoint, mode: String): String { + val label = modeLabel(context, lp, mode) + return if (mode == "smart" && alwaysChargeActive(lp)) "$label ∞" else label } enum class LpStatus(val active: Boolean) { @@ -133,8 +158,11 @@ fun title(context: Context, lp: Loadpoint): String { return vt.ifEmpty { lp.title ?: context.getString(R.string.widget_loadpoint_name) } } -fun modes(lp: Loadpoint): List = - if (lp.chargerFeatureSwitchDevice) listOf("off", "pv", "now") else listOf("off", "pv", "minpv", "now") +fun modes(lp: Loadpoint): List = when { + lp.alwaysCharge != null -> listOf("off", "smart", "now") + lp.chargerFeatureSwitchDevice -> listOf("off", "pv", "now") + else -> listOf("off", "pv", "minpv", "now") +} class LoadpointWidget : GlanceAppWidget() { override val sizeMode = SizeMode.Responsive(setOf(SMALL_SIZE, WIDE_SIZE)) @@ -266,7 +294,7 @@ class LoadpointWidget : GlanceAppWidget() { Row { modes(state.lp).forEachIndexed { i, mode -> if (i > 0) Spacer(GlanceModifier.width(4.dp)) - ModeChip(context, mode = mode, current = state.lp.mode, serverId = state.serverId, lpIndex = state.lpIndex) + ModeChip(context, lp = state.lp, mode = mode, serverId = state.serverId, lpIndex = state.lpIndex) } } } @@ -278,7 +306,7 @@ class LoadpointWidget : GlanceAppWidget() { modes(state.lp).forEachIndexed { i, mode -> if (i > 0) Spacer(GlanceModifier.height(6.dp)) ModeChip( - context, mode = mode, current = state.lp.mode, serverId = state.serverId, lpIndex = state.lpIndex, + context, lp = state.lp, mode = mode, serverId = state.serverId, lpIndex = state.lpIndex, modifier = GlanceModifier.fillMaxWidth().defaultWeight(), ) } @@ -287,13 +315,13 @@ class LoadpointWidget : GlanceAppWidget() { @Composable private fun ModeChip( context: Context, + lp: Loadpoint, mode: String, - current: String?, serverId: String, lpIndex: Int, modifier: GlanceModifier = GlanceModifier, ) { - val selected = mode == current + val selected = mode == lp.mode Box( modifier = modifier .background(if (selected) modeSelectedBackground else modeUnselectedBackground) @@ -311,7 +339,7 @@ class LoadpointWidget : GlanceAppWidget() { contentAlignment = Alignment.Center, ) { Text( - text = modeLabel(context, mode), + text = modeChipLabel(context, lp, mode), style = modeChipStyle.copy(color = if (selected) modeSelectedText else modeUnselectedText), ) } diff --git a/targets/android-widget/kotlin/WidgetPreview.kt b/targets/android-widget/kotlin/WidgetPreview.kt index 11827c4..daeaaef 100644 --- a/targets/android-widget/kotlin/WidgetPreview.kt +++ b/targets/android-widget/kotlin/WidgetPreview.kt @@ -136,7 +136,7 @@ object WidgetPreview { val chipsRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } modes(lp).forEachIndexed { i, mode -> if (i > 0) chipsRow.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(d(4), 1) }) - chipsRow.addView(chip(context, modeLabel(context, mode), mode == lp.mode, dark)) + chipsRow.addView(chip(context, modeChipLabel(context, lp, mode), mode == lp.mode, dark)) } root.addView(chipsRow) diff --git a/targets/android-widget/res/values-b+ar/strings.xml b/targets/android-widget/res/values-b+ar/strings.xml index 8d9581f..b40c17d 100644 --- a/targets/android-widget/res/values-b+ar/strings.xml +++ b/targets/android-widget/res/values-b+ar/strings.xml @@ -30,10 +30,14 @@ Disconnected. Finished. Ready. Waiting for vehicle… + Boost Min+Solar + Normal Fast Off + On Solar + Smart CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+bs/strings.xml b/targets/android-widget/res/values-b+bs/strings.xml index 8d9581f..b40c17d 100644 --- a/targets/android-widget/res/values-b+bs/strings.xml +++ b/targets/android-widget/res/values-b+bs/strings.xml @@ -30,10 +30,14 @@ Disconnected. Finished. Ready. Waiting for vehicle… + Boost Min+Solar + Normal Fast Off + On Solar + Smart CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+cs/strings.xml b/targets/android-widget/res/values-b+cs/strings.xml index d50401a..ef84d58 100644 --- a/targets/android-widget/res/values-b+cs/strings.xml +++ b/targets/android-widget/res/values-b+cs/strings.xml @@ -30,10 +30,14 @@ Odpojeno. Dokončeno. Připraveno. Čekám na vozidlo… + Boost Min+Solar + Normal Rychlé Vypnuto + On Solár + Chytrý CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+da/strings.xml b/targets/android-widget/res/values-b+da/strings.xml index 7ba75b2..ada8e9b 100644 --- a/targets/android-widget/res/values-b+da/strings.xml +++ b/targets/android-widget/res/values-b+da/strings.xml @@ -30,10 +30,14 @@ Afbrudt. Færdig. Parat. Venter på køretøj… + Boost Min+Sol + Normal Hurtig Fra + On Sol + Smart CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+de/strings.xml b/targets/android-widget/res/values-b+de/strings.xml index a790ea6..1754981 100644 --- a/targets/android-widget/res/values-b+de/strings.xml +++ b/targets/android-widget/res/values-b+de/strings.xml @@ -30,10 +30,14 @@ Nicht verbunden. Abgeschlossen. Ladebereit. Warte auf Fahrzeug … + Boost Min+PV + Normal Schnell Aus + An PV + Smart CO₂ Einspeisevergütung Netzbezugspreis diff --git a/targets/android-widget/res/values-b+el/strings.xml b/targets/android-widget/res/values-b+el/strings.xml index 8e41511..4e58e78 100644 --- a/targets/android-widget/res/values-b+el/strings.xml +++ b/targets/android-widget/res/values-b+el/strings.xml @@ -30,10 +30,14 @@ Αποσυνδεδεμένο. Τελείωσε. Έτοιμο. Αναμονή για όχημα… + Boost Ελαχ+Φ/Β + Normal Ταχύ Κλειστό + On Φ/Β + Έξυπνο CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+et/strings.xml b/targets/android-widget/res/values-b+et/strings.xml index 8d9581f..b40c17d 100644 --- a/targets/android-widget/res/values-b+et/strings.xml +++ b/targets/android-widget/res/values-b+et/strings.xml @@ -30,10 +30,14 @@ Disconnected. Finished. Ready. Waiting for vehicle… + Boost Min+Solar + Normal Fast Off + On Solar + Smart CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+fi/strings.xml b/targets/android-widget/res/values-b+fi/strings.xml index 8d21c8e..76f49e1 100644 --- a/targets/android-widget/res/values-b+fi/strings.xml +++ b/targets/android-widget/res/values-b+fi/strings.xml @@ -30,10 +30,14 @@ Irroitettu. Valmis. Valmiina. Odotetaan ajoneuvoa… + Boost Min+PV + Normal Välitön Seis + On PV + Älykäs CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+fr/strings.xml b/targets/android-widget/res/values-b+fr/strings.xml index 9356640..35c2066 100644 --- a/targets/android-widget/res/values-b+fr/strings.xml +++ b/targets/android-widget/res/values-b+fr/strings.xml @@ -30,10 +30,14 @@ Déconnecté. Terminée. Prêt. Attente du véhicule… + Boost Min+Solaire + Normal Rapide Arrêté + On Solaire + Intelligent CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+hr/strings.xml b/targets/android-widget/res/values-b+hr/strings.xml index 9458beb..2fb9a59 100644 --- a/targets/android-widget/res/values-b+hr/strings.xml +++ b/targets/android-widget/res/values-b+hr/strings.xml @@ -30,10 +30,14 @@ Nepovezano. Završeno. Spremno. Čeka se vozilo … + Boost Min+Solarno + Normal Brzo Isključeno + On Solarno + Pametno CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+hu/strings.xml b/targets/android-widget/res/values-b+hu/strings.xml index 47afde3..d1c2701 100644 --- a/targets/android-widget/res/values-b+hu/strings.xml +++ b/targets/android-widget/res/values-b+hu/strings.xml @@ -30,10 +30,14 @@ Lecsatlakoztatva. Befejezve. Üzemkész. Járműre várakozás… + Boost Min+Szolár + Normal Gyors Ki + On Szolár + Okos CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+it/strings.xml b/targets/android-widget/res/values-b+it/strings.xml index f0d457a..767ac4c 100644 --- a/targets/android-widget/res/values-b+it/strings.xml +++ b/targets/android-widget/res/values-b+it/strings.xml @@ -30,10 +30,14 @@ Disconnesso. Finito. Pronto. In attesa del veicolo… + Boost Min+Solare + Normal Veloce Off + On Solare + Intelligente CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+ja/strings.xml b/targets/android-widget/res/values-b+ja/strings.xml index 0d29d5a..30ef7c2 100644 --- a/targets/android-widget/res/values-b+ja/strings.xml +++ b/targets/android-widget/res/values-b+ja/strings.xml @@ -30,10 +30,14 @@ 切断済み。 充電完了。 準備完了。車両の応答を待っています… + Boost Min+太陽光 + Normal 高速 オフ + On 太陽光 + スマート CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+lb/strings.xml b/targets/android-widget/res/values-b+lb/strings.xml index 063cfa7..0755c7b 100644 --- a/targets/android-widget/res/values-b+lb/strings.xml +++ b/targets/android-widget/res/values-b+lb/strings.xml @@ -30,10 +30,14 @@ Deconnectéiert. Ofgeschloss. Prett. Waarden op d\'Gefier… + Boost Min+PV + Normal Schnell Aus + On PV + Clever CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+lt/strings.xml b/targets/android-widget/res/values-b+lt/strings.xml index 69ce886..63037ef 100644 --- a/targets/android-widget/res/values-b+lt/strings.xml +++ b/targets/android-widget/res/values-b+lt/strings.xml @@ -30,10 +30,14 @@ Neprijungtas. Baigta. Paruošta. Laukiama automobilio… + Boost Min+Saulė + Normal Greitas Stop + On Saulė + Išmanus CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+nb+NO/strings.xml b/targets/android-widget/res/values-b+nb+NO/strings.xml index 8d9581f..b40c17d 100644 --- a/targets/android-widget/res/values-b+nb+NO/strings.xml +++ b/targets/android-widget/res/values-b+nb+NO/strings.xml @@ -30,10 +30,14 @@ Disconnected. Finished. Ready. Waiting for vehicle… + Boost Min+Solar + Normal Fast Off + On Solar + Smart CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+nl/strings.xml b/targets/android-widget/res/values-b+nl/strings.xml index 0e25531..c4eddd5 100644 --- a/targets/android-widget/res/values-b+nl/strings.xml +++ b/targets/android-widget/res/values-b+nl/strings.xml @@ -30,10 +30,14 @@ Niet verbonden. Beëindigd. Klaar. Wachten op voertuig… + Boost Min+PV + Normal Snel Uit + On PV + Slim CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+pl/strings.xml b/targets/android-widget/res/values-b+pl/strings.xml index e6c2571..bf635da 100644 --- a/targets/android-widget/res/values-b+pl/strings.xml +++ b/targets/android-widget/res/values-b+pl/strings.xml @@ -30,10 +30,14 @@ Rozłączony. Zakończone. Gotowe. Czekam na pojazd… + Boost Min+Słońce + Normal Szybko Stop + On Słońce + Inteligentny CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+pt/strings.xml b/targets/android-widget/res/values-b+pt/strings.xml index f895e56..f700ab5 100644 --- a/targets/android-widget/res/values-b+pt/strings.xml +++ b/targets/android-widget/res/values-b+pt/strings.xml @@ -30,10 +30,14 @@ Desligado. Concluído. Pronto. A aguardar veículo… + Boost Min+Solar + Normal Rápido Off + On Solar + Smart CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+sk/strings.xml b/targets/android-widget/res/values-b+sk/strings.xml index 38f6b87..bacab77 100644 --- a/targets/android-widget/res/values-b+sk/strings.xml +++ b/targets/android-widget/res/values-b+sk/strings.xml @@ -30,10 +30,14 @@ Odpojené. Dokončené. Pripravené. Čakám na vozidlo… + Boost Min+Solár + Normal Rýchlo Vypnuté + On Solár + Inteligentné CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+sl/strings.xml b/targets/android-widget/res/values-b+sl/strings.xml index b1f3289..6a90a40 100644 --- a/targets/android-widget/res/values-b+sl/strings.xml +++ b/targets/android-widget/res/values-b+sl/strings.xml @@ -30,10 +30,14 @@ Odklopljen. Končano. Pripravljen. Čakam na vozilo… + Boost Min+Sonce + Normal Hitro Izklop + On Sonce + Pametno CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+sv/strings.xml b/targets/android-widget/res/values-b+sv/strings.xml index 90d2aa7..dfc4f10 100644 --- a/targets/android-widget/res/values-b+sv/strings.xml +++ b/targets/android-widget/res/values-b+sv/strings.xml @@ -30,10 +30,14 @@ Frånkopplad. Färdig. Redo. Väntar på fordon… + Boost Min+Sol + Normal Snabbt Av + On Sol + Smart CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+ta/strings.xml b/targets/android-widget/res/values-b+ta/strings.xml index 19d89e4..1cb1b8c 100644 --- a/targets/android-widget/res/values-b+ta/strings.xml +++ b/targets/android-widget/res/values-b+ta/strings.xml @@ -30,10 +30,14 @@ துண்டிக்கப்பட்டது. முடிந்தது. ஆயத்தம். வாகனத்திற்காக காத்திருக்கிறது… + Boost குறை+ஞாயிறு + Normal வேகமாக அணை + On ஞாயிறு + அறிவாளி CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+tr/strings.xml b/targets/android-widget/res/values-b+tr/strings.xml index f097b31..1fa096d 100644 --- a/targets/android-widget/res/values-b+tr/strings.xml +++ b/targets/android-widget/res/values-b+tr/strings.xml @@ -30,10 +30,14 @@ Bağlantı kesildi. Tamamlandı. Doldurmaya hazır. Araç bekleniyor… + Boost Asg.+GES + Normal Hızlı Kapalı + On GES + Akıllı CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+uk/strings.xml b/targets/android-widget/res/values-b+uk/strings.xml index 0c3fdf0..49291f4 100644 --- a/targets/android-widget/res/values-b+uk/strings.xml +++ b/targets/android-widget/res/values-b+uk/strings.xml @@ -30,10 +30,14 @@ Відключено. Готово. Готовий. Очікування на транспортний засіб… + Boost Мін+Сонце + Normal Швидко Вимк. + On Сонячна + Розумний CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values-b+zh+Hans/strings.xml b/targets/android-widget/res/values-b+zh+Hans/strings.xml index ac6adc9..dbb7e71 100644 --- a/targets/android-widget/res/values-b+zh+Hans/strings.xml +++ b/targets/android-widget/res/values-b+zh+Hans/strings.xml @@ -30,10 +30,14 @@ 已断开连接。 已完成。 准备就绪。等待车辆连接… + Boost 最少+太阳能 + Normal 快速 关闭 + On 太阳能 + 智能 CO₂ Grid export price Grid import price diff --git a/targets/android-widget/res/values/strings.xml b/targets/android-widget/res/values/strings.xml index 8d9581f..b40c17d 100644 --- a/targets/android-widget/res/values/strings.xml +++ b/targets/android-widget/res/values/strings.xml @@ -30,10 +30,14 @@ Disconnected. Finished. Ready. Waiting for vehicle… + Boost Min+Solar + Normal Fast Off + On Solar + Smart CO₂ Grid export price Grid import price From c4a1ee9a5855ee921d391f3a9dcc30457f119dff Mon Sep 17 00:00:00 2001 From: Alexandre JARDON <28548335+webalexeu@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:35:42 +0000 Subject: [PATCH 7/8] feat: Android widget reload button, deep links, fixed resize bounds Addresses evcc-io/app#255#issuecomment-5317470240 (Maschga's PR review): - LoadpointWidget gets a reload button in the title row (res/drawable/ic_reload.xml tinted via Glance's ColorFilter.tint(), wired to a new ReloadAction), mirroring iOS's ReloadIntent. - Both widget families are now tappable end-to-end, deep-linking to the right app screen (evcc://loadpoint?server=&lp=, evcc://forecast?server=, evcc://server when unconfigured) via a shared deepLinkAction() helper - mirrors widgetURL in LoadpointViews.swift/Views.swift, including the same query param semantics the app's own router (AppContext.tsx) expects. - loadpoint_widget_info.xml's resize bounds are now pinned to SizeMode.Responsive's two declared breakpoints (180-340dp wide, height locked at 110dp, resizeMode="horizontal" only) instead of open-ended horizontal|vertical; forecast_widget_info.xml drops resizeMode entirely since those widgets have no size-variant layout. Closes the gap where a launcher could hand the widget a real container bigger than any size Glance was told to lay content out for, leaving unfillable blank space - the likely cause of the "strange spacing" in Maschga's screenshot, pending on-device confirmation. The mode-button highlight bug and widget-reconfigure check from the same review are held for a live-device pass. --- scripts/androidWidget/withAndroidWidget.ts | 31 +++++++++++-- targets/android-widget/README.md | 25 +++++++++++ .../android-widget/kotlin/ForecastWidget.kt | 9 +++- .../android-widget/kotlin/LoadpointWidget.kt | 45 ++++++++++++++++--- 4 files changed, 100 insertions(+), 10 deletions(-) diff --git a/scripts/androidWidget/withAndroidWidget.ts b/scripts/androidWidget/withAndroidWidget.ts index b6cbeeb..df41040 100644 --- a/scripts/androidWidget/withAndroidWidget.ts +++ b/scripts/androidWidget/withAndroidWidget.ts @@ -150,14 +150,24 @@ const withWidgetReceiver: ConfigPlugin = (config) => return config; }); +// Resize bounds pinned to LoadpointWidget.kt's two SizeMode.Responsive +// breakpoints (SMALL_SIZE 180x110 / WIDE_SIZE 340x110dp) - height is fixed +// since only width varies between them. Letting the launcher grant an +// intermediate/taller size than either breakpoint leaves the Glance content +// (sized for whichever breakpoint LocalSize resolves to) stranded inside a +// bigger real container than it was laid out for. const widgetInfoXml = (pkg: string) => ` `; +// Reload button icon (LoadpointWidget.kt only, mirrors iOS's "arrow.clockwise" +// SF Symbol next to the title). Standard Material "refresh" glyph, tinted at +// runtime via Glance's ColorFilter.tint() so it follows day/night like the +// rest of the widget's text. +const reloadIconVector = ` + + + +`; + // Loadpoint preview image: a card with title / status / power lines and a row // of mode "pills" (one highlighted), so it reads as a loadpoint, not a chart. const loadpointPreviewImageVector = ` @@ -211,7 +234,9 @@ const loadpointPreviewImageVector = ` `; -// Forecast widgets: medium size, no configuration Activity. +// Forecast widgets: one fixed size, no size-variant layout (unlike Loadpoint) - +// no resizeMode, so the launcher can't grow the frame past the content and +// leave blank space below the footer. const forecastInfoXml = (pkg: string) => ` ` android:targetCellWidth="4" android:targetCellHeight="2" android:updatePeriodMillis="1800000" - android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" android:configure="${pkg}.${WIDGET_SUBDIR}.ForecastWidgetConfigActivity" android:previewImage="@drawable/widget_preview" @@ -319,6 +343,7 @@ const withWidgetFiles: ConfigPlugin = (config) => fs.mkdirSync(drawableDir, { recursive: true }); fs.writeFileSync(path.join(drawableDir, "widget_preview.xml"), previewImageVector); fs.writeFileSync(path.join(drawableDir, "widget_preview_loadpoint.xml"), loadpointPreviewImageVector); + fs.writeFileSync(path.join(drawableDir, "ic_reload.xml"), reloadIconVector); // 3. localized strings.xml per locale (generated by `npm run widget:strings`). // The default values/strings.xml already exists (app_name etc. from the diff --git a/targets/android-widget/README.md b/targets/android-widget/README.md index ff3065e..dbc84df 100644 --- a/targets/android-widget/README.md +++ b/targets/android-widget/README.md @@ -83,6 +83,31 @@ Done: the widget, matching iOS. `widget.mode.pv`/`widget.mode.minpv` stay in the strings script as frozen (non-Weblate) translations since evcc removed them from its own i18n once the redesign shipped. +- **Reload button + deep link + fixed resize bounds**, from Maschga's PR #255 + review (evcc-io/app#255#issuecomment-5317470240): `LoadpointWidget`'s title + row now has a reload icon (`res/drawable/ic_reload.xml`, a Material "refresh" + glyph tinted via Glance's `ColorFilter.tint()`) wired to a new `ReloadAction`, + mirroring iOS's `ReloadIntent`. Both widget families are tappable end-to-end + and open the app to the right place (`evcc://loadpoint?server=…&lp=…` / + `evcc://forecast?server=…`, `evcc://server` when unconfigured), mirroring + `widgetURL` in `LoadpointViews.swift`/`Views.swift` via a shared + `deepLinkAction()` (`actionStartActivity` + `ACTION_VIEW`). The card-wide + clickable sits under the mode chips/reload button's own clickable regions, + which take priority within their bounds - same layering iOS gets for free + from SwiftUI's region-based hit testing. + `loadpoint_widget_info.xml`'s resize bounds are now pinned exactly to + `SizeMode.Responsive`'s two declared breakpoints + (`minResizeWidth`/`maxResizeWidth` 180-340dp, height locked at 110dp, + `resizeMode="horizontal"` only) instead of the previous open-ended + `horizontal|vertical`; `forecast_widget_info.xml` dropped `resizeMode` + entirely since those widgets have no size-variant layout to grow into. Both + changes close the gap where a launcher could hand the widget a real + container bigger than any size Glance was told to lay content out for, + leaving blank space the Composable had no way to fill - the likely cause + behind the "strange spacing" Maschga's screenshot showed, though this still + needs on-device confirmation (tracked as a live-device follow-up, along with + the mode-button highlight bug and the widget-reconfigure check from the same + review). Not done yet (follow-ups for parity with iOS): none currently tracked - remaining gaps (reload button, deep links, spline chart smoothing) are diff --git a/targets/android-widget/kotlin/ForecastWidget.kt b/targets/android-widget/kotlin/ForecastWidget.kt index 15dbeef..a22e719 100644 --- a/targets/android-widget/kotlin/ForecastWidget.kt +++ b/targets/android-widget/kotlin/ForecastWidget.kt @@ -9,6 +9,7 @@ import androidx.glance.GlanceId import androidx.glance.GlanceModifier import androidx.glance.Image import androidx.glance.ImageProvider +import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.GlanceAppWidgetReceiver @@ -237,15 +238,19 @@ abstract class ForecastWidget(private val kind: ForecastKind) : GlanceAppWidget( val server = SharedStore.server(context, serverId) ?: return@withContext ForecastState.NotConfigured loadForecastState(context, kind, server, adjust) } - provideContent { Content(context, state) } + provideContent { Content(context, state, serverId) } } @Composable - private fun Content(context: Context, state: ForecastState) { + private fun Content(context: Context, state: ForecastState, serverId: String?) { val notConfigured = state == ForecastState.NotConfigured + // mirrors ForecastWidgetView.deepLink in Views.swift: no `type` param - + // the app tab is fixed (forecast), only the server needs to be passed. + val deepLink = serverId?.let { "evcc://forecast?server=$it" } ?: "evcc://server" Column( modifier = GlanceModifier.fillMaxSize() .background(if (notConfigured) notConfiguredBackground else cardBackground) + .clickable(deepLinkAction(deepLink)) .padding(12.dp), verticalAlignment = Alignment.Vertical.Top, ) { diff --git a/targets/android-widget/kotlin/LoadpointWidget.kt b/targets/android-widget/kotlin/LoadpointWidget.kt index 89b0440..09288a3 100644 --- a/targets/android-widget/kotlin/LoadpointWidget.kt +++ b/targets/android-widget/kotlin/LoadpointWidget.kt @@ -1,13 +1,17 @@ package io.evcc.android.widget import android.content.Context +import android.content.Intent +import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import androidx.glance.ColorFilter import androidx.glance.GlanceId import androidx.glance.GlanceModifier import androidx.glance.Image import androidx.glance.ImageProvider +import androidx.glance.action.Action import androidx.glance.action.ActionParameters import androidx.glance.action.actionParametersOf import androidx.glance.action.clickable @@ -16,6 +20,7 @@ import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.LocalSize import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.cornerRadius @@ -40,6 +45,9 @@ import io.evcc.android.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +// visible (not private) so ForecastWidget.kt can reuse it for its own deep link +fun deepLinkAction(uri: String): Action = actionStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri))) + /** * Loadpoint home-screen widget (Android counterpart of LoadpointWidget.swift / * LoadpointViews.swift's LoadpointCard). Uses the default server and the first @@ -51,8 +59,10 @@ import kotlinx.coroutines.withContext * current mode as text - keeping the chips interactive here instead of * dropping mode-switching entirely for the smallest size); wide shows a * vertical mode-selector column alongside, mirroring LoadpointCard's - * `HStack { left; modeSelector }`. Deliberate simplifications vs. iOS: no - * reload button, no deep link. + * `HStack { left; modeSelector }`. Tapping the card opens the app to the + * configured loadpoint (`evcc://loadpoint`, mirrors iOS's `widgetURL`); the + * reload button and mode chips have their own clickable regions that take + * priority over the card-wide deep link within their bounds. */ private val SMALL_SIZE = DpSize(180.dp, 110.dp) private val WIDE_SIZE = DpSize(340.dp, 110.dp) @@ -178,7 +188,7 @@ class LoadpointWidget : GlanceAppWidget() { val (serverId, lpIndex) = resolved load(context, serverId, lpIndex) } - provideContent { Content(context, state) } + provideContent { Content(context, state, resolved) } } private suspend fun load(context: Context, serverId: String?, lpIndex: Int): LoadpointState = withContext(Dispatchers.IO) { @@ -193,11 +203,21 @@ class LoadpointWidget : GlanceAppWidget() { } @Composable - private fun Content(context: Context, state: LoadpointState) { + private fun Content(context: Context, state: LoadpointState, resolved: Pair?) { val notConfigured = state == LoadpointState.NotConfigured + // mirrors LoadpointView.deepLink in LoadpointViews.swift: always the + // configured loadpoint (even in noData/unreachable, so the user can go + // fix things in-app), "evcc://server" only when never configured at all. + // A null serverId means the default server - the app resolves that on + // its own, so the query param is simply omitted (mirrors iOS's + // `if let id = entry.serverId`). + val deepLink = resolved?.let { (serverId, lpIndex) -> + "evcc://loadpoint?lp=$lpIndex" + (serverId?.let { "&server=$it" } ?: "") + } ?: "evcc://server" Column( modifier = GlanceModifier.fillMaxSize() .background(if (notConfigured) notConfiguredBackground else cardBackground) + .clickable(deepLinkAction(deepLink)) .padding(12.dp), verticalAlignment = Alignment.Vertical.Top, ) { @@ -244,7 +264,15 @@ class LoadpointWidget : GlanceAppWidget() { val m = metric(lp) val heating = lp.chargerFeatureHeating - Text(title(context, lp), style = titleStyle) + Row(verticalAlignment = Alignment.Vertical.CenterVertically) { + Text(title(context, lp), style = titleStyle, modifier = GlanceModifier.defaultWeight()) + Image( + provider = ImageProvider(R.drawable.ic_reload), + contentDescription = null, + colorFilter = ColorFilter.tint(textSecondary), + modifier = GlanceModifier.width(13.dp).height(13.dp).clickable(actionRunCallback()), + ) + } Row(modifier = GlanceModifier.padding(top = 3.dp), verticalAlignment = Alignment.Vertical.CenterVertically) { Box(modifier = GlanceModifier.width(7.dp).height(7.dp).background(statusColor(s.active, heating)).cornerRadius(4.dp)) {} @@ -390,6 +418,13 @@ class ModeAction : ActionCallback { } } +/** Forces a fresh fetch, mirrors iOS's ReloadIntent (WidgetCenter.shared.reloadAllTimelines()). */ +class ReloadAction : ActionCallback { + override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { + LoadpointWidget().updateAll(context) + } +} + class EvccLoadpointWidgetReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = LoadpointWidget() } From 2332ee2e548e07aaaf2bc29256a07a31db6fcd85 Mon Sep 17 00:00:00 2001 From: Alexandre JARDON <28548335+webalexeu@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:23:14 +0000 Subject: [PATCH 8/8] feat: narrow Android widgets PR to loadpoint only Per naltatis's review (evcc-io/app#255#issuecomment-5327087002): Android has no first-party charting API comparable to iOS's Swift Charts, and even third-party Compose chart libraries generally can't render inside a Glance widget's RemoteViews surface - the four forecast widgets carry real hand-rolled-canvas risk this PR doesn't need to also resolve. Splits them out: - Removes ForecastWidget.kt, ForecastWidgetConfigActivity.kt, ChartRenderer.kt and their manifest receiver/config-activity/widget-info-xml/preview registration in the config plugin. - Trims the now-unused forecast-only pieces from the files shared between both widget families: WidgetConfig.kt (saveForecast/forecastServerId/ forecastAdjust), Theme.kt (per-forecast-type Palette + header/footer styles), WidgetPreview.kt (forecast()/footerSide()). - build-widget-strings.mts is untouched - it also feeds iOS's already-shipped forecast widgets, so its KEYS table still has forecast entries; only unused on the Android side now. - README rewritten for loadpoint-only scope, pointing at the feat/android-widgets-forecast branch (a snapshot of this branch pre-split) for the deferred forecast-widget follow-up. Verified with expo prebuild + a full local Android build (assembleDebug) and npm run lint. --- scripts/androidWidget/withAndroidWidget.ts | 97 +---- targets/android-widget/README.md | 117 +++--- .../android-widget/kotlin/ChartRenderer.kt | 174 --------- .../android-widget/kotlin/ForecastWidget.kt | 364 ------------------ .../kotlin/ForecastWidgetConfigActivity.kt | 215 ----------- .../android-widget/kotlin/LoadpointWidget.kt | 3 +- targets/android-widget/kotlin/Theme.kt | 36 -- targets/android-widget/kotlin/WidgetConfig.kt | 18 - .../android-widget/kotlin/WidgetPreview.kt | 66 +--- 9 files changed, 71 insertions(+), 1019 deletions(-) delete mode 100644 targets/android-widget/kotlin/ChartRenderer.kt delete mode 100644 targets/android-widget/kotlin/ForecastWidget.kt delete mode 100644 targets/android-widget/kotlin/ForecastWidgetConfigActivity.kt diff --git a/scripts/androidWidget/withAndroidWidget.ts b/scripts/androidWidget/withAndroidWidget.ts index df41040..e827145 100644 --- a/scripts/androidWidget/withAndroidWidget.ts +++ b/scripts/androidWidget/withAndroidWidget.ts @@ -80,15 +80,6 @@ const withGlanceComposeCompiler: ConfigPlugin = (config) => return config; }); -// The forecast widgets (Solar/Price/CO₂/Feed-in) share one appwidget-provider -// XML, but each gets a distinct android:label so the widget picker names them. -const FORECAST_RECEIVERS = [ - { name: "EvccSolarWidgetReceiver", label: "Solar" }, - { name: "EvccPriceWidgetReceiver", label: "Price" }, - { name: "EvccCo2WidgetReceiver", label: "CO₂" }, - { name: "EvccFeedinWidgetReceiver", label: "Feed-in" }, -]; - // The manifest types don't model android:label or on // (Expo's typings only add them for activities/applications), though the // manifest XML writer accepts both fine. @@ -129,15 +120,11 @@ const withWidgetReceiver: ConfigPlugin = (config) => const app = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults); pushWidgetReceiver(app, "EvccLoadpointWidgetReceiver", "loadpoint_widget_info", "Loadpoint"); - for (const r of FORECAST_RECEIVERS) pushWidgetReceiver(app, r.name, "forecast_widget_info", r.label); - // widget placement configuration Activities (loadpoint picker; forecast server + solar toggle) + // widget placement configuration Activity (server, then loadpoint picker) app.activity = app.activity ?? []; - for (const actName of [ - `.${WIDGET_SUBDIR}.LoadpointWidgetConfigActivity`, - `.${WIDGET_SUBDIR}.ForecastWidgetConfigActivity`, - ]) { - if (app.activity.some((a) => a.$["android:name"] === actName)) continue; + const actName = `.${WIDGET_SUBDIR}.LoadpointWidgetConfigActivity`; + if (!app.activity.some((a) => a.$["android:name"] === actName)) { app.activity.push({ $: { "android:name": actName, "android:exported": "true" }, "intent-filter": [ @@ -174,36 +161,6 @@ const widgetInfoXml = (pkg: string) => ` android:previewLayout="@layout/loadpoint_widget_preview" /> `; -// Static preview image (vector) for the widget picker. Many OEM launchers -// (Xiaomi/MIUI, Nova, …) ignore android:previewLayout and only honour a -// previewImage drawable, so provide both. -const vBar = (x: number, h: number, w = 16) => - ``; -const previewImageVector = ` - - - - - ${[ - [14, 22], - [35, 40], - [56, 58], - [77, 78], - [98, 96], - [119, 100], - [140, 82], - [161, 60], - [182, 42], - [203, 26], - [224, 16], - ] - .map(([x, h]) => vBar(x, h)) - .join("\n ")} - -`; - // Reload button icon (LoadpointWidget.kt only, mirrors iOS's "arrow.clockwise" // SF Symbol next to the title). Standard Material "refresh" glyph, tinted at // runtime via Glance's ColorFilter.tint() so it follows day/night like the @@ -234,24 +191,8 @@ const loadpointPreviewImageVector = ` `; -// Forecast widgets: one fixed size, no size-variant layout (unlike Loadpoint) - -// no resizeMode, so the launcher can't grow the frame past the content and -// leave blank space below the footer. -const forecastInfoXml = (pkg: string) => ` - -`; - -// Static preview layouts shown in the widget picker (the Glance content only -// renders once placed). Kept representative of the real widgets. +// Static preview layout shown in the widget picker (the Glance content only +// renders once placed). Kept representative of the real widget. const loadpointPreviewXml = ` `; -// A green bar in the preview sparkline (fixed height, equal weight). -const bar = (h: number) => - ``; - -const forecastPreviewXml = ` - - - - - - ${[6, 10, 16, 24, 34, 40, 44, 38, 30, 20, 12, 6].map(bar).join("\n ")} - - -`; - const withWidgetFiles: ConfigPlugin = (config) => withDangerousMod(config, [ "android", @@ -332,16 +248,13 @@ const withWidgetFiles: ConfigPlugin = (config) => const xmlDir = path.join(main, "res", "xml"); fs.mkdirSync(xmlDir, { recursive: true }); fs.writeFileSync(path.join(xmlDir, "loadpoint_widget_info.xml"), widgetInfoXml(pkg)); - fs.writeFileSync(path.join(xmlDir, "forecast_widget_info.xml"), forecastInfoXml(pkg)); const layoutDir = path.join(main, "res", "layout"); fs.mkdirSync(layoutDir, { recursive: true }); fs.writeFileSync(path.join(layoutDir, "loadpoint_widget_preview.xml"), loadpointPreviewXml); - fs.writeFileSync(path.join(layoutDir, "forecast_widget_preview.xml"), forecastPreviewXml); const drawableDir = path.join(main, "res", "drawable"); fs.mkdirSync(drawableDir, { recursive: true }); - fs.writeFileSync(path.join(drawableDir, "widget_preview.xml"), previewImageVector); fs.writeFileSync(path.join(drawableDir, "widget_preview_loadpoint.xml"), loadpointPreviewImageVector); fs.writeFileSync(path.join(drawableDir, "ic_reload.xml"), reloadIconVector); diff --git a/targets/android-widget/README.md b/targets/android-widget/README.md index dbc84df..66a8897 100644 --- a/targets/android-widget/README.md +++ b/targets/android-widget/README.md @@ -6,10 +6,22 @@ this is a Kotlin/Glance reimplementation of the same contracts. ## Status -Five interactive home-screen widgets, end-to-end, with per-instance -configuration and instant refresh: **Loadpoint**, and forecast widgets for -**Solar / Price / CO₂ / Feed-in**. Verified with `expo prebuild` + a real local -Android build (`./gradlew assembleDebug` / `assembleRelease`). +One interactive home-screen widget, end-to-end, with per-instance +configuration and instant refresh: **Loadpoint**. Verified with `expo prebuild` ++ a real local Android build (`./gradlew assembleDebug` / `assembleRelease`). + +**Scope note**: this PR was originally built with five widgets (Loadpoint plus +forecast widgets for Solar/Price/CO₂/Feed-in). Per naltatis's review +(evcc-io/app#255#issuecomment-5327087002), the forecast widgets are split out +to a separate follow-up PR/branch (`feat/android-widgets-forecast`) — Android +has no first-party charting API comparable to iOS's Swift Charts (not even for +regular in-app Compose UI, and third-party Compose chart libraries generally +can't render inside a Glance widget's `RemoteViews` surface at all), so those +widgets carry real hand-rolled-canvas risk this PR doesn't need to also +resolve. This PR now covers the loadpoint widget only; the forecast branch +still has the from-scratch four-widget implementation (`ForecastWidget.kt`, +`ChartRenderer.kt`, `ForecastWidgetConfigActivity.kt`) for whenever that's +picked back up. Done: @@ -20,60 +32,51 @@ Done: - `kotlin/ApiClient.kt` — GET `/api/state?jq=…` + basic auth + POST actions, plus the `Loadpoint` model (mirrors `ApiClient.swift` / `Loadpoint.swift`). - `kotlin/LoadpointWidget.kt` — Glance widget + interactive mode buttons. -- `kotlin/ForecastWidget.kt` / `ChartRenderer.kt` — the four forecast widgets, - with a Canvas-drawn chart (mirrors `ForecastWidget.swift`). - **Per-instance config**: `LoadpointWidgetConfigActivity.kt` (pick server, then - loadpoint) and `ForecastWidgetConfigActivity.kt` (pick server; Solar also gets - an "adjust to real production" toggle). Selections persist per `appWidgetId` in - `WidgetConfig.kt`, including a fallback queue for launchers (e.g. MIUI) that - hand the configure Activity a different id than the one the widget binds with. + loadpoint). Selections persist per `appWidgetId` in `WidgetConfig.kt`, + including a fallback queue for launchers (e.g. MIUI) that hand the configure + Activity a different id than the one the widget binds with. - **Immediate refresh on config/server change**: `modules/evcc-widget` (a small local Expo native module) exposes `refresh()`, called from `utils/widgetRefresh.ts` after `widgetSync.ts` writes the file — no need to wait for the periodic `updatePeriodMillis` tick. - `kotlin/Theme.kt` — day/night colors (mirrors iOS's `scheme == .dark` - branches), full typography scale, per-forecast-type palette (mirrors - `Theme.swift`'s `Palette.make`). + branches) and typography scale. - `scripts/androidWidget/withAndroidWidget.ts` — Expo config plugin: injects the Kotlin, the `res/xml` widget info, the manifest ``/`` entries, and the Glance/Compose gradle wiring. Registered in `app.config.ts`. -- **Visual parity with iOS** (mirrors `LoadpointViews.swift`/`Views.swift`): - status dot + color-coded status text, a rounded/striped progress bar +- **Visual parity with iOS** (mirrors `LoadpointViews.swift`): status dot + + color-coded status text, a rounded/striped progress bar (`ProgressBarRenderer.kt`, since Glance has no fractional-width layout modifier), chip-style mode buttons with a selected-state fill, full heating/finished/waitForVehicle status + kWh-fallback metric logic ported - from `LoadpointVM.build`, a two-column forecast header, a Y-axis + - step-vs-area chart modes + per-type color in `ChartRenderer.kt` (previously - always a flat green area line regardless of data type), bold/colored footer - stats, and light/dark card backgrounds throughout. Deliberately not ported: - the reload button, deep links, and Swift Charts' `.monotone` spline - smoothing (straight line segments instead). -- **Live preview when configuring**: both config Activities now fetch real - data for the tapped server/loadpoint/toggle and render an actual preview of - the widget (`WidgetPreview.kt`) before committing via a new "Use this" - button - previously the pick-a-row tap committed immediately with no - preview. Built with plain Views (reusing `ChartRenderer`/`ProgressBarRenderer` - bitmaps) rather than a live Glance render, since embedding real Glance - content in a classic-Views Activity needs the full Compose UI stack plus an + from `LoadpointVM.build`, and light/dark card backgrounds throughout. +- **Live preview when configuring**: `LoadpointWidgetConfigActivity` fetches + real data for the tapped server/loadpoint and renders an actual preview of + the widget (`WidgetPreview.kt`) before committing via a new "Use this + loadpoint" button - previously the pick-a-row tap committed immediately with + no preview. Built with plain Views (reusing `ProgressBarRenderer` bitmaps) + rather than a live Glance render, since embedding real Glance content in a + classic-Views Activity needs the full Compose UI stack plus an unpublished/experimental Google API - see the "Live preview" discussion this was scoped from for the trade-off. -- **Localization**: `scripts/build-widget-strings.mts` now also generates - Android string resources (`res/values(-b+)/strings.xml`) alongside - the iOS `.xcstrings` catalog, from the same evcc-daemon + this-app Weblate +- **Localization**: `scripts/build-widget-strings.mts` also generates Android + string resources (`res/values(-b+)/strings.xml`) alongside the iOS + `.xcstrings` catalog, from the same evcc-daemon + this-app Weblate translations. Every widget/config-Activity string reads from `R.string.*` - now - none are hardcoded. The config Activities' picker/live-preview flow - has no iOS equivalent, so those strings are new additions to this app's own - `i18n/en.json`/`de.json` (`widget.androidConfig.*`) rather than reuses. -- **Size variants**: `LoadpointWidget` now declares + now - none are hardcoded. The config Activity's picker/live-preview flow has + no iOS equivalent, so those strings are new additions to this app's own + `i18n/en.json`/`de.json` (`widget.androidConfig.*`) rather than reuses. (The + script's `KEYS` table still includes forecast-widget-only entries - + untouched here since the same script also feeds iOS's already-shipped + forecast widgets.) +- **Size variants**: `LoadpointWidget` declares `SizeMode.Responsive(setOf(SMALL_SIZE, WIDE_SIZE))` and reads `LocalSize` to branch layout - compact stays the inline mode-chip row below the metric (deliberately kept interactive, unlike iOS's compact size which drops to a plain-text mode label instead of buttons), wide adds a vertical mode-selector column alongside, mirroring `LoadpointCard`'s - `HStack { left; modeSelector }`. No manifest change needed - the widget was - already resizable (`resizeMode="horizontal|vertical"`); this just makes the - wider layout actually render something different once resized. The forecast - widgets don't have an iOS size-variant precedent, so they stay single-size. + `HStack { left; modeSelector }`. - **Smart mode redesign** (mirrors iOS's `Loadpoint.swift`/#246): `Loadpoint` gained `alwaysCharge`/`chargerFeatureContinuous`. Its presence detects smart-mode servers (`off/smart/now`, with per-device-class labels - e.g. @@ -87,31 +90,29 @@ Done: review (evcc-io/app#255#issuecomment-5317470240): `LoadpointWidget`'s title row now has a reload icon (`res/drawable/ic_reload.xml`, a Material "refresh" glyph tinted via Glance's `ColorFilter.tint()`) wired to a new `ReloadAction`, - mirroring iOS's `ReloadIntent`. Both widget families are tappable end-to-end - and open the app to the right place (`evcc://loadpoint?server=…&lp=…` / - `evcc://forecast?server=…`, `evcc://server` when unconfigured), mirroring - `widgetURL` in `LoadpointViews.swift`/`Views.swift` via a shared - `deepLinkAction()` (`actionStartActivity` + `ACTION_VIEW`). The card-wide - clickable sits under the mode chips/reload button's own clickable regions, - which take priority within their bounds - same layering iOS gets for free - from SwiftUI's region-based hit testing. + mirroring iOS's `ReloadIntent`. The widget is tappable end-to-end and opens + the app to the right loadpoint (`evcc://loadpoint?server=…&lp=…`, + `evcc://server` when unconfigured), mirroring `widgetURL` in + `LoadpointViews.swift` via `deepLinkAction()` (`actionStartActivity` + + `ACTION_VIEW`). The card-wide clickable sits under the mode chips/reload + button's own clickable regions, which take priority within their bounds - + same layering iOS gets for free from SwiftUI's region-based hit testing. `loadpoint_widget_info.xml`'s resize bounds are now pinned exactly to `SizeMode.Responsive`'s two declared breakpoints (`minResizeWidth`/`maxResizeWidth` 180-340dp, height locked at 110dp, `resizeMode="horizontal"` only) instead of the previous open-ended - `horizontal|vertical`; `forecast_widget_info.xml` dropped `resizeMode` - entirely since those widgets have no size-variant layout to grow into. Both - changes close the gap where a launcher could hand the widget a real - container bigger than any size Glance was told to lay content out for, - leaving blank space the Composable had no way to fill - the likely cause - behind the "strange spacing" Maschga's screenshot showed, though this still - needs on-device confirmation (tracked as a live-device follow-up, along with - the mode-button highlight bug and the widget-reconfigure check from the same - review). + `horizontal|vertical` - closing the gap where a launcher could hand the + widget a real container bigger than any size Glance was told to lay content + out for, leaving blank space the Composable had no way to fill. Likely the + cause behind the "strange spacing" Maschga's screenshot showed, though this + still needs on-device confirmation (tracked as a live-device follow-up, + along with the mode-button highlight bug and the widget-reconfigure check + from the same review). -Not done yet (follow-ups for parity with iOS): none currently tracked - -remaining gaps (reload button, deep links, spline chart smoothing) are -documented as deliberate simplifications above, not open TODOs. +Not done yet (follow-ups for parity with iOS): none currently tracked for the +loadpoint widget - remaining gaps are documented as deliberate simplifications +above, not open TODOs. The forecast widgets are out of scope for this PR (see +"Scope note" above). ## Build / test diff --git a/targets/android-widget/kotlin/ChartRenderer.kt b/targets/android-widget/kotlin/ChartRenderer.kt deleted file mode 100644 index 75a1b97..0000000 --- a/targets/android-widget/kotlin/ChartRenderer.kt +++ /dev/null @@ -1,174 +0,0 @@ -package io.evcc.android.widget - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.Path -import androidx.glance.color.isNightMode -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Date -import java.util.Locale -import kotlin.math.ceil - -/** Mirrors ChartKind in Views.swift: area = solar (monotone-ish line + fill), - * step = price/CO2 (stepEnd line, no fill), stepArea = feed-in (stepEnd + fill). */ -enum class ChartKind { AREA, STEP, STEP_AREA } - -/** - * Renders a forecast series to a Bitmap (line + optional area fill, a Y axis, - * local-midnight day dividers, and weekday labels), shown in the widget via a - * Glance Image. Glance has no chart primitive, so this Canvas bitmap is how we - * approximate the iOS Swift Charts look (see ForecastChart in Views.swift) - - * straight line segments rather than Swift Charts' `.monotone` spline - * smoothing is a known simplification. - */ -object ChartRenderer { - private const val W = 720 - private const val H = 240 - private const val PAD_TOP = 18f - private const val PAD_BOTTOM = 30f - private const val PAD_LEFT = 32f - - /** values and times must be index-aligned; times in epoch millis (may be empty). */ - fun render( - context: Context, - values: List, - times: List, - kind: ChartKind, - accentDay: Int, - accentNight: Int, - ): Bitmap { - val bmp = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bmp) - if (values.size < 2) return bmp - - val dark = context.isNightMode - val accent = if (dark) accentNight else accentDay - val fillColor = (accent and 0x00FFFFFF) or 0x33000000 - val dividerColor = if (dark) 0x33FFFFFF.toInt() else 0x22000000.toInt() - val labelColor = if (dark) 0x99FFFFFF.toInt() else 0x99000000.toInt() - val zeroLineColor = if (dark) 0x40FFFFFF.toInt() else 0x33000000.toInt() - - val axisBottom = minOf(0.0, values.min()) - val axisTop = axisTop(values) - val span = (axisTop - axisBottom).let { if (it <= 0.0) 1.0 else it } - - val plotW = W - PAD_LEFT - val plotH = H - PAD_TOP - PAD_BOTTOM - - fun x(i: Int) = PAD_LEFT + plotW * (i.toFloat() / (values.size - 1)) - fun y(v: Double) = PAD_TOP + plotH * (1f - ((v - axisBottom) / span).toFloat()) - - // Y axis: 0 line + min/max labels (mirrors chartYAxis in Views.swift) - val axisLabelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = labelColor - textSize = 18f - } - canvas.drawLine(PAD_LEFT, y(0.0), W.toFloat(), y(0.0), Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = zeroLineColor - strokeWidth = 1f - }) - canvas.drawText(axisLabel(0.0), 2f, y(0.0) + 6f, axisLabelPaint) - canvas.drawText(axisLabel(axisTop), 2f, y(axisTop) + 6f, axisLabelPaint) - - // day dividers + weekday labels (drawn first, behind the series) - if (times.size == values.size) { - val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = dividerColor - strokeWidth = 1.5f - } - val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = labelColor - textSize = 22f - } - val weekday = SimpleDateFormat("EEE", Locale.getDefault()) - val cal = Calendar.getInstance() - var lastDay = -1 - for (i in values.indices) { - cal.timeInMillis = times[i] - val day = cal.get(Calendar.DAY_OF_YEAR) - if (day != lastDay) { - if (lastDay != -1) { - val xx = x(i) - canvas.drawLine(xx, PAD_TOP, xx, PAD_TOP + plotH, dividerPaint) - canvas.drawText(weekday.format(Date(times[i])), xx + 6f, H - 8f, textPaint) - } - lastDay = day - } - } - } - - // area fill under the line (skipped for pure STEP, like iOS's `if kind != .step`) - if (kind != ChartKind.STEP) { - val area = if (kind == ChartKind.AREA) { - areaPath(::x, ::y, values, y(axisBottom)) - } else { - stepAreaPath(::x, ::y, values, y(axisBottom)) - } - canvas.drawPath(area, Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL - color = fillColor - }) - } - - // series line - val line = if (kind == ChartKind.AREA) linePath(::x, ::y, values) else stepLinePath(::x, ::y, values) - canvas.drawPath(line, Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.STROKE - strokeWidth = if (kind == ChartKind.AREA) 4.4f else 4f - color = accent - strokeJoin = Paint.Join.ROUND - strokeCap = Paint.Cap.ROUND - }) - - return bmp - } - - // labels: 0 + max, ceil to next integer; fractional (<1) series ceil to 0.1 - // so sub-unit currencies aren't flattened. Mirrors axisTop/axisBottom in - // ForecastChart (Views.swift). - private fun axisTop(values: List): Double { - val m = values.maxOrNull() ?: 0.0 - if (m <= 0.0) return 1.0 - return if (m < 1.0) ceil(m * 10) / 10 else ceil(m) - } - - private fun axisLabel(v: Double): String = - if (v == v.toLong().toDouble()) v.toLong().toString() else String.format(Locale.getDefault(), "%.1f", v) - - private fun linePath(x: (Int) -> Float, y: (Double) -> Float, values: List): Path = Path().apply { - moveTo(x(0), y(values[0])) - for (i in 1 until values.size) lineTo(x(i), y(values[i])) - } - - private fun areaPath(x: (Int) -> Float, y: (Double) -> Float, values: List, baselineY: Float): Path = - Path().apply { - moveTo(x(0), baselineY) - for (i in values.indices) lineTo(x(i), y(values[i])) - lineTo(x(values.size - 1), baselineY) - close() - } - - /** Staircase: horizontal to the next x at the current value, then a vertical jump. */ - private fun stepLinePath(x: (Int) -> Float, y: (Double) -> Float, values: List): Path = Path().apply { - moveTo(x(0), y(values[0])) - for (i in 1 until values.size) { - lineTo(x(i), y(values[i - 1])) - lineTo(x(i), y(values[i])) - } - } - - private fun stepAreaPath(x: (Int) -> Float, y: (Double) -> Float, values: List, baselineY: Float): Path = - Path().apply { - moveTo(x(0), baselineY) - lineTo(x(0), y(values[0])) - for (i in 1 until values.size) { - lineTo(x(i), y(values[i - 1])) - lineTo(x(i), y(values[i])) - } - lineTo(x(values.size - 1), baselineY) - close() - } -} diff --git a/targets/android-widget/kotlin/ForecastWidget.kt b/targets/android-widget/kotlin/ForecastWidget.kt deleted file mode 100644 index a22e719..0000000 --- a/targets/android-widget/kotlin/ForecastWidget.kt +++ /dev/null @@ -1,364 +0,0 @@ -package io.evcc.android.widget - -import android.content.Context -import android.graphics.Bitmap -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.unit.dp -import androidx.glance.GlanceId -import androidx.glance.GlanceModifier -import androidx.glance.Image -import androidx.glance.ImageProvider -import androidx.glance.action.clickable -import androidx.glance.appwidget.GlanceAppWidget -import androidx.glance.appwidget.GlanceAppWidgetManager -import androidx.glance.appwidget.GlanceAppWidgetReceiver -import androidx.glance.appwidget.provideContent -import androidx.glance.background -import androidx.glance.layout.Alignment -import androidx.glance.layout.Column -import androidx.glance.layout.ContentScale -import androidx.glance.layout.Row -import androidx.glance.layout.Spacer -import androidx.glance.layout.fillMaxSize -import androidx.glance.layout.fillMaxWidth -import androidx.glance.layout.height -import androidx.glance.layout.padding -import androidx.glance.text.Text -import androidx.glance.unit.ColorProvider -import io.evcc.android.R -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.json.JSONObject - -/** - * Forecast home-screen widgets (Android counterpart of ForecastWidget.swift / - * Views.swift's SolarCard/SeriesCard): Solar, Price, CO₂ and Feed-in. Glance - * has no chart primitive, so the 48h series is rendered to a Bitmap (see - * [ChartRenderer]) and shown via Image, alongside a two-column header and a - * colored/bold footer. - * - * Per-instance config (server, and for Solar the "adjust to real production" - * toggle) is set by ForecastWidgetConfigActivity, keyed by appWidgetId. The - * data-fetching functions below (loadForecastState and friends) are top-level, - * not GlanceAppWidget instance methods, so ForecastWidgetConfigActivity can - * reuse them for its live preview without duplicating the parsing logic. - */ -// Titles mirror evcc's own forecast.type.*/widget.type.* strings (see Configuration.swift). -enum class ForecastKind { - SOLAR, PRICE, CO2, FEEDIN; - - fun title(context: Context): String = context.getString( - when (this) { - SOLAR -> R.string.widget_type_solar - PRICE -> R.string.widget_type_price - CO2 -> R.string.widget_type_co2 - FEEDIN -> R.string.widget_type_feedin - }, - ) -} - -data class FooterSide(val prefix: String? = null, val emphasis: String, val label: String? = null) - -sealed interface ForecastState { - data class Data( - val value: String, - val unit: String, - val chart: Bitmap, - val footerLeft: FooterSide, - val footerRight: FooterSide, - ) : ForecastState - object NoData : ForecastState - object Unreachable : ForecastState - object NotConfigured : ForecastState -} - -private const val WINDOW_MS = 48L * 3600 * 1000 -private const val MAX_POINTS = 200 - -/** Cap the number of chart points by striding, keeping value/time index-aligned. */ -private fun downsampleIndices(size: Int): List { - if (size <= MAX_POINTS) return (0 until size).toList() - val step = size.toDouble() / MAX_POINTS - return (0 until MAX_POINTS).map { (it * step).toInt() } -} - -private fun chart(context: Context, kind: ForecastKind, values: List, times: List, chartKind: ChartKind): Bitmap { - val idx = downsampleIndices(values.size) - val p = palette(kind) - return ChartRenderer.render( - context, idx.map { values[it] }, idx.map { times[it] }, chartKind, - p.accentDay.toArgb(), p.accentNight.toArgb(), - ) -} - -/** Fetches and parses the forecast state for `kind` from `server`. Runs network I/O - call off the main thread. */ -fun loadForecastState(context: Context, kind: ForecastKind, server: StoredServer, adjust: Boolean): ForecastState = when (kind) { - ForecastKind.SOLAR -> solar(context, server, adjust) - ForecastKind.PRICE -> series(context, kind, server, "{currency:.currency,slots:.forecast.grid}") - ForecastKind.FEEDIN -> series(context, kind, server, "{currency:.currency,slots:.forecast.feedin}") - ForecastKind.CO2 -> co2(context, server) -} - -private fun solar(context: Context, server: StoredServer, adjust: Boolean): ForecastState { - val out = ApiClient.fetch(server, ".forecast.solar") - if (out is FetchOutcome.NoData) return ForecastState.NoData - if (out !is FetchOutcome.Success) return ForecastState.Unreachable - return runCatching { - val o = JSONObject(out.json) - val rawScale = if (o.has("scale") && !o.isNull("scale")) o.optDouble("scale") else 1.0 - val scale = if (adjust) rawScale else 1.0 - val ts = o.optJSONArray("timeseries") ?: return@runCatching ForecastState.NoData - val now = System.currentTimeMillis() - val end = now + WINDOW_MS - val values = ArrayList() - val times = ArrayList() - var currentW: Double? = null - for (i in 0 until ts.length()) { - // entries are [ts, val] tuples, unix seconds (see timeseries.MarshalJSON in evcc) - val p = ts.optJSONArray(i) ?: continue - if (p.length() < 2) continue - val t = (p.optDouble(0) * 1000).toLong() - val v = p.optDouble(1) * scale - if (t in now..end) { - values.add(v) - times.add(t) - } - if (t <= now) currentW = v // last slot at/behind now wins - } - if (values.isEmpty()) return@runCatching ForecastState.NoData - val today = o.optJSONObject("today")?.optDouble("energy") ?: 0.0 - val tomorrow = o.optJSONObject("tomorrow")?.optDouble("energy") ?: 0.0 - val (value, unit) = splitValueUnit(Format.fmtW(currentW ?: values.first())) - ForecastState.Data( - value = value, - unit = unit, - chart = chart(context, ForecastKind.SOLAR, values, times, ChartKind.AREA), - footerLeft = FooterSide( - emphasis = Format.fmtWh(today * scale), - label = context.getString(R.string.widget_solar_remaining), - ), - footerRight = FooterSide( - emphasis = Format.fmtWh(tomorrow * scale), - label = context.getString(R.string.widget_solar_tomorrow), - ), - ) - }.getOrDefault(ForecastState.NoData) -} - -private fun series(context: Context, kind: ForecastKind, server: StoredServer, jq: String): ForecastState { - val out = ApiClient.fetch(server, jq) - if (out is FetchOutcome.NoData) return ForecastState.NoData - if (out !is FetchOutcome.Success) return ForecastState.Unreachable - return runCatching { - val o = JSONObject(out.json) - val currency = o.optString("currency").takeIf { it.isNotEmpty() && it != "null" } ?: "EUR" - val slots = o.optJSONArray("slots") ?: return@runCatching ForecastState.NoData - val stat = windowStats(slots) ?: return@runCatching ForecastState.NoData - val (value, unit) = splitValueUnit(Format.fmtPricePerKWh(stat.current, currency)) - ForecastState.Data( - value = value, - unit = unit, - chart = chart(context, kind, stat.values, stat.times, ChartKind.STEP_AREA), - footerLeft = FooterSide( - emphasis = "${Format.fmtPricePerKWh(stat.min, currency, withUnit = false)}–" + - Format.fmtPricePerKWh(stat.max, currency, withUnit = false), - label = Format.pricePerKWhUnit(currency), - ), - footerRight = FooterSide( - prefix = "ø ", - emphasis = Format.fmtPricePerKWh(stat.avg, currency), - ), - ) - }.getOrDefault(ForecastState.NoData) -} - -private fun co2(context: Context, server: StoredServer): ForecastState { - val out = ApiClient.fetch(server, ".forecast.co2") - if (out is FetchOutcome.NoData) return ForecastState.NoData - if (out !is FetchOutcome.Success) return ForecastState.Unreachable - return runCatching { - val slots = org.json.JSONArray(out.json) - val stat = windowStats(slots) ?: return@runCatching ForecastState.NoData - val (value, unit) = splitValueUnit(Format.fmtCo2(stat.current)) - ForecastState.Data( - value = value, - unit = unit, - chart = chart(context, ForecastKind.CO2, stat.values, stat.times, ChartKind.STEP), - footerLeft = FooterSide( - emphasis = "${Format.fmtNumber(stat.min, 0)}–${Format.fmtNumber(stat.max, 0)}", - label = "g", - ), - footerRight = FooterSide(prefix = "ø ", emphasis = "${Format.fmtNumber(stat.avg, 0)} g"), - ) - }.getOrDefault(ForecastState.NoData) -} - -private data class Stats( - val values: List, val times: List, - val current: Double, val min: Double, val max: Double, val avg: Double, -) - -/** Trim slots ([start, end, value] tuples, unix seconds) to the 48h window and reduce to stats + series. */ -private fun windowStats(slots: org.json.JSONArray): Stats? { - val now = System.currentTimeMillis() - val end = now + WINDOW_MS - val values = ArrayList() - val times = ArrayList() - var current: Double? = null - for (i in 0 until slots.length()) { - val s = slots.optJSONArray(i) ?: continue - if (s.length() < 3) continue - val start = (s.optDouble(0) * 1000).toLong() - val slotEnd = (s.optDouble(1) * 1000).toLong() - val v = s.optDouble(2) - if (start in now..end) { - values.add(v) - times.add(start) - } - if (current == null && now < slotEnd) current = v // first slot ending after now - } - if (values.isEmpty()) return null - return Stats( - values = values, - times = times, - current = current ?: values.first(), - min = values.min(), - max = values.max(), - avg = values.average(), - ) -} - -abstract class ForecastWidget(private val kind: ForecastKind) : GlanceAppWidget() { - override suspend fun provideGlance(context: Context, id: GlanceId) { - val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) - val serverId = WidgetConfig.forecastServerId(context, appWidgetId) - val adjust = WidgetConfig.forecastAdjust(context, appWidgetId) - val state = withContext(Dispatchers.IO) { - val server = SharedStore.server(context, serverId) ?: return@withContext ForecastState.NotConfigured - loadForecastState(context, kind, server, adjust) - } - provideContent { Content(context, state, serverId) } - } - - @Composable - private fun Content(context: Context, state: ForecastState, serverId: String?) { - val notConfigured = state == ForecastState.NotConfigured - // mirrors ForecastWidgetView.deepLink in Views.swift: no `type` param - - // the app tab is fixed (forecast), only the server needs to be passed. - val deepLink = serverId?.let { "evcc://forecast?server=$it" } ?: "evcc://server" - Column( - modifier = GlanceModifier.fillMaxSize() - .background(if (notConfigured) notConfiguredBackground else cardBackground) - .clickable(deepLinkAction(deepLink)) - .padding(12.dp), - verticalAlignment = Alignment.Vertical.Top, - ) { - when (state) { - is ForecastState.Data -> DataBody(context, state) - ForecastState.NoData -> MessageBody( - context.getString(R.string.widget_noData_title), - context.getString(R.string.widget_noData_body), - ) - ForecastState.Unreachable -> MessageBody( - context.getString(R.string.widget_unreachable_title), - context.getString(R.string.widget_unreachable_body), - ) - ForecastState.NotConfigured -> NotConfiguredBody(context) - } - } - } - - @Composable - private fun DataBody(context: Context, state: ForecastState.Data) { - val p = palette(kind) - Header(context, p, state.value, state.unit) - Spacer(GlanceModifier.height(4.dp)) - Image( - provider = ImageProvider(state.chart), - contentDescription = null, - modifier = GlanceModifier.fillMaxWidth().height(64.dp), - contentScale = ContentScale.FillBounds, - ) - Spacer(GlanceModifier.height(5.dp)) - Footer(p, state.footerLeft, state.footerRight) - } - - @Composable - private fun Header(context: Context, p: Palette, value: String, unit: String) { - Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.Vertical.Bottom) { - Text(kind.title(context), style = headerHeadlineStyle.copy(color = p.headline)) - Spacer(GlanceModifier.defaultWeight()) - Column(horizontalAlignment = Alignment.Horizontal.End) { - Row { - Text(value, style = headerHeadlineStyle.copy(color = p.headline)) - Text(" $unit", style = headerHeadlineUnitStyle.copy(color = p.headline)) - } - Text(context.getString(R.string.widget_now), style = headerSubStyle) - } - } - } - - @Composable - private fun Footer(p: Palette, left: FooterSide, right: FooterSide) { - Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.Vertical.CenterVertically) { - FooterText(left, p.headline) - Spacer(GlanceModifier.defaultWeight()) - FooterText(right, textPrimary) - } - } - - @Composable - private fun FooterText(side: FooterSide, emphasisColor: ColorProvider) { - Row { - if (side.prefix != null) Text(side.prefix, style = footerStyle) - Text(side.emphasis, style = footerEmphasisStyle.copy(color = emphasisColor)) - if (side.label != null) Text(" ${side.label}", style = footerStyle) - } - } - - @Composable - private fun MessageBody(title: String, message: String) { - Column( - modifier = GlanceModifier.fillMaxSize(), - verticalAlignment = Alignment.Vertical.CenterVertically, - horizontalAlignment = Alignment.Horizontal.CenterHorizontally, - ) { - Text(title, style = messageTitleStyle) - Text(message, style = messageBodyStyle) - } - } - - @Composable - private fun NotConfiguredBody(context: Context) { - Column( - modifier = GlanceModifier.fillMaxSize(), - verticalAlignment = Alignment.Vertical.CenterVertically, - horizontalAlignment = Alignment.Horizontal.CenterHorizontally, - ) { - Text(context.getString(R.string.widget_setup_title), style = notConfiguredTitleStyle) - Text(context.getString(R.string.widget_setup_body), style = notConfiguredBodyStyle) - } - } -} - -class SolarWidget : ForecastWidget(ForecastKind.SOLAR) -class PriceWidget : ForecastWidget(ForecastKind.PRICE) -class Co2Widget : ForecastWidget(ForecastKind.CO2) -class FeedinWidget : ForecastWidget(ForecastKind.FEEDIN) - -class EvccSolarWidgetReceiver : GlanceAppWidgetReceiver() { - override val glanceAppWidget: GlanceAppWidget = SolarWidget() -} - -class EvccPriceWidgetReceiver : GlanceAppWidgetReceiver() { - override val glanceAppWidget: GlanceAppWidget = PriceWidget() -} - -class EvccCo2WidgetReceiver : GlanceAppWidgetReceiver() { - override val glanceAppWidget: GlanceAppWidget = Co2Widget() -} - -class EvccFeedinWidgetReceiver : GlanceAppWidgetReceiver() { - override val glanceAppWidget: GlanceAppWidget = FeedinWidget() -} diff --git a/targets/android-widget/kotlin/ForecastWidgetConfigActivity.kt b/targets/android-widget/kotlin/ForecastWidgetConfigActivity.kt deleted file mode 100644 index 362aa05..0000000 --- a/targets/android-widget/kotlin/ForecastWidgetConfigActivity.kt +++ /dev/null @@ -1,215 +0,0 @@ -package io.evcc.android.widget - -import android.app.Activity -import android.appwidget.AppWidgetManager -import android.content.Intent -import android.content.res.Configuration -import android.graphics.Color -import android.graphics.Typeface -import android.os.Bundle -import android.util.TypedValue -import android.view.Gravity -import android.view.View -import android.widget.FrameLayout -import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextView -import io.evcc.android.R -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * Config for the forecast widgets: pick a server, and (Solar only) whether to - * adjust the forecast to real production. Stored per appWidgetId in - * WidgetConfig. Shared by all four forecast types; the Solar toggle is shown - * only when the widget being configured is the Solar provider. Each choice - * fetches live data and shows a preview of the actual widget (see - * WidgetPreview) before committing via the "Use this" button. - */ -class ForecastWidgetConfigActivity : Activity() { - private val scope = MainScope() - private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID - private lateinit var kind: ForecastKind - private var pending: Pair? = null // (serverId, adjust) shown in the preview, ready to confirm - - private lateinit var titleView: TextView - private lateinit var previewContainer: FrameLayout - private lateinit var container: LinearLayout // holds the tappable rows - private lateinit var confirmButton: TextView - - private val dark: Boolean - get() = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setResult(RESULT_CANCELED) - - appWidgetId = intent?.extras?.getInt( - AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID, - ) ?: AppWidgetManager.INVALID_APPWIDGET_ID - if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { - finish() - return - } - - val className = AppWidgetManager.getInstance(this).getAppWidgetInfo(appWidgetId)?.provider?.className - kind = when { - className?.endsWith("EvccSolarWidgetReceiver") == true -> ForecastKind.SOLAR - className?.endsWith("EvccPriceWidgetReceiver") == true -> ForecastKind.PRICE - className?.endsWith("EvccCo2WidgetReceiver") == true -> ForecastKind.CO2 - else -> ForecastKind.FEEDIN - } - - setContentView(buildLayout()) - showServers() - } - - // --- UI helpers (mirrors LoadpointWidgetConfigActivity) --- - - private fun dp(v: Int): Int = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, v.toFloat(), resources.displayMetrics, - ).toInt() - - private fun buildLayout(): View { - val root = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(0, dp(24), 0, 0) - } - titleView = TextView(this).apply { - setPadding(dp(20), dp(8), dp(20), dp(16)) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f) - setTypeface(typeface, Typeface.BOLD) - } - previewContainer = FrameLayout(this).apply { - setPadding(dp(20), 0, dp(20), dp(4)) - visibility = View.GONE - } - container = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } - val scroll = ScrollView(this).apply { - layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f) - addView(container) - } - confirmButton = TextView(this).apply { - text = getString(R.string.widget_androidConfig_useThis) - setTextColor(Color.WHITE) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) - setTypeface(typeface, Typeface.BOLD) - gravity = Gravity.CENTER - setPadding(dp(20), dp(16), dp(20), dp(16)) - setBackgroundColor(0xFF0FDE41.toInt()) - visibility = View.GONE - setOnClickListener { pending?.let { (serverId, adjust) -> save(serverId, adjust) } } - } - root.addView(titleView) - root.addView(previewContainer) - root.addView(scroll) - root.addView(confirmButton) - return root - } - - private fun setRows(items: List, onClick: ((Int) -> Unit)?) { - container.removeAllViews() - items.forEachIndexed { index, label -> - val row = TextView(this).apply { - text = label - setPadding(dp(20), dp(18), dp(20), dp(18)) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) - setTextColor(if (onClick != null) textColor() else Color.GRAY) - if (onClick != null) { - isClickable = true - setBackgroundResource(selectableItemBackground()) - setOnClickListener { onClick(index) } - } - } - container.addView(row) - } - } - - private fun selectableItemBackground(): Int { - val tv = TypedValue() - theme.resolveAttribute(android.R.attr.selectableItemBackground, tv, true) - return tv.resourceId - } - - private fun textColor(): Int { - val tv = TypedValue() - return if (theme.resolveAttribute(android.R.attr.textColorPrimary, tv, true)) { - resources.getColor(tv.resourceId, theme) - } else { - Color.DKGRAY - } - } - - // --- flow --- - - private fun showServers() { - titleView.text = getString(R.string.widget_androidConfig_chooseServer) - val servers = SharedStore.servers(this) - when { - servers.isEmpty() -> setRows(listOf(getString(R.string.widget_androidConfig_noServers)), null) - servers.size == 1 -> onServer(servers[0]) - else -> setRows(servers.map { it.displayTitle }) { index -> onServer(servers[index]) } - } - } - - private fun onServer(server: StoredServer) { - if (kind == ForecastKind.SOLAR) showAdjust(server) else preview(server, adjust = true) - } - - private fun showAdjust(server: StoredServer) { - titleView.text = getString(R.string.widget_androidConfig_adjustQuestion) - setRows( - listOf( - getString(R.string.widget_androidConfig_yesRecommended), - getString(R.string.widget_androidConfig_no), - ), - ) { index -> preview(server, adjust = index == 0) } - } - - /** Fetches live data for the chosen server (+ adjust setting) and shows a preview of the real widget. */ - private fun preview(server: StoredServer, adjust: Boolean) { - pending = null - confirmButton.visibility = View.GONE - showPreview(WidgetPreview.message(this, getString(R.string.widget_androidConfig_loadingPreview), dark)) - scope.launch { - val state = withContext(Dispatchers.IO) { loadForecastState(this@ForecastWidgetConfigActivity, kind, server, adjust) } - if (state !is ForecastState.Data) { - showPreview( - WidgetPreview.message( - this@ForecastWidgetConfigActivity, - getString(R.string.widget_androidConfig_previewError), - dark, - ), - ) - return@launch - } - showPreview(WidgetPreview.forecast(this@ForecastWidgetConfigActivity, kind, state, dark)) - pending = server.id to adjust - confirmButton.visibility = View.VISIBLE - } - } - - private fun showPreview(view: View) { - previewContainer.removeAllViews() - previewContainer.addView(view) - previewContainer.visibility = View.VISIBLE - } - - private fun save(serverId: String, adjust: Boolean) { - WidgetConfig.saveForecast(this, appWidgetId, serverId, adjust) - // ask the just-configured widget to render now - AppWidgetManager.getInstance(this).getAppWidgetInfo(appWidgetId)?.provider?.let { provider -> - sendBroadcast( - Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE).apply { - component = provider - putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(appWidgetId)) - }, - ) - } - setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)) - finish() - } -} diff --git a/targets/android-widget/kotlin/LoadpointWidget.kt b/targets/android-widget/kotlin/LoadpointWidget.kt index 09288a3..d001989 100644 --- a/targets/android-widget/kotlin/LoadpointWidget.kt +++ b/targets/android-widget/kotlin/LoadpointWidget.kt @@ -45,8 +45,7 @@ import io.evcc.android.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -// visible (not private) so ForecastWidget.kt can reuse it for its own deep link -fun deepLinkAction(uri: String): Action = actionStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri))) +private fun deepLinkAction(uri: String): Action = actionStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri))) /** * Loadpoint home-screen widget (Android counterpart of LoadpointWidget.swift / diff --git a/targets/android-widget/kotlin/Theme.kt b/targets/android-widget/kotlin/Theme.kt index bd4928c..f9922bc 100644 --- a/targets/android-widget/kotlin/Theme.kt +++ b/targets/android-widget/kotlin/Theme.kt @@ -13,12 +13,7 @@ import androidx.glance.unit.ColorProvider // .dark` branches in LoadpointViews.swift/Views.swift/Theme.swift. private val evccDarkGreen = Color(0xFF0FDE41) private val evccDarkerGreen = Color(0xFF0BA631) -private val evccYellow = Color(0xFFFAF000) -private val evccDarkYellow = Color(0xFFF6BB0F) private val evccOrange = Color(0xFFFF9000) -private val evccPrice = Color(0xFFFF912F) -private val evccCo2 = Color(0xFF00916E) -private val co2Dark = Color(0xFF1BB88F) private val bsGrayMedium = Color(0xFF93949E) private val bsGrayDeep = Color(0xFF010322) @@ -56,11 +51,6 @@ val powerUnitStyle = TextStyle(color = textSecondary, fontSize = 11.sp, fontWeig val statusStyle = TextStyle(fontSize = 10.sp, fontWeight = FontWeight.Bold) val modeChipStyle = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Bold) -val headerHeadlineStyle = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold) -val headerHeadlineUnitStyle = TextStyle(fontSize = 11.sp, fontWeight = FontWeight.Bold) -val headerSubStyle = TextStyle(color = textSecondary, fontSize = 10.sp, fontWeight = FontWeight.Medium) -val footerStyle = TextStyle(color = textSecondary, fontSize = 11.sp, fontWeight = FontWeight.Medium) -val footerEmphasisStyle = TextStyle(fontSize = 11.sp, fontWeight = FontWeight.Bold) val messageTitleStyle = TextStyle(color = textPrimary, fontSize = 13.sp, fontWeight = FontWeight.Bold) val messageBodyStyle = TextStyle(color = textSecondary, fontSize = 11.sp) val notConfiguredTitleStyle = TextStyle(color = ColorProvider(onGreen), fontSize = 15.sp, fontWeight = FontWeight.Bold) @@ -129,29 +119,3 @@ fun modeSelectedBackgroundArgb(dark: Boolean): Int = if (dark) Color.White.toArg fun modeSelectedTextArgb(dark: Boolean): Int = if (dark) Color.Black.toArgb() else Color.White.toArgb() fun modeUnselectedBackgroundArgb(dark: Boolean): Int = if (dark) modeBgDarkArgb else modeBgLightArgb fun modeUnselectedTextArgb(dark: Boolean): Int = if (dark) modeTextDarkArgb else modeTextLightArgb - -// -- forecast per-type palette (mirrors Theme.swift's Palette.make) -- - -data class Palette(val accent: ColorProvider, val headline: ColorProvider, val accentDay: Color, val accentNight: Color) - -fun palette(kind: ForecastKind): Palette = when (kind) { - ForecastKind.SOLAR -> Palette( - accent = ColorProvider(evccDarkGreen), - headline = ColorProvider(day = evccDarkerGreen, night = evccDarkGreen), - accentDay = evccDarkerGreen, accentNight = evccDarkGreen, - ) - ForecastKind.PRICE -> Palette( - accent = ColorProvider(evccPrice), headline = ColorProvider(evccPrice), - accentDay = evccPrice, accentNight = evccPrice, - ) - ForecastKind.CO2 -> Palette( - accent = ColorProvider(day = evccCo2, night = co2Dark), - headline = ColorProvider(day = evccCo2, night = co2Dark), - accentDay = evccCo2, accentNight = co2Dark, - ) - ForecastKind.FEEDIN -> Palette( - accent = ColorProvider(day = evccDarkYellow, night = evccYellow), - headline = ColorProvider(day = evccDarkYellow, night = evccYellow), - accentDay = evccDarkYellow, accentNight = evccYellow, - ) -} diff --git a/targets/android-widget/kotlin/WidgetConfig.kt b/targets/android-widget/kotlin/WidgetConfig.kt index 24b8f37..386d05a 100644 --- a/targets/android-widget/kotlin/WidgetConfig.kt +++ b/targets/android-widget/kotlin/WidgetConfig.kt @@ -65,24 +65,6 @@ object WidgetConfig { return serverId to lpIndex } - // --- forecast widgets (server + solar "adjust to real production" toggle) --- - // These share the server_ key with the loadpoint config but have no - // loadpoint index, so they don't use the pending/resolve mechanism. - - fun saveForecast(context: Context, appWidgetId: Int, serverId: String?, adjust: Boolean) { - prefs(context).edit() - .putString("server_$appWidgetId", serverId) - .putBoolean("adjust_$appWidgetId", adjust) - .commit() - } - - fun forecastServerId(context: Context, appWidgetId: Int): String? = - prefs(context).getString("server_$appWidgetId", null) - - /** Solar "adjust to real production" toggle; defaults to on (apply scale). */ - fun forecastAdjust(context: Context, appWidgetId: Int): Boolean = - prefs(context).getBoolean("adjust_$appWidgetId", true) - fun clear(context: Context, appWidgetId: Int) { prefs(context).edit() .remove("server_$appWidgetId") diff --git a/targets/android-widget/kotlin/WidgetPreview.kt b/targets/android-widget/kotlin/WidgetPreview.kt index daeaaef..89a1e65 100644 --- a/targets/android-widget/kotlin/WidgetPreview.kt +++ b/targets/android-widget/kotlin/WidgetPreview.kt @@ -11,16 +11,15 @@ import android.view.ViewGroup import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView -import androidx.compose.ui.graphics.toArgb import io.evcc.android.R /** - * Builds a plain-Views mock of the Loadpoint/Forecast widgets for the config - * screens' live preview. Glance content can't be embedded in a classic-Views - * Activity without pulling in the full Compose UI stack (this repo is - * deliberately Compose-free outside Glance itself), so this reuses the same - * data plus the same ChartRenderer/ProgressBarRenderer bitmaps to approximate - * the real widget closely rather than rendering it exactly. + * Builds a plain-Views mock of the Loadpoint widget for the config screen's + * live preview. Glance content can't be embedded in a classic-Views Activity + * without pulling in the full Compose UI stack (this repo is deliberately + * Compose-free outside Glance itself), so this reuses the same data plus the + * same ProgressBarRenderer bitmaps to approximate the real widget closely + * rather than rendering it exactly. */ object WidgetPreview { private fun dp(context: Context, v: Int): Int = TypedValue.applyDimension( @@ -142,57 +141,4 @@ object WidgetPreview { return root } - - private fun footerSide(context: Context, side: FooterSide, emphasisColor: Int, secondary: Int): LinearLayout { - val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } - if (side.prefix != null) row.addView(text(context, side.prefix, 10f, secondary)) - row.addView(text(context, side.emphasis, 10f, emphasisColor, bold = true)) - if (side.label != null) row.addView(text(context, " ${side.label}", 10f, secondary)) - return row - } - - fun forecast(context: Context, kind: ForecastKind, data: ForecastState.Data, dark: Boolean): View { - val d = { v: Int -> dp(context, v) } - val p = palette(kind) - val headlineArgb = (if (dark) p.accentNight else p.accentDay).toArgb() - val secondary = textSecondaryArgb(dark) - - val root = card(context, cardBackgroundArgb(dark)) - - val headerRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.BOTTOM } - headerRow.addView( - text(context, kind.title(context), 15f, headlineArgb, bold = true).apply { - layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) - }, - ) - val valueCol = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL; gravity = Gravity.END } - val valueRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } - valueRow.addView(text(context, data.value, 15f, headlineArgb, bold = true)) - valueRow.addView(text(context, " ${data.unit}", 10f, headlineArgb, bold = true)) - valueCol.addView(valueRow) - valueCol.addView(text(context, context.getString(R.string.widget_now), 9f, secondary)) - headerRow.addView(valueCol) - root.addView(headerRow) - - root.addView(spacer(context, 4)) - root.addView( - ImageView(context).apply { - setImageBitmap(data.chart) - scaleType = ImageView.ScaleType.FIT_XY - layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, d(52)) - }, - ) - root.addView(spacer(context, 5)) - - val footerRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } - footerRow.addView( - footerSide(context, data.footerLeft, headlineArgb, secondary).apply { - layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) - }, - ) - footerRow.addView(footerSide(context, data.footerRight, textPrimaryArgb(dark), secondary)) - root.addView(footerRow) - - return root - } }