Merge pull request 'feat(core-reminders): reminder leads that count from the start' (#19) from feat/reminder-leads into main
Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/19
This commit is contained in:
@@ -10,6 +10,16 @@ are no binary releases; "version" tracks the shared `version` in the root build
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **`core-reminders`** — reminders can count back from a target's start as
|
||||
well as its due/end date. `ReminderLead(minutes, fromStart)`, an optional
|
||||
`fromStart` list on `ReminderOverride.Minutes` (with `leads` and
|
||||
`Minutes.of(leads)`), lead-aware `ReminderOverrideCodec.parseLeads` /
|
||||
`serializeLeads` (start leads stored as `s30`, plain values still read as
|
||||
before due) and lead versions of the override-map helpers. Additive:
|
||||
minute-only callers are unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`core-locale`** — picking a language did nothing on Android 13+. AppCompat's
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.jeanlucmakiola.floret.reminders
|
||||
|
||||
/**
|
||||
* One reminder lead time and the date it counts back from: the target's start
|
||||
* when [fromStart], otherwise its due/end date. An app whose targets have one
|
||||
* date only never sets [fromStart], and its leads are plain minutes.
|
||||
*/
|
||||
data class ReminderLead(val minutes: Int, val fromStart: Boolean = false) {
|
||||
|
||||
/** The stored form: `30` before due, `s30` before start. */
|
||||
fun toToken(): String = if (fromStart) "$START_PREFIX$minutes" else minutes.toString()
|
||||
|
||||
companion object {
|
||||
private const val START_PREFIX = "s"
|
||||
|
||||
/** [toToken] read back, or null when [token] is not one. */
|
||||
fun parse(token: String): ReminderLead? {
|
||||
val text = token.trim()
|
||||
val fromStart = text.startsWith(START_PREFIX)
|
||||
val minutes = text.removePrefix(START_PREFIX).toIntOrNull() ?: return null
|
||||
return ReminderLead(minutes, fromStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Distinct, start's first, then ascending — the canonical form stored and resolved. */
|
||||
@JvmName("normalizeReminderLeads")
|
||||
fun List<ReminderLead>.normalizeReminders(): List<ReminderLead> =
|
||||
distinct().sortedWith(compareBy({ !it.fromStart }, { it.minutes }))
|
||||
|
||||
/** Each lead as one counting back from the due/end date. */
|
||||
fun List<Int>.asReminderLeads(): List<ReminderLead> = map { ReminderLead(it) }
|
||||
|
||||
/** As [reminderOverrideFor], for an override map that names each lead's date. */
|
||||
@JvmName("reminderLeadOverrideFor")
|
||||
fun Map<Long, List<ReminderLead>>.reminderOverrideFor(id: Long): ReminderOverride = when {
|
||||
!containsKey(id) -> ReminderOverride.Inherit
|
||||
getValue(id).isEmpty() -> ReminderOverride.None
|
||||
else -> ReminderOverride.Minutes.of(getValue(id))
|
||||
}
|
||||
|
||||
/** As [applyReminderOverride], for an override map that names each lead's date. */
|
||||
@JvmName("applyReminderLeadOverride")
|
||||
fun MutableMap<Long, List<ReminderLead>>.applyReminderOverride(id: Long, override: ReminderOverride) {
|
||||
when (override) {
|
||||
ReminderOverride.Inherit -> remove(id)
|
||||
ReminderOverride.None -> put(id, emptyList())
|
||||
is ReminderOverride.Minutes -> put(id, override.leads.normalizeReminders())
|
||||
}
|
||||
}
|
||||
|
||||
/** As [reminderOverrideForMinutes], for leads that name their date. */
|
||||
fun reminderOverrideForLeads(leads: List<ReminderLead>, allowInherit: Boolean): ReminderOverride {
|
||||
val norm = leads.normalizeReminders()
|
||||
return when {
|
||||
norm.isNotEmpty() -> ReminderOverride.Minutes.of(norm)
|
||||
allowInherit -> ReminderOverride.Inherit
|
||||
else -> ReminderOverride.None
|
||||
}
|
||||
}
|
||||
+21
-3
@@ -15,8 +15,26 @@ sealed interface ReminderOverride {
|
||||
/** Explicit "no reminder" for this target, regardless of the global default. */
|
||||
data object None : ReminderOverride
|
||||
|
||||
/** Specific lead times in minutes before due (non-empty, distinct, ascending). */
|
||||
data class Minutes(val minutes: List<Int>) : ReminderOverride
|
||||
/**
|
||||
* Specific lead times in minutes before due (non-empty, distinct, ascending),
|
||||
* plus those counting back from the start instead ([fromStart], same form).
|
||||
*/
|
||||
data class Minutes(val minutes: List<Int>, val fromStart: List<Int> = emptyList()) : ReminderOverride {
|
||||
/** Every lead, naming the date it counts from. */
|
||||
val leads: List<ReminderLead>
|
||||
get() = fromStart.map { ReminderLead(it, fromStart = true) } + minutes.asReminderLeads()
|
||||
|
||||
companion object {
|
||||
/** [leads] split by the date they count from. */
|
||||
fun of(leads: List<ReminderLead>): Minutes {
|
||||
val norm = leads.normalizeReminders()
|
||||
return Minutes(
|
||||
minutes = norm.filter { !it.fromStart }.map { it.minutes },
|
||||
fromStart = norm.filter { it.fromStart }.map { it.minutes },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The lead times of a [ReminderOverride.Minutes]; empty for [ReminderOverride.Inherit] and [ReminderOverride.None]. */
|
||||
@@ -31,7 +49,7 @@ fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
|
||||
* means no reminder. Mirrors the absent/empty/value semantics of the override
|
||||
* map.
|
||||
*/
|
||||
fun Map<Long, List<Int>>.reminderLeadsFor(id: Long, default: List<Int>): List<Int> =
|
||||
fun <T> Map<Long, List<T>>.reminderLeadsFor(id: Long, default: List<T>): List<T> =
|
||||
if (containsKey(id)) getValue(id) else default
|
||||
|
||||
/**
|
||||
|
||||
+27
@@ -42,6 +42,33 @@ class ReminderOverrideCodec(
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* As [parse], for a map whose leads name their date ([ReminderLead.toToken]).
|
||||
* A value written by [serialize] reads as leads counting back from due.
|
||||
*/
|
||||
fun parseLeads(stored: String?): Map<Long, List<ReminderLead>> {
|
||||
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
|
||||
when (val value = parts[1]) {
|
||||
noneToken -> id to emptyList()
|
||||
else -> value.split(listSep).mapNotNull(ReminderLead::parse)
|
||||
.normalizeReminders().takeIf { it.isNotEmpty() }?.let { id to it }
|
||||
?: return@mapNotNull null
|
||||
}
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
/** Encode a map read by [parseLeads] back to its stored string form. */
|
||||
fun serializeLeads(map: Map<Long, List<ReminderLead>>): String =
|
||||
map.entries.joinToString(entrySep) { (id, leads) ->
|
||||
val value =
|
||||
if (leads.isEmpty()) noneToken
|
||||
else leads.normalizeReminders().joinToString(listSep) { it.toToken() }
|
||||
"$id$keyValueSep$value"
|
||||
}
|
||||
|
||||
/** Encode the map back to its stored string form. */
|
||||
fun serialize(map: Map<Long, List<Int>>): String =
|
||||
map.entries.joinToString(entrySep) { (id, minutes) ->
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.jeanlucmakiola.floret.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ReminderLeadTest {
|
||||
|
||||
private val codec = ReminderOverrideCodec.DEFAULT
|
||||
|
||||
@Test
|
||||
fun `round-trips leads that name their date`() {
|
||||
val map = mapOf(
|
||||
12L to listOf(ReminderLead(1_440, fromStart = true), ReminderLead(30)),
|
||||
7L to emptyList(),
|
||||
3L to listOf(ReminderLead(-540, fromStart = true)),
|
||||
)
|
||||
assertThat(codec.parseLeads(codec.serializeLeads(map))).isEqualTo(map)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a value written as plain minutes reads as leads before due`() {
|
||||
val stored = codec.serialize(mapOf(12L to listOf(30, 60)))
|
||||
assertThat(codec.parseLeads(stored)).isEqualTo(mapOf(12L to listOf(ReminderLead(30), ReminderLead(60))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an override splits its leads by date`() {
|
||||
val override = ReminderOverride.Minutes.of(listOf(ReminderLead(60), ReminderLead(10, fromStart = true), ReminderLead(0)))
|
||||
assertThat(override).isEqualTo(ReminderOverride.Minutes(minutes = listOf(0, 60), fromStart = listOf(10)))
|
||||
assertThat(override.leads).containsExactly(ReminderLead(10, fromStart = true), ReminderLead(0), ReminderLead(60)).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an override map of leads applies and reads back`() {
|
||||
val map = mutableMapOf<Long, List<ReminderLead>>()
|
||||
map.applyReminderOverride(1L, ReminderOverride.Minutes(listOf(30), fromStart = listOf(5)))
|
||||
assertThat(map.reminderOverrideFor(1L)).isEqualTo(ReminderOverride.Minutes(listOf(30), fromStart = listOf(5)))
|
||||
map.applyReminderOverride(1L, ReminderOverride.None)
|
||||
assertThat(map.reminderOverrideFor(1L)).isEqualTo(ReminderOverride.None)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty choice of leads falls back as the minutes one does`() {
|
||||
assertThat(reminderOverrideForLeads(emptyList(), allowInherit = true)).isEqualTo(ReminderOverride.Inherit)
|
||||
assertThat(reminderOverrideForLeads(emptyList(), allowInherit = false)).isEqualTo(ReminderOverride.None)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user