refactor(architecture): reorganize state variables into a separate state layer

This change also ensures a single source of truth for states used by the UI and those used by the Service. #117
This commit is contained in:
Nishant Mishra
2025-12-03 14:46:27 +05:30
parent 28f0b38290
commit 32a09593f3
10 changed files with 290 additions and 320 deletions

View File

@@ -69,14 +69,14 @@ class MainActivity : ComponentActivity() {
) {
val colorScheme = colorScheme
LaunchedEffect(colorScheme) {
appContainer.appTimerRepository.colorScheme = colorScheme
appContainer.stateRepository.colorScheme = colorScheme
}
AppScreen(
isPlus = isPlus,
isAODEnabled = settingsState.aodEnabled,
setTimerFrequency = {
appContainer.appTimerRepository.timerFrequency = it
appContainer.stateRepository.timerFrequency = it
}
)
}
@@ -86,13 +86,13 @@ class MainActivity : ComponentActivity() {
override fun onStop() {
super.onStop()
// Reduce the timer loop frequency when not visible to save battery power
appContainer.appTimerRepository.timerFrequency = 1f
// Reduce the timer loop frequency when not visible to save battery
appContainer.stateRepository.timerFrequency = 1f
}
override fun onStart() {
super.onStart()
// Increase the timer loop frequency again when visible to make the progress smoother
appContainer.appTimerRepository.timerFrequency = 60f
appContainer.stateRepository.timerFrequency = 60f
}
}

View File

@@ -31,19 +31,16 @@ import org.nsh07.pomodoro.billing.BillingManager
import org.nsh07.pomodoro.billing.BillingManagerProvider
import org.nsh07.pomodoro.service.ServiceHelper
import org.nsh07.pomodoro.service.addTimerActions
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState
import org.nsh07.pomodoro.utils.millisecondsToStr
interface AppContainer {
val appPreferenceRepository: AppPreferenceRepository
val appStatRepository: AppStatRepository
val appTimerRepository: AppTimerRepository
val stateRepository: StateRepository
val billingManager: BillingManager
val notificationManager: NotificationManagerCompat
val notificationManagerService: NotificationManager
val notificationBuilder: NotificationCompat.Builder
val serviceHelper: ServiceHelper
val timerState: MutableStateFlow<TimerState>
val time: MutableStateFlow<Long>
var activityTurnScreenOn: (Boolean) -> Unit
}
@@ -58,7 +55,9 @@ class DefaultAppContainer(context: Context) : AppContainer {
AppStatRepository(AppDatabase.getDatabase(context).statDao())
}
override val appTimerRepository: AppTimerRepository by lazy { AppTimerRepository() }
override val stateRepository: StateRepository by lazy {
StateRepository()
}
override val billingManager: BillingManager by lazy { BillingManagerProvider.manager }
@@ -93,20 +92,9 @@ class DefaultAppContainer(context: Context) : AppContainer {
ServiceHelper(context)
}
override val timerState: MutableStateFlow<TimerState> by lazy {
MutableStateFlow(
TimerState(
totalTime = appTimerRepository.focusTime,
timeStr = millisecondsToStr(appTimerRepository.focusTime),
nextTimeStr = millisecondsToStr(appTimerRepository.shortBreakTime)
)
)
}
override val time: MutableStateFlow<Long> by lazy {
MutableStateFlow(appTimerRepository.focusTime)
MutableStateFlow(stateRepository.settingsState.value.focusTime)
}
override var activityTurnScreenOn: (Boolean) -> Unit = {}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* This file is part of Tomato - a minimalist pomodoro timer for Android.
*
* Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tomato.
* If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.data
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.lightColorScheme
import kotlinx.coroutines.flow.MutableStateFlow
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsState
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState
class StateRepository {
val timerState = MutableStateFlow(TimerState())
val settingsState = MutableStateFlow(SettingsState())
var timerFrequency: Float = 60f
var colorScheme: ColorScheme = lightColorScheme()
}

View File

@@ -1,66 +0,0 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* This file is part of Tomato - a minimalist pomodoro timer for Android.
*
* Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tomato.
* If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.data
import android.net.Uri
import android.provider.Settings
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.lightColorScheme
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Interface that holds the timer durations for each timer type. This repository maintains a single
* source of truth for the timer durations for the various ViewModels in the app.
*/
interface TimerRepository {
var focusTime: Long
var shortBreakTime: Long
var longBreakTime: Long
var sessionLength: Int
var timerFrequency: Float
var alarmEnabled: Boolean
var vibrateEnabled: Boolean
var dndEnabled: Boolean
var colorScheme: ColorScheme
var alarmSoundUri: Uri?
var serviceRunning: MutableStateFlow<Boolean>
}
/**
* See [TimerRepository] for more details
*/
class AppTimerRepository : TimerRepository {
override var focusTime = 25 * 60 * 1000L
override var shortBreakTime = 5 * 60 * 1000L
override var longBreakTime = 15 * 60 * 1000L
override var sessionLength = 4
override var timerFrequency: Float = 60f
override var alarmEnabled = true
override var vibrateEnabled = true
override var dndEnabled: Boolean = false
override var colorScheme = lightColorScheme()
override var alarmSoundUri: Uri? =
Settings.System.DEFAULT_ALARM_ALERT_URI ?: Settings.System.DEFAULT_RINGTONE_URI
override var serviceRunning = MutableStateFlow(false)
}

