Implement timer logic

This commit is contained in:
Nishant Mishra
2025-06-29 18:34:30 +05:30
parent 1626563762
commit 99ea43d5a5
8 changed files with 359 additions and 132 deletions

View File

@@ -47,6 +47,7 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)

View File

@@ -4,23 +4,20 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.activity.viewModels
import org.nsh07.pomodoro.ui.AppScreen
import org.nsh07.pomodoro.ui.theme.PomodoroTheme
import org.nsh07.pomodoro.ui.viewModel.UiViewModel
class MainActivity : ComponentActivity() {
val viewModel: UiViewModel by viewModels<UiViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PomodoroTheme {
AppScreen()
AppScreen(viewModel)
}
}
}

View File

@@ -1,135 +1,21 @@
package org.nsh07.pomodoro.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.theme.AppFonts.interDisplayBlack
import org.nsh07.pomodoro.ui.theme.AppFonts.openRundeClock
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.nsh07.pomodoro.ui.timerScreen.TimerScreen
import org.nsh07.pomodoro.ui.viewModel.UiViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AppScreen(modifier: Modifier = Modifier) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
"Focus",
style = TextStyle(
fontFamily = interDisplayBlack,
fontSize = 32.sp,
lineHeight = 32.sp,
color = colorScheme.onPrimaryContainer
)
)
},
subtitle = {},
titleHorizontalAlignment = Alignment.CenterHorizontally
)
},
modifier = modifier.fillMaxSize()
) { insets ->
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(insets)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(contentAlignment = Alignment.Center) {
CircularProgressIndicator(
progress = { 0.3f },
modifier = Modifier.size(350.dp),
strokeWidth = 32.dp,
gapSize = 32.dp
)
Box(Modifier.width(220.dp)) {
Text(
text = "08:34",
style = TextStyle(
fontFamily = openRundeClock,
fontWeight = FontWeight.Bold,
fontSize = 76.sp,
letterSpacing = (-2).sp
),
maxLines = 1,
autoSize = TextAutoSize.StepBased()
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(16.dp)
) {
FilledTonalIconButton(
onClick = { /*TODO*/ },
shapes = IconButtonDefaults.shapes(),
modifier = Modifier.size(96.dp)
) {
Icon(
painterResource(R.drawable.restart_large),
contentDescription = "Restart"
)
}
FilledIconButton(
onClick = { /*TODO*/ },
shapes = IconButtonDefaults.shapes(),
modifier = Modifier.size(width = 128.dp, height = 96.dp)
) {
Icon(
painterResource(R.drawable.pause_large),
contentDescription = "Pause"
)
}
}
}
fun AppScreen(
viewModel: UiViewModel,
modifier: Modifier = Modifier
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Spacer(Modifier.height(32.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Up next", style = typography.titleSmall)
Text(
"5:00",
style = TextStyle(
fontFamily = openRundeClock,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp,
color = colorScheme.tertiary
)
)
Text("Short break", style = typography.titleMediumEmphasized)
}
}
}
TimerScreen(uiState = uiState, resetTimer = viewModel::resetTimer, toggleTimer = viewModel::toggleTimer, modifier = modifier)
}

View File

@@ -0,0 +1,200 @@
package org.nsh07.pomodoro.ui.timerScreen
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledIconToggleButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.motionScheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.theme.AppFonts.interDisplayBlack
import org.nsh07.pomodoro.ui.theme.AppFonts.openRundeClock
import org.nsh07.pomodoro.ui.viewModel.TimerMode
import org.nsh07.pomodoro.ui.viewModel.UiState
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun TimerScreen(
uiState: UiState,
resetTimer: () -> Unit,
toggleTimer: () -> Unit,
modifier: Modifier = Modifier
) {
val color by animateColorAsState(
if (uiState.timerMode == TimerMode.FOCUS) colorScheme.primary
else colorScheme.tertiary,
animationSpec = motionScheme.slowEffectsSpec()
)
val onColor by animateColorAsState(
if (uiState.timerMode == TimerMode.FOCUS) colorScheme.onPrimary
else colorScheme.onTertiary,
animationSpec = motionScheme.slowEffectsSpec()
)
val colorContainer by animateColorAsState(
if (uiState.timerMode == TimerMode.FOCUS) colorScheme.secondaryContainer
else colorScheme.tertiaryContainer,
animationSpec = motionScheme.slowEffectsSpec()
)
val onColorContainer by animateColorAsState(
if (uiState.timerMode == TimerMode.FOCUS) colorScheme.onPrimaryContainer
else colorScheme.onTertiaryContainer,
animationSpec = motionScheme.slowEffectsSpec()
)
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
when (uiState.timerMode) {
TimerMode.FOCUS -> "Focus"
TimerMode.SHORT_BREAK -> "Short Break"
TimerMode.LONG_BREAK -> "Long Break"
},
style = TextStyle(
fontFamily = interDisplayBlack,
fontSize = 32.sp,
lineHeight = 32.sp,
color = onColorContainer
)
)
},
subtitle = {},
titleHorizontalAlignment = Alignment.CenterHorizontally
)
},
modifier = modifier.fillMaxSize()
) { insets ->
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(insets)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(contentAlignment = Alignment.Center) {
CircularProgressIndicator(
progress = { (uiState.totalTime.toFloat() - uiState.remainingTime) / uiState.totalTime },
modifier = Modifier.size(350.dp),
color = color,
trackColor = colorContainer,
strokeWidth = 16.dp,
gapSize = 16.dp
)
// Box {
Text(
text = uiState.timeStr,
style = TextStyle(
fontFamily = openRundeClock,
fontWeight = FontWeight.Bold,
fontSize = 76.sp,
letterSpacing = (-2).sp
),
maxLines = 1
// autoSize = TextAutoSize.StepBased(stepSize = 24.sp)
)
// }
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(16.dp)
) {
FilledTonalIconButton(
onClick = resetTimer,
colors = IconButtonDefaults.filledTonalIconButtonColors(containerColor = colorContainer),
shapes = IconButtonDefaults.shapes(),
modifier = Modifier.size(96.dp)
) {
Icon(
painterResource(R.drawable.restart_large),
contentDescription = "Restart"
)
}
FilledIconToggleButton(
onCheckedChange = { toggleTimer() },
checked = uiState.timerRunning,
colors = IconButtonDefaults.filledIconToggleButtonColors(
checkedContainerColor = color,
checkedContentColor = onColor
),
shapes = IconButtonDefaults.toggleableShapes(),
modifier = Modifier.size(width = 128.dp, height = 96.dp)
) {
if (uiState.timerRunning) {
Icon(
painterResource(R.drawable.pause_large),
contentDescription = "Pause"
)
} else {
Icon(
painterResource(R.drawable.play_large),
contentDescription = "Play"
)
}
}
}
}
Spacer(Modifier.height(32.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Up next", style = typography.titleSmall)
Text(
uiState.nextTimeStr,
style = TextStyle(
fontFamily = openRundeClock,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp,
color = if (uiState.nextTimerMode == TimerMode.FOCUS) colorScheme.primary else colorScheme.tertiary
)
)
Text(
when (uiState.nextTimerMode) {
TimerMode.FOCUS -> "Focus"
TimerMode.SHORT_BREAK -> "Short Break"
TimerMode.LONG_BREAK -> "Long Break"
},
style = typography.titleMediumEmphasized
)
}
}
}
}
@Preview(showSystemUi = true)
@Composable
fun TimerScreenPreview() {
val uiState = UiState(
timeStr = "08:34", nextTimeStr = "5:00"
)
TimerScreen(uiState, {}, {})
}

