feat(alarm): a refresh cadence for the upcoming list, still read-only

`upcoming` takes the cadence it re-resolves on, so a live countdown comes from
the engine's own resolution — the grace window, the watermarks and the DST
walk stay in one place instead of being copied into a screen. Re-resolving
writes nothing, which is asserted rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
2026-09-12 14:43:33 +02:00
co-authored by Claude Opus 5
parent 431c4c2697
commit b5ec07b470
3 changed files with 96 additions and 8 deletions
@@ -22,6 +22,7 @@ import de.jeanlucmakiola.clockula.system.RebootRepair
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.time.Duration.Companion.minutes
@@ -116,9 +117,15 @@ class AlarmEngine @Inject constructor(
)
}
/** Read-only. Never writes. Sorted by nextFire ascending, nulls last, then by id. */
fun upcoming(): Flow<List<UpcomingAlarm>> =
combine(alarms.alarms(), states.states()) { alarms, states ->
/**
* Read-only. Never writes. Sorted by nextFire ascending, nulls last, then by id.
*
* [refresh] is the re-read cadence: every emission re-resolves against a
* fresh `wallClock.now()`. The default resolves once per repository
* emission; a live readout passes a `Ticker`'s flow (M5 D8).
*/
fun upcoming(refresh: Flow<Unit> = flowOf(Unit)): Flow<List<UpcomingAlarm>> =
combine(alarms.alarms(), states.states(), refresh) { alarms, states, _ ->
val now = wallClock.now()
val zone = zones.current()
val byId = states.associateBy { it.alarmId }
@@ -8,6 +8,8 @@ import android.media.MediaPlayer
import android.media.RingtoneManager
import android.net.Uri
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing
import de.jeanlucmakiola.clockula.domain.alarm.AudioSourcePolicy
import de.jeanlucmakiola.clockula.domain.alarm.RingtoneSourceKind
import de.jeanlucmakiola.clockula.domain.alarm.VolumeRamp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -93,11 +95,21 @@ class AlarmAudioPlayer(private val context: Context) {
abandonFocus()
}
private fun sources(ringtoneUri: String?): List<Uri> = listOfNotNull(
ringtoneUri?.takeIf { it.isNotBlank() }?.let(Uri::parse),
RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_ALARM),
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM),
)
/**
* The order is [AudioSourcePolicy]'s, so the decision — including the silent
* sentinel's empty list, which is what forces vibration (M5 D18) — lives in
* a pure object a JVM test can enumerate rather than in this file.
*/
private fun sources(ringtoneUri: String?): List<Uri> =
AudioSourcePolicy.sourcesFor(ringtoneUri).mapNotNull { kind ->
when (kind) {
RingtoneSourceKind.ALARM_OWN -> ringtoneUri?.let(Uri::parse)
RingtoneSourceKind.DEVICE_DEFAULT ->
RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_ALARM)
RingtoneSourceKind.BUILT_IN_DEFAULT ->
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
}
}
/**
* A prepared, not-yet-started player, or null. A source that throws has its