feat(stats): implement heatmap in last year screen
This commit is contained in:
@@ -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<Screen.Stats.Main> {
|
||||
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<Screen.Stats.LastWeek> {
|
||||
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<Screen.Stats.LastYear> {
|
||||
LastYearScreen(
|
||||
contentPadding = contentPadding,
|
||||
lastYearAnalysisValues = lastYearAnalysisValues,
|
||||
lastYearSummaryChartData = lastYearSummaryChartData,
|
||||
focusBreakdownValues = lastYearFocusBreakdownValues,
|
||||
focusHeatmapData = lastYearFocusHeatmapData,
|
||||
heatmapMaxValue = lastYearMaxFocus,
|
||||
mainChartData = lastYearMainChartData,
|
||||
onBack = backStack::removeLastOrNull,
|
||||
hoursMinutesFormat = hoursMinutesFormat,
|
||||
hoursFormat = hoursFormat,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<List<Long>, Long>,
|
||||
lastYearSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
|
||||
focusBreakdownValues: Pair<List<Long>, Long>,
|
||||
focusHeatmapData: List<List<Long>?>,
|
||||
heatmapMaxValue: Long,
|
||||
mainChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =
|
||||
private val _lastYearMaxFocus = MutableStateFlow(Long.MAX_VALUE)
|
||||
val lastYearMaxFocus = _lastYearMaxFocus.asStateFlow()
|
||||
|
||||
val lastWeekMainChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =
|
||||
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<List<Pair<String, List<Long>>>> =
|
||||
val lastWeekFocusHistoryValues: StateFlow<List<Pair<String, List<Long>>>> =
|
||||
lastWeekStatsFlow
|
||||
.map { value ->
|
||||
value.reversed().map {
|
||||
@@ -119,7 +128,7 @@ class StatsViewModel(
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val lastWeekAverageFocusTimes: StateFlow<Pair<List<Long>, Long>> =
|
||||
val lastWeekFocusBreakdownValues: StateFlow<Pair<List<Long>, Long>> =
|
||||
statRepository.getLastNDaysAverageFocusTimes(7)
|
||||
.map {
|
||||
Pair(
|
||||
@@ -178,8 +187,8 @@ class StatsViewModel(
|
||||
initialValue = Pair(listOf(0L, 0L, 0L, 0L), 0L)
|
||||
)
|
||||
|
||||
val lastYearSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =
|
||||
statRepository.getLastNDaysStats(365)
|
||||
val lastYearMainChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =
|
||||
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<Pair<List<Long>, Long>> =
|
||||
val lastYearFocusHeatmapData: StateFlow<List<List<Long>?>> =
|
||||
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<Pair<List<Long>, 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(
|
||||
|
||||
Reference in New Issue
Block a user