feat(stats): add calendar in last month screen

This commit is contained in:
Nishant Mishra
2025-12-18 19:09:43 +05:30
parent bf93984339
commit 506207e80f
4 changed files with 79 additions and 29 deletions

View File

@@ -62,8 +62,9 @@ fun StatsScreenRoot(
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 lastMonthMainChartData by viewModel.lastMonthMainChartData.collectAsStateWithLifecycle()
val lastMonthCalendarData by viewModel.lastMonthCalendarData.collectAsStateWithLifecycle()
val lastMonthFocusBreakdownValues by viewModel.lastMonthFocusBreakdownValues.collectAsStateWithLifecycle()
val lastYearMainChartData by viewModel.lastYearMainChartData.collectAsStateWithLifecycle()
val lastYearFocusHeatmapData by viewModel.lastYearFocusHeatmapData.collectAsStateWithLifecycle()
@@ -98,11 +99,11 @@ fun StatsScreenRoot(
StatsMainScreen(
contentPadding = contentPadding,
lastWeekSummaryChartData = lastWeekMainChartData,
lastMonthSummaryChartData = lastMonthSummaryChartData,
lastMonthSummaryChartData = lastMonthMainChartData,
lastYearSummaryChartData = lastYearMainChartData,
todayStat = todayStat,
lastWeekAverageFocusTimes = lastWeekFocusBreakdownValues.first,
lastMonthAverageFocusTimes = lastMonthAnalysisValues.first,
lastMonthAverageFocusTimes = lastMonthFocusBreakdownValues.first,
lastYearAverageFocusTimes = lastYearFocusBreakdownValues.first,
generateSampleData = viewModel::generateSampleData,
hoursFormat = hoursFormat,
@@ -136,8 +137,9 @@ fun StatsScreenRoot(
entry<Screen.Stats.LastMonth> {
LastMonthScreen(
contentPadding = contentPadding,
lastMonthAnalysisValues = lastMonthAnalysisValues,
lastMonthSummaryChartData = lastMonthSummaryChartData,
focusBreakdownValues = lastMonthFocusBreakdownValues,
calendarData = lastMonthCalendarData,
mainChartData = lastMonthMainChartData,
onBack = backStack::removeLastOrNull,
hoursMinutesFormat = hoursMinutesFormat,
hoursFormat = hoursFormat,

View File

@@ -57,7 +57,28 @@ import kotlin.random.Random
val CALENDAR_CELL_SIZE = 40.dp
val CALENDAR_CELL_HORIZONTAL_GAP = 2.dp
val CALENDAR_CELL_VERTICAL_GAP = 4.dp
val CALENDAR_INTERNAL_PADDING = 20.dp
/**
* A composable that displays a calendar visualizing focus history.
*
* This component shows a calendar grid (days grouped by week) that visualizes the user's focus
* history. Days with focus time are highlighted. It also distinguishes between days belonging
* to the last represented month and previous months. The cells are styled with rounded corners
* to indicate contiguous streaks of focus days.
*
* @param data Data to be represented in the heatmap as a [List] of [Stat] objects.. The list is
* expected to be ordered by date. It is assumed that this list starts with a Monday. Null entries
* are used for padding the start of the calendar, for example when the actual dates start with a
* day after 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 date is clicked
* @param modifier The [Modifier] to be applied to this composable.
* @param size The size of each calendar cell (width and height).
* @param horizontalGap The horizontal spacing between calendar cells.
* @param verticalGap The vertical spacing between calendar rows.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun FocusHistoryCalendar(
@@ -66,7 +87,8 @@ fun FocusHistoryCalendar(
modifier: Modifier = Modifier,
size: Dp = CALENDAR_CELL_SIZE,
horizontalGap: Dp = CALENDAR_CELL_HORIZONTAL_GAP,
verticalGap: Dp = CALENDAR_CELL_VERTICAL_GAP
verticalGap: Dp = CALENDAR_CELL_VERTICAL_GAP,
internalPadding: Dp = CALENDAR_INTERNAL_PADDING
) {
val locale = Locale.getDefault()
val shapes = shapes
@@ -92,7 +114,7 @@ fun FocusHistoryCalendar(
.fillMaxWidth()
.background(colorScheme.surfaceContainer, shapes.largeIncreased)
.horizontalScroll(rememberScrollState())
.padding(20.dp)
.padding(internalPadding)
) {
Row(horizontalArrangement = Arrangement.spacedBy(horizontalGap)) {
daysOfWeek.fastForEach {

View File

@@ -65,9 +65,11 @@ import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter
import com.patrykandpatrick.vico.core.cartesian.data.columnSeries
import com.patrykandpatrick.vico.core.common.data.ExtraStore
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.data.Stat
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.FocusHistoryCalendar
import org.nsh07.pomodoro.ui.statsScreen.components.HorizontalStackedBar
import org.nsh07.pomodoro.ui.statsScreen.components.TimeColumnChart
import org.nsh07.pomodoro.ui.statsScreen.components.sharedBoundsReveal
@@ -80,8 +82,9 @@ import org.nsh07.pomodoro.utils.millisecondsToMinutes
@Composable
fun SharedTransitionScope.LastMonthScreen(
contentPadding: PaddingValues,
lastMonthAnalysisValues: Pair<List<Long>, Long>,
lastMonthSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
focusBreakdownValues: Pair<List<Long>, Long>,
calendarData: List<Stat?>,
mainChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
onBack: () -> Unit,
modifier: Modifier = Modifier,
hoursMinutesFormat: String,
@@ -95,18 +98,18 @@ fun SharedTransitionScope.LastMonthScreen(
val lastMonthSummaryAnalysisModelProducer = remember { CartesianChartModelProducer() }
var breakdownChartExpanded by remember { mutableStateOf(false) }
LaunchedEffect(lastMonthAnalysisValues.first) {
LaunchedEffect(focusBreakdownValues.first) {
lastMonthSummaryAnalysisModelProducer.runTransaction {
columnSeries {
series(lastMonthAnalysisValues.first)
series(focusBreakdownValues.first)
}
}
}
val rankList = remember(lastMonthAnalysisValues) {
val rankList = remember(focusBreakdownValues) {
val sortedIndices =
lastMonthAnalysisValues.first.indices.sortedByDescending { lastMonthAnalysisValues.first[it] }
val ranks = MutableList(lastMonthAnalysisValues.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.LastMonthScreen(
ranks
}
val focusDuration = remember(lastMonthAnalysisValues) {
lastMonthAnalysisValues.first.sum()
val focusDuration = remember(focusBreakdownValues) {
focusBreakdownValues.first.sum()
}
Scaffold(
@@ -203,7 +206,7 @@ fun SharedTransitionScope.LastMonthScreen(
}
item {
TimeColumnChart(
modelProducer = lastMonthSummaryChartData.first,
modelProducer = mainChartData.first,
hoursFormat = hoursFormat,
hoursMinutesFormat = hoursMinutesFormat,
minutesFormat = minutesFormat,
@@ -211,7 +214,7 @@ fun SharedTransitionScope.LastMonthScreen(
markerTypeface = markerTypeface,
thickness = 8.dp,
xValueFormatter = CartesianValueFormatter { context, x, _ ->
context.model.extraStore[lastMonthSummaryChartData.second][x.toInt()]
context.model.extraStore[mainChartData.second][x.toInt()]
},
modifier = Modifier
.sharedBounds(
@@ -236,10 +239,10 @@ fun SharedTransitionScope.LastMonthScreen(
)
}
item { HorizontalStackedBar(lastMonthAnalysisValues.first, rankList = rankList) }
item { HorizontalStackedBar(focusBreakdownValues.first, rankList = rankList) }
item {
Row {
lastMonthAnalysisValues.first.fastForEach {
focusBreakdownValues.first.fastForEach {
Text(
if (it <= 60 * 60 * 1000)
millisecondsToMinutes(it, minutesFormat)
@@ -289,7 +292,7 @@ fun SharedTransitionScope.LastMonthScreen(
item {
FocusBreakRatioVisualization(
focusDuration = focusDuration,
breakDuration = lastMonthAnalysisValues.second
breakDuration = focusBreakdownValues.second
)
}
@@ -301,11 +304,17 @@ fun SharedTransitionScope.LastMonthScreen(
style = typography.headlineSmall
)
Text(
"Focus history of the past month",
"Focus history of the past month. Days of the previous month are marked with a different color. Click on a date for more info.",
style = typography.bodySmall,
color = colorScheme.onSurfaceVariant
)
}
item {
FocusHistoryCalendar(
data = calendarData,
averageRankList = rankList
)
}
}
}
}

View File

@@ -73,6 +73,7 @@ class StatsViewModel(
private val yearDayFormatter = DateTimeFormatter.ofPattern("d MMM")
private val lastWeekStatsFlow = statRepository.getLastNDaysStats(7)
private val lastMonthStatsFlow = statRepository.getLastNDaysStats(31)
private val lastYearStatsFlow = statRepository.getLastNDaysStats(365)
private val _lastYearMaxFocus = MutableStateFlow(Long.MAX_VALUE)
@@ -148,8 +149,8 @@ class StatsViewModel(
initialValue = Pair(listOf(0L, 0L, 0L, 0L), 0L)
)
val lastMonthSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =
statRepository.getLastNDaysStats(30)
val lastMonthMainChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =
lastMonthStatsFlow
.map { list ->
val reversed = list.reversed()
val keys = reversed.map { it.date.dayOfMonth.toString() }
@@ -167,7 +168,25 @@ class StatsViewModel(
initialValue = lastMonthSummary
)
val lastMonthAverageFocusTimes: StateFlow<Pair<List<Long>, Long>> =
val lastMonthCalendarData: StateFlow<List<Stat?>> =
lastMonthStatsFlow
.map { list ->
val list = list.reversed()
buildList {
repeat(list.first().date.dayOfWeek.value - DayOfWeek.MONDAY.value) {
add(null) // Make sure that the data starts with a Monday
}
addAll(list)
}
}
.flowOn(Dispatchers.IO)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
val lastMonthFocusBreakdownValues: StateFlow<Pair<List<Long>, Long>> =
statRepository.getLastNDaysAverageFocusTimes(30)
.map {
Pair(
@@ -223,9 +242,7 @@ class StatsViewModel(
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(list[it])
}
add(list[it])
}
}
}