View File

@@ -36,7 +36,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@@ -53,12 +52,13 @@ class TimerService : Service() {
(application as TomatoApplication).container
}
private val timerRepository by lazy { appContainer.appTimerRepository }
private val stateRepository by lazy { appContainer.stateRepository }
private val statRepository by lazy { appContainer.appStatRepository }
private val notificationManager by lazy { appContainer.notificationManager }
private val notificationManagerService by lazy { appContainer.notificationManagerService }
private val notificationBuilder by lazy { appContainer.notificationBuilder }
private val _timerState by lazy { appContainer.timerState }
private val _timerState by lazy { stateRepository.timerState }
private val _settingsState by lazy { stateRepository.settingsState }
private val _time by lazy { appContainer.time }
/**
@@ -68,8 +68,6 @@ class TimerService : Service() {
get() = _time.value
set(value) = _time.update { value }
private val timerState by lazy { _timerState.asStateFlow() }
private var cycles = 0
private var startTime = 0L
private var pauseTime = 0L
@@ -94,7 +92,7 @@ class TimerService : Service() {
}
}
private val cs by lazy { timerRepository.colorScheme }
private val cs by lazy { stateRepository.colorScheme }
private lateinit var notificationStyle: NotificationCompat.ProgressStyle
@@ -104,12 +102,12 @@ class TimerService : Service() {
override fun onCreate() {
super.onCreate()
timerRepository.serviceRunning.update { true }
stateRepository.timerState.update { it.copy(serviceRunning = true) }
alarm = initializeMediaPlayer()
}
override fun onDestroy() {
timerRepository.serviceRunning.update { false }
stateRepository.timerState.update { it.copy(serviceRunning = false) }
runBlocking {
job.cancel()
saveTimeToDb()
@@ -129,7 +127,7 @@ class TimerService : Service() {
}
Actions.RESET.toString() -> {
if (timerState.value.timerRunning) toggleTimer()
if (_timerState.value.timerRunning) toggleTimer()
skipScope.launch {
resetTimer()
stopForegroundService()
@@ -148,7 +146,7 @@ class TimerService : Service() {
private fun toggleTimer() {
updateProgressSegments()
if (timerState.value.timerRunning) {
if (_timerState.value.timerRunning) {
setDoNotDisturb(false)
notificationBuilder.clearActions().addTimerActions(
this, R.drawable.play, getString(R.string.start)
@@ -159,7 +157,7 @@ class TimerService : Service() {
}
pauseTime = SystemClock.elapsedRealtime()
} else {
if (timerState.value.timerMode == TimerMode.FOCUS) setDoNotDisturb(true)
if (_timerState.value.timerMode == TimerMode.FOCUS) setDoNotDisturb(true)
else setDoNotDisturb(false)
notificationBuilder.clearActions().addTimerActions(
this, R.drawable.pause, getString(R.string.stop)
@@ -171,19 +169,20 @@ class TimerService : Service() {
timerScope.launch {
while (true) {
if (!timerState.value.timerRunning) break
if (!_timerState.value.timerRunning) break
if (startTime == 0L) startTime = SystemClock.elapsedRealtime()
time = when (timerState.value.timerMode) {
TimerMode.FOCUS -> timerRepository.focusTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration)
val settingsState = _settingsState.value
time = when (_timerState.value.timerMode) {
TimerMode.FOCUS -> settingsState.focusTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration)
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration)
TimerMode.SHORT_BREAK -> settingsState.shortBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration)
else -> timerRepository.longBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration)
else -> settingsState.longBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration)
}
iterations =
(iterations + 1) % timerRepository.timerFrequency.toInt().coerceAtLeast(1)
(iterations + 1) % stateRepository.timerFrequency.toInt().coerceAtLeast(1)
if (iterations == 0) showTimerNotification(time.toInt())
@@ -199,7 +198,7 @@ class TimerService : Service() {
timeStr = millisecondsToStr(time)
)
}
val totalTime = timerState.value.totalTime
val totalTime = _timerState.value.totalTime
if (totalTime - time < lastSavedDuration)
lastSavedDuration =
@@ -208,7 +207,7 @@ class TimerService : Service() {
saveTimeToDb()
}
delay((1000f / timerRepository.timerFrequency).toLong())
delay((1000f / stateRepository.timerFrequency).toLong())
}
}
}
@@ -221,21 +220,23 @@ class TimerService : Service() {
fun showTimerNotification(
remainingTime: Int, paused: Boolean = false, complete: Boolean = false
) {
val settingsState = _settingsState.value
if (complete) notificationBuilder.clearActions().addStopAlarmAction(this)
val totalTime = when (timerState.value.timerMode) {
TimerMode.FOCUS -> timerRepository.focusTime.toInt()
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt()
else -> timerRepository.longBreakTime.toInt()
val totalTime = when (_timerState.value.timerMode) {
TimerMode.FOCUS -> settingsState.focusTime.toInt()
TimerMode.SHORT_BREAK -> settingsState.shortBreakTime.toInt()
else -> settingsState.longBreakTime.toInt()
}
val currentTimer = when (timerState.value.timerMode) {
val currentTimer = when (_timerState.value.timerMode) {
TimerMode.FOCUS -> getString(R.string.focus)
TimerMode.SHORT_BREAK -> getString(R.string.short_break)
else -> getString(R.string.long_break)
}
val nextTimer = when (timerState.value.nextTimerMode) {
val nextTimer = when (_timerState.value.nextTimerMode) {
TimerMode.FOCUS -> getString(R.string.focus)
TimerMode.SHORT_BREAK -> getString(R.string.short_break)
else -> getString(R.string.long_break)
@@ -258,14 +259,14 @@ class TimerService : Service() {
getString(
R.string.up_next_notification,
nextTimer,
timerState.value.nextTimeStr
_timerState.value.nextTimeStr
)
)
.setStyle(
notificationStyle
.setProgress( // Set the current progress by filling the previous intervals and part of the current interval
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
(totalTime - remainingTime) + ((cycles + 1) / 2) * timerRepository.focusTime.toInt() + (cycles / 2) * timerRepository.shortBreakTime.toInt()
(totalTime - remainingTime) + ((cycles + 1) / 2) * settingsState.focusTime.toInt() + (cycles / 2) * settingsState.shortBreakTime.toInt()
} else (totalTime - remainingTime)
)
)
@@ -283,37 +284,38 @@ class TimerService : Service() {
}
private fun updateProgressSegments() {
val settingsState = _settingsState.value
notificationStyle = NotificationCompat.ProgressStyle()
.also {
// Add all the Focus, Short break and long break intervals in order
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
// Android 16 and later supports live updates
// Set progress bar sections if on Baklava or later
for (i in 0..<timerRepository.sessionLength * 2) {
for (i in 0..<settingsState.sessionLength * 2) {
if (i % 2 == 0) it.addProgressSegment(
NotificationCompat.ProgressStyle.Segment(
timerRepository.focusTime.toInt()
settingsState.focusTime.toInt()
)
.setColor(cs.primary.toArgb())
)
else if (i != (timerRepository.sessionLength * 2 - 1)) it.addProgressSegment(
else if (i != (settingsState.sessionLength * 2 - 1)) it.addProgressSegment(
NotificationCompat.ProgressStyle.Segment(
timerRepository.shortBreakTime.toInt()
settingsState.shortBreakTime.toInt()
).setColor(cs.tertiary.toArgb())
)
else it.addProgressSegment(
NotificationCompat.ProgressStyle.Segment(
timerRepository.longBreakTime.toInt()
settingsState.longBreakTime.toInt()
).setColor(cs.tertiary.toArgb())
)
}
} else {
it.addProgressSegment(
NotificationCompat.ProgressStyle.Segment(
when (timerState.value.timerMode) {
TimerMode.FOCUS -> timerRepository.focusTime.toInt()
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt()
else -> timerRepository.longBreakTime.toInt()
when (_timerState.value.timerMode) {
TimerMode.FOCUS -> settingsState.focusTime.toInt()
TimerMode.SHORT_BREAK -> settingsState.shortBreakTime.toInt()
else -> settingsState.longBreakTime.toInt()
}
)
)
@@ -322,10 +324,12 @@ class TimerService : Service() {
}
private suspend fun resetTimer() {
val settingsState = _settingsState.value
updateProgressSegments()
saveTimeToDb()
lastSavedDuration = 0
time = timerRepository.focusTime
time = settingsState.focusTime
cycles = 0
startTime = 0L
pauseTime = 0L
@@ -336,15 +340,16 @@ class TimerService : Service() {
timerMode = TimerMode.FOCUS,
timeStr = millisecondsToStr(time),
totalTime = time,
nextTimerMode = if (timerRepository.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK,
nextTimeStr = millisecondsToStr(if (timerRepository.sessionLength > 1) timerRepository.shortBreakTime else timerRepository.longBreakTime),
nextTimerMode = if (settingsState.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK,
nextTimeStr = millisecondsToStr(if (settingsState.sessionLength > 1) settingsState.shortBreakTime else settingsState.longBreakTime),
currentFocusCount = 1,
totalFocusCount = timerRepository.sessionLength
totalFocusCount = settingsState.sessionLength
)
}
}
private suspend fun skipTimer(fromButton: Boolean = false) {
val settingsState = _settingsState.value
updateProgressSegments()
saveTimeToDb()
updateProgressSegments()
@@ -354,30 +359,30 @@ class TimerService : Service() {
pauseTime = 0L
pauseDuration = 0L
cycles = (cycles + 1) % (timerRepository.sessionLength * 2)
cycles = (cycles + 1) % (settingsState.sessionLength * 2)
if (cycles % 2 == 0) {
if (timerState.value.timerRunning) setDoNotDisturb(true)
time = timerRepository.focusTime
if (_timerState.value.timerRunning) setDoNotDisturb(true)
time = settingsState.focusTime
_timerState.update { currentState ->
currentState.copy(
timerMode = TimerMode.FOCUS,
timeStr = millisecondsToStr(time),
totalTime = time,
nextTimerMode = if (cycles == (timerRepository.sessionLength - 1) * 2) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK,
nextTimeStr = if (cycles == (timerRepository.sessionLength - 1) * 2) millisecondsToStr(
timerRepository.longBreakTime
nextTimerMode = if (cycles == (settingsState.sessionLength - 1) * 2) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK,
nextTimeStr = if (cycles == (settingsState.sessionLength - 1) * 2) millisecondsToStr(
settingsState.longBreakTime
) else millisecondsToStr(
timerRepository.shortBreakTime
settingsState.shortBreakTime
),
currentFocusCount = cycles / 2 + 1,
totalFocusCount = timerRepository.sessionLength
totalFocusCount = settingsState.sessionLength
)
}
} else {
if (timerState.value.timerRunning) setDoNotDisturb(false)
val long = cycles == (timerRepository.sessionLength * 2) - 1
time = if (long) timerRepository.longBreakTime else timerRepository.shortBreakTime
if (_timerState.value.timerRunning) setDoNotDisturb(false)
val long = cycles == (settingsState.sessionLength * 2) - 1
time = if (long) settingsState.longBreakTime else settingsState.shortBreakTime
_timerState.update { currentState ->
currentState.copy(
@@ -385,14 +390,15 @@ class TimerService : Service() {
timeStr = millisecondsToStr(time),
totalTime = time,
nextTimerMode = TimerMode.FOCUS,
nextTimeStr = millisecondsToStr(timerRepository.focusTime)
nextTimeStr = millisecondsToStr(settingsState.focusTime)
)
}
}
}
fun startAlarm() {
if (timerRepository.alarmEnabled) alarm?.start()
val settingsState = _settingsState.value
if (settingsState.alarmEnabled) alarm?.start()
appContainer.activityTurnScreenOn(true)
@@ -401,7 +407,7 @@ class TimerService : Service() {
stopAlarm()
}
if (timerRepository.vibrateEnabled) {
if (settingsState.vibrateEnabled) {
if (!vibrator.hasVibrator()) {
return
}
@@ -413,14 +419,15 @@ class TimerService : Service() {
}
fun stopAlarm() {
val settingsState = _settingsState.value
autoAlarmStopScope?.cancel()
if (timerRepository.alarmEnabled) {
if (settingsState.alarmEnabled) {
alarm?.pause()
alarm?.seekTo(0)
}
if (timerRepository.vibrateEnabled) {
if (settingsState.vibrateEnabled) {
vibrator.cancel()
}
@@ -434,10 +441,10 @@ class TimerService : Service() {
getString(R.string.start_next)
)
showTimerNotification(
when (timerState.value.timerMode) {
TimerMode.FOCUS -> timerRepository.focusTime.toInt()
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt()
else -> timerRepository.longBreakTime.toInt()
when (_timerState.value.timerMode) {
TimerMode.FOCUS -> settingsState.focusTime.toInt()
TimerMode.SHORT_BREAK -> settingsState.shortBreakTime.toInt()
else -> settingsState.longBreakTime.toInt()
}, paused = true, complete = false
)
}
@@ -451,7 +458,7 @@ class TimerService : Service() {
.setUsage(AudioAttributes.USAGE_ALARM)
.build()
)
timerRepository.alarmSoundUri?.let {
_settingsState.value.alarmSoundUri?.let {
setDataSource(applicationContext, it)
prepare()
}
@@ -463,7 +470,7 @@ class TimerService : Service() {
}
private fun setDoNotDisturb(doNotDisturb: Boolean) {
if (timerRepository.dndEnabled && notificationManagerService.isNotificationPolicyAccessGranted()) {
if (_settingsState.value.dndEnabled && notificationManagerService.isNotificationPolicyAccessGranted()) {
if (doNotDisturb) {
notificationManagerService.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_ALARMS)
} else notificationManagerService.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_ALL)
@@ -477,8 +484,8 @@ class TimerService : Service() {
suspend fun saveTimeToDb() {
saveLock.withLock {
val elapsedTime = timerState.value.totalTime - time
when (timerState.value.timerMode) {
val elapsedTime = _timerState.value.totalTime - time
when (_timerState.value.timerMode) {
TimerMode.FOCUS -> statRepository.addFocusTime(
(elapsedTime - lastSavedDuration).coerceAtLeast(0L)
)

View File

@@ -62,7 +62,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.nsh07.pomodoro.R
@@ -92,10 +91,10 @@ fun AlarmSettings(
var alarmName by remember { mutableStateOf("...") }
LaunchedEffect(settingsState.alarmSound) {
LaunchedEffect(settingsState.alarmSoundUri) {
withContext(Dispatchers.IO) {
alarmName =
RingtoneManager.getRingtone(context, settingsState.alarmSound.toUri())
RingtoneManager.getRingtone(context, settingsState.alarmSoundUri)
?.getTitle(context) ?: ""
}
}
@@ -119,11 +118,11 @@ fun AlarmSettings(
}
@SuppressLint("LocalContextGetResourceValueCall")
val ringtonePickerIntent = remember(settingsState.alarmSound) {
val ringtonePickerIntent = remember(settingsState.alarmSoundUri) {
Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {
putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_ALARM)
putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, context.getString(R.string.alarm_sound))
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, settingsState.alarmSound.toUri())
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, settingsState.alarmSoundUri)
}
}

View File

@@ -17,17 +17,27 @@
package org.nsh07.pomodoro.ui.settingsScreen.viewModel
import android.net.Uri
import android.provider.Settings
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
@Immutable
data class SettingsState(
val theme: String = "auto",
val alarmSound: String = "",
val colorScheme: String = Color.White.toString(),
val blackTheme: Boolean = false,
val aodEnabled: Boolean = false,
val alarmEnabled: Boolean = true,
val vibrateEnabled: Boolean = true,
val dndEnabled: Boolean = false
val dndEnabled: Boolean = false,
val focusTime: Long = 25 * 60 * 1000L,
val shortBreakTime: Long = 5 * 60 * 1000L,
val longBreakTime: Long = 15 * 60 * 1000L,
val sessionLength: Int = 4,
val alarmSoundUri: Uri? =
Settings.System.DEFAULT_ALARM_ALERT_URI ?: Settings.System.DEFAULT_RINGTONE_URI
)

View File

@@ -25,6 +25,7 @@ import androidx.compose.material3.SliderState
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
@@ -35,51 +36,57 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.billing.BillingManager
import org.nsh07.pomodoro.data.AppPreferenceRepository
import org.nsh07.pomodoro.data.TimerRepository
import org.nsh07.pomodoro.data.PreferenceRepository
import org.nsh07.pomodoro.data.StateRepository
import org.nsh07.pomodoro.service.ServiceHelper
import org.nsh07.pomodoro.ui.Screen
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState
import org.nsh07.pomodoro.utils.millisecondsToStr
@OptIn(FlowPreview::class, ExperimentalMaterial3Api::class)
class SettingsViewModel(
private val billingManager: BillingManager,
private val preferenceRepository: AppPreferenceRepository,
private val preferenceRepository: PreferenceRepository,
private val stateRepository: StateRepository,
private val serviceHelper: ServiceHelper,
private val time: MutableStateFlow<Long>,
private val timerRepository: TimerRepository,
private val timerState: MutableStateFlow<TimerState>
private val time: MutableStateFlow<Long>
) : ViewModel() {
val backStack = mutableStateListOf<Screen.Settings>(Screen.Settings.Main)
val isPlus = billingManager.isPlus
val serviceRunning = timerRepository.serviceRunning.asStateFlow()
val serviceRunning = stateRepository.timerState.map { it.serviceRunning }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
false
)
private val _settingsState = MutableStateFlow(SettingsState())
private val _settingsState = stateRepository.settingsState
val settingsState = _settingsState.asStateFlow()
val focusTimeTextFieldState by lazy {
TextFieldState((timerRepository.focusTime / 60000).toString())
TextFieldState((_settingsState.value.focusTime / 60000).toString())
}
val shortBreakTimeTextFieldState by lazy {
TextFieldState((timerRepository.shortBreakTime / 60000).toString())
TextFieldState((_settingsState.value.shortBreakTime / 60000).toString())
}
val longBreakTimeTextFieldState by lazy {
TextFieldState((timerRepository.longBreakTime / 60000).toString())
TextFieldState((_settingsState.value.longBreakTime / 60000).toString())
}
val sessionsSliderState by lazy {
SliderState(
value = timerRepository.sessionLength.toFloat(),
value = _settingsState.value.sessionLength.toFloat(),
steps = 4,
valueRange = 1f..6f,
onValueChangeFinished = ::updateSessionLength
@@ -110,11 +117,15 @@ class SettingsViewModel(
}
private fun updateSessionLength() {
viewModelScope.launch {
timerRepository.sessionLength = preferenceRepository.saveIntPreference(
"session_length",
sessionsSliderState.value.toInt()
)
viewModelScope.launch(Dispatchers.IO) {
_settingsState.update { currentState ->
currentState.copy(
sessionLength = preferenceRepository.saveIntPreference(
"session_length",
sessionsSliderState.value.toInt()
)
)
}
refreshTimer()
}
}
@@ -125,11 +136,13 @@ class SettingsViewModel(
.debounce(500)
.collect {
if (it.isNotEmpty()) {
timerRepository.focusTime = it.toString().toLong() * 60 * 1000
_settingsState.update { currentState ->
currentState.copy(focusTime = it.toString().toLong() * 60 * 1000)
}
refreshTimer()
preferenceRepository.saveIntPreference(
"focus_time",
timerRepository.focusTime.toInt()
_settingsState.value.focusTime.toInt()
)
}
}
@@ -139,11 +152,13 @@ class SettingsViewModel(
.debounce(500)
.collect {
if (it.isNotEmpty()) {
timerRepository.shortBreakTime = it.toString().toLong() * 60 * 1000
_settingsState.update { currentState ->
currentState.copy(shortBreakTime = it.toString().toLong() * 60 * 1000)
}
refreshTimer()
preferenceRepository.saveIntPreference(
"short_break_time",
timerRepository.shortBreakTime.toInt()
_settingsState.value.shortBreakTime.toInt()
)
}
}
@@ -153,11 +168,13 @@ class SettingsViewModel(
.debounce(500)
.collect {
if (it.isNotEmpty()) {
timerRepository.longBreakTime = it.toString().toLong() * 60 * 1000
_settingsState.update { currentState ->
currentState.copy(longBreakTime = it.toString().toLong() * 60 * 1000)
}
refreshTimer()
preferenceRepository.saveIntPreference(
"long_break_time",
timerRepository.longBreakTime.toInt()
_settingsState.value.longBreakTime.toInt()
)
}
}
@@ -173,7 +190,6 @@ class SettingsViewModel(
private fun saveAlarmEnabled(enabled: Boolean) {
viewModelScope.launch {
timerRepository.alarmEnabled = enabled
_settingsState.update { currentState ->
currentState.copy(alarmEnabled = enabled)
}
@@ -183,7 +199,6 @@ class SettingsViewModel(
private fun saveVibrateEnabled(enabled: Boolean) {
viewModelScope.launch {
timerRepository.vibrateEnabled = enabled
_settingsState.update { currentState ->
currentState.copy(vibrateEnabled = enabled)
}
@@ -193,7 +208,6 @@ class SettingsViewModel(
private fun saveDndEnabled(enabled: Boolean) {
viewModelScope.launch {
timerRepository.dndEnabled = enabled
_settingsState.update { currentState ->
currentState.copy(dndEnabled = enabled)
}
@@ -203,9 +217,8 @@ class SettingsViewModel(
private fun saveAlarmSound(uri: Uri?) {
viewModelScope.launch {
timerRepository.alarmSoundUri = uri
_settingsState.update { currentState ->
currentState.copy(alarmSound = uri.toString())
currentState.copy(alarmSoundUri = uri)
}
preferenceRepository.saveStringPreference("alarm_sound", uri.toString())
}
@@ -248,32 +261,71 @@ class SettingsViewModel(
}
suspend fun reloadSettings() {
var settingsState = _settingsState.value
val focusTime =
preferenceRepository.getIntPreference("focus_time")?.toLong()
?: preferenceRepository.saveIntPreference(
"focus_time",
settingsState.focusTime.toInt()
).toLong()
val shortBreakTime =
preferenceRepository.getIntPreference("short_break_time")?.toLong()
?: preferenceRepository.saveIntPreference(
"short_break_time",
settingsState.shortBreakTime.toInt()
).toLong()
val longBreakTime =
preferenceRepository.getIntPreference("long_break_time")?.toLong()
?: preferenceRepository.saveIntPreference(
"long_break_time",
settingsState.longBreakTime.toInt()
).toLong()
val sessionLength =
preferenceRepository.getIntPreference("session_length")
?: preferenceRepository.saveIntPreference(
"session_length",
settingsState.sessionLength
)
val alarmSoundUri = (
preferenceRepository.getStringPreference("alarm_sound")
?: preferenceRepository.saveStringPreference(
"alarm_sound",
(Settings.System.DEFAULT_ALARM_ALERT_URI
?: Settings.System.DEFAULT_RINGTONE_URI).toString()
)
).toUri()
val theme = preferenceRepository.getStringPreference("theme")
?: preferenceRepository.saveStringPreference("theme", "auto")
?: preferenceRepository.saveStringPreference("theme", settingsState.theme)
val colorScheme = preferenceRepository.getStringPreference("color_scheme")
?: preferenceRepository.saveStringPreference("color_scheme", Color.White.toString())
?: preferenceRepository.saveStringPreference("color_scheme", settingsState.colorScheme)
val blackTheme = preferenceRepository.getBooleanPreference("black_theme")
?: preferenceRepository.saveBooleanPreference("black_theme", false)
?: preferenceRepository.saveBooleanPreference("black_theme", settingsState.blackTheme)
val aodEnabled = preferenceRepository.getBooleanPreference("aod_enabled")
?: preferenceRepository.saveBooleanPreference("aod_enabled", false)
val alarmSound = preferenceRepository.getStringPreference("alarm_sound")
?: preferenceRepository.saveStringPreference(
"alarm_sound",
(Settings.System.DEFAULT_ALARM_ALERT_URI
?: Settings.System.DEFAULT_RINGTONE_URI).toString()
)
?: preferenceRepository.saveBooleanPreference("aod_enabled", settingsState.aodEnabled)
val alarmEnabled = preferenceRepository.getBooleanPreference("alarm_enabled")
?: preferenceRepository.saveBooleanPreference("alarm_enabled", true)
?: preferenceRepository.saveBooleanPreference(
"alarm_enabled",
settingsState.alarmEnabled
)
val vibrateEnabled = preferenceRepository.getBooleanPreference("vibrate_enabled")
?: preferenceRepository.saveBooleanPreference("vibrate_enabled", true)
?: preferenceRepository.saveBooleanPreference(
"vibrate_enabled",
settingsState.vibrateEnabled
)
val dndEnabled = preferenceRepository.getBooleanPreference("dnd_enabled")
?: preferenceRepository.saveBooleanPreference("dnd_enabled", false)
?: preferenceRepository.saveBooleanPreference("dnd_enabled", settingsState.dndEnabled)
_settingsState.update { currentState ->
currentState.copy(
focusTime = focusTime,
shortBreakTime = shortBreakTime,
longBreakTime = longBreakTime,
sessionLength = sessionLength,
theme = theme,
colorScheme = colorScheme,
alarmSound = alarmSound,
alarmSoundUri = alarmSoundUri,
blackTheme = blackTheme,
aodEnabled = aodEnabled,
alarmEnabled = alarmEnabled,
@@ -281,21 +333,40 @@ class SettingsViewModel(
dndEnabled = dndEnabled
)
}
}
private fun refreshTimer() {
if (!serviceRunning.value) {
time.update { timerRepository.focusTime }
settingsState = _settingsState.value
timerState.update { currentState ->
time.update { settingsState.focusTime }
if (!stateRepository.timerState.value.serviceRunning)
stateRepository.timerState.update { currentState ->
currentState.copy(
timerMode = TimerMode.FOCUS,
timeStr = millisecondsToStr(time.value),
totalTime = time.value,
nextTimerMode = if (timerRepository.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK,
nextTimeStr = millisecondsToStr(if (timerRepository.sessionLength > 1) timerRepository.shortBreakTime else timerRepository.longBreakTime),
nextTimerMode = if (settingsState.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK,
nextTimeStr = millisecondsToStr(if (settingsState.sessionLength > 1) settingsState.shortBreakTime else settingsState.longBreakTime),
currentFocusCount = 1,
totalFocusCount = timerRepository.sessionLength
totalFocusCount = settingsState.sessionLength
)
}
}
private fun refreshTimer() {
if (!serviceRunning.value) {
val settingsState = _settingsState.value
time.update { settingsState.focusTime }
stateRepository.timerState.update { currentState ->
currentState.copy(
timerMode = TimerMode.FOCUS,
timeStr = millisecondsToStr(time.value),
totalTime = time.value,
nextTimerMode = if (settingsState.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK,
nextTimeStr = millisecondsToStr(if (settingsState.sessionLength > 1) settingsState.shortBreakTime else settingsState.longBreakTime),
currentFocusCount = 1,
totalFocusCount = settingsState.sessionLength
)
}
}
@@ -307,18 +378,16 @@ class SettingsViewModel(
val application = (this[APPLICATION_KEY] as TomatoApplication)
val appBillingManager = application.container.billingManager
val appPreferenceRepository = application.container.appPreferenceRepository
val appTimerRepository = application.container.appTimerRepository
val serviceHelper = application.container.serviceHelper
val stateRepository = application.container.stateRepository
val time = application.container.time
val timerState = application.container.timerState
SettingsViewModel(
billingManager = appBillingManager,
preferenceRepository = appPreferenceRepository,
serviceHelper = serviceHelper,
time = time,
timerRepository = appTimerRepository,
timerState = timerState
stateRepository = stateRepository,
time = time
)
}
}

View File

@@ -1,8 +1,18 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* This file is part of Tomato - a minimalist pomodoro timer for Android.
*
* Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tomato.
* If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.ui.timerScreen.viewModel
@@ -17,7 +27,8 @@ data class TimerState(
val showBrandTitle: Boolean = true,
val currentFocusCount: Int = 1,
val totalFocusCount: Int = 4,
val alarmRinging: Boolean = false
val alarmRinging: Boolean = false,
val serviceRunning: Boolean = false
)
enum class TimerMode {

View File

@@ -17,8 +17,6 @@
package org.nsh07.pomodoro.ui.timerScreen.viewModel
import android.provider.Settings
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
@@ -37,122 +35,49 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.data.PreferenceRepository
import org.nsh07.pomodoro.data.Stat
import org.nsh07.pomodoro.data.StatRepository
import org.nsh07.pomodoro.data.TimerRepository
import org.nsh07.pomodoro.data.StateRepository
import org.nsh07.pomodoro.service.ServiceHelper
import org.nsh07.pomodoro.utils.millisecondsToStr
import java.time.LocalDate
import java.time.temporal.ChronoUnit
@OptIn(FlowPreview::class)
class TimerViewModel(
private val preferenceRepository: PreferenceRepository,
private val serviceHelper: ServiceHelper,
private val stateRepository: StateRepository,
private val statRepository: StatRepository,
private val timerRepository: TimerRepository,
private val _timerState: MutableStateFlow<TimerState>,
private val _time: MutableStateFlow<Long>
) : ViewModel() {
val timerState: StateFlow<TimerState> = _timerState.asStateFlow()
val timerState: StateFlow<TimerState> = stateRepository.timerState.asStateFlow()
val time: StateFlow<Long> = _time.asStateFlow()
val progress = _time.combine(_timerState) { remainingTime, uiState ->
val progress = _time.combine(stateRepository.timerState) { remainingTime, uiState ->
(uiState.totalTime.toFloat() - remainingTime) / uiState.totalTime
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0f)
private var cycles = 0
private var startTime = 0L
private var pauseTime = 0L
private var pauseDuration = 0L
init {
if (!timerRepository.serviceRunning.value)
viewModelScope.launch(Dispatchers.IO) {
timerRepository.focusTime =
preferenceRepository.getIntPreference("focus_time")?.toLong()
?: preferenceRepository.saveIntPreference(
"focus_time",
timerRepository.focusTime.toInt()
).toLong()
timerRepository.shortBreakTime =
preferenceRepository.getIntPreference("short_break_time")?.toLong()
?: preferenceRepository.saveIntPreference(
"short_break_time",
timerRepository.shortBreakTime.toInt()
).toLong()
timerRepository.longBreakTime =
preferenceRepository.getIntPreference("long_break_time")?.toLong()
?: preferenceRepository.saveIntPreference(
"long_break_time",
timerRepository.longBreakTime.toInt()
).toLong()
timerRepository.sessionLength =
preferenceRepository.getIntPreference("session_length")
?: preferenceRepository.saveIntPreference(
"session_length",
timerRepository.sessionLength
)
viewModelScope.launch(Dispatchers.IO) {
var lastDate = statRepository.getLastDate()
val today = LocalDate.now()
timerRepository.alarmEnabled =
preferenceRepository.getBooleanPreference("alarm_enabled")
?: preferenceRepository.saveBooleanPreference("alarm_enabled", true)
timerRepository.vibrateEnabled =
preferenceRepository.getBooleanPreference("vibrate_enabled")
?: preferenceRepository.saveBooleanPreference("vibrate_enabled", true)
timerRepository.dndEnabled =
preferenceRepository.getBooleanPreference("dnd_enabled")
?: preferenceRepository.saveBooleanPreference("dnd_enabled", false)
timerRepository.alarmSoundUri = (
preferenceRepository.getStringPreference("alarm_sound")
?: preferenceRepository.saveStringPreference(
"alarm_sound",
(Settings.System.DEFAULT_ALARM_ALERT_URI
?: Settings.System.DEFAULT_RINGTONE_URI).toString()
)
).toUri()
_time.update { timerRepository.focusTime }
cycles = 0
startTime = 0L
pauseTime = 0L
pauseDuration = 0L
_timerState.update { currentState ->
currentState.copy(
timerMode = TimerMode.FOCUS,
timeStr = millisecondsToStr(time.value),
totalTime = time.value,
nextTimerMode = if (timerRepository.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK,
nextTimeStr = millisecondsToStr(if (timerRepository.sessionLength > 1) timerRepository.shortBreakTime else timerRepository.longBreakTime),
currentFocusCount = 1,
totalFocusCount = timerRepository.sessionLength
)
}
var lastDate = statRepository.getLastDate()
val today = LocalDate.now()
// Fills dates between today and lastDate with 0s to ensure continuous history
if (lastDate != null) {
while (ChronoUnit.DAYS.between(lastDate, today) > 0) {
lastDate = lastDate?.plusDays(1)
statRepository.insertStat(Stat(lastDate!!, 0, 0, 0, 0, 0))
}
} else {
statRepository.insertStat(Stat(today, 0, 0, 0, 0, 0))
}
delay(1500)
_timerState.update { currentState ->
currentState.copy(showBrandTitle = false)
// Fills dates between today and lastDate with 0s to ensure continuous history
if (lastDate != null) {
while (ChronoUnit.DAYS.between(lastDate, today) > 0) {
lastDate = lastDate?.plusDays(1)
statRepository.insertStat(Stat(lastDate!!, 0, 0, 0, 0, 0))
}
} else {
statRepository.insertStat(Stat(today, 0, 0, 0, 0, 0))
}
delay(1500)
stateRepository.timerState.update { currentState ->
currentState.copy(showBrandTitle = false)
}
}
}
fun onAction(action: TimerAction) {
@@ -163,19 +88,15 @@ class TimerViewModel(
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as TomatoApplication)
val appPreferenceRepository = application.container.appPreferenceRepository
val appStatRepository = application.container.appStatRepository
val appTimerRepository = application.container.appTimerRepository
val stateRepository = application.container.stateRepository
val serviceHelper = application.container.serviceHelper
val timerState = application.container.timerState
val time = application.container.time
TimerViewModel(
preferenceRepository = appPreferenceRepository,
serviceHelper = serviceHelper,
stateRepository = stateRepository,
statRepository = appStatRepository,
timerRepository = appTimerRepository,
_timerState = timerState,
_time = time
)
}