From bf9398433932310c3dc91f2aed43723f051b2681 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Thu, 18 Dec 2025 18:38:36 +0530 Subject: [PATCH] feat(stats): implement a focus history calendar --- .../components/FocusHistoryCalendar.kt | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/components/FocusHistoryCalendar.kt diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/components/FocusHistoryCalendar.kt b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/components/FocusHistoryCalendar.kt new file mode 100644 index 0000000..d411ba0 --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/components/FocusHistoryCalendar.kt @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * This file is part of Tomato - a minimalist pomodoro timer for Android. + * + * Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tomato. + * If not, see . + */ + +package org.nsh07.pomodoro.ui.statsScreen.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.shapes +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +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 org.nsh07.pomodoro.data.Stat +import org.nsh07.pomodoro.ui.theme.TomatoTheme +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.format.TextStyle +import java.util.Locale +import kotlin.random.Random + +val CALENDAR_CELL_SIZE = 40.dp +val CALENDAR_CELL_HORIZONTAL_GAP = 2.dp +val CALENDAR_CELL_VERTICAL_GAP = 4.dp + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun FocusHistoryCalendar( + data: List, + averageRankList: List, + modifier: Modifier = Modifier, + size: Dp = CALENDAR_CELL_SIZE, + horizontalGap: Dp = CALENDAR_CELL_HORIZONTAL_GAP, + verticalGap: Dp = CALENDAR_CELL_VERTICAL_GAP +) { + val locale = Locale.getDefault() + val shapes = shapes + val last = data.lastOrNull { it != null } + + val daysOfWeek = remember(locale) { + DayOfWeek.entries.map { + it.getDisplayName( + TextStyle.SHORT, + locale + ) + } + } // Names of the 7 days of the week in the current locale + + val groupedData = remember(data) { + data.chunked(7) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(verticalGap), + modifier = modifier + .fillMaxWidth() + .background(colorScheme.surfaceContainer, shapes.largeIncreased) + .horizontalScroll(rememberScrollState()) + .padding(20.dp) + ) { + Row(horizontalArrangement = Arrangement.spacedBy(horizontalGap)) { + daysOfWeek.fastForEach { + Text( + text = it, + textAlign = TextAlign.Center, + style = typography.bodySmall, + color = colorScheme.outline, + modifier = Modifier.width(size) + ) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(verticalGap)) { + groupedData.fastForEachIndexed { baseIndex, items -> + Row( + horizontalArrangement = Arrangement.spacedBy(horizontalGap), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.height(size) + ) { + items.fastForEachIndexed { index, it -> + val background = remember(it) { (it?.totalFocusTime() ?: 0) > 0 } + val currentMonth = + remember(it, last) { it?.date?.month == last?.date?.month } + + val shape = remember(data, background) { + if (background) { + val next = + (data.getOrNull(baseIndex * 7 + index + 1)?.totalFocusTime() + ?: 0) > 0 + val previous = + (data.getOrNull(baseIndex * 7 + index - 1)?.totalFocusTime() + ?: 0) > 0 + + RoundedCornerShape( + topStart = if (previous) shapes.extraSmall.topStart else shapes.large.topStart, + topEnd = if (next) shapes.extraSmall.topEnd else shapes.large.topEnd, + bottomStart = if (previous) shapes.extraSmall.bottomStart else shapes.large.bottomStart, + bottomEnd = if (next) shapes.extraSmall.bottomEnd else shapes.large.bottomEnd + ) + } else RoundedCornerShape(0) + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(size) + .then( + if (background) Modifier.background( + if (currentMonth) colorScheme.primaryContainer + else colorScheme.secondaryContainer, + shape + ) + else Modifier + ) + ) { + Text( + text = it?.date?.dayOfMonth?.toString() ?: "", + color = + if (currentMonth) { + if (background) colorScheme.onPrimaryContainer + else colorScheme.onSurface + } else { + if (background) colorScheme.onSecondaryContainer + else colorScheme.outline + } + ) + } + } + } + } + } + } +} + +@Preview(name = "Focus History Calendar") +@Composable +private fun FocusHistoryCalendarPreview() { + val today1 = LocalDate.now() + val data = remember { + List(34) { index -> + if (index < 3) null + else { + val date = today1.minusDays((35 - index).toLong()) + val focusTimeSeconds = (index % 8 + 1) * 60L + val quarterTime = focusTimeSeconds / 4 + + val random = Random.nextInt() % 3 + + if (random == 0) Stat( + date, 0, 0, 0, 0, 0 + ) else Stat( + date = date, + focusTimeQ1 = quarterTime, + focusTimeQ2 = quarterTime, + focusTimeQ3 = quarterTime, + focusTimeQ4 = quarterTime, + breakTime = focusTimeSeconds / 4 + ) + } + } + } + + val averageRankList = listOf(3, 0, 1, 2) + + TomatoTheme(dynamicColor = false) { + Surface { + FocusHistoryCalendar( + data = data, + averageRankList = averageRankList, + modifier = Modifier.padding(16.dp) + ) + } + } +} \ No newline at end of file