core-locale: shared per-app language plumbing (AppLanguage)
Android-library module, drawn from the byte-identical AppLanguage in Agendula and Calendula: read the shipped languages from res/xml/locales_config.xml, get/set the applied language via AppCompatDelegate, and render each language's autonym. The locales_config resource id is now a parameter, so the module is app-agnostic. appcompat only, no Compose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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._
|
||||
|
||||
|
||||
46
core-locale/build.gradle.kts
Normal file
46
core-locale/build.gradle.kts
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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-<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 {
|
||||
|
||||
/**
|
||||
* 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<String> {
|
||||
val tags = mutableListOf<String>()
|
||||
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() }
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -33,6 +33,7 @@ rootProject.name = "floret-kit"
|
||||
|
||||
include(":core-time")
|
||||
include(":core-reminders")
|
||||
include(":core-locale")
|
||||
include(":core-crash")
|
||||
include(":identity")
|
||||
include(":components")
|
||||
|
||||
Reference in New Issue
Block a user