feat(stats): fully implement last week screen

This commit is contained in:
Nishant Mishra
2025-12-12 12:48:46 +05:30
parent 4ec2ba1321
commit c39089de21
10 changed files with 267 additions and 72 deletions

View File

@@ -1,8 +1,18 @@
/* /*
* Copyright (c) 2025 Nishant Mishra * Copyright (c) 2025 Nishant Mishra
* *
* You should have received a copy of the GNU General Public License * This file is part of Tomato - a minimalist pomodoro timer for Android.
* along with this program. If not, see <https://www.gnu.org/licenses/>. *
* 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 <https://www.gnu.org/licenses/>.
*/ */
package org.nsh07.pomodoro.data package org.nsh07.pomodoro.data
@@ -29,17 +39,10 @@ data class Stat(
fun totalFocusTime() = focusTimeQ1 + focusTimeQ2 + focusTimeQ3 + focusTimeQ4 fun totalFocusTime() = focusTimeQ1 + focusTimeQ2 + focusTimeQ3 + focusTimeQ4
} }
data class StatSummary( data class StatTime(
val date: LocalDate,
val focusTime: Long,
val breakTime: Long
)
data class StatFocusTime(
val focusTimeQ1: Long, val focusTimeQ1: Long,
val focusTimeQ2: Long, val focusTimeQ2: Long,
val focusTimeQ3: Long, val focusTimeQ3: Long,
val focusTimeQ4: Long val focusTimeQ4: Long,
) { val breakTime: Long,
fun total() = focusTimeQ1 + focusTimeQ2 + focusTimeQ3 + focusTimeQ4 )
}

View File

