feat(stats): implement heatmap in last year screen

This commit is contained in:
Nishant Mishra
2025-12-15 09:52:21 +05:30
parent c49c5b21a2
commit ea36d8d971
4 changed files with 95 additions and 40 deletions

View File

@@ -58,15 +58,17 @@ fun StatsScreenRoot(
val todayStat by viewModel.todayStat.collectAsStateWithLifecycle(null)
val lastWeekSummaryChartData by viewModel.lastWeekSummaryChartData.collectAsStateWithLifecycle()
val lastWeekSummaryValues by viewModel.lastWeekStats.collectAsStateWithLifecycle()
val lastWeekAnalysisValues by viewModel.lastWeekAverageFocusTimes.collectAsStateWithLifecycle()
val lastWeekMainChartData by viewModel.lastWeekMainChartData.collectAsStateWithLifecycle()
val lastWeekFocusHistoryValues by viewModel.lastWeekFocusHistoryValues.collectAsStateWithLifecycle()
val lastWeekFocusBreakdownValues by viewModel.lastWeekFocusBreakdownValues.collectAsStateWithLifecycle()
val lastMonthSummaryChartData by viewModel.lastMonthSummaryChartData.collectAsStateWithLifecycle()
val lastMonthAnalysisValues by viewModel.lastMonthAverageFocusTimes.collectAsStateWithLifecycle()
val lastYearSummaryChartData by viewModel.lastYearSummaryChartData.collectAsStateWithLifecycle()
val lastYearAnalysisValues by viewModel.lastYearAverageFocusTimes.collectAsStateWithLifecycle()
val lastYearMainChartData by viewModel.lastYearMainChartData.collectAsStateWithLifecycle()
val lastYearFocusHeatmapData by viewModel.lastYearFocusHeatmapData.collectAsStateWithLifecycle()
val lastYearFocusBreakdownValues by viewModel.lastYearFocusBreakdownValues.collectAsStateWithLifecycle()
val lastYearMaxFocus by viewModel.lastYearMaxFocus.collectAsStateWithLifecycle()
val colorScheme = colorScheme
@@ -95,13 +97,13 @@ fun StatsScreenRoot(
entry<Screen.Stats.Main> {
StatsMainScreen(
contentPadding = contentPadding,
lastWeekSummaryChartData = lastWeekSummaryChartData,
lastWeekSummaryChartData = lastWeekMainChartData,
lastMonthSummaryChartData = lastMonthSummaryChartData,
lastYearSummaryChartData = lastYearSummaryChartData,
lastYearSummaryChartData = lastYearMainChartData,
todayStat = todayStat,
lastWeekAverageFocusTimes = lastWeekAnalysisValues.first,
lastWeekAverageFocusTimes = lastWeekFocusBreakdownValues.first,
lastMonthAverageFocusTimes = lastMonthAnalysisValues.first,
lastYearAverageFocusTimes = lastYearAnalysisValues.first,
lastYearAverageFocusTimes = lastYearFocusBreakdownValues.first,
generateSampleData = viewModel::generateSampleData,
hoursFormat = hoursFormat,
hoursMinutesFormat = hoursMinutesFormat,
@@ -119,9 +121,9 @@ fun StatsScreenRoot(
entry<Screen.Stats.LastWeek> {
LastWeekScreen(
contentPadding = contentPadding,
focusBreakdownValues = lastWeekAnalysisValues,
focusHistoryValues = lastWeekSummaryValues,
mainChartData = lastWeekSummaryChartData,
focusBreakdownValues = lastWeekFocusBreakdownValues,
focusHistoryValues = lastWeekFocusHistoryValues,
mainChartData = lastWeekMainChartData,
onBack = backStack::removeLastOrNull,
hoursMinutesFormat = hoursMinutesFormat,
hoursFormat = hoursFormat,
@@ -148,8 +150,10 @@ fun StatsScreenRoot(
entry<Screen.Stats.LastYear> {
LastYearScreen(
contentPadding = contentPadding,
lastYearAnalysisValues = lastYearAnalysisValues,
lastYearSummaryChartData = lastYearSummaryChartData,
focusBreakdownValues = lastYearFocusBreakdownValues,
focusHeatmapData = lastYearFocusHeatmapData,
heatmapMaxValue = lastYearMaxFocus,
mainChartData = lastYearMainChartData,
onBack = backStack::removeLastOrNull,
hoursMinutesFormat = hoursMinutesFormat,
hoursFormat = hoursFormat,

View File

@@ -46,6 +46,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachIndexed
import androidx.compose.ui.util.fastMaxBy
import org.nsh07.pomodoro.ui.theme.TomatoTheme
import java.time.LocalDate
import java.time.format.TextStyle
@@ -208,6 +209,8 @@ val HEATMAP_CELL_GAP = 4.dp
* insert gaps in the heatmap, and can be used to, for example, delimit months by inserting an
* empty week
* @param modifier Modifier to be applied to the heatmap
* @param maxValue Maximum total value of the items present in [data]. This value must correspond to
* the sum of the list present in one of the elements on [data] for accurate representation.
*
* Note that it is assumed that the dates are continuous (without gaps) and start with a Monday
*/
@@ -218,7 +221,7 @@ fun HeatmapWithWeekLabels(
size: Dp = HEATMAP_CELL_SIZE,
gap: Dp = HEATMAP_CELL_GAP,
contentPadding: PaddingValues = PaddingValues(),
maxValue: Long = remember { data.maxBy { it?.sum() ?: 0 }?.sum() ?: 0 },
maxValue: Long = remember { data.fastMaxBy { it?.sum() ?: 0 }?.sum() ?: 0 },
) {
val locale = Locale.getDefault()
@@ -242,7 +245,7 @@ fun HeatmapWithWeekLabels(
LazyHorizontalGrid(
rows = GridCells.Fixed(7),
modifier = modifier,
modifier = modifier.height(size * 7 + gap * 6),
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(gap),
horizontalArrangement = Arrangement.spacedBy(gap)
@@ -265,7 +268,6 @@ fun HeatmapWithWeekLabels(
Spacer(
Modifier
.size(size)
.background(colorScheme.surfaceVariant, shapes.small)
.background(
colorScheme.primary.copy(
remember(it) {

View File

@@ -68,11 +68,12 @@ 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.HeatmapWithWeekLabels
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.ui.theme.TomatoShapeDefaults.bottomListItemShape
import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes
import org.nsh07.pomodoro.utils.millisecondsToMinutes
@@ -80,8 +81,10 @@ import org.nsh07.pomodoro.utils.millisecondsToMinutes
@Composable
fun SharedTransitionScope.LastYearScreen(
contentPadding: PaddingValues,
lastYearAnalysisValues: Pair<List<Long>, Long>,
lastYearSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
focusBreakdownValues: Pair<List<Long>, Long>,
focusHeatmapData: List<List<Long>?>,
heatmapMaxValue: Long,
mainChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
onBack: () -> Unit,
modifier: Modifier = Modifier,
hoursMinutesFormat: String,
@@ -95,18 +98,18 @@ fun SharedTransitionScope.LastYearScreen(
val lastYearSummaryAnalysisModelProducer = remember { CartesianChartModelProducer() }
var breakdownChartExpanded by remember { mutableStateOf(false) }
LaunchedEffect(lastYearAnalysisValues.first) {
LaunchedEffect(focusBreakdownValues.first) {
lastYearSummaryAnalysisModelProducer.runTransaction {
columnSeries {
series(lastYearAnalysisValues.first)
series(focusBreakdownValues.first)
}
}
}
val rankList = remember(lastYearAnalysisValues) {
val rankList = remember(focusBreakdownValues) {
val sortedIndices =
lastYearAnalysisValues.first.indices.sortedByDescending { lastYearAnalysisValues.first[it] }
val ranks = MutableList(lastYearAnalysisValues.first.size) { 0 }
focusBreakdownValues.first.indices.sortedByDescending { focusBreakdownValues.first[it] }
val ranks = MutableList(focusBreakdownValues.first.size) { 0 }
sortedIndices.forEachIndexed { rank, originalIndex ->
ranks[originalIndex] = rank
@@ -115,8 +118,8 @@ fun SharedTransitionScope.LastYearScreen(
ranks
}
val focusDuration = remember(lastYearAnalysisValues) {
lastYearAnalysisValues.first.sum()
val focusDuration = remember(focusBreakdownValues) {
focusBreakdownValues.first.sum()
}
Scaffold(
@@ -158,7 +161,7 @@ fun SharedTransitionScope.LastYearScreen(
"last year card"
),
animatedVisibilityScope = LocalNavAnimatedContentScope.current,
clipShape = middleListItemShape
clipShape = bottomListItemShape
)
) { innerPadding ->
val insets = mergePaddingValues(innerPadding, contentPadding)
@@ -203,14 +206,14 @@ fun SharedTransitionScope.LastYearScreen(
}
item {
TimeLineChart(
modelProducer = lastYearSummaryChartData.first,
modelProducer = mainChartData.first,
hoursFormat = hoursFormat,
hoursMinutesFormat = hoursMinutesFormat,
minutesFormat = minutesFormat,
axisTypeface = axisTypeface,
markerTypeface = markerTypeface,
xValueFormatter = CartesianValueFormatter { context, x, _ ->
context.model.extraStore[lastYearSummaryChartData.second][x.toInt()]
context.model.extraStore[mainChartData.second][x.toInt()]
},
modifier = Modifier
.sharedElement(
@@ -235,10 +238,10 @@ fun SharedTransitionScope.LastYearScreen(
)
}
item { HorizontalStackedBar(lastYearAnalysisValues.first, rankList = rankList) }
item { HorizontalStackedBar(focusBreakdownValues.first, rankList = rankList) }
item {
Row {
lastYearAnalysisValues.first.fastForEach {
focusBreakdownValues.first.fastForEach {
Text(
if (it <= 60 * 60 * 1000)
millisecondsToMinutes(it, minutesFormat)
@@ -288,7 +291,7 @@ fun SharedTransitionScope.LastYearScreen(
item {
FocusBreakRatioVisualization(
focusDuration = focusDuration,
breakDuration = lastYearAnalysisValues.second
breakDuration = focusBreakdownValues.second
)
}
@@ -305,6 +308,13 @@ fun SharedTransitionScope.LastYearScreen(
color = colorScheme.onSurfaceVariant
)
}
item {
HeatmapWithWeekLabels(
data = focusHeatmapData,
maxValue = heatmapMaxValue,
contentPadding = PaddingValues(horizontal = 16.dp),
)
}
}
}
}

View File

@@ -18,6 +18,7 @@
package org.nsh07.pomodoro.ui.statsScreen.viewModel
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.ui.util.fastMaxBy
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
@@ -29,17 +30,21 @@ import com.patrykandpatrick.vico.core.cartesian.data.columnSeries
import com.patrykandpatrick.vico.core.cartesian.data.lineSeries
import com.patrykandpatrick.vico.core.common.data.ExtraStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.nsh07.pomodoro.BuildConfig
import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.data.Stat
import org.nsh07.pomodoro.data.StatRepository
import org.nsh07.pomodoro.ui.Screen
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
@@ -68,8 +73,12 @@ class StatsViewModel(
private val yearDayFormatter = DateTimeFormatter.ofPattern("d MMM")
private val lastWeekStatsFlow = statRepository.getLastNDaysStats(7)
private val lastYearStatsFlow = statRepository.getLastNDaysStats(365)
val lastWeekSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =
private val _lastYearMaxFocus = MutableStateFlow(Long.MAX_VALUE)
val lastYearMaxFocus = _lastYearMaxFocus.asStateFlow()
val lastWeekMainChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =
lastWeekStatsFlow
.map { list ->
// reversing is required because we need ascending order while the DB returns descending order
@@ -94,7 +103,7 @@ class StatsViewModel(
initialValue = lastWeekSummary
)
val lastWeekStats: StateFlow<List<Pair<String, List<Long>>>> =
val lastWeekFocusHistoryValues: StateFlow<List<Pair<String, List<Long>>>> =
lastWeekStatsFlow
.map { value ->
value.reversed().map {
@@ -119,7 +128,7 @@ class StatsViewModel(
initialValue = emptyList()
)
val lastWeekAverageFocusTimes: StateFlow<Pair<List<Long>, Long>> =
val lastWeekFocusBreakdownValues: StateFlow<Pair<List<Long>, Long>> =
statRepository.getLastNDaysAverageFocusTimes(7)
.map {
Pair(
@@ -178,8 +187,8 @@ class StatsViewModel(
initialValue = Pair(listOf(0L, 0L, 0L, 0L), 0L)
)
val lastYearSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =
statRepository.getLastNDaysStats(365)
val lastYearMainChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =
lastYearStatsFlow
.map { list ->
val reversed = list.reversed()
val keys = reversed.map { it.date.format(yearDayFormatter) }
@@ -197,7 +206,37 @@ class StatsViewModel(
initialValue = lastYearSummary
)
val lastYearAverageFocusTimes: StateFlow<Pair<List<Long>, Long>> =
val lastYearFocusHeatmapData: StateFlow<List<List<Long>?>> =
lastYearStatsFlow
.map { list ->
val list = list.reversed()
_lastYearMaxFocus.update {
list.fastMaxBy {
it.totalFocusTime()
}?.totalFocusTime() ?: Long.MAX_VALUE
}
buildList {
repeat(list.first().date.dayOfWeek.value - DayOfWeek.MONDAY.value) {
add(null) // Make sure that the data starts with a Monday
}
list.indices.forEach {
if (it > 0 && list[it].date.month != list[it - 1].date.month) {
repeat(7) { add(null) } // Add a week gap if a new month starts
}
with(list[it]) {
add(listOf(focusTimeQ1, focusTimeQ2, focusTimeQ3, focusTimeQ4))
}
}
}
}
.flowOn(Dispatchers.IO)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
val lastYearFocusBreakdownValues: StateFlow<Pair<List<Long>, Long>> =
statRepository.getLastNDaysAverageFocusTimes(365)
.map {
Pair(
@@ -221,7 +260,7 @@ class StatsViewModel(
if (BuildConfig.DEBUG) {
viewModelScope.launch {
val today = LocalDate.now().plusDays(1)
var it = today.minusDays(40)
var it = today.minusDays(365)
while (it.isBefore(today)) {
statRepository.insertStat(