feat: Add stats for the current day, update typography

The arrow button can be used to get a detailed analysis of the focus durations
This commit is contained in:
Nishant Mishra
2025-07-12 12:17:45 +05:30
parent 2c6c00b24f
commit 2101b0465e
9 changed files with 283 additions and 37 deletions

View File

@@ -1,3 +1,10 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.ui.settingsScreen package org.nsh07.pomodoro.ui.settingsScreen
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@@ -52,7 +59,7 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import org.nsh07.pomodoro.R import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTitle import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.theme.TomatoTheme
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -106,7 +113,7 @@ private fun SettingsScreen(
Text( Text(
"Settings", "Settings",
style = LocalTextStyle.current.copy( style = LocalTextStyle.current.copy(
fontFamily = robotoFlexTitle, fontFamily = robotoFlexTopBar,
fontSize = 32.sp, fontSize = 32.sp,
lineHeight = 32.sp lineHeight = 32.sp
) )

View File

@@ -7,18 +7,41 @@
package org.nsh07.pomodoro.ui.statsScreen package org.nsh07.pomodoro.ui.statsScreen
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.motionScheme
import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -27,17 +50,23 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.core.cartesian.data.columnSeries import com.patrykandpatrick.vico.core.cartesian.data.columnSeries
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.data.Stat
import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTitle import org.nsh07.pomodoro.ui.theme.AppFonts.openRundeClock
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes
@Composable @Composable
fun StatsScreenRoot( fun StatsScreenRoot(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory) viewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)
) { ) {
val allStatsSummaryModelProducer = viewModel.allStatsSummaryModelProducer val todayStat by viewModel.todayStat.collectAsState(null)
StatsScreen( StatsScreen(
allStatsSummaryModelProducer = allStatsSummaryModelProducer, allStatsSummaryModelProducer = viewModel.allStatsSummaryModelProducer,
todayStatModelProducer = viewModel.todayStatModelProducer,
todayStat = todayStat,
modifier = modifier modifier = modifier
) )
} }
@@ -46,25 +75,150 @@ fun StatsScreenRoot(
@Composable @Composable
fun StatsScreen( fun StatsScreen(
allStatsSummaryModelProducer: CartesianChartModelProducer, allStatsSummaryModelProducer: CartesianChartModelProducer,
todayStatModelProducer: CartesianChartModelProducer,
todayStat: Stat?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column(modifier) { var todayStatExpanded by rememberSaveable { mutableStateOf(false) }
TopAppBar(
title = { LazyColumn(
Text( horizontalAlignment = Alignment.CenterHorizontally,
"Stats", modifier = modifier
style = LocalTextStyle.current.copy( ) {
fontFamily = robotoFlexTitle, item {
fontSize = 32.sp, TopAppBar(
lineHeight = 32.sp title = {
Text(
"Stats",
style = LocalTextStyle.current.copy(
fontFamily = robotoFlexTopBar,
fontSize = 32.sp,
lineHeight = 32.sp
)
) )
) },
}, subtitle = {},
subtitle = {}, titleHorizontalAlignment = Alignment.CenterHorizontally
titleHorizontalAlignment = Alignment.CenterHorizontally )
) }
Text("This week", style = typography.headlineSmall, modifier = Modifier.padding(16.dp)) item {
TimeColumnChart(allStatsSummaryModelProducer, modifier = Modifier.padding(start = 16.dp)) Text(
"Today",
style = typography.headlineSmall,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
Spacer(Modifier.height(16.dp))
}
item {
Row(modifier = Modifier.padding(horizontal = 16.dp)) {
Box(
modifier = Modifier
.background(
colorScheme.primaryContainer,
MaterialTheme.shapes.largeIncreased
)
.weight(1f)
) {
Column(Modifier.padding(16.dp)) {
Text(
"Focus",
style = typography.titleMedium,
color = colorScheme.onPrimaryContainer
)
Text(
if (todayStat != null) remember(todayStat) {
millisecondsToHoursMinutes(
todayStat.focusTimeQ1 +
todayStat.focusTimeQ2 +
todayStat.focusTimeQ3 +
todayStat.focusTimeQ4
)
} else "0h 0m",
style = typography.displaySmall,
fontFamily = openRundeClock,
color = colorScheme.onPrimaryContainer
)
}
}
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.background(
colorScheme.tertiaryContainer,
MaterialTheme.shapes.largeIncreased
)
.weight(1f)
) {
Column(Modifier.padding(16.dp)) {
Text(
"Break",
style = typography.titleMedium,
color = colorScheme.onTertiaryContainer
)
Text(
if (todayStat != null) remember(todayStat) {
millisecondsToHoursMinutes(todayStat.breakTime)
} else "0h 0m",
style = typography.displaySmall,
fontFamily = openRundeClock,
color = colorScheme.onTertiaryContainer
)
}
}
}
}
item {
val iconRotation by animateFloatAsState(
if (todayStatExpanded) 180f else 0f,
animationSpec = motionScheme.defaultSpatialSpec()
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
FilledTonalIconToggleButton(
checked = todayStatExpanded,
onCheckedChange = { todayStatExpanded = it },
shapes = IconButtonDefaults.toggleableShapes(),
modifier = Modifier
.padding(horizontal = 16.dp)
.width(52.dp)
.align(Alignment.End)
) {
Icon(
painterResource(R.drawable.arrow_down),
"More info",
modifier = Modifier.rotate(iconRotation)
)
}
AnimatedVisibility(todayStatExpanded) {
TimeColumnChart(
todayStatModelProducer,
timeConverter = ::millisecondsToHoursMinutes,
modifier = Modifier.padding(start = 16.dp)
)
}
}
Spacer(Modifier.height(16.dp))
}
item {
Text(
"This week",
style = typography.headlineSmall,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
Spacer(Modifier.height(16.dp))
}
item {
TimeColumnChart(
allStatsSummaryModelProducer,
modifier = Modifier.padding(start = 16.dp)
)
}
} }
} }
@@ -78,11 +232,15 @@ fun StatsScreenPreview() {
runBlocking { runBlocking {
modelProducer.runTransaction { modelProducer.runTransaction {
columnSeries { series(5, 6) } columnSeries {
series(5, 6, 5, 2, 11, 8, 5, 2, 15, 11, 8, 13, 12, 10, 2, 7)
}
} }
} }
StatsScreen( StatsScreen(
allStatsSummaryModelProducer = modelProducer modelProducer,
modelProducer,
null
) )
} }

View File

@@ -10,10 +10,10 @@ package org.nsh07.pomodoro.ui.statsScreen
import android.graphics.Path import android.graphics.Path
import android.graphics.RectF import android.graphics.RectF
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme.motionScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom
@@ -40,9 +40,11 @@ import org.nsh07.pomodoro.utils.millisecondsToHours
internal fun TimeColumnChart( internal fun TimeColumnChart(
modelProducer: CartesianChartModelProducer, modelProducer: CartesianChartModelProducer,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
thickness: Dp = 40.dp,
timeConverter: (Long) -> String = ::millisecondsToHours
) { ) {
val radius = with(LocalDensity.current) { val radius = with(LocalDensity.current) {
(48.dp / 2).toPx() (thickness / 2).toPx()
} }
ProvideVicoTheme(rememberM3VicoTheme()) { ProvideVicoTheme(rememberM3VicoTheme()) {
CartesianChartHost( CartesianChartHost(
@@ -53,7 +55,7 @@ internal fun TimeColumnChart(
vicoTheme.columnCartesianLayerColors.map { color -> vicoTheme.columnCartesianLayerColors.map { color ->
rememberLineComponent( rememberLineComponent(
fill = fill(color), fill = fill(color),
thickness = 48.dp, thickness = thickness,
shape = { _, path, left, top, right, bottom -> shape = { _, path, left, top, right, bottom ->
if (top + radius <= bottom - radius) { if (top + radius <= bottom - radius) {
path.arcTo( path.arcTo(
@@ -87,21 +89,21 @@ internal fun TimeColumnChart(
tick = rememberLineComponent(Fill.Transparent), tick = rememberLineComponent(Fill.Transparent),
guideline = rememberLineComponent(Fill.Transparent), guideline = rememberLineComponent(Fill.Transparent),
valueFormatter = CartesianValueFormatter { measuringContext, value, _ -> valueFormatter = CartesianValueFormatter { measuringContext, value, _ ->
millisecondsToHours(value.toLong()) timeConverter(value.toLong())
} }
), ),
bottomAxis = HorizontalAxis.rememberBottom( bottomAxis = HorizontalAxis.rememberBottom(
rememberLineComponent(Fill.Transparent), rememberLineComponent(Fill.Transparent),
tick = rememberLineComponent(Fill.Transparent), tick = rememberLineComponent(Fill.Transparent),
guideline = rememberLineComponent(Fill.Transparent) guideline = rememberLineComponent(Fill.Transparent)
), )
), ),
modelProducer = modelProducer, modelProducer = modelProducer,
zoomState = rememberVicoZoomState( zoomState = rememberVicoZoomState(
zoomEnabled = false,
initialZoom = Zoom.fixed(), initialZoom = Zoom.fixed(),
minZoom = Zoom.min(Zoom.fixed(), Zoom.Content) minZoom = Zoom.min(Zoom.Content, Zoom.fixed())
), ),
animationSpec = motionScheme.defaultSpatialSpec(),
modifier = modifier, modifier = modifier,
) )
} }

View File

@@ -16,6 +16,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.core.cartesian.data.columnSeries import com.patrykandpatrick.vico.core.cartesian.data.columnSeries
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.nsh07.pomodoro.TomatoApplication import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.data.StatRepository import org.nsh07.pomodoro.data.StatRepository
@@ -23,11 +24,12 @@ import org.nsh07.pomodoro.data.StatRepository
class StatsViewModel( class StatsViewModel(
statRepository: StatRepository statRepository: StatRepository
) : ViewModel() { ) : ViewModel() {
private val todayStat = statRepository.getTodayStat() val todayStat = statRepository.getTodayStat().distinctUntilChanged()
private val allStatsSummary = statRepository.getLastWeekStatsSummary() private val allStatsSummary = statRepository.getLastWeekStatsSummary()
private val averageFocusTimes = statRepository.getAverageFocusTimes() private val averageFocusTimes = statRepository.getAverageFocusTimes()
val allStatsSummaryModelProducer = CartesianChartModelProducer() val allStatsSummaryModelProducer = CartesianChartModelProducer()
val todayStatModelProducer = CartesianChartModelProducer()
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@@ -38,6 +40,21 @@ class StatsViewModel(
} }
} }
} }
viewModelScope.launch(Dispatchers.IO) {
todayStat
.collect {
todayStatModelProducer.runTransaction {
columnSeries {
series(
it?.focusTimeQ1 ?: 0,
it?.focusTimeQ2 ?: 0,
it?.focusTimeQ3 ?: 0,
it?.focusTimeQ4 ?: 0
)
}
}
}
}
} }
companion object { companion object {

View File

@@ -1,3 +1,10 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.ui.theme package org.nsh07.pomodoro.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
@@ -9,6 +16,8 @@ import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.nsh07.pomodoro.R import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexHeadline
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTitle
// Set of Material typography styles to start with // Set of Material typography styles to start with
val Typography = Typography( val Typography = Typography(
@@ -18,6 +27,18 @@ val Typography = Typography(
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.5.sp letterSpacing = 0.5.sp
),
headlineSmall = TextStyle(
fontFamily = robotoFlexHeadline,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp,
),
titleMedium = TextStyle(
fontFamily = robotoFlexTitle,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp,
) )
) )
@@ -27,7 +48,7 @@ object AppFonts {
) )
@OptIn(ExperimentalTextApi::class) @OptIn(ExperimentalTextApi::class)
val robotoFlexTitle = FontFamily( val robotoFlexTopBar = FontFamily(
Font( Font(
R.font.roboto_flex_variable, R.font.roboto_flex_variable,
variationSettings = FontVariation.Settings( variationSettings = FontVariation.Settings(
@@ -45,4 +66,28 @@ object AppFonts {
) )
) )
) )
@OptIn(ExperimentalTextApi::class)
val robotoFlexHeadline = FontFamily(
Font(
R.font.roboto_flex_variable,
variationSettings = FontVariation.Settings(
FontVariation.width(130f),
FontVariation.weight(600),
FontVariation.grade(0)
)
)
)
@OptIn(ExperimentalTextApi::class)
val robotoFlexTitle = FontFamily(
Font(
R.font.roboto_flex_variable,
variationSettings = FontVariation.Settings(
FontVariation.width(130f),
FontVariation.weight(700),
FontVariation.grade(0)
)
)
)
} }

View File

@@ -58,7 +58,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.nsh07.pomodoro.R import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.theme.AppFonts.openRundeClock import org.nsh07.pomodoro.ui.theme.AppFonts.openRundeClock
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTitle import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.theme.TomatoTheme
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode
@@ -112,7 +112,7 @@ fun TimerScreen(
Text( Text(
"Tomato", "Tomato",
style = TextStyle( style = TextStyle(
fontFamily = robotoFlexTitle, fontFamily = robotoFlexTopBar,
fontSize = 32.sp, fontSize = 32.sp,
lineHeight = 32.sp, lineHeight = 32.sp,
color = colorScheme.onErrorContainer color = colorScheme.onErrorContainer
@@ -125,7 +125,7 @@ fun TimerScreen(
Text( Text(
"Focus", "Focus",
style = TextStyle( style = TextStyle(
fontFamily = robotoFlexTitle, fontFamily = robotoFlexTopBar,
fontSize = 32.sp, fontSize = 32.sp,
lineHeight = 32.sp, lineHeight = 32.sp,
color = colorScheme.onPrimaryContainer color = colorScheme.onPrimaryContainer
@@ -137,7 +137,7 @@ fun TimerScreen(
TimerMode.SHORT_BREAK -> Text( TimerMode.SHORT_BREAK -> Text(
"Short Break", "Short Break",
style = TextStyle( style = TextStyle(
fontFamily = robotoFlexTitle, fontFamily = robotoFlexTopBar,
fontSize = 32.sp, fontSize = 32.sp,
lineHeight = 32.sp, lineHeight = 32.sp,
color = colorScheme.onTertiaryContainer color = colorScheme.onTertiaryContainer
@@ -149,7 +149,7 @@ fun TimerScreen(
TimerMode.LONG_BREAK -> Text( TimerMode.LONG_BREAK -> Text(
"Long Break", "Long Break",
style = TextStyle( style = TextStyle(
fontFamily = robotoFlexTitle, fontFamily = robotoFlexTopBar,
fontSize = 32.sp, fontSize = 32.sp,
lineHeight = 32.sp, lineHeight = 32.sp,
color = colorScheme.onTertiaryContainer color = colorScheme.onTertiaryContainer

View File

@@ -23,4 +23,11 @@ fun millisecondsToHours(t: Long): String =
Locale.getDefault(), Locale.getDefault(),
"%dh", "%dh",
TimeUnit.MILLISECONDS.toHours(t) TimeUnit.MILLISECONDS.toHours(t)
)
fun millisecondsToHoursMinutes(t: Long): String =
String.format(
Locale.getDefault(),
"%dh %dm", TimeUnit.MILLISECONDS.toHours(t),
TimeUnit.MILLISECONDS.toMinutes(t) % TimeUnit.HOURS.toMinutes(1)
) )

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,599q-8,0 -15,-2.5t-13,-8.5L268,404q-11,-11 -11,-28t11,-28q11,-11 28,-11t28,11l156,156 156,-156q11,-11 28,-11t28,11q11,11 11,28t-11,28L508,588q-6,6 -13,8.5t-15,2.5Z" />
</vector>