diff --git a/README.md b/README.md index 9c7665c..1e1f2d0 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ local source module — edit the kit and the app picks it up immediately. |--------|---------------| | `core-time` | Pure-Kotlin date/time helpers: local-day windows (`DayWindow`) for smart-list logic and locale/zone-aware display formatting (`Instant.formatDate()` / `formatTime()` / `formatDateTime()`). No Android, no deps. | | `core-reminders` | Pure-Kotlin reminder-lead plumbing: the lead-time unit model (`ReminderUnit`, `decomposeReminderMinutes`), the per-target override model (`ReminderOverride` + `reminderLeadFor` / `applyReminderOverride`) and the stored-format codec (`ReminderOverrideCodec`, separators configurable per app). No Android, no deps. Each app layers its own DataStore + string labels on top. | +| `core-locale` | Per-app language plumbing (`AppLanguage`): read the shipped languages from the app's `res/xml/locales_config.xml`, get/set the applied language via `AppCompatDelegate`, and render each language's autonym. The app passes its own `locales_config` resource id; appcompat only, no Compose. | _More modules (identity/theme, components, screen recipes, provider/prefs/crash plumbing) land as they're extracted from the apps._ diff --git a/core-locale/build.gradle.kts b/core-locale/build.gradle.kts new file mode 100644 index 0000000..bec725c --- /dev/null +++ b/core-locale/build.gradle.kts @@ -0,0 +1,46 @@ +// core-locale — per-app language plumbing shared across the family: read the +// shipped languages from res/xml/locales_config.xml, get/set the applied +// language via AppCompatDelegate, and render each language's autonym. App- +// agnostic — the consuming app passes its own locales_config resource id; no +// Compose, no DataStore. +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "de.jeanlucmakiola.floret.locale" + compileSdk = 37 + + defaultConfig { + minSdk = 29 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests { + all { it.useJUnitPlatform() } + isReturnDefaultValues = true + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + // appcompat brings AppCompatDelegate plus its core (LocaleListCompat) and + // annotation (XmlRes) transitives. + implementation(libs.androidx.appcompat) + + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.truth) +} diff --git a/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/AppLanguage.kt b/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/AppLanguage.kt new file mode 100644 index 0000000..7d8247e --- /dev/null +++ b/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/AppLanguage.kt @@ -0,0 +1,80 @@ +package de.jeanlucmakiola.floret.locale + +import android.content.Context +import androidx.annotation.XmlRes +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import org.xmlpull.v1.XmlPullParser +import java.util.Locale + +private const val ANDROID_NS = "http://schemas.android.com/apk/res/android" + +/** + * Per-app language via AppCompatDelegate, driven by an app's + * res/xml/locales_config.xml. + * + * That file is the single source of truth for which languages an app ships: + * 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 { + + /** + * The BCP-47 tags the app ships translations for, in declaration order, as + * listed in [localesConfig] (the app's `res/xml/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, @XmlRes localesConfig: Int): List { + val tags = mutableListOf() + val parser = context.resources.getXml(localesConfig) + 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 + } + + /** 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/core-locale/src/test/kotlin/de/jeanlucmakiola/floret/locale/AppLanguageTest.kt b/core-locale/src/test/kotlin/de/jeanlucmakiola/floret/locale/AppLanguageTest.kt new file mode 100644 index 0000000..e4224c0 --- /dev/null +++ b/core-locale/src/test/kotlin/de/jeanlucmakiola/floret/locale/AppLanguageTest.kt @@ -0,0 +1,20 @@ +package de.jeanlucmakiola.floret.locale + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class AppLanguageTest { + + @Test + fun `autonym is the language's own name`() { + assertThat(AppLanguage.displayName("en")).isEqualTo("English") + assertThat(AppLanguage.displayName("de")).isEqualTo("Deutsch") + } + + @Test + fun `autonym is capitalised per the language's own rules`() { + // French autonyms are lower-case in CLDR ("français"); the picker shows + // them title-cased so each language reads as a proper name. + assertThat(AppLanguage.displayName("fr")).isEqualTo("Français") + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 80f9d72..6bdb93d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ agp = "9.2.1" kotlin = "2.3.21" coreKtx = "1.19.0" +appcompat = "1.7.1" composeBom = "2026.05.01" # Material 3 Expressive APIs live in the 1.5 alpha line; pinned to override the BOM. material3 = "1.5.0-alpha21" @@ -13,6 +14,7 @@ truth = "1.4.5" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-foundation = { group = "androidx.compose.foundation", name = "foundation" } diff --git a/settings.gradle.kts b/settings.gradle.kts index bf30751..2a42f6c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,6 +33,7 @@ rootProject.name = "floret-kit" include(":core-time") include(":core-reminders") +include(":core-locale") include(":core-crash") include(":identity") include(":components")