feat(stats): implement a heatmap composable

This commit is contained in:
Nishant Mishra
2025-12-14 17:11:52 +05:30
parent b5cb26efda
commit 7f3add5d7a

View File

@@ -19,11 +19,18 @@ package org.nsh07.pomodoro.ui.statsScreen.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.MaterialTheme.shapes
import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.MaterialTheme.typography
@@ -37,8 +44,12 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp 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.fastForEachIndexed import androidx.compose.ui.util.fastForEachIndexed
import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.theme.TomatoTheme
import java.time.LocalDate
import java.time.format.TextStyle
import java.util.Locale
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
@@ -100,7 +111,7 @@ fun HorizontalStackedBar(
.weight(item.toFloat()) .weight(item.toFloat())
.height(height) .height(height)
.clip(shape) .clip(shape)
.background(colorScheme.primaryContainer) .background(colorScheme.surfaceVariant)
.background( .background(
colorScheme.primary.copy( colorScheme.primary.copy(
(1f - (rankList.getOrNull(index) ?: 0) * 0.1f).coerceAtLeast( (1f - (rankList.getOrNull(index) ?: 0) * 0.1f).coerceAtLeast(
@@ -114,11 +125,11 @@ fun HorizontalStackedBar(
} }
else else
Spacer( Spacer(
Modifier modifier
.fillMaxWidth() .fillMaxWidth()
.height(height) .height(height)
.clip(shapes.large) .clip(shapes.large)
.background(colorScheme.outlineVariant) .background(colorScheme.surfaceVariant)
) )
} }
@@ -177,28 +188,150 @@ fun FocusBreakRatioVisualization(
} }
} else { } else {
Spacer( Spacer(
Modifier modifier
.fillMaxWidth() .fillMaxWidth()
.height(height) .height(height)
.clip(shapes.large) .clip(shapes.large)
.background(colorScheme.outlineVariant) .background(colorScheme.surfaceVariant)
) )
} }
} }
val HEATMAP_CELL_SIZE = 28.dp
val HEATMAP_CELL_GAP = 4.dp
/**
* A horizontally scrollable heatmap with week labels in the first column
*
* @param data Data to be represented in the heatmap in the form of [Pair]s of [LocalDate]s and
* their corresponding focus durations as a list. A null value passed in the list can be used to
* 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
*
* Note that it is assumed that the dates are continuous (without gaps) and start with a Monday
*/
@Composable
fun HeatmapWithWeekLabels(
data: List<List<Long>?>,
modifier: Modifier = Modifier,
size: Dp = HEATMAP_CELL_SIZE,
gap: Dp = HEATMAP_CELL_GAP,
contentPadding: PaddingValues = PaddingValues(),
maxValue: Long = remember { data.maxBy { it?.sum() ?: 0 }?.sum() ?: 0 },
) {
val locale = Locale.getDefault()
val first7 = remember(locale) {
val monday = LocalDate.of(2024, 1, 1) // Monday
buildList {
repeat(7) {
add(
monday
.plusDays(it.toLong())
.dayOfWeek
.getDisplayName(
TextStyle.NARROW,
locale
)
)
}
}
} // Names of the 7 days of the week in the current locale
LazyHorizontalGrid(
rows = GridCells.Fixed(7),
modifier = modifier,
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(gap),
horizontalArrangement = Arrangement.spacedBy(gap)
) {
items(first7) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.size(size)
) {
Text(
text = it,
style = typography.labelSmall
)
}
}
items(data) {
if (it == null) {
Spacer(Modifier.size(size))
} else {
Spacer(
Modifier
.size(size)
.background(colorScheme.surfaceVariant, shapes.small)
.background(
colorScheme.primary.copy(
remember(it) {
val sum = it.sum().toFloat()
if (sum > 0) 0.3f + (0.7f * sum / maxValue)
else 0f
}
),
shapes.small
)
)
}
}
}
}
@Preview @Preview
@Composable @Composable
fun HorizontalStackedBarPreview() { fun HorizontalStackedBarPreview() {
val values = listOf(38L, 190L, 114L, 14L) val values = listOf(
listOf(38L, 190L, 114L, 14L),
listOf(0L, 0L, 0L, 0L)
)
val rankList = listOf(2, 0, 1, 3) val rankList = listOf(2, 0, 1, 3)
TomatoTheme(dynamicColor = false) { TomatoTheme(dynamicColor = false) {
Surface { Surface {
HorizontalStackedBar( Column {
values = values, values.fastForEach {
rankList = rankList, HorizontalStackedBar(
modifier = Modifier.padding(16.dp), values = it,
height = 40.dp, rankList = rankList,
gap = 2.dp, modifier = Modifier.padding(16.dp),
height = 40.dp,
gap = 2.dp,
)
}
}
}
}
}
@Preview
@Composable
fun HeatmapWithWeekLabelsPreview() {
val startDate = LocalDate.of(2024, 1, 1) // Monday
val sampleData = remember {
buildList {
(0..93).forEach { index ->
val date = startDate.plusDays(index.toLong())
val focusDurations = listOf(index % 10L / 2) // Varying focus durations
if (date.month != date.minusDays(1).month && index > 0)
repeat(7) { add(null) }
add(focusDurations)
}
}
}
TomatoTheme(dynamicColor = false) {
Surface {
HeatmapWithWeekLabels(
data = sampleData,
contentPadding = PaddingValues(horizontal = 16.dp),
modifier = Modifier
.padding(vertical = 16.dp)
.height(HEATMAP_CELL_SIZE * 7 + HEATMAP_CELL_GAP * 6)
) )
} }
} }