feat(stats): implement a basic last year screen

This commit is contained in:
Nishant Mishra
2025-12-14 08:57:54 +05:30
parent cf6eebad9a
commit b5cb26efda
5 changed files with 379 additions and 17 deletions

View File

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

View File

@@ -47,6 +47,7 @@ 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.LastYearScreen
import org.nsh07.pomodoro.ui.statsScreen.screens.StatsMainScreen
import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel
import org.nsh07.pomodoro.ui.theme.AppFonts.googleFlex400
@@ -82,7 +83,7 @@ fun StatsScreenRoot(
todayStat = todayStat,
lastWeekAnalysisValues = lastWeekAnalysisValues,
lastMonthAnalysisValues = lastMonthAnalysisValues,
lastYearAverageFocusTimes = lastYearAnalysisValues,
lastYearAnalysisValues = lastYearAnalysisValues,
generateSampleData = viewModel::generateSampleData,
modifier = modifier
)
@@ -103,7 +104,7 @@ fun StatsScreen(
todayStat: Stat?,
lastWeekAnalysisValues: Pair<List<Long>, Long>,
lastMonthAnalysisValues: Pair<List<Long>, Long>,
lastYearAverageFocusTimes: List<Int>,
lastYearAnalysisValues: Pair<List<Long>, Long>,
generateSampleData: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -140,7 +141,7 @@ fun StatsScreen(
todayStat = todayStat,
lastWeekAverageFocusTimes = lastWeekAnalysisValues.first,
lastMonthAverageFocusTimes = lastMonthAnalysisValues.first,
lastYearAverageFocusTimes = lastYearAverageFocusTimes,
lastYearAverageFocusTimes = lastYearAnalysisValues.first,
generateSampleData = generateSampleData,
hoursFormat = hoursFormat,
hoursMinutesFormat = hoursMinutesFormat,
@@ -183,6 +184,20 @@ fun StatsScreen(
markerTypeface = markerTypeface
)
}
entry<Screen.Stats.LastYear> {
LastYearScreen(
contentPadding = contentPadding,
lastYearAnalysisValues = lastYearAnalysisValues,
lastYearSummaryChartData = lastYearSummaryChartData,
onBack = backStack::removeLastOrNull,
hoursMinutesFormat = hoursMinutesFormat,
hoursFormat = hoursFormat,
minutesFormat = minutesFormat,
axisTypeface = axisTypeface,
markerTypeface = markerTypeface
)
}
}
)
}

View File