View File

@@ -0,0 +1,15 @@
package org.nsh07.pomodoro.ui.viewModel
data class UiState(
val timerMode: TimerMode = TimerMode.FOCUS,
val timeStr: String = "25:00",
val totalTime: Int = 25 * 60,
val remainingTime: Int = 25 * 60,
val timerRunning: Boolean = false,
val nextTimerMode: TimerMode = TimerMode.SHORT_BREAK,
val nextTimeStr: String = "5:00"
)
enum class TimerMode {
FOCUS, SHORT_BREAK, LONG_BREAK
}

View File

@@ -0,0 +1,114 @@
package org.nsh07.pomodoro.ui.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.Locale
class UiViewModel : ViewModel() {
val focusTime = 10
val shortBreakTime = 5
val longBreakTime = 20
private val _uiState = MutableStateFlow(
UiState(
totalTime = focusTime,
remainingTime = focusTime,
timeStr = secondsToStr(focusTime),
nextTimeStr = secondsToStr(shortBreakTime)
)
)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
var timerJob: Job? = null
var time = focusTime
var cycles = 0
fun resetTimer() {
time = focusTime
cycles = 0
_uiState.update { currentState ->
currentState.copy(
timerMode = TimerMode.FOCUS,
timeStr = secondsToStr(time),
totalTime = time,
remainingTime = time,
nextTimerMode = TimerMode.SHORT_BREAK,
nextTimeStr = secondsToStr(shortBreakTime)
)
}
}
fun toggleTimer() {
if (uiState.value.timerRunning) {
_uiState.update { currentState ->
currentState.copy(timerRunning = false)
}
timerJob?.cancel()
} else {
_uiState.update { it.copy(timerRunning = true) }
timerJob = viewModelScope.launch {
while (true) {
if (!uiState.value.timerRunning) break
time--;
if (time < 0) {
cycles++;
if (cycles % 2 == 0) {
time = focusTime
_uiState.update { currentState ->
currentState.copy(
timerMode = TimerMode.FOCUS,
timeStr = secondsToStr(time),
totalTime = time,
remainingTime = time,
nextTimerMode = if (cycles % 6 == 0) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK,
nextTimeStr = if (cycles % 6 == 0) secondsToStr(longBreakTime) else secondsToStr(
shortBreakTime
)
)
}
} else {
val long = cycles % 7 == 0
time = if (long) longBreakTime else shortBreakTime
_uiState.update { currentState ->
currentState.copy(
timerMode = if (long) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK,
timeStr = secondsToStr(time),
totalTime = time,
remainingTime = time,
nextTimerMode = TimerMode.FOCUS,
nextTimeStr = secondsToStr(focusTime)
)
}
}
} else {
_uiState.update { currentState ->
currentState.copy(
timeStr = secondsToStr(time),
remainingTime = time
)
}
}
delay(1000)
}
}
}
}
private fun secondsToStr(t: Int): String {
val min = t / 60
val sec = t % 60
return String.format(locale = Locale.getDefault(), "%02d:%02d", min, sec)
}
}

View File

@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:autoMirrored="true"
android:tint="#000000"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M301.33,706.67v-458q0,-17.86 12.67,-29.77 12.67,-11.9 30,-11.9 4.67,0 10.33,1.33 5.67,1.34 11.34,5l360.66,229q9.84,6.67 15.09,16 5.25,9.34 5.25,19.83 0,10.49 -5.5,19.66 -5.5,9.18 -14.84,15.18L365.67,742q-5.67,4.33 -11.56,5.67 -5.88,1.33 -10.44,1.33 -17,0 -29.67,-11.9 -12.67,-11.91 -12.67,-30.43Z" />
</vector>

View File

@@ -11,6 +11,7 @@ composeBom = "2025.06.01"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycleRuntimeKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }