Merge pull request 'feat(i18n): data-driven language picker + Weblate translation guard' (#5) from feat/translations into main
All checks were successful
Translations / check (push) Successful in 4s
CI / ci (push) Successful in 1m43s

This commit was merged in pull request #5.
This commit is contained in:
2026-06-18 08:41:46 +00:00
13 changed files with 245 additions and 35 deletions

View File

@@ -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

View File

@@ -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() }

View File

@@ -32,6 +32,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"

View File

@@ -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

View File

@@ -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-<tag> translation and adding a matching `<locale>` 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<String> {
val tags = mutableListOf<String>()
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() }
}
}

View File

@@ -204,11 +204,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<String?>(null) + AppLanguage.supportedTags(context) }
GroupedRow(
title = stringResource(R.string.settings_language),
summary = languageLabel(current),
@@ -220,7 +224,7 @@ private fun LanguageRow(position: Position) {
if (showDialog) {
OptionPicker(
title = stringResource(R.string.settings_language),
options = LanguagePref.entries,
options = options,
selected = current,
label = { languageLabel(it) },
onSelect = {
@@ -848,10 +852,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)

View File

@@ -268,8 +268,6 @@
<string name="settings_section_language">Sprache</string>
<string name="settings_language">App-Sprache</string>
<string name="settings_language_auto">Systemstandard</string>
<string name="settings_language_german">Deutsch</string>
<string name="settings_language_english">English</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>

View File

@@ -0,0 +1,6 @@
<resources>
<!-- Dark-scheme window backdrop, matching the Compose dark background/surface
(#101316) so activity recreation (e.g. language switch) doesn't flash a
lighter grey. See values/colors.xml. -->
<color name="window_background">#FF101316</color>
</resources>

View File

@@ -3,4 +3,8 @@
<color name="seed">#FF5C6B7A</color>
<!-- Adaptive icon background -->
<color name="ic_launcher_background">#FF5C6B7A</color>
<!-- Window backdrop shown during activity recreation (e.g. on a language
switch). Matches the Compose light scheme background/surface so the
recreation is seamless; overridden for dark in values-night. -->
<color name="window_background">#FFFBFCFE</color>
</resources>

View File

@@ -265,8 +265,6 @@
<string name="settings_section_language">Language</string>
<string name="settings_language">App language</string>
<string name="settings_language_auto">System default</string>
<string name="settings_language_german">Deutsch</string>
<string name="settings_language_english">English</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
<string name="settings_event_form_subtitle">Default fields for new events</string>

View File

@@ -1,5 +1,10 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Calendula" parent="android:Theme.Material.Light.NoActionBar">
<!-- AppCompat (DayNight) parent so MainActivity can be an AppCompatActivity,
which is required for AppCompatDelegate.setApplicationLocales (the in-app
language picker) to sync to the system. Actual colours are driven by the
Compose theme; this is essentially the launch/backdrop theme. -->
<style name="Theme.Calendula" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">@color/window_background</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The languages Calendula ships translations for. This is the single source of
truth: each entry must have a matching res/values-<tag>/strings.xml, and is
surfaced automatically in both the in-app language picker (parsed at runtime
by AppLanguage) and the system per-app language settings (Android 13+, via
android:localeConfig in the manifest). To add a community translation, drop
in the values-<tag> folder and add one <locale> line here.
-->
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="de" />
</locale-config>

94
scripts/check_translations.py Executable file
View File

@@ -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-<locale>/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())