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 641587f..f53e32e 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 @@ -58,15 +58,17 @@ fun StatsScreenRoot( val todayStat by viewModel.todayStat.collectAsStateWithLifecycle(null) - val lastWeekSummaryChartData by viewModel.lastWeekSummaryChartData.collectAsStateWithLifecycle() - val lastWeekSummaryValues by viewModel.lastWeekStats.collectAsStateWithLifecycle() - val lastWeekAnalysisValues by viewModel.lastWeekAverageFocusTimes.collectAsStateWithLifecycle() + val lastWeekMainChartData by viewModel.lastWeekMainChartData.collectAsStateWithLifecycle() + 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 lastYearSummaryChartData by viewModel.lastYearSummaryChartData.collectAsStateWithLifecycle() - val lastYearAnalysisValues by viewModel.lastYearAverageFocusTimes.collectAsStateWithLifecycle() + val lastYearMainChartData by viewModel.lastYearMainChartData.collectAsStateWithLifecycle() + val lastYearFocusHeatmapData by viewModel.lastYearFocusHeatmapData.collectAsStateWithLifecycle() + val lastYearFocusBreakdownValues by viewModel.lastYearFocusBreakdownValues.collectAsStateWithLifecycle() + val lastYearMaxFocus by viewModel.lastYearMaxFocus.collectAsStateWithLifecycle() val colorScheme = colorScheme @@ -95,13 +97,13 @@ fun StatsScreenRoot( entry { StatsMainScreen( contentPadding = contentPadding, - lastWeekSummaryChartData = lastWeekSummaryChartData, + lastWeekSummaryChartData = lastWeekMainChartData, lastMonthSummaryChartData = lastMonthSummaryChartData, - lastYearSummaryChartData = lastYearSummaryChartData, + lastYearSummaryChartData = lastYearMainChartData, todayStat = todayStat, - lastWeekAverageFocusTimes = lastWeekAnalysisValues.first, + lastWeekAverageFocusTimes = lastWeekFocusBreakdownValues.first, lastMonthAverageFocusTimes = lastMonthAnalysisValues.first, - lastYearAverageFocusTimes = lastYearAnalysisValues.first, + lastYearAverageFocusTimes = lastYearFocusBreakdownValues.first, generateSampleData = viewModel::generateSampleData, hoursFormat = hoursFormat, hoursMinutesFormat = hoursMinutesFormat, @@ -119,9 +121,9 @@ fun StatsScreenRoot( entry { LastWeekScreen( contentPadding = contentPadding, - focusBreakdownValues = lastWeekAnalysisValues, - focusHistoryValues = lastWeekSummaryValues, - mainChartData = lastWeekSummaryChartData, + focusBreakdownValues = lastWeekFocusBreakdownValues, + focusHistoryValues = lastWeekFocusHistoryValues, + mainChartData = lastWeekMainChartData, onBack = backStack::removeLastOrNull, hoursMinutesFormat = hoursMinutesFormat, hoursFormat = hoursFormat, @@ -148,8 +150,10 @@ fun StatsScreenRoot( entry { LastYearScreen( contentPadding = contentPadding, - lastYearAnalysisValues = lastYearAnalysisValues, - lastYearSummaryChartData = lastYearSummaryChartData, + focusBreakdownValues = lastYearFocusBreakdownValues, + focusHeatmapData = lastYearFocusHeatmapData, + heatmapMaxValue = lastYearMaxFocus, + mainChartData = lastYearMainChartData, onBack = backStack::removeLastOrNull, hoursMinutesFormat = hoursMinutesFormat, hoursFormat = hoursFormat, diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/components/visualizations.kt b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/components/visualizations.kt index 6233120..40fd715 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/components/visualizations.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/components/visualizations.kt @@ -46,6 +46,7 @@ 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 androidx.compose.ui.util.fastMaxBy import org.nsh07.pomodoro.ui.theme.TomatoTheme import java.time.LocalDate import java.time.format.TextStyle @@ -208,6 +209,8 @@ val HEATMAP_CELL_GAP = 4.dp * insert gaps in the heatmap, and can be used to, for example, delimit months by inserting an * empty week * @param modifier Modifier to be applied to the heatmap + * @param maxValue Maximum total value of the items present in [data]. This value must correspond to + * the sum of the list present in one of the elements on [data] for accurate representation. * * Note that it is assumed that the dates are continuous (without gaps) and start with a Monday */ @@ -218,7 +221,7 @@ fun HeatmapWithWeekLabels( size: Dp = HEATMAP_CELL_SIZE, gap: Dp = HEATMAP_CELL_GAP, contentPadding: PaddingValues = PaddingValues(), - maxValue: Long = remember { data.maxBy { it?.sum() ?: 0 }?.sum() ?: 0 }, + maxValue: Long = remember { data.fastMaxBy { it?.sum() ?: 0 }?.sum() ?: 0 }, ) { val locale = Locale.getDefault() @@ -242,7 +245,7 @@ fun HeatmapWithWeekLabels( LazyHorizontalGrid( rows = GridCells.Fixed(7), - modifier = modifier, + modifier = modifier.height(size * 7 + gap * 6), contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(gap), horizontalArrangement = Arrangement.spacedBy(gap) @@ -265,7 +268,6 @@ fun HeatmapWithWeekLabels( Spacer( Modifier .size(size) - .background(colorScheme.surfaceVariant, shapes.small) .background( colorScheme.primary.copy( remember(it) { diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/screens/LastYearScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/screens/LastYearScreen.kt index da52fc6..ada8096 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/screens/LastYearScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/screens/LastYearScreen.kt @@ -68,11 +68,12 @@ import org.nsh07.pomodoro.R 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.HeatmapWithWeekLabels import org.nsh07.pomodoro.ui.statsScreen.components.HorizontalStackedBar import org.nsh07.pomodoro.ui.statsScreen.components.TimeLineChart import org.nsh07.pomodoro.ui.statsScreen.components.sharedBoundsReveal import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar -import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape +import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.bottomListItemShape import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes import org.nsh07.pomodoro.utils.millisecondsToMinutes @@ -80,8 +81,10 @@ import org.nsh07.pomodoro.utils.millisecondsToMinutes @Composable fun SharedTransitionScope.LastYearScreen( contentPadding: PaddingValues, - lastYearAnalysisValues: Pair, Long>, - lastYearSummaryChartData: Pair>>, + focusBreakdownValues: Pair, Long>, + focusHeatmapData: List?>, + heatmapMaxValue: Long, + mainChartData: Pair>>, onBack: () -> Unit, modifier: Modifier = Modifier, hoursMinutesFormat: String, @@ -95,18 +98,18 @@ fun SharedTransitionScope.LastYearScreen( val lastYearSummaryAnalysisModelProducer = remember { CartesianChartModelProducer() } var breakdownChartExpanded by remember { mutableStateOf(false) } - LaunchedEffect(lastYearAnalysisValues.first) { + LaunchedEffect(focusBreakdownValues.first) { lastYearSummaryAnalysisModelProducer.runTransaction { columnSeries { - series(lastYearAnalysisValues.first) + series(focusBreakdownValues.first) } } } - val rankList = remember(lastYearAnalysisValues) { + val rankList = remember(focusBreakdownValues) { val sortedIndices = - lastYearAnalysisValues.first.indices.sortedByDescending { lastYearAnalysisValues.first[it] } - val ranks = MutableList(lastYearAnalysisValues.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.LastYearScreen( ranks } - val focusDuration = remember(lastYearAnalysisValues) { - lastYearAnalysisValues.first.sum() + val focusDuration = remember(focusBreakdownValues) { + focusBreakdownValues.first.sum() } Scaffold( @@ -158,7 +161,7 @@ fun SharedTransitionScope.LastYearScreen( "last year card" ), animatedVisibilityScope = LocalNavAnimatedContentScope.current, - clipShape = middleListItemShape + clipShape = bottomListItemShape ) ) { innerPadding -> val insets = mergePaddingValues(innerPadding, contentPadding) @@ -203,14 +206,14 @@ fun SharedTransitionScope.LastYearScreen( } item { TimeLineChart( - modelProducer = lastYearSummaryChartData.first, + modelProducer = mainChartData.first, hoursFormat = hoursFormat, hoursMinutesFormat = hoursMinutesFormat, minutesFormat = minutesFormat, axisTypeface = axisTypeface, markerTypeface = markerTypeface, xValueFormatter = CartesianValueFormatter { context, x, _ -> - context.model.extraStore[lastYearSummaryChartData.second][x.toInt()] + context.model.extraStore[mainChartData.second][x.toInt()] }, modifier = Modifier .sharedElement( @@ -235,10 +238,10 @@ fun SharedTransitionScope.LastYearScreen( ) } - item { HorizontalStackedBar(lastYearAnalysisValues.first, rankList = rankList) } + item { HorizontalStackedBar(focusBreakdownValues.first, rankList = rankList) } item { Row { - lastYearAnalysisValues.first.fastForEach { + focusBreakdownValues.first.fastForEach { Text( if (it <= 60 * 60 * 1000) millisecondsToMinutes(it, minutesFormat) @@ -288,7 +291,7 @@ fun SharedTransitionScope.LastYearScreen( item { FocusBreakRatioVisualization( focusDuration = focusDuration, - breakDuration = lastYearAnalysisValues.second + breakDuration = focusBreakdownValues.second ) } @@ -305,6 +308,13 @@ fun SharedTransitionScope.LastYearScreen( color = colorScheme.onSurfaceVariant ) } + item { + HeatmapWithWeekLabels( + data = focusHeatmapData, + maxValue = heatmapMaxValue, + contentPadding = PaddingValues(horizontal = 16.dp), + ) + } } } } \ 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 d3f2d85..0baa281 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 @@ -18,6 +18,7 @@ package org.nsh07.pomodoro.ui.statsScreen.viewModel import androidx.compose.runtime.mutableStateListOf +import androidx.compose.ui.util.fastMaxBy import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY @@ -29,17 +30,21 @@ import com.patrykandpatrick.vico.core.cartesian.data.columnSeries import com.patrykandpatrick.vico.core.cartesian.data.lineSeries import com.patrykandpatrick.vico.core.common.data.ExtraStore import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.nsh07.pomodoro.BuildConfig import org.nsh07.pomodoro.TomatoApplication import org.nsh07.pomodoro.data.Stat import org.nsh07.pomodoro.data.StatRepository import org.nsh07.pomodoro.ui.Screen +import java.time.DayOfWeek import java.time.LocalDate import java.time.format.DateTimeFormatter import java.time.format.TextStyle @@ -68,8 +73,12 @@ class StatsViewModel( private val yearDayFormatter = DateTimeFormatter.ofPattern("d MMM") private val lastWeekStatsFlow = statRepository.getLastNDaysStats(7) + private val lastYearStatsFlow = statRepository.getLastNDaysStats(365) - val lastWeekSummaryChartData: StateFlow>>> = + private val _lastYearMaxFocus = MutableStateFlow(Long.MAX_VALUE) + val lastYearMaxFocus = _lastYearMaxFocus.asStateFlow() + + val lastWeekMainChartData: StateFlow>>> = lastWeekStatsFlow .map { list -> // reversing is required because we need ascending order while the DB returns descending order @@ -94,7 +103,7 @@ class StatsViewModel( initialValue = lastWeekSummary ) - val lastWeekStats: StateFlow>>> = + val lastWeekFocusHistoryValues: StateFlow>>> = lastWeekStatsFlow .map { value -> value.reversed().map { @@ -119,7 +128,7 @@ class StatsViewModel( initialValue = emptyList() ) - val lastWeekAverageFocusTimes: StateFlow, Long>> = + val lastWeekFocusBreakdownValues: StateFlow, Long>> = statRepository.getLastNDaysAverageFocusTimes(7) .map { Pair( @@ -178,8 +187,8 @@ class StatsViewModel( initialValue = Pair(listOf(0L, 0L, 0L, 0L), 0L) ) - val lastYearSummaryChartData: StateFlow>>> = - statRepository.getLastNDaysStats(365) + val lastYearMainChartData: StateFlow>>> = + lastYearStatsFlow .map { list -> val reversed = list.reversed() val keys = reversed.map { it.date.format(yearDayFormatter) } @@ -197,7 +206,37 @@ class StatsViewModel( initialValue = lastYearSummary ) - val lastYearAverageFocusTimes: StateFlow, Long>> = + val lastYearFocusHeatmapData: StateFlow?>> = + lastYearStatsFlow + .map { list -> + val list = list.reversed() + _lastYearMaxFocus.update { + list.fastMaxBy { + it.totalFocusTime() + }?.totalFocusTime() ?: Long.MAX_VALUE + } + buildList { + repeat(list.first().date.dayOfWeek.value - DayOfWeek.MONDAY.value) { + add(null) // Make sure that the data starts with a Monday + } + list.indices.forEach { + 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(listOf(focusTimeQ1, focusTimeQ2, focusTimeQ3, focusTimeQ4)) + } + } + } + } + .flowOn(Dispatchers.IO) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + val lastYearFocusBreakdownValues: StateFlow, Long>> = statRepository.getLastNDaysAverageFocusTimes(365) .map { Pair( @@ -221,7 +260,7 @@ class StatsViewModel( if (BuildConfig.DEBUG) { viewModelScope.launch { val today = LocalDate.now().plusDays(1) - var it = today.minusDays(40) + var it = today.minusDays(365) while (it.isBefore(today)) { statRepository.insertStat(