feat(stats): add tooltip in calendar

This commit is contained in:
Nishant Mishra
2025-12-18 19:52:52 +05:30
parent 506207e80f
commit d3e9bb79fb
2 changed files with 92 additions and 14 deletions

View File

@@ -18,17 +18,20 @@
package org.nsh07.pomodoro.ui.statsScreen.components package org.nsh07.pomodoro.ui.statsScreen.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
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.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.colorScheme
@@ -37,21 +40,34 @@ import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Surface 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.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
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.IntOffset
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.window.Popup
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.data.Stat import org.nsh07.pomodoro.data.Stat
import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.theme.TomatoTheme
import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes
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.random.Random import kotlin.random.Random
val CALENDAR_CELL_SIZE = 40.dp val CALENDAR_CELL_SIZE = 40.dp
@@ -103,9 +119,22 @@ fun FocusHistoryCalendar(
} }
} // 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 dateFormat = remember(locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(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 groupedData = remember(data) { val groupedData = remember(data) {
data.chunked(7) data.chunked(7)
} }
var selectedItemIndex by remember { mutableIntStateOf(-1) }
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -120,6 +149,8 @@ fun FocusHistoryCalendar(
daysOfWeek.fastForEach { daysOfWeek.fastForEach {
Text( Text(
text = it, text = it,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = typography.bodySmall, style = typography.bodySmall,
color = colorScheme.outline, color = colorScheme.outline,
@@ -136,18 +167,18 @@ fun FocusHistoryCalendar(
modifier = Modifier.height(size) modifier = Modifier.height(size)
) { ) {
items.fastForEachIndexed { index, it -> items.fastForEachIndexed { index, it ->
val background = remember(it) { (it?.totalFocusTime() ?: 0) > 0 } val sum = remember(it) { it?.totalFocusTime() ?: 0L }
val background = sum > 0
val currentMonth = val currentMonth =
remember(it, last) { it?.date?.month == last?.date?.month } remember(it, last) { it?.date?.month == last?.date?.month }
val flatIndex = baseIndex * 7 + index // Calculate flat index
val shape = remember(data, background) { val shape = remember(data, background) {
if (background) { if (background) {
val next = val next =
(data.getOrNull(baseIndex * 7 + index + 1)?.totalFocusTime() (data.getOrNull(flatIndex + 1)?.totalFocusTime() ?: 0) > 0
?: 0) > 0
val previous = val previous =
(data.getOrNull(baseIndex * 7 + index - 1)?.totalFocusTime() (data.getOrNull(flatIndex - 1)?.totalFocusTime() ?: 0) > 0
?: 0) > 0
RoundedCornerShape( RoundedCornerShape(
topStart = if (previous) shapes.extraSmall.topStart else shapes.large.topStart, topStart = if (previous) shapes.extraSmall.topStart else shapes.large.topStart,
@@ -158,6 +189,8 @@ fun FocusHistoryCalendar(
} else RoundedCornerShape(0) } else RoundedCornerShape(0)
} }
val isTooltipVisible = it != null && selectedItemIndex == flatIndex
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier modifier = Modifier
@@ -166,10 +199,13 @@ fun FocusHistoryCalendar(
if (background) Modifier.background( if (background) Modifier.background(
if (currentMonth) colorScheme.primaryContainer if (currentMonth) colorScheme.primaryContainer
else colorScheme.secondaryContainer, else colorScheme.secondaryContainer,
shape if (isTooltipVisible) CircleShape else shape
) )
else Modifier else Modifier
) )
.clickable(enabled = it != null) {
selectedItemIndex = flatIndex
}
) { ) {
Text( Text(
text = it?.date?.dayOfMonth?.toString() ?: "", text = it?.date?.dayOfMonth?.toString() ?: "",
@@ -182,6 +218,51 @@ fun FocusHistoryCalendar(
else colorScheme.outline else colorScheme.outline
} }
) )
if (isTooltipVisible) {
val values = remember(it) {
listOf(
it.focusTimeQ1,
it.focusTimeQ2,
it.focusTimeQ3,
it.focusTimeQ4
)
}
Popup(
alignment = Alignment.TopCenter,
offset = IntOffset(0, -tooltipOffset),
onDismissRequest = { selectedItemIndex = -1 }
) {
Surface(
shape = shapes.large,
color = colorScheme.surfaceContainer,
contentColor = colorScheme.onSurfaceVariant,
shadowElevation = 3.dp,
tonalElevation = 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,
stringResource(R.string.hours_and_minutes_format)
),
style = typography.bodyMedium
)
Spacer(Modifier.height(8.dp))
HorizontalStackedBar(
values = values,
rankList = averageRankList
)
}
}
}
}
} }
} }
} }

View File

@@ -34,8 +34,6 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape 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.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
@@ -204,13 +202,12 @@ fun HeatmapWithWeekLabels(
activeTooltipIndex = -1 activeTooltipIndex = -1
} }
) { ) {
ElevatedCard( Surface(
colors = CardDefaults.elevatedCardColors(
containerColor = colorScheme.surfaceContainer,
contentColor = colorScheme.onSurfaceVariant
),
shape = shapes.large, shape = shapes.large,
elevation = CardDefaults.elevatedCardElevation(3.dp), color = colorScheme.surfaceContainer,
contentColor = colorScheme.onSurfaceVariant,
shadowElevation = 3.dp,
tonalElevation = 3.dp,
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier.padding(horizontal = 16.dp)
) { ) {
Column(Modifier.padding(16.dp)) { Column(Modifier.padding(16.dp)) {