fix(bedtime): switch a recreated rule, keep manual overrides, finish edits

A rule recreated by a pass (deleted in Settings, access re-granted) now
gets its state set even when no boundary was crossed, and the applied
state is recorded only once the system accepted it. Edits that can't move
a boundary (reminder, effects) no longer re-assert the mode over a manual
override, and the screen's writes run on the application scope so leaving
right after a change can't cut it short. A late reminder counts the
minutes actually left, and one slot fire reads the zone once.

An equal bedtime and wake time is now no window instead of a day-long one,
which on every night meant Do Not Disturb never ended, and an empty days
mask in prefs reads as every night, as a backup's does. The rule-id extras
are translated to the bedtime action in MainActivity, keeping the routing
table keyed on the action alone.

Refs #1
This commit is contained in:
makiolaj committed 2026-10-22 11:38:56 +02:00
1 parent 119197106b
commit 2dce78281a
14 files changed
+148 -67

No files matched your search

@@ -8,6 +8,9 @@ import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.ZoneId
import kotlin.math.ceil
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@@ -42,15 +45,21 @@ class BedtimeEngine @Inject constructor(
/**
* A change made on the bedtime screen: stored, then applied at once. The
* rule is rewritten when a field it carries changed, and the mode is
* switched to what the new schedule says now, so turning bedtime on at
* 23:30 for a 23:00 bedtime starts it straight away.
* rule is rewritten when a field it carries changed. When the schedule
* itself changed, the mode is switched to what it says now, so turning
* bedtime on at 23:30 for a 23:00 bedtime starts it straight away; an
* edit that cannot move a boundary (the reminder, an effect) leaves a
* mode switched by hand alone.
*/
suspend fun update(transform: (BedtimeSettings) -> BedtimeSettings) = lock.withLock {
val previous = settings.currentBedtime()
val updated = transform(previous)
settings.setBedtime(updated)
apply(updated, refreshRule = previous.ruleFields() != updated.ruleFields(), force = true)
apply(
updated,
refreshRule = previous.ruleFields() != updated.ruleFields(),
force = previous.scheduleFields() != updated.scheduleFields(),
)
}
/**
@@ -74,9 +83,12 @@ class BedtimeEngine @Inject constructor(
suspend fun onSlotFired() = lock.withLock {
val current = settings.currentBedtime()
val now = wallClock.now()
apply(current, refreshRule = false, force = false, now = now)
BedtimeSchedule.dueReminder(current, now, zones.current())?.let { window ->
notifier.showReminder(window.start, current.reminderMinutes)
val zone = zones.current()
apply(current, refreshRule = false, force = false, now = now, zone = zone)
BedtimeSchedule.dueReminder(current, now, zone)?.let { window ->
// What is actually left: a late fire must not promise the full lead.
val minutesLeft = ceil((window.start - now) / 1.minutes).toInt().coerceAtLeast(1)
notifier.showReminder(window.start, minutesLeft)
}
}
@@ -96,8 +108,8 @@ class BedtimeEngine @Inject constructor(
refreshRule: Boolean,
force: Boolean,
now: Instant = wallClock.now(),
zone: ZoneId = zones.current(),
) {
val zone = zones.current()
if (!current.enabled) {
scheduler.cancel()
notifier.cancelReminder()
@@ -113,10 +125,13 @@ class BedtimeEngine @Inject constructor(
// or the schedule changed under it) is stale.
if (BedtimeSchedule.dueReminder(current, now, zone) == null) notifier.cancelReminder()
if (mode.hasAccess()) {
mode.install(current, refresh = refreshRule)
if (force || active != settings.bedtimeAppliedActive()) {
mode.setActive(active)
settings.setBedtimeAppliedActive(active)
// A rule that was just (re)created starts off, whatever was last
// applied to its predecessor, so it is switched regardless.
val created = mode.install(current, refresh = refreshRule)
if (force || created || active != settings.bedtimeAppliedActive()) {
// Recorded only once the system took it, so a refused call is
// retried by the next pass rather than forgotten.
if (mode.setActive(active)) settings.setBedtimeAppliedActive(active)
}
}
val next = BedtimeSchedule.nextEvent(current, now, zone)
@@ -125,4 +140,7 @@ class BedtimeEngine @Inject constructor(
/** What the system rule is built from; the reminder is not part of it. */
private fun BedtimeSettings.ruleFields() = listOf(enabled, bedtime, wakeTime, effects)
/** What decides whether the mode should be on right now. */
private fun BedtimeSettings.scheduleFields() = listOf(enabled, bedtime, wakeTime, days)
}