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 |
|
| 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
|
||||||
|
|
||||||
|
|||||||
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"
|
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" }
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user