feat(stats): implement a basic last month screen

This commit is contained in:
Nishant Mishra
2025-12-13 20:05:56 +05:30
parent 29873cea77
commit cf6eebad9a
6 changed files with 399 additions and 36 deletions

View File

@@ -54,6 +54,9 @@ sealed class Screen : NavKey {
@Serializable
object LastWeek : Stats()
@Serializable
object LastMonth : Stats()
}
}

View File

@@ -45,6 +45,7 @@ import com.patrykandpatrick.vico.core.common.data.ExtraStore
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.data.Stat
import org.nsh07.pomodoro.ui.Screen
import org.nsh07.pomodoro.ui.statsScreen.screens.LastMonthScreen
import org.nsh07.pomodoro.ui.statsScreen.screens.LastWeekScreen
import org.nsh07.pomodoro.ui.statsScreen.screens.StatsMainScreen
import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel
@@ -80,7 +81,7 @@ fun StatsScreenRoot(
lastYearSummaryChartData = lastYearSummaryChartData,
todayStat = todayStat,
lastWeekAnalysisValues = lastWeekAnalysisValues,
lastMonthAverageFocusTimes = lastMonthAnalysisValues,
lastMonthAnalysisValues = lastMonthAnalysisValues,
lastYearAverageFocusTimes = lastYearAnalysisValues,
generateSampleData = viewModel::generateSampleData,
modifier = modifier
@@ -101,7 +102,7 @@ fun StatsScreen(
lastYearSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
todayStat: Stat?,
lastWeekAnalysisValues: Pair<List<Long>, Long>,
lastMonthAverageFocusTimes: List<Int>,
lastMonthAnalysisValues: Pair<List<Long>, Long>,
lastYearAverageFocusTimes: List<Int>,
generateSampleData: () -> Unit,
modifier: Modifier = Modifier
@@ -138,7 +139,7 @@ fun StatsScreen(
lastYearSummaryChartData = lastYearSummaryChartData,
todayStat = todayStat,
lastWeekAverageFocusTimes = lastWeekAnalysisValues.first,
lastMonthAverageFocusTimes = lastMonthAverageFocusTimes,
lastMonthAverageFocusTimes = lastMonthAnalysisValues.first,
lastYearAverageFocusTimes = lastYearAverageFocusTimes,
generateSampleData = generateSampleData,
hoursFormat = hoursFormat,
@@ -157,9 +158,23 @@ fun StatsScreen(
entry<Screen.Stats.LastWeek> {
LastWeekScreen(
contentPadding = contentPadding,
lastWeekAnalysisValues = lastWeekAnalysisValues,
lastWeekSummaryValues = lastWeekSummaryValues,
lastWeekSummaryChartData = lastWeekSummaryChartData,
focusBreakdownValues = lastWeekAnalysisValues,
focusHistoryValues = lastWeekSummaryValues,
mainChartData = lastWeekSummaryChartData,
onBack = backStack::removeLastOrNull,
hoursMinutesFormat = hoursMinutesFormat,
hoursFormat = hoursFormat,
minutesFormat = minutesFormat,
axisTypeface = axisTypeface,
markerTypeface = markerTypeface
)
}
entry<Screen.Stats.LastMonth> {
LastMonthScreen(
contentPadding = contentPadding,
lastMonthAnalysisValues = lastMonthAnalysisValues,
lastMonthSummaryChartData = lastMonthSummaryChartData,
onBack = backStack::removeLastOrNull,
hoursMinutesFormat = hoursMinutesFormat,
hoursFormat = hoursFormat,

View File

@@ -0,0 +1,311 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* This file is part of Tomato - a minimalist pomodoro timer for Android.
*
* Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tomato.
* If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.ui.statsScreen.screens
import android.graphics.Typeface
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TonalToggleButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
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.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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.navigation3.ui.LocalNavAnimatedContentScope
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 org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.mergePaddingValues
import org.nsh07.pomodoro.ui.statsScreen.components.FocusBreakRatioVisualization
import org.nsh07.pomodoro.ui.statsScreen.components.FocusBreakdownChart
import org.nsh07.pomodoro.ui.statsScreen.components.HorizontalStackedBar
import org.nsh07.pomodoro.ui.statsScreen.components.TimeColumnChart
import org.nsh07.pomodoro.ui.statsScreen.components.sharedBoundsReveal
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape
import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes
import org.nsh07.pomodoro.utils.millisecondsToMinutes
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SharedTransitionScope.LastMonthScreen(
contentPadding: PaddingValues,
lastMonthAnalysisValues: Pair<List<Long>, Long>,
lastMonthSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
onBack: () -> Unit,
modifier: Modifier = Modifier,
hoursMinutesFormat: String,
hoursFormat: String,
minutesFormat: String,
axisTypeface: Typeface,
markerTypeface: Typeface
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val lastMonthSummaryAnalysisModelProducer = remember { CartesianChartModelProducer() }
var breakdownChartExpanded by remember { mutableStateOf(false) }
LaunchedEffect(lastMonthAnalysisValues.first) {
lastMonthSummaryAnalysisModelProducer.runTransaction {
columnSeries {
series(lastMonthAnalysisValues.first)
}
}
}
val rankList = remember(lastMonthAnalysisValues) {
val sortedIndices =
lastMonthAnalysisValues.first.indices.sortedByDescending { lastMonthAnalysisValues.first[it] }
val ranks = MutableList(lastMonthAnalysisValues.first.size) { 0 }
sortedIndices.forEachIndexed { rank, originalIndex ->
ranks[originalIndex] = rank
}
ranks
}
val focusDuration = remember(lastMonthAnalysisValues) {
lastMonthAnalysisValues.first.sum()
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.last_month),
fontFamily = robotoFlexTopBar,
modifier = Modifier.sharedBounds(
sharedContentState = this@LastMonthScreen
.rememberSharedContentState("last month heading"),
animatedVisibilityScope = LocalNavAnimatedContentScope.current
)
)
},
subtitle = {
Text(stringResource(R.string.stats))
},
navigationIcon = {
FilledTonalIconButton(
onClick = onBack,
shapes = IconButtonDefaults.shapes()
) {
Icon(
painterResource(R.drawable.arrow_back),
null
)
}
},
scrollBehavior = scrollBehavior
)
},
modifier = modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
.sharedBoundsReveal(
sharedTransitionScope = this@LastMonthScreen,
sharedContentState = this@LastMonthScreen.rememberSharedContentState(
"last month card"
),
animatedVisibilityScope = LocalNavAnimatedContentScope.current,
clipShape = middleListItemShape
)
) { innerPadding ->
val insets = mergePaddingValues(innerPadding, contentPadding)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = insets,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
item {
Spacer(Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
millisecondsToHoursMinutes(
focusDuration,
hoursMinutesFormat
),
style = typography.displaySmall,
modifier = Modifier
.sharedElement(
sharedContentState = this@LastMonthScreen
.rememberSharedContentState("last month average focus timer"),
animatedVisibilityScope = LocalNavAnimatedContentScope.current
)
)
Text(
stringResource(R.string.focus_per_day_avg),
style = typography.titleSmall,
modifier = Modifier
.padding(bottom = 5.2.dp)
.sharedElement(
sharedContentState = this@LastMonthScreen
.rememberSharedContentState("focus per day average (month)"),
animatedVisibilityScope = LocalNavAnimatedContentScope.current
)
)
}
}
item {
TimeColumnChart(
modelProducer = lastMonthSummaryChartData.first,
hoursFormat = hoursFormat,
hoursMinutesFormat = hoursMinutesFormat,
minutesFormat = minutesFormat,
axisTypeface = axisTypeface,
markerTypeface = markerTypeface,
thickness = 8.dp,
xValueFormatter = CartesianValueFormatter { context, x, _ ->
context.model.extraStore[lastMonthSummaryChartData.second][x.toInt()]
},
modifier = Modifier
.sharedElement(
sharedContentState = this@LastMonthScreen
.rememberSharedContentState("last month chart"),
animatedVisibilityScope = LocalNavAnimatedContentScope.current
)
)
}
item { Spacer(Modifier.height(8.dp)) }
item {
Text(
stringResource(R.string.focus_breakdown),
style = typography.headlineSmall
)
Text(
stringResource(R.string.focus_breakdown_desc),
style = typography.bodySmall,
color = colorScheme.onSurfaceVariant
)
}
item { HorizontalStackedBar(lastMonthAnalysisValues.first, rankList = rankList) }
item {
Row {
lastMonthAnalysisValues.first.fastForEach {
Text(
if (it <= 60 * 60 * 1000)
millisecondsToMinutes(it, minutesFormat)
else millisecondsToHoursMinutes(it, hoursMinutesFormat),
style = typography.bodyLarge,
textAlign = TextAlign.Center,
color = colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
}
}
}
item {
val iconRotation by animateFloatAsState(
if (breakdownChartExpanded) 180f else 0f
)
Column(modifier = Modifier.fillMaxWidth()) {
TonalToggleButton(
checked = breakdownChartExpanded,
onCheckedChange = { breakdownChartExpanded = it },
modifier = Modifier.align(Alignment.End)
) {
Icon(
painterResource(R.drawable.arrow_down),
stringResource(R.string.more_info),
modifier = Modifier.rotate(iconRotation)
)
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
Text("Show chart")
}
FocusBreakdownChart(
expanded = breakdownChartExpanded,
modelProducer = lastMonthSummaryAnalysisModelProducer,
modifier = Modifier.padding(top = 16.dp, bottom = 24.dp)
)
}
}
item {
Text(
stringResource(R.string.focus_break_ratio),
style = typography.headlineSmall
)
}
item {
FocusBreakRatioVisualization(
focusDuration = focusDuration,
breakDuration = lastMonthAnalysisValues.second
)
}
item { Spacer(Modifier.height(8.dp)) }
item {
Text(
"Focus history heatmap",
style = typography.headlineSmall
)
Text(
"Focus history of the past year. Brighter colors represent a longer focus duration.",
style = typography.bodySmall,
color = colorScheme.onSurfaceVariant
)
}
}
}
}

