feat: Add axis labels for charts, add productivity analysis for current week

This commit is contained in:
Nishant Mishra
2025-07-12 20:21:39 +05:30
parent 268d66e51d
commit 1c2db6d9ea
6 changed files with 114 additions and 31 deletions

View File

@@ -40,8 +40,17 @@ interface StatDao {
@Query("SELECT date, focusTimeQ1 + focusTimeQ2 + focusTimeQ3 + focusTimeQ4 as focusTime, breakTime FROM stat ORDER BY date DESC LIMIT 7") @Query("SELECT date, focusTimeQ1 + focusTimeQ2 + focusTimeQ3 + focusTimeQ4 as focusTime, breakTime FROM stat ORDER BY date DESC LIMIT 7")
fun getLastWeekStatsSummary(): Flow<List<StatSummary>> fun getLastWeekStatsSummary(): Flow<List<StatSummary>>
@Query("SELECT AVG(focusTimeQ1) AS focusTimeQ1, AVG(focusTimeQ2) AS focusTimeQ2, AVG(focusTimeQ3) AS focusTimeQ3, AVG(focusTimeQ4) AS focusTimeQ4 FROM stat") @Query(
fun getAvgFocusTimes(): Flow<StatFocusTime?> "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<StatFocusTime?>
@Query("SELECT EXISTS (SELECT * FROM stat WHERE date = :date)") @Query("SELECT EXISTS (SELECT * FROM stat WHERE date = :date)")
suspend fun statExists(date: LocalDate): Boolean suspend fun statExists(date: LocalDate): Boolean

View File

@@ -30,7 +30,7 @@ interface StatRepository {
fun getLastWeekStatsSummary(): Flow<List<StatSummary>> fun getLastWeekStatsSummary(): Flow<List<StatSummary>>
fun getAverageFocusTimes(): Flow<StatFocusTime?> fun getLastWeekAverageFocusTimes(): Flow<StatFocusTime?>
suspend fun getLastDate(): LocalDate? suspend fun getLastDate(): LocalDate?
} }
@@ -104,7 +104,8 @@ class AppStatRepository(
override fun getLastWeekStatsSummary(): Flow<List<StatSummary>> = override fun getLastWeekStatsSummary(): Flow<List<StatSummary>> =
statDao.getLastWeekStatsSummary() statDao.getLastWeekStatsSummary()
override fun getAverageFocusTimes(): Flow<StatFocusTime?> = statDao.getAvgFocusTimes() override fun getLastWeekAverageFocusTimes(): Flow<StatFocusTime?> =
statDao.getLastWeekAvgFocusTimes()
override suspend fun getLastDate(): LocalDate? = statDao.getLastDate() override suspend fun getLastDate(): LocalDate? = statDao.getLastDate()
} }

View File

