diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/StatsScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/StatsScreen.kt index f53e32e..1f19c7e 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/StatsScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/StatsScreen.kt @@ -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 { LastMonthScreen( contentPadding = contentPadding, - lastMonthAnalysisValues = lastMonthAnalysisValues, - lastMonthSummaryChartData = lastMonthSummaryChartData, + focusBreakdownValues = lastMonthFocusBreakdownValues, + calendarData = lastMonthCalendarData, + mainChartData = lastMonthMainChartData, onBack = backStack::removeLastOrNull, hoursMinutesFormat = hoursMinutesFormat, hoursFormat = hoursFormat, 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 index d411ba0..d327eda 100644 --- 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 @@ -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 { diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/screens/LastMonthScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/screens/LastMonthScreen.kt index 001c96a..7121254 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/screens/LastMonthScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/screens/LastMonthScreen.kt @@ -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, Long>, - lastMonthSummaryChartData: Pair>>, + focusBreakdownValues: Pair, Long>, + calendarData: List, + mainChartData: Pair>>, 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 + ) + } } } } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt index 7dee21b..1217046 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt @@ -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>>> = - statRepository.getLastNDaysStats(30) + val lastMonthMainChartData: StateFlow>>> = + 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, Long>> = + val lastMonthCalendarData: StateFlow> = + 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, 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]) } } }