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/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 07f0c2a..c01f068 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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" 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/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 4b94a8f..ac3bef3 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 @@ -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(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) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 156cb6b..8aeadd5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -268,8 +268,6 @@ Sprache App-Sprache Systemstandard - Deutsch - English Design, dynamische Farben, Wochenstart Standardfelder für neue Termine 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/strings.xml b/app/src/main/res/values/strings.xml index 138ee64..f27b914 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -265,8 +265,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/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 @@ -