@@ -1,8 +1,18 @@
/* /*
* Copyright (c) 2025 Nishant Mishra * Copyright (c) 2025 Nishant Mishra
* *
* You should have received a copy of the GNU General Public License * This file is part of Tomato - a minimalist pomodoro timer for Android.
* along with this program. If not, see <https://www.gnu.org/licenses/>. *
* 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 <https://www.gnu.org/licenses/>.
*/ */
package org.nsh07.pomodoro.data package org.nsh07.pomodoro.data
@@ -37,23 +47,24 @@ interface StatDao {
@Query("SELECT * FROM stat WHERE date = :date") @Query("SELECT * FROM stat WHERE date = :date")
fun getStat(date: LocalDate): Flow<Stat?> fun getStat(date: LocalDate): Flow<Stat?>
@Query("SELECT date, focusTimeQ1 + focusTimeQ2 + focusTimeQ3 + focusTimeQ4 as focusTime, breakTime FROM stat ORDER BY date DESC LIMIT :n") @Query("SELECT date, focusTimeQ1, focusTimeQ2, focusTimeQ3, focusTimeQ4, breakTime FROM stat ORDER BY date DESC LIMIT :n")
fun getLastNDaysStatsSummary(n: Int): Flow<List<StatSummary>> fun getLastNDaysStats(n: Int): Flow<List<Stat>>
@Query( @Query(
"SELECT " + "SELECT " +
"AVG(focusTimeQ1) AS focusTimeQ1, " + "AVG(focusTimeQ1) AS focusTimeQ1, " +
"AVG(focusTimeQ2) AS focusTimeQ2, " + "AVG(focusTimeQ2) AS focusTimeQ2, " +
"AVG(focusTimeQ3) AS focusTimeQ3, " + "AVG(focusTimeQ3) AS focusTimeQ3, " +
"AVG(focusTimeQ4) AS focusTimeQ4 " + "AVG(focusTimeQ4) AS focusTimeQ4, " +
"AVG(breakTime) AS breakTime " +
"FROM (" + "FROM (" +
"SELECT * FROM (" + "SELECT * FROM (" +
"SELECT focusTimeQ1, focusTimeQ2, focusTimeQ3, focusTimeQ4 FROM stat ORDER BY date DESC LIMIT :n" + "SELECT focusTimeQ1, focusTimeQ2, focusTimeQ3, focusTimeQ4, breakTime FROM stat ORDER BY date DESC LIMIT :n" +
") " + ") " +
"WHERE focusTimeQ1 != 0 OR focusTimeQ2 != 0 OR focusTimeQ3 != 0 OR focusTimeQ4 != 0 " + "WHERE focusTimeQ1 != 0 OR focusTimeQ2 != 0 OR focusTimeQ3 != 0 OR focusTimeQ4 != 0 " +
")" ")"
) )
fun getLastNDaysAvgFocusTimes(n: Int): Flow<StatFocusTime?> fun getLastNDaysAvgStats(n: Int): Flow<StatTime?>
@Query("SELECT EXISTS (SELECT * FROM stat WHERE date = :date)") @Query("SELECT EXISTS (SELECT * FROM stat WHERE date = :date)")
suspend fun statExists(date: LocalDate): Boolean suspend fun statExists(date: LocalDate): Boolean

View File

@@ -1,8 +1,18 @@
/* /*
* Copyright (c) 2025 Nishant Mishra * Copyright (c) 2025 Nishant Mishra
* *
* You should have received a copy of the GNU General Public License * This file is part of Tomato - a minimalist pomodoro timer for Android.
* along with this program. If not, see <https://www.gnu.org/licenses/>. *
* 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 <https://www.gnu.org/licenses/>.
*/ */
package org.nsh07.pomodoro.data package org.nsh07.pomodoro.data
@@ -28,9 +38,9 @@ interface StatRepository {
fun getTodayStat(): Flow<Stat?> fun getTodayStat(): Flow<Stat?>
fun getLastNDaysStatsSummary(n: Int): Flow<List<StatSummary>> fun getLastNDaysStats(n: Int): Flow<List<Stat>>
fun getLastNDaysAverageFocusTimes(n: Int): Flow<StatFocusTime?> fun getLastNDaysAverageFocusTimes(n: Int): Flow<StatTime?>
suspend fun getLastDate(): LocalDate? suspend fun getLastDate(): LocalDate?
} }
@@ -101,11 +111,11 @@ class AppStatRepository(
return statDao.getStat(currentDate) return statDao.getStat(currentDate)
} }
override fun getLastNDaysStatsSummary(n: Int): Flow<List<StatSummary>> = override fun getLastNDaysStats(n: Int): Flow<List<Stat>> =
statDao.getLastNDaysStatsSummary(n) statDao.getLastNDaysStats(n)
override fun getLastNDaysAverageFocusTimes(n: Int): Flow<StatFocusTime?> = override fun getLastNDaysAverageFocusTimes(n: Int): Flow<StatTime?> =
statDao.getLastNDaysAvgFocusTimes(n) statDao.getLastNDaysAvgStats(n)
override suspend fun getLastDate(): LocalDate? = statDao.getLastDate() override suspend fun getLastDate(): LocalDate? = statDao.getLastDate()
} }

View File

