diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e9ffe26..1b4f644 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt b/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt index 94d8900..dfdf04c 100644 --- a/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt +++ b/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt @@ -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) } } } diff --git a/app/src/main/java/org/nsh07/pomodoro/data/StatDao.kt b/app/src/main/java/org/nsh07/pomodoro/data/StatDao.kt index 3dcba51..f1767f5 100644 --- a/app/src/main/java/org/nsh07/pomodoro/data/StatDao.kt +++ b/app/src/main/java/org/nsh07/pomodoro/data/StatDao.kt @@ -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 diff --git a/app/src/main/java/org/nsh07/pomodoro/data/TimerRepository.kt b/app/src/main/java/org/nsh07/pomodoro/data/TimerRepository.kt index e1e3068..a7a7b46 100644 --- a/app/src/main/java/org/nsh07/pomodoro/data/TimerRepository.kt +++ b/app/src/main/java/org/nsh07/pomodoro/data/TimerRepository.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/service/AddActions.kt b/app/src/main/java/org/nsh07/pomodoro/service/AddActions.kt index 181e4f9..b410a75 100644 --- a/app/src/main/java/org/nsh07/pomodoro/service/AddActions.kt +++ b/app/src/main/java/org/nsh07/pomodoro/service/AddActions.kt @@ -34,7 +34,7 @@ fun NotificationCompat.Builder.addTimerActions( ) .addAction( R.drawable.restart, - "Reset", + "Exit", PendingIntent.getService( context, 0, diff --git a/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt b/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt index a3c348c..2828a34 100644 --- a/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt +++ b/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt @@ -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.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.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 } diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt index 0a1e0d9..d3125e5 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt @@ -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 { StatsScreenRoot( contentPadding = contentPadding, - viewModel = statsViewModel, modifier = modifier.padding( start = contentPadding.calculateStartPadding(layoutDirection), end = contentPadding.calculateEndPadding(layoutDirection), diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/ClickableListItem.kt b/app/src/main/java/org/nsh07/pomodoro/ui/ClickableListItem.kt new file mode 100644 index 0000000..2d05a68 --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/ClickableListItem.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ColorSchemePickerDialog.kt b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ColorSchemePickerDialog.kt new file mode 100644 index 0000000..d963ee5 --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ColorSchemePickerDialog.kt @@ -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 } + ) + } +} diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ColorSchemePickerListItem.kt b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ColorSchemePickerListItem.kt new file mode 100644 index 0000000..bc09f9e --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ColorSchemePickerListItem.kt @@ -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 . + */ + +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 } +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt index 6420566..b558816 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt @@ -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> = 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 = 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() ) } diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ThemeDialog.kt b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ThemeDialog.kt new file mode 100644 index 0000000..32a131e --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ThemeDialog.kt @@ -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 . + */ + +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>, + reverseThemeMap: Map, + 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> -> + 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") + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ThemePickerListItem.kt b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ThemePickerListItem.kt new file mode 100644 index 0000000..9fda303 --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ThemePickerListItem.kt @@ -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 . + */ + +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>, + reverseThemeMap: Map, + 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 } +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/PreferencesState.kt b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/PreferencesState.kt new file mode 100644 index 0000000..0ae4849 --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/PreferencesState.kt @@ -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 . + */ + +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 +) diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt index dc2439d..fee0550 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt @@ -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 { diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/theme/Color.kt b/app/src/main/java/org/nsh07/pomodoro/ui/theme/Color.kt index bf8ee3a..738f989 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/theme/Color.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/theme/Color.kt @@ -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 + ) } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/theme/Theme.kt b/app/src/main/java/org/nsh07/pomodoro/ui/theme/Theme.kt index af90563..da61be6 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/theme/Theme.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/theme/Theme.kt @@ -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 ) } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt index daed55a..e373346 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt @@ -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 diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt index 71cb79f..e01a1d5 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt @@ -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 { diff --git a/app/src/main/java/org/nsh07/pomodoro/utils/Utils.kt b/app/src/main/java/org/nsh07/pomodoro/utils/Utils.kt index 2696c04..6406065 100644 --- a/app/src/main/java/org/nsh07/pomodoro/utils/Utils.kt +++ b/app/src/main/java/org/nsh07/pomodoro/utils/Utils.kt @@ -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) ) -} \ No newline at end of file +} + +/** + * 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) +} diff --git a/app/src/main/res/drawable/brightness_auto.xml b/app/src/main/res/drawable/brightness_auto.xml new file mode 100644 index 0000000..4cf4213 --- /dev/null +++ b/app/src/main/res/drawable/brightness_auto.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/colors.xml b/app/src/main/res/drawable/colors.xml new file mode 100644 index 0000000..7449d11 --- /dev/null +++ b/app/src/main/res/drawable/colors.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/contrast.xml b/app/src/main/res/drawable/contrast.xml new file mode 100644 index 0000000..e25b8a8 --- /dev/null +++ b/app/src/main/res/drawable/contrast.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/dark_mode.xml b/app/src/main/res/drawable/dark_mode.xml new file mode 100644 index 0000000..74b86ba --- /dev/null +++ b/app/src/main/res/drawable/dark_mode.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/light_mode.xml b/app/src/main/res/drawable/light_mode.xml new file mode 100644 index 0000000..78dd9d6 --- /dev/null +++ b/app/src/main/res/drawable/light_mode.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/palette.xml b/app/src/main/res/drawable/palette.xml new file mode 100644 index 0000000..22a62f8 --- /dev/null +++ b/app/src/main/res/drawable/palette.xml @@ -0,0 +1,9 @@ + + + diff --git a/fastlane/metadata/android/en-US/changelogs/8.txt b/fastlane/metadata/android/en-US/changelogs/8.txt new file mode 100644 index 0000000..db499db --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/8.txt @@ -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 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png index 77e28cc..dd24621 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3725628..ed4870e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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]