From b5cb26efda661db857edfb73cfcbf09a4b07d4df Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sun, 14 Dec 2025 08:57:54 +0530 Subject: [PATCH] feat(stats): implement a basic last year screen --- .../main/java/org/nsh07/pomodoro/ui/Screen.kt | 3 + .../pomodoro/ui/statsScreen/StatsScreen.kt | 21 +- .../ui/statsScreen/screens/LastYearScreen.kt | 310 ++++++++++++++++++ .../ui/statsScreen/screens/StatsMainScreen.kt | 45 ++- .../statsScreen/viewModel/StatsViewModel.kt | 17 +- 5 files changed, 379 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/screens/LastYearScreen.kt diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/Screen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/Screen.kt index 0d7936c..d6f97d8 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/Screen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/Screen.kt @@ -57,6 +57,9 @@ sealed class Screen : NavKey { @Serializable object LastMonth : Stats() + + @Serializable + object LastYear : Stats() } } 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 4a93b3f..d4dae85 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 @@ -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, Long>, lastMonthAnalysisValues: Pair, Long>, - lastYearAverageFocusTimes: List, + lastYearAnalysisValues: Pair, 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 { + LastYearScreen( + contentPadding = contentPadding, + lastYearAnalysisValues = lastYearAnalysisValues, + lastYearSummaryChartData = lastYearSummaryChartData, + onBack = backStack::removeLastOrNull, + hoursMinutesFormat = hoursMinutesFormat, + hoursFormat = hoursFormat, + minutesFormat = minutesFormat, + axisTypeface = axisTypeface, + markerTypeface = markerTypeface + ) + } } ) } diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/screens/LastYearScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/screens/LastYearScreen.kt new file mode 100644 index 0000000..da52fc6 --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/screens/LastYearScreen.kt @@ -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 . + */ + +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, Long>, + lastYearSummaryChartData: Pair>>, + 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 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/screens/StatsMainScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/screens/StatsMainScreen.kt index 5156dac..ec71e7f 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/screens/StatsMainScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/screens/StatsMainScreen.kt @@ -84,7 +84,7 @@ fun SharedTransitionScope.StatsMainScreen( todayStat: Stat?, lastWeekAverageFocusTimes: List, lastMonthAverageFocusTimes: List, - lastYearAverageFocusTimes: List, + lastYearAverageFocusTimes: List, 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 + ) ) } } 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 9d34a7a..d3f2d85 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 @@ -197,21 +197,24 @@ class StatsViewModel( initialValue = lastYearSummary ) - val lastYearAverageFocusTimes: StateFlow> = + val lastYearAverageFocusTimes: StateFlow, 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() {