I try to implement a restartable count down in pure Kotlin (without CountDownTimer from Android SDK)
I got inspired from How to create a simple countdown timer in Kotlin?
I adapted it because I want coroutine scope managed by caller.
I add a filter in initTimer
flow to stop and restart countdown when coroutine is cancelled but it doesn't work, count down continue and not restarted, when I call toggleTime
before last is not finished.
class CountDownTimerUseCase {
private val _countDown = MutableStateFlow(0)
val countDown: StateFlow<Int> = _countDown
private var countDownTimerJob: Job? = null
suspend fun toggleTime(totalSeconds: Int) {
coroutineScope {
countDownTimerJob?.cancel()
countDownTimerJob = launch {
initTimer(this, totalSeconds)
.cancellable()
.onCompletion { _countDown.emit(0) }
.collect { _countDown.emit(it) }
}
}
}
/**
* The timer emits the total seconds immediately.
* Each second after that, it will emit the next value.
*/
private suspend fun initTimer(coroutineScope: CoroutineScope, totalSeconds: Int): Flow<Int> =
(totalSeconds - 1 downTo 0)
.filter {
//coroutineContext[Job]?.isActive == true
coroutineScope.isActive
}
.asFlow() // Emit total - 1 because the first was emitted onStart
.cancellable()
.onEach { delay(1000) } // Each second later emit a number
.onStart { emit(totalSeconds) } // Emit total seconds immediately
.conflate() // In case the operation onTick takes some time, conflate keeps the time ticking separately
.transform { remainingSeconds: Int ->
emit(remainingSeconds)
}
}
Here the junit test :
class CountDownTimerUseCaseTest {
private val countDownTimerUseCase = CountDownTimerUseCase()
@Test
fun `WHEN count down timer re-start THEN get re-initialized tick`() = runTest{
countDownTimerUseCase.countDown.test {
//init value
var tick = awaitItem()
assertEquals(0, tick)
//start count down
countDownTimerUseCase.toggleTime(30)
// not loop until 0 to be sure cancel is done before the end
for (i in 30 downTo 1) {
tick = awaitItem()
println(tick)
if(tick==0) {
//re-start has be done
break
}
assertEquals(i, tick)
if(i==30) {
println("relaunch")
countDownTimerUseCase.toggleTime(30)
}
}
// check tick after restart
for (i in 30 downTo 0) {
tick = awaitItem()
println(tick)
assertEquals(i, tick)
}
}
}
}
Solution in comment from @Tenfour04 works, thanks
class CountDownTimerUseCase {
private val _countDown = MutableStateFlow(0)
val countDown: StateFlow<Int> = _countDown
private var countDownTimerJob: Job? = null
fun toggleTime(scope: CoroutineScope, totalSeconds: Int) {
countDownTimerJob?.cancel()
countDownTimerJob = initTimer(totalSeconds)
.onEach { _countDown.emit(it) }
.onCompletion { _countDown.emit(0) }
.launchIn(scope)
}
/**
* The timer emits the total seconds immediately.
* Each second after that, it will emit the next value.
*/
private fun initTimer(totalSeconds: Int): Flow<Int> =
flow {
for (i in totalSeconds downTo 1) {
emit(i)
delay(1000)
}
emit(0)
}.conflate()
}
And unit-tests:
class CountDownTimerUseCaseTest {
private val countDownTimerUseCase = CountDownTimerUseCase()
@Test
fun `WHEN count down timer start THEN get tick`() = runTest {
countDownTimerUseCase.countDown.test {
//init value
var tick = awaitItem()
assertEquals(0, tick)
countDownTimerUseCase.toggleTime(this@runTest, 30)
for (i in 30 downTo 0) {
tick = awaitItem()
assertEquals(i, tick)
}
}
}
@Test
fun `WHEN count down timer re-start THEN get re-initialized tick`() = runTest{
countDownTimerUseCase.countDown.test {
//init value
var tick = awaitItem()
assertEquals(0, tick)
//start count down
countDownTimerUseCase.toggleTime(this@runTest, 30)
// not loop until 0 to be sure cancel is done before the end
for (i in 30 downTo 1) {
tick = awaitItem()
if(tick==0) {
//re-start has be done
break
}
assertEquals(i, tick)
if(i==30) {
countDownTimerUseCase.toggleTime(this@runTest, 30)
}
}
// check tick after restart
for (i in 30 downTo 0) {
tick = awaitItem()
assertEquals(i, tick)
}
}
}
}