feat(stats): add calendar in last month screen
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user