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:
@@ -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
|
||||
|
||||
@@ -39,6 +39,7 @@ import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
@@ -892,6 +893,74 @@ class AlarmEngineTest {
|
||||
assertThat(states.state(1L).handledOccurrence).isEqualTo(sevenToday)
|
||||
assertThat(ring.ringing.value).isEqualTo(1L)
|
||||
}
|
||||
|
||||
// --- M5: upcoming(refresh) (D8) ---
|
||||
|
||||
@Test
|
||||
fun `upcoming with its default refresh still re-emits on a repository change`(@TempDir dir: Path) =
|
||||
runTest {
|
||||
givenAlarm(id = 1L, hour = 7)
|
||||
val engine = engine(dir)
|
||||
|
||||
engine.upcoming().test {
|
||||
assertThat(awaitItem().map { it.nextFire }).containsExactly(sevenToday)
|
||||
|
||||
alarms.create(AlarmDraft(time = TimeOfDay(6, 0)))
|
||||
|
||||
assertThat(awaitItem().map { it.nextFire })
|
||||
.containsExactly(offsetInstant("2026-06-10T06:00+02:00"), sevenToday)
|
||||
.inOrder()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a refresh re-resolves against the new now, with no repository change`(@TempDir dir: Path) =
|
||||
runTest {
|
||||
givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY)
|
||||
val snoozeAt = wallClock.instant + 5.minutes
|
||||
states.save(ringState(snoozedUntil = snoozeAt, snoozeCount = 1))
|
||||
val engine = engine(dir)
|
||||
val refresh = MutableSharedFlow<Unit>(replay = 1)
|
||||
refresh.emit(Unit)
|
||||
|
||||
engine.upcoming(refresh).test {
|
||||
val snoozed = awaitItem().single()
|
||||
assertThat(snoozed.source to snoozed.nextFire).isEqualTo(FireSource.SNOOZE to snoozeAt)
|
||||
|
||||
// The snooze lapses with the passage of time alone: nothing was written.
|
||||
wallClock.advance(20.minutes)
|
||||
refresh.emit(Unit)
|
||||
|
||||
val lapsed = awaitItem().single()
|
||||
assertThat(lapsed.source to lapsed.nextFire)
|
||||
.isEqualTo(FireSource.OCCURRENCE to sevenToday)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collecting upcoming across several refreshes writes nothing`(@TempDir dir: Path) = runTest {
|
||||
givenAlarm(repeatDays = RepeatDays.EVERY_DAY, skipNextOccurrence = true)
|
||||
val engine = engine(dir)
|
||||
val before = stateDao.stored
|
||||
val writesBefore = stateDao.writes
|
||||
val refresh = MutableSharedFlow<Unit>(replay = 1)
|
||||
refresh.emit(Unit)
|
||||
|
||||
engine.upcoming(refresh).test {
|
||||
awaitItem()
|
||||
repeat(3) {
|
||||
wallClock.advance(1.minutes)
|
||||
refresh.emit(Unit)
|
||||
awaitItem()
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
|
||||
assertThat(stateDao.writes).isEqualTo(writesBefore)
|
||||
assertThat(stateDao.stored).isEqualTo(before)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user