@@ -62,6 +62,7 @@ fun StatsScreenRoot(
val todayStat by viewModel.todayStat.collectAsStateWithLifecycle(null) val todayStat by viewModel.todayStat.collectAsStateWithLifecycle(null)
val lastWeekSummaryChartData by viewModel.lastWeekSummaryChartData.collectAsStateWithLifecycle() val lastWeekSummaryChartData by viewModel.lastWeekSummaryChartData.collectAsStateWithLifecycle()
val lastWeekSummaryValues by viewModel.lastWeekStats.collectAsStateWithLifecycle()
val lastWeekAnalysisValues by viewModel.lastWeekAverageFocusTimes.collectAsStateWithLifecycle() val lastWeekAnalysisValues by viewModel.lastWeekAverageFocusTimes.collectAsStateWithLifecycle()
val lastMonthSummaryChartData by viewModel.lastMonthSummaryChartData.collectAsStateWithLifecycle() val lastMonthSummaryChartData by viewModel.lastMonthSummaryChartData.collectAsStateWithLifecycle()
@@ -74,10 +75,11 @@ fun StatsScreenRoot(
contentPadding = contentPadding, contentPadding = contentPadding,
backStack = backStack, backStack = backStack,
lastWeekSummaryChartData = lastWeekSummaryChartData, lastWeekSummaryChartData = lastWeekSummaryChartData,
lastWeekSummaryValues = lastWeekSummaryValues,
lastMonthSummaryChartData = lastMonthSummaryChartData, lastMonthSummaryChartData = lastMonthSummaryChartData,
lastYearSummaryChartData = lastYearSummaryChartData, lastYearSummaryChartData = lastYearSummaryChartData,
todayStat = todayStat, todayStat = todayStat,
lastWeekAverageFocusTimes = lastWeekAnalysisValues, lastWeekAnalysisValues = lastWeekAnalysisValues,
lastMonthAverageFocusTimes = lastMonthAnalysisValues, lastMonthAverageFocusTimes = lastMonthAnalysisValues,
lastYearAverageFocusTimes = lastYearAnalysisValues, lastYearAverageFocusTimes = lastYearAnalysisValues,
generateSampleData = viewModel::generateSampleData, generateSampleData = viewModel::generateSampleData,
@@ -94,10 +96,11 @@ fun StatsScreen(
contentPadding: PaddingValues, contentPadding: PaddingValues,
backStack: SnapshotStateList<Screen.Stats>, backStack: SnapshotStateList<Screen.Stats>,
lastWeekSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>, lastWeekSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
lastWeekSummaryValues: List<Pair<String, List<Long>>>,
lastMonthSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>, lastMonthSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
lastYearSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>, lastYearSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
todayStat: Stat?, todayStat: Stat?,
lastWeekAverageFocusTimes: List<Int>, lastWeekAnalysisValues: Pair<List<Long>, Long>,
lastMonthAverageFocusTimes: List<Int>, lastMonthAverageFocusTimes: List<Int>,
lastYearAverageFocusTimes: List<Int>, lastYearAverageFocusTimes: List<Int>,
generateSampleData: () -> Unit, generateSampleData: () -> Unit,
@@ -134,7 +137,7 @@ fun StatsScreen(
lastMonthSummaryChartData = lastMonthSummaryChartData, lastMonthSummaryChartData = lastMonthSummaryChartData,
lastYearSummaryChartData = lastYearSummaryChartData, lastYearSummaryChartData = lastYearSummaryChartData,
todayStat = todayStat, todayStat = todayStat,
lastWeekAverageFocusTimes = lastWeekAverageFocusTimes, lastWeekAverageFocusTimes = lastWeekAnalysisValues.first,
lastMonthAverageFocusTimes = lastMonthAverageFocusTimes, lastMonthAverageFocusTimes = lastMonthAverageFocusTimes,
lastYearAverageFocusTimes = lastYearAverageFocusTimes, lastYearAverageFocusTimes = lastYearAverageFocusTimes,
generateSampleData = generateSampleData, generateSampleData = generateSampleData,
@@ -154,10 +157,11 @@ fun StatsScreen(
entry<Screen.Stats.LastWeek> { entry<Screen.Stats.LastWeek> {
LastWeekScreen( LastWeekScreen(
contentPadding = contentPadding, contentPadding = contentPadding,
lastWeekAverageFocusTimes = lastWeekAverageFocusTimes, lastWeekAnalysisValues = lastWeekAnalysisValues,
lastWeekSummaryValues = lastWeekSummaryValues,
lastWeekSummaryChartData = lastWeekSummaryChartData,
onBack = backStack::removeLastOrNull, onBack = backStack::removeLastOrNull,
hoursMinutesFormat = hoursMinutesFormat, hoursMinutesFormat = hoursMinutesFormat,
lastWeekSummaryChartData = lastWeekSummaryChartData,
hoursFormat = hoursFormat, hoursFormat = hoursFormat,
minutesFormat = minutesFormat, minutesFormat = minutesFormat,
axisTypeface = axisTypeface, axisTypeface = axisTypeface,

View File

@@ -23,6 +23,8 @@ import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionDefaults import androidx.compose.animation.SharedTransitionDefaults
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -37,8 +39,8 @@ fun Modifier.sharedBoundsReveal(
sharedTransitionScope: SharedTransitionScope, sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope = LocalNavAnimatedContentScope.current, animatedVisibilityScope: AnimatedVisibilityScope = LocalNavAnimatedContentScope.current,
boundsTransform: BoundsTransform = SharedTransitionDefaults.BoundsTransform, boundsTransform: BoundsTransform = SharedTransitionDefaults.BoundsTransform,
enter: EnterTransition = EnterTransition.None, enter: EnterTransition = fadeIn(),
exit: ExitTransition = ExitTransition.None, exit: ExitTransition = fadeOut(),
resizeMode: SharedTransitionScope.ResizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, resizeMode: SharedTransitionScope.ResizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
clipShape: Shape = MaterialTheme.shapes.largeIncreased, clipShape: Shape = MaterialTheme.shapes.largeIncreased,
renderInOverlayDuringTransition: Boolean = true, renderInOverlayDuringTransition: Boolean = true,
@@ -55,7 +57,7 @@ fun Modifier.sharedBoundsReveal(
clipInOverlayDuringTransition = OverlayClip(clipShape), clipInOverlayDuringTransition = OverlayClip(clipShape),
renderInOverlayDuringTransition = renderInOverlayDuringTransition, renderInOverlayDuringTransition = renderInOverlayDuringTransition,
) )
.skipToLookaheadSize() // .skipToLookaheadSize()
.skipToLookaheadPosition() // .skipToLookaheadPosition()
} }
} }

View File

@@ -25,9 +25,12 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.MaterialTheme.shapes
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -35,6 +38,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed import androidx.compose.ui.util.fastForEachIndexed
import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.theme.TomatoTheme
import kotlin.math.roundToInt
/** /**
* A custom implementation of the 1-Dimensional heatmap plot that varies the width of the cells * A custom implementation of the 1-Dimensional heatmap plot that varies the width of the cells
@@ -98,6 +102,60 @@ fun VariableWidth1DHeatmap(
} }
} }
@Composable
fun FocusBreakRatioVisualization(
focusDuration: Long,
breakDuration: Long,
modifier: Modifier = Modifier,
height: Dp = 40.dp,
gap: Dp = 2.dp
) {
val focusPercentage = ((focusDuration / (focusDuration.toFloat() + breakDuration)) * 100)
val breakPercentage = ((breakDuration / (focusDuration.toFloat() + breakDuration)) * 100)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(gap),
modifier = modifier
) {
Text(
text = focusPercentage.roundToInt().toString() + '%',
style = typography.bodyLarge,
color = colorScheme.primary,
modifier = Modifier.padding(end = 6.dp)
)
Spacer(
Modifier
.weight(focusPercentage)
.height(height)
.background(
colorScheme.primary,
shapes.large.copy(
topEnd = shapes.extraSmall.topEnd,
bottomEnd = shapes.extraSmall.bottomEnd
)
)
)
Spacer(
Modifier
.weight(breakPercentage)
.height(height)
.background(
colorScheme.tertiary,
shapes.large.copy(
topStart = shapes.extraSmall.topStart,
bottomStart = shapes.extraSmall.bottomStart
)
)
)
Text(
text = breakPercentage.roundToInt().toString() + '%',
style = typography.bodyLarge,
color = colorScheme.tertiary,
modifier = Modifier.padding(start = 6.dp)
)
}
}
@Preview @Preview
@Composable @Composable
fun VariableWidth1DHeatmapPreview() { fun VariableWidth1DHeatmapPreview() {

View File

@@ -19,14 +19,15 @@ package org.nsh07.pomodoro.ui.statsScreen.screens
import android.graphics.Typeface import android.graphics.Typeface
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -46,28 +47,34 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.navigation3.ui.LocalNavAnimatedContentScope import androidx.navigation3.ui.LocalNavAnimatedContentScope
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter
import com.patrykandpatrick.vico.core.common.data.ExtraStore import com.patrykandpatrick.vico.core.common.data.ExtraStore
import org.nsh07.pomodoro.R import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.mergePaddingValues import org.nsh07.pomodoro.ui.mergePaddingValues
import org.nsh07.pomodoro.ui.statsScreen.components.FocusBreakRatioVisualization
import org.nsh07.pomodoro.ui.statsScreen.components.TimeColumnChart import org.nsh07.pomodoro.ui.statsScreen.components.TimeColumnChart
import org.nsh07.pomodoro.ui.statsScreen.components.VariableWidth1DHeatmap
import org.nsh07.pomodoro.ui.statsScreen.components.sharedBoundsReveal import org.nsh07.pomodoro.ui.statsScreen.components.sharedBoundsReveal
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape
import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes
import org.nsh07.pomodoro.utils.millisecondsToMinutes
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun SharedTransitionScope.LastWeekScreen( fun SharedTransitionScope.LastWeekScreen(
contentPadding: PaddingValues, contentPadding: PaddingValues,
lastWeekAverageFocusTimes: List<Int>, lastWeekAnalysisValues: Pair<List<Long>, Long>,
lastWeekSummaryValues: List<Pair<String, List<Long>>>,
lastWeekSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
hoursMinutesFormat: String, hoursMinutesFormat: String,
lastWeekSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
hoursFormat: String, hoursFormat: String,
minutesFormat: String, minutesFormat: String,
axisTypeface: Typeface, axisTypeface: Typeface,
@@ -75,10 +82,10 @@ fun SharedTransitionScope.LastWeekScreen(
) { ) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val rankList = remember(lastWeekAverageFocusTimes) { val rankList = remember(lastWeekAnalysisValues) {
val sortedIndices = val sortedIndices =
lastWeekAverageFocusTimes.indices.sortedByDescending { lastWeekAverageFocusTimes[it] } lastWeekAnalysisValues.first.indices.sortedByDescending { lastWeekAnalysisValues.first[it] }
val ranks = MutableList(lastWeekAverageFocusTimes.size) { 0 } val ranks = MutableList(lastWeekAnalysisValues.first.size) { 0 }
sortedIndices.forEachIndexed { rank, originalIndex -> sortedIndices.forEachIndexed { rank, originalIndex ->
ranks[originalIndex] = rank ranks[originalIndex] = rank
@@ -87,6 +94,10 @@ fun SharedTransitionScope.LastWeekScreen(
ranks ranks
} }
val focusDuration = remember(lastWeekAnalysisValues) {
lastWeekAnalysisValues.first.sum()
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -115,11 +126,7 @@ fun SharedTransitionScope.LastWeekScreen(
) )
} }
}, },
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior
colors = TopAppBarDefaults.topAppBarColors(
containerColor = colorScheme.surfaceBright,
scrolledContainerColor = colorScheme.surfaceBright
)
) )
}, },
modifier = modifier modifier = modifier
@@ -135,11 +142,10 @@ fun SharedTransitionScope.LastWeekScreen(
) { innerPadding -> ) { innerPadding ->
val insets = mergePaddingValues(innerPadding, contentPadding) val insets = mergePaddingValues(innerPadding, contentPadding)
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = insets, contentPadding = insets,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(colorScheme.surfaceBright)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
item { item {
@@ -150,9 +156,7 @@ fun SharedTransitionScope.LastWeekScreen(
) { ) {
Text( Text(
millisecondsToHoursMinutes( millisecondsToHoursMinutes(
remember(lastWeekAverageFocusTimes) { focusDuration,
lastWeekAverageFocusTimes.sum().toLong()
},
hoursMinutesFormat hoursMinutesFormat
), ),
style = typography.displaySmall, style = typography.displaySmall,
@@ -175,7 +179,8 @@ fun SharedTransitionScope.LastWeekScreen(
) )
) )
} }
Spacer(Modifier.height(16.dp)) }
item {
TimeColumnChart( TimeColumnChart(
modelProducer = lastWeekSummaryChartData.first, modelProducer = lastWeekSummaryChartData.first,
hoursFormat = hoursFormat, hoursFormat = hoursFormat,
@@ -194,6 +199,79 @@ fun SharedTransitionScope.LastWeekScreen(
) )
) )
} }
item { Spacer(Modifier.height(8.dp)) }
item {
Text(
"Focus overview",
style = typography.headlineSmall
)
Text(
"Average focus durations at different times of the day",
style = typography.bodySmall,
color = colorScheme.onSurfaceVariant
)
}
item { VariableWidth1DHeatmap(lastWeekAnalysisValues.first, rankList = rankList) }
item {
Row {
lastWeekAnalysisValues.first.fastForEach {
Text(
if (it <= 60 * 60 * 1000)
millisecondsToMinutes(it, minutesFormat)
else millisecondsToHoursMinutes(it, hoursMinutesFormat),
style = typography.bodyLarge,
textAlign = TextAlign.Center,
color = colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
}
}
}
item { Spacer(Modifier.height(8.dp)) }
item {
Text(
stringResource(R.string.focus_break_ratio),
style = typography.headlineSmall
)
}
item {
FocusBreakRatioVisualization(
focusDuration = focusDuration,
breakDuration = lastWeekAnalysisValues.second
)
}
item { Spacer(Modifier.height(8.dp)) }
item {
Text(
"Focus insights",
style = typography.headlineSmall
)
Text(
"Focus overview of each day of the past week",
style = typography.bodySmall,
color = colorScheme.onSurfaceVariant
)
}
item {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
lastWeekSummaryValues.fastForEach {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
it.first,
style = typography.labelSmall,
modifier = Modifier.size(18.dp)
)
VariableWidth1DHeatmap(it.second, rankList = rankList)
}
}
}
}
} }
} }
} }

View File

@@ -82,7 +82,7 @@ fun SharedTransitionScope.StatsMainScreen(
lastMonthSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>, lastMonthSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
lastYearSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>, lastYearSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
todayStat: Stat?, todayStat: Stat?,
lastWeekAverageFocusTimes: List<Int>, lastWeekAverageFocusTimes: List<Long>,
lastMonthAverageFocusTimes: List<Int>, lastMonthAverageFocusTimes: List<Int>,
lastYearAverageFocusTimes: List<Int>, lastYearAverageFocusTimes: List<Int>,
generateSampleData: () -> Unit, generateSampleData: () -> Unit,
@@ -254,7 +254,7 @@ fun SharedTransitionScope.StatsMainScreen(
Text( Text(
millisecondsToHoursMinutes( millisecondsToHoursMinutes(
remember(lastWeekAverageFocusTimes) { remember(lastWeekAverageFocusTimes) {
lastWeekAverageFocusTimes.sum().toLong() lastWeekAverageFocusTimes.sum()
}, },
hoursMinutesFormat hoursMinutesFormat
), ),

View File

@@ -67,8 +67,10 @@ class StatsViewModel(
private val yearDayFormatter = DateTimeFormatter.ofPattern("d MMM") private val yearDayFormatter = DateTimeFormatter.ofPattern("d MMM")
private val lastWeekStatsFlow = statRepository.getLastNDaysStats(7)
val lastWeekSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> = val lastWeekSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =
statRepository.getLastNDaysStatsSummary(7) lastWeekStatsFlow
.map { list -> .map { list ->
// reversing is required because we need ascending order while the DB returns descending order // reversing is required because we need ascending order while the DB returns descending order
val reversed = list.reversed() val reversed = list.reversed()
@@ -78,7 +80,7 @@ class StatsViewModel(
Locale.getDefault() Locale.getDefault()
) )
} }
val values = reversed.map { it.focusTime } val values = reversed.map { it.totalFocusTime() }
lastWeekSummary.first.runTransaction { lastWeekSummary.first.runTransaction {
columnSeries { series(values) } columnSeries { series(values) }
extras { it[lastWeekSummary.second] = keys } extras { it[lastWeekSummary.second] = keys }
@@ -92,29 +94,57 @@ class StatsViewModel(
initialValue = lastWeekSummary initialValue = lastWeekSummary
) )
val lastWeekAverageFocusTimes: StateFlow<List<Int>> = val lastWeekStats: StateFlow<List<Pair<String, List<Long>>>> =
lastWeekStatsFlow
.map { value ->
value.reversed().map {
Pair(
it.date.dayOfWeek.getDisplayName(
TextStyle.NARROW,
Locale.getDefault()
),
listOf(
it.focusTimeQ1,
it.focusTimeQ2,
it.focusTimeQ3,
it.focusTimeQ4
)
)
}
}
.flowOn(Dispatchers.IO)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
val lastWeekAverageFocusTimes: StateFlow<Pair<List<Long>, Long>> =
statRepository.getLastNDaysAverageFocusTimes(7) statRepository.getLastNDaysAverageFocusTimes(7)
.map { .map {
listOf( Pair(
it?.focusTimeQ1?.toInt() ?: 0, listOf(
it?.focusTimeQ2?.toInt() ?: 0, it?.focusTimeQ1 ?: 0L,
it?.focusTimeQ3?.toInt() ?: 0, it?.focusTimeQ2 ?: 0L,
it?.focusTimeQ4?.toInt() ?: 0 it?.focusTimeQ3 ?: 0L,
it?.focusTimeQ4 ?: 0L
),
it?.breakTime ?: 0L
) )
} }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000), started = SharingStarted.WhileSubscribed(5000),
initialValue = listOf(0, 0, 0, 0) initialValue = Pair(listOf(0L, 0L, 0L, 0L), 0L)
) )
val lastMonthSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> = val lastMonthSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =
statRepository.getLastNDaysStatsSummary(30) statRepository.getLastNDaysStats(30)
.map { list -> .map { list ->
val reversed = list.reversed() val reversed = list.reversed()
val keys = reversed.map { it.date.dayOfMonth.toString() } val keys = reversed.map { it.date.dayOfMonth.toString() }
val values = reversed.map { it.focusTime } val values = reversed.map { it.totalFocusTime() }
lastMonthSummary.first.runTransaction { lastMonthSummary.first.runTransaction {
columnSeries { series(values) } columnSeries { series(values) }
extras { it[lastMonthSummary.second] = keys } extras { it[lastMonthSummary.second] = keys }
@@ -146,11 +176,11 @@ class StatsViewModel(
) )
val lastYearSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> = val lastYearSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =
statRepository.getLastNDaysStatsSummary(365) statRepository.getLastNDaysStats(365)
.map { list -> .map { list ->
val reversed = list.reversed() val reversed = list.reversed()
val keys = reversed.map { it.date.format(yearDayFormatter) } val keys = reversed.map { it.date.format(yearDayFormatter) }
val values = reversed.map { it.focusTime } val values = reversed.map { it.totalFocusTime() }
lastYearSummary.first.runTransaction { lastYearSummary.first.runTransaction {
lineSeries { series(values) } lineSeries { series(values) }
extras { it[lastYearSummary.second] = keys } extras { it[lastYearSummary.second] = keys }
@@ -195,13 +225,11 @@ class StatsViewModel(
(1 * 60 * 60 * 1000L..3 * 60 * 60 * 1000L).random(), (1 * 60 * 60 * 1000L..3 * 60 * 60 * 1000L).random(),
(0..3 * 60 * 60 * 1000L).random(), (0..3 * 60 * 60 * 1000L).random(),
(0..1 * 60 * 60 * 1000L).random(), (0..1 * 60 * 60 * 1000L).random(),
0 (0..100 * 60 * 1000L).random()
) )
) )
it = it.plusDays(1) it = it.plusDays(1)
} }
statRepository.addBreakTime((0..30 * 60 * 1000L).random())
} }
} }
} }

View File

@@ -113,4 +113,5 @@
<string name="secure_aod_desc">Automatically lock your device after a timeout, while keeping the AOD visible</string> <string name="secure_aod_desc">Automatically lock your device after a timeout, while keeping the AOD visible</string>
<string name="timer_reset_message">Timer reset</string> <string name="timer_reset_message">Timer reset</string>
<string name="undo">Undo</string> <string name="undo">Undo</string>
<string name="focus_break_ratio">Focus-break ratio</string>
</resources> </resources>