Write a separate ViewModel for settings screen

This commit is contained in:
Nishant Mishra
2025-07-08 08:54:47 +05:30
parent 6381fa3da5
commit 40c6608d79
8 changed files with 176 additions and 151 deletions

View File

@@ -9,11 +9,11 @@ import org.nsh07.pomodoro.ui.AppScreen
import org.nsh07.pomodoro.ui.NavItem
import org.nsh07.pomodoro.ui.Screen
import org.nsh07.pomodoro.ui.theme.TomatoTheme
import org.nsh07.pomodoro.ui.viewModel.UiViewModel
import org.nsh07.pomodoro.ui.viewModel.TimerViewModel
class MainActivity : ComponentActivity() {
private val viewModel: UiViewModel by viewModels(factoryProducer = { UiViewModel.Factory })
private val viewModel: TimerViewModel by viewModels(factoryProducer = { TimerViewModel.Factory })
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

View File

@@ -4,6 +4,7 @@ import android.content.Context
interface AppContainer {
val appPreferencesRepository: AppPreferenceRepository
val appTimerRepository: AppTimerRepository
}
class DefaultAppContainer(context: Context) : AppContainer {
@@ -14,4 +15,8 @@ class DefaultAppContainer(context: Context) : AppContainer {
)
}
override val appTimerRepository: AppTimerRepository by lazy {
AppTimerRepository()
}
}

View File

@@ -0,0 +1,13 @@
package org.nsh07.pomodoro.data
interface TimerRepository {
var focusTime: Int
var shortBreakTime: Int
var longBreakTime: Int
}
class AppTimerRepository : TimerRepository {
override var focusTime = 25 * 60 * 1000
override var shortBreakTime = 5 * 60 * 1000
override var longBreakTime = 15 * 60 * 1000
}

View File

