feat(stats): implement a tooltip in heatmap

This commit is contained in:
Nishant Mishra
2025-12-17 16:07:55 +05:30
parent f4bd74aafd
commit 5d5d47f5ae
2 changed files with 115 additions and 29 deletions

View File

@@ -32,8 +32,11 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.MaterialTheme.shapes
@@ -42,6 +45,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -65,10 +69,14 @@ import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes
import org.nsh07.pomodoro.utils.millisecondsToMinutes import org.nsh07.pomodoro.utils.millisecondsToMinutes
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle import java.time.format.TextStyle
import java.util.Locale import java.util.Locale
import kotlin.math.roundToInt import kotlin.math.roundToInt
val HORIZONTAL_STACKED_BAR_HEIGHT = 40.dp
/** /**
* A "Horizontal stacked bar" component, which can be considered as a horizontal stacked bar chart * A "Horizontal stacked bar" component, which can be considered as a horizontal stacked bar chart
* with a single bar. This component can be stacked in a column to create a "100% stacked bar chart" * with a single bar. This component can be stacked in a column to create a "100% stacked bar chart"
@@ -123,9 +131,10 @@ fun HorizontalStackedBar(
append(" (%.2f".format((value.toFloat() / total) * 100) + "%)") append(" (%.2f".format((value.toFloat() / total) * 100) + "%)")
} }
}, },
height: Dp = 40.dp, height: Dp = HORIZONTAL_STACKED_BAR_HEIGHT,
gap: Dp = 2.dp gap: Dp = 2.dp
) { ) {
val shapes = shapes
val firstNonZeroIndex = remember(values) { values.indexOfFirst { it > 0L } } val firstNonZeroIndex = remember(values) { values.indexOfFirst { it > 0L } }
val lastNonZeroIndex = remember(values) { values.indexOfLast { it > 0L } } val lastNonZeroIndex = remember(values) { values.indexOfLast { it > 0L } }
@@ -139,7 +148,7 @@ fun HorizontalStackedBar(
values.fastForEachIndexed { index, item -> values.fastForEachIndexed { index, item ->
if (item > 0L) { if (item > 0L) {
var showTooltip by remember { mutableStateOf(false) } var showTooltip by remember { mutableStateOf(false) }
val shape = val shape = remember(index, firstNonZeroIndex, lastNonZeroIndex) {
if (firstNonZeroIndex == lastNonZeroIndex) shapes.large if (firstNonZeroIndex == lastNonZeroIndex) shapes.large
else when (index) { else when (index) {
firstNonZeroIndex -> shapes.large.copy( firstNonZeroIndex -> shapes.large.copy(
@@ -154,6 +163,7 @@ fun HorizontalStackedBar(
else -> shapes.extraSmall else -> shapes.extraSmall
} }
}
Box( Box(
Modifier Modifier
.weight(item.toFloat()) .weight(item.toFloat())
@@ -210,19 +220,34 @@ fun FocusBreakRatioVisualization(
focusDuration: Long, focusDuration: Long,
breakDuration: Long, breakDuration: Long,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
height: Dp = 40.dp, height: Dp = HORIZONTAL_STACKED_BAR_HEIGHT,
gap: Dp = 2.dp gap: Dp = 2.dp
) { ) {
if (focusDuration + breakDuration > 0) { if (focusDuration + breakDuration > 0) {
val shapes = shapes
val focusPercentage = ((focusDuration / (focusDuration.toFloat() + breakDuration)) * 100) val focusPercentage = ((focusDuration / (focusDuration.toFloat() + breakDuration)) * 100)
val breakPercentage = 100 - focusPercentage val breakPercentage = 100 - focusPercentage
val focusShape = remember(breakDuration) {
if (breakDuration > 0) shapes.large.copy(
topEnd = shapes.extraSmall.topEnd,
bottomEnd = shapes.extraSmall.bottomEnd
) else shapes.large
}
val breakShape = remember(focusDuration) {
if (focusDuration > 0) shapes.large.copy(
topStart = shapes.extraSmall.topStart,
bottomStart = shapes.extraSmall.bottomStart
) else shapes.large
}
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(gap), horizontalArrangement = Arrangement.spacedBy(gap),
modifier = modifier modifier = modifier
) { ) {
Text( Text(
text = focusPercentage.roundToInt().toString() + '%', text = "${focusPercentage.roundToInt()}%",
style = typography.bodyLarge, style = typography.bodyLarge,
color = colorScheme.primary, color = colorScheme.primary,
modifier = Modifier.padding(end = 6.dp) modifier = Modifier.padding(end = 6.dp)
@@ -233,10 +258,7 @@ fun FocusBreakRatioVisualization(
.height(height) .height(height)
.background( .background(
colorScheme.primary, colorScheme.primary,
if (breakDuration > 0) shapes.large.copy( focusShape
topEnd = shapes.extraSmall.topEnd,
bottomEnd = shapes.extraSmall.bottomEnd
) else shapes.large
) )
) )
if (breakDuration > 0) Spacer( if (breakDuration > 0) Spacer(
@@ -245,10 +267,7 @@ fun FocusBreakRatioVisualization(
.height(height) .height(height)
.background( .background(
colorScheme.tertiary, colorScheme.tertiary,
if (focusDuration > 0) shapes.large.copy( breakShape
topStart = shapes.extraSmall.topStart,
bottomStart = shapes.extraSmall.bottomStart
) else shapes.large
) )
) )
Text( Text(
@@ -279,6 +298,9 @@ val HEATMAP_CELL_GAP = 2.dp
* passed in the list can be used to insert gaps in the heatmap, and can be used to, for example, * passed in the list can be used to insert gaps in the heatmap, and can be used to, for example,
* delimit months by inserting a null week. Note that it is assumed that the dates are continuous * delimit months by inserting a null week. Note that it is assumed that the dates are continuous
* (without gaps) and start with a Monday. * (without gaps) and start with a Monday.
* @param averageRankList A list of the ranks of the average focus duration for the 4 parts of a
* day. See the rankList parameter of [HorizontalStackedBar] for more info. This is used to show a
* [HorizontalStackedBar] in a tooltip when a cell is clicked
* @param modifier Modifier to be applied to the heatmap * @param modifier Modifier to be applied to the heatmap
* @param maxValue Maximum total focus duration of the items present in [data]. This value must * @param maxValue Maximum total focus duration of the items present in [data]. This value must
* correspond to the total focus duration one of the elements in [data] for accurate representation. * correspond to the total focus duration one of the elements in [data] for accurate representation.
@@ -286,6 +308,7 @@ val HEATMAP_CELL_GAP = 2.dp
@Composable @Composable
fun HeatmapWithWeekLabels( fun HeatmapWithWeekLabels(
data: List<Stat?>, data: List<Stat?>,
averageRankList: List<Int>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
size: Dp = HEATMAP_CELL_SIZE, size: Dp = HEATMAP_CELL_SIZE,
gap: Dp = HEATMAP_CELL_GAP, gap: Dp = HEATMAP_CELL_GAP,
@@ -306,6 +329,20 @@ fun HeatmapWithWeekLabels(
} }
} // Names of the 7 days of the week in the current locale } // Names of the 7 days of the week in the current locale
val tooltipOffset = with(LocalDensity.current) {
(16 * 2 + // Vertical padding in the tooltip card
typography.titleSmall.lineHeight.value + 4 + // Heading
typography.bodyMedium.lineHeight.value + 8 + // Text
HORIZONTAL_STACKED_BAR_HEIGHT.value + // Obvious
8).dp.toPx().roundToInt()
}
val dateFormat = remember(locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(locale)
}
var activeTooltipIndex by remember { mutableIntStateOf(-1) }
Row(modifier) { Row(modifier) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(gap), verticalArrangement = Arrangement.spacedBy(gap),
@@ -332,14 +369,16 @@ fun HeatmapWithWeekLabels(
horizontalArrangement = Arrangement.spacedBy(gap) horizontalArrangement = Arrangement.spacedBy(gap)
) { ) {
itemsIndexed( itemsIndexed(
data, items = data,
key = { index, it -> it?.date?.toEpochDay() ?: index.toString() }) { index, it -> key = { index, it -> it?.date?.toEpochDay() ?: index.toString() },
contentType = { _, it -> if (it == null) "spacer" else "cell" }
) { index, it ->
if (it == null) { if (it == null) {
Spacer(Modifier.size(size)) Spacer(Modifier.size(size))
} else { } else {
val sum = remember { it.totalFocusTime().toFloat() } val sum = remember { it.totalFocusTime() }
val shape = remember { val shape = remember(data, index) {
val top = data.getOrNull(index - 1) != null && index % 7 != 0 val top = data.getOrNull(index - 1) != null && index % 7 != 0
val end = data.getOrNull(index + 7) != null val end = data.getOrNull(index + 7) != null
val bottom = data.getOrNull(index + 1) != null && index % 7 != 6 val bottom = data.getOrNull(index + 1) != null && index % 7 != 6
@@ -353,20 +392,65 @@ fun HeatmapWithWeekLabels(
) )
} }
if (sum > 0) val isTooltipVisible = activeTooltipIndex == index
Spacer(
Modifier Box(
.size(size)
.background(
colorScheme.primary.copy(0.4f + (0.6f * sum / maxValue)),
shape
)
)
else Spacer(
Modifier Modifier
.size(size) .size(size)
.background(colorScheme.surfaceVariant, shape) .background(
) if (sum > 0)
colorScheme.primary.copy(0.4f + (0.6f * sum / maxValue))
else colorScheme.surfaceVariant,
if (!isTooltipVisible) shape else CircleShape
)
.clickable { activeTooltipIndex = index }
) {
if (isTooltipVisible) {
val values = remember(it) {
listOf(
it.focusTimeQ1,
it.focusTimeQ2,
it.focusTimeQ3,
it.focusTimeQ4
)
}
Popup(
alignment = Alignment.TopCenter,
offset = IntOffset(0, -tooltipOffset),
onDismissRequest = {
activeTooltipIndex = -1
}
) {
ElevatedCard(
colors = CardDefaults.elevatedCardColors(
containerColor = colorScheme.surfaceContainer,
contentColor = colorScheme.onSurfaceVariant
),
shape = shapes.large,
elevation = CardDefaults.elevatedCardElevation(3.dp),
modifier = Modifier.padding(horizontal = 16.dp)
) {
Column(Modifier.padding(16.dp)) {
Text(
text = it.date.format(dateFormat),
style = typography.titleSmall
)
Spacer(Modifier.height(4.dp))
Text(
text = millisecondsToHoursMinutes(sum),
style = typography.bodyMedium
)
Spacer(Modifier.height(8.dp))
HorizontalStackedBar(
values = values,
rankList = averageRankList
)
}
}
}
}
}
} }
} }
} }
@@ -389,7 +473,7 @@ fun HorizontalStackedBarPreview() {
values = it, values = it,
rankList = rankList, rankList = rankList,
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
height = 40.dp, height = HORIZONTAL_STACKED_BAR_HEIGHT,
gap = 2.dp, gap = 2.dp,
) )
} }
@@ -419,6 +503,7 @@ fun HeatmapWithWeekLabelsPreview() {
Surface { Surface {
HeatmapWithWeekLabels( HeatmapWithWeekLabels(
data = sampleData, data = sampleData,
averageRankList = listOf(3, 0, 1, 2),
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
modifier = Modifier modifier = Modifier
.padding(vertical = 16.dp) .padding(vertical = 16.dp)

View File

@@ -334,6 +334,7 @@ fun SharedTransitionScope.LastYearScreen(
item { item {
HeatmapWithWeekLabels( HeatmapWithWeekLabels(
data = focusHeatmapData, data = focusHeatmapData,
averageRankList = rankList,
maxValue = heatmapMaxValue, maxValue = heatmapMaxValue,
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
modifier = Modifier.padding(start = 16.dp) modifier = Modifier.padding(start = 16.dp)