Compare commits
2 Commits
0db0e3883f
...
4eb9809993
| Author | SHA1 | Date | |
|---|---|---|---|
| 4eb9809993 | |||
| 367a8ff8af |
@@ -32,8 +32,10 @@ local source module — edit the kit and the app picks it up immediately.
|
||||
| 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-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
|
||||
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
32
core-reminders/build.gradle.kts
Normal file
32
core-reminders/build.gradle.kts
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -32,6 +32,8 @@ dependencyResolutionManagement {
|
||||
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