Merge branch 'dev'

This commit is contained in:
Nishant Mishra
2025-07-13 11:32:47 +05:30
40 changed files with 1402 additions and 161 deletions

BIN
.github/repo_photos/banner.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

BIN
.github/repo_photos/bmc_qr.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
.github/repo_photos/sponsors.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -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
<p align="center" width="100%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="30%">
@@ -15,4 +23,26 @@ Tomato is a minimalist Pomodoro timer for Android based on Material 3 Expressive
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="30%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="30%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6.png" width="30%">
</p>
</p>
## 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):
<a href="https://github.com/sponsors/nsh07">
<img src=".github/repo_photos/sponsors.png" width="128px">
</a>
<a href="https://coff.ee/nsh07">
<img src=".github/repo_photos/bmc_qr.png" width="128px">
</a>
## 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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)

View File

@@ -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')"
]
}
}

View File

@@ -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)
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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() }
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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 {

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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")

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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))

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Stat?>
@Query("SELECT date, focusTimeQ1 + focusTimeQ2 + focusTimeQ3 + focusTimeQ4 as focusTime, breakTime FROM stat ORDER BY date DESC LIMIT :n")
fun getLastNDaysStatsSummary(n: Int): Flow<List<StatSummary>>
@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<StatFocusTime?>
@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?
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Stat?>
fun getLastNDaysStatsSummary(n: Int): Flow<List<StatSummary>>
fun getLastNDaysAverageFocusTimes(n: Int): Flow<StatFocusTime?>
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<Stat?> {
val currentDate = LocalDate.now()
return statDao.getStat(currentDate)
}
override fun getLastNDaysStatsSummary(n: Int): Flow<List<StatSummary>> =
statDao.getLastNDaysStatsSummary(n)
override fun getLastNDaysAverageFocusTimes(n: Int): Flow<StatFocusTime?> =
statDao.getLastNDaysAvgFocusTimes(n)
override suspend fun getLastDate(): LocalDate? = statDao.getLastDate()
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Screen.Timer> {
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<Screen.Stats> {
StatsScreen()
StatsScreenRoot(
viewModel = statsViewModel,
modifier = modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection),
bottom = contentPadding.calculateBottomPadding()
)
)
}
}
)

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
)

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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(

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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())
}
)
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
lastWeekSummaryAnalysisModelProducer: CartesianChartModelProducer,
lastMonthSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
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))
}
}
}
}
}
@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
)
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Float>? = 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,
)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<List<String>>())
val lastWeekSummaryAnalysisModelProducer = CartesianChartModelProducer()
val lastMonthSummaryChartData =
Pair(CartesianChartModelProducer(), ExtraStore.Key<List<String>>())
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)
}
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
)
)
)
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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 },
{},
{},
{}
)
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.ui.timerScreen.viewModel
sealed interface TimerAction {
data object ResetTimer : TimerAction
data object SkipTimer : TimerAction
data object ToggleTimer : TimerAction
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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 {

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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> = _timerState.asStateFlow()
var timerJob: Job? = null
private val _time = MutableStateFlow(timerRepository.focusTime)
val time: StateFlow<Int> = _time.asStateFlow()
val time: StateFlow<Long> = _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
)
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
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)
)

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,599q-8,0 -15,-2.5t-13,-8.5L268,404q-11,-11 -11,-28t11,-28q11,-11 28,-11t28,11l156,156 156,-156q11,-11 28,-11t28,11q11,11 11,28t-11,28L508,588q-6,6 -13,8.5t-15,2.5Z" />
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 303 KiB

View File

@@ -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" }

View File

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