@@ -0,0 +1,310 @@
/*
* 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.TimeLineChart
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.LastYearScreen(
contentPadding: PaddingValues,
lastYearAnalysisValues: Pair<List<Long>, Long>,
lastYearSummaryChartData: 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 lastYearSummaryAnalysisModelProducer = remember { CartesianChartModelProducer() }
var breakdownChartExpanded by remember { mutableStateOf(false) }
LaunchedEffect(lastYearAnalysisValues.first) {
lastYearSummaryAnalysisModelProducer.runTransaction {
columnSeries {
series(lastYearAnalysisValues.first)
}
}
}
val rankList = remember(lastYearAnalysisValues) {
val sortedIndices =
lastYearAnalysisValues.first.indices.sortedByDescending { lastYearAnalysisValues.first[it] }
val ranks = MutableList(lastYearAnalysisValues.first.size) { 0 }
sortedIndices.forEachIndexed { rank, originalIndex ->
ranks[originalIndex] = rank
}
ranks
}
val focusDuration = remember(lastYearAnalysisValues) {
lastYearAnalysisValues.first.sum()
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.last_year),
fontFamily = robotoFlexTopBar,
modifier = Modifier.sharedBounds(
sharedContentState = this@LastYearScreen
.rememberSharedContentState("last year 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@LastYearScreen,
sharedContentState = this@LastYearScreen.rememberSharedContentState(
"last year 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@LastYearScreen
.rememberSharedContentState("last year 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@LastYearScreen
.rememberSharedContentState("focus per day average (year)"),
animatedVisibilityScope = LocalNavAnimatedContentScope.current
)
)
}
}
item {
TimeLineChart(
modelProducer = lastYearSummaryChartData.first,
hoursFormat = hoursFormat,
hoursMinutesFormat = hoursMinutesFormat,
minutesFormat = minutesFormat,
axisTypeface = axisTypeface,
markerTypeface = markerTypeface,
xValueFormatter = CartesianValueFormatter { context, x, _ ->
context.model.extraStore[lastYearSummaryChartData.second][x.toInt()]
},
modifier = Modifier
.sharedElement(
sharedContentState = this@LastYearScreen
.rememberSharedContentState("last year 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(lastYearAnalysisValues.first, rankList = rankList) }
item {
Row {
lastYearAnalysisValues.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 = lastYearSummaryAnalysisModelProducer,
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 = lastYearAnalysisValues.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

@@ -84,7 +84,7 @@ fun SharedTransitionScope.StatsMainScreen(
todayStat: Stat?,
lastWeekAverageFocusTimes: List<Long>,
lastMonthAverageFocusTimes: List<Long>,
lastYearAverageFocusTimes: List<Int>,
lastYearAverageFocusTimes: List<Long>,
generateSampleData: () -> Unit,
hoursMinutesFormat: String,
hoursFormat: String,
@@ -387,9 +387,17 @@ fun SharedTransitionScope.StatsMainScreen(
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.sharedBoundsReveal(
sharedTransitionScope = this@StatsMainScreen,
sharedContentState = this@StatsMainScreen.rememberSharedContentState(
"last year card"
),
animatedVisibilityScope = LocalNavAnimatedContentScope.current,
clipShape = bottomListItemShape
)
.clip(bottomListItemShape)
.background(listItemColors.containerColor)
.clickable {}
.clickable { onNavigate(Screen.Stats.LastYear) }
.padding(
start = 20.dp,
top = 20.dp,
@@ -398,7 +406,12 @@ fun SharedTransitionScope.StatsMainScreen(
) {
Text(
stringResource(R.string.last_year),
style = typography.headlineSmall
style = typography.headlineSmall,
modifier = Modifier.sharedBounds(
sharedContentState = this@StatsMainScreen
.rememberSharedContentState("last year heading"),
animatedVisibilityScope = LocalNavAnimatedContentScope.current
)
)
Row(
@@ -408,16 +421,28 @@ fun SharedTransitionScope.StatsMainScreen(
Text(
millisecondsToHoursMinutes(
remember(lastYearAverageFocusTimes) {
lastYearAverageFocusTimes.sum().toLong()
lastYearAverageFocusTimes.sum()
},
hoursMinutesFormat
),
style = typography.displaySmall
style = typography.displaySmall,
modifier = Modifier
.sharedElement(
sharedContentState = this@StatsMainScreen
.rememberSharedContentState("last year 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 (year)"),
animatedVisibilityScope = LocalNavAnimatedContentScope.current
)
)
}
@@ -430,7 +455,13 @@ fun SharedTransitionScope.StatsMainScreen(
markerTypeface = markerTypeface,
xValueFormatter = CartesianValueFormatter { context, x, _ ->
context.model.extraStore[lastYearSummaryChartData.second][x.toInt()]
}
},
modifier = Modifier
.sharedElement(
sharedContentState = this@StatsMainScreen
.rememberSharedContentState("last year chart"),
animatedVisibilityScope = LocalNavAnimatedContentScope.current
)
)
}
}

View File

@@ -197,21 +197,24 @@ class StatsViewModel(
initialValue = lastYearSummary
)
val lastYearAverageFocusTimes: StateFlow<List<Int>> =
val lastYearAverageFocusTimes: StateFlow<Pair<List<Long>, Long>> =
statRepository.getLastNDaysAverageFocusTimes(365)
.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)
)
fun generateSampleData() {