Merge pull request #133 from nsh07/auto-timer-update

Auto timer update
This commit is contained in:
Nishant Mishra
2025-11-09 22:52:09 +05:30
committed by GitHub
12 changed files with 153 additions and 47 deletions

View File

@@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import org.nsh07.pomodoro.R
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
@@ -41,6 +42,7 @@ interface AppContainer {
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
@@ -87,6 +89,10 @@ class DefaultAppContainer(context: Context) : AppContainer {
.setVisibility(VISIBILITY_PUBLIC)
}
override val serviceHelper: ServiceHelper by lazy {
ServiceHelper(context)
}
override val timerState: MutableStateFlow<TimerState> by lazy {
MutableStateFlow(
TimerState(

View File

@@ -21,6 +21,7 @@ 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
@@ -43,7 +44,7 @@ interface TimerRepository {
var alarmSoundUri: Uri?
var serviceRunning: Boolean
var serviceRunning: MutableStateFlow<Boolean>
}
/**
@@ -61,5 +62,5 @@ class AppTimerRepository : TimerRepository {
override var colorScheme = lightColorScheme()
override var alarmSoundUri: Uri? =
Settings.System.DEFAULT_ALARM_ALERT_URI ?: Settings.System.DEFAULT_RINGTONE_URI
override var serviceRunning = false
override var serviceRunning = MutableStateFlow(false)
}

View File

@@ -0,0 +1,58 @@
/*
* 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.service
import android.content.Context
import android.content.Intent
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction
/**
* Helper class that holds a reference to [Context] and helps call [Context.startService] in
* [androidx.lifecycle.ViewModel]s. This class must be managed by an [android.app.Application] class
* to scope it to the Activity's lifecycle and prevent leaks.
*/
class ServiceHelper(private val context: Context) {
fun startService(action: TimerAction) {
when (action) {
TimerAction.ResetTimer ->
Intent(context, TimerService::class.java).also {
it.action = TimerService.Actions.RESET.toString()
context.startService(it)
}
is TimerAction.SkipTimer ->
Intent(context, TimerService::class.java).also {
it.action = TimerService.Actions.SKIP.toString()
context.startService(it)
}
TimerAction.StopAlarm ->
Intent(context, TimerService::class.java).also {
it.action =
TimerService.Actions.STOP_ALARM.toString()
context.startService(it)
}
TimerAction.ToggleTimer ->
Intent(context, TimerService::class.java).also {
it.action = TimerService.Actions.TOGGLE.toString()
context.startService(it)
}
}
}
}

View File

@@ -98,12 +98,12 @@ class TimerService : Service() {
override fun onCreate() {
super.onCreate()
timerRepository.serviceRunning = true
timerRepository.serviceRunning.update { true }
alarm = initializeMediaPlayer()
}
override fun onDestroy() {
timerRepository.serviceRunning = false
timerRepository.serviceRunning.update { false }
runBlocking {
job.cancel()
saveTimeToDb()

View File

@@ -63,7 +63,6 @@ import org.nsh07.pomodoro.ui.settingsScreen.SettingsScreenRoot
import org.nsh07.pomodoro.ui.statsScreen.StatsScreenRoot
import org.nsh07.pomodoro.ui.timerScreen.AlarmDialog
import org.nsh07.pomodoro.ui.timerScreen.TimerScreen
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@@ -163,34 +162,7 @@ fun AppScreen(
timerState = uiState,
isPlus = isPlus,
progress = { progress },
onAction = { action ->
when (action) {
TimerAction.ResetTimer ->
Intent(context, TimerService::class.java).also {
it.action = TimerService.Actions.RESET.toString()
context.startService(it)
}
is TimerAction.SkipTimer ->
Intent(context, TimerService::class.java).also {
it.action = TimerService.Actions.SKIP.toString()
context.startService(it)
}
TimerAction.StopAlarm ->
Intent(context, TimerService::class.java).also {
it.action =
TimerService.Actions.STOP_ALARM.toString()
context.startService(it)
}
TimerAction.ToggleTimer ->
Intent(context, TimerService::class.java).also {
it.action = TimerService.Actions.TOGGLE.toString()
context.startService(it)
}
}
},
onAction = timerViewModel::onAction,
modifier = modifier
.padding(
start = contentPadding.calculateStartPadding(layoutDirection),

View File

@@ -101,6 +101,7 @@ fun SettingsScreenRoot(
val longBreakTimeInputFieldState = viewModel.longBreakTimeTextFieldState
val isPlus by viewModel.isPlus.collectAsStateWithLifecycle()
val serviceRunning by viewModel.serviceRunning.collectAsStateWithLifecycle()
val settingsState by viewModel.settingsState.collectAsStateWithLifecycle()
@@ -115,6 +116,7 @@ fun SettingsScreenRoot(
SettingsScreen(
isPlus = isPlus,
serviceRunning = serviceRunning,
settingsState = settingsState,
backStack = backStack,
focusTimeInputFieldState = focusTimeInputFieldState,
@@ -132,6 +134,7 @@ fun SettingsScreenRoot(
@Composable
private fun SettingsScreen(
isPlus: Boolean,
serviceRunning: Boolean,
settingsState: SettingsState,
backStack: SnapshotStateList<Screen.Settings>,
focusTimeInputFieldState: TextFieldState,
@@ -292,6 +295,7 @@ private fun SettingsScreen(
entry<Screen.Settings.Timer> {
TimerSettings(
isPlus = isPlus,
serviceRunning = serviceRunning,
settingsState = settingsState,
focusTimeInputFieldState = focusTimeInputFieldState,
shortBreakTimeInputFieldState = shortBreakTimeInputFieldState,

View File

@@ -22,6 +22,7 @@ import androidx.annotation.StringRes
data class SettingsSwitchItem(
val checked: Boolean,
val enabled: Boolean = true,
@param:DrawableRes val icon: Int,
@param:StringRes val label: Int,
@param:StringRes val description: Int,

View File

@@ -46,12 +46,14 @@ import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
@Composable
fun MinuteInputField(
state: TextFieldState,
enabled: Boolean,
shape: Shape,
modifier: Modifier = Modifier,
imeAction: ImeAction = ImeAction.Next
) {
BasicTextField(
state = state,
enabled = enabled,
lineLimits = TextFieldLineLimits.SingleLine,
inputTransformation = MinutesInputTransformation,
// outputTransformation = MinutesOutputTransformation,
@@ -63,7 +65,7 @@ fun MinuteInputField(
fontFamily = interClock,
fontSize = 57.sp,
letterSpacing = (-2).sp,
color = colorScheme.onSurfaceVariant,
color = if (enabled) colorScheme.onSurfaceVariant else colorScheme.outlineVariant,
textAlign = TextAlign.Center
),
cursorBrush = SolidColor(colorScheme.onSurface),

View File

@@ -50,6 +50,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LargeFlexibleTopAppBar
import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Slider
@@ -60,7 +61,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberSliderState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -95,6 +96,7 @@ import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape
@Composable
fun TimerSettings(
isPlus: Boolean,
serviceRunning: Boolean,
settingsState: SettingsState,
focusTimeInputFieldState: TextFieldState,
shortBreakTimeInputFieldState: TextFieldState,
@@ -111,14 +113,10 @@ fun TimerSettings(
val notificationManagerService =
remember { context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager }
LaunchedEffect(Unit) {
if (!notificationManagerService.isNotificationPolicyAccessGranted())
onAction(SettingsAction.SaveDndEnabled(false))
}
val switchItems = listOf(
SettingsSwitchItem(
checked = settingsState.dndEnabled,
enabled = !serviceRunning,
icon = R.drawable.dnd,
label = R.string.dnd,
description = R.string.dnd_desc,
@@ -171,6 +169,20 @@ fun TimerSettings(
.padding(horizontal = 16.dp)
) {
item {
CompositionLocalProvider(LocalContentColor provides colorScheme.error) {
AnimatedVisibility(serviceRunning) {
Column {
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(painterResource(R.drawable.info), null)
Text(stringResource(R.string.timer_settings_reset_info))
}
}
}
}
Spacer(Modifier.height(14.dp))
}
item {
@@ -190,6 +202,7 @@ fun TimerSettings(
)
MinuteInputField(
state = focusTimeInputFieldState,
enabled = !serviceRunning,
shape = RoundedCornerShape(
topStart = topListItemShape.topStart,
bottomStart = topListItemShape.topStart,
@@ -210,6 +223,7 @@ fun TimerSettings(
)
MinuteInputField(
state = shortBreakTimeInputFieldState,
enabled = !serviceRunning,
shape = RoundedCornerShape(middleListItemShape.topStart),
imeAction = ImeAction.Next
)
@@ -225,6 +239,7 @@ fun TimerSettings(
)
MinuteInputField(
state = longBreakTimeInputFieldState,
enabled = !serviceRunning,
shape = RoundedCornerShape(
topStart = bottomListItemShape.topStart,
bottomStart = bottomListItemShape.topStart,
@@ -257,6 +272,7 @@ fun TimerSettings(
)
Slider(
state = sessionsSliderState,
enabled = !serviceRunning,
modifier = Modifier.padding(vertical = 4.dp)
)
}
@@ -281,6 +297,7 @@ fun TimerSettings(
trailingContent = {
Switch(
checked = item.checked,
enabled = item.enabled,
onCheckedChange = { item.onClick(it) },
thumbContent = {
if (item.checked) {
@@ -405,6 +422,7 @@ private fun TimerSettingsPreview() {
)
TimerSettings(
isPlus = false,
serviceRunning = true,
settingsState = remember { SettingsState() },
focusTimeInputFieldState = focusTimeInputFieldState,
shortBreakTimeInputFieldState = shortBreakTimeInputFieldState,

View File

@@ -43,17 +43,26 @@ 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.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 serviceHelper: ServiceHelper,
private val time: MutableStateFlow<Long>,
private val timerRepository: TimerRepository,
private val timerState: MutableStateFlow<TimerState>
) : ViewModel() {
val backStack = mutableStateListOf<Screen.Settings>(Screen.Settings.Main)
val isPlus = billingManager.isPlus
val serviceRunning = timerRepository.serviceRunning.asStateFlow()
private val _settingsState = MutableStateFlow(SettingsState())
val settingsState = _settingsState.asStateFlow()
@@ -106,6 +115,7 @@ class SettingsViewModel(
"session_length",
sessionsSliderState.value.toInt()
)
refreshTimer()
}
}
@@ -116,6 +126,7 @@ class SettingsViewModel(
.collect {
if (it.isNotEmpty()) {
timerRepository.focusTime = it.toString().toLong() * 60 * 1000
refreshTimer()
preferenceRepository.saveIntPreference(
"focus_time",
timerRepository.focusTime.toInt()
@@ -129,6 +140,7 @@ class SettingsViewModel(
.collect {
if (it.isNotEmpty()) {
timerRepository.shortBreakTime = it.toString().toLong() * 60 * 1000
refreshTimer()
preferenceRepository.saveIntPreference(
"short_break_time",
timerRepository.shortBreakTime.toInt()
@@ -142,6 +154,7 @@ class SettingsViewModel(
.collect {
if (it.isNotEmpty()) {
timerRepository.longBreakTime = it.toString().toLong() * 60 * 1000
refreshTimer()
preferenceRepository.saveIntPreference(
"long_break_time",
timerRepository.longBreakTime.toInt()
@@ -152,6 +165,7 @@ class SettingsViewModel(
}
fun cancelTextFieldFlowCollection() {
if (!serviceRunning.value) serviceHelper.startService(TimerAction.ResetTimer)
focusFlowCollectionJob?.cancel()
shortBreakFlowCollectionJob?.cancel()
longBreakFlowCollectionJob?.cancel()
@@ -269,18 +283,42 @@ class SettingsViewModel(
}
}
private fun refreshTimer() {
if (!serviceRunning.value) {
time.update { timerRepository.focusTime }
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
)
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as TomatoApplication)
val appBillingManager = application.container.billingManager
val appPreferenceRepository = application.container.appPreferenceRepository
val appTimerRepository = application.container.appTimerRepository
val appBillingManager = application.container.billingManager
val serviceHelper = application.container.serviceHelper
val time = application.container.time
val timerState = application.container.timerState
SettingsViewModel(
billingManager = appBillingManager,
preferenceRepository = appPreferenceRepository,
serviceHelper = serviceHelper,
time = time,
timerRepository = appTimerRepository,
timerState = timerState
)
}
}

View File

@@ -17,10 +17,9 @@
package org.nsh07.pomodoro.ui.timerScreen.viewModel
import android.app.Application
import android.provider.Settings
import androidx.core.net.toUri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
@@ -42,19 +41,20 @@ 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.service.ServiceHelper
import org.nsh07.pomodoro.utils.millisecondsToStr
import java.time.LocalDate
import java.time.temporal.ChronoUnit
@OptIn(FlowPreview::class)
class TimerViewModel(
application: Application,
private val preferenceRepository: PreferenceRepository,
private val serviceHelper: ServiceHelper,
private val statRepository: StatRepository,
private val timerRepository: TimerRepository,
private val _timerState: MutableStateFlow<TimerState>,
private val _time: MutableStateFlow<Long>
) : AndroidViewModel(application) {
) : ViewModel() {
val timerState: StateFlow<TimerState> = _timerState.asStateFlow()
val time: StateFlow<Long> = _time.asStateFlow()
@@ -70,7 +70,7 @@ class TimerViewModel(
private var pauseDuration = 0L
init {
if (!timerRepository.serviceRunning)
if (!timerRepository.serviceRunning.value)
viewModelScope.launch(Dispatchers.IO) {
timerRepository.focusTime =
preferenceRepository.getIntPreference("focus_time")?.toLong()
@@ -155,6 +155,10 @@ class TimerViewModel(
}
}
fun onAction(action: TimerAction) {
serviceHelper.startService(action)
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
@@ -162,12 +166,13 @@ class TimerViewModel(
val appPreferenceRepository = application.container.appPreferenceRepository
val appStatRepository = application.container.appStatRepository
val appTimerRepository = application.container.appTimerRepository
val serviceHelper = application.container.serviceHelper
val timerState = application.container.timerState
val time = application.container.time
TimerViewModel(
application = application,
preferenceRepository = appPreferenceRepository,
serviceHelper = serviceHelper,
statRepository = appStatRepository,
timerRepository = appTimerRepository,
_timerState = timerState,

View File

@@ -93,4 +93,5 @@
<string name="bmc">BuyMeACoffee</string>
<string name="selected">Selected</string>
<string name="help_with_translation">Help with translation</string>
<string name="timer_settings_reset_info">Reset the timer to change settings</string>
</resources>