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

View File

@@ -5,19 +5,23 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.runtime.LaunchedEffect 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.AppScreen
import org.nsh07.pomodoro.ui.NavItem import org.nsh07.pomodoro.ui.NavItem
import org.nsh07.pomodoro.ui.Screen 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.theme.TomatoTheme
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel
import org.nsh07.pomodoro.utils.toColor
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val timerViewModel: TimerViewModel by viewModels(factoryProducer = { TimerViewModel.Factory }) 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 { private val appContainer by lazy {
(application as TomatoApplication).container (application as TomatoApplication).container
@@ -27,14 +31,27 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { 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 val colorScheme = colorScheme
LaunchedEffect(colorScheme) { LaunchedEffect(colorScheme) {
appContainer.appTimerRepository.colorScheme = colorScheme appContainer.appTimerRepository.colorScheme = colorScheme
} }
timerViewModel.setCompositionLocals(colorScheme) AppScreen(timerViewModel = timerViewModel)
AppScreen(timerViewModel = timerViewModel, statsViewModel = statsViewModel)
} }
} }
} }

View File

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

View File

@@ -31,6 +31,8 @@ interface TimerRepository {
var colorScheme: ColorScheme var colorScheme: ColorScheme
var alarmSoundUri: Uri? var alarmSoundUri: Uri?
var serviceRunning: Boolean
} }
/** /**
@@ -47,4 +49,5 @@ class AppTimerRepository : TimerRepository {
override var colorScheme = lightColorScheme() override var colorScheme = lightColorScheme()
override var alarmSoundUri: Uri? = override var alarmSoundUri: Uri? =
Settings.System.DEFAULT_ALARM_ALERT_URI ?: Settings.System.DEFAULT_RINGTONE_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( .addAction(
R.drawable.restart, R.drawable.restart,
"Reset", "Exit",
PendingIntent.getService( PendingIntent.getService(
context, context,
0, 0,

View File

@@ -69,15 +69,29 @@ class TimerService : Service() {
private val cs by lazy { timerRepository.colorScheme } private val cs by lazy { timerRepository.colorScheme }
private lateinit var notificationStyle: NotificationCompat.ProgressStyle
override fun onBind(intent: Intent?): IBinder? { override fun onBind(intent: Intent?): IBinder? {
return null return null
} }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
timerRepository.serviceRunning = true
alarm = MediaPlayer.create(this, timerRepository.alarmSoundUri) 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 { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) { when (intent?.action) {
Actions.TOGGLE.toString() -> { Actions.TOGGLE.toString() -> {
@@ -101,6 +115,8 @@ class TimerService : Service() {
} }
private fun toggleTimer() { private fun toggleTimer() {
updateProgressSegments()
if (timerState.value.timerRunning) { if (timerState.value.timerRunning) {
notificationBuilder.clearActions().addTimerActions( notificationBuilder.clearActions().addTimerActions(
this, R.drawable.play, "Start" this, R.drawable.play, "Start"
@@ -194,42 +210,7 @@ class TimerService : Service() {
) )
.setContentText("Up next: $nextTimer (${timerState.value.nextTimeStr})") .setContentText("Up next: $nextTimer (${timerState.value.nextTimeStr})")
.setStyle( .setStyle(
NotificationCompat.ProgressStyle() notificationStyle
.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()
}
)
)
}
}
.setProgress( // Set the current progress by filling the previous intervals and part of the current interval .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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
(totalTime - remainingTime) + ((cycles + 1) / 2) * timerRepository.focusTime.toInt() + (cycles / 2) * timerRepository.shortBreakTime.toInt() (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() { private fun resetTimer() {
skipScope.launch { skipScope.launch {
saveTimeToDb() saveTimeToDb()
@@ -275,6 +295,7 @@ class TimerService : Service() {
private fun skipTimer(fromButton: Boolean = false) { private fun skipTimer(fromButton: Boolean = false) {
skipScope.launch { skipScope.launch {
saveTimeToDb() saveTimeToDb()
updateProgressSegments()
showTimerNotification(0, paused = true, complete = !fromButton) showTimerNotification(0, paused = true, complete = !fromButton)
startTime = 0L startTime = 0L
pauseTime = 0L pauseTime = 0L
@@ -380,16 +401,6 @@ class TimerService : Service() {
stopSelf() stopSelf()
} }
override fun onDestroy() {
super.onDestroy()
runBlocking {
job.cancel()
saveTimeToDb()
notificationManager.cancel(1)
alarm?.release()
}
}
enum class Actions { enum class Actions {
TOGGLE, SKIP, RESET, STOP_ALARM, UPDATE_ALARM_TONE 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.compose.ui.res.painterResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.entry
import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay 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.service.TimerService
import org.nsh07.pomodoro.ui.settingsScreen.SettingsScreenRoot import org.nsh07.pomodoro.ui.settingsScreen.SettingsScreenRoot
import org.nsh07.pomodoro.ui.statsScreen.StatsScreenRoot 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.AlarmDialog
import org.nsh07.pomodoro.ui.timerScreen.TimerScreen import org.nsh07.pomodoro.ui.timerScreen.TimerScreen
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction
@@ -56,8 +54,7 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel
@Composable @Composable
fun AppScreen( fun AppScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory), timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory)
statsViewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -194,7 +191,6 @@ fun AppScreen(
entry<Screen.Stats> { entry<Screen.Stats> {
StatsScreenRoot( StatsScreenRoot(
contentPadding = contentPadding, contentPadding = contentPadding,
viewModel = statsViewModel,
modifier = modifier.padding( modifier = modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection), start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(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.TopAppBarDefaults
import androidx.compose.material3.rememberSliderState import androidx.compose.material3.rememberSliderState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -61,6 +63,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -74,14 +77,17 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import org.nsh07.pomodoro.R import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.service.TimerService 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.settingsScreen.viewModel.SettingsViewModel
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.bottomListItemShape import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.bottomListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.cardShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.theme.TomatoTheme
import org.nsh07.pomodoro.utils.toColor
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -90,6 +96,12 @@ fun SettingsScreenRoot(
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory) viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory)
) { ) {
val context = LocalContext.current val context = LocalContext.current
DisposableEffect(Unit) {
viewModel.runTextFieldFlowCollection()
onDispose { viewModel.cancelTextFieldFlowCollection() }
}
val focusTimeInputFieldState = rememberSaveable(saver = TextFieldState.Saver) { val focusTimeInputFieldState = rememberSaveable(saver = TextFieldState.Saver) {
viewModel.focusTimeTextFieldState viewModel.focusTimeTextFieldState
} }
@@ -104,6 +116,8 @@ fun SettingsScreenRoot(
val vibrateEnabled by viewModel.vibrateEnabled.collectAsStateWithLifecycle(true) val vibrateEnabled by viewModel.vibrateEnabled.collectAsStateWithLifecycle(true)
val alarmSound by viewModel.alarmSound.collectAsStateWithLifecycle(viewModel.currentAlarmSound) val alarmSound by viewModel.alarmSound.collectAsStateWithLifecycle(viewModel.currentAlarmSound)
val preferencesState by viewModel.preferencesState.collectAsStateWithLifecycle()
val sessionsSliderState = rememberSaveable( val sessionsSliderState = rememberSaveable(
saver = SliderState.Saver( saver = SliderState.Saver(
viewModel.sessionsSliderState.onValueChangeFinished, viewModel.sessionsSliderState.onValueChangeFinished,
@@ -114,6 +128,7 @@ fun SettingsScreenRoot(
} }
SettingsScreen( SettingsScreen(
preferencesState = preferencesState,
focusTimeInputFieldState = focusTimeInputFieldState, focusTimeInputFieldState = focusTimeInputFieldState,
shortBreakTimeInputFieldState = shortBreakTimeInputFieldState, shortBreakTimeInputFieldState = shortBreakTimeInputFieldState,
longBreakTimeInputFieldState = longBreakTimeInputFieldState, longBreakTimeInputFieldState = longBreakTimeInputFieldState,
@@ -123,6 +138,7 @@ fun SettingsScreenRoot(
alarmSound = alarmSound, alarmSound = alarmSound,
onAlarmEnabledChange = viewModel::saveAlarmEnabled, onAlarmEnabledChange = viewModel::saveAlarmEnabled,
onVibrateEnabledChange = viewModel::saveVibrateEnabled, onVibrateEnabledChange = viewModel::saveVibrateEnabled,
onBlackThemeChange = viewModel::saveBlackTheme,
onAlarmSoundChanged = { onAlarmSoundChanged = {
viewModel.saveAlarmSound(it) viewModel.saveAlarmSound(it)
Intent(context, TimerService::class.java).apply { Intent(context, TimerService::class.java).apply {
@@ -130,6 +146,8 @@ fun SettingsScreenRoot(
context.startService(this) context.startService(this)
} }
}, },
onThemeChange = viewModel::saveTheme,
onColorSchemeChange = viewModel::saveColorScheme,
modifier = modifier modifier = modifier
) )
} }
@@ -137,6 +155,7 @@ fun SettingsScreenRoot(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
private fun SettingsScreen( private fun SettingsScreen(
preferencesState: PreferencesState,
focusTimeInputFieldState: TextFieldState, focusTimeInputFieldState: TextFieldState,
shortBreakTimeInputFieldState: TextFieldState, shortBreakTimeInputFieldState: TextFieldState,
longBreakTimeInputFieldState: TextFieldState, longBreakTimeInputFieldState: TextFieldState,
@@ -146,7 +165,10 @@ private fun SettingsScreen(
alarmSound: String, alarmSound: String,
onAlarmEnabledChange: (Boolean) -> Unit, onAlarmEnabledChange: (Boolean) -> Unit,
onVibrateEnabledChange: (Boolean) -> Unit, onVibrateEnabledChange: (Boolean) -> Unit,
onBlackThemeChange: (Boolean) -> Unit,
onAlarmSoundChanged: (Uri?) -> Unit, onAlarmSoundChanged: (Uri?) -> Unit,
onThemeChange: (String) -> Unit,
onColorSchemeChange: (Color) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
@@ -154,7 +176,31 @@ private fun SettingsScreen(
checkedIconColor = colorScheme.primary, 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 val context = LocalContext.current
var alarmName by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
alarmName = RingtoneManager.getRingtone(context, alarmSound.toUri())
?.getTitle(context) ?: ""
}
val ringtonePickerLauncher = rememberLauncherForActivityResult( val ringtonePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult() contract = ActivityResultContracts.StartActivityForResult()
@@ -180,8 +226,15 @@ private fun SettingsScreen(
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, alarmSound.toUri()) putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, alarmSound.toUri())
} }
val switchItems = remember(alarmEnabled, vibrateEnabled) { val switchItems = remember(preferencesState.blackTheme, alarmEnabled, vibrateEnabled) {
listOf( listOf(
SettingsSwitchItem(
checked = preferencesState.blackTheme,
icon = R.drawable.contrast,
label = "Black theme",
description = "Use a pure black dark theme",
onClick = onBlackThemeChange
),
SettingsSwitchItem( SettingsSwitchItem(
checked = alarmEnabled, checked = alarmEnabled,
icon = R.drawable.alarm_on, icon = R.drawable.alarm_on,
@@ -314,31 +367,83 @@ private fun SettingsScreen(
} }
}, },
colors = listItemColors, 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 { item {
ListItem( ListItem(
leadingContent = { leadingContent = {
Icon(painterResource(R.drawable.alarm), null) Icon(painterResource(R.drawable.alarm), null)
}, },
headlineContent = { Text("Alarm sound") }, headlineContent = { Text("Alarm sound") },
supportingContent = { supportingContent = { Text(alarmName) },
Text(
remember(alarmSound) {
RingtoneManager.getRingtone(context, alarmSound.toUri())
.getTitle(context)
}
)
},
colors = listItemColors, colors = listItemColors,
modifier = Modifier modifier = Modifier
.clip(bottomListItemShape) .clip(topListItemShape)
.clickable(onClick = { ringtonePickerLauncher.launch(intent) }) .clickable(onClick = { ringtonePickerLauncher.launch(intent) })
) )
} }
item { Spacer(Modifier.height(12.dp)) } itemsIndexed(switchItems.drop(1)) { index, item ->
itemsIndexed(switchItems) { index, item ->
ListItem( ListItem(
leadingContent = { leadingContent = {
Icon(painterResource(item.icon), contentDescription = null) Icon(painterResource(item.icon), contentDescription = null)
@@ -371,8 +476,7 @@ private fun SettingsScreen(
modifier = Modifier modifier = Modifier
.clip( .clip(
when (index) { when (index) {
0 -> topListItemShape switchItems.lastIndex - 1 -> bottomListItemShape
switchItems.lastIndex -> bottomListItemShape
else -> middleListItemShape else -> middleListItemShape
} }
) )
@@ -422,16 +526,20 @@ private fun SettingsScreen(
fun SettingsScreenPreview() { fun SettingsScreenPreview() {
TomatoTheme { TomatoTheme {
SettingsScreen( SettingsScreen(
focusTimeInputFieldState = rememberTextFieldState((25 * 60 * 1000).toString()), preferencesState = PreferencesState(),
shortBreakTimeInputFieldState = rememberTextFieldState((5 * 60 * 1000).toString()), focusTimeInputFieldState = rememberTextFieldState((25).toString()),
longBreakTimeInputFieldState = rememberTextFieldState((15 * 60 * 1000).toString()), shortBreakTimeInputFieldState = rememberTextFieldState((5).toString()),
longBreakTimeInputFieldState = rememberTextFieldState((15).toString()),
sessionsSliderState = rememberSliderState(value = 3f, steps = 3, valueRange = 1f..5f), sessionsSliderState = rememberSliderState(value = 3f, steps = 3, valueRange = 1f..5f),
alarmEnabled = true, alarmEnabled = true,
vibrateEnabled = true, vibrateEnabled = true,
alarmSound = "null", alarmSound = "null",
onAlarmEnabledChange = {}, onAlarmEnabledChange = {},
onVibrateEnabledChange = {}, onVibrateEnabledChange = {},
onBlackThemeChange = {},
onAlarmSoundChanged = {}, onAlarmSoundChanged = {},
onThemeChange = {},
onColorSchemeChange = {},
modifier = Modifier.fillMaxSize() 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.ExperimentalMaterial3Api
import androidx.compose.material3.SliderState import androidx.compose.material3.SliderState
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
@@ -20,8 +21,13 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview 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.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.nsh07.pomodoro.TomatoApplication import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.data.AppPreferenceRepository import org.nsh07.pomodoro.data.AppPreferenceRepository
@@ -32,12 +38,18 @@ class SettingsViewModel(
private val preferenceRepository: AppPreferenceRepository, private val preferenceRepository: AppPreferenceRepository,
private val timerRepository: TimerRepository private val timerRepository: TimerRepository
) : ViewModel() { ) : ViewModel() {
val focusTimeTextFieldState = private val _preferencesState = MutableStateFlow(PreferencesState())
val preferencesState = _preferencesState.asStateFlow()
val focusTimeTextFieldState by lazy {
TextFieldState((timerRepository.focusTime / 60000).toString()) TextFieldState((timerRepository.focusTime / 60000).toString())
val shortBreakTimeTextFieldState = }
val shortBreakTimeTextFieldState by lazy {
TextFieldState((timerRepository.shortBreakTime / 60000).toString()) TextFieldState((timerRepository.shortBreakTime / 60000).toString())
val longBreakTimeTextFieldState = }
val longBreakTimeTextFieldState by lazy {
TextFieldState((timerRepository.longBreakTime / 60000).toString()) TextFieldState((timerRepository.longBreakTime / 60000).toString())
}
val sessionsSliderState = SliderState( val sessionsSliderState = SliderState(
value = timerRepository.sessionLength.toFloat(), value = timerRepository.sessionLength.toFloat(),
@@ -48,6 +60,11 @@ class SettingsViewModel(
val currentAlarmSound = timerRepository.alarmSoundUri.toString() 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 = val alarmSound =
preferenceRepository.getStringPreferenceFlow("alarm_sound").distinctUntilChanged() preferenceRepository.getStringPreferenceFlow("alarm_sound").distinctUntilChanged()
val alarmEnabled = val alarmEnabled =
@@ -56,41 +73,21 @@ class SettingsViewModel(
preferenceRepository.getBooleanPreferenceFlow("vibrate_enabled").distinctUntilChanged() preferenceRepository.getBooleanPreferenceFlow("vibrate_enabled").distinctUntilChanged()
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch {
snapshotFlow { focusTimeTextFieldState.text } val theme = preferenceRepository.getStringPreference("theme")
.debounce(500) ?: preferenceRepository.saveStringPreference("theme", "auto")
.collect { val colorScheme = preferenceRepository.getStringPreference("color_scheme")
if (it.isNotEmpty()) { ?: preferenceRepository.saveStringPreference("color_scheme", Color.White.toString())
timerRepository.focusTime = preferenceRepository.saveIntPreference( val blackTheme = preferenceRepository.getBooleanPreference("black_theme")
"focus_time", ?: preferenceRepository.saveBooleanPreference("black_theme", false)
it.toString().toInt() * 60 * 1000
).toLong() _preferencesState.update { currentState ->
} currentState.copy(
} theme = theme,
} colorScheme = colorScheme,
viewModelScope.launch(Dispatchers.IO) { blackTheme = blackTheme
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()
}
}
} }
} }
@@ -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) { fun saveAlarmEnabled(enabled: Boolean) {
viewModelScope.launch { viewModelScope.launch {
preferenceRepository.saveBooleanPreference("alarm_enabled", enabled)
timerRepository.alarmEnabled = enabled timerRepository.alarmEnabled = enabled
preferenceRepository.saveBooleanPreference("alarm_enabled", enabled)
} }
} }
fun saveVibrateEnabled(enabled: Boolean) { fun saveVibrateEnabled(enabled: Boolean) {
viewModelScope.launch { viewModelScope.launch {
preferenceRepository.saveBooleanPreference("vibrate_enabled", enabled)
timerRepository.vibrateEnabled = enabled timerRepository.vibrateEnabled = enabled
preferenceRepository.saveBooleanPreference("vibrate_enabled", enabled)
} }
} }
fun saveAlarmSound(uri: Uri?) { fun saveAlarmSound(uri: Uri?) {
viewModelScope.launch { viewModelScope.launch {
timerRepository.alarmSoundUri = uri
preferenceRepository.saveStringPreference("alarm_sound", uri.toString()) 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 { companion object {

View File

@@ -18,15 +18,27 @@ val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260) val Pink40 = Color(0xFF7D5260)
object CustomColors { object CustomColors {
var black = false
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
val topBarColors: TopAppBarColors val topBarColors: TopAppBarColors
@Composable get() { @Composable get() =
return TopAppBarDefaults.topAppBarColors( TopAppBarDefaults.topAppBarColors(
containerColor = colorScheme.surfaceContainer, containerColor = if (!black) colorScheme.surfaceContainer else colorScheme.surface,
scrolledContainerColor = colorScheme.surfaceContainer scrolledContainerColor = if (!black) colorScheme.surfaceContainer else colorScheme.surface
) )
}
val listItemColors: ListItemColors 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 package org.nsh07.pomodoro.ui.theme
import android.app.Activity
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme 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.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable 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.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( private val DarkColorScheme = darkColorScheme(
primary = Purple80, primary = Purple80,
@@ -32,11 +41,13 @@ private val LightColorScheme = lightColorScheme(
*/ */
) )
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun TomatoTheme( fun TomatoTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+ seedColor: Color = Color.White,
dynamicColor: Boolean = true, dynamicColor: Boolean = true,
blackTheme: Boolean = false,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = when { val colorScheme = when {
@@ -49,9 +60,33 @@ fun TomatoTheme(
else -> LightColorScheme else -> LightColorScheme
} }
MaterialTheme( val view = LocalView.current
colorScheme = colorScheme, 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, typography = Typography,
motionScheme = MotionScheme.expressive(),
content = content content = content
) )
} }

