Merge branch 'dev'
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +42,10 @@ interface StatDao {
|
||||
|
||||
@Query(
|
||||
"SELECT " +
|
||||
"AVG(focusTimeQ1) AS focusTimeQ1, " +
|
||||
"AVG(focusTimeQ2) AS focusTimeQ2, " +
|
||||
"AVG(focusTimeQ3) AS focusTimeQ3, " +
|
||||
"AVG(focusTimeQ4) AS focusTimeQ4 " +
|
||||
"AVG(NULLIF(focusTimeQ1,0)) AS focusTimeQ1, " +
|
||||
"AVG(NULLIF(focusTimeQ2,0)) AS focusTimeQ2, " +
|
||||
"AVG(NULLIF(focusTimeQ3,0)) AS focusTimeQ3, " +
|
||||
"AVG(NULLIF(focusTimeQ4,0)) AS focusTimeQ4 " +
|
||||
"FROM (SELECT focusTimeQ1, focusTimeQ2, focusTimeQ3, focusTimeQ4 FROM stat ORDER BY date DESC LIMIT :n)"
|
||||
)
|
||||
fun getLastNDaysAvgFocusTimes(n: Int): Flow<StatFocusTime?>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -34,7 +34,7 @@ fun NotificationCompat.Builder.addTimerActions(
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.restart,
|
||||
"Reset",
|
||||
"Exit",
|
||||
PendingIntent.getService(
|
||||
context,
|
||||
0,
|
||||
|
||||
@@ -69,15 +69,29 @@ class TimerService : Service() {
|
||||
|
||||
private val cs by lazy { timerRepository.colorScheme }
|
||||
|
||||
private lateinit var notificationStyle: NotificationCompat.ProgressStyle
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
timerRepository.serviceRunning = true
|
||||
alarm = MediaPlayer.create(this, timerRepository.alarmSoundUri)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
timerRepository.serviceRunning = false
|
||||
runBlocking {
|
||||
job.cancel()
|
||||
saveTimeToDb()
|
||||
notificationManager.cancel(1)
|
||||
alarm?.release()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
Actions.TOGGLE.toString() -> {
|
||||
@@ -101,6 +115,8 @@ class TimerService : Service() {
|
||||
}
|
||||
|
||||
private fun toggleTimer() {
|
||||
updateProgressSegments()
|
||||
|
||||
if (timerState.value.timerRunning) {
|
||||
notificationBuilder.clearActions().addTimerActions(
|
||||
this, R.drawable.play, "Start"
|
||||
@@ -194,42 +210,7 @@ class TimerService : Service() {
|
||||
)
|
||||
.setContentText("Up next: $nextTimer (${timerState.value.nextTimeStr})")
|
||||
.setStyle(
|
||||
NotificationCompat.ProgressStyle()
|
||||
.also {
|
||||
// Add all the Focus, Short break and long break intervals in order
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
|
||||
// Android 16 and later supports live updates
|
||||
// Set progress bar sections if on Baklava or later
|
||||
for (i in 0..<timerRepository.sessionLength * 2) {
|
||||
if (i % 2 == 0) it.addProgressSegment(
|
||||
NotificationCompat.ProgressStyle.Segment(
|
||||
timerRepository.focusTime.toInt()
|
||||
)
|
||||
.setColor(cs.primary.toArgb())
|
||||
)
|
||||
else if (i != (timerRepository.sessionLength * 2 - 1)) it.addProgressSegment(
|
||||
NotificationCompat.ProgressStyle.Segment(
|
||||
timerRepository.shortBreakTime.toInt()
|
||||
).setColor(cs.tertiary.toArgb())
|
||||
)
|
||||
else it.addProgressSegment(
|
||||
NotificationCompat.ProgressStyle.Segment(
|
||||
timerRepository.longBreakTime.toInt()
|
||||
).setColor(cs.tertiary.toArgb())
|
||||
)
|
||||
}
|
||||
} else {
|
||||
it.addProgressSegment(
|
||||
NotificationCompat.ProgressStyle.Segment(
|
||||
when (timerState.value.timerMode) {
|
||||
TimerMode.FOCUS -> timerRepository.focusTime.toInt()
|
||||
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt()
|
||||
else -> timerRepository.longBreakTime.toInt()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
notificationStyle
|
||||
.setProgress( // Set the current progress by filling the previous intervals and part of the current interval
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
|
||||
(totalTime - remainingTime) + ((cycles + 1) / 2) * timerRepository.focusTime.toInt() + (cycles / 2) * timerRepository.shortBreakTime.toInt()
|
||||
@@ -249,6 +230,45 @@ class TimerService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateProgressSegments() {
|
||||
notificationStyle = NotificationCompat.ProgressStyle()
|
||||
.also {
|
||||
// Add all the Focus, Short break and long break intervals in order
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
|
||||
// Android 16 and later supports live updates
|
||||
// Set progress bar sections if on Baklava or later
|
||||
for (i in 0..<timerRepository.sessionLength * 2) {
|
||||
if (i % 2 == 0) it.addProgressSegment(
|
||||
NotificationCompat.ProgressStyle.Segment(
|
||||
timerRepository.focusTime.toInt()
|
||||
)
|
||||
.setColor(cs.primary.toArgb())
|
||||
)
|
||||
else if (i != (timerRepository.sessionLength * 2 - 1)) it.addProgressSegment(
|
||||
NotificationCompat.ProgressStyle.Segment(
|
||||
timerRepository.shortBreakTime.toInt()
|
||||
).setColor(cs.tertiary.toArgb())
|
||||
)
|
||||
else it.addProgressSegment(
|
||||
NotificationCompat.ProgressStyle.Segment(
|
||||
timerRepository.longBreakTime.toInt()
|
||||
).setColor(cs.tertiary.toArgb())
|
||||
)
|
||||
}
|
||||
} else {
|
||||
it.addProgressSegment(
|
||||
NotificationCompat.ProgressStyle.Segment(
|
||||
when (timerState.value.timerMode) {
|
||||
TimerMode.FOCUS -> timerRepository.focusTime.toInt()
|
||||
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt()
|
||||
else -> timerRepository.longBreakTime.toInt()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetTimer() {
|
||||
skipScope.launch {
|
||||
saveTimeToDb()
|
||||
@@ -275,6 +295,7 @@ class TimerService : Service() {
|
||||
private fun skipTimer(fromButton: Boolean = false) {
|
||||
skipScope.launch {
|
||||
saveTimeToDb()
|
||||
updateProgressSegments()
|
||||
showTimerNotification(0, paused = true, complete = !fromButton)
|
||||
startTime = 0L
|
||||
pauseTime = 0L
|
||||
@@ -380,16 +401,6 @@ class TimerService : Service() {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
runBlocking {
|
||||
job.cancel()
|
||||
saveTimeToDb()
|
||||
notificationManager.cancel(1)
|
||||
alarm?.release()
|
||||
}
|
||||
}
|
||||
|
||||
enum class Actions {
|
||||
TOGGLE, SKIP, RESET, STOP_ALARM, UPDATE_ALARM_TONE
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation3.runtime.entry
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
@@ -46,7 +45,6 @@ import org.nsh07.pomodoro.MainActivity.Companion.screens
|
||||
import org.nsh07.pomodoro.service.TimerService
|
||||
import org.nsh07.pomodoro.ui.settingsScreen.SettingsScreenRoot
|
||||
import org.nsh07.pomodoro.ui.statsScreen.StatsScreenRoot
|
||||
import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel
|
||||
import org.nsh07.pomodoro.ui.timerScreen.AlarmDialog
|
||||
import org.nsh07.pomodoro.ui.timerScreen.TimerScreen
|
||||
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction
|
||||
@@ -56,8 +54,7 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel
|
||||
@Composable
|
||||
fun AppScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory),
|
||||
statsViewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)
|
||||
timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory)
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -194,7 +191,6 @@ fun AppScreen(
|
||||
entry<Screen.Stats> {
|
||||
StatsScreenRoot(
|
||||
contentPadding = contentPadding,
|
||||
viewModel = statsViewModel,
|
||||
modifier = modifier.padding(
|
||||
start = contentPadding.calculateStartPadding(layoutDirection),
|
||||
end = contentPadding.calculateEndPadding(layoutDirection),
|
||||
|
||||
80
app/src/main/java/org/nsh07/pomodoro/ui/ClickableListItem.kt
Normal file
80
app/src/main/java/org/nsh07/pomodoro/ui/ClickableListItem.kt
Normal 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
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -53,6 +53,8 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberSliderState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -61,6 +63,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -74,14 +77,17 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import org.nsh07.pomodoro.R
|
||||
import org.nsh07.pomodoro.service.TimerService
|
||||
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.PreferencesState
|
||||
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel
|
||||
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
|
||||
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
|
||||
import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors
|
||||
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.bottomListItemShape
|
||||
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.cardShape
|
||||
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape
|
||||
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape
|
||||
import org.nsh07.pomodoro.ui.theme.TomatoTheme
|
||||
import org.nsh07.pomodoro.utils.toColor
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -90,6 +96,12 @@ fun SettingsScreenRoot(
|
||||
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory)
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.runTextFieldFlowCollection()
|
||||
onDispose { viewModel.cancelTextFieldFlowCollection() }
|
||||
}
|
||||
|
||||
val focusTimeInputFieldState = rememberSaveable(saver = TextFieldState.Saver) {
|
||||
viewModel.focusTimeTextFieldState
|
||||
}
|
||||
@@ -104,6 +116,8 @@ fun SettingsScreenRoot(
|
||||
val vibrateEnabled by viewModel.vibrateEnabled.collectAsStateWithLifecycle(true)
|
||||
val alarmSound by viewModel.alarmSound.collectAsStateWithLifecycle(viewModel.currentAlarmSound)
|
||||
|
||||
val preferencesState by viewModel.preferencesState.collectAsStateWithLifecycle()
|
||||
|
||||
val sessionsSliderState = rememberSaveable(
|
||||
saver = SliderState.Saver(
|
||||
viewModel.sessionsSliderState.onValueChangeFinished,
|
||||
@@ -114,6 +128,7 @@ fun SettingsScreenRoot(
|
||||
}
|
||||
|
||||
SettingsScreen(
|
||||
preferencesState = preferencesState,
|
||||
focusTimeInputFieldState = focusTimeInputFieldState,
|
||||
shortBreakTimeInputFieldState = shortBreakTimeInputFieldState,
|
||||
longBreakTimeInputFieldState = longBreakTimeInputFieldState,
|
||||
@@ -123,6 +138,7 @@ fun SettingsScreenRoot(
|
||||
alarmSound = alarmSound,
|
||||
onAlarmEnabledChange = viewModel::saveAlarmEnabled,
|
||||
onVibrateEnabledChange = viewModel::saveVibrateEnabled,
|
||||
onBlackThemeChange = viewModel::saveBlackTheme,
|
||||
onAlarmSoundChanged = {
|
||||
viewModel.saveAlarmSound(it)
|
||||
Intent(context, TimerService::class.java).apply {
|
||||
@@ -130,6 +146,8 @@ fun SettingsScreenRoot(
|
||||
context.startService(this)
|
||||
}
|
||||
},
|
||||
onThemeChange = viewModel::saveTheme,
|
||||
onColorSchemeChange = viewModel::saveColorScheme,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@@ -137,6 +155,7 @@ fun SettingsScreenRoot(
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun SettingsScreen(
|
||||
preferencesState: PreferencesState,
|
||||
focusTimeInputFieldState: TextFieldState,
|
||||
shortBreakTimeInputFieldState: TextFieldState,
|
||||
longBreakTimeInputFieldState: TextFieldState,
|
||||
@@ -146,7 +165,10 @@ private fun SettingsScreen(
|
||||
alarmSound: String,
|
||||
onAlarmEnabledChange: (Boolean) -> Unit,
|
||||
onVibrateEnabledChange: (Boolean) -> Unit,
|
||||
onBlackThemeChange: (Boolean) -> Unit,
|
||||
onAlarmSoundChanged: (Uri?) -> Unit,
|
||||
onThemeChange: (String) -> Unit,
|
||||
onColorSchemeChange: (Color) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
@@ -154,7 +176,31 @@ private fun SettingsScreen(
|
||||
checkedIconColor = colorScheme.primary,
|
||||
)
|
||||
|
||||
val themeMap: Map<String, Pair<Int, String>> = remember {
|
||||
mapOf(
|
||||
"auto" to Pair(
|
||||
R.drawable.brightness_auto,
|
||||
"System default"
|
||||
),
|
||||
"light" to Pair(R.drawable.light_mode, "Light"),
|
||||
"dark" to Pair(R.drawable.dark_mode, "Dark")
|
||||
)
|
||||
}
|
||||
val reverseThemeMap: Map<String, String> = remember {
|
||||
mapOf(
|
||||
"System default" to "auto",
|
||||
"Light" to "light",
|
||||
"Dark" to "dark"
|
||||
)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
var alarmName by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
alarmName = RingtoneManager.getRingtone(context, alarmSound.toUri())
|
||||
?.getTitle(context) ?: ""
|
||||
}
|
||||
|
||||
val ringtonePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
@@ -180,8 +226,15 @@ private fun SettingsScreen(
|
||||
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, alarmSound.toUri())
|
||||
}
|
||||
|
||||
val switchItems = remember(alarmEnabled, vibrateEnabled) {
|
||||
val switchItems = remember(preferencesState.blackTheme, alarmEnabled, vibrateEnabled) {
|
||||
listOf(
|
||||
SettingsSwitchItem(
|
||||
checked = preferencesState.blackTheme,
|
||||
icon = R.drawable.contrast,
|
||||
label = "Black theme",
|
||||
description = "Use a pure black dark theme",
|
||||
onClick = onBlackThemeChange
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
checked = alarmEnabled,
|
||||
icon = R.drawable.alarm_on,
|
||||
@@ -314,31 +367,83 @@ private fun SettingsScreen(
|
||||
}
|
||||
},
|
||||
colors = listItemColors,
|
||||
modifier = Modifier.clip(topListItemShape)
|
||||
modifier = Modifier.clip(cardShape)
|
||||
)
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(12.dp)) }
|
||||
|
||||
item {
|
||||
ColorSchemePickerListItem(
|
||||
color = preferencesState.colorScheme.toColor(),
|
||||
items = 3,
|
||||
index = 0,
|
||||
onColorChange = onColorSchemeChange
|
||||
)
|
||||
}
|
||||
item {
|
||||
ThemePickerListItem(
|
||||
theme = preferencesState.theme,
|
||||
themeMap = themeMap,
|
||||
reverseThemeMap = reverseThemeMap,
|
||||
onThemeChange = onThemeChange,
|
||||
items = 3,
|
||||
index = 1,
|
||||
modifier = Modifier
|
||||
.clip(middleListItemShape)
|
||||
)
|
||||
}
|
||||
item {
|
||||
val item = switchItems[0]
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(painterResource(item.icon), contentDescription = null)
|
||||
},
|
||||
headlineContent = { Text(item.label) },
|
||||
supportingContent = { Text(item.description) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = item.checked,
|
||||
onCheckedChange = { item.onClick(it) },
|
||||
thumbContent = {
|
||||
if (item.checked) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.check),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(SwitchDefaults.IconSize),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.clear),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(SwitchDefaults.IconSize),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = switchColors
|
||||
)
|
||||
},
|
||||
colors = listItemColors,
|
||||
modifier = Modifier.clip(bottomListItemShape)
|
||||
)
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(12.dp)) }
|
||||
|
||||
item {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(painterResource(R.drawable.alarm), null)
|
||||
},
|
||||
headlineContent = { Text("Alarm sound") },
|
||||
supportingContent = {
|
||||
Text(
|
||||
remember(alarmSound) {
|
||||
RingtoneManager.getRingtone(context, alarmSound.toUri())
|
||||
.getTitle(context)
|
||||
}
|
||||
)
|
||||
},
|
||||
supportingContent = { Text(alarmName) },
|
||||
colors = listItemColors,
|
||||
modifier = Modifier
|
||||
.clip(bottomListItemShape)
|
||||
.clip(topListItemShape)
|
||||
.clickable(onClick = { ringtonePickerLauncher.launch(intent) })
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(12.dp)) }
|
||||
itemsIndexed(switchItems) { index, item ->
|
||||
itemsIndexed(switchItems.drop(1)) { index, item ->
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(painterResource(item.icon), contentDescription = null)
|
||||
@@ -371,8 +476,7 @@ private fun SettingsScreen(
|
||||
modifier = Modifier
|
||||
.clip(
|
||||
when (index) {
|
||||
0 -> topListItemShape
|
||||
switchItems.lastIndex -> bottomListItemShape
|
||||
switchItems.lastIndex - 1 -> bottomListItemShape
|
||||
else -> middleListItemShape
|
||||
}
|
||||
)
|
||||
@@ -422,16 +526,20 @@ private fun SettingsScreen(
|
||||
fun SettingsScreenPreview() {
|
||||
TomatoTheme {
|
||||
SettingsScreen(
|
||||
focusTimeInputFieldState = rememberTextFieldState((25 * 60 * 1000).toString()),
|
||||
shortBreakTimeInputFieldState = rememberTextFieldState((5 * 60 * 1000).toString()),
|
||||
longBreakTimeInputFieldState = rememberTextFieldState((15 * 60 * 1000).toString()),
|
||||
preferencesState = PreferencesState(),
|
||||
focusTimeInputFieldState = rememberTextFieldState((25).toString()),
|
||||
shortBreakTimeInputFieldState = rememberTextFieldState((5).toString()),
|
||||
longBreakTimeInputFieldState = rememberTextFieldState((15).toString()),
|
||||
sessionsSliderState = rememberSliderState(value = 3f, steps = 3, valueRange = 1f..5f),
|
||||
alarmEnabled = true,
|
||||
vibrateEnabled = true,
|
||||
alarmSound = "null",
|
||||
onAlarmEnabledChange = {},
|
||||
onVibrateEnabledChange = {},
|
||||
onBlackThemeChange = {},
|
||||
onAlarmSoundChanged = {},
|
||||
onThemeChange = {},
|
||||
onColorSchemeChange = {},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
package org.nsh07.pomodoro.utils
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -36,4 +37,26 @@ fun millisecondsToHoursMinutes(t: Long): String {
|
||||
"%dh %dm", TimeUnit.MILLISECONDS.toHours(t),
|
||||
TimeUnit.MILLISECONDS.toMinutes(t) % TimeUnit.HOURS.toMinutes(1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function for [String] to convert it to a [androidx.compose.ui.graphics.Color]
|
||||
*
|
||||
* The base string must be of the format produced by [androidx.compose.ui.graphics.Color.toString],
|
||||
* i.e, the color black with 100% opacity in sRGB would be represented by:
|
||||
*
|
||||
* Color(0.0, 0.0, 0.0, 1.0, sRGB IEC61966-2.1)
|
||||
*/
|
||||
fun String.toColor(): Color {
|
||||
// Sample string: Color(0.0, 0.0, 0.0, 1.0, sRGB IEC61966-2.1)
|
||||
val comma1 = this.indexOf(',')
|
||||
val comma2 = this.indexOf(',', comma1 + 1)
|
||||
val comma3 = this.indexOf(',', comma2 + 1)
|
||||
val comma4 = this.indexOf(',', comma3 + 1)
|
||||
|
||||
val r = this.substringAfter('(').substringBefore(',').toFloat()
|
||||
val g = this.slice(comma1 + 1..comma2 - 1).toFloat()
|
||||
val b = this.slice(comma2 + 1..comma3 - 1).toFloat()
|
||||
val a = this.slice(comma3 + 1..comma4 - 1).toFloat()
|
||||
return Color(r, g, b, a)
|
||||
}
|
||||
|
||||
9
app/src/main/res/drawable/brightness_auto.xml
Normal file
9
app/src/main/res/drawable/brightness_auto.xml
Normal 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>
|
||||
16
app/src/main/res/drawable/colors.xml
Normal file
16
app/src/main/res/drawable/colors.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/contrast.xml
Normal file
9
app/src/main/res/drawable/contrast.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/dark_mode.xml
Normal file
9
app/src/main/res/drawable/dark_mode.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/light_mode.xml
Normal file
9
app/src/main/res/drawable/light_mode.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/palette.xml
Normal file
9
app/src/main/res/drawable/palette.xml
Normal 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>
|
||||
8
fastlane/metadata/android/en-US/changelogs/8.txt
Normal file
8
fastlane/metadata/android/en-US/changelogs/8.txt
Normal 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 |
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user