Skip to content

Commit 54da01f

Browse files
committed
Main UI concept (list of timers, nested progress circles for each unit, …) with beginning of progress animation
1 parent 9b55fe0 commit 54da01f

File tree

9 files changed

+689
-7
lines changed

9 files changed

+689
-7
lines changed
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* Copyright (c) 2021 Olivier Patry
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining
5+
* a copy of this software and associated documentation files (the "Software"),
6+
* to deal in the Software without restriction, including without limitation
7+
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
8+
* and/or sell copies of the Software, and to permit persons to whom the Software
9+
* is furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in
12+
* all copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15+
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
16+
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17+
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18+
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19+
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
20+
* OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21+
*/
22+
package net.opatry.countdowntimer
23+
24+
import androidx.lifecycle.LiveData
25+
import androidx.lifecycle.MutableLiveData
26+
import androidx.lifecycle.ViewModel
27+
import androidx.lifecycle.viewModelScope
28+
import kotlinx.coroutines.CoroutineDispatcher
29+
import kotlinx.coroutines.Dispatchers
30+
import kotlinx.coroutines.launch
31+
import kotlin.time.Duration
32+
import kotlin.time.ExperimentalTime
33+
import kotlin.time.hours
34+
import kotlin.time.minutes
35+
import kotlin.time.seconds
36+
37+
@ExperimentalTime
38+
data class Timer(val duration: Duration, val name: String? = null)
39+
40+
@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+
}
47+
48+
@ExperimentalStdlibApi
49+
@ExperimentalTime
50+
class CounterViewModel(private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main) : ViewModel() {
51+
private val _state = MutableLiveData<TimerState>(TimerState.Reset(null))
52+
val state: LiveData<TimerState>
53+
get() = _state
54+
55+
private val _timers = MutableLiveData(
56+
listOf(
57+
Timer(3.minutes, "🥚 Eggs — Boiled"),
58+
Timer(3.seconds, "Hurry up!!!"),
59+
Timer(3.hours + 35.minutes + 12.seconds, "⏳ Take your time"),
60+
Timer(40.seconds, "🏋️‍♂️Workout — Squats"),
61+
Timer(37.seconds),
62+
Timer(45.seconds, "🏋️‍♂️Workout — Plank"),
63+
Timer(25.minutes, "🍅 Pomodoro"),
64+
Timer(5.minutes, "⏸ Break"),
65+
Timer(15.minutes, "😴 Long break"),
66+
)
67+
)
68+
val timers: LiveData<List<Timer>>
69+
get() = _timers
70+
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+
}
86+
87+
fun reset() {
88+
if (_state.value !is TimerState.Reset) {
89+
// TODO stop countdown scheduler
90+
}
91+
viewModelScope.launch(mainDispatcher) {
92+
_state.value = TimerState.Reset(null)
93+
}
94+
}
95+
96+
fun start(timer: Timer) {
97+
viewModelScope.launch(mainDispatcher) {
98+
// TODO review poor state & timers modeling
99+
100+
// keep list of timers up to date keeping last used first
101+
_timers.value = buildList {
102+
add(timer)
103+
val timers = _timers.value?.filterNot { it == timer }
104+
if (!timers.isNullOrEmpty()) {
105+
addAll(timers)
106+
}
107+
}
108+
_state.value = TimerState.Running(timer.duration, timer)
109+
}
110+
}
111+
}

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