View File

@@ -81,9 +81,9 @@ import org.nsh07.pomodoro.utils.millisecondsToMinutes
@Composable
fun SharedTransitionScope.LastWeekScreen(
contentPadding: PaddingValues,
lastWeekAnalysisValues: Pair<List<Long>, Long>,
lastWeekSummaryValues: List<Pair<String, List<Long>>>,
lastWeekSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
focusBreakdownValues: Pair<List<Long>, Long>,
focusHistoryValues: List<Pair<String, List<Long>>>,
mainChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
onBack: () -> Unit,
modifier: Modifier = Modifier,
hoursMinutesFormat: String,
@@ -97,18 +97,18 @@ fun SharedTransitionScope.LastWeekScreen(
val lastWeekSummaryAnalysisModelProducer = remember { CartesianChartModelProducer() }
var breakdownChartExpanded by remember { mutableStateOf(false) }
LaunchedEffect(lastWeekAnalysisValues.first) {
LaunchedEffect(focusBreakdownValues.first) {
lastWeekSummaryAnalysisModelProducer.runTransaction {
columnSeries {
series(lastWeekAnalysisValues.first)
series(focusBreakdownValues.first)
}
}
}
val rankList = remember(lastWeekAnalysisValues) {
val rankList = remember(focusBreakdownValues) {
val sortedIndices =
lastWeekAnalysisValues.first.indices.sortedByDescending { lastWeekAnalysisValues.first[it] }
val ranks = MutableList(lastWeekAnalysisValues.first.size) { 0 }
focusBreakdownValues.first.indices.sortedByDescending { focusBreakdownValues.first[it] }
val ranks = MutableList(focusBreakdownValues.first.size) { 0 }
sortedIndices.forEachIndexed { rank, originalIndex ->
ranks[originalIndex] = rank
@@ -117,8 +117,8 @@ fun SharedTransitionScope.LastWeekScreen(
ranks
}
val focusDuration = remember(lastWeekAnalysisValues) {
lastWeekAnalysisValues.first.sum()
val focusDuration = remember(focusBreakdownValues) {
focusBreakdownValues.first.sum()
}
Scaffold(
@@ -205,14 +205,14 @@ fun SharedTransitionScope.LastWeekScreen(
}
item {
TimeColumnChart(
modelProducer = lastWeekSummaryChartData.first,
modelProducer = mainChartData.first,
hoursFormat = hoursFormat,
hoursMinutesFormat = hoursMinutesFormat,
minutesFormat = minutesFormat,
axisTypeface = axisTypeface,
markerTypeface = markerTypeface,
xValueFormatter = CartesianValueFormatter { context, x, _ ->
context.model.extraStore[lastWeekSummaryChartData.second][x.toInt()]
context.model.extraStore[mainChartData.second][x.toInt()]
},
modifier = Modifier
.sharedElement(
@@ -237,10 +237,10 @@ fun SharedTransitionScope.LastWeekScreen(
)
}
item { HorizontalStackedBar(lastWeekAnalysisValues.first, rankList = rankList) }
item { HorizontalStackedBar(focusBreakdownValues.first, rankList = rankList) }
item {
Row {
lastWeekAnalysisValues.first.fastForEach {
focusBreakdownValues.first.fastForEach {
Text(
if (it <= 60 * 60 * 1000)
millisecondsToMinutes(it, minutesFormat)
@@ -290,7 +290,7 @@ fun SharedTransitionScope.LastWeekScreen(
item {
FocusBreakRatioVisualization(
focusDuration = focusDuration,
breakDuration = lastWeekAnalysisValues.second
breakDuration = focusBreakdownValues.second
)
}
@@ -320,7 +320,7 @@ fun SharedTransitionScope.LastWeekScreen(
)
}
}
lastWeekSummaryValues.fastForEach {
focusHistoryValues.fastForEach {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
it.first,

View File

@@ -83,7 +83,7 @@ fun SharedTransitionScope.StatsMainScreen(
lastYearSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
todayStat: Stat?,
lastWeekAverageFocusTimes: List<Long>,
lastMonthAverageFocusTimes: List<Int>,
lastMonthAverageFocusTimes: List<Long>,
lastYearAverageFocusTimes: List<Int>,
generateSampleData: () -> Unit,
hoursMinutesFormat: String,
@@ -303,9 +303,17 @@ fun SharedTransitionScope.StatsMainScreen(
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.sharedBoundsReveal(
sharedTransitionScope = this@StatsMainScreen,
sharedContentState = this@StatsMainScreen.rememberSharedContentState(
"last month card"
),
animatedVisibilityScope = LocalNavAnimatedContentScope.current,
clipShape = middleListItemShape
)
.clip(middleListItemShape)
.background(listItemColors.containerColor)
.clickable {}
.clickable { onNavigate(Screen.Stats.LastMonth) }
.padding(
start = 20.dp,
top = 20.dp,
@@ -314,7 +322,12 @@ fun SharedTransitionScope.StatsMainScreen(
) {
Text(
stringResource(R.string.last_month),
style = typography.headlineSmall
style = typography.headlineSmall,
modifier = Modifier.sharedBounds(
sharedContentState = this@StatsMainScreen
.rememberSharedContentState("last month heading"),
animatedVisibilityScope = LocalNavAnimatedContentScope.current
)
)
Row(
@@ -324,16 +337,28 @@ fun SharedTransitionScope.StatsMainScreen(
Text(
millisecondsToHoursMinutes(
remember(lastMonthAverageFocusTimes) {
lastMonthAverageFocusTimes.sum().toLong()
lastMonthAverageFocusTimes.sum()
},
hoursMinutesFormat
),
style = typography.displaySmall
style = typography.displaySmall,
modifier = Modifier
.sharedElement(
sharedContentState = this@StatsMainScreen
.rememberSharedContentState("last month average focus timer"),
animatedVisibilityScope = LocalNavAnimatedContentScope.current
)
)
Text(
text = stringResource(R.string.focus_per_day_avg),
style = typography.titleSmall,
modifier = Modifier.padding(bottom = 5.2.dp)
modifier = Modifier
.padding(bottom = 5.2.dp)
.sharedElement(
sharedContentState = this@StatsMainScreen
.rememberSharedContentState("focus per day average (month)"),
animatedVisibilityScope = LocalNavAnimatedContentScope.current
)
)
}
@@ -347,7 +372,13 @@ fun SharedTransitionScope.StatsMainScreen(
thickness = 8.dp,
xValueFormatter = CartesianValueFormatter { context, x, _ ->
context.model.extraStore[lastMonthSummaryChartData.second][x.toInt()]
}
},
modifier = Modifier
.sharedElement(
sharedContentState = this@StatsMainScreen
.rememberSharedContentState("last month chart"),
animatedVisibilityScope = LocalNavAnimatedContentScope.current
)
)
}
}

View File

@@ -158,21 +158,24 @@ class StatsViewModel(
initialValue = lastMonthSummary
)
val lastMonthAverageFocusTimes: StateFlow<List<Int>> =
val lastMonthAverageFocusTimes: StateFlow<Pair<List<Long>, Long>> =
statRepository.getLastNDaysAverageFocusTimes(30)
.map {
listOf(
it?.focusTimeQ1?.toInt() ?: 0,
it?.focusTimeQ2?.toInt() ?: 0,
it?.focusTimeQ3?.toInt() ?: 0,
it?.focusTimeQ4?.toInt() ?: 0
Pair(
listOf(
it?.focusTimeQ1 ?: 0L,
it?.focusTimeQ2 ?: 0L,
it?.focusTimeQ3 ?: 0L,
it?.focusTimeQ4 ?: 0L
),
it?.breakTime ?: 0L
)
}
.flowOn(Dispatchers.IO)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = listOf(0, 0, 0, 0)
initialValue = Pair(listOf(0L, 0L, 0L, 0L), 0L)
)
val lastYearSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =