Compare commits

...

2 Commits

Author SHA1 Message Date
4eb9809993 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>
2026-06-28 14:12:16 +02:00
367a8ff8af core-reminders: shared reminder-lead plumbing (ReminderUnit, override model + codec)
Pure-Kotlin module, drawn from the duplicate reminder logic in Agendula and
Calendula: the lead-time unit model (ReminderUnit / decomposeReminderMinutes),
the per-target override model (ReminderOverride + reminderLeadFor /
applyReminderOverride) and the stored-format codec (ReminderOverrideCodec).

The codec's separators are configurable so each app keeps its on-disk format
(Agendula stores id:minutes, Calendula id=minutes) without a data migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:11:55 +02:00
12 changed files with 402 additions and 1 deletions

View File

@@ -32,8 +32,10 @@ local source module — edit the kit and the app picks it up immediately.
| Module | What it holds | | Module | What it holds |
|--------|---------------| |--------|---------------|
| `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-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/reminders/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._
## Build ## Build

View 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)
}

View File

@@ -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() }
}
}

View File

@@ -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")
}
}

View File

@@ -0,0 +1,32 @@
// core-reminders — pure-Kotlin reminder-lead plumbing shared across the family:
// the lead-time unit model (ReminderUnit / decomposeReminderMinutes), the
// per-target override model (ReminderOverride + resolution helpers) and the
// stored-format codec (ReminderOverrideCodec, separators configurable so each
// app keeps its on-disk format). No Android, no third-party deps — a plain JVM
// library, like core-time; each app layers its own DataStore + string labels on
// top.
plugins {
alias(libs.plugins.kotlin.jvm)
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
dependencies {
testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine)
testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.truth)
}
tasks.test {
useJUnitPlatform()
}

View File

@@ -0,0 +1,44 @@
package de.jeanlucmakiola.floret.reminders
/**
* A target's override of the default reminder lead time — a list (Agendula) or a
* calendar (Calendula). Targets are keyed by id in the override map; this is the
* choice a picker returns.
*/
sealed interface ReminderOverride {
/** No override — the target uses the global default. */
data object Inherit : ReminderOverride
/** Explicit "no reminder" for this target, regardless of the global default. */
data object None : ReminderOverride
/** A specific lead time in minutes before due. */
data class Minutes(val minutes: Int) : ReminderOverride
}
/**
* The lead time for [id]: its override if one is set (a null map value means an
* explicit "no reminder"), otherwise the global [default]. A null result means
* no reminder. Mirrors the absent/null/value semantics of the override map.
*/
fun Map<Long, Int?>.reminderLeadFor(id: Long, default: Int): Int? =
if (containsKey(id)) this[id] else default
/**
* Read [id]'s entry as a [ReminderOverride]: absent → [Inherit], null → [None],
* a value → [Minutes].
*/
fun Map<Long, Int?>.reminderOverrideFor(id: Long): ReminderOverride = when {
!containsKey(id) -> ReminderOverride.Inherit
this[id] == null -> ReminderOverride.None
else -> ReminderOverride.Minutes(getValue(id)!!)
}
/** Apply [override] for [id] to this override map ([Inherit] removes the key). */
fun MutableMap<Long, Int?>.applyReminderOverride(id: Long, override: ReminderOverride) {
when (override) {
ReminderOverride.Inherit -> remove(id)
ReminderOverride.None -> put(id, null)
is ReminderOverride.Minutes -> put(id, override.minutes)
}
}

View File

@@ -0,0 +1,38 @@
package de.jeanlucmakiola.floret.reminders
/**
* Encodes a per-target reminder-override map (`id → minutes`, where a null value
* is an explicit "no reminder") to and from a single stored string, e.g.
* `"12:30;7:none"`.
*
* The separators are configurable because each app already has bytes on disk in
* its own dialect — Agendula stores `id:minutes`, Calendula `id=minutes` — and
* the stored format must stay stable. Use [DEFAULT] (Agendula's `:` / `;`) or
* build a codec with the app's own separators; never change an app's separators
* after release without a migration.
*/
class ReminderOverrideCodec(
private val entrySep: String = ";",
private val keyValueSep: String = ":",
private val noneToken: String = "none",
) {
/** Decode the stored string into a map (a null value = explicit no-reminder). */
fun parse(stored: String?): Map<Long, Int?> {
if (stored.isNullOrBlank()) return emptyMap()
return stored.split(entrySep).mapNotNull { entry ->
val parts = entry.split(keyValueSep).takeIf { it.size == 2 } ?: return@mapNotNull null
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
val value = if (parts[1] == noneToken) null else parts[1].toIntOrNull() ?: return@mapNotNull null
id to value
}.toMap()
}
/** Encode the map back to its stored string form. */
fun serialize(map: Map<Long, Int?>): String =
map.entries.joinToString(entrySep) { (id, minutes) -> "$id$keyValueSep${minutes ?: noneToken}" }
companion object {
/** Agendula's dialect: `id:minutes` entries joined by `;`. */
val DEFAULT = ReminderOverrideCodec()
}
}

View File

@@ -0,0 +1,31 @@
package de.jeanlucmakiola.floret.reminders
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
enum class ReminderUnit(val minutesFactor: Int) {
Minutes(1),
Hours(60),
Days(1_440),
Weeks(10_080),
}
/**
* A reminder lead time broken into a whole [amount] of a [unit] — the seed for a
* custom-amount editor. [amount] is null when there is no lead time to show.
*/
data class ReminderAmount(val amount: Int?, val unit: ReminderUnit)
/**
* Decompose a reminder lead time (minutes before due) into the largest unit that
* divides it evenly: 120 → 2 hours, 1 440 → 1 day, 10 080 → 1 week, 90 → 90
* minutes. null or a non-positive lead time → no amount, defaulting to minutes.
*/
fun decomposeReminderMinutes(minutes: Int?): ReminderAmount = when {
minutes == null || minutes <= 0 -> ReminderAmount(null, ReminderUnit.Minutes)
minutes % ReminderUnit.Weeks.minutesFactor == 0 ->
ReminderAmount(minutes / ReminderUnit.Weeks.minutesFactor, ReminderUnit.Weeks)
minutes % ReminderUnit.Days.minutesFactor == 0 ->
ReminderAmount(minutes / ReminderUnit.Days.minutesFactor, ReminderUnit.Days)
minutes % ReminderUnit.Hours.minutesFactor == 0 ->
ReminderAmount(minutes / ReminderUnit.Hours.minutesFactor, ReminderUnit.Hours)
else -> ReminderAmount(minutes, ReminderUnit.Minutes)
}

View File

@@ -0,0 +1,52 @@
package de.jeanlucmakiola.floret.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class ReminderModelTest {
@Test
fun `decompose picks the largest whole unit`() {
assertThat(decomposeReminderMinutes(120)).isEqualTo(ReminderAmount(2, ReminderUnit.Hours))
assertThat(decomposeReminderMinutes(1_440)).isEqualTo(ReminderAmount(1, ReminderUnit.Days))
assertThat(decomposeReminderMinutes(10_080)).isEqualTo(ReminderAmount(1, ReminderUnit.Weeks))
assertThat(decomposeReminderMinutes(90)).isEqualTo(ReminderAmount(90, ReminderUnit.Minutes))
}
@Test
fun `decompose of null or non-positive has no amount`() {
assertThat(decomposeReminderMinutes(null)).isEqualTo(ReminderAmount(null, ReminderUnit.Minutes))
assertThat(decomposeReminderMinutes(0)).isEqualTo(ReminderAmount(null, ReminderUnit.Minutes))
assertThat(decomposeReminderMinutes(-5)).isEqualTo(ReminderAmount(null, ReminderUnit.Minutes))
}
@Test
fun `reminderLeadFor distinguishes absent, explicit-none and a value`() {
val map = mapOf(1L to 30, 2L to null)
assertThat(map.reminderLeadFor(1L, default = 10)).isEqualTo(30)
assertThat(map.reminderLeadFor(2L, default = 10)).isNull() // explicit no-reminder
assertThat(map.reminderLeadFor(3L, default = 10)).isEqualTo(10) // inherits default
}
@Test
fun `reminderOverrideFor maps absent, null and value to the choice types`() {
val map = mapOf(1L to 30, 2L to null)
assertThat(map.reminderOverrideFor(1L)).isEqualTo(ReminderOverride.Minutes(30))
assertThat(map.reminderOverrideFor(2L)).isEqualTo(ReminderOverride.None)
assertThat(map.reminderOverrideFor(3L)).isEqualTo(ReminderOverride.Inherit)
}
@Test
fun `applyReminderOverride writes, clears and removes entries`() {
val map = mutableMapOf<Long, Int?>()
map.applyReminderOverride(1L, ReminderOverride.Minutes(45))
assertThat(map).containsExactly(1L, 45)
map.applyReminderOverride(1L, ReminderOverride.None)
assertThat(map[1L]).isNull()
assertThat(map).containsKey(1L)
map.applyReminderOverride(1L, ReminderOverride.Inherit)
assertThat(map).doesNotContainKey(1L)
}
}

View File

@@ -0,0 +1,52 @@
package de.jeanlucmakiola.floret.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class ReminderOverrideCodecTest {
private val codec = ReminderOverrideCodec.DEFAULT
@Test
fun `round-trips ids, minutes and explicit none`() {
val map = mapOf(12L to 30, 7L to null, 3L to 0)
assertThat(codec.parse(codec.serialize(map))).isEqualTo(map)
}
@Test
fun `null or blank parses to empty`() {
assertThat(codec.parse(null)).isEmpty()
assertThat(codec.parse("")).isEmpty()
assertThat(codec.parse(" ")).isEmpty()
}
@Test
fun `empty map serializes to empty string`() {
assertThat(codec.serialize(emptyMap())).isEmpty()
}
@Test
fun `none token decodes to a null value`() {
assertThat(codec.parse("7:none")).isEqualTo(mapOf(7L to null))
}
@Test
fun `garbage entries are dropped, valid ones kept`() {
assertThat(codec.parse("12:30;garbage;x:5;9:notnum;4:10"))
.isEqualTo(mapOf(12L to 30, 4L to 10))
}
@Test
fun `a custom dialect keeps a separate on-disk format`() {
// Calendula's dialect: id=minutes entries.
val calendula = ReminderOverrideCodec(entrySep = ";", keyValueSep = "=", noneToken = "none")
val map = mapOf(12L to 30, 7L to null)
val stored = calendula.serialize(map)
assertThat(stored).contains("12=30")
assertThat(stored).contains("7=none")
assertThat(calendula.parse(stored)).isEqualTo(map)
// The Agendula dialect cannot read Calendula's bytes — the formats are distinct.
assertThat(codec.parse(stored)).isEmpty()
}
}

View File

@@ -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" }

View File

@@ -32,6 +32,8 @@ dependencyResolutionManagement {
rootProject.name = "floret-kit" rootProject.name = "floret-kit"
include(":core-time") include(":core-time")
include(":core-reminders")
include(":core-locale")
include(":core-crash") include(":core-crash")
include(":identity") include(":identity")
include(":components") include(":components")