Lines changed: 155 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,49 @@ package net.opatry.countdowntimer
2424
import android.os.Bundle
2525
import androidx.activity.compose.setContent
2626
import androidx.appcompat.app.AppCompatActivity
27+
import androidx.compose.foundation.ExperimentalFoundationApi
28+
import androidx.compose.foundation.layout.Arrangement
29+
import androidx.compose.foundation.layout.Column
30+
import androidx.compose.foundation.layout.fillMaxHeight
31+
import androidx.compose.foundation.layout.fillMaxWidth
32+
import androidx.compose.foundation.layout.padding
33+
import androidx.compose.material.BackdropScaffold
34+
import androidx.compose.material.ExperimentalMaterialApi
35+
import androidx.compose.material.FloatingActionButton
36+
import androidx.compose.material.Icon
2737
import androidx.compose.material.MaterialTheme
2838
import androidx.compose.material.Surface
2939
import androidx.compose.material.Text
40+
import androidx.compose.material.icons.Icons
41+
import androidx.compose.material.icons.twotone.PlayArrow
3042
import androidx.compose.runtime.Composable
31-
import androidx.compose.ui.tooling.preview.Preview
43+
import androidx.compose.runtime.LaunchedEffect
44+
import androidx.compose.runtime.getValue
45+
import androidx.compose.runtime.livedata.observeAsState
46+
import androidx.compose.runtime.mutableStateOf
47+
import androidx.compose.runtime.remember
48+
import androidx.compose.runtime.setValue
49+
import androidx.compose.ui.Alignment
50+
import androidx.compose.ui.Modifier
51+
import androidx.compose.ui.res.stringResource
52+
import androidx.compose.ui.unit.dp
53+
import androidx.lifecycle.viewmodel.compose.viewModel
54+
import kotlinx.coroutines.delay
55+
import kotlinx.coroutines.isActive
56+
import net.opatry.countdowntimer.ui.component.TimerCircle
57+
import net.opatry.countdowntimer.ui.component.TimerControls
58+
import net.opatry.countdowntimer.ui.component.TimerLabel
59+
import net.opatry.countdowntimer.ui.component.TimerList
3260
import net.opatry.countdowntimer.ui.theme.MyTheme
61+
import kotlin.math.roundToInt
62+
import kotlin.time.ExperimentalTime
63+
import kotlin.time.milliseconds
3364

3465
class MainActivity : AppCompatActivity() {
66+
@ExperimentalTime
67+
@ExperimentalMaterialApi
68+
@ExperimentalStdlibApi
69+
@ExperimentalFoundationApi
3570
override fun onCreate(savedInstanceState: Bundle?) {
3671
super.onCreate(savedInstanceState)
3772
setContent {
@@ -44,8 +79,126 @@ class MainActivity : AppCompatActivity() {
4479

4580
// Start building your app here!
4681
@Composable
82+
@ExperimentalTime
83+
@ExperimentalStdlibApi
84+
@ExperimentalMaterialApi
85+
@ExperimentalFoundationApi
4786
fun MyApp() {
4887
Surface(color = MaterialTheme.colors.background) {
49-
Text(text = "Ready... Set... GO!")
88+
CountDownTimerDispatcher()
5089
}
5190
}
91+
92+
@Composable
93+
@ExperimentalTime
94+
@ExperimentalStdlibApi
95+
@ExperimentalMaterialApi
96+
@ExperimentalFoundationApi
97+
fun CountDownTimerDispatcher() {
98+
val viewModel = viewModel<CounterViewModel>()
99+
val timers by viewModel.timers.observeAsState(listOf())
100+
val state by viewModel.state.observeAsState(TimerState.Reset(null))
101+
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])
107+
}
108+
is TimerState.Running -> {
109+
val activeTimer = uiState.timer
110+
CountDownTimerLayout(
111+
activeTimer,
112+
timers,
113+
onTimerClicked = {
114+
viewModel.start(it)
115+
},
116+
onFABClicked = {
117+
viewModel.reset()
118+
// viewModel.pause()
119+
}
120+
)
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+
// }
132+
}
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+
}
144+
}
145+
146+
@Composable
147+
@ExperimentalTime
148+
@ExperimentalMaterialApi
149+
@ExperimentalFoundationApi
150+
fun CountDownTimerLayout(
151+
activeTimer: Timer,
152+
timers: List<Timer>,
153+
onTimerClicked: (Timer) -> Unit,
154+
onFABClicked: () -> Unit
155+
) {
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)
168+
val minutes = (remainingDuration.inMinutes % 60).coerceAtLeast(.0)
169+
val seconds = (remainingDuration.inSeconds % 60).coerceAtLeast(.0)
170+
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+
)
204+
}

0 commit comments

Comments
 (0)