diff --git a/.github/repo_photos/banner.png b/.github/repo_photos/banner.png new file mode 100644 index 0000000..9095115 Binary files /dev/null and b/.github/repo_photos/banner.png differ diff --git a/.github/repo_photos/bmc_qr.png b/.github/repo_photos/bmc_qr.png new file mode 100644 index 0000000..c5f4614 Binary files /dev/null and b/.github/repo_photos/bmc_qr.png differ diff --git a/.github/repo_photos/sponsors.png b/.github/repo_photos/sponsors.png new file mode 100644 index 0000000..e06561a Binary files /dev/null and b/.github/repo_photos/sponsors.png differ diff --git a/README.md b/README.md index d341f28..74a9493 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,20 @@ -![Feature graphic](fastlane/metadata/android/en-US/images/featureGraphic.png) +![Feature graphic](.github/repo_photos/banner.png) -### THIS PROJECT IS IN A VERY EARLY DEVELOPMENT STAGE. MOST FEATURES ARE NOT YET READY +## About -### About +Tomato is a minimalist Pomodoro timer for Android based on Material 3 Expressive. -Tomato is a minimalist Pomodoro timer for Android based on Material 3 Expressive +### Features -### Screenshots +- Simple, minimalist UI based on the latest Material 3 Expressive guidelines +- Detailed statistics of work/study times in an easy to understand manner + - Stats for the current day visible at a glance + - Stats for the last week and last month shown in an easy to read, clean graph + - Additional stats for last week and month showing at what time of the day you're the most + productive +- Customizable timer parameters + +## Screenshots

@@ -15,4 +23,26 @@ Tomato is a minimalist Pomodoro timer for Android based on Material 3 Expressive -

\ No newline at end of file +

