diff --git a/date-time/src/commonMain/kotlin/com/splendo/kaluga/datetime/timer/RecurringTimer.kt b/date-time/src/commonMain/kotlin/com/splendo/kaluga/datetime/timer/RecurringTimer.kt index f7672b6ed..068a038a0 100644 --- a/date-time/src/commonMain/kotlin/com/splendo/kaluga/datetime/timer/RecurringTimer.kt +++ b/date-time/src/commonMain/kotlin/com/splendo/kaluga/datetime/timer/RecurringTimer.kt @@ -27,10 +27,16 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.Duration @@ -55,29 +61,57 @@ class RecurringTimer( delayFunction: DelayFunction = { delayDuration -> delay(delayDuration) }, coroutineScope: CoroutineScope = MainScope(), ) : ControllableTimer { - private val stateRepo = TimerStateRepo(duration, interval, timeSource, delayFunction, coroutineScope) - override val state: Flow = stateRepo.stateFlow.map { it.timerState } - override val currentState: Timer.State get() = stateRepo.stateFlow.value.timerState + + private sealed class State { + data class Active(val repo: TimerStateRepo) : State() + data class Finished(val state: Timer.State.NotRunning.Finished) : State() + } + + private val stateRepo: StateFlow init { + val repo = TimerStateRepo(duration, interval, timeSource, delayFunction, coroutineScope) + val stateRepo = MutableStateFlow(State.Active(repo)) + this.stateRepo = stateRepo.asStateFlow() coroutineScope.launch { - awaitFinish() - stateRepo.cancel() + val finishedState = repo.stateFlow.filterIsInstance().first() + stateRepo.compareAndSet(State.Active(repo), State.Finished(finishedState)) + repo.cancel() } } - override suspend fun start() = runIfNotFinished { start() } - - override suspend fun pause() = runIfNotFinished { pause() } + override val state: Flow = stateRepo.flatMapLatest { state -> + when (state) { + is State.Active -> state.repo.stateFlow.map { it.timerState } + is State.Finished -> flowOf(state.state) + } + } + override val currentState: Timer.State get() = when (val state = stateRepo.value) { + is State.Active -> state.repo.stateFlow.value.timerState + is State.Finished -> state.state + } - override suspend fun stop() = runIfNotFinished { stop() } + override suspend fun start(): Boolean = stateRepo.transformLatest { state -> + when (state) { + is State.Active -> emit(state.repo.start()) + is State.Finished -> emit(false) + } + }.first() - private suspend fun runIfNotFinished(block: suspend TimerStateRepo.() -> Unit) { - if (stateRepo.peekState() is Timer.State.NotRunning.Finished) { - throw IllegalStateException("Timer has already finished") - } else { - stateRepo.block() + override suspend fun pause() = stateRepo.transformLatest { state -> + when (state) { + is State.Active -> emit(state.repo.pause()) + is State.Finished -> emit(false) } + }.first() + + override suspend fun stop() { + stateRepo.transformLatest { state -> + when (state) { + is State.Active -> state.repo.stop() + is State.Finished -> emit(Unit) + } + }.first() } } @@ -94,29 +128,25 @@ private class TimerStateRepo( State.NotRunning.Paused(elapsedSoFar = Duration.ZERO, totalDuration = totalDuration) }, ) { - suspend fun start() { - withContext(coroutineScope.coroutineContext) { - takeAndChangeState { state -> - when (state) { - is State.NotRunning.Paused -> suspend { - state.start(interval, timeSource, delayFunction, coroutineScope, ::finish) - } - is State.NotRunning.Finished, is State.Running -> state.remain() + suspend fun start(): Boolean = withContext(coroutineScope.coroutineContext) { + takeAndChangeState { state -> + when (state) { + is State.NotRunning.Paused -> suspend { + state.start(interval, timeSource, delayFunction, coroutineScope, ::finish) } + is State.NotRunning.Finished, is State.Running -> state.remain() } } - } + } is State.Running - suspend fun pause() { - withContext(coroutineScope.coroutineContext) { - takeAndChangeState { state -> - when (state) { - is State.Running -> state::pause - is State.NotRunning -> state.remain() - } + suspend fun pause() = withContext(coroutineScope.coroutineContext) { + takeAndChangeState { state -> + when (state) { + is State.Running -> state::pause + is State.NotRunning -> state.remain() } } - } + } is State.NotRunning.Paused suspend fun stop() { withContext(coroutineScope.coroutineContext) { diff --git a/date-time/src/commonMain/kotlin/com/splendo/kaluga/datetime/timer/Timer.kt b/date-time/src/commonMain/kotlin/com/splendo/kaluga/datetime/timer/Timer.kt index e0f7ac505..d9e158d54 100644 --- a/date-time/src/commonMain/kotlin/com/splendo/kaluga/datetime/timer/Timer.kt +++ b/date-time/src/commonMain/kotlin/com/splendo/kaluga/datetime/timer/Timer.kt @@ -69,19 +69,18 @@ interface Timer { interface ControllableTimer : Timer { /** * Starts the timer. - * @throws [IllegalStateException] if the timer has finished. + * @return `true` if the timer as started successfully, `false` otherwise. * */ - suspend fun start() + suspend fun start(): Boolean /** * Pauses the timer. Calling [start] again will make it resume. - * @throws [IllegalStateException] if the timer has finished. + * @return `true` if the timer as paused successfully, `false` otherwise. * */ - suspend fun pause() + suspend fun pause(): Boolean /** - * Stops the timer causing it to finish. Calling [start] again will throw [IllegalStateException] - * @throws [IllegalStateException] if the timer has finished. + * Stops the timer causing it to finish. Calling [start] again will return `false`. * */ suspend fun stop() } diff --git a/date-time/src/commonTest/kotlin/com/splendo/kaluga/datetime/timer/RecurringTimerTest.kt b/date-time/src/commonTest/kotlin/com/splendo/kaluga/datetime/timer/RecurringTimerTest.kt index 340290d87..2d47c3290 100644 --- a/date-time/src/commonTest/kotlin/com/splendo/kaluga/datetime/timer/RecurringTimerTest.kt +++ b/date-time/src/commonTest/kotlin/com/splendo/kaluga/datetime/timer/RecurringTimerTest.kt @@ -28,11 +28,11 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.withTimeout import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeMark import kotlin.time.TimeSource @@ -42,24 +42,24 @@ class RecurringTimerTest { fun stateTransitions(): Unit = runBlocking { val timerScope = CoroutineScope(Dispatchers.Default) val timer = RecurringTimer( - duration = 100.milliseconds, + duration = 1.seconds, interval = 10.milliseconds, coroutineScope = timerScope, ) timer.state.assertEmits("timer was not paused after creation") { it is Timer.State.NotRunning.Paused } - timer.start() + assertTrue(timer.start()) timer.state.assertEmits("timer is not running after start") { it is Timer.State.Running } - timer.pause() + assertTrue(timer.pause()) timer.state.assertEmits("timer was not paused after pause") { it is Timer.State.NotRunning.Paused } delay(500) timer.state.assertEmits("timer pause is not working") { it is Timer.State.NotRunning.Paused } - timer.start() + assertTrue(timer.start()) timer.state.assertEmits("timer is not running after start") { it is Timer.State.Running } - delay(500) + delay(1.5.seconds) timer.state.assertEmits("timer was not finished after time elapsed") { it is Timer.State.NotRunning.Finished } - assertFailsWith { timer.start() } + assertFalse(timer.start()) timer.state.assertEmits("was able to start timer after finish") { it is Timer.State.NotRunning.Finished } - assertFailsWith { timer.pause() } + assertFalse(timer.pause()) timer.state.assertEmits("was able to pause timer after finish") { it is Timer.State.NotRunning.Finished } timer.awaitFinish() } @@ -91,13 +91,12 @@ class RecurringTimerTest { val initial = timer.elapsed().captureFor(100.milliseconds) assertEquals(listOf(Duration.ZERO), initial, "timer was not started in paused state") - timer.start() + assertTrue(timer.start()) assertTrue(timerScope.isActive) timer.state.assertEmits("timer is not running after start") { it is Timer.State.Running } timer.stop() timer.state.assertEmits("timer is not running after start") { it is Timer.State.NotRunning.Finished } - assertFailsWith { timer.start() } - assertFailsWith { timer.pause() } + assertFalse(timer.start()) assertFalse(timerScope.isActive) } @@ -119,15 +118,15 @@ class RecurringTimerTest { assertEquals(listOf(Duration.ZERO), initial, "timer was not started in paused state") // capture and validate a first chunk of data - timer.start() + assertTrue(timer.start()) val result0 = timer.elapsed().captureFor(200.milliseconds) - timer.pause() + assertTrue(timer.pause()) assertTrue(result0.isNotEmpty(), "values not emitted") assertTrue(initial.last() <= result0.first(), "values are not in ascending order") assertTrue(result0.isAscending(), "values are not in ascending order") // capture and validate the rest of the data - timer.start() + assertTrue(timer.start()) val result1 = timer.elapsed().captureFor(1000.milliseconds) assertTrue(result1.isNotEmpty(), "values not emitted") assertTrue(result0.last() <= result1.first(), "values are not in ascending order")