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 @@
-
+
-### 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