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

View File

@@ -46,6 +46,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachIndexed import androidx.compose.ui.util.fastForEachIndexed
import androidx.compose.ui.util.fastMaxBy
import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.theme.TomatoTheme
import java.time.LocalDate import java.time.LocalDate
import java.time.format.TextStyle 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 * insert gaps in the heatmap, and can be used to, for example, delimit months by inserting an
* empty week * empty week
* @param modifier Modifier to be applied to the heatmap * @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 * 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, size: Dp = HEATMAP_CELL_SIZE,
gap: Dp = HEATMAP_CELL_GAP, gap: Dp = HEATMAP_CELL_GAP,
contentPadding: PaddingValues = PaddingValues(), 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() val locale = Locale.getDefault()
@@ -242,7 +245,7 @@ fun HeatmapWithWeekLabels(
LazyHorizontalGrid( LazyHorizontalGrid(
rows = GridCells.Fixed(7), rows = GridCells.Fixed(7),
modifier = modifier, modifier = modifier.height(size * 7 + gap * 6),
contentPadding = contentPadding, contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(gap), verticalArrangement = Arrangement.spacedBy(gap),
horizontalArrangement = Arrangement.spacedBy(gap) horizontalArrangement = Arrangement.spacedBy(gap)
@@ -265,7 +268,6 @@ fun HeatmapWithWeekLabels(
Spacer( Spacer(
Modifier Modifier
.size(size) .size(size)
.background(colorScheme.surfaceVariant, shapes.small)
.background( .background(
colorScheme.primary.copy( colorScheme.primary.copy(
remember(it) { remember(it) {

View File

@@ -68,11 +68,12 @@ import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.mergePaddingValues import org.nsh07.pomodoro.ui.mergePaddingValues
import org.nsh07.pomodoro.ui.statsScreen.components.FocusBreakRatioVisualization import org.nsh07.pomodoro.ui.statsScreen.components.FocusBreakRatioVisualization
import org.nsh07.pomodoro.ui.statsScreen.components.FocusBreakdownChart 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.HorizontalStackedBar
import org.nsh07.pomodoro.ui.statsScreen.components.TimeLineChart import org.nsh07.pomodoro.ui.statsScreen.components.TimeLineChart
import org.nsh07.pomodoro.ui.statsScreen.components.sharedBoundsReveal import org.nsh07.pomodoro.ui.statsScreen.components.sharedBoundsReveal
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar 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.millisecondsToHoursMinutes
import org.nsh07.pomodoro.utils.millisecondsToMinutes import org.nsh07.pomodoro.utils.millisecondsToMinutes
@@ -80,8 +81,10 @@ import org.nsh07.pomodoro.utils.millisecondsToMinutes
@Composable @Composable
fun SharedTransitionScope.LastYearScreen( fun SharedTransitionScope.LastYearScreen(
contentPadding: PaddingValues, contentPadding: PaddingValues,
lastYearAnalysisValues: Pair<List<Long>, Long>, focusBreakdownValues: Pair<List<Long>, Long>,
lastYearSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>, focusHeatmapData: List<List<Long>?>,
heatmapMaxValue: Long,
mainChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
hoursMinutesFormat: String, hoursMinutesFormat: String,
@@ -95,18 +98,18 @@ fun SharedTransitionScope.LastYearScreen(
val lastYearSummaryAnalysisModelProducer = remember { CartesianChartModelProducer() } val lastYearSummaryAnalysisModelProducer = remember { CartesianChartModelProducer() }
var breakdownChartExpanded by remember { mutableStateOf(false) } var breakdownChartExpanded by remember { mutableStateOf(false) }
LaunchedEffect(lastYearAnalysisValues.first) { LaunchedEffect(focusBreakdownValues.first) {
lastYearSummaryAnalysisModelProducer.runTransaction { lastYearSummaryAnalysisModelProducer.runTransaction {
columnSeries { columnSeries {
series(lastYearAnalysisValues.first) series(focusBreakdownValues.first)
} }
} }
} }
val rankList = remember(lastYearAnalysisValues) { val rankList = remember(focusBreakdownValues) {
val sortedIndices = val sortedIndices =
lastYearAnalysisValues.first.indices.sortedByDescending { lastYearAnalysisValues.first[it] } focusBreakdownValues.first.indices.sortedByDescending { focusBreakdownValues.first[it] }
val ranks = MutableList(lastYearAnalysisValues.first.size) { 0 } val ranks = MutableList(focusBreakdownValues.first.size) { 0 }
sortedIndices.forEachIndexed { rank, originalIndex -> sortedIndices.forEachIndexed { rank, originalIndex ->
ranks[originalIndex] = rank ranks[originalIndex] = rank
@@ -115,8 +118,8 @@ fun SharedTransitionScope.LastYearScreen(
ranks ranks
} }
val focusDuration = remember(lastYearAnalysisValues) { val focusDuration = remember(focusBreakdownValues) {
lastYearAnalysisValues.first.sum() focusBreakdownValues.first.sum()
} }
Scaffold( Scaffold(
@@ -158,7 +161,7 @@ fun SharedTransitionScope.LastYearScreen(
"last year card" "last year card"
), ),
animatedVisibilityScope = LocalNavAnimatedContentScope.current, animatedVisibilityScope = LocalNavAnimatedContentScope.current,
clipShape = middleListItemShape clipShape = bottomListItemShape
) )
) { innerPadding -> ) { innerPadding ->
val insets = mergePaddingValues(innerPadding, contentPadding) val insets = mergePaddingValues(innerPadding, contentPadding)
@@ -203,14 +206,14 @@ fun SharedTransitionScope.LastYearScreen(
} }
item { item {
TimeLineChart( TimeLineChart(
modelProducer = lastYearSummaryChartData.first, modelProducer = mainChartData.first,
hoursFormat = hoursFormat, hoursFormat = hoursFormat,
hoursMinutesFormat = hoursMinutesFormat, hoursMinutesFormat = hoursMinutesFormat,
minutesFormat = minutesFormat, minutesFormat = minutesFormat,
axisTypeface = axisTypeface, axisTypeface = axisTypeface,
markerTypeface = markerTypeface, markerTypeface = markerTypeface,
xValueFormatter = CartesianValueFormatter { context, x, _ -> xValueFormatter = CartesianValueFormatter { context, x, _ ->
context.model.extraStore[lastYearSummaryChartData.second][x.toInt()] context.model.extraStore[mainChartData.second][x.toInt()]
}, },
modifier = Modifier modifier = Modifier
.sharedElement( .sharedElement(
@@ -235,10 +238,10 @@ fun SharedTransitionScope.LastYearScreen(
) )
} }
item { HorizontalStackedBar(lastYearAnalysisValues.first, rankList = rankList) } item { HorizontalStackedBar(focusBreakdownValues.first, rankList = rankList) }
item { item {
Row { Row {
lastYearAnalysisValues.first.fastForEach { focusBreakdownValues.first.fastForEach {
Text( Text(
if (it <= 60 * 60 * 1000) if (it <= 60 * 60 * 1000)
millisecondsToMinutes(it, minutesFormat) millisecondsToMinutes(it, minutesFormat)
@@ -288,7 +291,7 @@ fun SharedTransitionScope.LastYearScreen(
item { item {
FocusBreakRatioVisualization( FocusBreakRatioVisualization(
focusDuration = focusDuration, focusDuration = focusDuration,
breakDuration = lastYearAnalysisValues.second breakDuration = focusBreakdownValues.second
) )
} }
@@ -305,6 +308,13 @@ fun SharedTransitionScope.LastYearScreen(
color = colorScheme.onSurfaceVariant 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 package org.nsh07.pomodoro.ui.statsScreen.viewModel
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.ui.util.fastMaxBy
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY 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.cartesian.data.lineSeries
import com.patrykandpatrick.vico.core.common.data.ExtraStore import com.patrykandpatrick.vico.core.common.data.ExtraStore
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.nsh07.pomodoro.BuildConfig import org.nsh07.pomodoro.BuildConfig
import org.nsh07.pomodoro.TomatoApplication import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.data.Stat import org.nsh07.pomodoro.data.Stat
import org.nsh07.pomodoro.data.StatRepository import org.nsh07.pomodoro.data.StatRepository
import org.nsh07.pomodoro.ui.Screen import org.nsh07.pomodoro.ui.Screen
import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.TextStyle import java.time.format.TextStyle
@@ -68,8 +73,12 @@ class StatsViewModel(
private val yearDayFormatter = DateTimeFormatter.ofPattern("d MMM") private val yearDayFormatter = DateTimeFormatter.ofPattern("d MMM")
private val lastWeekStatsFlow = statRepository.getLastNDaysStats(7) 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 lastWeekStatsFlow
.map { list -> .map { list ->
// reversing is required because we need ascending order while the DB returns descending order // reversing is required because we need ascending order while the DB returns descending order
@@ -94,7 +103,7 @@ class StatsViewModel(
initialValue = lastWeekSummary initialValue = lastWeekSummary
) )
val lastWeekStats: StateFlow<List<Pair<String, List<Long>>>> = val lastWeekFocusHistoryValues: StateFlow<List<Pair<String, List<Long>>>> =
lastWeekStatsFlow lastWeekStatsFlow
.map { value -> .map { value ->
value.reversed().map { value.reversed().map {
@@ -119,7 +128,7 @@ class StatsViewModel(
initialValue = emptyList() initialValue = emptyList()
) )
val lastWeekAverageFocusTimes: StateFlow<Pair<List<Long>, Long>> = val lastWeekFocusBreakdownValues: StateFlow<Pair<List<Long>, Long>> =
statRepository.getLastNDaysAverageFocusTimes(7) statRepository.getLastNDaysAverageFocusTimes(7)
.map { .map {
Pair( Pair(
@@ -178,8 +187,8 @@ class StatsViewModel(
initialValue = Pair(listOf(0L, 0L, 0L, 0L), 0L) initialValue = Pair(listOf(0L, 0L, 0L, 0L), 0L)
) )
val lastYearSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> = val lastYearMainChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =
statRepository.getLastNDaysStats(365) lastYearStatsFlow
.map { list -> .map { list ->
val reversed = list.reversed() val reversed = list.reversed()
val keys = reversed.map { it.date.format(yearDayFormatter) } val keys = reversed.map { it.date.format(yearDayFormatter) }
@@ -197,7 +206,37 @@ class StatsViewModel(
initialValue = lastYearSummary 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) statRepository.getLastNDaysAverageFocusTimes(365)
.map { .map {
Pair( Pair(
@@ -221,7 +260,7 @@ class StatsViewModel(
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
viewModelScope.launch { viewModelScope.launch {
val today = LocalDate.now().plusDays(1) val today = LocalDate.now().plusDays(1)
var it = today.minusDays(40) var it = today.minusDays(365)
while (it.isBefore(today)) { while (it.isBefore(today)) {
statRepository.insertStat( statRepository.insertStat(