Merge pull request 'feat(i18n): data-driven language picker + Weblate translation guard' (#5) from feat/translations into main
This commit was merged in pull request #5.
This commit is contained in:
41
.gitea/workflows/translations.yaml
Normal file
41
.gitea/workflows/translations.yaml
Normal 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
|
||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
6
app/src/main/res/values-night/colors.xml
Normal file
6
app/src/main/res/values-night/colors.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
13
app/src/main/res/xml/locales_config.xml
Normal file
13
app/src/main/res/xml/locales_config.xml
Normal 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
94
scripts/check_translations.py
Executable 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())
|
||||||
Reference in New Issue
Block a user