From 9177a926df97e0eb1b4e60d669b832e471a484a8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 17 Jun 2026 23:31:54 +0200 Subject: [PATCH 1/3] feat(i18n): data-driven language picker + locale config Make the supported-language list a single source of truth so community translations show up with no code change: add res/xml/locales_config.xml (en, de) and reference it via android:localeConfig, which also surfaces the per-app language entry in Android 13+ system settings. Rewrite AppLanguage to parse locales_config.xml for the supported BCP-47 tags and expose currentTag/apply/displayName (autonyms), dropping the hardcoded LanguagePref enum; the Settings picker is now built from that list. Remove the now-unused settings_language_german/english strings. Adding a language is now: drop in values-/strings.xml and add one line. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/main/AndroidManifest.xml | 1 + .../calendula/ui/settings/AppLanguage.kt | 80 ++++++++++++++----- .../calendula/ui/settings/SettingsScreen.kt | 17 ++-- app/src/main/res/values-de/strings.xml | 2 - app/src/main/res/values/strings.xml | 2 - app/src/main/res/xml/locales_config.xml | 13 +++ 6 files changed, 83 insertions(+), 32 deletions(-) create mode 100644 app/src/main/res/xml/locales_config.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7ca396d..5437387 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -25,6 +25,7 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:localeConfig="@xml/locales_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Calendula" diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppLanguage.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppLanguage.kt index 05e1178..26e59d5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppLanguage.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppLanguage.kt @@ -1,36 +1,78 @@ package de.jeanlucmakiola.calendula.ui.settings +import android.content.Context import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat +import de.jeanlucmakiola.calendula.R +import org.xmlpull.v1.XmlPullParser +import java.util.Locale -/** UI-facing language choice. AUTO follows the system languages. */ -enum class LanguagePref { AUTO, GERMAN, ENGLISH } +private const val ANDROID_NS = "http://schemas.android.com/apk/res/android" /** - * Per-app language via AppCompatDelegate. On API 33+ this delegates to the - * platform per-app-languages API; below that the appcompat backport persists - * the choice itself (manifest `autoStoreLocales` service), so we don't mirror - * it in DataStore. Setting a locale recreates the activity, which re-reads the - * current value for the dropdown. + * Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml. + * + * That file is the single source of truth for which languages we ship: dropping + * in a values- translation and adding a matching `` entry makes the + * language show up here and in the system per-app-language settings, with no + * other code change. The system-default choice is represented as `null`. + * + * On API 33+ this delegates to the platform per-app-languages API; below that + * the appcompat backport persists the choice itself (manifest `autoStoreLocales` + * service), so we don't mirror it in DataStore. Setting a locale recreates the + * activity, which re-reads the current value for the picker. */ object AppLanguage { - fun current(): LanguagePref { - val locales = AppCompatDelegate.getApplicationLocales() - if (locales.isEmpty) return LanguagePref.AUTO - return when (locales[0]?.language) { - "de" -> LanguagePref.GERMAN - "en" -> LanguagePref.ENGLISH - else -> LanguagePref.AUTO + /** + * The BCP-47 tags the app ships translations for, in declaration order, as + * listed in locales_config.xml. Returns whatever could be parsed; a missing + * or malformed config yields an empty list (the picker then offers only the + * system-default entry rather than crashing). + */ + fun supportedTags(context: Context): List { + val tags = mutableListOf() + val parser = context.resources.getXml(R.xml.locales_config) + try { + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + if (event == XmlPullParser.START_TAG && parser.name == "locale") { + parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add) + } + event = parser.next() + } + } catch (_: Exception) { + // Fall back to whatever was parsed before the failure. + } finally { + parser.close() } + return tags } - fun apply(pref: LanguagePref) { - val locales = when (pref) { - LanguagePref.AUTO -> LocaleListCompat.getEmptyLocaleList() - LanguagePref.GERMAN -> LocaleListCompat.forLanguageTags("de") - LanguagePref.ENGLISH -> LocaleListCompat.forLanguageTags("en") + /** The applied app language as a BCP-47 tag, or `null` when following the system. */ + fun currentTag(): String? { + val locales = AppCompatDelegate.getApplicationLocales() + return if (locales.isEmpty) null else locales[0]?.toLanguageTag() + } + + /** Apply a BCP-47 tag, or `null` to follow the system languages. */ + fun apply(tag: String?) { + val locales = if (tag == null) { + LocaleListCompat.getEmptyLocaleList() + } else { + LocaleListCompat.forLanguageTags(tag) } AppCompatDelegate.setApplicationLocales(locales) } + + /** + * The autonym for a tag — the language's own name in its own script, e.g. + * "Deutsch", "English", "Français" — so users find their language regardless + * of the current UI language. Capitalised per the language's own rules. + */ + fun displayName(tag: String): String { + val locale = Locale.forLanguageTag(tag) + return locale.getDisplayName(locale) + .replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() } + } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index f0949b5..bfd3297 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -188,11 +188,15 @@ private fun SettingsHub( @Composable private fun LanguageRow(position: Position) { + val context = LocalContext.current // Setting a locale recreates the activity; mirror the choice locally so the // row updates instantly even before the recreation lands. - var current by remember { mutableStateOf(AppLanguage.current()) } + var current by remember { mutableStateOf(AppLanguage.currentTag()) } var showDialog by remember { mutableStateOf(false) } + // null = follow the system; the rest are BCP-47 tags from locales_config.xml. + val options = remember { listOf(null) + AppLanguage.supportedTags(context) } + GroupedRow( title = stringResource(R.string.settings_language), summary = languageLabel(current), @@ -204,7 +208,7 @@ private fun LanguageRow(position: Position) { if (showDialog) { OptionPickerDialog( title = stringResource(R.string.settings_language), - options = LanguagePref.entries, + options = options, selected = current, label = { languageLabel(it) }, onSelect = { @@ -598,10 +602,5 @@ private fun weekStartLabel(pref: WeekStartPref): String = stringResource( ) @Composable -private fun languageLabel(pref: LanguagePref): String = stringResource( - when (pref) { - LanguagePref.AUTO -> R.string.settings_language_auto - LanguagePref.GERMAN -> R.string.settings_language_german - LanguagePref.ENGLISH -> R.string.settings_language_english - }, -) +private fun languageLabel(tag: String?): String = + if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 35618e4..5397cd0 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -254,8 +254,6 @@ Sprache App-Sprache Systemstandard - Deutsch - English Design, dynamische Farben, Wochenstart Standardfelder für neue Termine diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1abe92f..f691317 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -251,8 +251,6 @@ Language App language System default - Deutsch - English Theme, dynamic colour, week start Default fields for new events diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml new file mode 100644 index 0000000..f68afab --- /dev/null +++ b/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,13 @@ + + + + + + From cf380b6eabcc69084e0fb52be7aacd11f5110c78 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 18 Jun 2026 08:43:06 +0200 Subject: [PATCH 2/3] ci(i18n): translation parity guard + allow partial translations Add scripts/check_translations.py and a lightweight Translations workflow that runs it (no Android SDK needed) so Weblate PRs get fast feedback. The script fails on stale keys (present in a translation but not the base) and on translating translatable="false" entries; missing keys are reported as coverage only. Downgrade lint's MissingTranslation to informational: partial community translations are expected and fall back to the English base at runtime. Stale/extra keys (ExtraTranslation) remain fatal in lintDebug. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/translations.yaml | 41 +++++++++++++ app/build.gradle.kts | 9 +++ scripts/check_translations.py | 94 ++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 .gitea/workflows/translations.yaml create mode 100755 scripts/check_translations.py diff --git a/.gitea/workflows/translations.yaml b/.gitea/workflows/translations.yaml new file mode 100644 index 0000000..c3440e8 --- /dev/null +++ b/.gitea/workflows/translations.yaml @@ -0,0 +1,41 @@ +name: Translations + +# Fast, SDK-free parity check for translation resources, so Weblate PRs (which +# only touch values-*/strings.xml) get quick feedback without the full Android +# build. The deeper checks still run in CI via lintDebug (ExtraTranslation). +on: + push: + branches: + - '**' + tags-ignore: + - '**' + paths: + - 'app/src/main/res/values*/strings.xml' + - 'app/src/main/res/xml/locales_config.xml' + - 'scripts/check_translations.py' + - '.gitea/workflows/translations.yaml' + +concurrency: + group: translations-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Ensure python3 + run: | + if ! command -v python3 >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then + apt-get update && apt-get install -y python3 + elif command -v apk >/dev/null 2>&1; then + apk add --no-cache python3 + fi + fi + python3 --version + + - name: Check translation parity + run: python3 scripts/check_translations.py diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8d1d8c1..dc36f9c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -78,6 +78,15 @@ android { } } + lint { + // Community translations are expected to be partial — a missing string + // falls back to the English base at runtime — so don't fail the build on + // it. Stale/extra keys (ExtraTranslation) stay fatal; scripts/ + // check_translations.py guards the same invariants with clearer, + // translator-facing messages. + informational += "MissingTranslation" + } + testOptions { unitTests { all { it.useJUnitPlatform() } diff --git a/scripts/check_translations.py b/scripts/check_translations.py new file mode 100755 index 0000000..fc3699c --- /dev/null +++ b/scripts/check_translations.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Validate Android translation resources against the base strings.xml. + +Community translations live in ``app/src/main/res/values-/strings.xml`` +and are produced via Weblate. This guard keeps incoming translation PRs honest: + + * every translation file must be well-formed XML; + * a translation must not define keys absent from the base — those are stale + keys left behind after a rename/removal upstream; + * a translation must not translate strings marked ``translatable="false"`` in + the base (URLs, IDs and the like). + +Missing keys are *allowed* and only reported as coverage: a missing string +falls back to the English base at runtime, so partial translations are fine +(this mirrors the lint config, which downgrades ``MissingTranslation``). + +Exits non-zero if any error is found. Errors are emitted as Gitea/GitHub +Actions ``::error`` annotations so they surface inline on the PR. +""" +from __future__ import annotations + +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +RES_DIR = Path("app/src/main/res") +BASE = RES_DIR / "values" / "strings.xml" +RESOURCE_TAGS = ("string", "plurals", "string-array") + + +def entries(path: Path) -> dict[str, bool]: + """Map resource name -> is-translatable for every entry in ``path``.""" + root = ET.parse(path).getroot() + return { + el.attrib["name"]: el.attrib.get("translatable", "true") != "false" + for el in root + if el.tag in RESOURCE_TAGS and "name" in el.attrib + } + + +def main() -> int: + if not BASE.exists(): + print(f"::error::base resource file {BASE} not found", file=sys.stderr) + return 1 + + base = entries(BASE) + base_keys = set(base) + nontranslatable = {name for name, ok in base.items() if not ok} + translatable_total = len(base_keys - nontranslatable) + + files = sorted(RES_DIR.glob("values-*/strings.xml")) + if not files: + print("No translation files found (values-*/strings.xml).") + return 0 + + errors = 0 + for path in files: + locale = path.parent.name[len("values-"):] + try: + translated = entries(path) + except ET.ParseError as exc: + print(f"::error file={path}::{locale}: malformed XML: {exc}") + errors += 1 + continue + + keys = set(translated) + stale = sorted(keys - base_keys) + translated_fixed = sorted(keys & nontranslatable) + missing = base_keys - nontranslatable - keys + + for name in stale: + print(f"::error file={path}::{locale}: stale key '{name}' is not in the base strings.xml") + errors += 1 + for name in translated_fixed: + print( + f"::error file={path}::{locale}: key '{name}' is translatable=\"false\" " + "in the base and must not be translated" + ) + errors += 1 + + covered = translatable_total - len(missing) + pct = covered * 100 // translatable_total if translatable_total else 100 + verdict = "OK" if not (stale or translated_fixed) else "FAIL" + print(f"{locale:<10} {covered}/{translatable_total} keys ({pct}%) — {verdict}") + + if errors: + print(f"\n{errors} translation error(s) found.", file=sys.stderr) + return 1 + print("\nAll translation files are consistent with the base.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From bab6fd175a5f868cf92cecef4ad2a732f309bc5d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 18 Jun 2026 10:28:13 +0200 Subject: [PATCH 3/3] fix(i18n): make the language picker actually apply on device The in-app language picker silently did nothing: AppCompatDelegate.set ApplicationLocales only syncs to the system from an AppCompatActivity, but MainActivity was a plain ComponentActivity (with a platform theme). Switch MainActivity to AppCompatActivity and base Theme.Calendula on Theme.AppCompat.DayNight.NoActionBar. Changing the locale recreates the activity; set android:windowBackground to a DayNight colour matching the Compose background (light #FBFCFE / dark #101316) so the recreation no longer flashes a contrasting backdrop. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/de/jeanlucmakiola/calendula/MainActivity.kt | 4 ++-- app/src/main/res/values-night/colors.xml | 6 ++++++ app/src/main/res/values/colors.xml | 4 ++++ app/src/main/res/values/themes.xml | 7 ++++++- 4 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 app/src/main/res/values-night/colors.xml diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index f8b07b1..d8d6599 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -3,9 +3,9 @@ package de.jeanlucmakiola.calendula import android.content.Context import android.content.Intent import android.os.Bundle -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.getValue @@ -24,7 +24,7 @@ import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme import kotlinx.datetime.LocalDate @AndroidEntryPoint -class MainActivity : ComponentActivity() { +class MainActivity : AppCompatActivity() { // The occurrence a reminder notification was tapped for (eventId, begin, // end — the detail screen's key shape). singleTop + onNewIntent route a diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..15fc990 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,6 @@ + + + #FF101316 + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index eefed3d..3435ae8 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -3,4 +3,8 @@ #FF5C6B7A #FF5C6B7A + + #FFFBFCFE diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 7a8c0b8..254dfad 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,10 @@ -