@@ -25,18 +25,16 @@ import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes
fun ColumnScope.ProductivityGraph( fun ColumnScope.ProductivityGraph(
expanded: Boolean, expanded: Boolean,
modelProducer: CartesianChartModelProducer, modelProducer: CartesianChartModelProducer,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
label: String = "Productivity analysis"
) { ) {
AnimatedVisibility(expanded) { AnimatedVisibility(expanded) {
Column(modifier = modifier) { Column(modifier = modifier) {
Text("Productivity analysis", style = typography.titleMedium) Text(label, style = typography.titleMedium)
Text("Time of day versus focus hours", style = typography.bodySmall) Text("Time of day versus focus hours", style = typography.bodySmall)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
TimeColumnChart( TimeColumnChart(
modelProducer, modelProducer,
yValueFormatter = CartesianValueFormatter { _, value, _ ->
millisecondsToHoursMinutes(value.toLong())
},
xValueFormatter = CartesianValueFormatter { _, value, _ -> xValueFormatter = CartesianValueFormatter { _, value, _ ->
when (value) { when (value) {
0.0 -> "0 - 6" 0.0 -> "0 - 6"
@@ -45,7 +43,11 @@ fun ColumnScope.ProductivityGraph(
3.0 -> "18 - 24" 3.0 -> "18 - 24"
else -> "" else -> ""
} }
} },
yValueFormatter = CartesianValueFormatter { _, value, _ ->
millisecondsToHoursMinutes(value.toLong())
},
animationSpec = null
) )
} }
} }

View File

@@ -49,7 +49,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer 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.cartesian.data.columnSeries
import com.patrykandpatrick.vico.core.common.data.ExtraStore
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.nsh07.pomodoro.R import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.data.Stat import org.nsh07.pomodoro.data.Stat
@@ -65,8 +67,9 @@ fun StatsScreenRoot(
) { ) {
val todayStat by viewModel.todayStat.collectAsState(null) val todayStat by viewModel.todayStat.collectAsState(null)
StatsScreen( StatsScreen(
lastWeekSummaryModelProducer = viewModel.lastWeekSummaryChartModelProducer, lastWeekSummaryChartData = viewModel.lastWeekSummaryChartData,
todayStatModelProducer = viewModel.todayStatModelProducer, lastWeekSummaryAnalysisModelProducer = viewModel.lastWeekSummaryAnalysisModelProducer,
todayStatAnalysisModelProducer = viewModel.todayStatAnalysisModelProducer,
todayStat = todayStat, todayStat = todayStat,
modifier = modifier modifier = modifier
) )
@@ -75,14 +78,16 @@ fun StatsScreenRoot(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun StatsScreen( fun StatsScreen(
lastWeekSummaryModelProducer: CartesianChartModelProducer, lastWeekSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
todayStatModelProducer: CartesianChartModelProducer, lastWeekSummaryAnalysisModelProducer: CartesianChartModelProducer,
todayStatAnalysisModelProducer: CartesianChartModelProducer,
todayStat: Stat?, todayStat: Stat?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
var todayStatExpanded by rememberSaveable { mutableStateOf(false) } var todayStatExpanded by rememberSaveable { mutableStateOf(false) }
var lastWeekStatExpanded by rememberSaveable { mutableStateOf(false) }
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -181,6 +186,7 @@ fun StatsScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Spacer(Modifier.height(2.dp))
FilledTonalIconToggleButton( FilledTonalIconToggleButton(
checked = todayStatExpanded, checked = todayStatExpanded,
onCheckedChange = { todayStatExpanded = it }, onCheckedChange = { todayStatExpanded = it },
@@ -198,7 +204,7 @@ fun StatsScreen(
} }
ProductivityGraph( ProductivityGraph(
todayStatExpanded, todayStatExpanded,
todayStatModelProducer, todayStatAnalysisModelProducer,
Modifier.padding(horizontal = 32.dp) Modifier.padding(horizontal = 32.dp)
) )
} }
@@ -215,10 +221,47 @@ fun StatsScreen(
} }
item { item {
TimeColumnChart( TimeColumnChart(
lastWeekSummaryModelProducer, lastWeekSummaryChartData.first,
modifier = Modifier.padding(start = 16.dp) 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( StatsScreen(
Pair(modelProducer, ExtraStore.Key()),
modelProducer, modelProducer,
modelProducer, modelProducer,
null null

View File

@@ -9,6 +9,8 @@ package org.nsh07.pomodoro.ui.statsScreen
import android.graphics.Path import android.graphics.Path
import android.graphics.RectF import android.graphics.RectF
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.tween
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -45,7 +47,8 @@ internal fun TimeColumnChart(
xValueFormatter: CartesianValueFormatter = CartesianValueFormatter.Default, xValueFormatter: CartesianValueFormatter = CartesianValueFormatter.Default,
yValueFormatter: CartesianValueFormatter = CartesianValueFormatter { measuringContext, value, _ -> yValueFormatter: CartesianValueFormatter = CartesianValueFormatter { measuringContext, value, _ ->
millisecondsToHours(value.toLong()) millisecondsToHours(value.toLong())
} },
animationSpec: AnimationSpec<Float>? = tween(500)
) { ) {
val radius = with(LocalDensity.current) { val radius = with(LocalDensity.current) {
(thickness / 2).toPx() (thickness / 2).toPx()
@@ -107,6 +110,7 @@ internal fun TimeColumnChart(
initialZoom = Zoom.fixed(), initialZoom = Zoom.fixed(),
minZoom = Zoom.min(Zoom.Content, Zoom.fixed()) minZoom = Zoom.min(Zoom.Content, Zoom.fixed())
), ),
animationSpec = animationSpec,
modifier = modifier, modifier = modifier,
) )
} }

View File

@@ -15,38 +15,61 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.core.cartesian.data.columnSeries import com.patrykandpatrick.vico.core.cartesian.data.columnSeries
import com.patrykandpatrick.vico.core.common.data.ExtraStore
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.nsh07.pomodoro.TomatoApplication import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.data.StatRepository import org.nsh07.pomodoro.data.StatRepository
import java.time.format.DateTimeFormatter
class StatsViewModel( class StatsViewModel(
statRepository: StatRepository statRepository: StatRepository
) : ViewModel() { ) : ViewModel() {
val todayStat = statRepository.getTodayStat().distinctUntilChanged() private val dayFormatter = DateTimeFormatter.ofPattern("E")
private val allStatsSummary = statRepository.getLastWeekStatsSummary()
private val averageFocusTimes = statRepository.getAverageFocusTimes()
val lastWeekSummaryChartModelProducer = CartesianChartModelProducer() val todayStat = statRepository.getTodayStat().distinctUntilChanged()
val todayStatModelProducer = CartesianChartModelProducer() private val lastWeekStatsSummary = statRepository.getLastWeekStatsSummary()
private val lastWeekAverageFocusTimes = statRepository.getLastWeekAverageFocusTimes()
val lastWeekSummaryChartData =
Pair(CartesianChartModelProducer(), ExtraStore.Key<List<String>>())
val lastWeekSummaryAnalysisModelProducer = CartesianChartModelProducer()
val todayStatAnalysisModelProducer = CartesianChartModelProducer()
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
allStatsSummary lastWeekStatsSummary
.collect { list -> .collect { list ->
lastWeekSummaryChartModelProducer.runTransaction { // reversing is required because we need ascending order while the DB returns descending order
columnSeries { val reversed = list.reversed()
// reversing is required because we need ascending order while the DB returns descending order val keys = reversed.map { it.date.format(dayFormatter) }
series(list.reversed().map { it.focusTime }) val values = reversed.map { it.focusTime }
} lastWeekSummaryChartData.first.runTransaction {
columnSeries { series(values) }
extras { it[lastWeekSummaryChartData.second] = keys }
} }
} }
} }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
todayStat todayStat
.collect { .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 { columnSeries {
series( series(
it?.focusTimeQ1 ?: 0, it?.focusTimeQ1 ?: 0,