From 1c2db6d9ea7b65206ffa29b494b7fdd7b3c3af57 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sat, 12 Jul 2025 20:21:39 +0530 Subject: [PATCH] feat: Add axis labels for charts, add productivity analysis for current week --- .../java/org/nsh07/pomodoro/data/StatDao.kt | 13 +++- .../org/nsh07/pomodoro/data/StatRepository.kt | 5 +- .../ui/statsScreen/ProductivityGraph.kt | 14 +++-- .../pomodoro/ui/statsScreen/StatsScreen.kt | 60 ++++++++++++++++--- .../ui/statsScreen/TimeColumnChart.kt | 6 +- .../statsScreen/viewModel/StatsViewModel.kt | 47 +++++++++++---- 6 files changed, 114 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/org/nsh07/pomodoro/data/StatDao.kt b/app/src/main/java/org/nsh07/pomodoro/data/StatDao.kt index d18026b..995106d 100644 --- a/app/src/main/java/org/nsh07/pomodoro/data/StatDao.kt +++ b/app/src/main/java/org/nsh07/pomodoro/data/StatDao.kt @@ -40,8 +40,17 @@ interface StatDao { @Query("SELECT date, focusTimeQ1 + focusTimeQ2 + focusTimeQ3 + focusTimeQ4 as focusTime, breakTime FROM stat ORDER BY date DESC LIMIT 7") fun getLastWeekStatsSummary(): Flow> - @Query("SELECT AVG(focusTimeQ1) AS focusTimeQ1, AVG(focusTimeQ2) AS focusTimeQ2, AVG(focusTimeQ3) AS focusTimeQ3, AVG(focusTimeQ4) AS focusTimeQ4 FROM stat") - fun getAvgFocusTimes(): Flow + @Query( + "SELECT " + + "AVG(focusTimeQ1) AS focusTimeQ1, " + + "AVG(focusTimeQ2) AS focusTimeQ2, " + + "AVG(focusTimeQ3) AS focusTimeQ3, " + + "AVG(focusTimeQ4) AS focusTimeQ4 " + + "FROM (" + + "SELECT focusTimeQ1, focusTimeQ2, focusTimeQ3, focusTimeQ4 FROM stat ORDER BY date DESC LIMIT 7" + + ")" + ) + fun getLastWeekAvgFocusTimes(): Flow @Query("SELECT EXISTS (SELECT * FROM stat WHERE date = :date)") suspend fun statExists(date: LocalDate): Boolean diff --git a/app/src/main/java/org/nsh07/pomodoro/data/StatRepository.kt b/app/src/main/java/org/nsh07/pomodoro/data/StatRepository.kt index be027ba..ffaf6ef 100644 --- a/app/src/main/java/org/nsh07/pomodoro/data/StatRepository.kt +++ b/app/src/main/java/org/nsh07/pomodoro/data/StatRepository.kt @@ -30,7 +30,7 @@ interface StatRepository { fun getLastWeekStatsSummary(): Flow> - fun getAverageFocusTimes(): Flow + fun getLastWeekAverageFocusTimes(): Flow suspend fun getLastDate(): LocalDate? } @@ -104,7 +104,8 @@ class AppStatRepository( override fun getLastWeekStatsSummary(): Flow> = statDao.getLastWeekStatsSummary() - override fun getAverageFocusTimes(): Flow = statDao.getAvgFocusTimes() + override fun getLastWeekAverageFocusTimes(): Flow = + statDao.getLastWeekAvgFocusTimes() override suspend fun getLastDate(): LocalDate? = statDao.getLastDate() } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/ProductivityGraph.kt b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/ProductivityGraph.kt index ceb2fb1..7d47a63 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/ProductivityGraph.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/ProductivityGraph.kt @@ -25,18 +25,16 @@ import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes fun ColumnScope.ProductivityGraph( expanded: Boolean, modelProducer: CartesianChartModelProducer, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + label: String = "Productivity analysis" ) { AnimatedVisibility(expanded) { Column(modifier = modifier) { - Text("Productivity analysis", style = typography.titleMedium) + Text(label, style = typography.titleMedium) Text("Time of day versus focus hours", style = typography.bodySmall) Spacer(Modifier.height(8.dp)) TimeColumnChart( modelProducer, - yValueFormatter = CartesianValueFormatter { _, value, _ -> - millisecondsToHoursMinutes(value.toLong()) - }, xValueFormatter = CartesianValueFormatter { _, value, _ -> when (value) { 0.0 -> "0 - 6" @@ -45,7 +43,11 @@ fun ColumnScope.ProductivityGraph( 3.0 -> "18 - 24" else -> "" } - } + }, + yValueFormatter = CartesianValueFormatter { _, value, _ -> + millisecondsToHoursMinutes(value.toLong()) + }, + animationSpec = null ) } } diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/StatsScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/StatsScreen.kt index d7c2b6c..e98ea26 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/StatsScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/StatsScreen.kt @@ -49,7 +49,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp 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 @@ -65,8 +67,9 @@ fun StatsScreenRoot( ) { val todayStat by viewModel.todayStat.collectAsState(null) StatsScreen( - lastWeekSummaryModelProducer = viewModel.lastWeekSummaryChartModelProducer, - todayStatModelProducer = viewModel.todayStatModelProducer, + lastWeekSummaryChartData = viewModel.lastWeekSummaryChartData, + lastWeekSummaryAnalysisModelProducer = viewModel.lastWeekSummaryAnalysisModelProducer, + todayStatAnalysisModelProducer = viewModel.todayStatAnalysisModelProducer, todayStat = todayStat, modifier = modifier ) @@ -75,14 +78,16 @@ fun StatsScreenRoot( @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun StatsScreen( - lastWeekSummaryModelProducer: CartesianChartModelProducer, - todayStatModelProducer: CartesianChartModelProducer, + lastWeekSummaryChartData: Pair>>, + lastWeekSummaryAnalysisModelProducer: CartesianChartModelProducer, + todayStatAnalysisModelProducer: CartesianChartModelProducer, todayStat: Stat?, modifier: Modifier = Modifier ) { - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() var todayStatExpanded by rememberSaveable { mutableStateOf(false) } + var lastWeekStatExpanded by rememberSaveable { mutableStateOf(false) } Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -181,6 +186,7 @@ fun StatsScreen( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth() ) { + Spacer(Modifier.height(2.dp)) FilledTonalIconToggleButton( checked = todayStatExpanded, onCheckedChange = { todayStatExpanded = it }, @@ -198,7 +204,7 @@ fun StatsScreen( } ProductivityGraph( todayStatExpanded, - todayStatModelProducer, + todayStatAnalysisModelProducer, Modifier.padding(horizontal = 32.dp) ) } @@ -215,10 +221,47 @@ fun StatsScreen( } item { TimeColumnChart( - lastWeekSummaryModelProducer, - modifier = Modifier.padding(start = 16.dp) + 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) + ) + } + Spacer(Modifier.height(16.dp)) + } } } } @@ -240,6 +283,7 @@ fun StatsScreenPreview() { } StatsScreen( + Pair(modelProducer, ExtraStore.Key()), modelProducer, modelProducer, null diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/TimeColumnChart.kt b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/TimeColumnChart.kt index d468ea4..e622c16 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/TimeColumnChart.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/TimeColumnChart.kt @@ -9,6 +9,8 @@ package org.nsh07.pomodoro.ui.statsScreen import android.graphics.Path import android.graphics.RectF +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.tween import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -45,7 +47,8 @@ internal fun TimeColumnChart( xValueFormatter: CartesianValueFormatter = CartesianValueFormatter.Default, yValueFormatter: CartesianValueFormatter = CartesianValueFormatter { measuringContext, value, _ -> millisecondsToHours(value.toLong()) - } + }, + animationSpec: AnimationSpec? = tween(500) ) { val radius = with(LocalDensity.current) { (thickness / 2).toPx() @@ -107,6 +110,7 @@ internal fun TimeColumnChart( initialZoom = Zoom.fixed(), minZoom = Zoom.min(Zoom.Content, Zoom.fixed()) ), + animationSpec = animationSpec, modifier = modifier, ) } diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt index 296456a..dd28133 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt @@ -15,38 +15,61 @@ 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.DateTimeFormatter class StatsViewModel( statRepository: StatRepository ) : ViewModel() { - val todayStat = statRepository.getTodayStat().distinctUntilChanged() - private val allStatsSummary = statRepository.getLastWeekStatsSummary() - private val averageFocusTimes = statRepository.getAverageFocusTimes() + private val dayFormatter = DateTimeFormatter.ofPattern("E") - val lastWeekSummaryChartModelProducer = CartesianChartModelProducer() - val todayStatModelProducer = CartesianChartModelProducer() + val todayStat = statRepository.getTodayStat().distinctUntilChanged() + private val lastWeekStatsSummary = statRepository.getLastWeekStatsSummary() + private val lastWeekAverageFocusTimes = statRepository.getLastWeekAverageFocusTimes() + + val lastWeekSummaryChartData = + Pair(CartesianChartModelProducer(), ExtraStore.Key>()) + val lastWeekSummaryAnalysisModelProducer = CartesianChartModelProducer() + val todayStatAnalysisModelProducer = CartesianChartModelProducer() init { viewModelScope.launch(Dispatchers.IO) { - allStatsSummary + lastWeekStatsSummary .collect { list -> - lastWeekSummaryChartModelProducer.runTransaction { - columnSeries { - // reversing is required because we need ascending order while the DB returns descending order - series(list.reversed().map { it.focusTime }) - } + // reversing is required because we need ascending order while the DB returns descending order + val reversed = list.reversed() + val keys = reversed.map { it.date.format(dayFormatter) } + val values = reversed.map { it.focusTime } + lastWeekSummaryChartData.first.runTransaction { + columnSeries { series(values) } + extras { it[lastWeekSummaryChartData.second] = keys } } } } viewModelScope.launch(Dispatchers.IO) { todayStat .collect { - todayStatModelProducer.runTransaction { + todayStatAnalysisModelProducer.runTransaction { + columnSeries { + series( + it?.focusTimeQ1 ?: 0, + it?.focusTimeQ2 ?: 0, + it?.focusTimeQ3 ?: 0, + it?.focusTimeQ4 ?: 0 + ) + } + } + } + } + viewModelScope.launch(Dispatchers.IO) { + lastWeekAverageFocusTimes + .collect { + lastWeekSummaryAnalysisModelProducer.runTransaction { columnSeries { series( it?.focusTimeQ1 ?: 0,