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 + } +}