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/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/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..e827145
--- /dev/null
+++ b/scripts/androidWidget/withAndroidWidget.ts
@@ -0,0 +1,297 @@
+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";
+// 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
+// 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 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) => 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" } }] },
+ ],
+ "meta-data": [
+ {
+ $: {
+ "android:name": "android.appwidget.provider",
+ "android:resource": `@xml/${infoResource}`,
+ },
+ },
+ ],
+ };
+ app.receiver.push(receiver);
+};
+
+const withWidgetReceiver: ConfigPlugin = (config) =>
+ withAndroidManifest(config, (config) => {
+ const app = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);
+
+ pushWidgetReceiver(app, "EvccLoadpointWidgetReceiver", "loadpoint_widget_info", "Loadpoint");
+
+ // widget placement configuration Activity (server, then loadpoint picker)
+ app.activity = app.activity ?? [];
+ 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": [
+ {
+ action: [{ $: { "android:name": "android.appwidget.action.APPWIDGET_CONFIGURE" } }],
+ },
+ ],
+ });
+ }
+ 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 = `
+
+
+
+
+
+
+
+
+
+
+`;
+
+// Static preview layout shown in the widget picker (the Glance content only
+// renders once placed). Kept representative of the real widget.
+const loadpointPreviewXml = `
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+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;
+
+ // 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(packageRe, pkg);
+ 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));
+
+ const layoutDir = path.join(main, "res", "layout");
+ fs.mkdirSync(layoutDir, { recursive: true });
+ fs.writeFileSync(path.join(layoutDir, "loadpoint_widget_preview.xml"), loadpointPreviewXml);
+
+ const drawableDir = path.join(main, "res", "drawable");
+ fs.mkdirSync(drawableDir, { recursive: true });
+ 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
+ // 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;
+ },
+ ]);
+
+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/scripts/build-widget-strings.mts b/scripts/build-widget-strings.mts
index 81d19b2..c6b7b9e 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,30 +181,29 @@ 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)) {
- 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) {
@@ -194,8 +211,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/README.md b/targets/android-widget/README.md
new file mode 100644
index 0000000..66a8897
--- /dev/null
+++ b/targets/android-widget/README.md
@@ -0,0 +1,141 @@
+# 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
+
+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:
+
+- `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.
+- **Per-instance config**: `LoadpointWidgetConfigActivity.kt` (pick server, then
+ 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) 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`): 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`, 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` 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 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 }`.
+- **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.
+- **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`. 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` - 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 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
+
+```
+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..39f6934
--- /dev/null
+++ b/targets/android-widget/kotlin/ApiClient.kt
@@ -0,0 +1,140 @@
+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[].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?,
+ val vehicleTitle: String?,
+ val vehicleSoc: Double?,
+ val effectiveLimitSoc: Double?,
+ val chargePower: Double?,
+ 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 {
+ 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() },
+ 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/Format.kt b/targets/android-widget/kotlin/Format.kt
new file mode 100644
index 0000000..71d070e
--- /dev/null
+++ b/targets/android-widget/kotlin/Format.kt
@@ -0,0 +1,85 @@
+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
+ }
+}
+
+/** 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
new file mode 100644
index 0000000..d001989
--- /dev/null
+++ b/targets/android-widget/kotlin/LoadpointWidget.kt
@@ -0,0 +1,429 @@
+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
+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.action.actionStartActivity
+import androidx.glance.LocalSize
+import androidx.glance.appwidget.SizeMode
+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.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
+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
+
+private 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
+ * loadpoint; per-instance configuration (server + loadpoint picker) is set by
+ * LoadpointWidgetConfigActivity.
+ *
+ * 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 }`. 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)
+
+// 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.
+// 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) {
+ 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
+ object Unreachable : 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.* 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 {
+ 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(context: Context, lp: Loadpoint): String {
+ val vt = lp.vehicleTitle?.trim().orEmpty()
+ return vt.ifEmpty { lp.title ?: context.getString(R.string.widget_loadpoint_name) }
+}
+
+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))
+
+ 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(context, state, resolved) }
+ }
+
+ 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(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,
+ ) {
+ when (state) {
+ is LoadpointState.Data -> LoadpointBody(context, state)
+ 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)
+ }
+ }
+ }
+
+ @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)
+ val heating = lp.chargerFeatureHeating
+
+ 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)) {}
+ Spacer(GlanceModifier.width(5.dp))
+ Text(statusLabel(context, 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)
+ }
+ }
+
+ // 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(state.lp).forEachIndexed { i, mode ->
+ if (i > 0) Spacer(GlanceModifier.width(4.dp))
+ ModeChip(context, lp = state.lp, mode = 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, lp = state.lp, mode = mode, serverId = state.serverId, lpIndex = state.lpIndex,
+ modifier = GlanceModifier.fillMaxWidth().defaultWeight(),
+ )
+ }
+ }
+
+ @Composable
+ private fun ModeChip(
+ context: Context,
+ lp: Loadpoint,
+ mode: String,
+ serverId: String,
+ lpIndex: Int,
+ modifier: GlanceModifier = GlanceModifier,
+ ) {
+ val selected = mode == lp.mode
+ Box(
+ modifier = modifier
+ .background(if (selected) modeSelectedBackground else modeUnselectedBackground)
+ .cornerRadius(9.dp)
+ .padding(horizontal = 8.dp, vertical = 5.dp)
+ .clickable(
+ actionRunCallback(
+ actionParametersOf(
+ ModeAction.serverKey to serverId,
+ ModeAction.lpKey to (lpIndex + 1), // API is 1-based
+ ModeAction.modeKey to mode,
+ ),
+ ),
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = modeChipLabel(context, lp, mode),
+ style = modeChipStyle.copy(color = if (selected) modeSelectedText else modeUnselectedText),
+ )
+ }
+ }
+
+ @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)
+ }
+ }
+}
+
+/** 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")
+ }
+}
+
+/** 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()
+}
diff --git a/targets/android-widget/kotlin/LoadpointWidgetConfigActivity.kt b/targets/android-widget/kotlin/LoadpointWidgetConfigActivity.kt
new file mode 100644
index 0000000..6764fd6
--- /dev/null
+++ b/targets/android-widget/kotlin/LoadpointWidgetConfigActivity.kt
@@ -0,0 +1,216 @@
+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
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import io.evcc.android.R
+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).
+ * 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
+ * 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 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)
+ // 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)
+ }
+ 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_useThisLoadpoint)
+ 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
+ }
+
+ /**
+ * 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 = getString(R.string.widget_androidConfig_chooseServer)
+ val servers = SharedStore.servers(this)
+ when {
+ servers.isEmpty() ->
+ 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]) }
+ }
+ }
+
+ private fun showLoadpoints(server: StoredServer) {
+ 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(getString(R.string.widget_androidConfig_noLoadpoints)), null)
+ return@launch
+ }
+ 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, 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,
+ getString(R.string.widget_androidConfig_previewError),
+ 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)
+ 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/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/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..f9922bc
--- /dev/null
+++ b/targets/android-widget/kotlin/Theme.kt
@@ -0,0 +1,121 @@
+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 + 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 evccOrange = Color(0xFFFF9000)
+
+private val bsGrayMedium = Color(0xFF93949E)
+private val bsGrayDeep = Color(0xFF010322)
+
+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)
+
+// 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 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
diff --git a/targets/android-widget/kotlin/WidgetConfig.kt b/targets/android-widget/kotlin/WidgetConfig.kt
new file mode 100644
index 0000000..386d05a
--- /dev/null
+++ b/targets/android-widget/kotlin/WidgetConfig.kt
@@ -0,0 +1,75 @@
+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
+ }
+
+ fun clear(context: Context, appWidgetId: Int) {
+ prefs(context).edit()
+ .remove("server_$appWidgetId")
+ .remove("lp_$appWidgetId")
+ .remove("adjust_$appWidgetId")
+ .apply()
+ }
+}
diff --git a/targets/android-widget/kotlin/WidgetPreview.kt b/targets/android-widget/kotlin/WidgetPreview.kt
new file mode 100644
index 0000000..89a1e65
--- /dev/null
+++ b/targets/android-widget/kotlin/WidgetPreview.kt
@@ -0,0 +1,144 @@
+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 io.evcc.android.R
+
+/**
+ * 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(
+ 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(context, 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(context, 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, modeChipLabel(context, lp, mode), mode == lp.mode, dark))
+ }
+ root.addView(chipsRow)
+
+ return root
+ }
+}
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..b40c17d
--- /dev/null
+++ b/targets/android-widget/res/values-b+ar/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+Solar
+ Normal
+ Fast
+ Off
+ On
+ Solar
+ Smart
+ 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..b40c17d
--- /dev/null
+++ b/targets/android-widget/res/values-b+bs/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+Solar
+ Normal
+ Fast
+ Off
+ On
+ Solar
+ Smart
+ 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..ef84d58
--- /dev/null
+++ b/targets/android-widget/res/values-b+cs/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+Solar
+ Normal
+ Rychlé
+ Vypnuto
+ On
+ Solár
+ Chytrý
+ 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..ada8e9b
--- /dev/null
+++ b/targets/android-widget/res/values-b+da/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+Sol
+ Normal
+ Hurtig
+ Fra
+ On
+ Sol
+ Smart
+ 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..1754981
--- /dev/null
+++ b/targets/android-widget/res/values-b+de/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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 …
+ Boost
+ Min+PV
+ Normal
+ Schnell
+ Aus
+ An
+ PV
+ Smart
+ 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..4e58e78
--- /dev/null
+++ b/targets/android-widget/res/values-b+el/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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
+ Θερμαίνεται…
+ Αναμονή.
+ Έτοιμο. Αναμονή για θερμαντήρα…
+ Φορτίζει…
+ Συνδέθηκε.
+ Αποσυνδεδεμένο.
+ Τελείωσε.
+ Έτοιμο. Αναμονή για όχημα…
+ Boost
+ Ελαχ+Φ/Β
+ Normal
+ Ταχύ
+ Κλειστό
+ On
+ Φ/Β
+ Έξυπνο
+ 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..b40c17d
--- /dev/null
+++ b/targets/android-widget/res/values-b+et/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+Solar
+ Normal
+ Fast
+ Off
+ On
+ Solar
+ Smart
+ 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..76f49e1
--- /dev/null
+++ b/targets/android-widget/res/values-b+fi/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+PV
+ Normal
+ Välitön
+ Seis
+ On
+ PV
+ Älykäs
+ 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..35c2066
--- /dev/null
+++ b/targets/android-widget/res/values-b+fr/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+Solaire
+ Normal
+ Rapide
+ Arrêté
+ On
+ Solaire
+ Intelligent
+ 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..2fb9a59
--- /dev/null
+++ b/targets/android-widget/res/values-b+hr/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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 …
+ Boost
+ Min+Solarno
+ Normal
+ Brzo
+ Isključeno
+ On
+ Solarno
+ Pametno
+ 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..d1c2701
--- /dev/null
+++ b/targets/android-widget/res/values-b+hu/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+Szolár
+ Normal
+ Gyors
+ Ki
+ On
+ Szolár
+ Okos
+ 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..767ac4c
--- /dev/null
+++ b/targets/android-widget/res/values-b+it/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+Solare
+ Normal
+ Veloce
+ Off
+ On
+ Solare
+ Intelligente
+ 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..30ef7c2
--- /dev/null
+++ b/targets/android-widget/res/values-b+ja/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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
+ 加熱中…
+ 待機中。
+ 加熱開始を待機中…
+ 充電中…
+ 接続済み。
+ 切断済み。
+ 充電完了。
+ 準備完了。車両の応答を待っています…
+ Boost
+ Min+太陽光
+ Normal
+ 高速
+ オフ
+ On
+ 太陽光
+ スマート
+ 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..0755c7b
--- /dev/null
+++ b/targets/android-widget/res/values-b+lb/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+PV
+ Normal
+ Schnell
+ Aus
+ On
+ PV
+ Clever
+ 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..63037ef
--- /dev/null
+++ b/targets/android-widget/res/values-b+lt/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+Saulė
+ Normal
+ Greitas
+ Stop
+ On
+ Saulė
+ Išmanus
+ 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..b40c17d
--- /dev/null
+++ b/targets/android-widget/res/values-b+nb+NO/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+Solar
+ Normal
+ Fast
+ Off
+ On
+ Solar
+ Smart
+ 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..c4eddd5
--- /dev/null
+++ b/targets/android-widget/res/values-b+nl/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+PV
+ Normal
+ Snel
+ Uit
+ On
+ PV
+ Slim
+ 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..bf635da
--- /dev/null
+++ b/targets/android-widget/res/values-b+pl/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+Słońce
+ Normal
+ Szybko
+ Stop
+ On
+ Słońce
+ Inteligentny
+ 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..f700ab5
--- /dev/null
+++ b/targets/android-widget/res/values-b+pt/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+Solar
+ Normal
+ Rápido
+ Off
+ On
+ Solar
+ Smart
+ 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..bacab77
--- /dev/null
+++ b/targets/android-widget/res/values-b+sk/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+Solár
+ Normal
+ Rýchlo
+ Vypnuté
+ On
+ Solár
+ Inteligentné
+ 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..6a90a40
--- /dev/null
+++ b/targets/android-widget/res/values-b+sl/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+Sonce
+ Normal
+ Hitro
+ Izklop
+ On
+ Sonce
+ Pametno
+ 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..dfc4f10
--- /dev/null
+++ b/targets/android-widget/res/values-b+sv/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+Sol
+ Normal
+ Snabbt
+ Av
+ On
+ Sol
+ Smart
+ 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..1cb1b8c
--- /dev/null
+++ b/targets/android-widget/res/values-b+ta/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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
+ வெப்பமாக்கல்…
+ காத்திருப்பு.
+ சூடாக்க தயார்…
+ சார்சிங்…
+ இணைக்கப்பட்டுள்ளது.
+ துண்டிக்கப்பட்டது.
+ முடிந்தது.
+ ஆயத்தம். வாகனத்திற்காக காத்திருக்கிறது…
+ Boost
+ குறை+ஞாயிறு
+ Normal
+ வேகமாக
+ அணை
+ On
+ ஞாயிறு
+ அறிவாளி
+ 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..1fa096d
--- /dev/null
+++ b/targets/android-widget/res/values-b+tr/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Asg.+GES
+ Normal
+ Hızlı
+ Kapalı
+ On
+ GES
+ Akıllı
+ 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..49291f4
--- /dev/null
+++ b/targets/android-widget/res/values-b+uk/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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
+ Опалення…
+ Режим очікування.
+ Готовий. Очікування обігрівача…
+ Зарядка…
+ Підключено.
+ Відключено.
+ Готово.
+ Готовий. Очікування на транспортний засіб…
+ Boost
+ Мін+Сонце
+ Normal
+ Швидко
+ Вимк.
+ On
+ Сонячна
+ Розумний
+ 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..dbb7e71
--- /dev/null
+++ b/targets/android-widget/res/values-b+zh+Hans/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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
+ 加热中…
+ 待机。
+ 准备就绪。等待加热器启动…
+ 充电中…
+ 已连接。
+ 已断开连接。
+ 已完成。
+ 准备就绪。等待车辆连接…
+ Boost
+ 最少+太阳能
+ Normal
+ 快速
+ 关闭
+ On
+ 太阳能
+ 智能
+ 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..b40c17d
--- /dev/null
+++ b/targets/android-widget/res/values/strings.xml
@@ -0,0 +1,58 @@
+
+
+ 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…
+ Boost
+ Min+Solar
+ Normal
+ Fast
+ Off
+ On
+ Solar
+ Smart
+ 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": {
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
+ }
+}