Merge pull request #87 from nsh07/settings-redesign

Settings redesign
This commit is contained in:
Nishant Mishra
2025-10-22 22:24:18 +05:30
committed by GitHub
22 changed files with 1305 additions and 515 deletions

View File

@@ -1,3 +1,20 @@
/*
* 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
import android.os.Bundle
@@ -11,8 +28,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.nsh07.pomodoro.ui.AppScreen
import org.nsh07.pomodoro.ui.NavItem
import org.nsh07.pomodoro.ui.Screen
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel
import org.nsh07.pomodoro.ui.theme.TomatoTheme
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel
@@ -71,27 +86,4 @@ class MainActivity : ComponentActivity() {
// Increase the timer loop frequency again when visible to make the progress smoother
appContainer.appTimerRepository.timerFrequency = 10f
}
companion object {
val screens = listOf(
NavItem(
Screen.Timer,
R.drawable.timer_outlined,
R.drawable.timer_filled,
R.string.timer
),
NavItem(
Screen.Stats,
R.drawable.monitoring,
R.drawable.monitoring_filled,
R.string.stats
),
NavItem(
Screen.Settings,
R.drawable.settings,
R.drawable.settings_filled,
R.string.settings
)
)
}
}

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
@@ -45,7 +55,6 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import androidx.window.core.layout.WindowSizeClass
import org.nsh07.pomodoro.MainActivity.Companion.screens
import org.nsh07.pomodoro.service.TimerService
import org.nsh07.pomodoro.ui.settingsScreen.SettingsScreenRoot
import org.nsh07.pomodoro.ui.statsScreen.StatsScreenRoot
@@ -100,7 +109,7 @@ fun AppScreen(
if (wide) ShortNavigationBarArrangement.Centered
else ShortNavigationBarArrangement.EqualWeight
) {
screens.forEach {
mainScreens.forEach {
val selected = backStack.last() == it.route
ShortNavigationBarItem(
selected = selected,
@@ -131,7 +140,7 @@ fun AppScreen(
SharedTransitionLayout {
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
onBack = backStack::removeLastOrNull,
transitionSpec = {
ContentTransform(
fadeIn(motionScheme.defaultEffectsSpec()),
@@ -213,7 +222,7 @@ fun AppScreen(
)
}
entry<Screen.Settings> {
entry<Screen.Settings.Main> {
SettingsScreenRoot(
modifier = modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection),

View File

@@ -0,0 +1,62 @@
/*
* 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.ui
import org.nsh07.pomodoro.R
val mainScreens = listOf(
NavItem(
Screen.Timer,
R.drawable.timer_outlined,
R.drawable.timer_filled,
R.string.timer
),
NavItem(
Screen.Stats,
R.drawable.monitoring,
R.drawable.monitoring_filled,
R.string.stats
),
NavItem(
Screen.Settings.Main,
R.drawable.settings,
R.drawable.settings_filled,
R.string.settings
)
)
val settingsScreens = listOf(
SettingsNavItem(
Screen.Settings.Timer,
R.drawable.timer_filled,
R.string.timer,
listOf(R.string.durations, R.string.session_length, R.string.always_on_display)
),
SettingsNavItem(
Screen.Settings.Alarm,
R.drawable.alarm,
R.string.alarm,
listOf(R.string.alarm_sound, R.string.alarm, R.string.vibrate)
),
SettingsNavItem(
Screen.Settings.Appearance,
R.drawable.palette,
R.string.appearance,
listOf(R.string.color_scheme, R.string.theme, R.string.black_theme)
)
)

View File

@@ -1,3 +1,20 @@
/*
* 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.ui
import androidx.annotation.DrawableRes
@@ -13,7 +30,19 @@ sealed class Screen : NavKey {
object AOD : Screen()
@Serializable
object Settings : Screen()
sealed class Settings : Screen() {
@Serializable
object Main : Settings()
@Serializable
object Alarm : Settings()
@Serializable
object Appearance : Settings()
@Serializable
object Timer : Settings()
}
@Serializable
object Stats : Screen()
@@ -24,4 +53,11 @@ data class NavItem(
@param:DrawableRes val unselectedIcon: Int,
@param:DrawableRes val selectedIcon: Int,
@param:StringRes val label: Int
)
)
data class SettingsNavItem(
val route: Screen.Settings,
@param:DrawableRes val icon: Int,
@param:StringRes val label: Int,
val innerSettings: List<Int>
)

View File

@@ -1,25 +0,0 @@
package org.nsh07.pomodoro.ui.settingsScreen
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.insert
import androidx.core.text.isDigitsOnly
object MinutesInputTransformation : InputTransformation {
override fun TextFieldBuffer.transformInput() {
if (!this.asCharSequence().isDigitsOnly() || this.length > 2) {
revertAllChanges()
}
}
}
object MinutesOutputTransformation : OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
if (this.length == 0) {
insert(0, "00")
} else if (this.toString().toInt() < 10) {
insert(0, "0")
}
}
}

View File

@@ -1,97 +1,80 @@
/*
* 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.settingsScreen
import android.app.Activity
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderState
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
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
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.service.TimerService
import org.nsh07.pomodoro.ui.ClickableListItem
import org.nsh07.pomodoro.ui.Screen
import org.nsh07.pomodoro.ui.settingsScreen.components.AboutCard
import org.nsh07.pomodoro.ui.settingsScreen.screens.AlarmSettings
import org.nsh07.pomodoro.ui.settingsScreen.screens.AppearanceSettings
import org.nsh07.pomodoro.ui.settingsScreen.screens.TimerSettings
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.PreferencesState
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel
import org.nsh07.pomodoro.ui.settingsScreens
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors
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.topListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoTheme
import org.nsh07.pomodoro.utils.toColor
@OptIn(ExperimentalMaterial3Api::class)
@@ -102,6 +85,8 @@ fun SettingsScreenRoot(
) {
val context = LocalContext.current
val backStack = viewModel.backStack
DisposableEffect(Unit) {
viewModel.runTextFieldFlowCollection()
onDispose { viewModel.cancelTextFieldFlowCollection() }
@@ -134,6 +119,7 @@ fun SettingsScreenRoot(
SettingsScreen(
preferencesState = preferencesState,
backStack = backStack,
focusTimeInputFieldState = focusTimeInputFieldState,
shortBreakTimeInputFieldState = shortBreakTimeInputFieldState,
longBreakTimeInputFieldState = longBreakTimeInputFieldState,
@@ -162,6 +148,7 @@ fun SettingsScreenRoot(
@Composable
private fun SettingsScreen(
preferencesState: PreferencesState,
backStack: SnapshotStateList<Screen.Settings>,
focusTimeInputFieldState: TextFieldState,
shortBreakTimeInputFieldState: TextFieldState,
longBreakTimeInputFieldState: TextFieldState,
@@ -179,402 +166,118 @@ private fun SettingsScreen(
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val switchColors = SwitchDefaults.colors(
checkedIconColor = colorScheme.primary,
)
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
val themeMap: Map<String, Pair<Int, Int>> = remember {
mapOf(
"auto" to Pair(
R.drawable.brightness_auto,
R.string.system_default
),
"light" to Pair(R.drawable.light_mode, R.string.light),
"dark" to Pair(R.drawable.dark_mode, R.string.dark)
)
}
val reverseThemeMap: Map<String, String> = remember {
mapOf(
context.getString(R.string.system_default) to "auto",
context.getString(R.string.light) to "light",
context.getString(R.string.dark) to "dark"
)
}
var alarmName by remember { mutableStateOf("...") }
LaunchedEffect(alarmSound) {
withContext(Dispatchers.IO) {
alarmName =
RingtoneManager.getRingtone(context, alarmSound.toUri())?.getTitle(context) ?: ""
}
}
val ringtonePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val uri =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
result.data?.getParcelableExtra(
RingtoneManager.EXTRA_RINGTONE_PICKED_URI,
Uri::class.java
)
} else {
@Suppress("DEPRECATION")
result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
}
onAlarmSoundChanged(uri)
}
}
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {
putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_ALARM)
putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, stringResource(R.string.alarm_sound))
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, alarmSound.toUri())
}
val switchItems = remember(
preferencesState.blackTheme,
preferencesState.aodEnabled,
alarmEnabled,
vibrateEnabled
) {
listOf(
SettingsSwitchItem(
checked = preferencesState.blackTheme,
icon = R.drawable.contrast,
label = R.string.black_theme,
description = R.string.black_theme_desc,
onClick = onBlackThemeChange
),
SettingsSwitchItem(
checked = preferencesState.aodEnabled,
icon = R.drawable.aod,
label = R.string.always_on_display,
description = R.string.always_on_display_desc,
onClick = onAodEnabledChange
),
SettingsSwitchItem(
checked = alarmEnabled,
icon = R.drawable.alarm_on,
label = R.string.alarm,
description = R.string.alarm_desc,
onClick = onAlarmEnabledChange
),
SettingsSwitchItem(
checked = vibrateEnabled,
icon = R.drawable.mobile_vibrate,
label = R.string.vibrate,
description = R.string.vibrate_desc,
onClick = onVibrateEnabledChange
)
)
}
Column(modifier.nestedScroll(scrollBehavior.nestedScrollConnection)) {
TopAppBar(
title = {
Text(
stringResource(R.string.settings),
style = LocalTextStyle.current.copy(
fontFamily = robotoFlexTopBar,
fontSize = 32.sp,
lineHeight = 32.sp
)
)
},
subtitle = {},
colors = topBarColors,
titleHorizontalAlignment = Alignment.CenterHorizontally,
scrollBehavior = scrollBehavior
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.background(topBarColors.containerColor)
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
item {
Spacer(Modifier.height(12.dp))
}
item {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
stringResource(R.string.focus),
style = typography.titleSmallEmphasized
)
MinuteInputField(
state = focusTimeInputFieldState,
shape = RoundedCornerShape(
topStart = topListItemShape.topStart,
bottomStart = topListItemShape.topStart,
topEnd = topListItemShape.bottomStart,
bottomEnd = topListItemShape.bottomStart
),
imeAction = ImeAction.Next
)
}
Spacer(Modifier.width(2.dp))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
stringResource(R.string.short_break),
style = typography.titleSmallEmphasized
)
MinuteInputField(
state = shortBreakTimeInputFieldState,
shape = RoundedCornerShape(middleListItemShape.topStart),
imeAction = ImeAction.Next
)
}
Spacer(Modifier.width(2.dp))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
stringResource(R.string.long_break),
style = typography.titleSmallEmphasized
)
MinuteInputField(
state = longBreakTimeInputFieldState,
shape = RoundedCornerShape(
topStart = bottomListItemShape.topStart,
bottomStart = bottomListItemShape.topStart,
topEnd = bottomListItemShape.bottomStart,
bottomEnd = bottomListItemShape.bottomStart
),
imeAction = ImeAction.Done
)
}
}
}
item {
Spacer(Modifier.height(12.dp))
}
item {
ListItem(
leadingContent = {
Icon(
painterResource(R.drawable.clocks),
null
)
},
headlineContent = {
Text(stringResource(R.string.session_length))
},
supportingContent = {
Column {
NavDisplay(
backStack = backStack,
onBack = backStack::removeLastOrNull,
transitionSpec = {
(slideInHorizontally(initialOffsetX = { it }))
.togetherWith(slideOutHorizontally(targetOffsetX = { -it / 4 }) + fadeOut())
},
popTransitionSpec = {
(slideInHorizontally(initialOffsetX = { -it / 4 }) + fadeIn())
.togetherWith(slideOutHorizontally(targetOffsetX = { it }))
},
predictivePopTransitionSpec = {
(slideInHorizontally(initialOffsetX = { -it / 4 }) + fadeIn())
.togetherWith(slideOutHorizontally(targetOffsetX = { it }))
},
entryProvider = entryProvider {
entry<Screen.Settings.Main> {
Column(modifier.nestedScroll(scrollBehavior.nestedScrollConnection)) {
TopAppBar(
title = {
Text(
stringResource(
R.string.session_length_desc,
sessionsSliderState.value.toInt()
stringResource(R.string.settings),
style = LocalTextStyle.current.copy(
fontFamily = robotoFlexTopBar,
fontSize = 32.sp,
lineHeight = 32.sp
)
)
Slider(
state = sessionsSliderState,
modifier = Modifier.padding(vertical = 4.dp)
)
}
},
colors = listItemColors,
modifier = Modifier.clip(cardShape)
)
}
},
subtitle = {},
colors = topBarColors,
titleHorizontalAlignment = Alignment.CenterHorizontally,
scrollBehavior = scrollBehavior
)
item { Spacer(Modifier.height(12.dp)) }
item {
ColorSchemePickerListItem(
color = preferencesState.colorScheme.toColor(),
items = 3,
index = 0,
onColorChange = onColorSchemeChange
)
}
item {
ThemePickerListItem(
theme = preferencesState.theme,
themeMap = themeMap,
reverseThemeMap = reverseThemeMap,
onThemeChange = onThemeChange,
items = 3,
index = 1,
modifier = Modifier
.clip(middleListItemShape)
)
}
itemsIndexed(switchItems.take(2)) { index, item ->
ListItem(
leadingContent = {
Icon(painterResource(item.icon), contentDescription = null)
},
headlineContent = { Text(stringResource(item.label)) },
supportingContent = { Text(stringResource(item.description)) },
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
.padding(top = if (index != 0) 16.dp else 0.dp)
.clip(if (index == 0) bottomListItemShape else cardShape)
)
}
item { Spacer(Modifier.height(12.dp)) }
item {
ListItem(
leadingContent = {
Icon(painterResource(R.drawable.alarm), null)
},
headlineContent = { Text(stringResource(R.string.alarm_sound)) },
supportingContent = { Text(alarmName) },
colors = listItemColors,
modifier = Modifier
.clip(topListItemShape)
.clickable(onClick = { ringtonePickerLauncher.launch(intent) })
)
}
itemsIndexed(switchItems.drop(2)) { index, item ->
ListItem(
leadingContent = {
Icon(painterResource(item.icon), contentDescription = null)
},
headlineContent = { Text(stringResource(item.label)) },
supportingContent = { Text(stringResource(item.description)) },
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 (index) {
switchItems.lastIndex - 2 -> bottomListItemShape
else -> middleListItemShape
}
)
)
}
item {
var expanded by remember { mutableStateOf(false) }
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier
.padding(vertical = 6.dp)
.fillMaxWidth()
) {
FilledTonalIconToggleButton(
checked = expanded,
onCheckedChange = { expanded = it },
shapes = IconButtonDefaults.toggleableShapes(),
modifier = Modifier.width(52.dp)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.background(topBarColors.containerColor)
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
Icon(
painterResource(R.drawable.info),
null
)
}
AnimatedVisibility(expanded) {
Text(
stringResource(R.string.pomodoro_info),
style = typography.bodyMedium,
color = colorScheme.onSurfaceVariant,
modifier = Modifier.padding(8.dp)
)
item { Spacer(Modifier.height(12.dp)) }
item { AboutCard() }
item { Spacer(Modifier.height(12.dp)) }
itemsIndexed(settingsScreens) { index, item ->
ClickableListItem(
leadingContent = {
Icon(painterResource(item.icon), null)
},
headlineContent = { Text(stringResource(item.label)) },
supportingContent = {
Text(
remember {
item.innerSettings.joinToString(", ") {
context.getString(it)
}
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
trailingContent = {
Icon(painterResource(R.drawable.arrow_forward_big), null)
},
items = settingsScreens.size,
index = index
) { backStack.add(item.route) }
}
item { Spacer(Modifier.height(12.dp)) }
}
}
}
entry<Screen.Settings.Alarm> {
AlarmSettings(
preferencesState = preferencesState,
alarmEnabled = alarmEnabled,
vibrateEnabled = vibrateEnabled,
alarmSound = alarmSound,
onAlarmEnabledChange = onAlarmEnabledChange,
onVibrateEnabledChange = onVibrateEnabledChange,
onAlarmSoundChanged = onAlarmSoundChanged,
onBack = backStack::removeLastOrNull
)
}
entry<Screen.Settings.Appearance> {
AppearanceSettings(
preferencesState = preferencesState,
onBlackThemeChange = onBlackThemeChange,
onThemeChange = onThemeChange,
onColorSchemeChange = onColorSchemeChange,
onBack = backStack::removeLastOrNull
)
}
entry<Screen.Settings.Timer> {
TimerSettings(
aodEnabled = preferencesState.aodEnabled,
focusTimeInputFieldState = focusTimeInputFieldState,
shortBreakTimeInputFieldState = shortBreakTimeInputFieldState,
longBreakTimeInputFieldState = longBreakTimeInputFieldState,
sessionsSliderState = sessionsSliderState,
onAodEnabledChange = onAodEnabledChange,
onBack = backStack::removeLastOrNull
)
}
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(
showSystemUi = true,
device = Devices.PIXEL_9_PRO
)
@Composable
fun SettingsScreenPreview() {
TomatoTheme {
SettingsScreen(
preferencesState = PreferencesState(),
focusTimeInputFieldState = rememberTextFieldState((25).toString()),
shortBreakTimeInputFieldState = rememberTextFieldState((5).toString()),
longBreakTimeInputFieldState = rememberTextFieldState((15).toString()),
sessionsSliderState = rememberSliderState(value = 3f, steps = 3, valueRange = 1f..5f),
alarmEnabled = true,
vibrateEnabled = true,
alarmSound = "null",
onAlarmEnabledChange = {},
onVibrateEnabledChange = {},
onBlackThemeChange = {},
onAodEnabledChange = {},
onAlarmSoundChanged = {},
onThemeChange = {},
onColorSchemeChange = {},
modifier = Modifier.fillMaxSize()
)
}
}
data class SettingsSwitchItem(
val checked: Boolean,
@param:DrawableRes val icon: Int,
@param:StringRes val label: Int,
@param:StringRes val description: Int,
val onClick: (Boolean) -> Unit
)

View File

@@ -0,0 +1,29 @@
/*
* 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.ui.settingsScreen
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
data class SettingsSwitchItem(
val checked: Boolean,
@param:DrawableRes val icon: Int,
@param:StringRes val label: Int,
@param:StringRes val description: Int,
val onClick: (Boolean) -> Unit
)

View File

@@ -1,11 +1,21 @@
/*
* 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.settingsScreen
package org.nsh07.pomodoro.ui.settingsScreen.components
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
@@ -36,7 +46,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.nsh07.pomodoro.BuildConfig
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexHeadline
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
// Taken from https://github.com/shub39/Grit/blob/master/app/src/main/java/com/shub39/grit/core/presentation/settings/ui/component/AboutApp.kt
@@ -71,10 +80,7 @@ fun AboutCard(modifier: Modifier = Modifier) {
style = MaterialTheme.typography.titleLarge,
fontFamily = robotoFlexTopBar
)
Text(
text = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
fontFamily = robotoFlexHeadline
)
Text(text = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
}
Spacer(modifier = Modifier.weight(1f))

View File

@@ -1,4 +1,21 @@
package org.nsh07.pomodoro.ui.settingsScreen
/*
* 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.ui.settingsScreen.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Column

View File

@@ -1,11 +1,21 @@
/*
* 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.settingsScreen
package org.nsh07.pomodoro.ui.settingsScreen.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Icon

View File

@@ -1,4 +1,21 @@
package org.nsh07.pomodoro.ui.settingsScreen
/*
* 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.ui.settingsScreen.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background

View File

@@ -0,0 +1,42 @@
/*
* 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.ui.settingsScreen.components
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.insert
import androidx.core.text.isDigitsOnly
object MinutesInputTransformation : InputTransformation {
override fun TextFieldBuffer.transformInput() {
if (!this.asCharSequence().isDigitsOnly() || this.length > 2) {
revertAllChanges()
}
}
}
object MinutesOutputTransformation : OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
if (this.length == 0) {
insert(0, "00")
} else if (this.toString().toInt() < 10) {
insert(0, "0")
}
}
}

View File

@@ -1,11 +1,21 @@
/*
* 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.settingsScreen
package org.nsh07.pomodoro.ui.settingsScreen.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Arrangement
@@ -85,7 +95,7 @@ fun ThemeDialog(
) {
themeMap.entries.forEachIndexed { index: Int, pair: Map.Entry<String, Pair<Int, Int>> ->
val text = pair.value.second
val selected = text == selectedOption.value
val selected = text == selectedOption.intValue
ListItem(
leadingContent = {
@@ -113,9 +123,9 @@ fun ThemeDialog(
}
)
.selectable(
selected = (text == selectedOption.value),
selected = (text == selectedOption.intValue),
onClick = {
selectedOption.value = text
selectedOption.intValue = text
onThemeChange(
reverseThemeMap[context.getString(
selectedOption.intValue

View File

@@ -1,11 +1,21 @@
/*
* 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.settingsScreen
package org.nsh07.pomodoro.ui.settingsScreen.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Icon

View File

@@ -0,0 +1,254 @@
/*
* 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.ui.settingsScreen.screens
import android.app.Activity
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeFlexibleTopAppBar
import androidx.compose.material3.ListItem
import androidx.compose.material3.Switch
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
import androidx.compose.runtime.setValue
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.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
import org.nsh07.pomodoro.ui.settingsScreen.SettingsSwitchItem
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.PreferencesState
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
import org.nsh07.pomodoro.ui.theme.CustomColors.switchColors
import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.bottomListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AlarmSettings(
preferencesState: PreferencesState,
alarmEnabled: Boolean,
vibrateEnabled: Boolean,
alarmSound: String,
onAlarmEnabledChange: (Boolean) -> Unit,
onVibrateEnabledChange: (Boolean) -> Unit,
onAlarmSoundChanged: (Uri?) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier
) {
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
val context = LocalContext.current
var alarmName by remember { mutableStateOf("...") }
LaunchedEffect(alarmSound) {
withContext(Dispatchers.IO) {
alarmName =
RingtoneManager.getRingtone(context, alarmSound.toUri())?.getTitle(context) ?: ""
}
}
val ringtonePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val uri =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
result.data?.getParcelableExtra(
RingtoneManager.EXTRA_RINGTONE_PICKED_URI,
Uri::class.java
)
} else {
@Suppress("DEPRECATION")
result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
}
onAlarmSoundChanged(uri)
}
}
val ringtonePickerIntent = remember(alarmSound) {
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, alarmSound.toUri())
}
}
val switchItems = remember(
preferencesState.blackTheme,
preferencesState.aodEnabled,
alarmEnabled,
vibrateEnabled
) {
listOf(
SettingsSwitchItem(
checked = alarmEnabled,
icon = R.drawable.alarm_on,
label = R.string.alarm,
description = R.string.alarm_desc,
onClick = onAlarmEnabledChange
),
SettingsSwitchItem(
checked = vibrateEnabled,
icon = R.drawable.mobile_vibrate,
label = R.string.vibrate,
description = R.string.vibrate_desc,
onClick = onVibrateEnabledChange
)
)
}
Column(modifier.nestedScroll(scrollBehavior.nestedScrollConnection)) {
LargeFlexibleTopAppBar(
title = {
Text(stringResource(R.string.alarm), fontFamily = robotoFlexTopBar)
},
subtitle = {
Text(stringResource(R.string.settings))
},
navigationIcon = {
IconButton(onBack) {
Icon(
painterResource(R.drawable.arrow_back),
null
)
}
},
colors = topBarColors,
scrollBehavior = scrollBehavior
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.background(topBarColors.containerColor)
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
item {
Spacer(Modifier.height(14.dp))
}
item {
ListItem(
leadingContent = {
Icon(painterResource(R.drawable.alarm), null)
},
headlineContent = { Text(stringResource(R.string.alarm_sound)) },
supportingContent = { Text(alarmName) },
colors = listItemColors,
modifier = Modifier
.clip(topListItemShape)
.clickable(onClick = { ringtonePickerLauncher.launch(ringtonePickerIntent) })
)
}
itemsIndexed(switchItems) { index, item ->
ListItem(
leadingContent = {
Icon(painterResource(item.icon), contentDescription = null)
},
headlineContent = { Text(stringResource(item.label)) },
supportingContent = { Text(stringResource(item.description)) },
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 (index) {
switchItems.lastIndex -> bottomListItemShape
else -> middleListItemShape
}
)
)
}
item { Spacer(Modifier.height(12.dp)) }
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Preview
@Composable
fun AlarmSettingsPreview() {
val preferencesState = PreferencesState()
AlarmSettings(
preferencesState = preferencesState,
alarmEnabled = true,
vibrateEnabled = false,
alarmSound = "",
onAlarmEnabledChange = {},
onVibrateEnabledChange = {},
onAlarmSoundChanged = {},
onBack = {})
}

View File

@@ -0,0 +1,197 @@
/*
* 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.ui.settingsScreen.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeFlexibleTopAppBar
import androidx.compose.material3.ListItem
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
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 org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.settingsScreen.SettingsSwitchItem
import org.nsh07.pomodoro.ui.settingsScreen.components.ColorSchemePickerListItem
import org.nsh07.pomodoro.ui.settingsScreen.components.ThemePickerListItem
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.PreferencesState
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
import org.nsh07.pomodoro.ui.theme.CustomColors.switchColors
import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.bottomListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape
import org.nsh07.pomodoro.utils.toColor
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AppearanceSettings(
preferencesState: PreferencesState,
onBlackThemeChange: (Boolean) -> Unit,
onThemeChange: (String) -> Unit,
onColorSchemeChange: (Color) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier
) {
val themeMap: Map<String, Pair<Int, Int>> = remember {
mapOf(
"auto" to Pair(
R.drawable.brightness_auto,
R.string.system_default
),
"light" to Pair(R.drawable.light_mode, R.string.light),
"dark" to Pair(R.drawable.dark_mode, R.string.dark)
)
}
val reverseThemeMap: Map<String, String> = mapOf(
stringResource(R.string.system_default) to "auto",
stringResource(R.string.light) to "light",
stringResource(R.string.dark) to "dark"
)
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
Column(modifier.nestedScroll(scrollBehavior.nestedScrollConnection)) {
LargeFlexibleTopAppBar(
title = {
Text(stringResource(R.string.appearance), fontFamily = robotoFlexTopBar)
},
subtitle = {
Text(stringResource(R.string.settings))
},
navigationIcon = {
IconButton(onBack) {
Icon(
painterResource(R.drawable.arrow_back),
null
)
}
},
colors = topBarColors,
scrollBehavior = scrollBehavior
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.background(topBarColors.containerColor)
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
item {
Spacer(Modifier.height(14.dp))
}
item {
ColorSchemePickerListItem(
color = preferencesState.colorScheme.toColor(),
items = 3,
index = 0,
onColorChange = onColorSchemeChange
)
}
item {
ThemePickerListItem(
theme = preferencesState.theme,
themeMap = themeMap,
reverseThemeMap = reverseThemeMap,
onThemeChange = onThemeChange,
items = 3,
index = 1,
modifier = Modifier
.clip(middleListItemShape)
)
}
item {
val item = SettingsSwitchItem(
checked = preferencesState.blackTheme,
icon = R.drawable.contrast,
label = R.string.black_theme,
description = R.string.black_theme_desc,
onClick = onBlackThemeChange
)
ListItem(
leadingContent = {
Icon(painterResource(item.icon), contentDescription = null)
},
headlineContent = { Text(stringResource(item.label)) },
supportingContent = { Text(stringResource(item.description)) },
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(bottomListItemShape)
)
}
item { Spacer(Modifier.height(12.dp)) }
}
}
}
@Preview
@Composable
fun AppearanceSettingsPreview() {
val preferencesState = PreferencesState()
AppearanceSettings(
preferencesState = preferencesState,
onBlackThemeChange = {},
onThemeChange = {},
onColorSchemeChange = {},
onBack = {}
)
}

View File

@@ -0,0 +1,317 @@
/*
* 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.ui.settingsScreen.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LargeFlexibleTopAppBar
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderState
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.settingsScreen.SettingsSwitchItem
import org.nsh07.pomodoro.ui.settingsScreen.components.MinuteInputField
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
import org.nsh07.pomodoro.ui.theme.CustomColors.switchColors
import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors
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.topListItemShape
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun TimerSettings(
aodEnabled: Boolean,
focusTimeInputFieldState: TextFieldState,
shortBreakTimeInputFieldState: TextFieldState,
longBreakTimeInputFieldState: TextFieldState,
sessionsSliderState: SliderState,
onAodEnabledChange: (Boolean) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier
) {
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
Column(modifier.nestedScroll(scrollBehavior.nestedScrollConnection)) {
LargeFlexibleTopAppBar(
title = {
Text(stringResource(R.string.timer), fontFamily = robotoFlexTopBar)
},
subtitle = {
Text(stringResource(R.string.settings))
},
navigationIcon = {
IconButton(onBack) {
Icon(
painterResource(R.drawable.arrow_back),
null
)
}
},
colors = topBarColors,
scrollBehavior = scrollBehavior
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.background(topBarColors.containerColor)
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
item {
Spacer(Modifier.height(14.dp))
}
item {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
stringResource(R.string.focus),
style = typography.titleSmallEmphasized
)
MinuteInputField(
state = focusTimeInputFieldState,
shape = RoundedCornerShape(
topStart = topListItemShape.topStart,
bottomStart = topListItemShape.topStart,
topEnd = topListItemShape.bottomStart,
bottomEnd = topListItemShape.bottomStart
),
imeAction = ImeAction.Next
)
}
Spacer(Modifier.width(2.dp))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
stringResource(R.string.short_break),
style = typography.titleSmallEmphasized
)
MinuteInputField(
state = shortBreakTimeInputFieldState,
shape = RoundedCornerShape(middleListItemShape.topStart),
imeAction = ImeAction.Next
)
}
Spacer(Modifier.width(2.dp))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
stringResource(R.string.long_break),
style = typography.titleSmallEmphasized
)
MinuteInputField(
state = longBreakTimeInputFieldState,
shape = RoundedCornerShape(
topStart = bottomListItemShape.topStart,
bottomStart = bottomListItemShape.topStart,
topEnd = bottomListItemShape.bottomStart,
bottomEnd = bottomListItemShape.bottomStart
),
imeAction = ImeAction.Done
)
}
}
}
item {
Spacer(Modifier.height(12.dp))
}
item {
ListItem(
leadingContent = {
Icon(painterResource(R.drawable.clocks), null)
},
headlineContent = {
Text(stringResource(R.string.session_length))
},
supportingContent = {
Column {
Text(
stringResource(
R.string.session_length_desc,
sessionsSliderState.value.toInt()
)
)
Slider(
state = sessionsSliderState,
modifier = Modifier.padding(vertical = 4.dp)
)
}
},
colors = listItemColors,
modifier = Modifier.clip(cardShape)
)
}
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
)
ListItem(
leadingContent = {
Icon(
painterResource(item.icon),
contentDescription = null,
modifier = Modifier.padding(top = 4.dp)
)
},
headlineContent = { Text(stringResource(item.label)) },
supportingContent = { Text(stringResource(item.description)) },
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(cardShape)
)
}
item {
var expanded by remember { mutableStateOf(false) }
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier
.padding(vertical = 6.dp)
.fillMaxWidth()
) {
FilledTonalIconToggleButton(
checked = expanded,
onCheckedChange = { expanded = it },
shapes = IconButtonDefaults.toggleableShapes(),
modifier = Modifier.width(52.dp)
) {
Icon(
painterResource(R.drawable.info),
null
)
}
AnimatedVisibility(expanded) {
Text(
stringResource(R.string.pomodoro_info),
style = typography.bodyMedium,
color = colorScheme.onSurfaceVariant,
modifier = Modifier.padding(8.dp)
)
}
}
}
item { Spacer(Modifier.height(12.dp)) }
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
private fun TimerSettingsPreview() {
val focusTimeInputFieldState = TextFieldState("25")
val shortBreakTimeInputFieldState = TextFieldState("5")
val longBreakTimeInputFieldState = TextFieldState("15")
val sessionsSliderState = SliderState(
value = 4f,
valueRange = 1f..8f,
steps = 6
)
TimerSettings(
focusTimeInputFieldState = focusTimeInputFieldState,
shortBreakTimeInputFieldState = shortBreakTimeInputFieldState,
longBreakTimeInputFieldState = longBreakTimeInputFieldState,
sessionsSliderState = sessionsSliderState,
aodEnabled = true,
onBack = {},
onAodEnabledChange = {}
)
}

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.settingsScreen.viewModel
@@ -11,6 +21,7 @@ import android.net.Uri
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SliderState
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
@@ -31,12 +42,15 @@ import kotlinx.coroutines.launch
import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.data.AppPreferenceRepository
import org.nsh07.pomodoro.data.TimerRepository
import org.nsh07.pomodoro.ui.Screen
@OptIn(FlowPreview::class, ExperimentalMaterial3Api::class)
class SettingsViewModel(
private val preferenceRepository: AppPreferenceRepository,
private val timerRepository: TimerRepository,
) : ViewModel() {
val backStack = mutableStateListOf<Screen.Settings>(Screen.Settings.Main)
private val _preferencesState = MutableStateFlow(PreferencesState())
val preferencesState = _preferencesState.asStateFlow()

View File

@@ -1,9 +1,28 @@
/*
* 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.ui.theme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.SwitchColors
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
@@ -41,4 +60,9 @@ object CustomColors {
supportingColor = colorScheme.onSecondaryFixedVariant,
trailingIconColor = colorScheme.onSecondaryFixedVariant
)
val switchColors: SwitchColors
@Composable get() = SwitchDefaults.colors(
checkedIconColor = colorScheme.primary,
)
}

View File

@@ -0,0 +1,21 @@
<!--
~ 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 Foobar. 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="m313,520 l196,196q12,12 11.5,28T508,772q-12,11 -28,11.5T452,772L188,508q-6,-6 -8.5,-13t-2.5,-15q0,-8 2.5,-15t8.5,-13l264,-264q11,-11 27.5,-11t28.5,11q12,12 12,28.5T508,245L313,440h447q17,0 28.5,11.5T800,480q0,17 -11.5,28.5T760,520L313,520Z" />
</vector>

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="m321,880 l-71,-71 329,-329 -329,-329 71,-71 400,400L321,880Z" />
</vector>

View File

@@ -1,3 +1,20 @@
<!--
~ 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/>.
-->
<resources>
<string name="alarm">Alarm</string>
<string name="alarm_desc">Ring alarm when a timer completes</string>
@@ -59,4 +76,6 @@
<string name="vibrate">Vibrate</string>
<string name="vibrate_desc">Vibrate when a timer completes</string>
<string name="weekly_productivity_analysis">Weekly productivity analysis</string>
<string name="appearance">Appearance</string>
<string name="durations">Durations</string>
</resources>