From 9177a926df97e0eb1b4e60d669b832e471a484a8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 17 Jun 2026 23:31:54 +0200 Subject: [PATCH] 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 @@ + + + + + +