diff --git a/androidApp/src/main/java/org/nsh07/pomodoro/MainActivity.kt b/androidApp/src/main/java/org/nsh07/pomodoro/MainActivity.kt index 2139e9a2..f9bebb97 100644 --- a/androidApp/src/main/java/org/nsh07/pomodoro/MainActivity.kt +++ b/androidApp/src/main/java/org/nsh07/pomodoro/MainActivity.kt @@ -33,7 +33,6 @@ import org.nsh07.pomodoro.di.ActivityCallbacks import org.nsh07.pomodoro.ui.AppScreen import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel import org.nsh07.pomodoro.ui.theme.TomatoTheme -import org.nsh07.pomodoro.utils.toColor class MainActivity : ComponentActivity() { @@ -61,7 +60,7 @@ class MainActivity : ComponentActivity() { else -> isSystemInDarkTheme() } - val seed = settingsState.colorScheme.toColor() + val seed = settingsState.colorScheme val isPlus by settingsViewModel.isPlus.collectAsStateWithLifecycle() diff --git a/androidApp/src/main/java/org/nsh07/pomodoro/di/androidModules.kt b/androidApp/src/main/java/org/nsh07/pomodoro/di/androidModules.kt index 0c007939..f57ac8e7 100644 --- a/androidApp/src/main/java/org/nsh07/pomodoro/di/androidModules.kt +++ b/androidApp/src/main/java/org/nsh07/pomodoro/di/androidModules.kt @@ -37,9 +37,11 @@ import org.nsh07.pomodoro.BuildConfig import org.nsh07.pomodoro.R import org.nsh07.pomodoro.data.AppPreferenceRepository import org.nsh07.pomodoro.data.AppStatRepository +import org.nsh07.pomodoro.data.AppTopicRepository import org.nsh07.pomodoro.data.PreferenceRepository import org.nsh07.pomodoro.data.StatRepository import org.nsh07.pomodoro.data.StateRepository +import org.nsh07.pomodoro.data.TopicRepository import org.nsh07.pomodoro.service.AndroidTimerHelper import org.nsh07.pomodoro.service.TimerHelper import org.nsh07.pomodoro.service.TimerManager @@ -50,6 +52,7 @@ val servicesModule = module { single { create(::createAppInfo) } single() bind StatRepository::class + single() bind TopicRepository::class single() bind PreferenceRepository::class single() single() bind TimerHelper::class diff --git a/androidApp/src/main/java/org/nsh07/pomodoro/service/AndroidTimerHelper.kt b/androidApp/src/main/java/org/nsh07/pomodoro/service/AndroidTimerHelper.kt index bfde2dda..3679ad37 100644 --- a/androidApp/src/main/java/org/nsh07/pomodoro/service/AndroidTimerHelper.kt +++ b/androidApp/src/main/java/org/nsh07/pomodoro/service/AndroidTimerHelper.kt @@ -62,7 +62,7 @@ class AndroidTimerHelper(private val context: Context) : TimerHelper { context.startService(it) } - is TimerAction.SetInfiniteFocus -> { + else -> { Log.e("StartService", "Invalid action: $action") } } diff --git a/androidApp/src/main/java/org/nsh07/pomodoro/service/TimerService.kt b/androidApp/src/main/java/org/nsh07/pomodoro/service/TimerService.kt index 4794d161..45c34a46 100644 --- a/androidApp/src/main/java/org/nsh07/pomodoro/service/TimerService.kt +++ b/androidApp/src/main/java/org/nsh07/pomodoro/service/TimerService.kt @@ -56,6 +56,7 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode import org.nsh07.pomodoro.utils.millisecondsToStr import org.nsh07.pomodoro.widget.TimerAppWidget import kotlin.text.Typography.middleDot +import kotlin.time.Duration.Companion.milliseconds class TimerService : Service(), KoinComponent { @@ -207,14 +208,15 @@ class TimerService : Service(), KoinComponent { remainingTime: Int, paused: Boolean = false, complete: Boolean = false ) { val settingsState = _settingsState.value + val currentTopic = stateRepository.currentTopic.value val timerState = _timerState.value if (complete) notificationBuilder.clearActions().addStopAlarmAction(this) val totalTime = when (timerState.timerMode) { - TimerMode.FOCUS -> settingsState.focusTime.toInt() - TimerMode.SHORT_BREAK -> settingsState.shortBreakTime.toInt() - else -> settingsState.longBreakTime.toInt() + TimerMode.FOCUS -> currentTopic.focusTime.toInt() + TimerMode.SHORT_BREAK -> currentTopic.shortBreakTime.toInt() + else -> currentTopic.longBreakTime.toInt() } val currentTimer = when (timerState.timerMode) { @@ -259,7 +261,7 @@ class TimerService : Service(), KoinComponent { if (timerState.timerMode == TimerMode.FOCUS) (Long.MAX_VALUE - remainingTime).toInt() else (totalTime - remainingTime) } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA && !settingsState.singleProgressBar) { - (totalTime - remainingTime) + ((timerManager.cycles + 1) / 2) * settingsState.focusTime.toInt() + (timerManager.cycles / 2) * settingsState.shortBreakTime.toInt() + (totalTime - remainingTime) + ((timerManager.cycles + 1) / 2) * currentTopic.focusTime.toInt() + (timerManager.cycles / 2) * currentTopic.shortBreakTime.toInt() } else (totalTime - remainingTime) ) ) @@ -293,27 +295,28 @@ class TimerService : Service(), KoinComponent { private fun updateProgressSegments() { val settingsState = _settingsState.value + val currentTopic = stateRepository.currentTopic.value 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 && !settingsState.singleProgressBar && !_timerState.value.infiniteFocus) { // Android 16 and later supports live updates // Set progress bar sections if on Baklava or later - for (i in 0.. settingsState.focusTime.toInt() - TimerMode.SHORT_BREAK -> settingsState.shortBreakTime.toInt() - else -> settingsState.longBreakTime.toInt() + TimerMode.FOCUS -> currentTopic.focusTime.toInt() + TimerMode.SHORT_BREAK -> currentTopic.shortBreakTime.toInt() + else -> currentTopic.longBreakTime.toInt() } ) ) @@ -338,7 +341,7 @@ class TimerService : Service(), KoinComponent { activityCallbacks.activityTurnScreenOn(true) autoAlarmStopScope = CoroutineScope(Dispatchers.IO).launch { - delay(1 * 60 * 1000) + delay((1 * 60 * 1000).milliseconds) stopAlarm(fromAutoStop = true) } @@ -374,6 +377,7 @@ class TimerService : Service(), KoinComponent { updateProgressSegments() // Make sure notification style is initialized val settingsState = _settingsState.value + val currentTopic = stateRepository.currentTopic.value autoAlarmStopScope?.cancel() if (settingsState.alarmEnabled) { @@ -398,13 +402,13 @@ class TimerService : Service(), KoinComponent { ) showTimerNotification( when (_timerState.value.timerMode) { - TimerMode.FOCUS -> settingsState.focusTime.toInt() - TimerMode.SHORT_BREAK -> settingsState.shortBreakTime.toInt() - else -> settingsState.longBreakTime.toInt() + TimerMode.FOCUS -> currentTopic.focusTime.toInt() + TimerMode.SHORT_BREAK -> currentTopic.shortBreakTime.toInt() + else -> currentTopic.longBreakTime.toInt() }, paused = true, complete = false ) - if (settingsState.autostartNextSession && !fromAutoStop) // auto start next session + if (currentTopic.autostartNextSession && !fromAutoStop) // auto start next session toggleTimer() CoroutineScope(Dispatchers.IO).launch { @@ -444,7 +448,7 @@ class TimerService : Service(), KoinComponent { } private fun setDoNotDisturb(doNotDisturb: Boolean) { - if (_settingsState.value.dndEnabled && notificationManagerService.isNotificationPolicyAccessGranted()) { + if (stateRepository.currentTopic.value.dndEnabled && notificationManagerService.isNotificationPolicyAccessGranted()) { if (doNotDisturb) { notificationManagerService.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_PRIORITY) } else notificationManagerService.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_ALL) diff --git a/androidApp/src/main/java/org/nsh07/pomodoro/widget/HistoryAppWidget.kt b/androidApp/src/main/java/org/nsh07/pomodoro/widget/HistoryAppWidget.kt index fb9612a6..0a0953ff 100644 --- a/androidApp/src/main/java/org/nsh07/pomodoro/widget/HistoryAppWidget.kt +++ b/androidApp/src/main/java/org/nsh07/pomodoro/widget/HistoryAppWidget.kt @@ -23,7 +23,6 @@ import androidx.compose.material3.MaterialTheme.typography import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed import androidx.glance.ColorFilter @@ -55,7 +54,6 @@ import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.height import androidx.glance.layout.padding import androidx.glance.layout.width -import androidx.glance.material3.ColorProviders import androidx.glance.preview.ExperimentalGlancePreviewApi import androidx.glance.preview.Preview import androidx.glance.text.FontWeight @@ -67,11 +65,9 @@ import org.nsh07.pomodoro.MainActivity import org.nsh07.pomodoro.R import org.nsh07.pomodoro.data.Stat import org.nsh07.pomodoro.data.StatRepository -import org.nsh07.pomodoro.ui.theme.lightScheme import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes import org.nsh07.pomodoro.widget.TomatoWidgetSize.Width4 import org.nsh07.pomodoro.widget.components.GlanceText -import java.time.LocalDate class HistoryAppWidget : GlanceAppWidget(), KoinComponent { override val sizeMode: SizeMode = SizeMode.Exact @@ -203,139 +199,140 @@ class HistoryAppWidget : GlanceAppWidget(), KoinComponent { @Preview(widthDp = 400, heightDp = 216) @Composable private fun ContentPreview() { - val history = listOf( - Stat( - date = LocalDate.of(2026, 3, 12), - focusTimeQ1 = 1617943 + 7200000, - focusTimeQ2 = 5704591, - focusTimeQ3 = 556490, - focusTimeQ4 = 1200498, - breakTime = 3939448 - ), - Stat( - date = LocalDate.of(2026, 3, 13), - focusTimeQ1 = 1128282 + 7200000, - focusTimeQ2 = 4590524, - focusTimeQ3 = 7747202, - focusTimeQ4 = 1119272, - breakTime = 311887 - ), - Stat( - date = LocalDate.of(2026, 3, 14), - focusTimeQ1 = 1418079 + 7200000, - focusTimeQ2 = 8141785, - focusTimeQ3 = 5208864, - focusTimeQ4 = 2793210, - breakTime = 2873581 - ), - Stat( - date = LocalDate.of(2026, 3, 15), - focusTimeQ1 = 38960 + 7200000, - focusTimeQ2 = 9544172, - focusTimeQ3 = 2216626, - focusTimeQ4 = 1424242, - breakTime = 4635775 - ), - Stat( - date = LocalDate.of(2026, 3, 16), - focusTimeQ1 = 948108 + 7200000, - focusTimeQ2 = 7715257, - focusTimeQ3 = 648629, - focusTimeQ4 = 319655, - breakTime = 1710029 - ), - Stat( - date = LocalDate.of(2026, 3, 17), - focusTimeQ1 = 1673932 + 7200000, - focusTimeQ2 = 7368028, - focusTimeQ3 = 6028910, - focusTimeQ4 = 2134210, - breakTime = 2811766 - ), - Stat( - date = LocalDate.of(2026, 3, 18), - focusTimeQ1 = 435688 + 7200000, - focusTimeQ2 = 9487983, - focusTimeQ3 = 248276, - focusTimeQ4 = 913853, - breakTime = 162869 - ), - Stat( - date = LocalDate.of(2026, 3, 19), - focusTimeQ1 = 1579291 + 7200000, - focusTimeQ2 = 3743344, - focusTimeQ3 = 3383617, - focusTimeQ4 = 3424645, - breakTime = 3443552 - ), - Stat( - date = LocalDate.of(2026, 3, 20), - focusTimeQ1 = 522247 + 7200000, - focusTimeQ2 = 7156785, - focusTimeQ3 = 5190730, - focusTimeQ4 = 3086522, - breakTime = 3768831 - ), - Stat( - date = LocalDate.of(2026, 3, 21), - focusTimeQ1 = 310048 + 7200000, - focusTimeQ2 = 5901959, - focusTimeQ3 = 441673, - focusTimeQ4 = 3562958, - breakTime = 5470220 - ), - Stat( - date = LocalDate.of(2026, 3, 22), - focusTimeQ1 = 1200000 + 7200000, - focusTimeQ2 = 4000000, - focusTimeQ3 = 3000000, - focusTimeQ4 = 1000000, - breakTime = 2000000 - ), - Stat( - date = LocalDate.of(2026, 3, 23), - focusTimeQ1 = 500000 + 7200000, - focusTimeQ2 = 8000000, - focusTimeQ3 = 1000000, - focusTimeQ4 = 500000, - breakTime = 1000000 - ), - Stat( - date = LocalDate.of(2026, 3, 24), - focusTimeQ1 = 2000000 + 7200000, - focusTimeQ2 = 2000000, - focusTimeQ3 = 2000000, - focusTimeQ4 = 2000000, - breakTime = 3000000 - ), - Stat( - date = LocalDate.of(2026, 3, 25), - focusTimeQ1 = 0 + 7200000, - focusTimeQ2 = 10000000, - focusTimeQ3 = 0, - focusTimeQ4 = 0, - breakTime = 500000 - ), - Stat( - date = LocalDate.of(2026, 3, 26), - focusTimeQ1 = 3000000 + 7200000, - focusTimeQ2 = 3000000, - focusTimeQ3 = 3000000, - focusTimeQ4 = 3000000, - breakTime = 4000000 - ) - ) - GlanceTheme(colors = ColorProviders(lightScheme)) { - Box(GlanceModifier.background(Color.White)) { - Box( - GlanceModifier.cornerRadius(32.dp) - ) { - Content( - history = history, - maxFocus = history.maxBy { it.totalFocusTime() }.totalFocusTime() - ) - } - } - } + // TODO: add topic parameter +// val history = listOf( +// Stat( +// date = LocalDate.of(2026, 3, 12), +// focusTimeQ1 = 1617943 + 7200000, +// focusTimeQ2 = 5704591, +// focusTimeQ3 = 556490, +// focusTimeQ4 = 1200498, +// breakTime = 3939448 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 13), +// focusTimeQ1 = 1128282 + 7200000, +// focusTimeQ2 = 4590524, +// focusTimeQ3 = 7747202, +// focusTimeQ4 = 1119272, +// breakTime = 311887 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 14), +// focusTimeQ1 = 1418079 + 7200000, +// focusTimeQ2 = 8141785, +// focusTimeQ3 = 5208864, +// focusTimeQ4 = 2793210, +// breakTime = 2873581 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 15), +// focusTimeQ1 = 38960 + 7200000, +// focusTimeQ2 = 9544172, +// focusTimeQ3 = 2216626, +// focusTimeQ4 = 1424242, +// breakTime = 4635775 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 16), +// focusTimeQ1 = 948108 + 7200000, +// focusTimeQ2 = 7715257, +// focusTimeQ3 = 648629, +// focusTimeQ4 = 319655, +// breakTime = 1710029 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 17), +// focusTimeQ1 = 1673932 + 7200000, +// focusTimeQ2 = 7368028, +// focusTimeQ3 = 6028910, +// focusTimeQ4 = 2134210, +// breakTime = 2811766 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 18), +// focusTimeQ1 = 435688 + 7200000, +// focusTimeQ2 = 9487983, +// focusTimeQ3 = 248276, +// focusTimeQ4 = 913853, +// breakTime = 162869 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 19), +// focusTimeQ1 = 1579291 + 7200000, +// focusTimeQ2 = 3743344, +// focusTimeQ3 = 3383617, +// focusTimeQ4 = 3424645, +// breakTime = 3443552 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 20), +// focusTimeQ1 = 522247 + 7200000, +// focusTimeQ2 = 7156785, +// focusTimeQ3 = 5190730, +// focusTimeQ4 = 3086522, +// breakTime = 3768831 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 21), +// focusTimeQ1 = 310048 + 7200000, +// focusTimeQ2 = 5901959, +// focusTimeQ3 = 441673, +// focusTimeQ4 = 3562958, +// breakTime = 5470220 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 22), +// focusTimeQ1 = 1200000 + 7200000, +// focusTimeQ2 = 4000000, +// focusTimeQ3 = 3000000, +// focusTimeQ4 = 1000000, +// breakTime = 2000000 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 23), +// focusTimeQ1 = 500000 + 7200000, +// focusTimeQ2 = 8000000, +// focusTimeQ3 = 1000000, +// focusTimeQ4 = 500000, +// breakTime = 1000000 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 24), +// focusTimeQ1 = 2000000 + 7200000, +// focusTimeQ2 = 2000000, +// focusTimeQ3 = 2000000, +// focusTimeQ4 = 2000000, +// breakTime = 3000000 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 25), +// focusTimeQ1 = 0 + 7200000, +// focusTimeQ2 = 10000000, +// focusTimeQ3 = 0, +// focusTimeQ4 = 0, +// breakTime = 500000 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 26), +// focusTimeQ1 = 3000000 + 7200000, +// focusTimeQ2 = 3000000, +// focusTimeQ3 = 3000000, +// focusTimeQ4 = 3000000, +// breakTime = 4000000 +// ) +// ) +// GlanceTheme(colors = ColorProviders(lightScheme)) { +// Box(GlanceModifier.background(Color.White)) { +// Box( +// GlanceModifier.cornerRadius(32.dp) +// ) { +// Content( +// history = history, +// maxFocus = history.maxBy { it.totalFocusTime() }.totalFocusTime() +// ) +// } +// } +// } } } diff --git a/androidApp/src/main/java/org/nsh07/pomodoro/widget/TodayAppWidget.kt b/androidApp/src/main/java/org/nsh07/pomodoro/widget/TodayAppWidget.kt index d2dff0f7..9f2800c6 100644 --- a/androidApp/src/main/java/org/nsh07/pomodoro/widget/TodayAppWidget.kt +++ b/androidApp/src/main/java/org/nsh07/pomodoro/widget/TodayAppWidget.kt @@ -84,7 +84,7 @@ class TodayAppWidget : GlanceAppWidget(), KoinComponent { ) { val statRepository: StatRepository = get() val stat = statRepository.getTodayStat().first() - ?: Stat(LocalDate.now(), 0, 0, 0, 0, 0) + ?: Stat(LocalDate.now(), "default", 0, 0, 0, 0, 0) provideContent { key(LocalSize.current) { @@ -205,6 +205,7 @@ class TodayAppWidget : GlanceAppWidget(), KoinComponent { Content( Stat( date = LocalDate.of(2026, 3, 12), + topicId = "default", focusTimeQ1 = 1617943, focusTimeQ2 = 5704591, focusTimeQ3 = 556490, diff --git a/androidApp/src/test/java/org/nsh07/pomodoro/utils/UtilsKtTest.kt b/androidApp/src/test/java/org/nsh07/pomodoro/utils/UtilsKtTest.kt index 90b54dda..3ebdbde1 100644 --- a/androidApp/src/test/java/org/nsh07/pomodoro/utils/UtilsKtTest.kt +++ b/androidApp/src/test/java/org/nsh07/pomodoro/utils/UtilsKtTest.kt @@ -17,7 +17,6 @@ package org.nsh07.pomodoro.utils -import androidx.compose.ui.graphics.Color import junit.framework.TestCase.assertEquals import org.junit.Test import java.util.Locale @@ -148,24 +147,4 @@ class UtilsKtTest { fun `millisecondsToHoursMinutes Long MAX VALUE`() { assertEquals("2562047788015h 12m", millisecondsToHoursMinutes(Long.MAX_VALUE)) } - - @Test - fun `toColor with a standard valid color string`() { - assertEquals(Color.Black.toString().toColor(), Color.Black) - } - - @Test - fun `toColor with color components at maximum valid values`() { - assertEquals(Color.White.toString().toColor(), Color.White) - } - - @Test - fun `toColor with floating point values having multiple decimal places`() { - assertEquals( - Color(0.12345f, 0.23456f, 0.34567f, 0.45678f) - .toString() - .toColor(), - Color(0.12345f, 0.23456f, 0.34567f, 0.45678f) - ) - } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d9ef65c5..506caabf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,8 +28,8 @@ revenuecat = "9.29.1" room = "2.8.4" sqlite = "2.6.2" vico = "3.1.0" -composeMultiplatform = "1.11.0-beta02" -composeMaterial3 = "1.11.0-alpha06" +composeMultiplatform = "1.12.0-alpha01" +composeMaterial3 = "1.12.0-alpha01" koinBom = "4.2.1" koinCompilerPlugin = "0.6.2" diff --git a/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json b/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json new file mode 100644 index 00000000..61ff3fe9 --- /dev/null +++ b/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json @@ -0,0 +1,236 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "70186f81b432ac30b2e29038e8c5ba85", + "entities": [ + { + "tableName": "int_preference", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "boolean_preference", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "string_preference", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "stat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` TEXT NOT NULL, `topicId` TEXT NOT NULL, `focusTimeQ1` INTEGER NOT NULL, `focusTimeQ2` INTEGER NOT NULL, `focusTimeQ3` INTEGER NOT NULL, `focusTimeQ4` INTEGER NOT NULL, `breakTime` INTEGER NOT NULL, PRIMARY KEY(`date`, `topicId`), FOREIGN KEY(`topicId`) REFERENCES `topic`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "topicId", + "columnName": "topicId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTimeQ1", + "columnName": "focusTimeQ1", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "focusTimeQ2", + "columnName": "focusTimeQ2", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "focusTimeQ3", + "columnName": "focusTimeQ3", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "focusTimeQ4", + "columnName": "focusTimeQ4", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "breakTime", + "columnName": "breakTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "date", + "topicId" + ] + }, + "indices": [ + { + "name": "index_stat_topicId", + "unique": false, + "columnNames": [ + "topicId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_stat_topicId` ON `${TABLE_NAME}` (`topicId`)" + } + ], + "foreignKeys": [ + { + "table": "topic", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "topicId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "topic", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `shape` TEXT NOT NULL, `focusTime` INTEGER NOT NULL, `shortBreakTime` INTEGER NOT NULL, `longBreakTime` INTEGER NOT NULL, `sessionLength` INTEGER NOT NULL, `autostartNextSession` INTEGER NOT NULL, `dndEnabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shape", + "columnName": "shape", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shortBreakTime", + "columnName": "shortBreakTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "longBreakTime", + "columnName": "longBreakTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionLength", + "columnName": "sessionLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autostartNextSession", + "columnName": "autostartNextSession", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dndEnabled", + "columnName": "dndEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '70186f81b432ac30b2e29038e8c5ba85')" + ] + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt b/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt index 6ba0b1b0..d1fafd74 100644 --- a/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt +++ b/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt @@ -19,6 +19,9 @@ package org.nsh07.pomodoro.di import android.content.Context import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL import org.koin.dsl.bind import org.koin.dsl.module import org.koin.plugin.module.dsl.create @@ -27,7 +30,11 @@ import org.koin.plugin.module.dsl.viewModel import org.nsh07.pomodoro.BuildKonfig import org.nsh07.pomodoro.data.AndroidBackupRestoreManager import org.nsh07.pomodoro.data.AppDatabase +import org.nsh07.pomodoro.data.AppTopicRepository import org.nsh07.pomodoro.data.BackupRestoreManager +import org.nsh07.pomodoro.data.MIGRATION_2_3 +import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic +import org.nsh07.pomodoro.data.TopicRepository import org.nsh07.pomodoro.ui.settingsScreen.screens.backupRestore.viewModel.BackupRestoreViewModel import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel @@ -37,6 +44,7 @@ val dbModule = module { single { create(::createDatabase) } single { get().preferenceDao() } single { get().statDao() } + single { get().topicDao() } single { get().systemDao() } } @@ -49,12 +57,41 @@ val viewModels = module { val androidModule = module { single() bind BackupRestoreManager::class + single() bind TopicRepository::class } private fun createDatabase(context: Context): AppDatabase { - return Room.databaseBuilder( - context, - AppDatabase::class.java, - BuildKonfig.DATABASE_NAME - ).build() + return Room + .databaseBuilder( + context, + AppDatabase::class.java, + BuildKonfig.DATABASE_NAME + ) + .addMigrations(MIGRATION_2_3) + .addCallback( + object : RoomDatabase.Callback() { + override fun onCreate(connection: SQLiteConnection) { + super.onCreate(connection) + connection.execSQL( + """ + INSERT OR IGNORE INTO `topic` + (`id`, `name`, `color`, `shape`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autostartNextSession`, `dndEnabled`) + VALUES ( + '${defaultTopic.id}', + '${defaultTopic.name}', + ${defaultTopic.color.value.toLong()}, + '${defaultTopic.shape.name}', + ${defaultTopic.focusTime}, + ${defaultTopic.shortBreakTime}, + ${defaultTopic.longBreakTime}, + ${defaultTopic.sessionLength}, + ${if (defaultTopic.autostartNextSession) 1 else 0}, + ${if (defaultTopic.dndEnabled) 1 else 0} + ) + """.trimIndent() + ) + } + } + ) + .build() } diff --git a/shared/src/commonMain/composeResources/drawable/add.xml b/shared/src/commonMain/composeResources/drawable/add.xml new file mode 100644 index 00000000..a0052f26 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/add.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/edit.xml b/shared/src/commonMain/composeResources/drawable/edit.xml new file mode 100644 index 00000000..00de7088 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/edit.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/grid_view.xml b/shared/src/commonMain/composeResources/drawable/grid_view.xml new file mode 100644 index 00000000..ce794814 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/grid_view.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/label.xml b/shared/src/commonMain/composeResources/drawable/label.xml new file mode 100644 index 00000000..39739aa1 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/label.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/list_view.xml b/shared/src/commonMain/composeResources/drawable/list_view.xml new file mode 100644 index 00000000..75f9e436 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/list_view.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/new_label.xml b/shared/src/commonMain/composeResources/drawable/new_label.xml new file mode 100644 index 00000000..f3b593a4 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/new_label.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 22c43be1..8f78b595 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -153,4 +153,6 @@ Long press the timer clock to enable infinite focus mode Cancel Advanced + Topics + Create new topic \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/Navigation.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/Navigation.kt index 57a51804..46d5f29c 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/Navigation.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/Navigation.kt @@ -28,12 +28,14 @@ import tomato.shared.generated.resources.black_theme import tomato.shared.generated.resources.color_scheme import tomato.shared.generated.resources.dnd import tomato.shared.generated.resources.durations +import tomato.shared.generated.resources.label import tomato.shared.generated.resources.media_volume_for_alarm import tomato.shared.generated.resources.palette import tomato.shared.generated.resources.sound import tomato.shared.generated.resources.theme import tomato.shared.generated.resources.timer import tomato.shared.generated.resources.timer_filled +import tomato.shared.generated.resources.topics import tomato.shared.generated.resources.vibrate val settingsScreens = listOf( @@ -43,6 +45,12 @@ val settingsScreens = listOf( Res.string.timer, listOf(Res.string.durations, Res.string.dnd, Res.string.always_on_display) ), + SettingsNavItem( + Screen.Settings.Topics, + Res.drawable.label, + Res.string.topics, + listOf() + ), SettingsNavItem( Screen.Settings.Alarm, Res.drawable.alarm, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/AppDatabase.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/AppDatabase.kt index ba3da5d6..10f5e7da 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/AppDatabase.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/AppDatabase.kt @@ -23,15 +23,16 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters @Database( - entities = [IntPreference::class, BooleanPreference::class, StringPreference::class, Stat::class], - version = 2, + entities = [IntPreference::class, BooleanPreference::class, StringPreference::class, Stat::class, Topic::class], + version = 3, autoMigrations = [ AutoMigration(from = 1, to = 2) ] ) -@TypeConverters(Converters::class) +@TypeConverters(LocalDateConverter::class, ComposeColorConverter::class) abstract class AppDatabase : RoomDatabase() { abstract fun preferenceDao(): PreferenceDao abstract fun statDao(): StatDao + abstract fun topicDao(): TopicDao abstract fun systemDao(): SystemDao } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Converters.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Converters.kt index d9231b26..dff87f52 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Converters.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Converters.kt @@ -17,10 +17,11 @@ package org.nsh07.pomodoro.data +import androidx.compose.ui.graphics.Color import androidx.room.TypeConverter import java.time.LocalDate -class Converters { +object LocalDateConverter { @TypeConverter fun localDateToString(localDate: LocalDate?): String? { return localDate?.toString() @@ -30,4 +31,16 @@ class Converters { fun stringToLocalDate(date: String?): LocalDate? { return if (date != null) LocalDate.parse(date) else null } +} + +object ComposeColorConverter { + @TypeConverter + fun fromColor(color: Color): Long { + return color.value.toLong() + } + + @TypeConverter + fun toColor(value: Long): Color { + return Color(value.toULong()) + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt new file mode 100644 index 00000000..acb6b08a --- /dev/null +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026 Nishant Mishra + * + * This file is part of Tomato - a minimalist pomodoro timer for Android. + * + * Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tomato. + * If not, see . + */ + +package org.nsh07.pomodoro.data + +import androidx.compose.ui.graphics.Color +import androidx.room.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL +import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic + +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(connection: SQLiteConnection) { + connection.execSQL( + """ + CREATE TABLE IF NOT EXISTS `topic` ( + `id` TEXT NOT NULL, + `name` TEXT NOT NULL, + `color` INTEGER NOT NULL, + `shape` TEXT NOT NULL, + `focusTime` INTEGER NOT NULL, + `shortBreakTime` INTEGER NOT NULL, + `longBreakTime` INTEGER NOT NULL, + `sessionLength` INTEGER NOT NULL, + `autostartNextSession` INTEGER NOT NULL, + `dndEnabled` INTEGER NOT NULL, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + + // Seed default topic with current global settings or hardcoded defaults if preferences don't exist + connection.execSQL( + """ + INSERT OR IGNORE INTO `topic` + (`id`, `name`, `color`, `shape`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autostartNextSession`, `dndEnabled`) + VALUES ( + '${defaultTopic.id}', + '${defaultTopic.name}', + ${Color.White.value.toLong()}, + '${defaultTopic.shape.name}', + COALESCE((SELECT value FROM int_preference WHERE key = 'focus_time'), 1500000), + COALESCE((SELECT value FROM int_preference WHERE key = 'short_break_time'), 300000), + COALESCE((SELECT value FROM int_preference WHERE key = 'long_break_time'), 900000), + COALESCE((SELECT value FROM int_preference WHERE key = 'session_length'), 4), + COALESCE((SELECT value FROM boolean_preference WHERE key = 'autostart_next_session'), 0), + COALESCE((SELECT value FROM boolean_preference WHERE key = 'dnd_enabled'), 0) + ) + """.trimIndent() + ) + + connection.execSQL( + """ + CREATE TABLE IF NOT EXISTS `new_stat` ( + `date` TEXT NOT NULL, + `topicId` TEXT NOT NULL, + `focusTimeQ1` INTEGER NOT NULL, + `focusTimeQ2` INTEGER NOT NULL, + `focusTimeQ3` INTEGER NOT NULL, + `focusTimeQ4` INTEGER NOT NULL, + `breakTime` INTEGER NOT NULL, + PRIMARY KEY(`date`, `topicId`), + FOREIGN KEY(`topicId`) REFERENCES `topic`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + connection.execSQL("CREATE INDEX IF NOT EXISTS `index_stat_topicId` ON `new_stat` (`topicId`)") + + // Copy existing data into new_stat, mapping to 'default' topic + connection.execSQL( + """ + INSERT INTO `new_stat` (`date`, `topicId`, `focusTimeQ1`, `focusTimeQ2`, `focusTimeQ3`, `focusTimeQ4`, `breakTime`) + SELECT `date`, '${defaultTopic.id}', `focusTimeQ1`, `focusTimeQ2`, `focusTimeQ3`, `focusTimeQ4`, `breakTime` FROM `stat` + """.trimIndent() + ) + + connection.execSQL("DROP TABLE `stat`") + connection.execSQL("ALTER TABLE `new_stat` RENAME TO `stat`") + + // Clean up migrated settings from global preference tables + connection.execSQL("DELETE FROM int_preference WHERE key IN ('focus_time', 'short_break_time', 'long_break_time', 'session_length')") + connection.execSQL("DELETE FROM boolean_preference WHERE key IN ('autostart_next_session', 'dnd_enabled')") + } +} diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Preference.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Preference.kt index 6169f748..568e6779 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Preference.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Preference.kt @@ -37,7 +37,7 @@ data class BooleanPreference( data class IntPreference( @PrimaryKey val key: String, - val value: Int + val value: Long ) /** diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/PreferenceDao.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/PreferenceDao.kt index e3893d9d..b8cfe27f 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/PreferenceDao.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/PreferenceDao.kt @@ -44,7 +44,7 @@ interface PreferenceDao { suspend fun resetStringPreferences() @Query("SELECT value FROM int_preference WHERE `key` = :key") - suspend fun getIntPreference(key: String): Int? + suspend fun getIntPreference(key: String): Long? @Query("SELECT value FROM boolean_preference WHERE `key` = :key") suspend fun getBooleanPreference(key: String): Boolean? diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/PreferenceRepository.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/PreferenceRepository.kt index e989c730..9bb1f39a 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/PreferenceRepository.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/PreferenceRepository.kt @@ -17,6 +17,7 @@ package org.nsh07.pomodoro.data +import androidx.compose.ui.graphics.Color import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext @@ -42,6 +43,8 @@ interface PreferenceRepository { */ suspend fun saveStringPreference(key: String, value: String): String + suspend fun saveColorPreference(key: String, value: Color): Color + /** * Retrieves an integer preference key-value pair from the database. */ @@ -62,6 +65,8 @@ interface PreferenceRepository { */ suspend fun getStringPreference(key: String): String? + suspend fun getColorPreference(key: String): Color? + /** * Retrieves a string preference key-value pair as a flow from the database. */ @@ -83,7 +88,7 @@ class AppPreferenceRepository( ) : PreferenceRepository { override suspend fun saveIntPreference(key: String, value: Int): Int = withContext(ioDispatcher) { - preferenceDao.insertIntPreference(IntPreference(key, value)) + preferenceDao.insertIntPreference(IntPreference(key, value.toLong())) value } @@ -99,8 +104,19 @@ class AppPreferenceRepository( value } + override suspend fun saveColorPreference(key: String, value: Color): Color = + withContext(ioDispatcher) { + preferenceDao.insertIntPreference( + IntPreference( + key, + ComposeColorConverter.fromColor(value) + ) + ) + value + } + override suspend fun getIntPreference(key: String): Int? = withContext(ioDispatcher) { - preferenceDao.getIntPreference(key) + preferenceDao.getIntPreference(key)?.toInt() } override suspend fun getBooleanPreference(key: String): Boolean? = withContext(ioDispatcher) { @@ -114,6 +130,10 @@ class AppPreferenceRepository( preferenceDao.getStringPreference(key) } + override suspend fun getColorPreference(key: String): Color? = withContext(ioDispatcher) { + preferenceDao.getIntPreference(key)?.let { ComposeColorConverter.toColor(it) } + } + override fun getStringPreferenceFlow(key: String): Flow = preferenceDao.getStringPreferenceFlow(key) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Stat.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Stat.kt index 5016d23c..f779eec1 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Stat.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Stat.kt @@ -19,7 +19,8 @@ package org.nsh07.pomodoro.data import androidx.compose.runtime.Immutable import androidx.room.Entity -import androidx.room.PrimaryKey +import androidx.room.ForeignKey +import androidx.room.Index import java.time.LocalDate /** @@ -28,10 +29,22 @@ import java.time.LocalDate * separately for later analysis (e.g. for showing which parts of the day are most productive). */ @Immutable -@Entity(tableName = "stat") +@Entity( + tableName = "stat", + primaryKeys = ["date", "topicId"], + foreignKeys = [ + ForeignKey( + entity = Topic::class, + parentColumns = ["id"], + childColumns = ["topicId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("topicId")] +) data class Stat( - @PrimaryKey val date: LocalDate, + val topicId: String, val focusTimeQ1: Long, val focusTimeQ2: Long, val focusTimeQ3: Long, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatDao.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatDao.kt index 214f8f38..71366cbf 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatDao.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatDao.kt @@ -29,25 +29,56 @@ interface StatDao { @Insert(onConflict = REPLACE) suspend fun insertStat(stat: Stat) - @Query("UPDATE stat SET focusTimeQ1 = focusTimeQ1 + :focusTime WHERE date = :date") - suspend fun addFocusTimeQ1(date: LocalDate, focusTime: Long) + @Query("UPDATE stat SET focusTimeQ1 = focusTimeQ1 + :focusTime WHERE date = :date AND topicId = :topicId") + suspend fun addFocusTimeQ1(date: LocalDate, topicId: String, focusTime: Long) - @Query("UPDATE stat SET focusTimeQ2 = focusTimeQ2 + :focusTime WHERE date = :date") - suspend fun addFocusTimeQ2(date: LocalDate, focusTime: Long) + @Query("UPDATE stat SET focusTimeQ2 = focusTimeQ2 + :focusTime WHERE date = :date AND topicId = :topicId") + suspend fun addFocusTimeQ2(date: LocalDate, topicId: String, focusTime: Long) - @Query("UPDATE stat SET focusTimeQ3 = focusTimeQ3 + :focusTime WHERE date = :date") - suspend fun addFocusTimeQ3(date: LocalDate, focusTime: Long) + @Query("UPDATE stat SET focusTimeQ3 = focusTimeQ3 + :focusTime WHERE date = :date AND topicId = :topicId") + suspend fun addFocusTimeQ3(date: LocalDate, topicId: String, focusTime: Long) - @Query("UPDATE stat SET focusTimeQ4 = focusTimeQ4 + :focusTime WHERE date = :date") - suspend fun addFocusTimeQ4(date: LocalDate, focusTime: Long) + @Query("UPDATE stat SET focusTimeQ4 = focusTimeQ4 + :focusTime WHERE date = :date AND topicId = :topicId") + suspend fun addFocusTimeQ4(date: LocalDate, topicId: String, focusTime: Long) - @Query("UPDATE stat SET breakTime = breakTime + :breakTime WHERE date = :date") - suspend fun addBreakTime(date: LocalDate, breakTime: Long) + @Query("UPDATE stat SET breakTime = breakTime + :breakTime WHERE date = :date AND topicId = :topicId") + suspend fun addBreakTime(date: LocalDate, topicId: String, breakTime: Long) - @Query("SELECT * FROM stat WHERE date = :date") + @Query( + """ + SELECT + date, + 'merged' as topicId, + SUM(focusTimeQ1) as focusTimeQ1, + SUM(focusTimeQ2) as focusTimeQ2, + SUM(focusTimeQ3) as focusTimeQ3, + SUM(focusTimeQ4) as focusTimeQ4, + SUM(breakTime) as breakTime + FROM stat WHERE date = :date + GROUP BY date + """ + ) fun getStat(date: LocalDate): Flow - @Query("SELECT * FROM stat ORDER BY date DESC LIMIT :n") + @Query("SELECT * FROM stat WHERE date = :date") + fun getStatsByDate(date: LocalDate): Flow> + + @Query( + """ + SELECT + date, + 'merged' as topicId, + SUM(focusTimeQ1) as focusTimeQ1, + SUM(focusTimeQ2) as focusTimeQ2, + SUM(focusTimeQ3) as focusTimeQ3, + SUM(focusTimeQ4) as focusTimeQ4, + SUM(breakTime) as breakTime + FROM stat + GROUP BY date + ORDER BY date DESC + LIMIT :n + """ + ) fun getLastNDaysStats(n: Int): Flow> @Query( @@ -58,23 +89,31 @@ interface StatDao { " CAST(AVG(focusTimeQ4) AS INTEGER) AS focusTimeQ4," + " CAST(AVG(breakTime) AS INTEGER) AS breakTime " + "FROM (" + - " SELECT * FROM stat" + - " ORDER BY date DESC" + + " SELECT " + + " date, " + + " SUM(focusTimeQ1) as focusTimeQ1, " + + " SUM(focusTimeQ2) as focusTimeQ2, " + + " SUM(focusTimeQ3) as focusTimeQ3, " + + " SUM(focusTimeQ4) as focusTimeQ4, " + + " SUM(breakTime) as breakTime " + + " FROM stat " + + " GROUP BY date " + + " ORDER BY date DESC " + " LIMIT :n" + - ")" + + ") " + "WHERE focusTimeQ1 > 0 OR focusTimeQ2 > 0 OR focusTimeQ3 > 0 OR focusTimeQ4 > 0" ) fun getLastNDaysAvgStats(n: Int): Flow - @Query("SELECT EXISTS (SELECT * FROM stat WHERE date = :date)") - suspend fun statExists(date: LocalDate): Boolean + @Query("SELECT EXISTS (SELECT * FROM stat WHERE date = :date AND topicId = :topicId)") + suspend fun statExists(date: LocalDate, topicId: String): Boolean @Query("SELECT date FROM stat ORDER BY date DESC LIMIT 1") suspend fun getLastDate(): LocalDate? - @Query("SELECT SUM(focusTimeQ1 + focusTimeQ2 + focusTimeQ3 + focusTimeQ4) FROM STAT") + @Query("SELECT SUM(focusTimeQ1 + focusTimeQ2 + focusTimeQ3 + focusTimeQ4) FROM stat") fun getAllTimeTotalFocusTime(): Flow @Query("DELETE FROM stat") suspend fun clearAll() -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatRepository.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatRepository.kt index 9bc6fa85..354204da 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatRepository.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatRepository.kt @@ -31,9 +31,9 @@ import java.time.LocalTime interface StatRepository { suspend fun insertStat(stat: Stat) - suspend fun addFocusTime(focusTime: Long) + suspend fun addFocusTime(topicId: String, focusTime: Long) - suspend fun addBreakTime(breakTime: Long) + suspend fun addBreakTime(topicId: String, breakTime: Long) fun getTodayStat(): Flow @@ -57,57 +57,59 @@ class AppStatRepository( ) : StatRepository { override suspend fun insertStat(stat: Stat) = statDao.insertStat(stat) - override suspend fun addFocusTime(focusTime: Long) = withContext(ioDispatcher) { - val currentDate = LocalDate.now() - val currentTime = LocalTime.now().toSecondOfDay() - val secondsInDay = 24 * 60 * 60 - - if (statDao.statExists(currentDate)) { - when (currentTime) { - in 0..(secondsInDay / 4) -> - statDao.addFocusTimeQ1(currentDate, focusTime) - - in (secondsInDay / 4)..(secondsInDay / 2) -> - statDao.addFocusTimeQ2(currentDate, focusTime) - - in (secondsInDay / 2)..(3 * secondsInDay / 4) -> - statDao.addFocusTimeQ3(currentDate, focusTime) - - else -> statDao.addFocusTimeQ4(currentDate, focusTime) - } - } else { - when (currentTime) { - in 0..(secondsInDay / 4) -> - statDao.insertStat( - Stat(currentDate, focusTime, 0, 0, 0, 0) - ) - - in (secondsInDay / 4)..(secondsInDay / 2) -> - statDao.insertStat( - Stat(currentDate, 0, focusTime, 0, 0, 0) - ) - - in (secondsInDay / 2)..(3 * secondsInDay / 4) -> - statDao.insertStat( - Stat(currentDate, 0, 0, focusTime, 0, 0) - ) - - else -> - statDao.insertStat( - Stat(currentDate, 0, 0, 0, focusTime, 0) - ) + override suspend fun addFocusTime(topicId: String, focusTime: Long) = + withContext(ioDispatcher) { + val currentDate = LocalDate.now() + val currentTime = LocalTime.now().toSecondOfDay() + val secondsInDay = 24 * 60 * 60 + + if (statDao.statExists(currentDate, topicId)) { + when (currentTime) { + in 0..(secondsInDay / 4) -> + statDao.addFocusTimeQ1(currentDate, topicId, focusTime) + + in (secondsInDay / 4)..(secondsInDay / 2) -> + statDao.addFocusTimeQ2(currentDate, topicId, focusTime) + + in (secondsInDay / 2)..(3 * secondsInDay / 4) -> + statDao.addFocusTimeQ3(currentDate, topicId, focusTime) + + else -> statDao.addFocusTimeQ4(currentDate, topicId, focusTime) + } + } else { + when (currentTime) { + in 0..(secondsInDay / 4) -> + statDao.insertStat( + Stat(currentDate, topicId, focusTime, 0, 0, 0, 0) + ) + + in (secondsInDay / 4)..(secondsInDay / 2) -> + statDao.insertStat( + Stat(currentDate, topicId, 0, focusTime, 0, 0, 0) + ) + + in (secondsInDay / 2)..(3 * secondsInDay / 4) -> + statDao.insertStat( + Stat(currentDate, topicId, 0, 0, focusTime, 0, 0) + ) + + else -> + statDao.insertStat( + Stat(currentDate, topicId, 0, 0, 0, focusTime, 0) + ) + } } } - } - override suspend fun addBreakTime(breakTime: Long) = withContext(ioDispatcher) { - val currentDate = LocalDate.now() - if (statDao.statExists(currentDate)) { - statDao.addBreakTime(currentDate, breakTime) - } else { - statDao.insertStat(Stat(currentDate, 0, 0, 0, 0, breakTime)) + override suspend fun addBreakTime(topicId: String, breakTime: Long) = + withContext(ioDispatcher) { + val currentDate = LocalDate.now() + if (statDao.statExists(currentDate, topicId)) { + statDao.addBreakTime(currentDate, topicId, breakTime) + } else { + statDao.insertStat(Stat(currentDate, topicId, 0, 0, 0, 0, breakTime)) + } } - } override fun getTodayStat(): Flow { val currentDate = LocalDate.now() @@ -126,4 +128,4 @@ class AppStatRepository( override suspend fun getLastDate(): LocalDate? = statDao.getLastDate() override suspend fun deleteAllStats() = statDao.clearAll() -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt index 556af93c..69b8c677 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic import org.nsh07.pomodoro.service.TimerStateSnapshot import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsState import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode @@ -31,9 +32,14 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState import org.nsh07.pomodoro.utils.getDefaultAlarmTone import org.nsh07.pomodoro.utils.millisecondsToStr -class StateRepository(private val preferenceRepository: PreferenceRepository) { +class StateRepository( + private val preferenceRepository: PreferenceRepository, + private val topicRepository: TopicRepository +) { val timerState = MutableStateFlow(TimerState()) val settingsState = MutableStateFlow(SettingsState()) + val currentTopic = MutableStateFlow(defaultTopic) + val time = MutableStateFlow(25 * 60 * 1000L) var timerFrequency: Float = 60f var colorScheme: ColorScheme = lightColorScheme() @@ -52,35 +58,13 @@ class StateRepository(private val preferenceRepository: PreferenceRepository) { suspend fun reloadSettings() { val defaults = SettingsState() - val focusTime = - preferenceRepository.getIntPreference("focus_time")?.toLong() - ?: preferenceRepository.saveIntPreference( - "focus_time", - defaults.focusTime.toInt() - ).toLong() - val shortBreakTime = - preferenceRepository.getIntPreference("short_break_time")?.toLong() - ?: preferenceRepository.saveIntPreference( - "short_break_time", - defaults.shortBreakTime.toInt() - ).toLong() - val longBreakTime = - preferenceRepository.getIntPreference("long_break_time")?.toLong() - ?: preferenceRepository.saveIntPreference( - "long_break_time", - defaults.longBreakTime.toInt() - ).toLong() + + currentTopic.update { topicRepository.getTopicById(currentTopic.value.id) ?: defaultTopic } + val focusGoal = preferenceRepository.getIntPreference("focus_goal")?.toLong() ?: preferenceRepository.saveIntPreference("focus_goal", defaults.focusGoal.toInt()) .toLong() - val sessionLength = - preferenceRepository.getIntPreference("session_length") - ?: preferenceRepository.saveIntPreference( - "session_length", - defaults.sessionLength - ) - val alarmSoundUri = ( preferenceRepository.getStringPreference("alarm_sound") ?: preferenceRepository.saveStringPreference( @@ -91,8 +75,8 @@ class StateRepository(private val preferenceRepository: PreferenceRepository) { val theme = preferenceRepository.getStringPreference("theme") ?: preferenceRepository.saveStringPreference("theme", defaults.theme) - val colorSchemeStr = preferenceRepository.getStringPreference("color_scheme") - ?: preferenceRepository.saveStringPreference("color_scheme", defaults.colorScheme) + val colorScheme = preferenceRepository.getColorPreference("color_scheme") + ?: preferenceRepository.saveColorPreference("color_scheme", defaults.colorScheme) val blackTheme = preferenceRepository.getBooleanPreference("black_theme") ?: preferenceRepository.saveBooleanPreference("black_theme", defaults.blackTheme) val aodEnabled = preferenceRepository.getBooleanPreference("aod_enabled") @@ -107,8 +91,6 @@ class StateRepository(private val preferenceRepository: PreferenceRepository) { "vibrate_enabled", defaults.vibrateEnabled ) - val dndEnabled = preferenceRepository.getBooleanPreference("dnd_enabled") - ?: preferenceRepository.saveBooleanPreference("dnd_enabled", defaults.dndEnabled) val mediaVolumeForAlarm = preferenceRepository.getBooleanPreference("media_volume_for_alarm") ?: preferenceRepository.saveBooleanPreference( @@ -120,12 +102,6 @@ class StateRepository(private val preferenceRepository: PreferenceRepository) { "single_progress_bar", defaults.singleProgressBar ) - val autostartNextSession = - preferenceRepository.getBooleanPreference("autostart_next_session") - ?: preferenceRepository.saveBooleanPreference( - "autostart_next_session", - defaults.autostartNextSession - ) val secureAod = preferenceRepository.getBooleanPreference("secure_aod") ?: preferenceRepository.saveBooleanPreference("secure_aod", defaults.secureAod) @@ -155,22 +131,16 @@ class StateRepository(private val preferenceRepository: PreferenceRepository) { settingsState.update { currentState -> currentState.copy( - focusTime = focusTime, - shortBreakTime = shortBreakTime, - longBreakTime = longBreakTime, focusGoal = focusGoal, - sessionLength = sessionLength, theme = theme, - colorScheme = colorSchemeStr, + colorScheme = colorScheme, alarmSoundUri = alarmSoundUri, blackTheme = blackTheme, aodEnabled = aodEnabled, alarmEnabled = alarmEnabled, vibrateEnabled = vibrateEnabled, - dndEnabled = dndEnabled, mediaVolumeForAlarm = mediaVolumeForAlarm, singleProgressBar = singleProgressBar, - autostartNextSession = autostartNextSession, secureAod = secureAod, vibrationOnDuration = vibrationOnDuration, vibrationOffDuration = vibrationOffDuration, @@ -181,19 +151,23 @@ class StateRepository(private val preferenceRepository: PreferenceRepository) { if (isFirstLoad) { isFirstLoad = false - val settings = settingsState.value - time.update { settings.focusTime } + val topic = currentTopic.value + time.update { topic.focusTime } timerState.update { currentState -> currentState.copy( timerMode = TimerMode.FOCUS, - timeStr = millisecondsToStr(settings.focusTime), - totalTime = settings.focusTime, - nextTimerMode = if (settings.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK, - nextTimeStr = millisecondsToStr(if (settings.sessionLength > 1) settings.shortBreakTime else settings.longBreakTime), + timeStr = millisecondsToStr(topic.focusTime), + totalTime = topic.focusTime, + nextTimerMode = if (topic.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK, + nextTimeStr = millisecondsToStr(if (topic.sessionLength > 1) topic.shortBreakTime else topic.longBreakTime), currentFocusCount = 1, - totalFocusCount = settings.sessionLength + totalFocusCount = topic.sessionLength ) } } } -} \ No newline at end of file + + fun setTopic(topic: Topic) { + currentTopic.update { topic } + } +} diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt new file mode 100644 index 00000000..96fc13c9 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026 Nishant Mishra + * + * This file is part of Tomato - a minimalist pomodoro timer for Android. + * + * Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tomato. + * If not, see . + */ + +package org.nsh07.pomodoro.data + +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.graphics.shapes.RoundedPolygon +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Entity representing a Topic with its own timer settings. + */ +@Entity(tableName = "topic") +data class Topic( + @PrimaryKey + val id: String, // name in lowercase, spaces replaced with underscores + val name: String, + val color: Color, + val shape: TopicShape, + val focusTime: Long, + val shortBreakTime: Long, + val longBreakTime: Long, + val sessionLength: Int, + val autostartNextSession: Boolean, + val dndEnabled: Boolean +) { + companion object { + val defaultTopic = Topic( + id = "default", + name = "Default", + color = Color.White, + shape = TopicShape.COOKIE_12_SIDED, + focusTime = 25 * 60 * 1000L, + shortBreakTime = 5 * 60 * 1000L, + longBreakTime = 15 * 60 * 1000L, + sessionLength = 4, + autostartNextSession = false, + dndEnabled = false + ) + } +} + +enum class TopicShape { + CIRCLE, SQUARE, SLANTED, ARCH, FAN, ARROW, SEMI_CIRCLE, OVAL, PILL, TRIANGLE, DIAMOND, + CLAM_SHELL, PENTAGON, GEM, SUNNY, VERY_SUNNY, COOKIE_4_SIDED, COOKIE_6_SIDED, COOKIE_7_SIDED, + COOKIE_9_SIDED, COOKIE_12_SIDED, GHOSTISH, CLOVER_4_LEAF, CLOVER_8_LEAF, BURST, SOFT_BURST, + BOOM, SOFT_BOOM, FLOWER, PUFFY, PUFFY_DIAMOND, PIXEL_CIRCLE, PIXEL_TRIANGLE, BUN, HEART; + + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + fun toRoundedPolygon(): RoundedPolygon { + return when (this) { + CIRCLE -> MaterialShapes.Circle + SQUARE -> MaterialShapes.Square + SLANTED -> MaterialShapes.Slanted + ARCH -> MaterialShapes.Arch + FAN -> MaterialShapes.Fan + ARROW -> MaterialShapes.Arrow + SEMI_CIRCLE -> MaterialShapes.SemiCircle + OVAL -> MaterialShapes.Oval + PILL -> MaterialShapes.Pill + TRIANGLE -> MaterialShapes.Triangle + DIAMOND -> MaterialShapes.Diamond + CLAM_SHELL -> MaterialShapes.ClamShell + PENTAGON -> MaterialShapes.Pentagon + GEM -> MaterialShapes.Gem + SUNNY -> MaterialShapes.Sunny + VERY_SUNNY -> MaterialShapes.VerySunny + COOKIE_4_SIDED -> MaterialShapes.Cookie4Sided + COOKIE_6_SIDED -> MaterialShapes.Cookie6Sided + COOKIE_7_SIDED -> MaterialShapes.Cookie7Sided + COOKIE_9_SIDED -> MaterialShapes.Cookie9Sided + COOKIE_12_SIDED -> MaterialShapes.Cookie12Sided + GHOSTISH -> MaterialShapes.Ghostish + CLOVER_4_LEAF -> MaterialShapes.Clover4Leaf + CLOVER_8_LEAF -> MaterialShapes.Clover8Leaf + BURST -> MaterialShapes.Burst + SOFT_BURST -> MaterialShapes.SoftBurst + BOOM -> MaterialShapes.Boom + SOFT_BOOM -> MaterialShapes.SoftBoom + FLOWER -> MaterialShapes.Flower + PUFFY -> MaterialShapes.Puffy + PUFFY_DIAMOND -> MaterialShapes.PuffyDiamond + PIXEL_CIRCLE -> MaterialShapes.PixelCircle + PIXEL_TRIANGLE -> MaterialShapes.PixelTriangle + BUN -> MaterialShapes.Bun + HEART -> MaterialShapes.Heart + } + } + + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + @Composable + fun toShape(startAngle: Int = 0) = this.toRoundedPolygon().toShape(startAngle) +} diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicDao.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicDao.kt new file mode 100644 index 00000000..68d6c6ff --- /dev/null +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicDao.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Nishant Mishra + * + * This file is part of Tomato - a minimalist pomodoro timer for Android. + * + * Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tomato. + * If not, see . + */ + +package org.nsh07.pomodoro.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +@Dao +interface TopicDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertTopic(topic: Topic): Long + + @Update + suspend fun updateTopic(topic: Topic) + + @Delete + suspend fun deleteTopic(topic: Topic) + + @Query("SELECT * FROM topic ORDER BY name ASC") + fun getAllTopics(): Flow> + + @Query("SELECT * FROM topic WHERE id = :id") + suspend fun getTopicById(id: String): Topic? + + @Query("SELECT id FROM topic") + suspend fun getTopicIds(): List +} diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicRepository.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicRepository.kt new file mode 100644 index 00000000..c51c028d --- /dev/null +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicRepository.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Nishant Mishra + * + * This file is part of Tomato - a minimalist pomodoro timer for Android. + * + * Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tomato. + * If not, see . + */ + +package org.nsh07.pomodoro.data + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +interface TopicRepository { + suspend fun insertTopic(topic: Topic): Long + suspend fun updateTopic(topic: Topic) + suspend fun deleteTopic(topic: Topic) + fun getAllTopics(): Flow> + suspend fun getTopicById(id: String): Topic? + suspend fun getTopicIds(): List +} + +class AppTopicRepository( + private val topicDao: TopicDao, + private val ioDispatcher: CoroutineDispatcher +) : TopicRepository { + override suspend fun insertTopic(topic: Topic): Long = withContext(ioDispatcher) { + topicDao.insertTopic(topic) + } + + override suspend fun updateTopic(topic: Topic) = withContext(ioDispatcher) { + topicDao.updateTopic(topic) + } + + override suspend fun deleteTopic(topic: Topic) = withContext(ioDispatcher) { + topicDao.deleteTopic(topic) + } + + override fun getAllTopics(): Flow> = topicDao.getAllTopics() + + override suspend fun getTopicById(id: String): Topic? = withContext(ioDispatcher) { + topicDao.getTopicById(id) + } + + override suspend fun getTopicIds(): List = withContext(ioDispatcher) { + topicDao.getTopicIds() + } +} diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt index 640cf404..450483d4 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt @@ -31,6 +31,7 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode import org.nsh07.pomodoro.utils.millisecondsToStr import tomato.shared.generated.resources.Res import tomato.shared.generated.resources.infinite +import kotlin.time.Duration.Companion.milliseconds class TimerManager( private val stateRepository: StateRepository, @@ -38,7 +39,7 @@ class TimerManager( private val currentTime: () -> Long, ) { private val _timerState by lazy { stateRepository.timerState } - private val _settingsState by lazy { stateRepository.settingsState } + private val _time = stateRepository.time /** @@ -107,17 +108,17 @@ class TimerManager( if (!_timerState.value.timerRunning) break if (startTime == 0L) startTime = currentTime() - val settingsState = _settingsState.value + val currentTopic = stateRepository.currentTopic.value val timerState = _timerState.value val focusTime = - if (!timerState.infiniteFocus) settingsState.focusTime else Long.MAX_VALUE + if (!timerState.infiniteFocus) currentTopic.focusTime else Long.MAX_VALUE time = when (_timerState.value.timerMode) { TimerMode.FOCUS -> focusTime - (currentTime() - startTime - pauseDuration) - TimerMode.SHORT_BREAK -> settingsState.shortBreakTime - (currentTime() - startTime - pauseDuration) + TimerMode.SHORT_BREAK -> currentTopic.shortBreakTime - (currentTime() - startTime - pauseDuration) - else -> settingsState.longBreakTime - (currentTime() - startTime - pauseDuration) + else -> currentTopic.longBreakTime - (currentTime() - startTime - pauseDuration) } val freq = stateRepository.timerFrequency.toInt().coerceAtLeast(1) @@ -159,7 +160,7 @@ class TimerManager( saveTimeToDb() } - delay((1000f / stateRepository.timerFrequency).toLong()) + delay((1000f / stateRepository.timerFrequency).toLong().milliseconds) } } } @@ -170,12 +171,15 @@ class TimerManager( suspend fun saveTimeToDb() { saveLock.withLock { val elapsedTime = _timerState.value.totalTime - time + val topicId = stateRepository.currentTopic.value.id when (_timerState.value.timerMode) { TimerMode.FOCUS -> statRepository.addFocusTime( + topicId, (elapsedTime - lastSavedDuration).coerceAtLeast(0L) ) else -> statRepository.addBreakTime( + topicId, (elapsedTime - lastSavedDuration).coerceAtLeast(0L) ) } @@ -188,7 +192,7 @@ class TimerManager( onCompletion: suspend () -> Unit, setDoNotDisturb: (Boolean) -> Unit ) { - val settingsState = _settingsState.value + val currentTopic = stateRepository.currentTopic.value saveTimeToDb() onStart() @@ -198,31 +202,31 @@ class TimerManager( pauseTime = 0L pauseDuration = 0L - cycles = (cycles + 1) % (settingsState.sessionLength * 2) + cycles = (cycles + 1) % (currentTopic.sessionLength * 2) if (cycles % 2 == 0) { _timerState.update { currentState -> if (currentState.timerRunning) setDoNotDisturb(true) - time = if (!currentState.infiniteFocus) settingsState.focusTime else Long.MAX_VALUE + time = if (!currentState.infiniteFocus) currentTopic.focusTime else Long.MAX_VALUE currentState.copy( timerMode = TimerMode.FOCUS, timeStr = if (!currentState.infiniteFocus) millisecondsToStr(time) else millisecondsToStr(0), totalTime = time, - nextTimerMode = if (cycles == (settingsState.sessionLength - 1) * 2) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK, - nextTimeStr = if (cycles == (settingsState.sessionLength - 1) * 2) millisecondsToStr( - settingsState.longBreakTime + nextTimerMode = if (cycles == (currentTopic.sessionLength - 1) * 2) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK, + nextTimeStr = if (cycles == (currentTopic.sessionLength - 1) * 2) millisecondsToStr( + currentTopic.longBreakTime ) else millisecondsToStr( - settingsState.shortBreakTime + currentTopic.shortBreakTime ), currentFocusCount = cycles / 2 + 1, - totalFocusCount = settingsState.sessionLength + totalFocusCount = currentTopic.sessionLength ) } } else { - val long = cycles == (settingsState.sessionLength * 2) - 1 - time = if (long) settingsState.longBreakTime else settingsState.shortBreakTime + val long = cycles == (currentTopic.sessionLength * 2) - 1 + time = if (long) currentTopic.longBreakTime else currentTopic.shortBreakTime _timerState.update { currentState -> if (currentState.timerRunning) setDoNotDisturb(false) @@ -233,7 +237,7 @@ class TimerManager( totalTime = time, nextTimerMode = TimerMode.FOCUS, nextTimeStr = if (!currentState.infiniteFocus) - millisecondsToStr(settingsState.focusTime) + millisecondsToStr(currentTopic.focusTime) else getString(Res.string.infinite) ) } @@ -243,7 +247,7 @@ class TimerManager( } suspend fun resetTimer(onCompletion: () -> Unit) { - val settingsState = _settingsState.value + val currentTopic = stateRepository.currentTopic.value val timerState = _timerState.value timerStateSnapshot.save( @@ -263,7 +267,7 @@ class TimerManager( pauseTime = 0L pauseDuration = 0L - time = if (!timerState.infiniteFocus) settingsState.focusTime else Long.MAX_VALUE + time = if (!timerState.infiniteFocus) currentTopic.focusTime else Long.MAX_VALUE _timerState.update { currentState -> currentState.copy( @@ -271,10 +275,10 @@ class TimerManager( timeStr = if (!currentState.infiniteFocus) millisecondsToStr(time) else millisecondsToStr(0), totalTime = time, - nextTimerMode = if (settingsState.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK, - nextTimeStr = millisecondsToStr(if (settingsState.sessionLength > 1) settingsState.shortBreakTime else settingsState.longBreakTime), + nextTimerMode = if (currentTopic.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK, + nextTimeStr = millisecondsToStr(if (currentTopic.sessionLength > 1) currentTopic.shortBreakTime else currentTopic.longBreakTime), currentFocusCount = 1, - totalFocusCount = settingsState.sessionLength + totalFocusCount = currentTopic.sessionLength ) } @@ -298,16 +302,4 @@ class TimerManager( fun resetLastSavedDuration() { lastSavedDuration = 0 } - - fun clear() { - cycles = 0 - startTime = 0L - pauseTime = 0L - pauseDuration = 0L - lastSavedDuration = 0L - - _timerState.update { currentState -> - currentState.copy(timerRunning = false) - } - } } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/AppScreen.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/AppScreen.kt index 851ec66d..f33ccd44 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/AppScreen.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/AppScreen.kt @@ -309,9 +309,13 @@ fun AppScreen( }, entryProvider = entryProvider { entry { + val topics by settingsViewModel.allTopics.collectAsStateWithLifecycle() + val currentTopic by timerViewModel.currentTopic.collectAsStateWithLifecycle() + TimerScreen( timerState = uiState, - settingsState = settingsState, + topics = topics, + currentTopic = currentTopic, isPlus = isPlus, contentPadding = contentPadding, progress = { progress }, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/Screen.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/Screen.kt index 108f9733..d278ea8d 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/Screen.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/Screen.kt @@ -48,6 +48,9 @@ sealed class Screen : NavKey { @Serializable object Timer : Settings() + + @Serializable + object Topics : Settings() } @Serializable diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt index 7332edea..cf7a00d1 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt @@ -50,6 +50,7 @@ import org.nsh07.pomodoro.ui.settingsScreen.screens.AlarmSettings import org.nsh07.pomodoro.ui.settingsScreen.screens.AppearanceSettings import org.nsh07.pomodoro.ui.settingsScreen.screens.SettingsMainScreen import org.nsh07.pomodoro.ui.settingsScreen.screens.TimerSettings +import org.nsh07.pomodoro.ui.settingsScreen.screens.TopicsSettings import org.nsh07.pomodoro.ui.settingsScreen.screens.backupRestore.BackupRestoreScreen import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors @@ -182,6 +183,9 @@ fun SettingsScreenRoot( entry( metadata = detailPane() ) { + val topics by viewModel.allTopics.collectAsStateWithLifecycle() + val editingTopic by viewModel.editingTopic.collectAsStateWithLifecycle() + TimerSettings( isPlus = isPlus, serviceRunning = serviceRunning, @@ -191,12 +195,34 @@ fun SettingsScreenRoot( shortBreakTimeInputFieldState = shortBreakTimeInputFieldState, longBreakTimeInputFieldState = longBreakTimeInputFieldState, sessionsSliderState = sessionsSliderState, + topics = topics, + editingTopic = editingTopic, onAction = viewModel::onAction, setShowPaywall = setShowPaywall, onBack = backStack::onBack, modifier = modifier, ) } + + entry( + metadata = detailPane() + ) { + val topics by viewModel.allTopics.collectAsStateWithLifecycle() + val editingTopic by viewModel.editingTopic.collectAsStateWithLifecycle() + + TopicsSettings( + topics = topics, + editingTopic = editingTopic, + serviceRunning = serviceRunning, + focusTimeInputFieldState = focusTimeInputFieldState, + shortBreakTimeInputFieldState = shortBreakTimeInputFieldState, + longBreakTimeInputFieldState = longBreakTimeInputFieldState, + sessionsSliderState = sessionsSliderState, + contentPadding = contentPadding, + onBack = backStack::onBack, + onAction = viewModel::onAction + ) + } }, modifier = Modifier.background(topBarColors.containerColor) ) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/components/TopicTimerSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/components/TopicTimerSettings.kt new file mode 100644 index 00000000..44220786 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/components/TopicTimerSettings.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2025-2026 Nishant Mishra + * + * This file is part of Tomato - a minimalist pomodoro timer for Android. + * + * Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tomato. + * If not, see . + */ + +package org.nsh07.pomodoro.ui.settingsScreen.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderState +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.text.input.ImeAction +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.nsh07.pomodoro.data.Topic +import org.nsh07.pomodoro.ui.rememberRequestDndPermissionCallback +import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsAction +import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors +import org.nsh07.pomodoro.ui.theme.CustomColors.switchColors +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.segmentedListItemShapes +import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape +import tomato.shared.generated.resources.Res +import tomato.shared.generated.resources.auto_start_next_timer +import tomato.shared.generated.resources.auto_start_next_timer_desc +import tomato.shared.generated.resources.autoplay +import tomato.shared.generated.resources.check +import tomato.shared.generated.resources.clear +import tomato.shared.generated.resources.clocks +import tomato.shared.generated.resources.dnd +import tomato.shared.generated.resources.dnd_desc +import tomato.shared.generated.resources.focus +import tomato.shared.generated.resources.long_break +import tomato.shared.generated.resources.session_length +import tomato.shared.generated.resources.session_length_desc +import tomato.shared.generated.resources.short_break + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun TopicTimerSettings( + topic: Topic, + serviceRunning: Boolean, + focusTimeInputFieldState: TextFieldState, + shortBreakTimeInputFieldState: TextFieldState, + longBreakTimeInputFieldState: TextFieldState, + sessionsSliderState: SliderState, + onAction: (SettingsAction) -> Unit, + modifier: Modifier = Modifier +) { + val requestDndPermissionCallback = rememberRequestDndPermissionCallback() + + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + ) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + stringResource(Res.string.focus), + style = typography.titleSmallEmphasized + ) + MinuteInputField( + state = focusTimeInputFieldState, + enabled = !serviceRunning, + shape = RoundedCornerShape( + topStart = topListItemShape.topStart, + bottomStart = topListItemShape.topStart, + topEnd = topListItemShape.bottomStart, + bottomEnd = topListItemShape.bottomStart + ), + inputTransformation = MinutesInputTransformation3Digits, + imeAction = ImeAction.Next + ) + } + Spacer(Modifier.width(2.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + stringResource(Res.string.short_break), + style = typography.titleSmallEmphasized + ) + MinuteInputField( + state = shortBreakTimeInputFieldState, + enabled = !serviceRunning, + shape = RoundedCornerShape(middleListItemShape.topStart), + imeAction = ImeAction.Next + ) + } + Spacer(Modifier.width(2.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + stringResource(Res.string.long_break), + style = typography.titleSmallEmphasized + ) + MinuteInputField( + state = longBreakTimeInputFieldState, + enabled = !serviceRunning, + shape = RoundedCornerShape( + topStart = bottomListItemShape.topStart, + bottomStart = bottomListItemShape.topStart, + topEnd = bottomListItemShape.bottomStart, + bottomEnd = bottomListItemShape.bottomStart + ), + imeAction = ImeAction.Done + ) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Column(Modifier.background(listItemColors.containerColor, topListItemShape)) { + ListItem( + leadingContent = { + Icon(painterResource(Res.drawable.clocks), null) + }, + headlineContent = { + Text(stringResource(Res.string.session_length)) + }, + supportingContent = { + Text( + stringResource( + Res.string.session_length_desc, + sessionsSliderState.value.toInt() + ) + ) + }, + colors = listItemColors, + modifier = Modifier.clip(cardShape) + ) + Slider( + state = sessionsSliderState, + enabled = !serviceRunning, + modifier = Modifier + .padding(start = (16 * 2 + 24).dp, end = 16.dp, bottom = 12.dp) + ) + } + + val switchItems = remember( + topic.autostartNextSession, + topic.dndEnabled + ) { + listOf( + SettingsSwitchItem( + checked = topic.autostartNextSession, + icon = Res.drawable.autoplay, + label = Res.string.auto_start_next_timer, + description = Res.string.auto_start_next_timer_desc, + onClick = { onAction(SettingsAction.SaveAutostartNextSession(it)) } + ), + SettingsSwitchItem( + checked = topic.dndEnabled, + enabled = !serviceRunning, + icon = Res.drawable.dnd, + label = Res.string.dnd, + description = Res.string.dnd_desc, + onClick = { + requestDndPermissionCallback(it) + onAction(SettingsAction.SaveDndEnabled(it)) + } + ) + ) + } + + switchItems.forEachIndexed { index, item -> + SegmentedListItem( + onClick = { item.onClick(!item.checked) }, + leadingContent = { + Icon( + painterResource(item.icon), + contentDescription = null, + modifier = Modifier.padding(top = 4.dp) + ) + }, + content = { Text(stringResource(item.label)) }, + supportingContent = { Text(stringResource(item.description)) }, + trailingContent = { + Switch( + checked = item.checked, + enabled = item.enabled, + onCheckedChange = { item.onClick(it) }, + thumbContent = { + if (item.checked) { + Icon( + painter = painterResource(Res.drawable.check), + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } else { + Icon( + painter = painterResource(Res.drawable.clear), + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } + }, + colors = switchColors + ) + }, + shapes = segmentedListItemShapes(index + 1, switchItems.size + 1), + colors = listItemColors + ) + } + } + } +} + +private data class SettingsSwitchItem( + val checked: Boolean, + val icon: DrawableResource, + val label: StringResource, + val description: StringResource, + val enabled: Boolean = true, + val onClick: (Boolean) -> Unit +) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/AppearanceSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/AppearanceSettings.kt index a7b64a11..d4b6a7f8 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/AppearanceSettings.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/AppearanceSettings.kt @@ -66,7 +66,6 @@ import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.PANE_MAX_WIDTH import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.segmentedListItemShapes import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.topBarWindowInsets -import org.nsh07.pomodoro.utils.toColor import tomato.shared.generated.resources.Res import tomato.shared.generated.resources.appearance import tomato.shared.generated.resources.arrow_back @@ -167,7 +166,7 @@ fun AppearanceSettings( item { ColorSchemePickerListItem( - color = settingsState.colorScheme.toColor(), + color = settingsState.colorScheme, items = 3, index = if (isPlus) 1 else 0, isPlus = isPlus, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt index dedf1b9e..b742ca4a 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt @@ -22,7 +22,6 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -37,11 +36,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.ButtonGroupDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalIconButton @@ -53,13 +52,15 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.motionScheme +import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider import androidx.compose.material3.SliderState import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.rememberSliderState @@ -73,20 +74,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource +import org.nsh07.pomodoro.data.Topic +import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic import org.nsh07.pomodoro.ui.mergePaddingValues -import org.nsh07.pomodoro.ui.rememberRequestDndPermissionCallback import org.nsh07.pomodoro.ui.settingsScreen.SettingsSwitchItem -import org.nsh07.pomodoro.ui.settingsScreen.components.MinuteInputField -import org.nsh07.pomodoro.ui.settingsScreen.components.MinutesInputTransformation3Digits import org.nsh07.pomodoro.ui.settingsScreen.components.PlusDivider import org.nsh07.pomodoro.ui.settingsScreen.components.SliderListItem +import org.nsh07.pomodoro.ui.settingsScreen.components.TopicTimerSettings import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsAction import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsState import org.nsh07.pomodoro.ui.theme.CustomColors.detailPaneTopBarColors @@ -99,40 +99,32 @@ 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.ui.timerScreen.harmonizeIf import org.nsh07.pomodoro.ui.topBarWindowInsets import org.nsh07.pomodoro.utils.androidSdkVersionAtLeast import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes import tomato.shared.generated.resources.Res +import tomato.shared.generated.resources.add import tomato.shared.generated.resources.always_on_display import tomato.shared.generated.resources.always_on_display_desc import tomato.shared.generated.resources.aod import tomato.shared.generated.resources.arrow_back -import tomato.shared.generated.resources.auto_start_next_timer -import tomato.shared.generated.resources.auto_start_next_timer_desc -import tomato.shared.generated.resources.autoplay import tomato.shared.generated.resources.back import tomato.shared.generated.resources.check import tomato.shared.generated.resources.clear -import tomato.shared.generated.resources.clocks import tomato.shared.generated.resources.daily_focus_goal -import tomato.shared.generated.resources.dnd -import tomato.shared.generated.resources.dnd_desc import tomato.shared.generated.resources.flag -import tomato.shared.generated.resources.focus import tomato.shared.generated.resources.hours_and_minutes_format import tomato.shared.generated.resources.info -import tomato.shared.generated.resources.long_break import tomato.shared.generated.resources.mobile_lock_portrait import tomato.shared.generated.resources.pomodoro_info import tomato.shared.generated.resources.secure_aod import tomato.shared.generated.resources.secure_aod_desc -import tomato.shared.generated.resources.session_length -import tomato.shared.generated.resources.session_length_desc import tomato.shared.generated.resources.session_only_progress import tomato.shared.generated.resources.session_only_progress_desc import tomato.shared.generated.resources.settings import tomato.shared.generated.resources.settings_infinite_focus_tip -import tomato.shared.generated.resources.short_break import tomato.shared.generated.resources.timer import tomato.shared.generated.resources.timer_settings_reset_info import tomato.shared.generated.resources.view_day @@ -148,65 +140,42 @@ fun TimerSettings( shortBreakTimeInputFieldState: TextFieldState, longBreakTimeInputFieldState: TextFieldState, sessionsSliderState: SliderState, + topics: List, + editingTopic: Topic, onAction: (SettingsAction) -> Unit, setShowPaywall: (Boolean) -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val colorScheme = colorScheme val widthExpanded = currentWindowAdaptiveInfo() .windowSizeClass .isWidthAtLeastBreakpoint(WIDTH_DP_EXPANDED_LOWER_BOUND) - val requestDndPermissionCallback = rememberRequestDndPermissionCallback() - val switchItems = remember( - settingsState.dndEnabled, settingsState.aodEnabled, - settingsState.autostartNextSession, settingsState.secureAod, isPlus, serviceRunning ) { listOf( - listOf( - SettingsSwitchItem( - checked = settingsState.autostartNextSession, - icon = Res.drawable.autoplay, - label = Res.string.auto_start_next_timer, - description = Res.string.auto_start_next_timer_desc, - onClick = { onAction(SettingsAction.SaveAutostartNextSession(it)) } - ), - SettingsSwitchItem( - checked = settingsState.dndEnabled, - enabled = !serviceRunning, - icon = Res.drawable.dnd, - label = Res.string.dnd, - description = Res.string.dnd_desc, - onClick = { - requestDndPermissionCallback(it) - onAction(SettingsAction.SaveDndEnabled(it)) - } - ) + SettingsSwitchItem( + checked = settingsState.aodEnabled, + enabled = isPlus, + icon = Res.drawable.aod, + label = Res.string.always_on_display, + description = Res.string.always_on_display_desc, + onClick = { onAction(SettingsAction.SaveAodEnabled(it)) } ), - listOf( - SettingsSwitchItem( - checked = settingsState.aodEnabled, - enabled = isPlus, - icon = Res.drawable.aod, - label = Res.string.always_on_display, - description = Res.string.always_on_display_desc, - onClick = { onAction(SettingsAction.SaveAodEnabled(it)) } - ), - SettingsSwitchItem( - checked = settingsState.secureAod && isPlus, - enabled = isPlus && settingsState.aodEnabled, - icon = Res.drawable.mobile_lock_portrait, - label = Res.string.secure_aod, - description = Res.string.secure_aod_desc, - onClick = { onAction(SettingsAction.SaveSecureAod(it)) } - ) + SettingsSwitchItem( + checked = settingsState.secureAod && isPlus, + enabled = isPlus && settingsState.aodEnabled, + icon = Res.drawable.mobile_lock_portrait, + label = Res.string.secure_aod, + description = Res.string.secure_aod_desc, + onClick = { onAction(SettingsAction.SaveSecureAod(it)) } ) ) } @@ -222,36 +191,90 @@ fun TimerSettings( ) { Scaffold( topBar = { - LargeFlexibleTopAppBar( - windowInsets = topBarWindowInsets(), - title = { - Text( - stringResource(Res.string.timer), - fontFamily = LocalAppFonts.current.topBarTitle - ) - }, - subtitle = { - Text(stringResource(Res.string.settings)) - }, - navigationIcon = { - if (!widthExpanded) - FilledTonalIconButton( - onClick = onBack, - shapes = IconButtonDefaults.shapes(), - colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = listItemColors.containerColor - ) + Column { + val isDefaultTopic = editingTopic.id == defaultTopic.id + val colors = ToggleButtonDefaults.toggleButtonColors( + containerColor = listItemColors.containerColor, + checkedContainerColor = remember(editingTopic.color) { + editingTopic.color.harmonizeIf( + colorScheme.secondaryContainer, + isDefaultTopic + ) + }, + checkedContentColor = remember(editingTopic.color) { + editingTopic.color.harmonizeIf( + colorScheme.onSecondaryContainer, + isDefaultTopic + ) + } + ) + + LargeFlexibleTopAppBar( + windowInsets = topBarWindowInsets(), + title = { + Text( + stringResource(Res.string.timer), + fontFamily = LocalAppFonts.current.topBarTitle + ) + }, + subtitle = { + Text(stringResource(Res.string.settings)) + }, + navigationIcon = { + if (!widthExpanded) + FilledTonalIconButton( + onClick = onBack, + shapes = IconButtonDefaults.shapes(), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = listItemColors.containerColor + ) + ) { + Icon( + painterResource(Res.drawable.arrow_back), + stringResource(Res.string.back) + + ) + } + }, + colors = barColors, + scrollBehavior = scrollBehavior + ) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), + contentPadding = PaddingValues(horizontal = 16.dp), + modifier = Modifier + .background(topBarColors.containerColor) + .padding(bottom = 4.dp) + ) { + itemsIndexed(topics, key = { _, item -> item.id }) { index, topic -> + ToggleButton( + checked = topic.id == editingTopic.id, + onCheckedChange = { onAction(SettingsAction.SetEditingTopic(topic)) }, + shapes = when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + colors = colors + ) { Text(topic.name) } + } + + item { + ToggleButton( + checked = false, + onCheckedChange = { }, + shapes = ButtonGroupDefaults.connectedTrailingButtonShapes(), + colors = colors ) { Icon( - painterResource(Res.drawable.arrow_back), - stringResource(Res.string.back) - + painterResource(Res.drawable.add), + null, + Modifier.size(IconButtonDefaults.extraSmallIconSize) ) } - }, - colors = barColors, - scrollBehavior = scrollBehavior - ) + } + } + } }, containerColor = barColors.containerColor, modifier = modifier @@ -296,103 +319,18 @@ fun TimerSettings( Spacer(Modifier.height(14.dp)) } item { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - stringResource(Res.string.focus), - style = typography.titleSmallEmphasized - ) - MinuteInputField( - state = focusTimeInputFieldState, - enabled = !serviceRunning, - shape = RoundedCornerShape( - topStart = topListItemShape.topStart, - bottomStart = topListItemShape.topStart, - topEnd = topListItemShape.bottomStart, - bottomEnd = topListItemShape.bottomStart - ), - inputTransformation = MinutesInputTransformation3Digits, - imeAction = ImeAction.Next - ) - } - Spacer(Modifier.width(2.dp)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - stringResource(Res.string.short_break), - style = typography.titleSmallEmphasized - ) - MinuteInputField( - state = shortBreakTimeInputFieldState, - enabled = !serviceRunning, - shape = RoundedCornerShape(middleListItemShape.topStart), - imeAction = ImeAction.Next - ) - } - Spacer(Modifier.width(2.dp)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - stringResource(Res.string.long_break), - style = typography.titleSmallEmphasized - ) - MinuteInputField( - state = longBreakTimeInputFieldState, - enabled = !serviceRunning, - shape = RoundedCornerShape( - topStart = bottomListItemShape.topStart, - bottomStart = bottomListItemShape.topStart, - topEnd = bottomListItemShape.bottomStart, - bottomEnd = bottomListItemShape.bottomStart - ), - imeAction = ImeAction.Done - ) - } - } - } - item { - Spacer(Modifier.height(12.dp)) - } - item { - Column(Modifier.background(listItemColors.containerColor, topListItemShape)) { - ListItem( - leadingContent = { - Icon(painterResource(Res.drawable.clocks), null) - }, - headlineContent = { - Text(stringResource(Res.string.session_length)) - }, - supportingContent = { - Text( - stringResource( - Res.string.session_length_desc, - sessionsSliderState.value.toInt() - ) - ) - }, - colors = listItemColors, - modifier = Modifier.clip(cardShape) - ) - Slider( - state = sessionsSliderState, - enabled = !serviceRunning, - modifier = Modifier - .padding(start = (16 * 2 + 24).dp, end = 16.dp, bottom = 12.dp) - ) - } + TopicTimerSettings( + topic = editingTopic, + serviceRunning = serviceRunning, + focusTimeInputFieldState = focusTimeInputFieldState, + shortBreakTimeInputFieldState = shortBreakTimeInputFieldState, + longBreakTimeInputFieldState = longBreakTimeInputFieldState, + sessionsSliderState = sessionsSliderState, + onAction = onAction + ) + Spacer(Modifier.height(14.dp)) } + item { val hmf = stringResource(Res.string.hours_and_minutes_format) SliderListItem( @@ -402,7 +340,7 @@ fun TimerSettings( label = stringResource(Res.string.daily_focus_goal), trailingLabel = { millisecondsToHoursMinutes(it.toLong(), hmf) }, icon = { Icon(painterResource(Res.drawable.flag), null) }, - shape = bottomListItemShape + shape = shapes.large ) { with(it.toLong()) { onAction(SettingsAction.SaveFocusGoal(this - (this % (30 * 60 * 1000)))) @@ -411,54 +349,9 @@ fun TimerSettings( } item { Spacer(Modifier.height(12.dp)) } - itemsIndexed(switchItems[0]) { index, item -> - ListItem( - leadingContent = { - Icon( - painterResource(item.icon), - contentDescription = null, - modifier = Modifier.padding(top = 4.dp) - ) - }, - headlineContent = { Text(stringResource(item.label)) }, - supportingContent = { Text(stringResource(item.description)) }, - trailingContent = { - Switch( - checked = item.checked, - enabled = item.enabled, - onCheckedChange = { item.onClick(it) }, - thumbContent = { - if (item.checked) { - Icon( - painter = painterResource(Res.drawable.check), - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize), - ) - } else { - Icon( - painter = painterResource(Res.drawable.clear), - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize), - ) - } - }, - colors = switchColors - ) - }, - colors = listItemColors, - modifier = Modifier.clip( - when (index) { - 0 -> topListItemShape - switchItems[0].size - 1 -> bottomListItemShape - else -> middleListItemShape - } - ) - ) - } - if (isPlus) { item { Spacer(Modifier.height(12.dp)) } - itemsIndexed(switchItems[1]) { index, item -> + itemsIndexed(switchItems) { index, item -> ListItem( leadingContent = { Icon( @@ -496,7 +389,7 @@ fun TimerSettings( modifier = Modifier.clip( when (index) { 0 -> topListItemShape - switchItems[1].size - 1 -> bottomListItemShape + switchItems.size - 1 -> bottomListItemShape else -> middleListItemShape } ) @@ -562,7 +455,7 @@ fun TimerSettings( item { PlusDivider(setShowPaywall) } - itemsIndexed(switchItems[1]) { index, item -> + itemsIndexed(switchItems) { index, item -> ListItem( leadingContent = { Icon( @@ -600,7 +493,7 @@ fun TimerSettings( modifier = Modifier.clip( when (index) { 0 -> topListItemShape - switchItems[1].size - 1 -> bottomListItemShape + switchItems.size - 1 -> bottomListItemShape else -> middleListItemShape } ) @@ -656,17 +549,26 @@ private fun TimerSettingsPreview() { valueRange = 1f..10f, steps = 8 ) - TimerSettings( - isPlus = false, - serviceRunning = false, - settingsState = remember { SettingsState() }, - contentPadding = PaddingValues(), - focusTimeInputFieldState = focusTimeInputFieldState, - shortBreakTimeInputFieldState = shortBreakTimeInputFieldState, - longBreakTimeInputFieldState = longBreakTimeInputFieldState, - sessionsSliderState = sessionsSliderState, - onAction = {}, - setShowPaywall = {}, - onBack = {} - ) + TomatoTheme(dynamicColor = false) { + TimerSettings( + isPlus = false, + serviceRunning = false, + settingsState = remember { SettingsState() }, + contentPadding = PaddingValues(), + focusTimeInputFieldState = focusTimeInputFieldState, + shortBreakTimeInputFieldState = shortBreakTimeInputFieldState, + longBreakTimeInputFieldState = longBreakTimeInputFieldState, + sessionsSliderState = sessionsSliderState, + topics = listOf( + defaultTopic, + defaultTopic.copy(id = "physics", name = "Physics"), + defaultTopic.copy(id = "math", name = "Math"), + defaultTopic.copy(id = "chemistry", name = "Chemistry") + ), + editingTopic = defaultTopic.copy(id = "math", name = "Math"), + onAction = {}, + setShowPaywall = {}, + onBack = {} + ) + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt new file mode 100644 index 00000000..93f7ae6b --- /dev/null +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt @@ -0,0 +1,668 @@ +/* + * Copyright (c) 2026 Nishant Mishra + * + * This file is part of Tomato - a minimalist pomodoro timer for Android. + * + * Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tomato. + * If not, see . + */ + +@file:OptIn(ExperimentalFoundationStyleApi::class) + +package org.nsh07.pomodoro.ui.settingsScreen.screens + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.animateBounds +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.style.ExperimentalFoundationStyleApi +import androidx.compose.foundation.style.MutableStyleState +import androidx.compose.foundation.style.Style +import androidx.compose.foundation.style.StyleScope +import androidx.compose.foundation.style.StyleStateKey +import androidx.compose.foundation.style.styleable +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FilledIconToggleButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LargeFlexibleTopAppBar +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.motionScheme +import androidx.compose.material3.MaterialTheme.shapes +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.SliderState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.rememberSliderState +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +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.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.lerp +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND +import com.materialkolor.ktx.harmonize +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.nsh07.pomodoro.data.Topic +import org.nsh07.pomodoro.data.TopicShape +import org.nsh07.pomodoro.ui.mergePaddingValues +import org.nsh07.pomodoro.ui.settingsScreen.components.TopicTimerSettings +import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsAction +import org.nsh07.pomodoro.ui.theme.CustomColors.detailPaneTopBarColors +import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors +import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors +import org.nsh07.pomodoro.ui.theme.LocalAppFonts +import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.PANE_MAX_WIDTH +import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.segmentedListItemShapes +import org.nsh07.pomodoro.ui.theme.TomatoTheme +import org.nsh07.pomodoro.ui.topBarWindowInsets +import tomato.shared.generated.resources.Res +import tomato.shared.generated.resources.add +import tomato.shared.generated.resources.arrow_back +import tomato.shared.generated.resources.back +import tomato.shared.generated.resources.create_new_topic +import tomato.shared.generated.resources.edit +import tomato.shared.generated.resources.minutes_format +import tomato.shared.generated.resources.settings + +val selectedKey = StyleStateKey(false) + +var MutableStyleState.selected: Boolean + get() = this[selectedKey] + set(value) { + this[selectedKey] = value + } + +fun StyleScope.selected(value: Style) { + state(selectedKey, value) { key, state -> state[key] } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun TopicsSettings( + topics: List, + editingTopic: Topic, + serviceRunning: Boolean, + focusTimeInputFieldState: TextFieldState, + shortBreakTimeInputFieldState: TextFieldState, + longBreakTimeInputFieldState: TextFieldState, + sessionsSliderState: SliderState, + contentPadding: PaddingValues, + onBack: () -> Unit, + onAction: (SettingsAction) -> Unit, + modifier: Modifier = Modifier +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val colorScheme = colorScheme + val motionScheme = motionScheme + val shapes = shapes + val topBarTitle = LocalAppFonts.current.topBarTitle + + val unselectedFont = typography.titleLargeEmphasized.copy( + fontFamily = typography.bodyLarge.fontFamily + ) + + val selectedFont = typography.titleLargeEmphasized.copy( + fontFamily = topBarTitle + ) + + val widthExpanded = currentWindowAdaptiveInfo() + .windowSizeClass + .isWidthAtLeastBreakpoint(WIDTH_DP_EXPANDED_LOWER_BOUND) + + val barColors = if (widthExpanded) detailPaneTopBarColors + else topBarColors + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .background(barColors.containerColor) + ) { + Scaffold( + topBar = { + LargeFlexibleTopAppBar( + windowInsets = topBarWindowInsets(), + title = { + Text( + "Topics", + fontFamily = topBarTitle + ) + }, + subtitle = { + Text(stringResource(Res.string.settings)) + }, + navigationIcon = { + if (!widthExpanded) + FilledTonalIconButton( + onClick = onBack, + shapes = IconButtonDefaults.shapes(), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = listItemColors.containerColor + ) + ) { + Icon( + painterResource(Res.drawable.arrow_back), + stringResource(Res.string.back) + ) + } + }, + colors = barColors, + scrollBehavior = scrollBehavior + ) + }, + containerColor = barColors.containerColor, + modifier = modifier + .widthIn(max = PANE_MAX_WIDTH) + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { innerPadding -> + val insets = mergePaddingValues(innerPadding, contentPadding) + val minFormat = stringResource(Res.string.minutes_format) + val lazyColumnState = rememberLazyListState() + var creatingTopic by remember { mutableStateOf(false) } + + SharedTransitionLayout { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(2.dp), + contentPadding = insets, + state = lazyColumnState, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + item { + val styleState = remember { MutableStyleState(null) } + styleState.selected = creatingTopic + + Box( + modifier = Modifier + .styleable(styleState) { + shape(RoundedCornerShape(40.dp)) + clip(true) + background(colorScheme.primary) + selected { animate { background(colorScheme.surfaceBright) } } + } + .animateBounds( + lookaheadScope = this@SharedTransitionLayout, + boundsTransform = BoundsTransform { _, _ -> + motionScheme.slowSpatialSpec() + } + ) + .clickable { creatingTopic = !creatingTopic }, + contentAlignment = Alignment.Center + ) { + val newTopicText = stringResource(Res.string.create_new_topic) + AnimatedContent(creatingTopic) { + if (!it) Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .styleable { + size(40.dp) + shape(CircleShape) + background(colorScheme.onPrimary) + } + ) { + val shape = MaterialShapes.Boom.toShape() + Box( + Modifier.styleable { + size(22.dp) + shape(shape) + background(colorScheme.onPrimaryContainer) + } + ) + } + Text( + newTopicText, + style = typography.bodyLargeEmphasized, + color = colorScheme.onPrimary, + modifier = Modifier + .sharedBounds( + rememberSharedContentState(newTopicText), + this@AnimatedContent + ) + .weight(1f) + ) + FilledIconButton( + onClick = { creatingTopic = true }, + shapes = IconButtonDefaults.shapes(), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = colorScheme.primaryContainer + ), + modifier = Modifier.size( + IconButtonDefaults.smallContainerSize(IconButtonDefaults.IconButtonWidthOption.Wide) + ) + ) { Icon(painterResource(Res.drawable.add), null) } + } + else Column( + Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + Text( + newTopicText, + style = typography.titleLargeEmphasized, + fontFamily = topBarTitle, + modifier = Modifier + .sharedBounds( + rememberSharedContentState(newTopicText), + this@AnimatedContent + ) + ) + Spacer(Modifier.height(400.dp)) + } + } + } + } + item { + HorizontalDivider( + thickness = 4.dp, + color = colorScheme.primary, + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) + } + itemsIndexed(topics, key = { _, topic -> topic.id }) { index, topic -> + val selected = topic.id == editingTopic.id && !creatingTopic + val shape = topic.shape.toShape() + + val styleState = remember { MutableStyleState(null) } + styleState.selected = selected + + val primary = remember(topic.color) { + topic.color.let { + if (it == Color.White) colorScheme.primary + else it.harmonize(colorScheme.primary, true) + } + } + val onPrimary = remember(topic.color) { + topic.color.let { + if (it == Color.White) colorScheme.onPrimary + else it.harmonize(colorScheme.onPrimary, true) + } + } + val primaryContainer = remember(topic.color) { + topic.color.let { + if (it == Color.White) colorScheme.primaryContainer + else it.harmonize(colorScheme.primaryContainer, true) + } + } + val onPrimaryContainer = remember(topic.color) { + topic.color.let { + if (it == Color.White) colorScheme.onPrimaryContainer + else it.harmonize(colorScheme.onPrimaryContainer, true) + } + } + val surfaceBright = remember(topic.color) { + topic.color.let { + if (it == Color.White) colorScheme.surfaceBright + else it.harmonize(colorScheme.surfaceBright, true) + } + } + val surfaceContainer = remember(topic.color) { + topic.color.let { + if (it == Color.White) colorScheme.surfaceContainer + else it.harmonize(colorScheme.surfaceContainer, true) + } + } + + val progress by animateFloatAsState( + if (selected) 1f else 0f, + animationSpec = motionScheme.defaultEffectsSpec() + ) + val titleFontFamily: TextStyle by remember(progress) { + derivedStateOf { + lerp(unselectedFont, selectedFont, progress) + } + } + + SegmentedListItem( + checked = selected, + onCheckedChange = { onAction(SettingsAction.SetEditingTopic(topic)) }, + shapes = segmentedListItemShapes( + index, + topics.size + ), + colors = listItemColors.copy( + containerColor = surfaceBright, + selectedContainerColor = primaryContainer + ), + verticalAlignment = Alignment.CenterVertically, + leadingContent = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .styleable(styleState) { + externalPaddingVertical(4.dp) + size(72.dp) + shape(CircleShape) + background(primaryContainer) + selected { animate { background(primary) } } + } + ) { + Box( + Modifier + .styleable(styleState) { + size(40.dp) + shape(shape) + background(primary) + selected { animate { background(onPrimary) } } + } + ) + } + }, + supportingContent = { + Text( + "${ + String.format( + minFormat, + topic.focusTime / 60000 + ) + } / ${ + String.format( + minFormat, + topic.shortBreakTime / 60000 + ) + } / ${ + String.format( + minFormat, + topic.longBreakTime / 60000 + ) + }, ${topic.sessionLength} timers", + style = typography.labelLarge, + color = colorScheme.onSecondaryContainer + ) + }, + trailingContent = { + FilledIconToggleButton( + checked = selected, + onCheckedChange = { + onAction(SettingsAction.SetEditingTopic(topic)) + }, + colors = IconButtonDefaults.filledIconToggleButtonColors( + containerColor = surfaceContainer, + checkedContainerColor = primary, + checkedContentColor = onPrimary + ), + shapes = IconButtonDefaults.toggleableShapes(checkedShape = shapes.large), + modifier = Modifier.size(IconButtonDefaults.mediumContainerSize()) + ) { + Icon( + painterResource(Res.drawable.edit), + null, + modifier = Modifier.size(IconButtonDefaults.mediumIconSize) + ) + } + }, + modifier = Modifier.styleable(styleState) { + externalPaddingTop(0.dp) + selected { animate { externalPaddingTop(2.dp) } } + } + ) { + Text( + topic.name, + style = titleFontFamily, + color = animateColorAsState( + if (!selected) colorScheme.onSurface + else onPrimaryContainer + ).value + ) + } + + AnimatedVisibility( + selected, + enter = expandVertically(motionScheme.slowSpatialSpec()), + exit = shrinkVertically(motionScheme.slowSpatialSpec()) + ) { + TopicTimerSettings( + topic = topic, + serviceRunning = serviceRunning, + focusTimeInputFieldState = focusTimeInputFieldState, + shortBreakTimeInputFieldState = shortBreakTimeInputFieldState, + longBreakTimeInputFieldState = longBreakTimeInputFieldState, + sessionsSliderState = sessionsSliderState, + onAction = onAction, + modifier = Modifier.padding(top = 8.dp, bottom = 2.dp) + ) + } + } + } + } + } + } +} + +@Preview +@Composable +fun TopicsSettingsPreview() { + var editingTopic by remember { + mutableStateOf(sampleTopics[5]) + } + TomatoTheme(dynamicColor = false) { + TopicsSettings( + topics = sampleTopics, + editingTopic = editingTopic, + serviceRunning = false, + focusTimeInputFieldState = TextFieldState("25"), + shortBreakTimeInputFieldState = TextFieldState("5"), + longBreakTimeInputFieldState = TextFieldState("15"), + sessionsSliderState = rememberSliderState(4f, valueRange = 1f..10f), + contentPadding = PaddingValues(0.dp), + onBack = {}, + onAction = { editingTopic = (it as SettingsAction.SetEditingTopic).topic } + ) + } +} + +@Preview +@Composable +fun TopicsSettingsDarkPreview() { + var editingTopic by remember { + mutableStateOf(sampleTopics[5]) + } + TomatoTheme(darkTheme = true, dynamicColor = false) { + TopicsSettings( + topics = sampleTopics, + editingTopic = editingTopic, + serviceRunning = false, + focusTimeInputFieldState = TextFieldState("25"), + shortBreakTimeInputFieldState = TextFieldState("5"), + longBreakTimeInputFieldState = TextFieldState("15"), + sessionsSliderState = rememberSliderState(4f, valueRange = 1f..10f), + contentPadding = PaddingValues(0.dp), + onBack = {}, + onAction = { editingTopic = (it as SettingsAction.SetEditingTopic).topic } + ) + } +} + +val sampleTopics = listOf( + Topic( + id = "default", + name = "Default", + color = Color.White, + shape = TopicShape.COOKIE_12_SIDED, + focusTime = 25 * 60000L, + shortBreakTime = 5 * 60000L, + longBreakTime = 15 * 60000L, + sessionLength = 4, + autostartNextSession = false, + dndEnabled = false + ), + Topic( + id = "work", + name = "Work", + color = Color(0xFF2196F3), + shape = TopicShape.SQUARE, + focusTime = 50 * 60000L, + shortBreakTime = 10 * 60000L, + longBreakTime = 30 * 60000L, + sessionLength = 3, + autostartNextSession = true, + dndEnabled = true + ), + Topic( + id = "study", + name = "Study", + color = Color(0xFF4CAF50), + shape = TopicShape.TRIANGLE, + focusTime = 45 * 60000L, + shortBreakTime = 5 * 60000L, + longBreakTime = 20 * 60000L, + sessionLength = 4, + autostartNextSession = false, + dndEnabled = true + ), + Topic( + id = "fitness", + name = "Fitness", + color = Color(0xFFF44336), + shape = TopicShape.CIRCLE, + focusTime = 30 * 60000L, + shortBreakTime = 2 * 60000L, + longBreakTime = 10 * 60000L, + sessionLength = 6, + autostartNextSession = true, + dndEnabled = false + ), + Topic( + id = "coding", + name = "Coding", + color = Color(0xFF9C27B0), + shape = TopicShape.DIAMOND, + focusTime = 60 * 60000L, + shortBreakTime = 10 * 60000L, + longBreakTime = 40 * 60000L, + sessionLength = 2, + autostartNextSession = false, + dndEnabled = true + ), + Topic( + id = "reading", + name = "Reading", + color = Color(0xFFFF9800), + shape = TopicShape.PENTAGON, + focusTime = 20 * 60000L, + shortBreakTime = 3 * 60000L, + longBreakTime = 15 * 60000L, + sessionLength = 5, + autostartNextSession = true, + dndEnabled = false + ), + Topic( + id = "meditation", + name = "Meditation", + color = Color(0xFF00BCD4), + shape = TopicShape.SUNNY, + focusTime = 15 * 60000L, + shortBreakTime = 0, + longBreakTime = 0, + sessionLength = 1, + autostartNextSession = false, + dndEnabled = true + ), + Topic( + id = "gaming", + name = "Gaming", + color = Color(0xFF795548), + shape = TopicShape.BOOM, + focusTime = 120 * 60000L, + shortBreakTime = 15 * 60000L, + longBreakTime = 60 * 60000L, + sessionLength = 2, + autostartNextSession = false, + dndEnabled = false + ), + Topic( + id = "chores", + name = "Chores", + color = Color(0xFF607D8B), + shape = TopicShape.FLOWER, + focusTime = 10 * 60000L, + shortBreakTime = 2 * 60000L, + longBreakTime = 5 * 60000L, + sessionLength = 10, + autostartNextSession = true, + dndEnabled = false + ), + Topic( + id = "music", + name = "Music", + color = Color(0xFFFFE082), + shape = TopicShape.HEART, + focusTime = 35 * 60000L, + shortBreakTime = 5 * 60000L, + longBreakTime = 15 * 60000L, + sessionLength = 4, + autostartNextSession = false, + dndEnabled = false + ), + Topic( + id = "travel", + name = "Travel", + color = Color(0xFF8BC34A), + shape = TopicShape.PILL, + focusTime = 40 * 60000L, + shortBreakTime = 8 * 60000L, + longBreakTime = 25 * 60000L, + sessionLength = 3, + autostartNextSession = true, + dndEnabled = true + ) +) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsAction.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsAction.kt index fe00a7be..2855eca1 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsAction.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsAction.kt @@ -18,6 +18,7 @@ package org.nsh07.pomodoro.ui.settingsScreen.viewModel import androidx.compose.ui.graphics.Color +import org.nsh07.pomodoro.data.Topic sealed interface SettingsAction { data class SaveAlarmEnabled(val enabled: Boolean) : SettingsAction @@ -39,6 +40,8 @@ sealed interface SettingsAction { data class SaveVibrationOffDuration(val duration: Long) : SettingsAction data class SaveVibrationAmplitude(val amplitude: Int) : SettingsAction + data class SetEditingTopic(val topic: Topic) : SettingsAction + data object AskEraseData : SettingsAction data object CancelEraseData : SettingsAction data object EraseData : SettingsAction diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt index 61b1add7..5d2131e4 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt @@ -26,30 +26,22 @@ import org.nsh07.pomodoro.utils.getDefaultAlarmTone @Immutable data class SettingsState( val theme: String = "auto", - val colorScheme: String = Color.White.toString(), + val colorScheme: Color = Color.White, val blackTheme: Boolean = false, val aodEnabled: Boolean = false, val alarmEnabled: Boolean = true, val vibrateEnabled: Boolean = true, - val dndEnabled: Boolean = false, val mediaVolumeForAlarm: Boolean = false, val singleProgressBar: Boolean = false, - val autostartNextSession: Boolean = false, val secureAod: Boolean = true, val isShowingEraseDataDialog: Boolean = false, val vibrationOnDuration: Long = 1000L, val vibrationOffDuration: Long = 1000L, val vibrationAmplitude: Int = -1, - - val focusTime: Long = 25 * 60 * 1000L, - val shortBreakTime: Long = 5 * 60 * 1000L, - val longBreakTime: Long = 15 * 60 * 1000L, val focusGoal: Long = 0L, - val sessionLength: Int = 4, - val alarmSoundUri: String? = getDefaultAlarmTone(), - val customWindowDecor: Boolean = currentOS != OS.WINDOWS + val customWindowDecor: Boolean = currentOS != OS.WINDOWS, ) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt index 24a8a2f8..a08771df 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt @@ -18,6 +18,7 @@ package org.nsh07.pomodoro.ui.settingsScreen.viewModel import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SliderState import androidx.compose.runtime.mutableStateListOf @@ -36,17 +37,21 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import org.nsh07.pomodoro.billing.BillingManager import org.nsh07.pomodoro.data.PreferenceRepository import org.nsh07.pomodoro.data.StatRepository import org.nsh07.pomodoro.data.StateRepository +import org.nsh07.pomodoro.data.Topic +import org.nsh07.pomodoro.data.TopicRepository import org.nsh07.pomodoro.service.TimerHelper import org.nsh07.pomodoro.ui.Screen import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode import org.nsh07.pomodoro.utils.logError import org.nsh07.pomodoro.utils.millisecondsToStr +import kotlin.time.Duration.Companion.milliseconds @OptIn(FlowPreview::class, ExperimentalMaterial3Api::class) class SettingsViewModel( @@ -54,6 +59,7 @@ class SettingsViewModel( private val preferenceRepository: PreferenceRepository, private val stateRepository: StateRepository, private val statRepository: StatRepository, + private val topicRepository: TopicRepository, private val timerHelper: TimerHelper ) : ViewModel() { private val time: MutableStateFlow = stateRepository.time @@ -73,25 +79,38 @@ class SettingsViewModel( private val _settingsState = stateRepository.settingsState val settingsState = _settingsState.asStateFlow() + private val _currentTopic = stateRepository.currentTopic + + private val _editingTopic = MutableStateFlow(_currentTopic.value) + val editingTopic = _editingTopic.asStateFlow() + val focusTimeTextFieldState by lazy { - TextFieldState((_settingsState.value.focusTime / 60000).toString()) + TextFieldState((_currentTopic.value.focusTime / 60000).toString()) } val shortBreakTimeTextFieldState by lazy { - TextFieldState((_settingsState.value.shortBreakTime / 60000).toString()) + TextFieldState((_currentTopic.value.shortBreakTime / 60000).toString()) } val longBreakTimeTextFieldState by lazy { - TextFieldState((_settingsState.value.longBreakTime / 60000).toString()) + TextFieldState((_currentTopic.value.longBreakTime / 60000).toString()) } val sessionsSliderState by lazy { SliderState( - value = _settingsState.value.sessionLength.toFloat(), + value = _currentTopic.value.sessionLength.toFloat(), steps = 8, valueRange = 1f..10f, onValueChangeFinished = ::updateSessionLength ) } + val allTopics = topicRepository + .getAllTopics() + .map { list -> + list.sortedBy { it.name } + } + .flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + private var focusFlowCollectionJob: Job? = null private var shortBreakFlowCollectionJob: Job? = null private var longBreakFlowCollectionJob: Job? = null @@ -117,12 +136,22 @@ class SettingsViewModel( is SettingsAction.SaveVibrationOffDuration -> saveVibrationOffDuration(action.duration) is SettingsAction.SaveVibrationAmplitude -> saveVibrationAmplitude(action.amplitude) + is SettingsAction.SetEditingTopic -> setEditingTopic(action.topic) + is SettingsAction.AskEraseData -> askEraseData() is SettingsAction.CancelEraseData -> cancelEraseData() is SettingsAction.EraseData -> deleteStats() } } + fun setEditingTopic(topic: Topic) { + _editingTopic.update { topic } + focusTimeTextFieldState.setTextAndPlaceCursorAtEnd((topic.focusTime / (60 * 1000)).toString()) + shortBreakTimeTextFieldState.setTextAndPlaceCursorAtEnd((topic.shortBreakTime / (60 * 1000)).toString()) + longBreakTimeTextFieldState.setTextAndPlaceCursorAtEnd((topic.longBreakTime / (60 * 1000)).toString()) + sessionsSliderState.value = topic.sessionLength.toFloat() + } + private fun cancelEraseData() { viewModelScope.launch(Dispatchers.IO) { _settingsState.update { currentState -> @@ -141,15 +170,14 @@ class SettingsViewModel( private fun updateSessionLength() { viewModelScope.launch(Dispatchers.IO) { - _settingsState.update { currentState -> - currentState.copy( - sessionLength = preferenceRepository.saveIntPreference( - "session_length", - sessionsSliderState.value.toInt() - ) - ) + val value = sessionsSliderState.value.toInt() + + val topic = _editingTopic.updateAndGet { it.copy(sessionLength = value) } + if (topic.id == _currentTopic.value.id) { + stateRepository.setTopic(topic) + refreshTimer() } - refreshTimer() + topicRepository.updateTopic(topic) } } @@ -167,49 +195,49 @@ class SettingsViewModel( fun runTextFieldFlowCollection() { focusFlowCollectionJob = viewModelScope.launch(Dispatchers.IO) { snapshotFlow { focusTimeTextFieldState.text } - .debounce(500) + .debounce(500.milliseconds) .collect { if (it.isNotEmpty()) { - _settingsState.update { currentState -> - currentState.copy(focusTime = it.toString().toLong() * 60 * 1000) + val value = it.toString().toLong() * 60 * 1000 + + val topic = _editingTopic.updateAndGet { it.copy(focusTime = value) } + if (topic.id == _currentTopic.value.id) { + stateRepository.setTopic(topic) + refreshTimer() } - refreshTimer() - preferenceRepository.saveIntPreference( - "focus_time", - _settingsState.value.focusTime.toInt() - ) + topicRepository.updateTopic(topic) } } } shortBreakFlowCollectionJob = viewModelScope.launch(Dispatchers.IO) { snapshotFlow { shortBreakTimeTextFieldState.text } - .debounce(500) + .debounce(500.milliseconds) .collect { if (it.isNotEmpty()) { - _settingsState.update { currentState -> - currentState.copy(shortBreakTime = it.toString().toLong() * 60 * 1000) + val value = it.toString().toLong() * 60 * 1000 + + val topic = _editingTopic.updateAndGet { it.copy(shortBreakTime = value) } + if (topic.id == _currentTopic.value.id) { + stateRepository.setTopic(topic) + refreshTimer() } - refreshTimer() - preferenceRepository.saveIntPreference( - "short_break_time", - _settingsState.value.shortBreakTime.toInt() - ) + topicRepository.updateTopic(topic) } } } longBreakFlowCollectionJob = viewModelScope.launch(Dispatchers.IO) { snapshotFlow { longBreakTimeTextFieldState.text } - .debounce(500) + .debounce(500.milliseconds) .collect { if (it.isNotEmpty()) { - _settingsState.update { currentState -> - currentState.copy(longBreakTime = it.toString().toLong() * 60 * 1000) + val value = it.toString().toLong() * 60 * 1000 + + val topic = _editingTopic.updateAndGet { it.copy(longBreakTime = value) } + if (topic.id == _currentTopic.value.id) { + stateRepository.setTopic(topic) + refreshTimer() } - refreshTimer() - preferenceRepository.saveIntPreference( - "long_break_time", - _settingsState.value.longBreakTime.toInt() - ) + topicRepository.updateTopic(topic) } } } @@ -260,10 +288,11 @@ class SettingsViewModel( private fun saveDndEnabled(enabled: Boolean) { viewModelScope.launch { - _settingsState.update { currentState -> - currentState.copy(dndEnabled = enabled) + val topic = _editingTopic.updateAndGet { it.copy(dndEnabled = enabled) } + if (topic.id == _currentTopic.value.id) { + stateRepository.setTopic(topic) } - preferenceRepository.saveBooleanPreference("dnd_enabled", enabled) + topicRepository.updateTopic(topic) } } @@ -279,9 +308,9 @@ class SettingsViewModel( private fun saveColorScheme(colorScheme: Color) { viewModelScope.launch { _settingsState.update { currentState -> - currentState.copy(colorScheme = colorScheme.toString()) + currentState.copy(colorScheme = colorScheme) } - preferenceRepository.saveStringPreference("color_scheme", colorScheme.toString()) + preferenceRepository.saveColorPreference("color_scheme", colorScheme) } } @@ -338,13 +367,12 @@ class SettingsViewModel( private fun saveAutostartNextSession(autostartNextSession: Boolean) { viewModelScope.launch { - _settingsState.update { currentState -> - currentState.copy(autostartNextSession = autostartNextSession) + val topic = + _editingTopic.updateAndGet { it.copy(autostartNextSession = autostartNextSession) } + if (topic.id == _currentTopic.value.id) { + stateRepository.setTopic(topic) } - preferenceRepository.saveBooleanPreference( - "autostart_next_session", - autostartNextSession - ) + topicRepository.updateTopic(topic) } } @@ -398,22 +426,22 @@ class SettingsViewModel( private fun refreshTimer() { if (!serviceRunning.value) { - val settingsState = _settingsState.value + val currentTopic = _currentTopic.value val infFocus = stateRepository.timerState.value.infiniteFocus - if (!infFocus) time.update { settingsState.focusTime } + if (!infFocus) time.update { currentTopic.focusTime } if (!infFocus) stateRepository.timerState.update { currentState -> currentState.copy( timerMode = TimerMode.FOCUS, timeStr = millisecondsToStr(time.value), totalTime = time.value, - nextTimerMode = if (settingsState.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK, - nextTimeStr = millisecondsToStr(if (settingsState.sessionLength > 1) settingsState.shortBreakTime else settingsState.longBreakTime), + nextTimerMode = if (currentTopic.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK, + nextTimeStr = millisecondsToStr(if (currentTopic.sessionLength > 1) currentTopic.shortBreakTime else currentTopic.longBreakTime), currentFocusCount = 1, - totalFocusCount = settingsState.sessionLength + totalFocusCount = currentTopic.sessionLength ) } } } -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/components/FocusHistoryCalendar.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/components/FocusHistoryCalendar.kt index 4fc95b57..cc55fcdb 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/components/FocusHistoryCalendar.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/components/FocusHistoryCalendar.kt @@ -239,9 +239,10 @@ private fun FocusHistoryCalendarPreview() { val random = Random.nextInt() % 3 if (random == 0) Stat( - date, 0, 0, 0, 0, 0 + date, "default", 0, 0, 0, 0, 0 ) else Stat( date = date, + topicId = "default", focusTimeQ1 = quarterTime, focusTimeQ2 = quarterTime, focusTimeQ3 = quarterTime, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/components/HeatmapWithWeekLabels.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/components/HeatmapWithWeekLabels.kt index 55553fed..c6b2cb80 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/components/HeatmapWithWeekLabels.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/components/HeatmapWithWeekLabels.kt @@ -201,7 +201,8 @@ fun HeatmapWithWeekLabelsPreview() { buildList { (0..93).forEach { index -> val date = startDate.plusDays(index.toLong()) - val focusStat = Stat(date, index % 10L / 2, 0, 0, 0, 0) // Varying focus durations + val focusStat = + Stat(date, "default", index % 10L / 2, 0, 0, 0, 0) // Varying focus durations if (date.month != date.minusDays(1).month && index > 0) repeat(7) { add(null) } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt index e2cd0476..87b66eba 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt @@ -42,8 +42,10 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.nsh07.pomodoro.data.Stat import org.nsh07.pomodoro.data.StatRepository +import org.nsh07.pomodoro.data.TopicRepository import org.nsh07.pomodoro.di.AppInfo import org.nsh07.pomodoro.ui.Screen +import org.nsh07.pomodoro.ui.settingsScreen.screens.sampleTopics import org.nsh07.pomodoro.utils.OS import org.nsh07.pomodoro.utils.currentOS import java.time.DayOfWeek @@ -54,6 +56,7 @@ import java.util.Locale class StatsViewModel( private val statRepository: StatRepository, + private val topicRepository: TopicRepository, private val appInfo: AppInfo, ) : ViewModel() { val backStack = mutableStateListOf(Screen.Stats.Main) @@ -289,21 +292,28 @@ class StatsViewModel( fun generateSampleData() { if (appInfo.debug) { viewModelScope.launch { - val today = LocalDate.now().plusDays(1) - var it = today.minusDays(365) + sampleTopics.take(5).forEach { topic -> + topicRepository.insertTopic(topic) - while (it.isBefore(today)) { - statRepository.insertStat( - Stat( - it, - (0..30 * 60 * 1000L).random(), - (1 * 60 * 60 * 1000L..3 * 60 * 60 * 1000L).random(), - (0..3 * 60 * 60 * 1000L).random(), - (0..1 * 60 * 60 * 1000L).random(), - (0..100 * 60 * 1000L).random() - ) - ) - it = it.plusDays(1) + val today = LocalDate.now().plusDays(1) + var it = today.minusDays(365) + + while (it.isBefore(today)) { + if ((0..10).random() > 2) { + statRepository.insertStat( + Stat( + it, + topic.id, + (0..30 * 60 * 1000L).random(), + (1 * 60 * 60 * 1000L..3 * 60 * 60 * 1000L).random(), + (0..3 * 60 * 60 * 1000L).random(), + (0..1 * 60 * 60 * 1000L).random(), + (0..100 * 60 * 1000L).random() + ) + ) + } + it = it.plusDays(1) + } } } } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/theme/Shape.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/theme/Shape.kt index 7fe7a1fd..7fd36e49 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/theme/Shape.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/theme/Shape.kt @@ -59,14 +59,15 @@ object TomatoShapeDefaults { fun segmentedListItemShapes( index: Int, count: Int, - singleElement: Boolean = count == 1 + singleElement: Boolean = count == 1, + selectedShape: CornerBasedShape = shapes.extraLargeIncreased ): ListItemShapes = ListItemDefaults.segmentedShapes( index, count, ListItemDefaults.shapes( shape = if (singleElement) shapes.large else shapes.extraSmall, - selectedShape = shapes.extraLargeIncreased, + selectedShape = selectedShape, pressedShape = shapes.extraLargeIncreased, focusedShape = shapes.large, hoveredShape = shapes.extraLarge, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/theme/Type.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/theme/Type.kt index cbe44285..6d8eb340 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/theme/Type.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/theme/Type.kt @@ -32,31 +32,30 @@ val TYPOGRAPHY = Typography() data class AppFonts( val topBarTitle: FontFamily, + val unselectedTopicTitle: FontFamily, val annotatedString: FontFamily ) @Composable fun typography(): Typography { - val googleFlex400 = FontFamily( - Font( - Res.font.google_sans_flex, - FontWeight.Normal, - variationSettings = FontVariation.Settings(FontVariation.weight(400)) - ) + val googleFlex400Font = Font( + Res.font.google_sans_flex, + FontWeight.Normal, + variationSettings = FontVariation.Settings(FontVariation.weight(400)) ) + val googleFlex400 = remember(googleFlex400Font) { FontFamily(googleFlex400Font) } - val googleFlex600 = FontFamily( - Font( - Res.font.google_sans_flex, - FontWeight.Bold, - variationSettings = FontVariation.Settings( - FontVariation.weight(600), - FontVariation.Setting("ROND", 100f) - ) + val googleFlex600Font = Font( + Res.font.google_sans_flex, + FontWeight.Bold, + variationSettings = FontVariation.Settings( + FontVariation.weight(600), + FontVariation.Setting("ROND", 100f) ) ) + val googleFlex600 = remember(googleFlex600Font) { FontFamily(googleFlex600Font) } - return remember { + return remember(googleFlex400, googleFlex600) { Typography( displayLarge = TYPOGRAPHY.displayLarge.copy( fontFamily = googleFlex600, @@ -124,35 +123,48 @@ fun typography(): Typography { @Composable fun getAppFonts(): AppFonts { - val robotoFlexTopBar = FontFamily( - Font( - Res.font.google_sans_flex, - variationSettings = FontVariation.Settings( - FontVariation.weight(900), - FontVariation.width(112.5f), - FontVariation.Setting("ROND", 35f) - ) + val robotoFlexTopBarFont = Font( + Res.font.google_sans_flex, + variationSettings = FontVariation.Settings( + FontVariation.weight(900), + FontVariation.width(112.5f), + FontVariation.Setting("ROND", 35f) ) ) + val robotoFlexTopBar = remember(robotoFlexTopBarFont) { FontFamily(robotoFlexTopBarFont) } - val annotatedStringFontFamily = FontFamily( - Font( - Res.font.google_sans_flex, - FontWeight.Normal, - variationSettings = FontVariation.Settings(FontVariation.weight(400)) - ), - Font( - Res.font.google_sans_flex, - FontWeight.Bold, - variationSettings = FontVariation.Settings( - FontVariation.weight(600), - FontVariation.Setting("ROND", 100f) - ) - ) // Used for tags + val unselectedTopicTitleFont = Font( + Res.font.google_sans_flex, + variationSettings = FontVariation.Settings( + FontVariation.weight(600), + FontVariation.width(100f), + FontVariation.Setting("ROND", 35f) + ) ) + val unselectedTopicTitle = + remember(unselectedTopicTitleFont) { FontFamily(unselectedTopicTitleFont) } + + val annotatedStringNormalFont = Font( + Res.font.google_sans_flex, + FontWeight.Normal, + variationSettings = FontVariation.Settings(FontVariation.weight(400)) + ) + val annotatedStringBoldFont = Font( + Res.font.google_sans_flex, + FontWeight.Bold, + variationSettings = FontVariation.Settings( + FontVariation.weight(600), + FontVariation.Setting("ROND", 100f) + ) + ) // Used for tags + + val annotatedStringFontFamily = remember(annotatedStringNormalFont, annotatedStringBoldFont) { + FontFamily(annotatedStringNormalFont, annotatedStringBoldFont) + } return AppFonts( topBarTitle = robotoFlexTopBar, + unselectedTopicTitle = unselectedTopicTitle, annotatedString = annotatedStringFontFamily ) } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt new file mode 100644 index 00000000..05abd245 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt @@ -0,0 +1,802 @@ +/* + * Copyright (c) 2025-2026 Nishant Mishra + * + * This file is part of Tomato - a minimalist pomodoro timer for Android. + * + * Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tomato. + * If not, see . + */ + +package org.nsh07.pomodoro.ui.timerScreen + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.ButtonGroup +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.DropdownMenuGroup +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenuPopup +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledIconToggleButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.FilledTonalIconToggleButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.motionScheme +import androidx.compose.material3.MaterialTheme.shapes +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEachIndexed +import androidx.navigation3.ui.LocalNavAnimatedContentScope +import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND +import com.materialkolor.ktx.harmonize +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.nsh07.pomodoro.data.Topic +import org.nsh07.pomodoro.ui.rememberRequestNotificationPermissionCallback +import org.nsh07.pomodoro.ui.settingsScreen.screens.sampleTopics +import org.nsh07.pomodoro.ui.theme.LocalAppFonts +import org.nsh07.pomodoro.ui.theme.TomatoTheme +import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction +import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode +import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState +import org.nsh07.pomodoro.ui.topBarWindowInsets +import org.nsh07.pomodoro.utils.androidSdkVersionAtLeast +import tomato.shared.generated.resources.Res +import tomato.shared.generated.resources.app_name +import tomato.shared.generated.resources.app_name_plus +import tomato.shared.generated.resources.focus +import tomato.shared.generated.resources.infinite_focus +import tomato.shared.generated.resources.label +import tomato.shared.generated.resources.long_break +import tomato.shared.generated.resources.new_label +import tomato.shared.generated.resources.pause +import tomato.shared.generated.resources.pause_large +import tomato.shared.generated.resources.play +import tomato.shared.generated.resources.play_large +import tomato.shared.generated.resources.restart +import tomato.shared.generated.resources.restart_large +import tomato.shared.generated.resources.short_break +import tomato.shared.generated.resources.skip_next +import tomato.shared.generated.resources.skip_next_large +import tomato.shared.generated.resources.skip_to_next +import tomato.shared.generated.resources.timer_reset_message +import tomato.shared.generated.resources.timer_session_count +import tomato.shared.generated.resources.timer_settings_reset_info +import tomato.shared.generated.resources.undo +import tomato.shared.generated.resources.up_next + +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalMaterial3AdaptiveApi::class +) +@Composable +fun SharedTransitionScope.TimerMainPane( + timerState: TimerState, + topics: List, + currentTopic: Topic, + isPlus: Boolean, + contentPadding: PaddingValues, + progress: () -> Float, + onAction: (TimerAction) -> Unit, + modifier: Modifier = Modifier +) { + val motionScheme = motionScheme + val scope = rememberCoroutineScope() + val haptic = LocalHapticFeedback.current + val defaultTopic = currentTopic.id == Topic.defaultTopic.id + val cc = currentTopic.color + + val fraction by animateFloatAsState( + targetValue = if (timerState.timerMode == TimerMode.FOCUS) 1f else 0f, + animationSpec = motionScheme.slowEffectsSpec() + ) + + val color = lerp( + cc.harmonizeIf(colorScheme.tertiary, defaultTopic), + cc.harmonizeIf(colorScheme.primary, defaultTopic), + fraction + ) + val onColor = lerp( + cc.harmonizeIf(colorScheme.onTertiary, defaultTopic), + cc.harmonizeIf(colorScheme.onPrimary, defaultTopic), + fraction + ) + val colorContainer = lerp( + cc.harmonizeIf(colorScheme.tertiaryContainer, defaultTopic), + cc.harmonizeIf(colorScheme.secondaryContainer, defaultTopic), + fraction + ) + val onColorContainer = lerp( + cc.harmonizeIf(colorScheme.onTertiaryContainer, defaultTopic), + cc.harmonizeIf(colorScheme.onSecondaryContainer, defaultTopic), + fraction + ) + + val clockFontSize by animateFloatAsState( + targetValue = if (!timerState.infiniteFocus) { + if (timerState.timeStr.length < 6) 72f else 64f + } else { + if (timerState.timeStr.length < 6) 100f else 88f + }, + animationSpec = motionScheme.defaultSpatialSpec() + ) + + val widthExpanded = currentWindowAdaptiveInfo() + .windowSizeClass + .isWidthAtLeastBreakpoint(WIDTH_DP_EXPANDED_LOWER_BOUND) + + val requestNotificationPermissionCallback = rememberRequestNotificationPermissionCallback() + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + topBar = { + TopAppBar( + windowInsets = topBarWindowInsets(), + title = { + AnimatedContent( + if (!timerState.showBrandTitle) timerState.timerMode else TimerMode.BRAND, + transitionSpec = { + slideInVertically( + animationSpec = motionScheme.defaultSpatialSpec(), + initialOffsetY = { (-it * 1.25).toInt() } + ).togetherWith( + slideOutVertically( + animationSpec = motionScheme.defaultSpatialSpec(), + targetOffsetY = { (it * 1.25).toInt() } + ) + ) + }, + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth(.9f) + ) { + when (it) { + TimerMode.BRAND -> + Text( + if (!isPlus) stringResource(Res.string.app_name) + else stringResource(Res.string.app_name_plus), + style = TextStyle( + fontFamily = LocalAppFonts.current.topBarTitle, + fontSize = 32.sp, + lineHeight = 32.sp, + color = colorScheme.error + ), + textAlign = TextAlign.Center + ) + + TimerMode.FOCUS -> + AnimatedContent(timerState.infiniteFocus) { inf -> + Text( + if (inf) stringResource(Res.string.infinite_focus) + else stringResource(Res.string.focus), + style = TextStyle( + fontFamily = LocalAppFonts.current.topBarTitle, + fontSize = 32.sp, + lineHeight = 32.sp, + color = color + ), + textAlign = TextAlign.Center + ) + } + + TimerMode.SHORT_BREAK -> Text( + stringResource(Res.string.short_break), + style = TextStyle( + fontFamily = LocalAppFonts.current.topBarTitle, + fontSize = 32.sp, + lineHeight = 32.sp, + color = color + ), + textAlign = TextAlign.Center + ) + + TimerMode.LONG_BREAK -> Text( + stringResource(Res.string.long_break), + style = TextStyle( + fontFamily = LocalAppFonts.current.topBarTitle, + fontSize = 32.sp, + lineHeight = 32.sp, + color = colorScheme.tertiary + ), + textAlign = TextAlign.Center + ) + } + } + }, + subtitle = { + AnimatedContent(currentTopic.name) { + Text(it) + } + }, + actions = { + var expanded by remember { mutableStateOf(false) } + FilledTonalIconToggleButton( + checked = expanded, + onCheckedChange = { expanded = it }, + shapes = IconButtonDefaults.toggleableShapes() + ) { Icon(painterResource(Res.drawable.label), null) } + + DropdownMenuPopup( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuGroup( + shapes = MenuDefaults.groupShape(0, 2), + containerColor = MenuDefaults.groupStandardContainerColor + ) { + topics.fastForEachIndexed { index, topic -> + Box { + DropdownMenuItem( + checked = topic.id == currentTopic.id, + onCheckedChange = { + expanded = false + onAction(TimerAction.SetTopic(topic)) + }, + text = { Text(topic.name) }, + shapes = MenuDefaults.itemShape(index, topics.size), + colors = MenuDefaults.selectableItemColors() + ) + } + } + } + + Spacer(Modifier.height(MenuDefaults.GroupSpacing)) + + DropdownMenuGroup( + shapes = MenuDefaults.groupShape(1, 2), + containerColor = MenuDefaults.groupStandardContainerColor + ) { + MaterialShapes.Bun + Box { + DropdownMenuItem( + onClick = {}, + text = { Text("Add new topic") }, + leadingIcon = { + Icon( + painterResource(Res.drawable.new_label), + null + ) + }, + shape = MenuDefaults.trailingItemShape, + colors = MenuDefaults.itemColors() + ) + } + } + } + }, + navigationIcon = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(IconButtonDefaults.smallContainerSize()) + ) { + Box( + Modifier + .size(IconButtonDefaults.largeIconSize) + .background(color, currentTopic.shape.toShape()) + ) + } + }, + titleHorizontalAlignment = CenterHorizontally, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), + scrollBehavior = scrollBehavior + ) + }, + bottomBar = { Spacer(Modifier.height(contentPadding.calculateBottomPadding())) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + ) { innerPadding -> + LazyColumn( + verticalArrangement = Arrangement.Center, + horizontalAlignment = CenterHorizontally, + contentPadding = innerPadding, + modifier = Modifier.fillMaxSize() + ) { + item { + Column(horizontalAlignment = CenterHorizontally) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .widthIn(max = 350.dp) + .aspectRatio(1f), + ) { + this@Column.AnimatedVisibility( + !timerState.infiniteFocus, + enter = fadeIn(motionScheme.defaultEffectsSpec()) + + scaleIn(motionScheme.defaultSpatialSpec(), 4f), + exit = fadeOut(motionScheme.defaultEffectsSpec()) + + scaleOut(motionScheme.defaultSpatialSpec(), 4f) + ) { + if (timerState.timerMode == TimerMode.FOCUS) { + CircularProgressIndicator( + progress = progress, + modifier = Modifier + .sharedBounds( + sharedContentState = this@TimerMainPane.rememberSharedContentState( + "focus progress" + ), + animatedVisibilityScope = LocalNavAnimatedContentScope.current + ) + .fillMaxWidth(0.9f) + .aspectRatio(1f), + color = color, + trackColor = colorContainer, + strokeWidth = 16.dp, + gapSize = 8.dp + ) + } else { + CircularWavyProgressIndicator( + progress = progress, + modifier = Modifier + .sharedBounds( + sharedContentState = this@TimerMainPane.rememberSharedContentState( + "break progress" + ), + animatedVisibilityScope = LocalNavAnimatedContentScope.current + ) + .fillMaxWidth(0.9f) + .aspectRatio(1f), + color = color, + trackColor = colorContainer, + stroke = Stroke( + width = with(LocalDensity.current) { + 16.dp.toPx() + }, + cap = StrokeCap.Round, + ), + trackStroke = Stroke( + width = with(LocalDensity.current) { + 16.dp.toPx() + }, + cap = StrokeCap.Round, + ), + wavelength = 60.dp, + gapSize = 8.dp + ) + } + } + var expanded by remember { mutableStateOf(timerState.showBrandTitle) } + val timerResetSettingsInfo = + stringResource(Res.string.timer_settings_reset_info) + Column( + horizontalAlignment = CenterHorizontally, + modifier = Modifier + .clip(shapes.largeIncreased) + .combinedClickable( + onClick = { expanded = !expanded }, + onLongClick = { + if (!timerState.timerRunning) onAction( + TimerAction.SetInfiniteFocus( + !timerState.infiniteFocus + ) + ) + else scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar( + timerResetSettingsInfo, + duration = SnackbarDuration.Short + ) + } + } + ) + ) { + LaunchedEffect(timerState.showBrandTitle) { + expanded = timerState.showBrandTitle + } + Text( + text = timerState.timeStr, + style = TextStyle( + fontFamily = typography.bodyLarge.fontFamily, + fontSize = clockFontSize.sp, + letterSpacing = (-2.6).sp, + fontFeatureSettings = "tnum" + ), + textAlign = TextAlign.Center, + maxLines = 1, + autoSize = TextAutoSize.StepBased( + maxFontSize = clockFontSize.sp + ), + modifier = Modifier + .fillMaxWidth() + .sharedBounds( + sharedContentState = this@TimerMainPane.rememberSharedContentState( + "clock" + ), + animatedVisibilityScope = LocalNavAnimatedContentScope.current + ) + ) + AnimatedVisibility( + expanded, + enter = fadeIn(motionScheme.defaultEffectsSpec()) + + expandVertically(motionScheme.defaultSpatialSpec()), + exit = fadeOut(motionScheme.defaultEffectsSpec()) + + shrinkVertically(motionScheme.defaultSpatialSpec()) + ) { + Text( + stringResource( + Res.string.timer_session_count, + timerState.currentFocusCount, + timerState.totalFocusCount + ), + fontFamily = typography.bodyLarge.fontFamily, + style = typography.titleLarge, + color = colorScheme.outline + ) + } + } + } + val interactionSources = + remember { List(3) { MutableInteractionSource() } } + ButtonGroup( + overflowIndicator = { state -> + ButtonGroupDefaults.OverflowIndicator( + state, + colors = IconButtonDefaults.filledTonalIconButtonColors(), + modifier = Modifier.size(64.dp, 96.dp) + ) + }, + modifier = Modifier.padding(16.dp) + ) { + customItem( + { + FilledIconToggleButton( + onCheckedChange = { checked -> + onAction(TimerAction.ToggleTimer) + + if (checked) haptic.performHapticFeedback( + HapticFeedbackType.ToggleOn + ) + else haptic.performHapticFeedback( + HapticFeedbackType.ToggleOff + ) + + if (androidSdkVersionAtLeast(33) && checked) { + requestNotificationPermissionCallback() + } + }, + checked = timerState.timerRunning, + colors = IconButtonDefaults.filledIconToggleButtonColors( + checkedContainerColor = color, + checkedContentColor = onColor + ), + shapes = IconButtonDefaults.toggleableShapes(), + interactionSource = interactionSources[0], + modifier = Modifier + .size(width = 128.dp, height = 96.dp) + .animateWidth(interactionSources[0]) + ) { + if (timerState.timerRunning) { + Icon( + painterResource(Res.drawable.pause_large), + contentDescription = stringResource(Res.string.pause), + modifier = Modifier.size(32.dp) + ) + } else { + Icon( + painterResource(Res.drawable.play_large), + contentDescription = stringResource(Res.string.play), + modifier = Modifier.size(32.dp) + ) + } + } + }, + { state -> + DropdownMenuItem( + leadingIcon = { + if (timerState.timerRunning) { + Icon( + painterResource(Res.drawable.pause), + contentDescription = stringResource(Res.string.pause) + ) + } else { + Icon( + painterResource(Res.drawable.play), + contentDescription = stringResource(Res.string.play) + ) + } + }, + text = { + Text( + if (timerState.timerRunning) stringResource( + Res.string.pause + ) else stringResource( + Res.string.play + ) + ) + }, + onClick = { + onAction(TimerAction.ToggleTimer) + state.dismiss() + } + ) + } + ) + + customItem( + { + val timerResetMessage = + stringResource(Res.string.timer_reset_message) + val undo = stringResource(Res.string.undo) + + FilledTonalIconButton( + onClick = { + onAction(TimerAction.ResetTimer) + haptic.performHapticFeedback(HapticFeedbackType.VirtualKey) + + scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + val result = snackbarHostState.showSnackbar( + timerResetMessage, + actionLabel = undo, + withDismissAction = true, + duration = SnackbarDuration.Long + ) + if (result == SnackbarResult.ActionPerformed) { + onAction(TimerAction.UndoReset) + } + } + }, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = colorContainer, + contentColor = onColorContainer + ), + shapes = IconButtonDefaults.shapes(), + interactionSource = interactionSources[1], + modifier = Modifier + .size(96.dp) + .animateWidth(interactionSources[1]) + ) { + Icon( + painterResource(Res.drawable.restart_large), + contentDescription = stringResource(Res.string.restart), + modifier = Modifier.size(32.dp) + ) + } + }, + { state -> + DropdownMenuItem( + leadingIcon = { + Icon( + painterResource(Res.drawable.restart), + stringResource(Res.string.restart) + ) + }, + text = { Text(stringResource(Res.string.restart)) }, + onClick = { + onAction(TimerAction.ResetTimer) + state.dismiss() + } + ) + } + ) + + customItem( + { + FilledTonalIconButton( + onClick = { + onAction(TimerAction.SkipTimer(fromButton = true)) + haptic.performHapticFeedback(HapticFeedbackType.VirtualKey) + }, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = colorContainer, + contentColor = onColorContainer + ), + shapes = IconButtonDefaults.shapes(), + interactionSource = interactionSources[2], + modifier = Modifier + .size(64.dp, 96.dp) + .animateWidth(interactionSources[2]) + ) { + Icon( + painterResource(Res.drawable.skip_next_large), + contentDescription = stringResource(Res.string.skip_to_next), + modifier = Modifier.size(32.dp) + ) + } + }, + { state -> + DropdownMenuItem( + leadingIcon = { + Icon( + painterResource(Res.drawable.skip_next), + stringResource(Res.string.skip_to_next) + ) + }, + text = { Text(stringResource(Res.string.skip_to_next)) }, + onClick = { + onAction(TimerAction.SkipTimer(fromButton = true)) + state.dismiss() + } + ) + } + ) + } + } + } + + item { Spacer(Modifier.height(32.dp)) } + + if (!widthExpanded) + item { + Column(horizontalAlignment = CenterHorizontally) { + Text( + stringResource(Res.string.up_next), + style = typography.titleSmall + ) + AnimatedContent( + timerState.nextTimeStr, + transitionSpec = { + slideInVertically( + animationSpec = motionScheme.defaultSpatialSpec(), + initialOffsetY = { (-it * 1.25).toInt() } + ).togetherWith( + slideOutVertically( + animationSpec = motionScheme.defaultSpatialSpec(), + targetOffsetY = { (it * 1.25).toInt() } + ) + ) + } + ) { + Text( + it, + style = TextStyle( + fontFamily = typography.bodyLarge.fontFamily, + fontSize = 22.sp, + lineHeight = 28.sp, + color = if (timerState.nextTimerMode == TimerMode.FOCUS) colorScheme.primary else colorScheme.tertiary, + textAlign = TextAlign.Center + ), + modifier = Modifier.width(200.dp) + ) + } + AnimatedContent( + timerState.nextTimerMode, + transitionSpec = { + slideInVertically( + animationSpec = motionScheme.defaultSpatialSpec(), + initialOffsetY = { (-it * 1.25).toInt() } + ).togetherWith( + slideOutVertically( + animationSpec = motionScheme.defaultSpatialSpec(), + targetOffsetY = { (it * 1.25).toInt() } + ) + ) + } + ) { + Text( + when (it) { + TimerMode.FOCUS -> stringResource(Res.string.focus) + TimerMode.SHORT_BREAK -> stringResource(Res.string.short_break) + else -> stringResource(Res.string.long_break) + }, + style = typography.titleMediumEmphasized, + textAlign = TextAlign.Center, + modifier = Modifier.width(200.dp) + ) + } + } + } + + item { Spacer(Modifier.height(16.dp)) } + } + } +} + +@Preview( + showSystemUi = true, + device = Devices.PIXEL_9_PRO +) +@Composable +fun TimerMainPanePreview() { + val timerState = TimerState( + timeStr = "03:34", nextTimeStr = "5:00", timerMode = TimerMode.FOCUS, timerRunning = true + ) + TomatoTheme { + Surface { + SharedTransitionLayout { + AnimatedContent(true) { + if (it) + CompositionLocalProvider(LocalNavAnimatedContentScope provides this) { + TimerMainPane( + timerState = timerState, + topics = sampleTopics, + currentTopic = sampleTopics[5], + isPlus = true, + contentPadding = PaddingValues(), + { 0.3f }, + {} + ) + } + } + } + } + } +} + +@Stable +fun Color.harmonizeIf(other: Color, condition: Boolean, matchSaturation: Boolean = true) = + if (!condition) this.harmonize(other, matchSaturation) else other diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt index c23d0769..0aaa303c 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt @@ -17,69 +17,14 @@ package org.nsh07.pomodoro.ui.timerScreen -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionScope -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.text.TextAutoSize -import androidx.compose.material3.ButtonGroup -import androidx.compose.material3.ButtonGroupDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.CircularWavyProgressIndicator -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledIconToggleButton -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalMinimumInteractiveComponentSize -import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.MaterialTheme.motionScheme -import androidx.compose.material3.MaterialTheme.shapes -import androidx.compose.material3.MaterialTheme.typography -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedListItem -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.VerticalDragHandle import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.layout.AdaptStrategy import androidx.compose.material3.adaptive.layout.AnimatedPane import androidx.compose.material3.adaptive.layout.SupportingPaneScaffold @@ -87,125 +32,33 @@ import androidx.compose.material3.adaptive.layout.SupportingPaneScaffoldDefaults import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState import androidx.compose.material3.adaptive.navigation.rememberSupportingPaneScaffoldNavigator import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.navigation3.ui.LocalNavAnimatedContentScope -import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.painterResource -import org.jetbrains.compose.resources.stringResource +import org.nsh07.pomodoro.data.Topic import org.nsh07.pomodoro.ui.androidSystemGestureExclusion -import org.nsh07.pomodoro.ui.rememberRequestNotificationPermissionCallback -import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsState -import org.nsh07.pomodoro.ui.theme.CustomColors.detailPaneTopBarColors -import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors -import org.nsh07.pomodoro.ui.theme.LocalAppFonts -import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.segmentedListItemShapes +import org.nsh07.pomodoro.ui.settingsScreen.screens.sampleTopics import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState -import org.nsh07.pomodoro.ui.topBarWindowInsets -import org.nsh07.pomodoro.utils.androidSdkVersionAtLeast -import org.nsh07.pomodoro.utils.millisecondsToStr -import tomato.shared.generated.resources.Res -import tomato.shared.generated.resources.app_name -import tomato.shared.generated.resources.app_name_plus -import tomato.shared.generated.resources.check_circle_40dp -import tomato.shared.generated.resources.focus -import tomato.shared.generated.resources.in_progress_40dp -import tomato.shared.generated.resources.infinite_focus -import tomato.shared.generated.resources.long_break -import tomato.shared.generated.resources.not_started_40dp -import tomato.shared.generated.resources.pause -import tomato.shared.generated.resources.pause_large -import tomato.shared.generated.resources.play -import tomato.shared.generated.resources.play_large -import tomato.shared.generated.resources.restart -import tomato.shared.generated.resources.restart_large -import tomato.shared.generated.resources.short_break -import tomato.shared.generated.resources.skip_next -import tomato.shared.generated.resources.skip_next_large -import tomato.shared.generated.resources.skip_to_next -import tomato.shared.generated.resources.timer_reset_message -import tomato.shared.generated.resources.timer_session_count -import tomato.shared.generated.resources.timer_settings_reset_info -import tomato.shared.generated.resources.undo -import tomato.shared.generated.resources.up_next @OptIn( - ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, + ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class ) @Composable fun SharedTransitionScope.TimerScreen( timerState: TimerState, - settingsState: SettingsState, + topics: List, + currentTopic: Topic, isPlus: Boolean, contentPadding: PaddingValues, progress: () -> Float, onAction: (TimerAction) -> Unit, modifier: Modifier = Modifier ) { - val motionScheme = motionScheme - val scope = rememberCoroutineScope() - val haptic = LocalHapticFeedback.current - - val color by animateColorAsState( - if (timerState.timerMode == TimerMode.FOCUS) colorScheme.primary - else colorScheme.tertiary, - animationSpec = motionScheme.slowEffectsSpec() - ) - val onColor by animateColorAsState( - if (timerState.timerMode == TimerMode.FOCUS) colorScheme.onPrimary - else colorScheme.onTertiary, - animationSpec = motionScheme.slowEffectsSpec() - ) - val colorContainer by animateColorAsState( - if (timerState.timerMode == TimerMode.FOCUS) colorScheme.secondaryContainer - else colorScheme.tertiaryContainer, - animationSpec = motionScheme.slowEffectsSpec() - ) - - val clockFontSize by animateFloatAsState( - targetValue = if (!timerState.infiniteFocus) { - if (timerState.timeStr.length < 6) 72f else 64f - } else { - if (timerState.timeStr.length < 6) 100f else 88f - }, - animationSpec = motionScheme.defaultSpatialSpec() - ) - - val widthExpanded = currentWindowAdaptiveInfo() - .windowSizeClass - .isWidthAtLeastBreakpoint(WIDTH_DP_EXPANDED_LOWER_BOUND) - - val requestNotificationPermissionCallback = rememberRequestNotificationPermissionCallback() - - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - val snackbarHostState = remember { SnackbarHostState() } - val navigator = rememberSupportingPaneScaffoldNavigator( adaptStrategies = SupportingPaneScaffoldDefaults.adaptStrategies(supportingPaneAdaptStrategy = AdaptStrategy.Hide) ) @@ -216,609 +69,30 @@ fun SharedTransitionScope.TimerScreen( scaffoldState = navigator.scaffoldState, mainPane = { AnimatedPane { - Scaffold( - topBar = { - TopAppBar( - windowInsets = topBarWindowInsets(), - title = { - AnimatedContent( - if (!timerState.showBrandTitle) timerState.timerMode else TimerMode.BRAND, - transitionSpec = { - slideInVertically( - animationSpec = motionScheme.defaultSpatialSpec(), - initialOffsetY = { (-it * 1.25).toInt() } - ).togetherWith( - slideOutVertically( - animationSpec = motionScheme.defaultSpatialSpec(), - targetOffsetY = { (it * 1.25).toInt() } - ) - ) - }, - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth(.9f) - ) { - when (it) { - TimerMode.BRAND -> - Text( - if (!isPlus) stringResource(Res.string.app_name) - else stringResource(Res.string.app_name_plus), - style = TextStyle( - fontFamily = LocalAppFonts.current.topBarTitle, - fontSize = 32.sp, - lineHeight = 32.sp, - color = colorScheme.error - ), - textAlign = TextAlign.Center - ) - - TimerMode.FOCUS -> - AnimatedContent(timerState.infiniteFocus) { inf -> - Text( - if (inf) stringResource(Res.string.infinite_focus) - else stringResource(Res.string.focus), - style = TextStyle( - fontFamily = LocalAppFonts.current.topBarTitle, - fontSize = 32.sp, - lineHeight = 32.sp, - color = colorScheme.primary - ), - textAlign = TextAlign.Center - ) - } - - TimerMode.SHORT_BREAK -> Text( - stringResource(Res.string.short_break), - style = TextStyle( - fontFamily = LocalAppFonts.current.topBarTitle, - fontSize = 32.sp, - lineHeight = 32.sp, - color = colorScheme.tertiary - ), - textAlign = TextAlign.Center - ) - - TimerMode.LONG_BREAK -> Text( - stringResource(Res.string.long_break), - style = TextStyle( - fontFamily = LocalAppFonts.current.topBarTitle, - fontSize = 32.sp, - lineHeight = 32.sp, - color = colorScheme.tertiary - ), - textAlign = TextAlign.Center - ) - } - } - }, - subtitle = {}, - titleHorizontalAlignment = CenterHorizontally, - colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), - scrollBehavior = scrollBehavior - ) - }, - bottomBar = { Spacer(Modifier.height(contentPadding.calculateBottomPadding())) }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection) - ) { innerPadding -> - LazyColumn( - verticalArrangement = Arrangement.Center, - horizontalAlignment = CenterHorizontally, - contentPadding = innerPadding, - modifier = Modifier.fillMaxSize() - ) { - item { - Column(horizontalAlignment = CenterHorizontally) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .widthIn(max = 350.dp) - .aspectRatio(1f), - ) { - this@Column.AnimatedVisibility( - !timerState.infiniteFocus, - enter = fadeIn(motionScheme.defaultEffectsSpec()) + - scaleIn(motionScheme.defaultSpatialSpec(), 4f), - exit = fadeOut(motionScheme.defaultEffectsSpec()) + - scaleOut(motionScheme.defaultSpatialSpec(), 4f) - ) { - if (timerState.timerMode == TimerMode.FOCUS) { - CircularProgressIndicator( - progress = progress, - modifier = Modifier - .sharedBounds( - sharedContentState = this@TimerScreen.rememberSharedContentState( - "focus progress" - ), - animatedVisibilityScope = LocalNavAnimatedContentScope.current - ) - .fillMaxWidth(0.9f) - .aspectRatio(1f), - color = color, - trackColor = colorContainer, - strokeWidth = 16.dp, - gapSize = 8.dp - ) - } else { - CircularWavyProgressIndicator( - progress = progress, - modifier = Modifier - .sharedBounds( - sharedContentState = this@TimerScreen.rememberSharedContentState( - "break progress" - ), - animatedVisibilityScope = LocalNavAnimatedContentScope.current - ) - .fillMaxWidth(0.9f) - .aspectRatio(1f), - color = color, - trackColor = colorContainer, - stroke = Stroke( - width = with(LocalDensity.current) { - 16.dp.toPx() - }, - cap = StrokeCap.Round, - ), - trackStroke = Stroke( - width = with(LocalDensity.current) { - 16.dp.toPx() - }, - cap = StrokeCap.Round, - ), - wavelength = 60.dp, - gapSize = 8.dp - ) - } - } - var expanded by remember { mutableStateOf(timerState.showBrandTitle) } - val timerResetSettingsInfo = - stringResource(Res.string.timer_settings_reset_info) - Column( - horizontalAlignment = CenterHorizontally, - modifier = Modifier - .clip(shapes.largeIncreased) - .combinedClickable( - onClick = { expanded = !expanded }, - onLongClick = { - if (!timerState.timerRunning) onAction( - TimerAction.SetInfiniteFocus( - !timerState.infiniteFocus - ) - ) - else scope.launch { - snackbarHostState.currentSnackbarData?.dismiss() - snackbarHostState.showSnackbar( - timerResetSettingsInfo, - duration = SnackbarDuration.Short - ) - } - } - ) - ) { - LaunchedEffect(timerState.showBrandTitle) { - expanded = timerState.showBrandTitle - } - Text( - text = timerState.timeStr, - style = TextStyle( - fontFamily = typography.bodyLarge.fontFamily, - fontSize = clockFontSize.sp, - letterSpacing = (-2.6).sp, - fontFeatureSettings = "tnum" - ), - textAlign = TextAlign.Center, - maxLines = 1, - autoSize = TextAutoSize.StepBased( - maxFontSize = clockFontSize.sp - ), - modifier = Modifier - .fillMaxWidth() - .sharedBounds( - sharedContentState = this@TimerScreen.rememberSharedContentState( - "clock" - ), - animatedVisibilityScope = LocalNavAnimatedContentScope.current - ) - ) - AnimatedVisibility( - expanded, - enter = fadeIn(motionScheme.defaultEffectsSpec()) + - expandVertically(motionScheme.defaultSpatialSpec()), - exit = fadeOut(motionScheme.defaultEffectsSpec()) + - shrinkVertically(motionScheme.defaultSpatialSpec()) - ) { - Text( - stringResource( - Res.string.timer_session_count, - timerState.currentFocusCount, - timerState.totalFocusCount - ), - fontFamily = typography.bodyLarge.fontFamily, - style = typography.titleLarge, - color = colorScheme.outline - ) - } - } - } - val interactionSources = - remember { List(3) { MutableInteractionSource() } } - ButtonGroup( - overflowIndicator = { state -> - ButtonGroupDefaults.OverflowIndicator( - state, - colors = IconButtonDefaults.filledTonalIconButtonColors(), - modifier = Modifier.size(64.dp, 96.dp) - ) - }, - modifier = Modifier.padding(16.dp) - ) { - customItem( - { - FilledIconToggleButton( - onCheckedChange = { checked -> - onAction(TimerAction.ToggleTimer) - - if (checked) haptic.performHapticFeedback( - HapticFeedbackType.ToggleOn - ) - else haptic.performHapticFeedback( - HapticFeedbackType.ToggleOff - ) - - if (androidSdkVersionAtLeast(33) && checked) { - requestNotificationPermissionCallback() - } - }, - checked = timerState.timerRunning, - colors = IconButtonDefaults.filledIconToggleButtonColors( - checkedContainerColor = color, - checkedContentColor = onColor - ), - shapes = IconButtonDefaults.toggleableShapes(), - interactionSource = interactionSources[0], - modifier = Modifier - .size(width = 128.dp, height = 96.dp) - .animateWidth(interactionSources[0]) - ) { - if (timerState.timerRunning) { - Icon( - painterResource(Res.drawable.pause_large), - contentDescription = stringResource(Res.string.pause), - modifier = Modifier.size(32.dp) - ) - } else { - Icon( - painterResource(Res.drawable.play_large), - contentDescription = stringResource(Res.string.play), - modifier = Modifier.size(32.dp) - ) - } - } - }, - { state -> - DropdownMenuItem( - leadingIcon = { - if (timerState.timerRunning) { - Icon( - painterResource(Res.drawable.pause), - contentDescription = stringResource(Res.string.pause) - ) - } else { - Icon( - painterResource(Res.drawable.play), - contentDescription = stringResource(Res.string.play) - ) - } - }, - text = { - Text( - if (timerState.timerRunning) stringResource( - Res.string.pause - ) else stringResource( - Res.string.play - ) - ) - }, - onClick = { - onAction(TimerAction.ToggleTimer) - state.dismiss() - } - ) - } - ) - - customItem( - { - val timerResetMessage = - stringResource(Res.string.timer_reset_message) - val undo = stringResource(Res.string.undo) - - FilledTonalIconButton( - onClick = { - onAction(TimerAction.ResetTimer) - haptic.performHapticFeedback(HapticFeedbackType.VirtualKey) - - scope.launch { - snackbarHostState.currentSnackbarData?.dismiss() - val result = snackbarHostState.showSnackbar( - timerResetMessage, - actionLabel = undo, - withDismissAction = true, - duration = SnackbarDuration.Long - ) - if (result == SnackbarResult.ActionPerformed) { - onAction(TimerAction.UndoReset) - } - } - }, - colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = colorContainer - ), - shapes = IconButtonDefaults.shapes(), - interactionSource = interactionSources[1], - modifier = Modifier - .size(96.dp) - .animateWidth(interactionSources[1]) - ) { - Icon( - painterResource(Res.drawable.restart_large), - contentDescription = stringResource(Res.string.restart), - modifier = Modifier.size(32.dp) - ) - } - }, - { state -> - DropdownMenuItem( - leadingIcon = { - Icon( - painterResource(Res.drawable.restart), - stringResource(Res.string.restart) - ) - }, - text = { Text(stringResource(Res.string.restart)) }, - onClick = { - onAction(TimerAction.ResetTimer) - state.dismiss() - } - ) - } - ) - - customItem( - { - FilledTonalIconButton( - onClick = { - onAction(TimerAction.SkipTimer(fromButton = true)) - haptic.performHapticFeedback(HapticFeedbackType.VirtualKey) - }, - colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = colorContainer - ), - shapes = IconButtonDefaults.shapes(), - interactionSource = interactionSources[2], - modifier = Modifier - .size(64.dp, 96.dp) - .animateWidth(interactionSources[2]) - ) { - Icon( - painterResource(Res.drawable.skip_next_large), - contentDescription = stringResource(Res.string.skip_to_next), - modifier = Modifier.size(32.dp) - ) - } - }, - { state -> - DropdownMenuItem( - leadingIcon = { - Icon( - painterResource(Res.drawable.skip_next), - stringResource(Res.string.skip_to_next) - ) - }, - text = { Text(stringResource(Res.string.skip_to_next)) }, - onClick = { - onAction(TimerAction.SkipTimer(fromButton = true)) - state.dismiss() - } - ) - } - ) - } - } - } - - item { Spacer(Modifier.height(32.dp)) } - - if (!widthExpanded) - item { - Column(horizontalAlignment = CenterHorizontally) { - Text( - stringResource(Res.string.up_next), - style = typography.titleSmall - ) - AnimatedContent( - timerState.nextTimeStr, - transitionSpec = { - slideInVertically( - animationSpec = motionScheme.defaultSpatialSpec(), - initialOffsetY = { (-it * 1.25).toInt() } - ).togetherWith( - slideOutVertically( - animationSpec = motionScheme.defaultSpatialSpec(), - targetOffsetY = { (it * 1.25).toInt() } - ) - ) - } - ) { - Text( - it, - style = TextStyle( - fontFamily = typography.bodyLarge.fontFamily, - fontSize = 22.sp, - lineHeight = 28.sp, - color = if (timerState.nextTimerMode == TimerMode.FOCUS) colorScheme.primary else colorScheme.tertiary, - textAlign = TextAlign.Center - ), - modifier = Modifier.width(200.dp) - ) - } - AnimatedContent( - timerState.nextTimerMode, - transitionSpec = { - slideInVertically( - animationSpec = motionScheme.defaultSpatialSpec(), - initialOffsetY = { (-it * 1.25).toInt() } - ).togetherWith( - slideOutVertically( - animationSpec = motionScheme.defaultSpatialSpec(), - targetOffsetY = { (it * 1.25).toInt() } - ) - ) - } - ) { - Text( - when (it) { - TimerMode.FOCUS -> stringResource(Res.string.focus) - TimerMode.SHORT_BREAK -> stringResource(Res.string.short_break) - else -> stringResource(Res.string.long_break) - }, - style = typography.titleMediumEmphasized, - textAlign = TextAlign.Center, - modifier = Modifier.width(200.dp) - ) - } - } - } - - item { Spacer(Modifier.height(16.dp)) } - } - } + TimerMainPane( + timerState = timerState, + currentTopic = currentTopic, + topics = topics, + isPlus = isPlus, + contentPadding = contentPadding, + progress = progress, + onAction = onAction, + modifier = modifier + ) } }, supportingPane = { - val isFocus = timerState.timerMode == TimerMode.FOCUS AnimatedPane { - LazyColumn( - contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .background(detailPaneTopBarColors.containerColor) - .fillMaxSize() - .padding(horizontal = 16.dp) - ) { - item { - TopAppBar( - title = { - Text( - text = stringResource(Res.string.up_next), - fontFamily = LocalAppFonts.current.topBarTitle, - maxLines = 1 - ) - }, - subtitle = {}, - windowInsets = WindowInsets(), - colors = detailPaneTopBarColors - ) - } - items(timerState.totalFocusCount) { - val currentSession = - it + 1 == timerState.currentFocusCount // currentFocusCount is 1-indexed - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - SegmentedListItem( - onClick = {}, - enabled = it + 1 >= timerState.currentFocusCount, - selected = currentSession && isFocus, - shapes = segmentedListItemShapes(0, 2), - colors = listItemColors, - leadingContent = { - AnimatedContent( - if (currentSession && isFocus) 1 - else if (it < timerState.currentFocusCount) 2 - else 3 - ) { show -> - when (show) { - 1 -> Icon( - painterResource(Res.drawable.in_progress_40dp), - null - ) - - 2 -> Icon( - painterResource(Res.drawable.check_circle_40dp), - null - ) - - else -> Icon( - painterResource(Res.drawable.not_started_40dp), - null - ) - } - } - }, - supportingContent = { - Text( - millisecondsToStr(settingsState.focusTime), - maxLines = 1 - ) - } - ) { - Text( - stringResource(Res.string.focus), - maxLines = 1 - ) - } - - SegmentedListItem( - onClick = {}, - enabled = it + 1 >= timerState.currentFocusCount, - selected = currentSession && !isFocus, - shapes = segmentedListItemShapes(1, 2), - colors = listItemColors, - leadingContent = { - AnimatedContent( - if (currentSession && !isFocus) 1 - else if (it + 1 < timerState.currentFocusCount) 2 - else 3 - ) { show -> - when (show) { - 1 -> Icon( - painterResource(Res.drawable.in_progress_40dp), - null - ) - - 2 -> Icon( - painterResource(Res.drawable.check_circle_40dp), - null - ) - - else -> Icon( - painterResource(Res.drawable.not_started_40dp), - null - ) - } - } - }, - supportingContent = { - Text( - if (it != timerState.totalFocusCount - 1) millisecondsToStr( - settingsState.shortBreakTime - ) - else millisecondsToStr(settingsState.longBreakTime), - maxLines = 1 - ) - } - ) { - Text( - if (it != timerState.totalFocusCount - 1) stringResource(Res.string.short_break) - else stringResource(Res.string.long_break), - maxLines = 1 - ) - } - } - } - } + TimerSupportingPane( + timerState = timerState, + currentTopic = currentTopic, + contentPadding = contentPadding + ) } }, paneExpansionDragHandle = { - val interactionSource = remember { MutableInteractionSource() } + val interactionSource = + remember { androidx.compose.foundation.interaction.MutableInteractionSource() } VerticalDragHandle( modifier = Modifier .paneExpansionDraggable( @@ -833,7 +107,6 @@ fun SharedTransitionScope.TimerScreen( ) } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Preview( showSystemUi = true, device = Devices.PIXEL_9_PRO @@ -847,8 +120,9 @@ fun TimerScreenPreview() { Surface { SharedTransitionLayout { TimerScreen( - timerState, - SettingsState(), + timerState = timerState, + topics = sampleTopics, + currentTopic = sampleTopics[5], isPlus = true, contentPadding = PaddingValues(), { 0.3f }, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerSupportingPane.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerSupportingPane.kt new file mode 100644 index 00000000..1ffb6dbc --- /dev/null +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerSupportingPane.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2025-2026 Nishant Mishra + * + * This file is part of Tomato - a minimalist pomodoro timer for Android. + * + * Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tomato. + * If not, see . + */ + +package org.nsh07.pomodoro.ui.timerScreen + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.nsh07.pomodoro.data.Topic +import org.nsh07.pomodoro.ui.theme.CustomColors.detailPaneTopBarColors +import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors +import org.nsh07.pomodoro.ui.theme.LocalAppFonts +import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.segmentedListItemShapes +import org.nsh07.pomodoro.ui.theme.TomatoTheme +import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode +import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState +import org.nsh07.pomodoro.utils.millisecondsToStr +import tomato.shared.generated.resources.Res +import tomato.shared.generated.resources.check_circle_40dp +import tomato.shared.generated.resources.focus +import tomato.shared.generated.resources.in_progress_40dp +import tomato.shared.generated.resources.long_break +import tomato.shared.generated.resources.not_started_40dp +import tomato.shared.generated.resources.short_break +import tomato.shared.generated.resources.up_next + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun TimerSupportingPane( + timerState: TimerState, + currentTopic: Topic, + contentPadding: PaddingValues, + modifier: Modifier = Modifier +) { + val isFocus = timerState.timerMode == TimerMode.FOCUS + LazyColumn( + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .background(detailPaneTopBarColors.containerColor) + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + item { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.up_next), + fontFamily = LocalAppFonts.current.topBarTitle, + maxLines = 1 + ) + }, + subtitle = {}, + windowInsets = WindowInsets(), + colors = detailPaneTopBarColors + ) + } + items(timerState.totalFocusCount) { + val currentSession = + it + 1 == timerState.currentFocusCount // currentFocusCount is 1-indexed + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + SegmentedListItem( + onClick = {}, + enabled = it + 1 >= timerState.currentFocusCount, + selected = currentSession && isFocus, + shapes = segmentedListItemShapes(0, 2), + colors = listItemColors, + leadingContent = { + AnimatedContent( + if (currentSession && isFocus) 1 + else if (it < timerState.currentFocusCount) 2 + else 3 + ) { show -> + when (show) { + 1 -> Icon( + painterResource(Res.drawable.in_progress_40dp), + null + ) + + 2 -> Icon( + painterResource(Res.drawable.check_circle_40dp), + null + ) + + else -> Icon( + painterResource(Res.drawable.not_started_40dp), + null + ) + } + } + }, + supportingContent = { + Text( + millisecondsToStr(currentTopic.focusTime), + maxLines = 1 + ) + } + ) { + Text( + stringResource(Res.string.focus), + maxLines = 1 + ) + } + + SegmentedListItem( + onClick = {}, + enabled = it + 1 >= timerState.currentFocusCount, + selected = currentSession && !isFocus, + shapes = segmentedListItemShapes(1, 2), + colors = listItemColors, + leadingContent = { + AnimatedContent( + if (currentSession && !isFocus) 1 + else if (it + 1 < timerState.currentFocusCount) 2 + else 3 + ) { show -> + when (show) { + 1 -> Icon( + painterResource(Res.drawable.in_progress_40dp), + null + ) + + 2 -> Icon( + painterResource(Res.drawable.check_circle_40dp), + null + ) + + else -> Icon( + painterResource(Res.drawable.not_started_40dp), + null + ) + } + } + }, + supportingContent = { + Text( + if (it != timerState.totalFocusCount - 1) millisecondsToStr( + currentTopic.shortBreakTime + ) + else millisecondsToStr(currentTopic.longBreakTime), + maxLines = 1 + ) + } + ) { + Text( + if (it != timerState.totalFocusCount - 1) stringResource(Res.string.short_break) + else stringResource(Res.string.long_break), + maxLines = 1 + ) + } + } + } + } +} + +@Preview( + showSystemUi = true, + device = Devices.PIXEL_9_PRO +) +@Composable +fun TimerSupportingPanePreview() { + val timerState = TimerState( + timeStr = "03:34", + nextTimeStr = "5:00", + timerMode = TimerMode.FOCUS, + timerRunning = true, + totalFocusCount = 4, + currentFocusCount = 1 + ) + TomatoTheme { + Surface { + TimerSupportingPane( + timerState, + Topic.defaultTopic, + contentPadding = PaddingValues() + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt index 8dfe1c15..03bd1897 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt @@ -17,9 +17,12 @@ package org.nsh07.pomodoro.ui.timerScreen.viewModel +import org.nsh07.pomodoro.data.Topic + sealed interface TimerAction { data class SkipTimer(val fromButton: Boolean) : TimerAction data class SetInfiniteFocus(val value: Boolean) : TimerAction + data class SetTopic(val topic: Topic) : TimerAction data object ResetTimer : TimerAction data object UndoReset : TimerAction diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt index 4b9d5d60..ddd68d6d 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt @@ -34,28 +34,34 @@ import kotlinx.coroutines.launch import org.nsh07.pomodoro.data.Stat import org.nsh07.pomodoro.data.StatRepository import org.nsh07.pomodoro.data.StateRepository +import org.nsh07.pomodoro.data.TopicRepository import org.nsh07.pomodoro.service.TimerHelper import org.nsh07.pomodoro.ui.Screen import java.time.LocalDate import java.time.temporal.ChronoUnit +import kotlin.time.Duration.Companion.milliseconds @OptIn(FlowPreview::class) class TimerViewModel( private val timerHelper: TimerHelper, private val stateRepository: StateRepository, - private val statRepository: StatRepository + private val statRepository: StatRepository, + private val topicRepository: TopicRepository ) : ViewModel() { val rootBackstack = mutableStateListOf(Screen.Timer) private val _time: MutableStateFlow = stateRepository.time val timerState: StateFlow = stateRepository.timerState.asStateFlow() + val currentTopic = stateRepository.currentTopic.asStateFlow() + val progress = _time.combine(stateRepository.timerState) { remainingTime, uiState -> (uiState.totalTime.toFloat() - remainingTime) / uiState.totalTime }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0f) init { viewModelScope.launch(Dispatchers.IO) { + val topicIds = topicRepository.getTopicIds() var lastDate = statRepository.getLastDate() val today = LocalDate.now() @@ -63,13 +69,17 @@ class TimerViewModel( if (lastDate != null) { while (ChronoUnit.DAYS.between(lastDate, today) > 0) { lastDate = lastDate?.plusDays(1) - statRepository.insertStat(Stat(lastDate!!, 0, 0, 0, 0, 0)) + topicIds.forEach { topicId -> + statRepository.insertStat(Stat(lastDate!!, topicId, 0, 0, 0, 0, 0)) + } } } else { - statRepository.insertStat(Stat(today, 0, 0, 0, 0, 0)) + topicIds.forEach { topicId -> + statRepository.insertStat(Stat(today, topicId, 0, 0, 0, 0, 0)) + } } - delay(1500) + delay(1500.milliseconds) stateRepository.timerState.update { currentState -> currentState.copy(showBrandTitle = false) @@ -78,14 +88,24 @@ class TimerViewModel( } fun onAction(action: TimerAction) { - if (action !is TimerAction.SetInfiniteFocus) timerHelper.onAction(action) - else { - stateRepository.timerState.update { - it.copy( - infiniteFocus = action.value - ) + when (action) { + is TimerAction.SetInfiniteFocus -> { + stateRepository.timerState.update { + it.copy( + infiniteFocus = action.value + ) + } + onAction(TimerAction.ResetTimer) } - onAction(TimerAction.ResetTimer) + + is TimerAction.SetTopic -> { + if (!timerState.value.serviceRunning) { + stateRepository.setTopic(action.topic) + onAction(TimerAction.ResetTimer) + } + } + + else -> timerHelper.onAction(action) } } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/utils/Utils.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/utils/Utils.kt index 60cae2b8..1b5c874c 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/utils/Utils.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/utils/Utils.kt @@ -17,7 +17,6 @@ package org.nsh07.pomodoro.utils -import androidx.compose.ui.graphics.Color import java.util.Locale import java.util.concurrent.TimeUnit @@ -55,30 +54,6 @@ fun millisecondsToHoursMinutes(t: Long, format: String = $$"%1$dh %2$dm"): Strin ) } -/** - * Extension function for [String] to convert it to a [Color] - * - * The base string MUST be of the format produced by [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) - * - * The behavior of this function is undefined if the format is not followed - */ -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.. MutableList.onBack() { if (size > 1) removeLastOrNull() } diff --git a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/AppWindow.kt b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/AppWindow.kt index 5fa91bef..5b3d009d 100644 --- a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/AppWindow.kt +++ b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/AppWindow.kt @@ -55,7 +55,6 @@ import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.utils.OS import org.nsh07.pomodoro.utils.currentOS -import org.nsh07.pomodoro.utils.toColor import tomato.shared.generated.resources.Res import tomato.shared.generated.resources.app_name import tomato.shared.generated.resources.logo @@ -121,7 +120,7 @@ fun ApplicationScope.AppWindow( else -> isSystemInDarkTheme() } - val seed = settingsState.colorScheme.toColor() + val seed = settingsState.colorScheme TomatoTheme( darkTheme = darkTheme, diff --git a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt index ed021eaf..7550fcc3 100644 --- a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt +++ b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt @@ -23,7 +23,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.WindowState import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.sqlite.SQLiteConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.databasesDir import io.github.vinceglb.filekit.path @@ -39,11 +42,15 @@ import org.nsh07.pomodoro.billing.BillingManager import org.nsh07.pomodoro.data.AppDatabase import org.nsh07.pomodoro.data.AppPreferenceRepository import org.nsh07.pomodoro.data.AppStatRepository +import org.nsh07.pomodoro.data.AppTopicRepository import org.nsh07.pomodoro.data.BackupRestoreManager import org.nsh07.pomodoro.data.DesktopBackupRestoreManager +import org.nsh07.pomodoro.data.MIGRATION_2_3 import org.nsh07.pomodoro.data.PreferenceRepository import org.nsh07.pomodoro.data.StatRepository import org.nsh07.pomodoro.data.StateRepository +import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic +import org.nsh07.pomodoro.data.TopicRepository import org.nsh07.pomodoro.service.TimerHelper import org.nsh07.pomodoro.service.TimerManager import org.nsh07.pomodoro.timer.DesktopTimerHelper @@ -58,6 +65,7 @@ val dbModule = module { single { create(::createDatabase) } single { get().preferenceDao() } single { get().statDao() } + single { get().topicDao() } single { get().systemDao() } } @@ -79,6 +87,7 @@ val servicesModule = module { single { create(::createAppInfo) } single() bind StatRepository::class + single() bind TopicRepository::class single() bind PreferenceRepository::class single() single() bind TimerHelper::class @@ -107,6 +116,30 @@ private fun createDatabase(): AppDatabase { .databaseBuilder(name = dbFile.absolutePath) .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) + .addMigrations(MIGRATION_2_3) + .addCallback(object : RoomDatabase.Callback() { + override fun onCreate(connection: SQLiteConnection) { + super.onCreate(connection) + connection.execSQL( + """ + INSERT OR IGNORE INTO `topic` + (`id`, `name`, `color`, `shape`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autostartNextSession`, `dndEnabled`) + VALUES ( + '${defaultTopic.id}', + '${defaultTopic.name}', + ${defaultTopic.color.value.toLong()}, + '${defaultTopic.shape.name}', + ${defaultTopic.focusTime}, + ${defaultTopic.shortBreakTime}, + ${defaultTopic.longBreakTime}, + ${defaultTopic.sessionLength}, + ${if (defaultTopic.autostartNextSession) 1 else 0}, + ${if (defaultTopic.dndEnabled) 1 else 0} + ) + """.trimIndent() + ) + } + }) .build() } diff --git a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/timer/DesktopTimerHelper.kt b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/timer/DesktopTimerHelper.kt index 1529fad2..2d3c76d5 100644 --- a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/timer/DesktopTimerHelper.kt +++ b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/timer/DesktopTimerHelper.kt @@ -28,6 +28,7 @@ import org.nsh07.pomodoro.data.StateRepository import org.nsh07.pomodoro.service.TimerHelper import org.nsh07.pomodoro.service.TimerManager import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction +import kotlin.time.Duration.Companion.milliseconds class DesktopTimerHelper( private val timerManager: TimerManager, @@ -76,7 +77,7 @@ class DesktopTimerHelper( TimerAction.UndoReset -> timerManager.undoReset() - is TimerAction.SetInfiniteFocus -> { + else -> { System.err.println("Invalid action: $action") } } @@ -121,7 +122,7 @@ class DesktopTimerHelper( if (settingsState.alarmEnabled) mp3Player.play() autoAlarmStopScope = CoroutineScope(Dispatchers.IO).launch { - delay(1 * 60 * 1000) + delay((1 * 60 * 1000).milliseconds) stopAlarm(fromAutoStop = true) } } @@ -142,7 +143,8 @@ class DesktopTimerHelper( showTimerNotification(complete = false) - if (settingsState.autostartNextSession && !fromAutoStop) // auto start next session + val currentTopic = stateRepository.currentTopic.value + if (currentTopic.autostartNextSession && !fromAutoStop) // auto start next session toggleTimer() } } \ No newline at end of file