Merge branch 'dev'

This commit is contained in:
Nishant Mishra
2025-09-27 19:06:43 +05:30
29 changed files with 1069 additions and 236 deletions

View File

@@ -33,8 +33,8 @@ android {
applicationId = "org.nsh07.pomodoro"
minSdk = 26
targetSdk = 36
versionCode = 7
versionName = "1.3.0"
versionCode = 8
versionName = "1.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -86,6 +86,7 @@ dependencies {
implementation(libs.androidx.navigation3.ui)
implementation(libs.vico.compose.m3)
implementation(libs.material.kolor)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)

View File

@@ -5,19 +5,23 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme.colorScheme
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.statsScreen.viewModel.StatsViewModel
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel
import org.nsh07.pomodoro.ui.theme.TomatoTheme
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel
import org.nsh07.pomodoro.utils.toColor
class MainActivity : ComponentActivity() {
private val timerViewModel: TimerViewModel by viewModels(factoryProducer = { TimerViewModel.Factory })
private val statsViewModel: StatsViewModel by viewModels(factoryProducer = { StatsViewModel.Factory })
private val settingsViewModel: SettingsViewModel by viewModels(factoryProducer = { SettingsViewModel.Factory })
private val appContainer by lazy {
(application as TomatoApplication).container
@@ -27,14 +31,27 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
TomatoTheme {
val preferencesState by settingsViewModel.preferencesState.collectAsStateWithLifecycle()
val darkTheme = when (preferencesState.theme) {
"dark" -> true
"light" -> false
else -> isSystemInDarkTheme()
}
val seed = preferencesState.colorScheme.toColor()
TomatoTheme(
darkTheme = darkTheme,
seedColor = seed,
blackTheme = preferencesState.blackTheme
) {
val colorScheme = colorScheme
LaunchedEffect(colorScheme) {
appContainer.appTimerRepository.colorScheme = colorScheme
}
timerViewModel.setCompositionLocals(colorScheme)
AppScreen(timerViewModel = timerViewModel, statsViewModel = statsViewModel)
AppScreen(timerViewModel = timerViewModel)
}
}
}

View File

@@ -42,10 +42,10 @@ interface StatDao {
@Query(
"SELECT " +
"AVG(focusTimeQ1) AS focusTimeQ1, " +
"AVG(focusTimeQ2) AS focusTimeQ2, " +
"AVG(focusTimeQ3) AS focusTimeQ3, " +
"AVG(focusTimeQ4) AS focusTimeQ4 " +
"AVG(NULLIF(focusTimeQ1,0)) AS focusTimeQ1, " +
"AVG(NULLIF(focusTimeQ2,0)) AS focusTimeQ2, " +
"AVG(NULLIF(focusTimeQ3,0)) AS focusTimeQ3, " +
"AVG(NULLIF(focusTimeQ4,0)) AS focusTimeQ4 " +
"FROM (SELECT focusTimeQ1, focusTimeQ2, focusTimeQ3, focusTimeQ4 FROM stat ORDER BY date DESC LIMIT :n)"
)
fun getLastNDaysAvgFocusTimes(n: Int): Flow<StatFocusTime?>

View File

@@ -31,6 +31,8 @@ interface TimerRepository {
var colorScheme: ColorScheme
var alarmSoundUri: Uri?
var serviceRunning: Boolean
}
/**
@@ -47,4 +49,5 @@ class AppTimerRepository : TimerRepository {
override var colorScheme = lightColorScheme()
override var alarmSoundUri: Uri? =
Settings.System.DEFAULT_ALARM_ALERT_URI ?: Settings.System.DEFAULT_RINGTONE_URI
override var serviceRunning = false
}

View File

@@ -34,7 +34,7 @@ fun NotificationCompat.Builder.addTimerActions(
)
.addAction(
R.drawable.restart,
"Reset",
"Exit",
PendingIntent.getService(
context,
0,

View File

@@ -69,15 +69,29 @@ class TimerService : Service() {
private val cs by lazy { timerRepository.colorScheme }
private lateinit var notificationStyle: NotificationCompat.ProgressStyle
override fun onBind(intent: Intent?): IBinder? {
return null
}
override fun onCreate() {
super.onCreate()
timerRepository.serviceRunning = true
alarm = MediaPlayer.create(this, timerRepository.alarmSoundUri)
}
override fun onDestroy() {
timerRepository.serviceRunning = false
runBlocking {
job.cancel()
saveTimeToDb()
notificationManager.cancel(1)
alarm?.release()
}
super.onDestroy()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
Actions.TOGGLE.toString() -> {
@@ -101,6 +115,8 @@ class TimerService : Service() {
}
private fun toggleTimer() {
updateProgressSegments()
if (timerState.value.timerRunning) {
notificationBuilder.clearActions().addTimerActions(
this, R.drawable.play, "Start"
@@ -194,42 +210,7 @@ class TimerService : Service() {
)
.setContentText("Up next: $nextTimer (${timerState.value.nextTimeStr})")
.setStyle(
NotificationCompat.ProgressStyle()
.also {
// Add all the Focus, Short break and long break intervals in order
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
// Android 16 and later supports live updates
// Set progress bar sections if on Baklava or later
for (i in 0..<timerRepository.sessionLength * 2) {
if (i % 2 == 0) it.addProgressSegment(
NotificationCompat.ProgressStyle.Segment(
timerRepository.focusTime.toInt()
)
.setColor(cs.primary.toArgb())
)
else if (i != (timerRepository.sessionLength * 2 - 1)) it.addProgressSegment(
NotificationCompat.ProgressStyle.Segment(
timerRepository.shortBreakTime.toInt()
).setColor(cs.tertiary.toArgb())
)
else it.addProgressSegment(
NotificationCompat.ProgressStyle.Segment(
timerRepository.longBreakTime.toInt()
).setColor(cs.tertiary.toArgb())
)
}
} else {
it.addProgressSegment(
NotificationCompat.ProgressStyle.Segment(
when (timerState.value.timerMode) {
TimerMode.FOCUS -> timerRepository.focusTime.toInt()
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt()
else -> timerRepository.longBreakTime.toInt()
}
)
)
}
}
notificationStyle
.setProgress( // Set the current progress by filling the previous intervals and part of the current interval
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
(totalTime - remainingTime) + ((cycles + 1) / 2) * timerRepository.focusTime.toInt() + (cycles / 2) * timerRepository.shortBreakTime.toInt()
@@ -249,6 +230,45 @@ class TimerService : Service() {
}
}
private fun updateProgressSegments() {
notificationStyle = NotificationCompat.ProgressStyle()
.also {
// Add all the Focus, Short break and long break intervals in order
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
// Android 16 and later supports live updates
// Set progress bar sections if on Baklava or later
for (i in 0..<timerRepository.sessionLength * 2) {
if (i % 2 == 0) it.addProgressSegment(
NotificationCompat.ProgressStyle.Segment(
timerRepository.focusTime.toInt()
)
.setColor(cs.primary.toArgb())
)
else if (i != (timerRepository.sessionLength * 2 - 1)) it.addProgressSegment(
NotificationCompat.ProgressStyle.Segment(
timerRepository.shortBreakTime.toInt()
).setColor(cs.tertiary.toArgb())
)
else it.addProgressSegment(
NotificationCompat.ProgressStyle.Segment(
timerRepository.longBreakTime.toInt()
).setColor(cs.tertiary.toArgb())
)
}
} else {
it.addProgressSegment(
NotificationCompat.ProgressStyle.Segment(
when (timerState.value.timerMode) {
TimerMode.FOCUS -> timerRepository.focusTime.toInt()
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt()
else -> timerRepository.longBreakTime.toInt()
}
)
)
}
}
}
private fun resetTimer() {
skipScope.launch {
saveTimeToDb()
@@ -275,6 +295,7 @@ class TimerService : Service() {
private fun skipTimer(fromButton: Boolean = false) {
skipScope.launch {
saveTimeToDb()
updateProgressSegments()
showTimerNotification(0, paused = true, complete = !fromButton)
startTime = 0L
pauseTime = 0L
@@ -380,16 +401,6 @@ class TimerService : Service() {
stopSelf()
}
override fun onDestroy() {
super.onDestroy()
runBlocking {
job.cancel()
saveTimeToDb()
notificationManager.cancel(1)
alarm?.release()
}
}
enum class Actions {
TOGGLE, SKIP, RESET, STOP_ALARM, UPDATE_ALARM_TONE
}

View File

@@ -37,7 +37,6 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.entry
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
@@ -46,7 +45,6 @@ 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
import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel
import org.nsh07.pomodoro.ui.timerScreen.AlarmDialog
import org.nsh07.pomodoro.ui.timerScreen.TimerScreen
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction
@@ -56,8 +54,7 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel
@Composable
fun AppScreen(
modifier: Modifier = Modifier,
timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory),
statsViewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)
timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory)
) {
val context = LocalContext.current
@@ -194,7 +191,6 @@ fun AppScreen(
entry<Screen.Stats> {
StatsScreenRoot(
contentPadding = contentPadding,
viewModel = statsViewModel,
modifier = modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection),

View File

@@ -0,0 +1,80 @@
package org.nsh07.pomodoro.ui
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme.motionScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ClickableListItem(
headlineContent: @Composable (() -> Unit),
modifier: Modifier = Modifier,
overlineContent: @Composable (() -> Unit)? = null,
supportingContent: @Composable (() -> Unit)? = null,
leadingContent: @Composable (() -> Unit)? = null,
trailingContent: @Composable (() -> Unit)? = null,
colors: ListItemColors = ListItemDefaults.colors(),
tonalElevation: Dp = ListItemDefaults.Elevation,
shadowElevation: Dp = ListItemDefaults.Elevation,
items: Int,
index: Int,
onClick: () -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val top by animateDpAsState(
if (isPressed) 40.dp
else {
if (items == 1 || index == 0) 20.dp
else 4.dp
},
motionScheme.fastSpatialSpec()
)
val bottom by animateDpAsState(
if (isPressed) 40.dp
else {
if (items == 1 || index == items - 1) 20.dp
else 4.dp
},
motionScheme.fastSpatialSpec()
)
ListItem(
headlineContent = headlineContent,
modifier = modifier
.clip(
RoundedCornerShape(
topStart = top,
topEnd = top,
bottomStart = bottom,
bottomEnd = bottom
)
)
.clickable(
onClick = onClick,
interactionSource = interactionSource,
),
overlineContent = overlineContent,
supportingContent = supportingContent,
leadingContent = leadingContent,
trailingContent = trailingContent,
colors = colors,
tonalElevation = tonalElevation,
shadowElevation = shadowElevation
)
}

View File

@@ -0,0 +1,157 @@
package org.nsh07.pomodoro.ui.settingsScreen
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.shapes
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.theme.TomatoTheme
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ColorPickerButton(
color: Color,
isSelected: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
IconButton(
shapes = IconButtonDefaults.shapes(),
colors = IconButtonDefaults.iconButtonColors(containerColor = color),
modifier = modifier.size(48.dp),
onClick = onClick
) {
AnimatedContent(isSelected) { isSelected ->
when (isSelected) {
true -> Icon(
painterResource(R.drawable.check),
tint = Color.Black,
contentDescription = null
)
else ->
if (color == Color.White) Icon(
painterResource(R.drawable.colors),
tint = Color.Black,
contentDescription = null
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ColorSchemePickerDialog(
currentColor: Color,
modifier: Modifier = Modifier,
setShowDialog: (Boolean) -> Unit,
onColorChange: (Color) -> Unit,
) {
val colorSchemes = listOf(
Color(0xfffeb4a7), Color(0xffffb3c0), Color(0xfffcaaff), Color(0xffb9c3ff),
Color(0xff62d3ff), Color(0xff44d9f1), Color(0xff52dbc9), Color(0xff78dd77),
Color(0xff9fd75c), Color(0xffc1d02d), Color(0xfffabd00), Color(0xffffb86e),
Color.White
)
BasicAlertDialog(
onDismissRequest = { setShowDialog(false) },
modifier = modifier
) {
Surface(
modifier = Modifier
.wrapContentWidth()
.wrapContentHeight(),
color = colorScheme.surfaceContainer,
shape = shapes.extraLarge,
tonalElevation = AlertDialogDefaults.TonalElevation
) {
Column(modifier = Modifier.padding(24.dp)) {
Text(
text = "Choose color scheme",
style = MaterialTheme.typography.headlineSmall
)
Spacer(Modifier.height(16.dp))
Column(Modifier.align(Alignment.CenterHorizontally)) {
(0..11 step 4).forEach {
Row {
colorSchemes.slice(it..it + 3).fastForEach { color ->
ColorPickerButton(
color,
color == currentColor,
modifier = Modifier.padding(4.dp)
) {
onColorChange(color)
}
}
}
}
ColorPickerButton(
colorSchemes.last(),
colorSchemes.last() == currentColor,
modifier = Modifier.padding(4.dp)
) {
onColorChange(colorSchemes.last())
}
}
Spacer(Modifier.height(24.dp))
TextButton(
shapes = ButtonDefaults.shapes(),
onClick = { setShowDialog(false) },
modifier = Modifier.align(Alignment.End)
) {
Text("Ok")
}
}
}
}
}
@Preview
@Composable
fun ColorPickerDialogPreview() {
var currentColor by remember { mutableStateOf(Color(0xfffeb4a7)) }
TomatoTheme(darkTheme = true) {
ColorSchemePickerDialog(
currentColor,
setShowDialog = {},
onColorChange = { currentColor = it }
)
}
}

View File

@@ -0,0 +1,64 @@
/*
* 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/>.
*/
package org.nsh07.pomodoro.ui.settingsScreen
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.ClickableListItem
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
@Composable
fun ColorSchemePickerListItem(
color: Color,
items: Int,
index: Int,
onColorChange: (Color) -> Unit,
modifier: Modifier = Modifier
) {
var showDialog by rememberSaveable { mutableStateOf(false) }
if (showDialog) {
ColorSchemePickerDialog(
currentColor = color,
setShowDialog = { showDialog = it },
onColorChange = onColorChange
)
}
ClickableListItem(
leadingContent = {
Icon(
painter = painterResource(R.drawable.palette),
contentDescription = null,
tint = colorScheme.primary
)
},
headlineContent = { Text("Color scheme") },
supportingContent = {
Text(
if (color == Color.White) "Dynamic"
else "Color"
)
},
colors = listItemColors,
items = items,
index = index,
modifier = modifier.fillMaxWidth()
) { showDialog = true }
}

View File

@@ -53,6 +53,8 @@ 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
@@ -61,6 +63,7 @@ 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.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
@@ -74,14 +77,17 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.service.TimerService
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.PreferencesState
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel
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)
@Composable
@@ -90,6 +96,12 @@ fun SettingsScreenRoot(
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory)
) {
val context = LocalContext.current
DisposableEffect(Unit) {
viewModel.runTextFieldFlowCollection()
onDispose { viewModel.cancelTextFieldFlowCollection() }
}
val focusTimeInputFieldState = rememberSaveable(saver = TextFieldState.Saver) {
viewModel.focusTimeTextFieldState
}
@@ -104,6 +116,8 @@ fun SettingsScreenRoot(
val vibrateEnabled by viewModel.vibrateEnabled.collectAsStateWithLifecycle(true)
val alarmSound by viewModel.alarmSound.collectAsStateWithLifecycle(viewModel.currentAlarmSound)
val preferencesState by viewModel.preferencesState.collectAsStateWithLifecycle()
val sessionsSliderState = rememberSaveable(
saver = SliderState.Saver(
viewModel.sessionsSliderState.onValueChangeFinished,
@@ -114,6 +128,7 @@ fun SettingsScreenRoot(
}
SettingsScreen(
preferencesState = preferencesState,
focusTimeInputFieldState = focusTimeInputFieldState,
shortBreakTimeInputFieldState = shortBreakTimeInputFieldState,
longBreakTimeInputFieldState = longBreakTimeInputFieldState,
@@ -123,6 +138,7 @@ fun SettingsScreenRoot(
alarmSound = alarmSound,
onAlarmEnabledChange = viewModel::saveAlarmEnabled,
onVibrateEnabledChange = viewModel::saveVibrateEnabled,
onBlackThemeChange = viewModel::saveBlackTheme,
onAlarmSoundChanged = {
viewModel.saveAlarmSound(it)
Intent(context, TimerService::class.java).apply {
@@ -130,6 +146,8 @@ fun SettingsScreenRoot(
context.startService(this)
}
},
onThemeChange = viewModel::saveTheme,
onColorSchemeChange = viewModel::saveColorScheme,
modifier = modifier
)
}
@@ -137,6 +155,7 @@ fun SettingsScreenRoot(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun SettingsScreen(
preferencesState: PreferencesState,
focusTimeInputFieldState: TextFieldState,
shortBreakTimeInputFieldState: TextFieldState,
longBreakTimeInputFieldState: TextFieldState,
@@ -146,7 +165,10 @@ private fun SettingsScreen(
alarmSound: String,
onAlarmEnabledChange: (Boolean) -> Unit,
onVibrateEnabledChange: (Boolean) -> Unit,
onBlackThemeChange: (Boolean) -> Unit,
onAlarmSoundChanged: (Uri?) -> Unit,
onThemeChange: (String) -> Unit,
onColorSchemeChange: (Color) -> Unit,
modifier: Modifier = Modifier
) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
@@ -154,7 +176,31 @@ private fun SettingsScreen(
checkedIconColor = colorScheme.primary,
)
val themeMap: Map<String, Pair<Int, String>> = remember {
mapOf(
"auto" to Pair(
R.drawable.brightness_auto,
"System default"
),
"light" to Pair(R.drawable.light_mode, "Light"),
"dark" to Pair(R.drawable.dark_mode, "Dark")
)
}
val reverseThemeMap: Map<String, String> = remember {
mapOf(
"System default" to "auto",
"Light" to "light",
"Dark" to "dark"
)
}
val context = LocalContext.current
var alarmName by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
alarmName = RingtoneManager.getRingtone(context, alarmSound.toUri())
?.getTitle(context) ?: ""
}
val ringtonePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
@@ -180,8 +226,15 @@ private fun SettingsScreen(
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, alarmSound.toUri())
}
val switchItems = remember(alarmEnabled, vibrateEnabled) {
val switchItems = remember(preferencesState.blackTheme, alarmEnabled, vibrateEnabled) {
listOf(
SettingsSwitchItem(
checked = preferencesState.blackTheme,
icon = R.drawable.contrast,
label = "Black theme",
description = "Use a pure black dark theme",
onClick = onBlackThemeChange
),
SettingsSwitchItem(
checked = alarmEnabled,
icon = R.drawable.alarm_on,
@@ -314,31 +367,83 @@ private fun SettingsScreen(
}
},
colors = listItemColors,
modifier = Modifier.clip(topListItemShape)
modifier = Modifier.clip(cardShape)
)
}
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)
)
}
item {
val item = switchItems[0]
ListItem(
leadingContent = {
Icon(painterResource(item.icon), contentDescription = null)
},
headlineContent = { Text(item.label) },
supportingContent = { Text(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)) }
item {
ListItem(
leadingContent = {
Icon(painterResource(R.drawable.alarm), null)
},
headlineContent = { Text("Alarm sound") },
supportingContent = {
Text(
remember(alarmSound) {
RingtoneManager.getRingtone(context, alarmSound.toUri())
.getTitle(context)
}
)
},
supportingContent = { Text(alarmName) },
colors = listItemColors,
modifier = Modifier
.clip(bottomListItemShape)
.clip(topListItemShape)
.clickable(onClick = { ringtonePickerLauncher.launch(intent) })
)
}
item { Spacer(Modifier.height(12.dp)) }
itemsIndexed(switchItems) { index, item ->
itemsIndexed(switchItems.drop(1)) { index, item ->
ListItem(
leadingContent = {
Icon(painterResource(item.icon), contentDescription = null)
@@ -371,8 +476,7 @@ private fun SettingsScreen(
modifier = Modifier
.clip(
when (index) {
0 -> topListItemShape
switchItems.lastIndex -> bottomListItemShape
switchItems.lastIndex - 1 -> bottomListItemShape
else -> middleListItemShape
}
)
@@ -422,16 +526,20 @@ private fun SettingsScreen(
fun SettingsScreenPreview() {
TomatoTheme {
SettingsScreen(
focusTimeInputFieldState = rememberTextFieldState((25 * 60 * 1000).toString()),
shortBreakTimeInputFieldState = rememberTextFieldState((5 * 60 * 1000).toString()),
longBreakTimeInputFieldState = rememberTextFieldState((15 * 60 * 1000).toString()),
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 = {},
onAlarmSoundChanged = {},
onThemeChange = {},
onColorSchemeChange = {},
modifier = Modifier.fillMaxSize()
)
}

View File

@@ -0,0 +1,130 @@
/*
* 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/>.
*/
package org.nsh07.pomodoro.ui.settingsScreen
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.shapes
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
import org.nsh07.pomodoro.ui.theme.CustomColors.selectedListItemColors
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 ThemeDialog(
themeMap: Map<String, Pair<Int, String>>,
reverseThemeMap: Map<String, String>,
theme: String,
setShowThemeDialog: (Boolean) -> Unit,
onThemeChange: (String) -> Unit
) {
val selectedOption =
remember { mutableStateOf(themeMap[theme]!!.second) }
BasicAlertDialog(
onDismissRequest = { setShowThemeDialog(false) }
) {
Surface(
modifier = Modifier
.wrapContentWidth()
.wrapContentHeight(),
shape = shapes.extraLarge,
color = colorScheme.surfaceContainer,
tonalElevation = AlertDialogDefaults.TonalElevation
) {
Column(modifier = Modifier.padding(24.dp)) {
Text(
text = "Choose theme",
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(16.dp))
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.selectableGroup()
) {
themeMap.entries.forEachIndexed { index: Int, pair: Map.Entry<String, Pair<Int, String>> ->
val text = pair.value.second
val selected = text == selectedOption.value
ListItem(
leadingContent = {
AnimatedContent(selected) {
if (it)
Icon(painterResource(R.drawable.check), null)
else
Icon(painterResource(pair.value.first), null)
}
},
headlineContent = {
Text(text = text, style = MaterialTheme.typography.bodyLarge)
},
colors = if (!selected) listItemColors else selectedListItemColors,
modifier = Modifier
.height(64.dp)
.clip(
when (index) {
0 -> topListItemShape
themeMap.size - 1 -> bottomListItemShape
else -> middleListItemShape
}
)
.selectable(
selected = (text == selectedOption.value),
onClick = {
selectedOption.value = text
onThemeChange(reverseThemeMap[selectedOption.value]!!)
},
role = Role.RadioButton
)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
TextButton(
shapes = ButtonDefaults.shapes(),
onClick = { setShowThemeDialog(false) },
modifier = Modifier.align(Alignment.End)
) {
Text("Ok")
}
}
}
}
}

View File

@@ -0,0 +1,61 @@
/*
* 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/>.
*/
package org.nsh07.pomodoro.ui.settingsScreen
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import org.nsh07.pomodoro.ui.ClickableListItem
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
@Composable
fun ThemePickerListItem(
theme: String,
themeMap: Map<String, Pair<Int, String>>,
reverseThemeMap: Map<String, String>,
items: Int,
index: Int,
onThemeChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
var showDialog by rememberSaveable { mutableStateOf(false) }
if (showDialog) {
ThemeDialog(
themeMap = themeMap,
reverseThemeMap = reverseThemeMap,
theme = theme,
setShowThemeDialog = { showDialog = it },
onThemeChange = onThemeChange
)
}
ClickableListItem(
leadingContent = {
Icon(
painter = painterResource(themeMap[theme]!!.first),
contentDescription = null
)
},
headlineContent = { Text("Theme") },
supportingContent = {
Text(themeMap[theme]!!.second)
},
colors = listItemColors,
items = items,
index = index,
modifier = modifier.fillMaxWidth()
) { showDialog = true }
}

View File

@@ -0,0 +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/>.
*/
package org.nsh07.pomodoro.ui.settingsScreen.viewModel
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
@Immutable
data class PreferencesState(
val theme: String = "auto",
val colorScheme: String = Color.White.toString(),
val blackTheme: Boolean = false
)

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SliderState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
@@ -20,8 +21,13 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.data.AppPreferenceRepository
@@ -32,12 +38,18 @@ class SettingsViewModel(
private val preferenceRepository: AppPreferenceRepository,
private val timerRepository: TimerRepository
) : ViewModel() {
val focusTimeTextFieldState =
private val _preferencesState = MutableStateFlow(PreferencesState())
val preferencesState = _preferencesState.asStateFlow()
val focusTimeTextFieldState by lazy {
TextFieldState((timerRepository.focusTime / 60000).toString())
val shortBreakTimeTextFieldState =
}
val shortBreakTimeTextFieldState by lazy {
TextFieldState((timerRepository.shortBreakTime / 60000).toString())
val longBreakTimeTextFieldState =
}
val longBreakTimeTextFieldState by lazy {
TextFieldState((timerRepository.longBreakTime / 60000).toString())
}
val sessionsSliderState = SliderState(
value = timerRepository.sessionLength.toFloat(),
@@ -48,6 +60,11 @@ class SettingsViewModel(
val currentAlarmSound = timerRepository.alarmSoundUri.toString()
private val flowCollectionJob = SupervisorJob()
private val focusFlowCollectionJob = Job(flowCollectionJob)
private val shortBreakFlowCollectionJob = Job(flowCollectionJob)
private val longBreakFlowCollectionJob = Job(flowCollectionJob)
val alarmSound =
preferenceRepository.getStringPreferenceFlow("alarm_sound").distinctUntilChanged()
val alarmEnabled =
@@ -56,41 +73,21 @@ class SettingsViewModel(
preferenceRepository.getBooleanPreferenceFlow("vibrate_enabled").distinctUntilChanged()
init {
viewModelScope.launch(Dispatchers.IO) {
snapshotFlow { focusTimeTextFieldState.text }
.debounce(500)
.collect {
if (it.isNotEmpty()) {
timerRepository.focusTime = preferenceRepository.saveIntPreference(
"focus_time",
it.toString().toInt() * 60 * 1000
).toLong()
}
}
}
viewModelScope.launch(Dispatchers.IO) {
snapshotFlow { shortBreakTimeTextFieldState.text }
.debounce(500)
.collect {
if (it.isNotEmpty()) {
timerRepository.shortBreakTime = preferenceRepository.saveIntPreference(
"short_break_time",
it.toString().toInt() * 60 * 1000
).toLong()
}
}
}
viewModelScope.launch(Dispatchers.IO) {
snapshotFlow { longBreakTimeTextFieldState.text }
.debounce(500)
.collect {
if (it.isNotEmpty()) {
timerRepository.longBreakTime = preferenceRepository.saveIntPreference(
"long_break_time",
it.toString().toInt() * 60 * 1000
).toLong()
}
}
viewModelScope.launch {
val theme = preferenceRepository.getStringPreference("theme")
?: preferenceRepository.saveStringPreference("theme", "auto")
val colorScheme = preferenceRepository.getStringPreference("color_scheme")
?: preferenceRepository.saveStringPreference("color_scheme", Color.White.toString())
val blackTheme = preferenceRepository.getBooleanPreference("black_theme")
?: preferenceRepository.saveBooleanPreference("black_theme", false)
_preferencesState.update { currentState ->
currentState.copy(
theme = theme,
colorScheme = colorScheme,
blackTheme = blackTheme
)
}
}
}
@@ -103,25 +100,96 @@ class SettingsViewModel(
}
}
fun runTextFieldFlowCollection() {
viewModelScope.launch(focusFlowCollectionJob + Dispatchers.IO) {
snapshotFlow { focusTimeTextFieldState.text }
.debounce(500)
.collect {
if (it.isNotEmpty()) {
timerRepository.focusTime = it.toString().toLong() * 60 * 1000
preferenceRepository.saveIntPreference(
"focus_time",
timerRepository.focusTime.toInt()
)
}
}
}
viewModelScope.launch(shortBreakFlowCollectionJob + Dispatchers.IO) {
snapshotFlow { shortBreakTimeTextFieldState.text }
.debounce(500)
.collect {
if (it.isNotEmpty()) {
timerRepository.shortBreakTime = it.toString().toLong() * 60 * 1000
preferenceRepository.saveIntPreference(
"short_break_time",
timerRepository.shortBreakTime.toInt()
)
}
}
}
viewModelScope.launch(longBreakFlowCollectionJob + Dispatchers.IO) {
snapshotFlow { longBreakTimeTextFieldState.text }
.debounce(500)
.collect {
if (it.isNotEmpty()) {
timerRepository.longBreakTime = it.toString().toLong() * 60 * 1000
preferenceRepository.saveIntPreference(
"long_break_time",
timerRepository.longBreakTime.toInt()
)
}
}
}
}
fun cancelTextFieldFlowCollection() = flowCollectionJob.cancel()
fun saveAlarmEnabled(enabled: Boolean) {
viewModelScope.launch {
preferenceRepository.saveBooleanPreference("alarm_enabled", enabled)
timerRepository.alarmEnabled = enabled
preferenceRepository.saveBooleanPreference("alarm_enabled", enabled)
}
}
fun saveVibrateEnabled(enabled: Boolean) {
viewModelScope.launch {
preferenceRepository.saveBooleanPreference("vibrate_enabled", enabled)
timerRepository.vibrateEnabled = enabled
preferenceRepository.saveBooleanPreference("vibrate_enabled", enabled)
}
}
fun saveAlarmSound(uri: Uri?) {
viewModelScope.launch {
timerRepository.alarmSoundUri = uri
preferenceRepository.saveStringPreference("alarm_sound", uri.toString())
}
timerRepository.alarmSoundUri = uri
}
fun saveColorScheme(colorScheme: Color) {
viewModelScope.launch {
_preferencesState.update { currentState ->
currentState.copy(colorScheme = colorScheme.toString())
}
preferenceRepository.saveStringPreference("color_scheme", colorScheme.toString())
}
}
fun saveTheme(theme: String) {
viewModelScope.launch {
_preferencesState.update { currentState ->
currentState.copy(theme = theme)
}
preferenceRepository.saveStringPreference("theme", theme)
}
}
fun saveBlackTheme(blackTheme: Boolean) {
viewModelScope.launch {
_preferencesState.update { currentState ->
currentState.copy(blackTheme = blackTheme)
}
preferenceRepository.saveBooleanPreference("black_theme", blackTheme)
}
}
companion object {

View File

@@ -18,15 +18,27 @@ val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
object CustomColors {
var black = false
@OptIn(ExperimentalMaterial3Api::class)
val topBarColors: TopAppBarColors
@Composable get() {
return TopAppBarDefaults.topAppBarColors(
containerColor = colorScheme.surfaceContainer,
scrolledContainerColor = colorScheme.surfaceContainer
@Composable get() =
TopAppBarDefaults.topAppBarColors(
containerColor = if (!black) colorScheme.surfaceContainer else colorScheme.surface,
scrolledContainerColor = if (!black) colorScheme.surfaceContainer else colorScheme.surface
)
}
val listItemColors: ListItemColors
@Composable get() = ListItemDefaults.colors(containerColor = colorScheme.surfaceBright)
@Composable get() =
ListItemDefaults.colors(containerColor = if (!black) colorScheme.surfaceBright else colorScheme.surfaceContainerHigh)
val selectedListItemColors: ListItemColors
@Composable get() =
ListItemDefaults.colors(
containerColor = colorScheme.secondaryContainer,
headlineColor = colorScheme.secondary,
leadingIconColor = colorScheme.onSecondaryContainer,
supportingColor = colorScheme.onSecondaryFixedVariant,
trailingIconColor = colorScheme.onSecondaryFixedVariant
)
}

View File

@@ -1,14 +1,23 @@
package org.nsh07.pomodoro.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import com.materialkolor.dynamiccolor.ColorSpec
import com.materialkolor.rememberDynamicColorScheme
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
@@ -32,11 +41,13 @@ private val LightColorScheme = lightColorScheme(
*/
)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun TomatoTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
seedColor: Color = Color.White,
dynamicColor: Boolean = true,
blackTheme: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
@@ -49,9 +60,33 @@ fun TomatoTheme(
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
CustomColors.black = blackTheme && darkTheme
val dynamicColorScheme = rememberDynamicColorScheme(
seedColor = when (seedColor) {
Color.White -> colorScheme.primary
else -> seedColor
},
isDark = darkTheme,
specVersion = if (blackTheme && darkTheme) ColorSpec.SpecVersion.SPEC_2021 else ColorSpec.SpecVersion.SPEC_2025,
isAmoled = blackTheme && darkTheme
)
val scheme =
if (seedColor == Color.White && !(blackTheme && darkTheme)) colorScheme
else dynamicColorScheme
MaterialExpressiveTheme(
colorScheme = scheme,
typography = Typography,
motionScheme = MotionScheme.expressive(),
content = content
)
}

View File

@@ -121,11 +121,11 @@ fun TimerScreen(
if (!timerState.showBrandTitle) timerState.timerMode else TimerMode.BRAND,
transitionSpec = {
slideInVertically(
animationSpec = motionScheme.slowSpatialSpec(),
animationSpec = motionScheme.defaultSpatialSpec(),
initialOffsetY = { (-it * 1.25).toInt() }
).togetherWith(
slideOutVertically(
animationSpec = motionScheme.slowSpatialSpec(),
animationSpec = motionScheme.defaultSpatialSpec(),
targetOffsetY = { (it * 1.25).toInt() }
)
)
@@ -159,7 +159,7 @@ fun TimerScreen(
)
TimerMode.SHORT_BREAK -> Text(
"Short Break",
"Short break",
style = TextStyle(
fontFamily = robotoFlexTopBar,
fontSize = 32.sp,
@@ -458,7 +458,7 @@ fun TimerScreen(
Text(
when (timerState.nextTimerMode) {
TimerMode.FOCUS -> "Focus"
TimerMode.SHORT_BREAK -> "Short Break"
TimerMode.SHORT_BREAK -> "Short break"
else -> "Long Break"
},
style = typography.titleMediumEmphasized

View File

@@ -9,7 +9,6 @@ package org.nsh07.pomodoro.ui.timerScreen.viewModel
import android.app.Application
import android.provider.Settings
import androidx.compose.material3.ColorScheme
import androidx.core.net.toUri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModelProvider
@@ -52,105 +51,84 @@ class TimerViewModel(
private var pauseTime = 0L
private var pauseDuration = 0L
private lateinit var cs: ColorScheme
init {
viewModelScope.launch(Dispatchers.IO) {
timerRepository.focusTime =
preferenceRepository.getIntPreference("focus_time")?.toLong()
?: preferenceRepository.saveIntPreference(
"focus_time",
timerRepository.focusTime.toInt()
).toLong()
timerRepository.shortBreakTime =
preferenceRepository.getIntPreference("short_break_time")?.toLong()
?: preferenceRepository.saveIntPreference(
"short_break_time",
timerRepository.shortBreakTime.toInt()
).toLong()
timerRepository.longBreakTime =
preferenceRepository.getIntPreference("long_break_time")?.toLong()
?: preferenceRepository.saveIntPreference(
"long_break_time",
timerRepository.longBreakTime.toInt()
).toLong()
timerRepository.sessionLength = preferenceRepository.getIntPreference("session_length")
?: preferenceRepository.saveIntPreference(
"session_length",
timerRepository.sessionLength
)
timerRepository.alarmEnabled =
preferenceRepository.getBooleanPreference("alarm_enabled")
?: preferenceRepository.saveBooleanPreference("alarm_enabled", true)
timerRepository.vibrateEnabled =
preferenceRepository.getBooleanPreference("vibrate_enabled")
?: preferenceRepository.saveBooleanPreference("vibrate_enabled", true)
timerRepository.alarmSoundUri = (
preferenceRepository.getStringPreference("alarm_sound")
?: preferenceRepository.saveStringPreference(
"alarm_sound",
(Settings.System.DEFAULT_ALARM_ALERT_URI
?: Settings.System.DEFAULT_RINGTONE_URI).toString()
if (!timerRepository.serviceRunning)
viewModelScope.launch(Dispatchers.IO) {
timerRepository.focusTime =
preferenceRepository.getIntPreference("focus_time")?.toLong()
?: preferenceRepository.saveIntPreference(
"focus_time",
timerRepository.focusTime.toInt()
).toLong()
timerRepository.shortBreakTime =
preferenceRepository.getIntPreference("short_break_time")?.toLong()
?: preferenceRepository.saveIntPreference(
"short_break_time",
timerRepository.shortBreakTime.toInt()
).toLong()
timerRepository.longBreakTime =
preferenceRepository.getIntPreference("long_break_time")?.toLong()
?: preferenceRepository.saveIntPreference(
"long_break_time",
timerRepository.longBreakTime.toInt()
).toLong()
timerRepository.sessionLength =
preferenceRepository.getIntPreference("session_length")
?: preferenceRepository.saveIntPreference(
"session_length",
timerRepository.sessionLength
)
).toUri()
resetTimer()
timerRepository.alarmEnabled =
preferenceRepository.getBooleanPreference("alarm_enabled")
?: preferenceRepository.saveBooleanPreference("alarm_enabled", true)
timerRepository.vibrateEnabled =
preferenceRepository.getBooleanPreference("vibrate_enabled")
?: preferenceRepository.saveBooleanPreference("vibrate_enabled", true)
var lastDate = statRepository.getLastDate()
val today = LocalDate.now()
timerRepository.alarmSoundUri = (
preferenceRepository.getStringPreference("alarm_sound")
?: preferenceRepository.saveStringPreference(
"alarm_sound",
(Settings.System.DEFAULT_ALARM_ALERT_URI
?: Settings.System.DEFAULT_RINGTONE_URI).toString()
)
).toUri()
// Fills dates between today and lastDate with 0s to ensure continuous history
if (lastDate != null)
while (ChronoUnit.DAYS.between(lastDate, today) > 0) {
lastDate = lastDate?.plusDays(1)
statRepository.insertStat(Stat(lastDate!!, 0, 0, 0, 0, 0))
_time.update { timerRepository.focusTime }
cycles = 0
startTime = 0L
pauseTime = 0L
pauseDuration = 0L
_timerState.update { currentState ->
currentState.copy(
timerMode = TimerMode.FOCUS,
timeStr = millisecondsToStr(time.value),
totalTime = time.value,
nextTimerMode = if (timerRepository.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK,
nextTimeStr = millisecondsToStr(if (timerRepository.sessionLength > 1) timerRepository.shortBreakTime else timerRepository.longBreakTime),
currentFocusCount = 1,
totalFocusCount = timerRepository.sessionLength
)
}
delay(1500)
var lastDate = statRepository.getLastDate()
val today = LocalDate.now()
_timerState.update { currentState ->
currentState.copy(showBrandTitle = false)
// Fills dates between today and lastDate with 0s to ensure continuous history
if (lastDate != null)
while (ChronoUnit.DAYS.between(lastDate, today) > 0) {
lastDate = lastDate?.plusDays(1)
statRepository.insertStat(Stat(lastDate!!, 0, 0, 0, 0, 0))
}
delay(1500)
_timerState.update { currentState ->
currentState.copy(showBrandTitle = false)
}
}
}
}
fun setCompositionLocals(colorScheme: ColorScheme) {
cs = colorScheme
}
private fun resetTimer() {
viewModelScope.launch {
saveTimeToDb()
_time.update { timerRepository.focusTime }
cycles = 0
startTime = 0L
pauseTime = 0L
pauseDuration = 0L
_timerState.update { currentState ->
currentState.copy(
timerMode = TimerMode.FOCUS,
timeStr = millisecondsToStr(time.value),
totalTime = time.value,
nextTimerMode = if (timerRepository.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK,
nextTimeStr = millisecondsToStr(if (timerRepository.sessionLength > 1) timerRepository.shortBreakTime else timerRepository.longBreakTime),
currentFocusCount = 1,
totalFocusCount = timerRepository.sessionLength
)
}
}
}
suspend fun saveTimeToDb() {
when (timerState.value.timerMode) {
TimerMode.FOCUS -> statRepository
.addFocusTime((timerState.value.totalTime - time.value).coerceAtLeast(0L))
else -> statRepository
.addBreakTime((timerState.value.totalTime - time.value).coerceAtLeast(0L))
}
}
companion object {

View File

@@ -7,6 +7,7 @@
package org.nsh07.pomodoro.utils
import androidx.compose.ui.graphics.Color
import java.util.Locale
import java.util.concurrent.TimeUnit
@@ -36,4 +37,26 @@ fun millisecondsToHoursMinutes(t: Long): String {
"%dh %dm", TimeUnit.MILLISECONDS.toHours(t),
TimeUnit.MILLISECONDS.toMinutes(t) % TimeUnit.HOURS.toMinutes(1)
)
}
}
/**
* Extension function for [String] to convert it to a [androidx.compose.ui.graphics.Color]
*
* The base string must be of the format produced by [androidx.compose.ui.graphics.Color.toString],
* i.e, the color black with 100% opacity in sRGB would be represented by:
*
* Color(0.0, 0.0, 0.0, 1.0, sRGB IEC61966-2.1)
*/
fun String.toColor(): Color {
// Sample string: Color(0.0, 0.0, 0.0, 1.0, sRGB IEC61966-2.1)
val comma1 = this.indexOf(',')
val comma2 = this.indexOf(',', comma1 + 1)
val comma3 = this.indexOf(',', comma2 + 1)
val comma4 = this.indexOf(',', comma3 + 1)
val r = this.substringAfter('(').substringBefore(',').toFloat()
val g = this.slice(comma1 + 1..comma2 - 1).toFloat()
val b = this.slice(comma2 + 1..comma3 - 1).toFloat()
val a = this.slice(comma3 + 1..comma4 - 1).toFloat()
return Color(r, g, b, a)
}

View File

@@ -0,0 +1,9 @@
<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="M312,640h64l16,-46q8,-21 31.5,-33.5T505,548q22,0 39.5,12.5T570,594l9,27q3,8 10.5,13.5T606,640q15,0 23.5,-12.5T633,601L519,299q-3,-9 -13.5,-14t-36.5,-5q-9,0 -17,5t-11,14L312,640ZM426,496 L478,346h4l52,150L426,496ZM346,800L240,800q-33,0 -56.5,-23.5T160,720v-106l-77,-78q-11,-12 -17,-26.5T60,480q0,-15 6,-29.5T83,424l77,-78v-106q0,-33 23.5,-56.5T240,160h106l78,-77q12,-11 26.5,-17t29.5,-6q15,0 29.5,6t26.5,17l78,77h106q33,0 56.5,23.5T800,240v106l77,78q11,12 17,26.5t6,29.5q0,15 -6,29.5T877,536l-77,78v106q0,33 -23.5,56.5T720,800L614,800l-78,77q-12,11 -26.5,17T480,900q-15,0 -29.5,-6T424,877l-78,-77Z" />
</vector>

View File

@@ -0,0 +1,16 @@
<!--
~ 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/>.
-->
<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="M346,820 L100,574q-10,-10 -15,-22t-5,-25q0,-13 5,-25t15,-22l230,-229 -75,-75q-13,-13 -13.5,-31t12.5,-32q13,-14 32,-14t33,14l367,367q10,10 14.5,22t4.5,25q0,13 -4.5,25T686,574L440,820q-10,10 -22,15t-25,5q-13,0 -25,-5t-22,-15ZM393,314L179,528h428L393,314ZM792,840q-36,0 -61,-25.5T706,752q0,-27 13.5,-51t30.5,-47l19,-24q9,-11 23.5,-11.5T816,629l20,25q16,23 30,47t14,51q0,37 -26,62.5T792,840Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<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="M480,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,880ZM520,797q119,-15 199.5,-104.5T800,480q0,-123 -80.5,-212.5T520,163v634Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<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="M480,840q-151,0 -255.5,-104.5T120,480q0,-138 90,-239.5T440,122q13,-2 23,3.5t16,14.5q6,9 6.5,21t-7.5,23q-17,26 -25.5,55t-8.5,61q0,90 63,153t153,63q31,0 61.5,-9t54.5,-25q11,-7 22.5,-6.5T819,481q10,5 15.5,15t3.5,24q-14,138 -117.5,229T480,840Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<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="M480,680q-83,0 -141.5,-58.5T280,480q0,-83 58.5,-141.5T480,280q83,0 141.5,58.5T680,480q0,83 -58.5,141.5T480,680ZM80,520q-17,0 -28.5,-11.5T40,480q0,-17 11.5,-28.5T80,440h80q17,0 28.5,11.5T200,480q0,17 -11.5,28.5T160,520L80,520ZM800,520q-17,0 -28.5,-11.5T760,480q0,-17 11.5,-28.5T800,440h80q17,0 28.5,11.5T920,480q0,17 -11.5,28.5T880,520h-80ZM480,200q-17,0 -28.5,-11.5T440,160v-80q0,-17 11.5,-28.5T480,40q17,0 28.5,11.5T520,80v80q0,17 -11.5,28.5T480,200ZM480,920q-17,0 -28.5,-11.5T440,880v-80q0,-17 11.5,-28.5T480,760q17,0 28.5,11.5T520,800v80q0,17 -11.5,28.5T480,920ZM226,282l-43,-42q-12,-11 -11.5,-28t11.5,-29q12,-12 29,-12t28,12l42,43q11,12 11,28t-11,28q-11,12 -27.5,11.5T226,282ZM720,777 L678,734q-11,-12 -11,-28.5t11,-27.5q11,-12 27.5,-11.5T734,678l43,42q12,11 11.5,28T777,777q-12,12 -29,12t-28,-12ZM678,282q-12,-11 -11.5,-27.5T678,226l42,-43q11,-12 28,-11.5t29,11.5q12,12 12,29t-12,28l-43,42q-12,11 -28,11t-28,-11ZM183,777q-12,-12 -12,-29t12,-28l43,-42q12,-11 28.5,-11t27.5,11q12,11 11.5,27.5T282,734l-42,43q-11,12 -28,11.5T183,777Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<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="M480,880q-82,0 -155,-31.5t-127.5,-86Q143,708 111.5,635T80,480q0,-83 32.5,-156t88,-127Q256,143 330,111.5T488,80q80,0 151,27.5t124.5,76q53.5,48.5 85,115T880,442q0,115 -70,176.5T640,680h-74q-9,0 -12.5,5t-3.5,11q0,12 15,34.5t15,51.5q0,50 -27.5,74T480,880ZM260,520q26,0 43,-17t17,-43q0,-26 -17,-43t-43,-17q-26,0 -43,17t-17,43q0,26 17,43t43,17ZM380,360q26,0 43,-17t17,-43q0,-26 -17,-43t-43,-17q-26,0 -43,17t-17,43q0,26 17,43t43,17ZM580,360q26,0 43,-17t17,-43q0,-26 -17,-43t-43,-17q-26,0 -43,17t-17,43q0,26 17,43t43,17ZM700,520q26,0 43,-17t17,-43q0,-26 -17,-43t-43,-17q-26,0 -43,17t-17,43q0,26 17,43t43,17Z" />
</vector>

View File

@@ -0,0 +1,8 @@
New features:
- You can now choose a custom theme and color scheme for the app's UI
- New pure black dark theme mode
Fixes:
- Average focus durations now do not include days with no activity
- Fix a critical bug that caused the app's timer state to reset to Focus whenever the app was closed from recents and then opened
- Replace the word "Reset" with "Exit" in the notification to make its purpose less ambiguous

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

After

Width:  |  Height:  |  Size: 386 KiB

View File

@@ -1,18 +1,19 @@
[versions]
activityCompose = "1.11.0"
adaptive = "1.1.0"
agp = "8.11.1"
composeBom = "2025.09.00"
agp = "8.11.2"
composeBom = "2025.09.01"
coreKtx = "1.17.0"
espressoCore = "3.7.0"
junit = "4.13.2"
junitVersion = "1.3.0"
kotlin = "2.2.20"
ksp = "2.2.20-2.0.3"
lifecycleRuntimeKtx = "2.9.3"
navigation3Runtime = "1.0.0-alpha09"
room = "2.8.0"
vico = "2.2.0-alpha.1"
lifecycleRuntimeKtx = "2.9.4"
materialKolor = "3.0.1"
navigation3 = "1.0.0-alpha10"
room = "2.8.1"
vico = "2.2.0"
[libraries]
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
@@ -25,8 +26,8 @@ androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecyc
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3Runtime" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3Runtime" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
@@ -37,6 +38,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" }
vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico" }
[plugins]