@@ -8,7 +8,6 @@ import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
@@ -25,7 +24,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -43,30 +41,20 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.nsh07.pomodoro.MainActivity.Companion.screens
import org.nsh07.pomodoro.ui.settingsScreen.SettingsScreen
import org.nsh07.pomodoro.ui.settingsScreen.SettingsScreenRoot
import org.nsh07.pomodoro.ui.statsScreen.StatsScreen
import org.nsh07.pomodoro.ui.timerScreen.TimerScreen
import org.nsh07.pomodoro.ui.viewModel.UiViewModel
import org.nsh07.pomodoro.ui.viewModel.TimerViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AppScreen(
modifier: Modifier = Modifier,
viewModel: UiViewModel = viewModel(factory = UiViewModel.Factory)
viewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory)
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val remainingTime by viewModel.time.collectAsStateWithLifecycle()
val focusTimeInputFieldState = rememberSaveable(saver = TextFieldState.Saver) {
viewModel.focusTimeTextFieldState
}
val shortBreakTimeInputFieldState = rememberSaveable(saver = TextFieldState.Saver) {
viewModel.shortBreakTimeTextFieldState
}
val longBreakTimeInputFieldState = rememberSaveable(saver = TextFieldState.Saver) {
viewModel.longBreakTimeTextFieldState
}
val progress by rememberUpdatedState((uiState.totalTime.toFloat() - remainingTime) / uiState.totalTime)
var showBrandTitle by remember { mutableStateOf(true) }
@@ -149,7 +137,7 @@ fun AppScreen(
uiState = uiState,
showBrandTitle = showBrandTitle,
progress = { progress },
resetTimer = viewModel::updateTimerConstants,
resetTimer = viewModel::resetTimer,
skipTimer = viewModel::skipTimer,
toggleTimer = viewModel::toggleTimer,
modifier = modifier.padding(
@@ -161,12 +149,7 @@ fun AppScreen(
}
entry<Screen.Settings> {
SettingsScreen(
focusTimeInputFieldState,
shortBreakTimeInputFieldState,
longBreakTimeInputFieldState,
viewModel::startTimeFieldsCollection,
viewModel::stopTimeFieldsCollection,
SettingsScreenRoot(
modifier = modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection),

View File

@@ -30,7 +30,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberSliderState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -41,27 +41,43 @@ import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTitle
import org.nsh07.pomodoro.ui.theme.TomatoTheme
import org.nsh07.pomodoro.ui.viewModel.SettingsViewModel
@Composable
fun SettingsScreenRoot(
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory)
) {
val focusTimeInputFieldState = rememberSaveable(saver = TextFieldState.Saver) {
viewModel.focusTimeTextFieldState
}
val shortBreakTimeInputFieldState = rememberSaveable(saver = TextFieldState.Saver) {
viewModel.shortBreakTimeTextFieldState
}
val longBreakTimeInputFieldState = rememberSaveable(saver = TextFieldState.Saver) {
viewModel.longBreakTimeTextFieldState
}
SettingsScreen(
focusTimeInputFieldState = focusTimeInputFieldState,
shortBreakTimeInputFieldState = shortBreakTimeInputFieldState,
longBreakTimeInputFieldState = longBreakTimeInputFieldState,
modifier = modifier
)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SettingsScreen(
private fun SettingsScreen(
focusTimeInputFieldState: TextFieldState,
shortBreakTimeInputFieldState: TextFieldState,
longBreakTimeInputFieldState: TextFieldState,
startCollectingTimeFields: () -> Unit,
stopCollectingTimeFields: () -> Unit,
modifier: Modifier = Modifier
) {
DisposableEffect(Unit) {
startCollectingTimeFields()
onDispose {
stopCollectingTimeFields()
}
}
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val sessionsSliderState = rememberSliderState(value = 3f, steps = 3, valueRange = 1f..5f)
@@ -197,8 +213,6 @@ fun SettingsScreenPreview() {
focusTimeInputFieldState = rememberTextFieldState((25 * 60 * 1000).toString()),
shortBreakTimeInputFieldState = rememberTextFieldState((5 * 60 * 1000).toString()),
longBreakTimeInputFieldState = rememberTextFieldState((15 * 60 * 1000).toString()),
startCollectingTimeFields = {},
stopCollectingTimeFields = {},
modifier = Modifier.fillMaxSize()
)
}

View File

@@ -0,0 +1,79 @@
package org.nsh07.pomodoro.ui.viewModel
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.data.AppPreferenceRepository
import org.nsh07.pomodoro.data.TimerRepository
@OptIn(FlowPreview::class)
class SettingsViewModel(
private val preferenceRepository: AppPreferenceRepository,
private val timerRepository: TimerRepository
) : ViewModel() {
val focusTimeTextFieldState =
TextFieldState((timerRepository.focusTime / 60000).toString())
val shortBreakTimeTextFieldState =
TextFieldState((timerRepository.shortBreakTime / 60000).toString())
val longBreakTimeTextFieldState =
TextFieldState((timerRepository.longBreakTime / 60000).toString())
init {
viewModelScope.launch {
snapshotFlow { focusTimeTextFieldState.text }
.debounce(500)
.collect {
if (it.isNotEmpty()) {
timerRepository.focusTime = preferenceRepository.saveIntPreference(
"focus_time",
it.toString().toInt() * 60 * 1000
)
}
}
snapshotFlow { shortBreakTimeTextFieldState.text }
.debounce(500)
.collect {
if (it.isNotEmpty()) {
timerRepository.shortBreakTime = preferenceRepository.saveIntPreference(
"short_break_time",
it.toString().toInt() * 60 * 1000
)
}
}
snapshotFlow { longBreakTimeTextFieldState.text }
.debounce(500)
.collect {
if (it.isNotEmpty()) {
timerRepository.longBreakTime = preferenceRepository.saveIntPreference(
"long_break_time",
it.toString().toInt() * 60 * 1000
)
}
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as TomatoApplication)
val appPreferenceRepository = application.container.appPreferencesRepository
val appTimerRepository = application.container.appTimerRepository
SettingsViewModel(
preferenceRepository = appPreferenceRepository,
timerRepository = appTimerRepository
)
}
}
}
}

View File

@@ -1,9 +1,6 @@
package org.nsh07.pomodoro.ui.viewModel
import android.os.SystemClock
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.delete
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
@@ -17,44 +14,42 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.data.AppPreferenceRepository
import java.util.Locale
import kotlin.math.ceil
import org.nsh07.pomodoro.data.TimerRepository
import org.nsh07.pomodoro.utils.millisecondsToStr
@OptIn(FlowPreview::class)
class UiViewModel(
private val preferenceRepository: AppPreferenceRepository
class TimerViewModel(
private val preferenceRepository: AppPreferenceRepository,
private val timerRepository: TimerRepository
) : ViewModel() {
var focusTime = 25 * 60 * 1000
var shortBreakTime = 5 * 60 * 1000
var longBreakTime = 15 * 60 * 1000
val focusTimeTextFieldState = TextFieldState((focusTime / 60000).toString())
val shortBreakTimeTextFieldState = TextFieldState((shortBreakTime / 60000).toString())
val longBreakTimeTextFieldState = TextFieldState((longBreakTime / 60000).toString())
init {
updateTimerConstants(true)
viewModelScope.launch(Dispatchers.IO) {
timerRepository.focusTime = preferenceRepository.getIntPreference("focus_time")
?: preferenceRepository.saveIntPreference("focus_time", timerRepository.focusTime)
timerRepository.shortBreakTime = preferenceRepository.getIntPreference("short_break_time")
?: preferenceRepository.saveIntPreference("short_break_time", timerRepository.shortBreakTime)
timerRepository.longBreakTime = preferenceRepository.getIntPreference("long_break_time")
?: preferenceRepository.saveIntPreference("long_break_time", timerRepository.longBreakTime)
resetTimer()
}
}
private val _uiState = MutableStateFlow(
UiState(
totalTime = focusTime,
timeStr = millisecondsToStr(focusTime),
nextTimeStr = millisecondsToStr(shortBreakTime)
totalTime = timerRepository.focusTime,
timeStr = millisecondsToStr(timerRepository.focusTime),
nextTimeStr = millisecondsToStr(timerRepository.shortBreakTime)
)
)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
var timerJob: Job? = null
var focusTimeJob: Job? = null
var shortBreakTimeJob: Job? = null
var longBreakTimeJob: Job? = null
private val _time = MutableStateFlow(focusTime)
private val _time = MutableStateFlow(timerRepository.focusTime)
val time: StateFlow<Int> = _time.asStateFlow()
private var cycles = 0
@@ -63,7 +58,7 @@ class UiViewModel(
private var pauseDuration = 0L
fun resetTimer() {
_time.update { focusTime }
_time.update { timerRepository.focusTime }
cycles = 0
startTime = 0L
pauseTime = 0L
@@ -75,7 +70,7 @@ class UiViewModel(
timeStr = millisecondsToStr(time.value),
totalTime = time.value,
nextTimerMode = TimerMode.SHORT_BREAK,
nextTimeStr = millisecondsToStr(shortBreakTime)
nextTimeStr = millisecondsToStr(timerRepository.shortBreakTime)
)
}
}
@@ -87,7 +82,7 @@ class UiViewModel(
cycles = (cycles + 1) % 8
if (cycles % 2 == 0) {
_time.update { focusTime }
_time.update { timerRepository.focusTime }
_uiState.update { currentState ->
currentState.copy(
timerMode = TimerMode.FOCUS,
@@ -95,15 +90,15 @@ class UiViewModel(
totalTime = time.value,
nextTimerMode = if (cycles == 6) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK,
nextTimeStr = if (cycles == 6) millisecondsToStr(
longBreakTime
timerRepository.longBreakTime
) else millisecondsToStr(
shortBreakTime
timerRepository.shortBreakTime
)
)
}
} else {
val long = cycles == 7
_time.update { if (long) longBreakTime else shortBreakTime }
_time.update { if (long) timerRepository.longBreakTime else timerRepository.shortBreakTime }
_uiState.update { currentState ->
currentState.copy(
@@ -111,7 +106,7 @@ class UiViewModel(
timeStr = millisecondsToStr(time.value),
totalTime = time.value,
nextTimerMode = TimerMode.FOCUS,
nextTimeStr = millisecondsToStr(focusTime)
nextTimeStr = millisecondsToStr(timerRepository.focusTime)
)
}
}
@@ -136,13 +131,13 @@ class UiViewModel(
_time.update {
when (uiState.value.timerMode) {
TimerMode.FOCUS ->
focusTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
timerRepository.focusTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
TimerMode.SHORT_BREAK ->
shortBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
timerRepository.shortBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
else ->
longBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
timerRepository.longBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
}
}
@@ -167,92 +162,18 @@ class UiViewModel(
}
}
fun updateTimerConstants(updateTextFields: Boolean = false) {
viewModelScope.launch(Dispatchers.IO) {
focusTime = preferenceRepository.getIntPreference("focus_time")
?: preferenceRepository.saveIntPreference("focus_time", focusTime)
shortBreakTime = preferenceRepository.getIntPreference("short_break_time")
?: preferenceRepository.saveIntPreference("short_break_time", shortBreakTime)
longBreakTime = preferenceRepository.getIntPreference("long_break_time")
?: preferenceRepository.saveIntPreference("long_break_time", longBreakTime)
if (updateTextFields) {
focusTimeTextFieldState.edit {
delete(0, length)
append((focusTime / 60000).toString())
}
shortBreakTimeTextFieldState.edit {
delete(0, length)
append((shortBreakTime / 60000).toString())
}
longBreakTimeTextFieldState.edit {
delete(0, length)
append((longBreakTime / 60000).toString())
}
}
resetTimer()
}
}
fun startTimeFieldsCollection() {
focusTimeJob = viewModelScope.launch {
snapshotFlow { focusTimeTextFieldState.text }
.debounce(500)
.collect {
if (it.isNotEmpty()) {
focusTime = preferenceRepository.saveIntPreference(
"focus_time",
it.toString().toInt() * 60 * 1000
)
}
}
}
shortBreakTimeJob = viewModelScope.launch {
snapshotFlow { shortBreakTimeTextFieldState.text }
.debounce(500)
.collect {
if (it.isNotEmpty()) {
shortBreakTime = preferenceRepository.saveIntPreference(
"short_break_time",
it.toString().toInt() * 60 * 1000
)
}
}
}
longBreakTimeJob = viewModelScope.launch {
snapshotFlow { longBreakTimeTextFieldState.text }
.debounce(500)
.collect {
if (it.isNotEmpty()) {
longBreakTime = preferenceRepository.saveIntPreference(
"long_break_time",
it.toString().toInt() * 60 * 1000
)
}
}
}
}
fun stopTimeFieldsCollection() {
focusTimeJob?.cancel()
shortBreakTimeJob?.cancel()
longBreakTimeJob?.cancel()
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as TomatoApplication)
val appPreferenceRepository = application.container.appPreferencesRepository
UiViewModel(preferenceRepository = appPreferenceRepository)
val appTimerRepository = application.container.appTimerRepository
TimerViewModel(
preferenceRepository = appPreferenceRepository,
timerRepository = appTimerRepository
)
}
}
}
}
fun millisecondsToStr(t: Int): String {
val min = (ceil(t / 1000.0).toInt() / 60)
val sec = (ceil(t / 1000.0).toInt() % 60)
return String.format(locale = Locale.getDefault(), "%02d:%02d", min, sec)
}

View File

@@ -0,0 +1,10 @@
package org.nsh07.pomodoro.utils
import java.util.Locale
import kotlin.math.ceil
fun millisecondsToStr(t: Int): String {
val min = (ceil(t / 1000.0).toInt() / 60)
val sec = (ceil(t / 1000.0).toInt() % 60)
return String.format(locale = Locale.getDefault(), "%02d:%02d", min, sec)
}