Merge branch 'dev'
BIN
.github/repo_photos/banner.png
vendored
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
.github/repo_photos/bmc_qr.png
vendored
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
.github/repo_photos/sponsors.png
vendored
Normal file
|
After Width: | Height: | Size: 88 KiB |
42
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
|
||||
|
||||
<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
|
||||
@@ -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)
|
||||
|
||||
85
app/schemas/org.nsh07.pomodoro.data.AppDatabase/1.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
|
||||
23
app/src/main/java/org/nsh07/pomodoro/data/Converters.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
45
app/src/main/java/org/nsh07/pomodoro/data/Stat.kt
Normal 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
|
||||
}
|
||||
58
app/src/main/java/org/nsh07/pomodoro/data/StatDao.kt
Normal 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?
|
||||
}
|
||||
111
app/src/main/java/org/nsh07/pomodoro/data/StatRepository.kt
Normal 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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
{},
|
||||
{},
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
10
app/src/main/res/drawable/arrow_down.xml
Normal 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>
|
||||
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 314 KiB After Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 314 KiB After Width: | Height: | Size: 303 KiB |
@@ -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" }
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -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
|
||||
|
||||