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 { testOptions {
unitTests { unitTests {
all { it.useJUnitPlatform() } all { it.useJUnitPlatform() }

View File

@@ -32,6 +32,7 @@
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Calendula" android:theme="@style/Theme.Calendula"

View File

@@ -3,9 +3,9 @@ package de.jeanlucmakiola.calendula
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -24,7 +24,7 @@ import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : AppCompatActivity() {
// The occurrence a reminder notification was tapped for (eventId, begin, // The occurrence a reminder notification was tapped for (eventId, begin,
// end — the detail screen's key shape). singleTop + onNewIntent route a // end — the detail screen's key shape). singleTop + onNewIntent route a

View File

@@ -1,36 +1,78 @@
package de.jeanlucmakiola.calendula.ui.settings package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat 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. */ private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
enum class LanguagePref { AUTO, GERMAN, ENGLISH }
/** /**
* Per-app language via AppCompatDelegate. On API 33+ this delegates to the * Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml.
* platform per-app-languages API; below that the appcompat backport persists *
* the choice itself (manifest `autoStoreLocales` service), so we don't mirror * That file is the single source of truth for which languages we ship: dropping
* it in DataStore. Setting a locale recreates the activity, which re-reads the * in a values-<tag> translation and adding a matching `<locale>` entry makes the
* current value for the dropdown. * 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 { object AppLanguage {
fun current(): LanguagePref { /**
val locales = AppCompatDelegate.getApplicationLocales() * The BCP-47 tags the app ships translations for, in declaration order, as
if (locales.isEmpty) return LanguagePref.AUTO * listed in locales_config.xml. Returns whatever could be parsed; a missing
return when (locales[0]?.language) { * or malformed config yields an empty list (the picker then offers only the
"de" -> LanguagePref.GERMAN * system-default entry rather than crashing).
"en" -> LanguagePref.ENGLISH */
else -> LanguagePref.AUTO 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) { /** The applied app language as a BCP-47 tag, or `null` when following the system. */
val locales = when (pref) { fun currentTag(): String? {
LanguagePref.AUTO -> LocaleListCompat.getEmptyLocaleList() val locales = AppCompatDelegate.getApplicationLocales()
LanguagePref.GERMAN -> LocaleListCompat.forLanguageTags("de") return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
LanguagePref.ENGLISH -> LocaleListCompat.forLanguageTags("en") }
/** 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) 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 @Composable
private fun LanguageRow(position: Position) { private fun LanguageRow(position: Position) {
val context = LocalContext.current
// Setting a locale recreates the activity; mirror the choice locally so the // Setting a locale recreates the activity; mirror the choice locally so the
// row updates instantly even before the recreation lands. // 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) } 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( GroupedRow(
title = stringResource(R.string.settings_language), title = stringResource(R.string.settings_language),
summary = languageLabel(current), summary = languageLabel(current),
@@ -220,7 +224,7 @@ private fun LanguageRow(position: Position) {
if (showDialog) { if (showDialog) {
OptionPicker( OptionPicker(
title = stringResource(R.string.settings_language), title = stringResource(R.string.settings_language),
options = LanguagePref.entries, options = options,
selected = current, selected = current,
label = { languageLabel(it) }, label = { languageLabel(it) },
onSelect = { onSelect = {
@@ -848,10 +852,5 @@ private fun weekStartLabel(pref: WeekStartPref): String = stringResource(
) )
@Composable @Composable
private fun languageLabel(pref: LanguagePref): String = stringResource( private fun languageLabel(tag: String?): String =
when (pref) { if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
LanguagePref.AUTO -> R.string.settings_language_auto
LanguagePref.GERMAN -> R.string.settings_language_german
LanguagePref.ENGLISH -> R.string.settings_language_english
},
)

View File

@@ -268,8 +268,6 @@
<string name="settings_section_language">Sprache</string> <string name="settings_section_language">Sprache</string>
<string name="settings_language">App-Sprache</string> <string name="settings_language">App-Sprache</string>
<string name="settings_language_auto">Systemstandard</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 --> <!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string> <string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</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> <color name="seed">#FF5C6B7A</color>
<!-- Adaptive icon background --> <!-- Adaptive icon background -->
<color name="ic_launcher_background">#FF5C6B7A</color> <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> </resources>

View File

@@ -265,8 +265,6 @@
<string name="settings_section_language">Language</string> <string name="settings_section_language">Language</string>
<string name="settings_language">App language</string> <string name="settings_language">App language</string>
<string name="settings_language_auto">System default</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 --> <!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string> <string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
<string name="settings_event_form_subtitle">Default fields for new events</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"> <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:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</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())