feat(alarm): add option to only play alarm on headphones

Closes: #134
This commit is contained in:
Nishant Mishra
2025-12-04 10:58:16 +05:30
parent 5ced6d7c29
commit 3c86c2ce38
8 changed files with 147 additions and 55 deletions

View File

@@ -450,15 +450,19 @@ class TimerService : Service() {
} }
private fun initializeMediaPlayer(): MediaPlayer? { private fun initializeMediaPlayer(): MediaPlayer? {
val settingsState = _settingsState.value
return try { return try {
MediaPlayer().apply { MediaPlayer().apply {
setAudioAttributes( setAudioAttributes(
AudioAttributes.Builder() AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_ALARM) .setUsage(
if (settingsState.mediaVolumeForAlarm) AudioAttributes.USAGE_MEDIA
else AudioAttributes.USAGE_ALARM
)
.build() .build()
) )
_settingsState.value.alarmSoundUri?.let { settingsState.alarmSoundUri?.let {
setDataSource(applicationContext, it) setDataSource(applicationContext, it)
prepare() prepare()
} }

View File

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

View File

@@ -25,6 +25,7 @@ import android.net.Uri
import android.os.Build import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -43,6 +44,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LargeFlexibleTopAppBar import androidx.compose.material3.LargeFlexibleTopAppBar
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme.motionScheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.SwitchDefaults
@@ -60,8 +62,10 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.nsh07.pomodoro.R import org.nsh07.pomodoro.R
@@ -74,6 +78,7 @@ import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
import org.nsh07.pomodoro.ui.theme.CustomColors.switchColors import org.nsh07.pomodoro.ui.theme.CustomColors.switchColors
import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.bottomListItemShape import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.bottomListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.cardShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape
@@ -127,25 +132,36 @@ fun AlarmSettings(
} }
val switchItems = remember( val switchItems = remember(
settingsState.blackTheme,
settingsState.aodEnabled,
settingsState.alarmEnabled, settingsState.alarmEnabled,
settingsState.vibrateEnabled settingsState.vibrateEnabled,
settingsState.mediaVolumeForAlarm
) { ) {
listOf( listOf(
SettingsSwitchItem( listOf(
checked = settingsState.alarmEnabled, SettingsSwitchItem(
icon = R.drawable.alarm_on, checked = settingsState.alarmEnabled,
label = R.string.sound, icon = R.drawable.alarm_on,
description = R.string.alarm_desc, label = R.string.sound,
onClick = { onAction(SettingsAction.SaveAlarmEnabled(it)) } description = R.string.alarm_desc,
onClick = { onAction(SettingsAction.SaveAlarmEnabled(it)) }
),
SettingsSwitchItem(
checked = settingsState.vibrateEnabled,
icon = R.drawable.mobile_vibrate,
label = R.string.vibrate,
description = R.string.vibrate_desc,
onClick = { onAction(SettingsAction.SaveVibrateEnabled(it)) }
)
), ),
SettingsSwitchItem( listOf(
checked = settingsState.vibrateEnabled, SettingsSwitchItem(
icon = R.drawable.mobile_vibrate, checked = settingsState.mediaVolumeForAlarm,
label = R.string.vibrate, collapsible = true,
description = R.string.vibrate_desc, icon = R.drawable.music_note,
onClick = { onAction(SettingsAction.SaveVibrateEnabled(it)) } label = R.string.media_volume_for_alarm,
description = R.string.media_volume_for_alarm_desc,
onClick = { onAction(SettingsAction.SaveMediaVolumeForAlarm(it)) }
)
) )
) )
} }
@@ -203,44 +219,65 @@ fun AlarmSettings(
.clickable(onClick = { ringtonePickerLauncher.launch(ringtonePickerIntent) }) .clickable(onClick = { ringtonePickerLauncher.launch(ringtonePickerIntent) })
) )
} }
itemsIndexed(switchItems) { index, item -> switchItems.fastForEach { items ->
ListItem( itemsIndexed(items) { index, item ->
leadingContent = { ListItem(
Icon(painterResource(item.icon), contentDescription = null) leadingContent = {
}, Icon(painterResource(item.icon), contentDescription = null)
headlineContent = { Text(stringResource(item.label)) }, },
supportingContent = { Text(stringResource(item.description)) }, headlineContent = { Text(stringResource(item.label)) },
trailingContent = { supportingContent = {
Switch( if (item.collapsible) {
checked = item.checked, var expanded by remember { mutableStateOf(false) }
onCheckedChange = { item.onClick(it) }, Text(
thumbContent = { stringResource(item.description),
if (item.checked) { maxLines = if (expanded) Int.MAX_VALUE else 2,
Icon( overflow = TextOverflow.Ellipsis,
painter = painterResource(R.drawable.check), modifier = Modifier
contentDescription = null, .clickable { expanded = !expanded }
modifier = Modifier.size(SwitchDefaults.IconSize), .animateContentSize(motionScheme.defaultSpatialSpec())
) )
} else { } else {
Icon( Text(stringResource(item.description))
painter = painterResource(R.drawable.clear),
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
}
},
colors = switchColors
)
},
colors = listItemColors,
modifier = Modifier
.clip(
when (index) {
switchItems.lastIndex -> bottomListItemShape
else -> middleListItemShape
} }
) },
) trailingContent = {
Switch(
checked = item.checked,
onCheckedChange = { item.onClick(it) },
thumbContent = {
if (item.checked) {
Icon(
painter = painterResource(R.drawable.check),
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
} else {
Icon(
painter = painterResource(R.drawable.clear),
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
}
},
colors = switchColors
)
},
colors = listItemColors,
modifier = Modifier
.clip(
when {
items.size == 1 -> cardShape
index == items.lastIndex -> bottomListItemShape
else -> middleListItemShape
}
)
)
}
item {
Spacer(Modifier.height(12.dp))
}
} }
item { Spacer(Modifier.height(12.dp)) } item { Spacer(Modifier.height(12.dp)) }

View File

@@ -26,6 +26,7 @@ sealed interface SettingsAction {
data class SaveBlackTheme(val enabled: Boolean) : SettingsAction data class SaveBlackTheme(val enabled: Boolean) : SettingsAction
data class SaveAodEnabled(val enabled: Boolean) : SettingsAction data class SaveAodEnabled(val enabled: Boolean) : SettingsAction
data class SaveDndEnabled(val enabled: Boolean) : SettingsAction data class SaveDndEnabled(val enabled: Boolean) : SettingsAction
data class SaveMediaVolumeForAlarm(val enabled: Boolean) : SettingsAction
data class SaveAlarmSound(val uri: Uri?) : SettingsAction data class SaveAlarmSound(val uri: Uri?) : SettingsAction
data class SaveTheme(val theme: String) : SettingsAction data class SaveTheme(val theme: String) : SettingsAction
data class SaveColorScheme(val color: Color) : SettingsAction data class SaveColorScheme(val color: Color) : SettingsAction

View File

@@ -31,6 +31,7 @@ data class SettingsState(
val alarmEnabled: Boolean = true, val alarmEnabled: Boolean = true,
val vibrateEnabled: Boolean = true, val vibrateEnabled: Boolean = true,
val dndEnabled: Boolean = false, val dndEnabled: Boolean = false,
val mediaVolumeForAlarm: Boolean = false,
val focusTime: Long = 25 * 60 * 1000L, val focusTime: Long = 25 * 60 * 1000L,
val shortBreakTime: Long = 5 * 60 * 1000L, val shortBreakTime: Long = 5 * 60 * 1000L,

View File

@@ -109,6 +109,7 @@ class SettingsViewModel(
is SettingsAction.SaveAlarmEnabled -> saveAlarmEnabled(action.enabled) is SettingsAction.SaveAlarmEnabled -> saveAlarmEnabled(action.enabled)
is SettingsAction.SaveVibrateEnabled -> saveVibrateEnabled(action.enabled) is SettingsAction.SaveVibrateEnabled -> saveVibrateEnabled(action.enabled)
is SettingsAction.SaveDndEnabled -> saveDndEnabled(action.enabled) is SettingsAction.SaveDndEnabled -> saveDndEnabled(action.enabled)
is SettingsAction.SaveMediaVolumeForAlarm -> saveMediaVolumeForAlarm(action.enabled)
is SettingsAction.SaveColorScheme -> saveColorScheme(action.color) is SettingsAction.SaveColorScheme -> saveColorScheme(action.color)
is SettingsAction.SaveTheme -> saveTheme(action.theme) is SettingsAction.SaveTheme -> saveTheme(action.theme)
is SettingsAction.SaveBlackTheme -> saveBlackTheme(action.enabled) is SettingsAction.SaveBlackTheme -> saveBlackTheme(action.enabled)
@@ -260,6 +261,18 @@ class SettingsViewModel(
} }
} }
private fun saveMediaVolumeForAlarm(mediaVolumeForAlarm: Boolean) {
viewModelScope.launch {
_settingsState.update { currentState ->
currentState.copy(mediaVolumeForAlarm = mediaVolumeForAlarm)
}
preferenceRepository.saveBooleanPreference(
"media_volume_for_alarm",
mediaVolumeForAlarm
)
}
}
suspend fun reloadSettings() { suspend fun reloadSettings() {
var settingsState = _settingsState.value var settingsState = _settingsState.value
val focusTime = val focusTime =
@@ -316,6 +329,12 @@ class SettingsViewModel(
) )
val dndEnabled = preferenceRepository.getBooleanPreference("dnd_enabled") val dndEnabled = preferenceRepository.getBooleanPreference("dnd_enabled")
?: preferenceRepository.saveBooleanPreference("dnd_enabled", settingsState.dndEnabled) ?: preferenceRepository.saveBooleanPreference("dnd_enabled", settingsState.dndEnabled)
val mediaVolumeForAlarm =
preferenceRepository.getBooleanPreference("media_volume_for_alarm")
?: preferenceRepository.saveBooleanPreference(
"media_volume_for_alarm",
settingsState.mediaVolumeForAlarm
)
_settingsState.update { currentState -> _settingsState.update { currentState ->
currentState.copy( currentState.copy(
@@ -330,7 +349,8 @@ class SettingsViewModel(
aodEnabled = aodEnabled, aodEnabled = aodEnabled,
alarmEnabled = alarmEnabled, alarmEnabled = alarmEnabled,
vibrateEnabled = vibrateEnabled, vibrateEnabled = vibrateEnabled,
dndEnabled = dndEnabled dndEnabled = dndEnabled,
mediaVolumeForAlarm = mediaVolumeForAlarm
) )
} }

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="M400,840q-66,0 -113,-47t-47,-113q0,-66 47,-113t113,-47q23,0 42.5,5.5T480,542v-382q0,-17 11.5,-28.5T520,120h160q17,0 28.5,11.5T720,160v80q0,17 -11.5,28.5T680,280L560,280v400q0,66 -47,113t-113,47Z" />
</vector>

View File

@@ -103,4 +103,6 @@
<string name="bmc_desc">Support me with a small donation</string> <string name="bmc_desc">Support me with a small donation</string>
<string name="tomato_plus_desc">Customize further with Tomato+</string> <string name="tomato_plus_desc">Customize further with Tomato+</string>
<string name="license">License</string> <string name="license">License</string>
<string name="media_volume_for_alarm">Headphone mode</string>
<string name="media_volume_for_alarm_desc">Plays on headphones only. If headphones are disconnected, alarm plays through speaker at media volume.</string>
</resources> </resources>