+ +## Donate + +You can support Tomato's development +through [my GitHub Sponsors page](https://github.com/sponsors/nsh07) +or [my BuyMeACoffee page](https://coff.ee/nsh07): + + + + + + + + +## Special Thanks + +This app was made possible by the following libraries: + +- [Jetpack Navigation 3](https://developer.android.com/jetpack/androidx/releases/navigation3) - + Navigation +- [Room](https://developer.android.com/jetpack/androidx/releases/room) - SQLite Database +- [Vico](https://github.com/patrykandpatrick/vico) - Graphs and charts \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 087330c..0c8f47f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -16,8 +23,8 @@ android { applicationId = "org.nsh07.pomodoro" minSdk = 26 targetSdk = 36 - versionCode = 1 - versionName = "1.0.0-01-alpha" + versionCode = 2 + versionName = "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -26,8 +33,7 @@ android { release { isMinifyEnabled = false proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } @@ -64,6 +70,8 @@ dependencies { implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) + implementation(libs.vico.compose.m3) + implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) ksp(libs.androidx.room.compiler) diff --git a/app/schemas/org.nsh07.pomodoro.data.AppDatabase/1.json b/app/schemas/org.nsh07.pomodoro.data.AppDatabase/1.json new file mode 100644 index 0000000..1dffd57 --- /dev/null +++ b/app/schemas/org.nsh07.pomodoro.data.AppDatabase/1.json @@ -0,0 +1,85 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "d9da72c9ea3c225d6c0025a98ad32a4a", + "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": "stat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` 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`))", + "fields": [ + { + "fieldPath": "date", + "columnName": "date", + "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" + ] + } + } + ], + "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, 'd9da72c9ea3c225d6c0025a98ad32a4a')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt b/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt index a753952..9e10f0f 100644 --- a/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt +++ b/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt @@ -8,19 +8,21 @@ import androidx.activity.viewModels import org.nsh07.pomodoro.ui.AppScreen import org.nsh07.pomodoro.ui.NavItem import org.nsh07.pomodoro.ui.Screen +import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel class MainActivity : ComponentActivity() { - private val viewModel: TimerViewModel by viewModels(factoryProducer = { TimerViewModel.Factory }) + private val timerViewModel: TimerViewModel by viewModels(factoryProducer = { TimerViewModel.Factory }) + private val statsViewModel: StatsViewModel by viewModels(factoryProducer = { StatsViewModel.Factory }) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { TomatoTheme { - AppScreen(viewModel = viewModel) + AppScreen(timerViewModel = timerViewModel, statsViewModel = statsViewModel) } } } diff --git a/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt b/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt index c8039cf..2d45910 100644 --- a/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt +++ b/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt @@ -1,22 +1,30 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.nsh07.pomodoro.data import android.content.Context interface AppContainer { - val appPreferencesRepository: AppPreferenceRepository + val appPreferenceRepository: AppPreferenceRepository + val appStatRepository: AppStatRepository val appTimerRepository: AppTimerRepository } class DefaultAppContainer(context: Context) : AppContainer { - override val appPreferencesRepository: AppPreferenceRepository by lazy { - AppPreferenceRepository( - AppDatabase.getDatabase(context).preferenceDao() - ) + override val appPreferenceRepository: AppPreferenceRepository by lazy { + AppPreferenceRepository(AppDatabase.getDatabase(context).preferenceDao()) } - override val appTimerRepository: AppTimerRepository by lazy { - AppTimerRepository() + override val appStatRepository: AppStatRepository by lazy { + AppStatRepository(AppDatabase.getDatabase(context).statDao()) } + override val appTimerRepository: AppTimerRepository by lazy { AppTimerRepository() } + } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/data/AppDatabase.kt b/app/src/main/java/org/nsh07/pomodoro/data/AppDatabase.kt index 96b8130..8e457cf 100644 --- a/app/src/main/java/org/nsh07/pomodoro/data/AppDatabase.kt +++ b/app/src/main/java/org/nsh07/pomodoro/data/AppDatabase.kt @@ -1,17 +1,27 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.nsh07.pomodoro.data import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.TypeConverters @Database( - entities = [IntPreference::class], + entities = [IntPreference::class, Stat::class], version = 1 ) +@TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { abstract fun preferenceDao(): PreferenceDao + abstract fun statDao(): StatDao companion object { diff --git a/app/src/main/java/org/nsh07/pomodoro/data/Converters.kt b/app/src/main/java/org/nsh07/pomodoro/data/Converters.kt new file mode 100644 index 0000000..431249d --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/data/Converters.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.nsh07.pomodoro.data + +import androidx.room.TypeConverter +import java.time.LocalDate + +class Converters { + @TypeConverter + fun localDateToString(localDate: LocalDate?): String? { + return localDate?.toString() + } + + @TypeConverter + fun stringToLocalDate(date: String?): LocalDate? { + return if (date != null) LocalDate.parse(date) else null + } +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/data/Preference.kt b/app/src/main/java/org/nsh07/pomodoro/data/Preference.kt index 50478aa..101dd84 100644 --- a/app/src/main/java/org/nsh07/pomodoro/data/Preference.kt +++ b/app/src/main/java/org/nsh07/pomodoro/data/Preference.kt @@ -1,8 +1,18 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.nsh07.pomodoro.data import androidx.room.Entity import androidx.room.PrimaryKey +/** + * Class for storing app preferences (settings) in the app's database + */ @Entity(tableName = "int_preference") data class IntPreference( @PrimaryKey diff --git a/app/src/main/java/org/nsh07/pomodoro/data/PreferenceDao.kt b/app/src/main/java/org/nsh07/pomodoro/data/PreferenceDao.kt index 490c0db..b450b4c 100644 --- a/app/src/main/java/org/nsh07/pomodoro/data/PreferenceDao.kt +++ b/app/src/main/java/org/nsh07/pomodoro/data/PreferenceDao.kt @@ -1,13 +1,20 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.nsh07.pomodoro.data import androidx.room.Dao import androidx.room.Insert -import androidx.room.OnConflictStrategy +import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query @Dao interface PreferenceDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) + @Insert(onConflict = REPLACE) suspend fun insertIntPreference(preference: IntPreference) @Query("DELETE FROM int_preference") diff --git a/app/src/main/java/org/nsh07/pomodoro/data/PreferenceRepository.kt b/app/src/main/java/org/nsh07/pomodoro/data/PreferenceRepository.kt index 9b9fa0a..438af3f 100644 --- a/app/src/main/java/org/nsh07/pomodoro/data/PreferenceRepository.kt +++ b/app/src/main/java/org/nsh07/pomodoro/data/PreferenceRepository.kt @@ -1,21 +1,46 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.nsh07.pomodoro.data import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -interface PreferencesRepository { +/** + * Interface for reading/writing app preferences to the app's database. This style of storage aims + * to mimic the Preferences DataStore library, preventing the requirement of migration if the + * database schema changes. + */ +interface PreferenceRepository { + /** + * Saves an integer preference key-value pair to the database. + */ suspend fun saveIntPreference(key: String, value: Int): Int + /** + * Retrieves an integer preference key-value pair from the database. + */ suspend fun getIntPreference(key: String): Int? + /** + * Erases all integer preference key-value pairs in the database. Do note that the default values + * will need to be rewritten manually + */ suspend fun resetSettings() } +/** + * See [PreferenceRepository] for more details + */ class AppPreferenceRepository( private val preferenceDao: PreferenceDao, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO -) : PreferencesRepository { +) : PreferenceRepository { override suspend fun saveIntPreference(key: String, value: Int): Int = withContext(ioDispatcher) { preferenceDao.insertIntPreference(IntPreference(key, value)) diff --git a/app/src/main/java/org/nsh07/pomodoro/data/Stat.kt b/app/src/main/java/org/nsh07/pomodoro/data/Stat.kt new file mode 100644 index 0000000..2110244 --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/data/Stat.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.nsh07.pomodoro.data + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.time.LocalDate + +/** + * Data class for storing the user's statistics in the app's database. This class stores the focus + * durations for the 4 quarters of a day (00:00 - 12:00, 12:00 - 16:00, 16:00 - 20:00, 20:00 - 00:00) + * separately for later analysis (e.g. for showing which parts of the day are most productive). + */ +@Entity(tableName = "stat") +data class Stat( + @PrimaryKey + val date: LocalDate, + val focusTimeQ1: Long, + val focusTimeQ2: Long, + val focusTimeQ3: Long, + val focusTimeQ4: Long, + val breakTime: Long +) { + fun totalFocusTime() = focusTimeQ1 + focusTimeQ2 + focusTimeQ3 + focusTimeQ4 +} + +data class StatSummary( + val date: LocalDate, + val focusTime: Long, + val breakTime: Long +) + +data class StatFocusTime( + val focusTimeQ1: Long, + val focusTimeQ2: Long, + val focusTimeQ3: Long, + val focusTimeQ4: Long +) { + fun total() = focusTimeQ1 + focusTimeQ2 + focusTimeQ3 + focusTimeQ4 +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/data/StatDao.kt b/app/src/main/java/org/nsh07/pomodoro/data/StatDao.kt new file mode 100644 index 0000000..3dcba51 --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/data/StatDao.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.nsh07.pomodoro.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy.Companion.REPLACE +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import java.time.LocalDate + +@Dao +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 focusTimeQ2 = focusTimeQ2 + :focusTime WHERE date = :date") + suspend fun addFocusTimeQ2(date: LocalDate, focusTime: Long) + + @Query("UPDATE stat SET focusTimeQ3 = focusTimeQ3 + :focusTime WHERE date = :date") + suspend fun addFocusTimeQ3(date: LocalDate, focusTime: Long) + + @Query("UPDATE stat SET focusTimeQ4 = focusTimeQ4 + :focusTime WHERE date = :date") + suspend fun addFocusTimeQ4(date: LocalDate, focusTime: Long) + + @Query("UPDATE stat SET breakTime = breakTime + :breakTime WHERE date = :date") + suspend fun addBreakTime(date: LocalDate, breakTime: Long) + + @Query("SELECT * FROM stat WHERE date = :date") + fun getStat(date: LocalDate): Flow + + @Query("SELECT date, focusTimeQ1 + focusTimeQ2 + focusTimeQ3 + focusTimeQ4 as focusTime, breakTime FROM stat ORDER BY date DESC LIMIT :n") + fun getLastNDaysStatsSummary(n: Int): Flow> + + @Query( + "SELECT " + + "AVG(focusTimeQ1) AS focusTimeQ1, " + + "AVG(focusTimeQ2) AS focusTimeQ2, " + + "AVG(focusTimeQ3) AS focusTimeQ3, " + + "AVG(focusTimeQ4) AS focusTimeQ4 " + + "FROM (SELECT focusTimeQ1, focusTimeQ2, focusTimeQ3, focusTimeQ4 FROM stat ORDER BY date DESC LIMIT :n)" + ) + fun getLastNDaysAvgFocusTimes(n: Int): Flow + + @Query("SELECT EXISTS (SELECT * FROM stat WHERE date = :date)") + suspend fun statExists(date: LocalDate): Boolean + + @Query("SELECT date FROM stat ORDER BY date DESC LIMIT 1") + suspend fun getLastDate(): LocalDate? +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/data/StatRepository.kt b/app/src/main/java/org/nsh07/pomodoro/data/StatRepository.kt new file mode 100644 index 0000000..7d2efbd --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/data/StatRepository.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.nsh07.pomodoro.data + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import java.time.LocalDate +import java.time.LocalTime + +/** + * Interface for reading/writing statistics to the app's database. Ideally, writing should be done + * through the timer screen's ViewModel and reading should be done through the stats screen's + * ViewModel + */ +interface StatRepository { + suspend fun insertStat(stat: Stat) + + suspend fun addFocusTime(focusTime: Long) + + suspend fun addBreakTime(breakTime: Long) + + fun getTodayStat(): Flow + + fun getLastNDaysStatsSummary(n: Int): Flow> + + fun getLastNDaysAverageFocusTimes(n: Int): Flow + + suspend fun getLastDate(): LocalDate? +} + +/** + * See [StatRepository] for more details + */ +class AppStatRepository( + private val statDao: StatDao, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO +) : 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 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 fun getTodayStat(): Flow { + val currentDate = LocalDate.now() + return statDao.getStat(currentDate) + } + + override fun getLastNDaysStatsSummary(n: Int): Flow> = + statDao.getLastNDaysStatsSummary(n) + + override fun getLastNDaysAverageFocusTimes(n: Int): Flow = + statDao.getLastNDaysAvgFocusTimes(n) + + override suspend fun getLastDate(): LocalDate? = statDao.getLastDate() +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/data/TimerRepository.kt b/app/src/main/java/org/nsh07/pomodoro/data/TimerRepository.kt index ba7b33d..0283a17 100644 --- a/app/src/main/java/org/nsh07/pomodoro/data/TimerRepository.kt +++ b/app/src/main/java/org/nsh07/pomodoro/data/TimerRepository.kt @@ -1,15 +1,29 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.nsh07.pomodoro.data +/** + * Interface that holds the timer durations for each timer type. This repository maintains a single + * source of truth for the timer durations for the various ViewModels in the app. + */ interface TimerRepository { - var focusTime: Int - var shortBreakTime: Int - var longBreakTime: Int + var focusTime: Long + var shortBreakTime: Long + var longBreakTime: Long var sessionLength: Int } +/** + * See [TimerRepository] for more details + */ class AppTimerRepository : TimerRepository { - override var focusTime = 25 * 60 * 1000 - override var shortBreakTime = 5 * 60 * 1000 - override var longBreakTime = 15 * 60 * 1000 + override var focusTime = 25 * 60 * 1000L + override var shortBreakTime = 5 * 60 * 1000L + override var longBreakTime = 15 * 60 * 1000L override var sessionLength = 4 } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt index 883c0a7..6a61814 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.nsh07.pomodoro.ui import androidx.compose.animation.ContentTransform @@ -22,10 +29,8 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo 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.rememberUpdatedState -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback @@ -38,12 +43,10 @@ import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay import androidx.window.core.layout.WindowSizeClass -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext import org.nsh07.pomodoro.MainActivity.Companion.screens import org.nsh07.pomodoro.ui.settingsScreen.SettingsScreenRoot -import org.nsh07.pomodoro.ui.statsScreen.StatsScreen +import org.nsh07.pomodoro.ui.statsScreen.StatsScreenRoot +import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel import org.nsh07.pomodoro.ui.timerScreen.TimerScreen import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel @@ -51,26 +54,19 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel @Composable fun AppScreen( modifier: Modifier = Modifier, - viewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory) + timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory), + statsViewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory) ) { - val uiState by viewModel.timerState.collectAsStateWithLifecycle() - val remainingTime by viewModel.time.collectAsStateWithLifecycle() + val uiState by timerViewModel.timerState.collectAsStateWithLifecycle() + val remainingTime by timerViewModel.time.collectAsStateWithLifecycle() val progress by rememberUpdatedState((uiState.totalTime.toFloat() - remainingTime) / uiState.totalTime) - var showBrandTitle by remember { mutableStateOf(true) } val layoutDirection = LocalLayoutDirection.current val haptic = LocalHapticFeedback.current val motionScheme = motionScheme val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass - LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { - delay(1500) - showBrandTitle = false - } - } - LaunchedEffect(uiState.timerMode) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } @@ -142,11 +138,8 @@ fun AppScreen( entry { TimerScreen( timerState = uiState, - showBrandTitle = showBrandTitle, progress = { progress }, - resetTimer = viewModel::resetTimer, - skipTimer = viewModel::skipTimer, - toggleTimer = viewModel::toggleTimer, + onAction = timerViewModel::onAction, modifier = modifier.padding( start = contentPadding.calculateStartPadding(layoutDirection), end = contentPadding.calculateEndPadding(layoutDirection), @@ -166,7 +159,14 @@ fun AppScreen( } entry { - StatsScreen() + StatsScreenRoot( + viewModel = statsViewModel, + modifier = modifier.padding( + start = contentPadding.calculateStartPadding(layoutDirection), + end = contentPadding.calculateEndPadding(layoutDirection), + bottom = contentPadding.calculateBottomPadding() + ) + ) } } ) diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt index 60d29b5..496b3ef 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.nsh07.pomodoro.ui.settingsScreen import androidx.compose.animation.AnimatedVisibility @@ -52,7 +59,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import org.nsh07.pomodoro.R import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel -import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTitle +import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar import org.nsh07.pomodoro.ui.theme.TomatoTheme @OptIn(ExperimentalMaterial3Api::class) @@ -106,7 +113,7 @@ private fun SettingsScreen( Text( "Settings", style = LocalTextStyle.current.copy( - fontFamily = robotoFlexTitle, + fontFamily = robotoFlexTopBar, fontSize = 32.sp, lineHeight = 32.sp ) diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt index 52c9407..c3454e5 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.nsh07.pomodoro.ui.settingsScreen.viewModel import androidx.compose.foundation.text.input.TextFieldState @@ -46,7 +53,7 @@ class SettingsViewModel( timerRepository.focusTime = preferenceRepository.saveIntPreference( "focus_time", it.toString().toInt() * 60 * 1000 - ) + ).toLong() } } } @@ -58,7 +65,7 @@ class SettingsViewModel( timerRepository.shortBreakTime = preferenceRepository.saveIntPreference( "short_break_time", it.toString().toInt() * 60 * 1000 - ) + ).toLong() } } } @@ -70,7 +77,7 @@ class SettingsViewModel( timerRepository.longBreakTime = preferenceRepository.saveIntPreference( "long_break_time", it.toString().toInt() * 60 * 1000 - ) + ).toLong() } } } @@ -89,7 +96,7 @@ class SettingsViewModel( val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { val application = (this[APPLICATION_KEY] as TomatoApplication) - val appPreferenceRepository = application.container.appPreferencesRepository + val appPreferenceRepository = application.container.appPreferenceRepository val appTimerRepository = application.container.appTimerRepository SettingsViewModel( diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/ProductivityGraph.kt b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/ProductivityGraph.kt new file mode 100644 index 0000000..f10402f --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/ProductivityGraph.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.nsh07.pomodoro.ui.statsScreen + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter +import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes + +@Composable +fun ColumnScope.ProductivityGraph( + expanded: Boolean, + modelProducer: CartesianChartModelProducer, + modifier: Modifier = Modifier, + label: String = "Productivity analysis" +) { + AnimatedVisibility(expanded) { + Column(modifier = modifier) { + Text(label, style = typography.titleMedium) + Text("Focus durations at different times of the day", style = typography.bodySmall) + Spacer(Modifier.height(8.dp)) + TimeColumnChart( + modelProducer, + xValueFormatter = CartesianValueFormatter { _, value, _ -> + when (value) { + 0.0 -> "0 - 6" + 1.0 -> "6 - 12" + 2.0 -> "12 - 18" + 3.0 -> "18 - 24" + else -> "" + } + }, + yValueFormatter = CartesianValueFormatter { _, value, _ -> + millisecondsToHoursMinutes(value.toLong()) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/StatsScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/StatsScreen.kt index 6720c33..5c9ad6c 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/StatsScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/StatsScreen.kt @@ -1,46 +1,367 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.nsh07.pomodoro.ui.statsScreen +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.FilledTonalIconToggleButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.motionScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource +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 org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTitle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter +import com.patrykandpatrick.vico.core.cartesian.data.columnSeries +import com.patrykandpatrick.vico.core.common.data.ExtraStore +import kotlinx.coroutines.runBlocking +import org.nsh07.pomodoro.R +import org.nsh07.pomodoro.data.Stat +import org.nsh07.pomodoro.data.StatFocusTime +import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel +import org.nsh07.pomodoro.ui.theme.AppFonts.openRundeClock +import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar +import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes + +@Composable +fun StatsScreenRoot( + modifier: Modifier = Modifier, + viewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory) +) { + val todayStat by viewModel.todayStat.collectAsStateWithLifecycle(null) + val lastWeekAverageFocusTimes by viewModel + .lastWeekAverageFocusTimes.collectAsStateWithLifecycle(null) + val lastMonthAverageFocusTimes by viewModel + .lastMonthAverageFocusTimes.collectAsStateWithLifecycle(null) + + StatsScreen( + lastWeekSummaryChartData = remember { viewModel.lastWeekSummaryChartData }, + lastWeekSummaryAnalysisModelProducer = remember { viewModel.lastWeekSummaryAnalysisModelProducer }, + lastMonthSummaryChartData = remember { viewModel.lastMonthSummaryChartData }, + lastMonthSummaryAnalysisModelProducer = remember { viewModel.lastMonthSummaryAnalysisModelProducer }, + todayStat = todayStat, + lastWeekAverageFocusTimes = lastWeekAverageFocusTimes, + lastMonthAverageFocusTimes = lastMonthAverageFocusTimes, + modifier = modifier + ) +} @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable -fun StatsScreen(modifier: Modifier = Modifier) { - Column(modifier) { +fun StatsScreen( + lastWeekSummaryChartData: Pair>>, + lastWeekSummaryAnalysisModelProducer: CartesianChartModelProducer, + lastMonthSummaryChartData: Pair>>, + lastMonthSummaryAnalysisModelProducer: CartesianChartModelProducer, + todayStat: Stat?, + lastWeekAverageFocusTimes: StatFocusTime?, + lastMonthAverageFocusTimes: StatFocusTime?, + modifier: Modifier = Modifier +) { + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + + var lastWeekStatExpanded by rememberSaveable { mutableStateOf(false) } + var lastMonthStatExpanded by rememberSaveable { mutableStateOf(false) } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + ) { TopAppBar( title = { Text( "Stats", style = LocalTextStyle.current.copy( - fontFamily = robotoFlexTitle, + fontFamily = robotoFlexTopBar, fontSize = 32.sp, lineHeight = 32.sp ) ) }, subtitle = {}, - titleHorizontalAlignment = Alignment.CenterHorizontally + titleHorizontalAlignment = Alignment.CenterHorizontally, + scrollBehavior = scrollBehavior ) - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize().background(colorScheme.surface)) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - LoadingIndicator() - Text("Coming Soon", style = typography.headlineSmall, fontFamily = robotoFlexTitle) + + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { Spacer(Modifier) } + item { + Text( + "Today", + style = typography.headlineSmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + } + item { + Row(modifier = Modifier.padding(horizontal = 16.dp)) { + Box( + modifier = Modifier + .background( + colorScheme.primaryContainer, + MaterialTheme.shapes.largeIncreased + ) + .weight(1f) + ) { + Column(Modifier.padding(16.dp)) { + Text( + "Focus", + style = typography.titleMedium, + color = colorScheme.onPrimaryContainer + ) + Text( + remember(todayStat) { + millisecondsToHoursMinutes(todayStat?.totalFocusTime() ?: 0) + }, + style = typography.displaySmall, + fontFamily = openRundeClock, + color = colorScheme.onPrimaryContainer + ) + } + } + Spacer(Modifier.width(8.dp)) + Box( + modifier = Modifier + .background( + colorScheme.tertiaryContainer, + MaterialTheme.shapes.largeIncreased + ) + .weight(1f) + ) { + Column(Modifier.padding(16.dp)) { + Text( + "Break", + style = typography.titleMedium, + color = colorScheme.onTertiaryContainer + ) + Text( + remember(todayStat) { + millisecondsToHoursMinutes(todayStat?.breakTime ?: 0) + }, + style = typography.displaySmall, + fontFamily = openRundeClock, + color = colorScheme.onTertiaryContainer + ) + } + } + } + } + item { Spacer(Modifier) } + item { + Text( + "Last week", + style = typography.headlineSmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + } + item { + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text( + millisecondsToHoursMinutes(lastWeekAverageFocusTimes?.total() ?: 0), + style = typography.displaySmall, + fontFamily = openRundeClock + ) + Text( + "focus per day (avg)", + style = typography.titleSmall, + modifier = Modifier.padding(bottom = 6.3.dp) + ) + } + } + item { + TimeColumnChart( + lastWeekSummaryChartData.first, + modifier = Modifier.padding(start = 16.dp), + xValueFormatter = CartesianValueFormatter { context, x, _ -> + context.model.extraStore[lastWeekSummaryChartData.second][x.toInt()] + } + ) + } + item { + val iconRotation by animateFloatAsState( + if (lastWeekStatExpanded) 180f else 0f, + animationSpec = motionScheme.defaultSpatialSpec() + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Spacer(Modifier.height(2.dp)) + FilledTonalIconToggleButton( + checked = lastWeekStatExpanded, + onCheckedChange = { lastWeekStatExpanded = it }, + shapes = IconButtonDefaults.toggleableShapes(), + modifier = Modifier + .padding(horizontal = 16.dp) + .width(52.dp) + .align(Alignment.End) + ) { + Icon( + painterResource(R.drawable.arrow_down), + "More info", + modifier = Modifier.rotate(iconRotation) + ) + } + ProductivityGraph( + lastWeekStatExpanded, + lastWeekSummaryAnalysisModelProducer, + label = "Weekly productivity analysis", + modifier = Modifier.padding(horizontal = 32.dp) + ) + } + } + item { Spacer(Modifier) } + item { + Text( + "Last month", + style = typography.headlineSmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + } + item { + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text( + millisecondsToHoursMinutes(lastMonthAverageFocusTimes?.total() ?: 0), + style = typography.displaySmall, + fontFamily = openRundeClock + ) + Text( + "focus per day (avg)", + style = typography.titleSmall, + modifier = Modifier.padding(bottom = 6.3.dp) + ) + } + } + item { + TimeColumnChart( + lastMonthSummaryChartData.first, + modifier = Modifier.padding(start = 16.dp), + thickness = 8.dp, + xValueFormatter = CartesianValueFormatter { context, x, _ -> + context.model.extraStore[lastMonthSummaryChartData.second][x.toInt()] + } + ) + } + item { + val iconRotation by animateFloatAsState( + if (lastMonthStatExpanded) 180f else 0f, + animationSpec = motionScheme.defaultSpatialSpec() + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Spacer(Modifier.height(2.dp)) + FilledTonalIconToggleButton( + checked = lastMonthStatExpanded, + onCheckedChange = { lastMonthStatExpanded = it }, + shapes = IconButtonDefaults.toggleableShapes(), + modifier = Modifier + .padding(horizontal = 16.dp) + .width(52.dp) + .align(Alignment.End) + ) { + Icon( + painterResource(R.drawable.arrow_down), + "More info", + modifier = Modifier.rotate(iconRotation) + ) + } + ProductivityGraph( + lastMonthStatExpanded, + lastMonthSummaryAnalysisModelProducer, + label = "Monthly productivity analysis", + modifier = Modifier.padding(horizontal = 32.dp) + ) + } + Spacer(Modifier.height(16.dp)) } } } -} \ No newline at end of file +} + +@Preview( + showSystemUi = true, + device = Devices.PIXEL_9_PRO +) +@Composable +fun StatsScreenPreview() { + val modelProducer = remember { CartesianChartModelProducer() } + + runBlocking { + modelProducer.runTransaction { + columnSeries { + series(5, 6, 5, 2, 11, 8, 5, 2, 15, 11, 8, 13, 12, 10, 2, 7) + } + } + } + + StatsScreen( + Pair(modelProducer, ExtraStore.Key()), + modelProducer, + Pair(modelProducer, ExtraStore.Key()), + modelProducer, + null, + null, + null + ) +} diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/TimeColumnChart.kt b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/TimeColumnChart.kt new file mode 100644 index 0000000..a085850 --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/TimeColumnChart.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.nsh07.pomodoro.ui.statsScreen + +import android.graphics.Path +import android.graphics.RectF +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme.motionScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom +import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberColumnCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState +import com.patrykandpatrick.vico.compose.common.ProvideVicoTheme +import com.patrykandpatrick.vico.compose.common.component.rememberLineComponent +import com.patrykandpatrick.vico.compose.common.fill +import com.patrykandpatrick.vico.compose.common.vicoTheme +import com.patrykandpatrick.vico.compose.m3.common.rememberM3VicoTheme +import com.patrykandpatrick.vico.core.cartesian.Zoom +import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis +import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter +import com.patrykandpatrick.vico.core.cartesian.layer.ColumnCartesianLayer +import com.patrykandpatrick.vico.core.common.Fill +import org.nsh07.pomodoro.utils.millisecondsToHours + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun TimeColumnChart( + modelProducer: CartesianChartModelProducer, + modifier: Modifier = Modifier, + thickness: Dp = 40.dp, + columnCollectionSpacing: Dp = 4.dp, + xValueFormatter: CartesianValueFormatter = CartesianValueFormatter.Default, + yValueFormatter: CartesianValueFormatter = CartesianValueFormatter { measuringContext, value, _ -> + millisecondsToHours(value.toLong()) + }, + animationSpec: AnimationSpec? = motionScheme.slowEffectsSpec() +) { + val radius = with(LocalDensity.current) { + (thickness / 2).toPx() + } + ProvideVicoTheme(rememberM3VicoTheme()) { + CartesianChartHost( + chart = + rememberCartesianChart( + rememberColumnCartesianLayer( + ColumnCartesianLayer.ColumnProvider.series( + vicoTheme.columnCartesianLayerColors.map { color -> + rememberLineComponent( + fill = fill(color), + thickness = thickness, + shape = { _, path, left, top, right, bottom -> + if (top + radius <= bottom - radius) { + path.arcTo( + RectF(left, top, right, top + 2 * radius), + 180f, + 180f + ) + path.lineTo(right, bottom - radius) + path.arcTo( + RectF(left, bottom - 2 * radius, right, bottom), + 0f, + 180f + ) + path.close() + } else { + path.addCircle( + left + radius, + bottom - radius, + radius, + Path.Direction.CW + ) + } + } + ) + } + ), + columnCollectionSpacing = columnCollectionSpacing + ), + startAxis = VerticalAxis.rememberStart( + line = rememberLineComponent(Fill.Transparent), + tick = rememberLineComponent(Fill.Transparent), + guideline = rememberLineComponent(Fill.Transparent), + valueFormatter = yValueFormatter + ), + bottomAxis = HorizontalAxis.rememberBottom( + rememberLineComponent(Fill.Transparent), + tick = rememberLineComponent(Fill.Transparent), + guideline = rememberLineComponent(Fill.Transparent), + valueFormatter = xValueFormatter + ) + ), + modelProducer = modelProducer, + zoomState = rememberVicoZoomState( + zoomEnabled = false, + initialZoom = Zoom.fixed(), + minZoom = Zoom.min(Zoom.Content, Zoom.fixed()) + ), + animationSpec = animationSpec, + modifier = modifier, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt new file mode 100644 index 0000000..73f4f8e --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.nsh07.pomodoro.ui.statsScreen.viewModel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.core.cartesian.data.columnSeries +import com.patrykandpatrick.vico.core.common.data.ExtraStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import org.nsh07.pomodoro.TomatoApplication +import org.nsh07.pomodoro.data.StatRepository +import java.time.format.TextStyle +import java.util.Locale + +class StatsViewModel( + statRepository: StatRepository +) : ViewModel() { + + val todayStat = statRepository.getTodayStat().distinctUntilChanged() + private val lastWeekStatsSummary = statRepository.getLastNDaysStatsSummary(7) + val lastWeekAverageFocusTimes = + statRepository.getLastNDaysAverageFocusTimes(7).distinctUntilChanged() + private val lastMonthStatsSummary = statRepository.getLastNDaysStatsSummary(30) + val lastMonthAverageFocusTimes = + statRepository.getLastNDaysAverageFocusTimes(30).distinctUntilChanged() + + val lastWeekSummaryChartData = + Pair(CartesianChartModelProducer(), ExtraStore.Key>()) + val lastWeekSummaryAnalysisModelProducer = CartesianChartModelProducer() + + val lastMonthSummaryChartData = + Pair(CartesianChartModelProducer(), ExtraStore.Key>()) + val lastMonthSummaryAnalysisModelProducer = CartesianChartModelProducer() + + init { + viewModelScope.launch(Dispatchers.IO) { + lastWeekStatsSummary + .collect { list -> + // reversing is required because we need ascending order while the DB returns descending order + val reversed = list.reversed() + val keys = reversed.map { + it.date.dayOfWeek.getDisplayName( + TextStyle.NARROW, + Locale.getDefault() + ) + } + val values = reversed.map { it.focusTime } + lastWeekSummaryChartData.first.runTransaction { + columnSeries { series(values) } + extras { it[lastWeekSummaryChartData.second] = keys } + } + } + } + viewModelScope.launch(Dispatchers.IO) { + lastWeekAverageFocusTimes + .collect { + lastWeekSummaryAnalysisModelProducer.runTransaction { + columnSeries { + series( + it?.focusTimeQ1 ?: 0, + it?.focusTimeQ2 ?: 0, + it?.focusTimeQ3 ?: 0, + it?.focusTimeQ4 ?: 0 + ) + } + } + } + } + viewModelScope.launch(Dispatchers.IO) { + lastMonthStatsSummary + .collect { list -> + val reversed = list.reversed() + val keys = reversed.map { it.date.dayOfMonth.toString() } + val values = reversed.map { it.focusTime } + lastMonthSummaryChartData.first.runTransaction { + columnSeries { series(values) } + extras { it[lastMonthSummaryChartData.second] = keys } + } + } + } + viewModelScope.launch(Dispatchers.IO) { + lastMonthAverageFocusTimes + .collect { + lastMonthSummaryAnalysisModelProducer.runTransaction { + columnSeries { + series( + it?.focusTimeQ1 ?: 0, + it?.focusTimeQ2 ?: 0, + it?.focusTimeQ3 ?: 0, + it?.focusTimeQ4 ?: 0 + ) + } + } + } + } + } + + companion object { + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { + val application = (this[APPLICATION_KEY] as TomatoApplication) + val appStatRepository = application.container.appStatRepository + + StatsViewModel(appStatRepository) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/theme/Type.kt b/app/src/main/java/org/nsh07/pomodoro/ui/theme/Type.kt index a2966f0..8231eb0 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/theme/Type.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/theme/Type.kt @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.nsh07.pomodoro.ui.theme import androidx.compose.material3.Typography @@ -9,6 +16,8 @@ import androidx.compose.ui.text.font.FontVariation import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp import org.nsh07.pomodoro.R +import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexHeadline +import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTitle // Set of Material typography styles to start with val Typography = Typography( @@ -18,6 +27,24 @@ val Typography = Typography( fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp + ), + headlineSmall = TextStyle( + fontFamily = robotoFlexHeadline, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + titleMedium = TextStyle( + fontFamily = robotoFlexTitle, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + titleSmall = TextStyle( + fontFamily = robotoFlexTitle, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp ) ) @@ -27,7 +54,7 @@ object AppFonts { ) @OptIn(ExperimentalTextApi::class) - val robotoFlexTitle = FontFamily( + val robotoFlexTopBar = FontFamily( Font( R.font.roboto_flex_variable, variationSettings = FontVariation.Settings( @@ -45,4 +72,28 @@ object AppFonts { ) ) ) + + @OptIn(ExperimentalTextApi::class) + val robotoFlexHeadline = FontFamily( + Font( + R.font.roboto_flex_variable, + variationSettings = FontVariation.Settings( + FontVariation.width(130f), + FontVariation.weight(600), + FontVariation.grade(0) + ) + ) + ) + + @OptIn(ExperimentalTextApi::class) + val robotoFlexTitle = FontFamily( + Font( + R.font.roboto_flex_variable, + variationSettings = FontVariation.Settings( + FontVariation.width(130f), + FontVariation.weight(700), + FontVariation.grade(0) + ) + ) + ) } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt index 0153c8c..d731065 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.nsh07.pomodoro.ui.timerScreen import androidx.compose.animation.AnimatedContent @@ -51,8 +58,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.nsh07.pomodoro.R import org.nsh07.pomodoro.ui.theme.AppFonts.openRundeClock -import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTitle +import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar 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 @@ -60,11 +68,8 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState @Composable fun TimerScreen( timerState: TimerState, - showBrandTitle: Boolean, progress: () -> Float, - resetTimer: () -> Unit, - skipTimer: () -> Unit, - toggleTimer: () -> Unit, + onAction: (TimerAction) -> Unit, modifier: Modifier = Modifier ) { val motionScheme = motionScheme @@ -89,7 +94,7 @@ fun TimerScreen( TopAppBar( title = { AnimatedContent( - if (!showBrandTitle) timerState.timerMode else TimerMode.BRAND, + if (!timerState.showBrandTitle) timerState.timerMode else TimerMode.BRAND, transitionSpec = { slideInVertically( animationSpec = motionScheme.slowSpatialSpec(), @@ -107,7 +112,7 @@ fun TimerScreen( Text( "Tomato", style = TextStyle( - fontFamily = robotoFlexTitle, + fontFamily = robotoFlexTopBar, fontSize = 32.sp, lineHeight = 32.sp, color = colorScheme.onErrorContainer @@ -120,7 +125,7 @@ fun TimerScreen( Text( "Focus", style = TextStyle( - fontFamily = robotoFlexTitle, + fontFamily = robotoFlexTopBar, fontSize = 32.sp, lineHeight = 32.sp, color = colorScheme.onPrimaryContainer @@ -132,7 +137,7 @@ fun TimerScreen( TimerMode.SHORT_BREAK -> Text( "Short Break", style = TextStyle( - fontFamily = robotoFlexTitle, + fontFamily = robotoFlexTopBar, fontSize = 32.sp, lineHeight = 32.sp, color = colorScheme.onTertiaryContainer @@ -144,7 +149,7 @@ fun TimerScreen( TimerMode.LONG_BREAK -> Text( "Long Break", style = TextStyle( - fontFamily = robotoFlexTitle, + fontFamily = robotoFlexTopBar, fontSize = 32.sp, lineHeight = 32.sp, color = colorScheme.onTertiaryContainer @@ -245,7 +250,7 @@ fun TimerScreen( customItem( { FilledIconToggleButton( - onCheckedChange = { toggleTimer() }, + onCheckedChange = { onAction(TimerAction.ToggleTimer) }, checked = timerState.timerRunning, colors = IconButtonDefaults.filledIconToggleButtonColors( checkedContainerColor = color, @@ -289,7 +294,7 @@ fun TimerScreen( }, text = { Text(if (timerState.timerRunning) "Pause" else "Play") }, onClick = { - toggleTimer() + onAction(TimerAction.ToggleTimer) state.dismiss() } ) @@ -299,7 +304,7 @@ fun TimerScreen( customItem( { FilledTonalIconButton( - onClick = resetTimer, + onClick = { onAction(TimerAction.ResetTimer) }, colors = IconButtonDefaults.filledTonalIconButtonColors( containerColor = colorContainer ), @@ -326,7 +331,7 @@ fun TimerScreen( }, text = { Text("Restart") }, onClick = { - resetTimer() + onAction(TimerAction.ResetTimer) state.dismiss() } ) @@ -336,7 +341,7 @@ fun TimerScreen( customItem( { FilledTonalIconButton( - onClick = skipTimer, + onClick = { onAction(TimerAction.SkipTimer) }, colors = IconButtonDefaults.filledTonalIconButtonColors( containerColor = colorContainer ), @@ -363,7 +368,7 @@ fun TimerScreen( }, text = { Text("Skip to next") }, onClick = { - skipTimer() + onAction(TimerAction.SkipTimer) state.dismiss() } ) @@ -411,10 +416,7 @@ fun TimerScreenPreview() { TomatoTheme { TimerScreen( timerState, - false, { 0.3f }, - {}, - {}, {} ) } diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt new file mode 100644 index 0000000..633c5cb --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.nsh07.pomodoro.ui.timerScreen.viewModel + +sealed interface TimerAction { + data object ResetTimer : TimerAction + data object SkipTimer : TimerAction + data object ToggleTimer : TimerAction +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerState.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerState.kt index e100237..f0ead74 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerState.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerState.kt @@ -1,12 +1,20 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.nsh07.pomodoro.ui.timerScreen.viewModel data class TimerState( val timerMode: TimerMode = TimerMode.FOCUS, val timeStr: String = "25:00", - val totalTime: Int = 25 * 60, + val totalTime: Long = 25 * 60, val timerRunning: Boolean = false, val nextTimerMode: TimerMode = TimerMode.SHORT_BREAK, - val nextTimeStr: String = "5:00" + val nextTimeStr: String = "5:00", + val showBrandTitle: Boolean = true ) enum class TimerMode { diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt index b266105..f94e8cf 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.nsh07.pomodoro.ui.timerScreen.viewModel import android.os.SystemClock @@ -17,30 +24,20 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.nsh07.pomodoro.TomatoApplication -import org.nsh07.pomodoro.data.AppPreferenceRepository +import org.nsh07.pomodoro.data.PreferenceRepository +import org.nsh07.pomodoro.data.Stat +import org.nsh07.pomodoro.data.StatRepository import org.nsh07.pomodoro.data.TimerRepository import org.nsh07.pomodoro.utils.millisecondsToStr +import java.time.LocalDate @OptIn(FlowPreview::class) class TimerViewModel( - private val preferenceRepository: AppPreferenceRepository, + private val preferenceRepository: PreferenceRepository, + private val statRepository: StatRepository, private val timerRepository: TimerRepository ) : ViewModel() { - init { - viewModelScope.launch(Dispatchers.IO) { - timerRepository.focusTime = preferenceRepository.getIntPreference("focus_time") - ?: preferenceRepository.saveIntPreference("focus_time", timerRepository.focusTime) - timerRepository.shortBreakTime = preferenceRepository.getIntPreference("short_break_time") - ?: preferenceRepository.saveIntPreference("short_break_time", timerRepository.shortBreakTime) - timerRepository.longBreakTime = preferenceRepository.getIntPreference("long_break_time") - ?: preferenceRepository.saveIntPreference("long_break_time", timerRepository.longBreakTime) - timerRepository.sessionLength = preferenceRepository.getIntPreference("session_length") - ?: preferenceRepository.saveIntPreference("session_length", timerRepository.sessionLength) - - resetTimer() - } - } - + // TODO: Document code private val _timerState = MutableStateFlow( TimerState( totalTime = timerRepository.focusTime, @@ -48,73 +45,134 @@ class TimerViewModel( nextTimeStr = millisecondsToStr(timerRepository.shortBreakTime) ) ) + val timerState: StateFlow = _timerState.asStateFlow() var timerJob: Job? = null - private val _time = MutableStateFlow(timerRepository.focusTime) - val time: StateFlow = _time.asStateFlow() + val time: StateFlow = _time.asStateFlow() private var cycles = 0 + private var startTime = 0L private var pauseTime = 0L private var pauseDuration = 0L - fun resetTimer() { - _time.update { timerRepository.focusTime } - cycles = 0 - startTime = 0L - pauseTime = 0L - pauseDuration = 0L + init { + viewModelScope.launch(Dispatchers.IO) { + timerRepository.focusTime = + preferenceRepository.getIntPreference("focus_time")?.toLong() + ?: preferenceRepository.saveIntPreference( + "focus_time", + timerRepository.focusTime.toInt() + ).toLong() + timerRepository.shortBreakTime = + preferenceRepository.getIntPreference("short_break_time")?.toLong() + ?: preferenceRepository.saveIntPreference( + "short_break_time", + timerRepository.shortBreakTime.toInt() + ).toLong() + timerRepository.longBreakTime = + preferenceRepository.getIntPreference("long_break_time")?.toLong() + ?: preferenceRepository.saveIntPreference( + "long_break_time", + timerRepository.longBreakTime.toInt() + ).toLong() + timerRepository.sessionLength = preferenceRepository.getIntPreference("session_length") + ?: preferenceRepository.saveIntPreference( + "session_length", + timerRepository.sessionLength + ) - _timerState.update { currentState -> - currentState.copy( - timerMode = TimerMode.FOCUS, - timeStr = millisecondsToStr(time.value), - totalTime = time.value, - nextTimerMode = TimerMode.SHORT_BREAK, - nextTimeStr = millisecondsToStr(timerRepository.shortBreakTime) - ) + resetTimer() + + var lastDate = statRepository.getLastDate() + val today = LocalDate.now() + + // Fills dates between today and lastDate with 0s to ensure continuous history + while ((lastDate?.until(today)?.days ?: -1) > 0) { + lastDate = lastDate?.plusDays(1) + statRepository.insertStat(Stat(lastDate!!, 0, 0, 0, 0, 0)) + } + + delay(1500) + + _timerState.update { currentState -> + currentState.copy(showBrandTitle = false) + } } } - fun skipTimer() { - startTime = 0L - pauseTime = 0L - pauseDuration = 0L - cycles = (cycles + 1) % (timerRepository.sessionLength * 2) + fun onAction(action: TimerAction) { + when (action) { + TimerAction.ResetTimer -> resetTimer() + TimerAction.SkipTimer -> skipTimer() + TimerAction.ToggleTimer -> toggleTimer() + } + } - if (cycles % 2 == 0) { + private fun resetTimer() { + viewModelScope.launch { + saveTimeToDb() _time.update { timerRepository.focusTime } + cycles = 0 + startTime = 0L + pauseTime = 0L + pauseDuration = 0L + _timerState.update { currentState -> currentState.copy( timerMode = TimerMode.FOCUS, timeStr = millisecondsToStr(time.value), totalTime = time.value, - nextTimerMode = if (cycles == 6) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK, - nextTimeStr = if (cycles == 6) millisecondsToStr( - timerRepository.longBreakTime - ) else millisecondsToStr( - timerRepository.shortBreakTime - ) - ) - } - } else { - val long = cycles == (timerRepository.sessionLength * 2) - 1 - _time.update { if (long) timerRepository.longBreakTime else timerRepository.shortBreakTime } - - _timerState.update { currentState -> - currentState.copy( - timerMode = if (long) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK, - timeStr = millisecondsToStr(time.value), - totalTime = time.value, - nextTimerMode = TimerMode.FOCUS, - nextTimeStr = millisecondsToStr(timerRepository.focusTime) + nextTimerMode = TimerMode.SHORT_BREAK, + nextTimeStr = millisecondsToStr(timerRepository.shortBreakTime) ) } } } - fun toggleTimer() { + private fun skipTimer() { + viewModelScope.launch { + saveTimeToDb() + startTime = 0L + pauseTime = 0L + pauseDuration = 0L + + cycles = (cycles + 1) % (timerRepository.sessionLength * 2) + + if (cycles % 2 == 0) { + _time.update { timerRepository.focusTime } + _timerState.update { currentState -> + currentState.copy( + timerMode = TimerMode.FOCUS, + timeStr = millisecondsToStr(time.value), + totalTime = time.value, + nextTimerMode = if (cycles == 6) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK, + nextTimeStr = if (cycles == 6) millisecondsToStr( + timerRepository.longBreakTime + ) else millisecondsToStr( + timerRepository.shortBreakTime + ) + ) + } + } else { + val long = cycles == (timerRepository.sessionLength * 2) - 1 + _time.update { if (long) timerRepository.longBreakTime else timerRepository.shortBreakTime } + + _timerState.update { currentState -> + currentState.copy( + timerMode = if (long) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK, + timeStr = millisecondsToStr(time.value), + totalTime = time.value, + nextTimerMode = TimerMode.FOCUS, + nextTimeStr = millisecondsToStr(timerRepository.focusTime) + ) + } + } + } + } + + private fun toggleTimer() { if (timerState.value.timerRunning) { _timerState.update { currentState -> currentState.copy(timerRunning = false) @@ -164,15 +222,27 @@ class TimerViewModel( } } + suspend fun saveTimeToDb() { + when (timerState.value.timerMode) { + TimerMode.FOCUS -> statRepository + .addFocusTime((timerState.value.totalTime - time.value)) + + else -> statRepository + .addBreakTime((timerState.value.totalTime - time.value)) + } + } + companion object { val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { val application = (this[APPLICATION_KEY] as TomatoApplication) - val appPreferenceRepository = application.container.appPreferencesRepository + val appPreferenceRepository = application.container.appPreferenceRepository + val appStatRepository = application.container.appStatRepository val appTimerRepository = application.container.appTimerRepository TimerViewModel( preferenceRepository = appPreferenceRepository, + statRepository = appStatRepository, timerRepository = appTimerRepository ) } diff --git a/app/src/main/java/org/nsh07/pomodoro/utils/Utils.kt b/app/src/main/java/org/nsh07/pomodoro/utils/Utils.kt index 1bb75c2..47378e2 100644 --- a/app/src/main/java/org/nsh07/pomodoro/utils/Utils.kt +++ b/app/src/main/java/org/nsh07/pomodoro/utils/Utils.kt @@ -1,10 +1,33 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.nsh07.pomodoro.utils import java.util.Locale -import kotlin.math.ceil +import java.util.concurrent.TimeUnit -fun millisecondsToStr(t: Int): String { - val min = (ceil(t / 1000.0).toInt() / 60) - val sec = (ceil(t / 1000.0).toInt() % 60) - return String.format(locale = Locale.getDefault(), "%02d:%02d", min, sec) -} \ No newline at end of file +fun millisecondsToStr(t: Long): String = + String.format( + Locale.getDefault(), + "%02d:%02d", + TimeUnit.MILLISECONDS.toMinutes(t), + TimeUnit.MILLISECONDS.toSeconds(t) % TimeUnit.MINUTES.toSeconds(1) + ) + +fun millisecondsToHours(t: Long): String = + String.format( + Locale.getDefault(), + "%dh", + TimeUnit.MILLISECONDS.toHours(t) + ) + +fun millisecondsToHoursMinutes(t: Long): String = + String.format( + Locale.getDefault(), + "%dh %dm", TimeUnit.MILLISECONDS.toHours(t), + TimeUnit.MILLISECONDS.toMinutes(t) % TimeUnit.HOURS.toMinutes(1) + ) \ No newline at end of file diff --git a/app/src/main/res/drawable/arrow_down.xml b/app/src/main/res/drawable/arrow_down.xml new file mode 100644 index 0000000..d565e75 --- /dev/null +++ b/app/src/main/res/drawable/arrow_down.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/font/open_runde_bold_clock_only.otf b/app/src/main/res/font/open_runde_bold_clock_only.otf index 9c1b2b4..1c18ecf 100644 Binary files a/app/src/main/res/font/open_runde_bold_clock_only.otf and b/app/src/main/res/font/open_runde_bold_clock_only.otf differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png index fe1e1ac..a296647 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png index e960980..cd76715 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png index 34f0f93..0d34bd3 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png index a31175b..83c6065 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png index 45b2bdf..3ffb9e5 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png index 780260b..2d18d2a 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2696859..21ea12e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] activityCompose = "1.10.1" adaptive = "1.1.0" -agp = "8.11.0" +agp = "8.11.1" composeBom = "2025.06.02" coreKtx = "1.16.0" espressoCore = "3.6.1" @@ -12,6 +12,7 @@ ksp = "2.2.0-2.0.2" lifecycleRuntimeKtx = "2.9.1" navigation3Runtime = "1.0.0-alpha05" room = "2.7.2" +vico = "2.2.0-alpha.1" [libraries] androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } @@ -36,10 +37,11 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } junit = { group = "junit", name = "junit", version.ref = "junit" } +vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin"} +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ff23a68..d4081da 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME