Skip to content

Commit 98878b8

Browse files
committed
Timer is driven by ViewModel and navigation is simplified to minimum
1 parent 54da01f commit 98878b8

File tree

8 files changed

+129
-138
lines changed

8 files changed

+129
-138
lines changed

app/src/main/java/net/opatry/countdowntimer/CounterViewModel.kt

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -21,35 +21,33 @@
2121
*/
2222
package net.opatry.countdowntimer
2323

24+
import android.os.CountDownTimer
2425
import androidx.lifecycle.LiveData
2526
import androidx.lifecycle.MutableLiveData
2627
import androidx.lifecycle.ViewModel
2728
import androidx.lifecycle.viewModelScope
2829
import kotlinx.coroutines.CoroutineDispatcher
2930
import kotlinx.coroutines.Dispatchers
3031
import kotlinx.coroutines.launch
32+
import kotlin.math.roundToLong
3133
import kotlin.time.Duration
3234
import kotlin.time.ExperimentalTime
3335
import kotlin.time.hours
36+
import kotlin.time.milliseconds
3437
import kotlin.time.minutes
3538
import kotlin.time.seconds
3639

3740
@ExperimentalTime
3841
data class Timer(val duration: Duration, val name: String? = null)
3942

4043
@ExperimentalTime
41-
sealed class TimerState {
42-
data class Reset(val duration: Duration?) : TimerState()
43-
data class Running(val remaining: Duration, val timer: Timer) : TimerState()
44-
data class Paused(val remaining: Duration, val timer: Timer) : TimerState() // TOOO __start__ blinking
45-
data class Done(val overdue: Duration, val timer: Timer) : TimerState() // TODO __start__ sound
46-
}
44+
data class TimerState(val remaining: Duration, val timer: Timer)
4745

