feat(settings): implement a settings option to enable auto-dnd

This commit is contained in:
Nishant Mishra
2025-10-25 14:27:25 +05:30
parent d3cead00ab
commit fddc1f8ed3
8 changed files with 171 additions and 56 deletions

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.data
@@ -27,6 +37,7 @@ interface TimerRepository {
var alarmEnabled: Boolean
var vibrateEnabled: Boolean
var dndEnabled: Boolean
var colorScheme: ColorScheme
@@ -46,6 +57,7 @@ class AppTimerRepository : TimerRepository {
override var timerFrequency: Float = 10f
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

View File

@@ -129,7 +129,7 @@ class TimerService : Service() {
}
}
Actions.SKIP.toString() -> skipTimer(true)
Actions.SKIP.toString() -> skipScope.launch { skipTimer(true) }
Actions.STOP_ALARM.toString() -> stopAlarm()
@@ -329,50 +329,48 @@ class TimerService : Service() {
}
}
private fun skipTimer(fromButton: Boolean = false) {
private suspend fun skipTimer(fromButton: Boolean = false) {
updateProgressSegments()
skipScope.launch {
saveTimeToDb()
updateProgressSegments()
showTimerNotification(0, paused = true, complete = !fromButton)
startTime = 0L
pauseTime = 0L
pauseDuration = 0L
saveTimeToDb()
updateProgressSegments()
showTimerNotification(0, paused = true, complete = !fromButton)
startTime = 0L
pauseTime = 0L
pauseDuration = 0L
cycles = (cycles + 1) % (timerRepository.sessionLength * 2)
cycles = (cycles + 1) % (timerRepository.sessionLength * 2)
if (cycles % 2 == 0) {
if (timerState.value.timerRunning) setDoNotDisturb(true)
time = timerRepository.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
) else millisecondsToStr(
timerRepository.shortBreakTime
),
currentFocusCount = cycles / 2 + 1,
totalFocusCount = timerRepository.sessionLength
)
}
} else {
if (timerState.value.timerRunning) setDoNotDisturb(false)
val long = cycles == (timerRepository.sessionLength * 2) - 1
time = if (long) timerRepository.longBreakTime else timerRepository.shortBreakTime
if (cycles % 2 == 0) {
if (timerState.value.timerRunning) setDoNotDisturb(true)
time = timerRepository.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
) else millisecondsToStr(
timerRepository.shortBreakTime
),
currentFocusCount = cycles / 2 + 1,
totalFocusCount = timerRepository.sessionLength
)
}
} else {
if (timerState.value.timerRunning) setDoNotDisturb(false)
val long = cycles == (timerRepository.sessionLength * 2) - 1
time = if (long) timerRepository.longBreakTime else timerRepository.shortBreakTime
_timerState.update { currentState ->
currentState.copy(
timerMode = if (long) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK,
timeStr = millisecondsToStr(time),
totalTime = time,
nextTimerMode = TimerMode.FOCUS,
nextTimeStr = millisecondsToStr(timerRepository.focusTime)
)
}
_timerState.update { currentState ->
currentState.copy(
timerMode = if (long) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK,
timeStr = millisecondsToStr(time),
totalTime = time,
nextTimerMode = TimerMode.FOCUS,
nextTimeStr = millisecondsToStr(timerRepository.focusTime)
)
}
}
}
@@ -449,7 +447,7 @@ class TimerService : Service() {
}
private fun setDoNotDisturb(doNotDisturb: Boolean) {
if (notificationManagerService.isNotificationPolicyAccessGranted()) {
if (timerRepository.dndEnabled && notificationManagerService.isNotificationPolicyAccessGranted()) {
if (doNotDisturb) {
notificationManagerService.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_ALARMS)
} else notificationManagerService.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_ALL)

View File

@@ -104,6 +104,7 @@ fun SettingsScreenRoot(
val alarmEnabled by viewModel.alarmEnabled.collectAsStateWithLifecycle(true)
val vibrateEnabled by viewModel.vibrateEnabled.collectAsStateWithLifecycle(true)
val dndEnabled by viewModel.dndEnabled.collectAsStateWithLifecycle(false)
val alarmSound by viewModel.alarmSound.collectAsStateWithLifecycle(viewModel.currentAlarmSound)
val preferencesState by viewModel.preferencesState.collectAsStateWithLifecycle()
@@ -126,11 +127,13 @@ fun SettingsScreenRoot(
sessionsSliderState = sessionsSliderState,
alarmEnabled = alarmEnabled,
vibrateEnabled = vibrateEnabled,
dndEnabled = dndEnabled,
alarmSound = alarmSound,
onAlarmEnabledChange = viewModel::saveAlarmEnabled,
onVibrateEnabledChange = viewModel::saveVibrateEnabled,
onBlackThemeChange = viewModel::saveBlackTheme,
onAodEnabledChange = viewModel::saveAodEnabled,
onDndEnabledChange = viewModel::saveDndEnabled,
onAlarmSoundChanged = {
viewModel.saveAlarmSound(it)
Intent(context, TimerService::class.java).apply {
@@ -155,11 +158,13 @@ private fun SettingsScreen(
sessionsSliderState: SliderState,
alarmEnabled: Boolean,
vibrateEnabled: Boolean,
dndEnabled: Boolean,
alarmSound: String,
onAlarmEnabledChange: (Boolean) -> Unit,
onVibrateEnabledChange: (Boolean) -> Unit,
onBlackThemeChange: (Boolean) -> Unit,
onAodEnabledChange: (Boolean) -> Unit,
onDndEnabledChange: (Boolean) -> Unit,
onAlarmSoundChanged: (Uri?) -> Unit,
onThemeChange: (String) -> Unit,
onColorSchemeChange: (Color) -> Unit,
@@ -270,11 +275,13 @@ private fun SettingsScreen(
entry<Screen.Settings.Timer> {
TimerSettings(
aodEnabled = preferencesState.aodEnabled,
dndEnabled = dndEnabled,
focusTimeInputFieldState = focusTimeInputFieldState,
shortBreakTimeInputFieldState = shortBreakTimeInputFieldState,
longBreakTimeInputFieldState = longBreakTimeInputFieldState,
sessionsSliderState = sessionsSliderState,
onAodEnabledChange = onAodEnabledChange,
onDndEnabledChange = onDndEnabledChange,
onBack = backStack::removeLastOrNull
)
}

View File

@@ -17,6 +17,11 @@
package org.nsh07.pomodoro.ui.settingsScreen.screens
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
@@ -31,6 +36,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.input.TextFieldState
@@ -51,6 +57,7 @@ import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -59,6 +66,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
@@ -76,19 +84,58 @@ import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.cardShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun TimerSettings(
aodEnabled: Boolean,
dndEnabled: Boolean,
focusTimeInputFieldState: TextFieldState,
shortBreakTimeInputFieldState: TextFieldState,
longBreakTimeInputFieldState: TextFieldState,
sessionsSliderState: SliderState,
onAodEnabledChange: (Boolean) -> Unit,
onDndEnabledChange: (Boolean) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier
) {
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
val context = LocalContext.current
val appName = stringResource(R.string.app_name)
val notificationManagerService =
remember { context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager }
LaunchedEffect(Unit) {
if (!notificationManagerService.isNotificationPolicyAccessGranted())
onDndEnabledChange(false)
}
val switchItems = listOf(
SettingsSwitchItem(
checked = dndEnabled,
icon = R.drawable.dnd,
label = R.string.dnd,
description = R.string.dnd_desc,
onClick = {
if (it && !notificationManagerService.isNotificationPolicyAccessGranted()) {
val intent = Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS)
Toast.makeText(context, "Enable permission for \"$appName\"", Toast.LENGTH_LONG)
.show()
context.startActivity(intent)
} else if (!it && notificationManagerService.isNotificationPolicyAccessGranted()) {
notificationManagerService.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_ALL)
}
onDndEnabledChange(it)
}
),
SettingsSwitchItem(
checked = aodEnabled,
icon = R.drawable.aod,
label = R.string.always_on_display,
description = R.string.always_on_display_desc,
onClick = onAodEnabledChange
)
)
Column(modifier.nestedScroll(scrollBehavior.nestedScrollConnection)) {
LargeFlexibleTopAppBar(
@@ -213,14 +260,7 @@ fun TimerSettings(
)
}
item { Spacer(Modifier.height(12.dp)) }
item {
val item = SettingsSwitchItem(
checked = aodEnabled,
icon = R.drawable.aod,
label = R.string.always_on_display,
description = R.string.always_on_display_desc,
onClick = onAodEnabledChange
)
itemsIndexed(switchItems) { index, item ->
ListItem(
leadingContent = {
Icon(
@@ -254,7 +294,13 @@ fun TimerSettings(
)
},
colors = listItemColors,
modifier = Modifier.clip(cardShape)
modifier = Modifier.clip(
when (index) {
0 -> topListItemShape
switchItems.size - 1 -> bottomListItemShape
else -> middleListItemShape
}
)
)
}
@@ -311,7 +357,9 @@ private fun TimerSettingsPreview() {
longBreakTimeInputFieldState = longBreakTimeInputFieldState,
sessionsSliderState = sessionsSliderState,
aodEnabled = true,
dndEnabled = false,
onBack = {},
onAodEnabledChange = {}
onAodEnabledChange = {},
onDndEnabledChange = {}
)
}

View File

@@ -85,6 +85,8 @@ class SettingsViewModel(
preferenceRepository.getBooleanPreferenceFlow("alarm_enabled").distinctUntilChanged()
val vibrateEnabled =
preferenceRepository.getBooleanPreferenceFlow("vibrate_enabled").distinctUntilChanged()
val dndEnabled =
preferenceRepository.getBooleanPreferenceFlow("dnd_enabled").distinctUntilChanged()
init {
viewModelScope.launch {
@@ -179,6 +181,13 @@ class SettingsViewModel(
}
}
fun saveDndEnabled(enabled: Boolean) {
viewModelScope.launch {
timerRepository.dndEnabled = enabled
preferenceRepository.saveBooleanPreference("dnd_enabled", enabled)
}
}
fun saveAlarmSound(uri: Uri?) {
viewModelScope.launch {
timerRepository.alarmSoundUri = uri

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
@@ -85,6 +95,9 @@ class TimerViewModel(
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")

View File

@@ -0,0 +1,26 @@
<!--
~ 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/>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#e3e3e3"
android:pathData="M320,520h320q17,0 28.5,-11.5T680,480q0,-17 -11.5,-28.5T640,440L320,440q-17,0 -28.5,11.5T280,480q0,17 11.5,28.5T320,520ZM480,880q-83,0 -156,-31.5T197,763q-54,-54 -85.5,-127T80,480q0,-83 31.5,-156T197,197q54,-54 127,-85.5T480,80q83,0 156,31.5T763,197q54,54 85.5,127T880,480q0,83 -31.5,156T763,763q-54,54 -127,85.5T480,880Z" />
</vector>

View File

@@ -79,4 +79,6 @@
<string name="appearance">Appearance</string>
<string name="durations">Durations</string>
<string name="sound">Sound</string>
<string name="dnd">Do Not Disturb</string>
<string name="dnd_desc">Turn on DND when running a Focus timer</string>
</resources>