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-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-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._
|
_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"
|
agp = "9.2.1"
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.3.21"
|
||||||
coreKtx = "1.19.0"
|
coreKtx = "1.19.0"
|
||||||
|
appcompat = "1.7.1"
|
||||||
composeBom = "2026.05.01"
|
composeBom = "2026.05.01"
|
||||||
# Material 3 Expressive APIs live in the 1.5 alpha line; pinned to override the BOM.
|
# Material 3 Expressive APIs live in the 1.5 alpha line; pinned to override the BOM.
|
||||||
material3 = "1.5.0-alpha21"
|
material3 = "1.5.0-alpha21"
|
||||||
@@ -13,6 +14,7 @@ truth = "1.4.5"
|
|||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
androidx-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
androidx-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ rootProject.name = "floret-kit"
|
|||||||
|
|
||||||
include(":core-time")
|
include(":core-time")
|
||||||
include(":core-reminders")
|
include(":core-reminders")
|
||||||
|
include(":core-locale")
|
||||||
include(":core-crash")
|
include(":core-crash")
|
||||||
include(":identity")
|
include(":identity")
|
||||||
include(":components")
|
include(":components")
|
||||||
|
|||||||
Reference in New Issue
Block a user