4846
@ExperimentalStdlibApi
4947
@ExperimentalTime
5048
class CounterViewModel(private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main) : ViewModel() {
51-
private val _state = MutableLiveData<TimerState>(TimerState.Reset(null))
52-
val state: LiveData<TimerState>
49+
private val _state = MutableLiveData<TimerState?>()
50+
val state: LiveData<TimerState?>
5351
get() = _state
5452

5553
private val _timers = MutableLiveData(
@@ -68,44 +66,48 @@ class CounterViewModel(private val mainDispatcher: CoroutineDispatcher = Dispatc
6866
val timers: LiveData<List<Timer>>
6967
get() = _timers
7068

71-
fun pause() {
72-
val state = _state.value as? TimerState.Running ?: return
73-
// TODO pause countdown scheduler
74-
viewModelScope.launch(mainDispatcher) {
75-
_state.value = TimerState.Paused(state.remaining, state.timer)
76-
}
77-
}
78-
79-
fun resume() {
80-
val state = _state.value as? TimerState.Paused ?: return
81-
// TODO restart countdown scheduler
82-
viewModelScope.launch(mainDispatcher) {
83-
_state.value = TimerState.Running(state.remaining, state.timer)
84-
}
85-
}
69+
private var countDownTimer: CountDownTimer? = null
8670

87-
fun reset() {
88-
if (_state.value !is TimerState.Reset) {
89-
// TODO stop countdown scheduler
90-
}
71+
fun stop() {
9172
viewModelScope.launch(mainDispatcher) {
92-
_state.value = TimerState.Reset(null)
73+
_state.value = null
74+
countDownTimer?.cancel()
75+
countDownTimer = null
9376
}
9477
}
9578

9679
fun start(timer: Timer) {
9780
viewModelScope.launch(mainDispatcher) {
9881
// TODO review poor state & timers modeling
99-
10082
// keep list of timers up to date keeping last used first
10183
_timers.value = buildList {
10284
add(timer)
10385
val timers = _timers.value?.filterNot { it == timer }
10486
if (!timers.isNullOrEmpty()) {
10587
addAll(timers)
10688
}
89+
}.distinct()
90+
91+
countDownTimer?.cancel()
92+
countDownTimer = object : CountDownTimer(timer.duration.inMilliseconds.roundToLong(), 16L) {
93+
override fun onTick(millisUntilFinished: Long) {
94+
val state = _state.value ?: return
95+
_state.postValue(state.copy(remaining = millisUntilFinished.milliseconds))
96+
}
97+
98+
override fun onFinish() {
99+
_state.postValue(null)
100+
}
101+
}.also {
102+
_state.value = TimerState(timer.duration, timer)
103+
it.start()
107104
}
108-
_state.value = TimerState.Running(timer.duration, timer)
109105
}
110106
}
107+
108+
override fun onCleared() {
109+
countDownTimer?.cancel()
110+
countDownTimer = null
111+
super.onCleared()
112+
}
111113
}

app/src/main/java/net/opatry/countdowntimer/MainActivity.kt

Lines changed: 65 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -26,41 +26,33 @@ import androidx.activity.compose.setContent
2626
import androidx.appcompat.app.AppCompatActivity
2727
import androidx.compose.foundation.ExperimentalFoundationApi
2828
import androidx.compose.foundation.layout.Arrangement
29+
import androidx.compose.foundation.layout.Box
2930
import androidx.compose.foundation.layout.Column
3031
import androidx.compose.foundation.layout.fillMaxHeight
3132
import androidx.compose.foundation.layout.fillMaxWidth
32-
import androidx.compose.foundation.layout.padding
3333
import androidx.compose.material.BackdropScaffold
34+
import androidx.compose.material.BackdropValue
3435
import androidx.compose.material.ExperimentalMaterialApi
35-
import androidx.compose.material.FloatingActionButton
36-
import androidx.compose.material.Icon
3736
import androidx.compose.material.MaterialTheme
3837
import androidx.compose.material.Surface
39-
import androidx.compose.material.Text
40-
import androidx.compose.material.icons.Icons
41-
import androidx.compose.material.icons.twotone.PlayArrow
38+
import androidx.compose.material.rememberBackdropScaffoldState
4239
import androidx.compose.runtime.Composable
43-
import androidx.compose.runtime.LaunchedEffect
4440
import androidx.compose.runtime.getValue
4541
import androidx.compose.runtime.livedata.observeAsState
46-
import androidx.compose.runtime.mutableStateOf
47-
import androidx.compose.runtime.remember
48-
import androidx.compose.runtime.setValue
42+
import androidx.compose.runtime.rememberCoroutineScope
4943
import androidx.compose.ui.Alignment
5044
import androidx.compose.ui.Modifier
51-
import androidx.compose.ui.res.stringResource
52-
import androidx.compose.ui.unit.dp
5345
import androidx.lifecycle.viewmodel.compose.viewModel
54-
import kotlinx.coroutines.delay
55-
import kotlinx.coroutines.isActive
46+
import kotlinx.coroutines.coroutineScope
47+
import kotlinx.coroutines.launch
5648
import net.opatry.countdowntimer.ui.component.TimerCircle
5749
import net.opatry.countdowntimer.ui.component.TimerControls
5850
import net.opatry.countdowntimer.ui.component.TimerLabel
5951
import net.opatry.countdowntimer.ui.component.TimerList
6052
import net.opatry.countdowntimer.ui.theme.MyTheme
6153
import kotlin.math.roundToInt
54+
import kotlin.time.Duration
6255
import kotlin.time.ExperimentalTime
63-
import kotlin.time.milliseconds
6456

6557
class MainActivity : AppCompatActivity() {
6658
@ExperimentalTime
@@ -97,108 +89,81 @@ fun MyApp() {
9789
fun CountDownTimerDispatcher() {
9890
val viewModel = viewModel<CounterViewModel>()
9991
val timers by viewModel.timers.observeAsState(listOf())
100-
val state by viewModel.state.observeAsState(TimerState.Reset(null))
92+
val state by viewModel.state.observeAsState(null)
93+
val coroutineScope = rememberCoroutineScope()
10194

102-
state.let { uiState ->
103-
when (uiState) {
104-
is TimerState.Reset -> CountDownTimerReset {
105-
// TODO TODO handle no timer and ask for a duration & name
106-
viewModel.start(timers[0])
95+
val scaffoldState = rememberBackdropScaffoldState(if (state == null) BackdropValue.Revealed else BackdropValue.Concealed)
96+
97+
BackdropScaffold(
98+
appBar = { },
99+
scaffoldState = scaffoldState,
100+
backLayerContent = {
101+
Column(
102+
Modifier.fillMaxWidth()
103+
) {
104+
// TODO if list is empty, allow to create one
105+
TimerList(state?.timer, timers) { timer ->
106+
coroutineScope.launch {
107+
scaffoldState.conceal()
108+
}
109+
viewModel.start(timer)
110+
}
107111
}
108-
is TimerState.Running -> {
109-
val activeTimer = uiState.timer
112+
},
113+
frontLayerContent = {
114+
state?.let { uiState ->
110115
CountDownTimerLayout(
111-
activeTimer,
112-
timers,
113-
onTimerClicked = {
114-
viewModel.start(it)
115-
},
116+
uiState.remaining,
116117
onFABClicked = {
117-
viewModel.reset()
118-
// viewModel.pause()
118+
if (uiState.remaining.isPositive()) {
119+
coroutineScope.launch {
120+
scaffoldState.reveal()
121+
}
122+
viewModel.stop()
123+
} else {
124+
coroutineScope.launch {
125+
scaffoldState.conceal()
126+
}
127+
viewModel.start(uiState.timer)
128+
}
119129
}
120130
)
121-
}
122-
// is TimerState.Paused -> CountDownTimerLayout(
123-
// onTimerClicked = {}
124-
// ) {
125-
// viewModel.resume()
126-
// }
127-
// is TimerState.Done -> CountDownTimerLayout(
128-
// onTimerClicked = {}
129-
// ) {
130-
// viewModel.reset()
131-
// }
131+
} ?: Box {}
132132
}
133-
}
134-
}
135-
136-
@Composable
137-
fun CountDownTimerReset(onFABClicked: () -> Unit) {
138-
Column {
139-
Text("TODO CREATE NEW TIMER", Modifier.padding(48.dp))
140-
FloatingActionButton(onClick = onFABClicked) {
141-
Icon(Icons.TwoTone.PlayArrow, stringResource(R.string.timer_start))
142-
}
143-
}
133+
)
144134
}
145135

146136
@Composable
147137
@ExperimentalTime
148138
@ExperimentalMaterialApi
149139
@ExperimentalFoundationApi
150140
fun CountDownTimerLayout(
151-
activeTimer: Timer,
152-
timers: List<Timer>,
153-
onTimerClicked: (Timer) -> Unit,
141+
remainingDuration: Duration,
154142
onFABClicked: () -> Unit
155143
) {
156-
// FIXME there is a bug when finishing the first progress for the first time, progress sticks to max
157-
var remainingDuration by remember/*Saveable TODO Duration to bundle*/ { mutableStateOf(activeTimer.duration) }
158-
LaunchedEffect(activeTimer/*.name*/) {
159-
// FIXME how to smoothly animate the progress and update remaining time accordingly
160-
// FIXME not stopped even when key change :(
161-
while (!remainingDuration.isNegative() && isActive) {
162-
remainingDuration = remainingDuration - 16.milliseconds
163-
delay(16)
164-
}
165-
}
166-
167-
val hours = (remainingDuration.inHours % 12).coerceAtLeast(.0)
144+
val hours = (remainingDuration.inHours % 24).coerceAtLeast(.0)
168145
val minutes = (remainingDuration.inMinutes % 60).coerceAtLeast(.0)
169146
val seconds = (remainingDuration.inSeconds % 60).coerceAtLeast(.0)
170147

171-
BackdropScaffold(
172-
appBar = { },
173-
backLayerContent = {
174-
Column(
175-
Modifier.fillMaxWidth()
176-
) {
177-
TimerList(activeTimer, timers, onTimerClicked)
178-
}
179-
},
180-
frontLayerContent = {
181-
Column(
182-
Modifier
183-
.fillMaxWidth()
184-
.fillMaxHeight(.8f),
185-
horizontalAlignment = Alignment.CenterHorizontally,
186-
verticalArrangement = Arrangement.SpaceEvenly
187-
) {
188-
// FIXME values rounding is incorrect
189-
// 1. when switching from 35min 30sec to 35min 29sec, it displays 34min 29sec
190-
// 2. when seconds is almost 0 but not yet, digit changes to 0 before the progress reach 12O'clock
191-
// (especially visible for last second)
192-
val hoursI = hours.roundToInt()
193-
val minutesI = minutes.roundToInt()
194-
val secondsI = seconds.roundToInt()
195-
TimerLabel(hoursI, minutesI, secondsI)
196-
val hoursP = if (hoursI == 0) 0f else (hours / 12).toFloat()
197-
val minutesP = if (minutesI == 0) 0f else (minutes / 60).toFloat()
198-
val secondsP = if (secondsI == 0) 0f else (seconds / 60).toFloat()
199-
TimerCircle(hoursP, minutesP, secondsP, onFABClicked)
200-
TimerControls(onClose = {}, onDelete = {})
201-
}
202-
}
203-
)
148+
Column(
149+
Modifier
150+
.fillMaxWidth()
151+
.fillMaxHeight(.8f),
152+
horizontalAlignment = Alignment.CenterHorizontally,
153+
verticalArrangement = Arrangement.SpaceEvenly
154+
) {
155+
// FIXME values rounding is incorrect
156+
// 1. when switching from 35min 30sec to 35min 29sec, it displays 34min 29sec
157+
// 2. when seconds is almost 0 but not yet, digit changes to 0 before the progress reach 12 O'clock
158+
// (especially visible for last second)
159+
val hoursI = hours.roundToInt()
160+
val minutesI = minutes.roundToInt()
161+
val secondsI = seconds.roundToInt()
162+
TimerLabel(hoursI, minutesI, secondsI)
163+
val hoursP = if (hoursI == 0) 0f else (hours / 24).toFloat()
164+
val minutesP = if (minutesI == 0) 0f else (minutes / 60).toFloat()
165+
val secondsP = if (secondsI == 0) 0f else (seconds / 60).toFloat()
166+
TimerCircle(hoursP, minutesP, secondsP, onFABClicked)
167+
TimerControls(onClose = { /* TODO */ }, onDelete = { /* TODO */ })
168+
}
204169
}

app/src/main/java/net/opatry/countdowntimer/ui/component/TimerCircle.kt

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import androidx.compose.material.Icon
3535
import androidx.compose.material.MaterialTheme
3636
import androidx.compose.material.ProgressIndicatorDefaults
3737
import androidx.compose.material.icons.Icons
38+
import androidx.compose.material.icons.twotone.PlayArrow
3839
import androidx.compose.material.icons.twotone.Stop
3940
import androidx.compose.runtime.Composable
4041
import androidx.compose.ui.Alignment
@@ -98,10 +99,13 @@ fun TimerCircle(hoursProgress: Float, minutesProgress: Float, secondsProgress: F
9899
onClick = onFABClicked,
99100
Modifier.wrapContentSize(Alignment.Center)
100101
) {
102+
val (icon, labelRes) = when {
103+
secondsProgress > 0 || secondsProgress > 0 || secondsProgress > 0 -> Icons.TwoTone.Stop to R.string.timer_stop
104+
else -> Icons.TwoTone.PlayArrow to R.string.timer_start
105+
}
101106
Icon(
102-
// if (secondsProgress > 0 || secondsProgress > 0 || secondsProgress > 0) Icons.TwoTone.Pause else Icons.TwoTone.Check,
103-
Icons.TwoTone.Stop,
104-
stringResource(R.string.timer_start)
107+
icon,
108+
stringResource(labelRes)
105109
)
106110
}
107111
}

app/src/main/java/net/opatry/countdowntimer/ui/component/TimerLabel.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ fun TimerLabel(hours: Int, minutes: Int, seconds: Int) {
6464
},
6565
Modifier.fillMaxWidth(),
6666
textAlign = TextAlign.Center,
67-
style = typography.h3
67+
style = typography.h3,
68+
fontFamily = ReemKufi
6869
)
6970
}

app/src/main/java/net/opatry/countdowntimer/ui/component/TimerList.kt

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,26 @@ val DurationUnit.color: Color
6262
@ExperimentalTime
6363
@ExperimentalFoundationApi
6464
fun TimerList(activeTimer: Timer?, timers: List<Timer>, onTimerClicked: (Timer) -> Unit) {
65-
// TODO no timer selected
66-
if (activeTimer == null) return
67-
6865
val otherTimers = timers.filterNot { it == activeTimer }
66+
6967
LazyColumn {
68+
// FIXME when header goes sticky, it's not opaque and list is displayed below
7069
stickyHeader {
71-
ActiveTimer(activeTimer.name ?: stringResource(R.string.timer_unnamed))
70+
if (activeTimer != null) {
71+
ActiveTimer(activeTimer.name ?: stringResource(R.string.timer_unnamed))
72+
} else {
73+
// TODO distinguish hint from selected timer but share common "title" style
74+
Text(
75+
stringResource(R.string.timer_select_a_timer),
76+
Modifier
77+
.fillMaxWidth()
78+
.padding(vertical = 8.dp, horizontal = 16.dp),
79+
style = typography.h5,
80+
textAlign = TextAlign.Center,
81+
fontStyle = FontStyle.Italic,
82+
color = MaterialTheme.colors.onBackground
83+
)
84+
}
7285
}
7386
items(otherTimers) { timer ->
7487
TimerListItem(timer, onTimerClicked)

0 commit comments

Comments
 (0)