View File

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

View File

@@ -9,7 +9,6 @@ package org.nsh07.pomodoro.ui.timerScreen.viewModel
import android.app.Application import android.app.Application
import android.provider.Settings import android.provider.Settings
import androidx.compose.material3.ColorScheme
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
@@ -52,105 +51,84 @@ class TimerViewModel(
private var pauseTime = 0L private var pauseTime = 0L
private var pauseDuration = 0L private var pauseDuration = 0L
private lateinit var cs: ColorScheme
init { init {
viewModelScope.launch(Dispatchers.IO) { if (!timerRepository.serviceRunning)
timerRepository.focusTime = viewModelScope.launch(Dispatchers.IO) {
preferenceRepository.getIntPreference("focus_time")?.toLong() timerRepository.focusTime =
?: preferenceRepository.saveIntPreference( preferenceRepository.getIntPreference("focus_time")?.toLong()
"focus_time", ?: preferenceRepository.saveIntPreference(
timerRepository.focusTime.toInt() "focus_time",
).toLong() timerRepository.focusTime.toInt()
timerRepository.shortBreakTime = ).toLong()
preferenceRepository.getIntPreference("short_break_time")?.toLong() timerRepository.shortBreakTime =
?: preferenceRepository.saveIntPreference( preferenceRepository.getIntPreference("short_break_time")?.toLong()
"short_break_time", ?: preferenceRepository.saveIntPreference(
timerRepository.shortBreakTime.toInt() "short_break_time",
).toLong() timerRepository.shortBreakTime.toInt()
timerRepository.longBreakTime = ).toLong()
preferenceRepository.getIntPreference("long_break_time")?.toLong() timerRepository.longBreakTime =
?: preferenceRepository.saveIntPreference( preferenceRepository.getIntPreference("long_break_time")?.toLong()
"long_break_time", ?: preferenceRepository.saveIntPreference(
timerRepository.longBreakTime.toInt() "long_break_time",
).toLong() timerRepository.longBreakTime.toInt()
timerRepository.sessionLength = preferenceRepository.getIntPreference("session_length") ).toLong()
?: preferenceRepository.saveIntPreference( timerRepository.sessionLength =
"session_length", preferenceRepository.getIntPreference("session_length")
timerRepository.sessionLength ?: 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()
) )
).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() timerRepository.alarmSoundUri = (
val today = LocalDate.now() 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 _time.update { timerRepository.focusTime }
if (lastDate != null) cycles = 0
while (ChronoUnit.DAYS.between(lastDate, today) > 0) { startTime = 0L
lastDate = lastDate?.plusDays(1) pauseTime = 0L
statRepository.insertStat(Stat(lastDate!!, 0, 0, 0, 0, 0)) 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 -> // Fills dates between today and lastDate with 0s to ensure continuous history
currentState.copy(showBrandTitle = false) 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 { companion object {

View File

@@ -7,6 +7,7 @@
package org.nsh07.pomodoro.utils package org.nsh07.pomodoro.utils
import androidx.compose.ui.graphics.Color
import java.util.Locale import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -36,4 +37,26 @@ fun millisecondsToHoursMinutes(t: Long): String {
"%dh %dm", TimeUnit.MILLISECONDS.toHours(t), "%dh %dm", TimeUnit.MILLISECONDS.toHours(t),
TimeUnit.MILLISECONDS.toMinutes(t) % TimeUnit.HOURS.toMinutes(1) 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] [versions]
activityCompose = "1.11.0" activityCompose = "1.11.0"
adaptive = "1.1.0" adaptive = "1.1.0"
agp = "8.11.1" agp = "8.11.2"
composeBom = "2025.09.00" composeBom = "2025.09.01"
coreKtx = "1.17.0" coreKtx = "1.17.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
kotlin = "2.2.20" kotlin = "2.2.20"
ksp = "2.2.20-2.0.3" ksp = "2.2.20-2.0.3"
lifecycleRuntimeKtx = "2.9.3" lifecycleRuntimeKtx = "2.9.4"
navigation3Runtime = "1.0.0-alpha09" materialKolor = "3.0.1"
room = "2.8.0" navigation3 = "1.0.0-alpha10"
vico = "2.2.0-alpha.1" room = "2.8.1"
vico = "2.2.0"
[libraries] [libraries]
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } 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 = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", 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-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3Runtime" } androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3Runtime" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", 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" } 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 = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
junit = { group = "junit", name = "junit", version.ref = "junit" } 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" } vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico" }
[plugins] [plugins]