diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4cb9390..efec7f6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt b/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt index 7d988a4..1da51a9 100644 --- a/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt +++ b/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt @@ -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() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { PomodoroTheme { - AppScreen() + AppScreen(viewModel) } } } diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt index 1159c92..66dc912 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt @@ -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) } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt new file mode 100644 index 0000000..1cacf19 --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt @@ -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, {}, {}) +} diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/viewModel/UiState.kt b/app/src/main/java/org/nsh07/pomodoro/ui/viewModel/UiState.kt new file mode 100644 index 0000000..0bd8415 --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/viewModel/UiState.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/viewModel/UiViewModel.kt b/app/src/main/java/org/nsh07/pomodoro/ui/viewModel/UiViewModel.kt new file mode 100644 index 0000000..0d78ee5 --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/viewModel/UiViewModel.kt @@ -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.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) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/play_large.xml b/app/src/main/res/drawable/play_large.xml new file mode 100644 index 0000000..beb2a0f --- /dev/null +++ b/app/src/main/res/drawable/play_large.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6234ece..7356262 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }