feat(stats): implement a basic last year screen
This commit is contained in:
@@ -57,6 +57,9 @@ sealed class Screen : NavKey {
|
||||
|
||||
@Serializable
|
||||
object LastMonth : Stats()
|
||||
|
||||
@Serializable
|
||||
object LastYear : Stats()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user