refactor(internal): Add TypeConvertor for LocalDate to directly handle LocalDate in database

This commit is contained in:
Nishant Mishra
2025-07-12 18:45:24 +05:30
parent 2101b0465e
commit 268d66e51d
11 changed files with 250 additions and 156 deletions

View File

@@ -11,11 +11,13 @@ import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(
entities = [IntPreference::class, Stat::class],
version = 1
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun preferenceDao(): PreferenceDao

View File

@@ -0,0 +1,23 @@
/*
* 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.data
import androidx.room.TypeConverter
import java.time.LocalDate
class Converters {
@TypeConverter
fun localDateToString(localDate: LocalDate?): String? {
return localDate?.toString()
}
@TypeConverter
fun stringToLocalDate(date: String?): LocalDate? {
return if (date != null) LocalDate.parse(date) else null
}
}

View File

@@ -9,6 +9,7 @@ package org.nsh07.pomodoro.data
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.LocalDate
/**
* Data class for storing the user's statistics in the app's database. This class stores the focus
@@ -18,7 +19,7 @@ import androidx.room.PrimaryKey
@Entity(tableName = "stat")
data class Stat(
@PrimaryKey
val date: String,
val date: LocalDate,
val focusTimeQ1: Long,
val focusTimeQ2: Long,
val focusTimeQ3: Long,
@@ -27,7 +28,7 @@ data class Stat(
)
data class StatSummary(
val date: String,
val date: LocalDate,
val focusTime: Long,
val breakTime: Long
)

View File

@@ -12,6 +12,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy.Companion.REPLACE
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
@Dao
interface StatDao {
@@ -19,22 +20,22 @@ interface StatDao {
suspend fun insertStat(stat: Stat)
@Query("UPDATE stat SET focusTimeQ1 = focusTimeQ1 + :focusTime WHERE date = :date")
suspend fun addFocusTimeQ1(date: String, focusTime: Long)
suspend fun addFocusTimeQ1(date: LocalDate, focusTime: Long)
@Query("UPDATE stat SET focusTimeQ2 = focusTimeQ2 + :focusTime WHERE date = :date")
suspend fun addFocusTimeQ2(date: String, focusTime: Long)
suspend fun addFocusTimeQ2(date: LocalDate, focusTime: Long)
@Query("UPDATE stat SET focusTimeQ3 = focusTimeQ3 + :focusTime WHERE date = :date")
suspend fun addFocusTimeQ3(date: String, focusTime: Long)
suspend fun addFocusTimeQ3(date: LocalDate, focusTime: Long)
@Query("UPDATE stat SET focusTimeQ4 = focusTimeQ4 + :focusTime WHERE date = :date")
suspend fun addFocusTimeQ4(date: String, focusTime: Long)
suspend fun addFocusTimeQ4(date: LocalDate, focusTime: Long)
@Query("UPDATE stat SET breakTime = breakTime + :breakTime WHERE date = :date")
suspend fun addBreakTime(date: String, breakTime: Long)
suspend fun addBreakTime(date: LocalDate, breakTime: Long)
@Query("SELECT * FROM stat WHERE date = :date")
fun getStat(date: String): 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 7")
fun getLastWeekStatsSummary(): Flow<List<StatSummary>>
@@ -43,8 +44,8 @@ interface StatDao {
fun getAvgFocusTimes(): Flow<StatFocusTime?>
@Query("SELECT EXISTS (SELECT * FROM stat WHERE date = :date)")
suspend fun statExists(date: String): Boolean
suspend fun statExists(date: LocalDate): Boolean
@Query("SELECT date FROM stat ORDER BY date DESC LIMIT 1")
suspend fun getLastDate(): String?
suspend fun getLastDate(): LocalDate?
}

View File

@@ -32,7 +32,7 @@ interface StatRepository {
fun getAverageFocusTimes(): Flow<StatFocusTime?>
suspend fun getLastDate(): String?
suspend fun getLastDate(): LocalDate?
}
/**
@@ -45,7 +45,7 @@ class AppStatRepository(
override suspend fun insertStat(stat: Stat) = statDao.insertStat(stat)
override suspend fun addFocusTime(focusTime: Long) = withContext(ioDispatcher) {
val currentDate = LocalDate.now().toString()
val currentDate = LocalDate.now()
val currentTime = LocalTime.now().toSecondOfDay()
val secondsInDay = 24 * 60 * 60
@@ -88,7 +88,7 @@ class AppStatRepository(
}
override suspend fun addBreakTime(breakTime: Long) = withContext(ioDispatcher) {
val currentDate = LocalDate.now().toString()
val currentDate = LocalDate.now()
if (statDao.statExists(currentDate)) {
statDao.addBreakTime(currentDate, breakTime)
} else {
@@ -97,7 +97,7 @@ class AppStatRepository(
}
override fun getTodayStat(): Flow<Stat?> {
val currentDate = LocalDate.now().toString()
val currentDate = LocalDate.now()
return statDao.getStat(currentDate)
}
@@ -106,5 +106,5 @@ class AppStatRepository(
override fun getAverageFocusTimes(): Flow<StatFocusTime?> = statDao.getAvgFocusTimes()
override suspend fun getLastDate(): String? = statDao.getLastDate()
override suspend fun getLastDate(): LocalDate? = statDao.getLastDate()
}

View File

@@ -157,7 +157,13 @@ fun AppScreen(
}
entry<Screen.Stats> {
StatsScreenRoot()
StatsScreenRoot(
modifier = modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection),
bottom = contentPadding.calculateBottomPadding()
)
)
}
}
)

View File

@@ -0,0 +1,52 @@
/*
* 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.statsScreen
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter
import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes
@Composable
fun ColumnScope.ProductivityGraph(
expanded: Boolean,
modelProducer: CartesianChartModelProducer,
modifier: Modifier = Modifier
) {
AnimatedVisibility(expanded) {
Column(modifier = modifier) {
Text("Productivity analysis", style = typography.titleMedium)
Text("Time of day versus focus hours", style = typography.bodySmall)
Spacer(Modifier.height(8.dp))
TimeColumnChart(
modelProducer,
yValueFormatter = CartesianValueFormatter { _, value, _ ->
millisecondsToHoursMinutes(value.toLong())
},
xValueFormatter = CartesianValueFormatter { _, value, _ ->
when (value) {
0.0 -> "0 - 6"
1.0 -> "6 - 12"
2.0 -> "12 - 18"
3.0 -> "18 - 24"
else -> ""
}
}
)
}
}
}

View File

@@ -7,7 +7,6 @@
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
@@ -31,6 +30,7 @@ import androidx.compose.material3.MaterialTheme.motionScheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -41,6 +41,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
@@ -64,7 +65,7 @@ fun StatsScreenRoot(
) {
val todayStat by viewModel.todayStat.collectAsState(null)
StatsScreen(
allStatsSummaryModelProducer = viewModel.allStatsSummaryModelProducer,
lastWeekSummaryModelProducer = viewModel.lastWeekSummaryChartModelProducer,
todayStatModelProducer = viewModel.todayStatModelProducer,
todayStat = todayStat,
modifier = modifier
@@ -74,150 +75,150 @@ fun StatsScreenRoot(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun StatsScreen(
allStatsSummaryModelProducer: CartesianChartModelProducer,
lastWeekSummaryModelProducer: CartesianChartModelProducer,
todayStatModelProducer: CartesianChartModelProducer,
todayStat: Stat?,
modifier: Modifier = Modifier
) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
var todayStatExpanded by rememberSaveable { mutableStateOf(false) }
LazyColumn(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
) {
item {
TopAppBar(
title = {
Text(
"Stats",
style = LocalTextStyle.current.copy(
fontFamily = robotoFlexTopBar,
fontSize = 32.sp,
lineHeight = 32.sp
)
TopAppBar(
title = {
Text(
"Stats",
style = LocalTextStyle.current.copy(
fontFamily = robotoFlexTopBar,
fontSize = 32.sp,
lineHeight = 32.sp
)
},
subtitle = {},
titleHorizontalAlignment = Alignment.CenterHorizontally
)
}
item {
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(
)
},
subtitle = {},
titleHorizontalAlignment = Alignment.CenterHorizontally,
scrollBehavior = scrollBehavior
)
LazyColumn(horizontalAlignment = Alignment.CenterHorizontally) {
item {
Text(
"Today",
style = typography.headlineSmall,
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
)
.fillMaxWidth()
.padding(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
)
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)
item {
val iconRotation by animateFloatAsState(
if (todayStatExpanded) 180f else 0f,
animationSpec = motionScheme.defaultSpatialSpec()
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Icon(
painterResource(R.drawable.arrow_down),
"More info",
modifier = Modifier.rotate(iconRotation)
)
}
AnimatedVisibility(todayStatExpanded) {
TimeColumnChart(
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)
)
}
ProductivityGraph(
todayStatExpanded,
todayStatModelProducer,
timeConverter = ::millisecondsToHoursMinutes,
modifier = Modifier.padding(start = 16.dp)
Modifier.padding(horizontal = 32.dp)
)
}
Spacer(Modifier.height(16.dp))
}
item {
Text(
"This week",
style = typography.headlineSmall,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
}
item {
TimeColumnChart(
lastWeekSummaryModelProducer,
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)
)
}
}
}

View File

@@ -41,7 +41,11 @@ internal fun TimeColumnChart(
modelProducer: CartesianChartModelProducer,
modifier: Modifier = Modifier,
thickness: Dp = 40.dp,
timeConverter: (Long) -> String = ::millisecondsToHours
columnCollectionSpacing: Dp = 4.dp,
xValueFormatter: CartesianValueFormatter = CartesianValueFormatter.Default,
yValueFormatter: CartesianValueFormatter = CartesianValueFormatter { measuringContext, value, _ ->
millisecondsToHours(value.toLong())
}
) {
val radius = with(LocalDensity.current) {
(thickness / 2).toPx()
@@ -82,20 +86,19 @@ internal fun TimeColumnChart(
)
}
),
columnCollectionSpacing = 4.dp
columnCollectionSpacing = columnCollectionSpacing
),
startAxis = VerticalAxis.rememberStart(
line = rememberLineComponent(Fill.Transparent),
tick = rememberLineComponent(Fill.Transparent),
guideline = rememberLineComponent(Fill.Transparent),
valueFormatter = CartesianValueFormatter { measuringContext, value, _ ->
timeConverter(value.toLong())
}
valueFormatter = yValueFormatter
),
bottomAxis = HorizontalAxis.rememberBottom(
rememberLineComponent(Fill.Transparent),
tick = rememberLineComponent(Fill.Transparent),
guideline = rememberLineComponent(Fill.Transparent)
guideline = rememberLineComponent(Fill.Transparent),
valueFormatter = xValueFormatter
)
),
modelProducer = modelProducer,

View File

@@ -28,15 +28,18 @@ class StatsViewModel(
private val allStatsSummary = statRepository.getLastWeekStatsSummary()
private val averageFocusTimes = statRepository.getAverageFocusTimes()
val allStatsSummaryModelProducer = CartesianChartModelProducer()
val lastWeekSummaryChartModelProducer = CartesianChartModelProducer()
val todayStatModelProducer = CartesianChartModelProducer()
init {
viewModelScope.launch(Dispatchers.IO) {
allStatsSummary
.collect { list ->
allStatsSummaryModelProducer.runTransaction {
columnSeries { series(list.reversed().map { it.focusTime }) }
lastWeekSummaryChartModelProducer.runTransaction {
columnSeries {
// reversing is required because we need ascending order while the DB returns descending order
series(list.reversed().map { it.focusTime })
}
}
}
}

View File

@@ -85,13 +85,15 @@ class TimerViewModel(
resetTimer()
var lastDate = LocalDate.parse(statRepository.getLastDate())
var lastDate = statRepository.getLastDate()
val today = LocalDate.now()
// Fills dates between today and lastDate with 0s to ensure continuous history
while (lastDate.until(today).days > 0) {
lastDate = lastDate.plusDays(1)
statRepository.insertStat(Stat(lastDate.toString(), 0, 0, 0, 0, 0))
lastDate?.until(today)?.days?.let {
while (it > 0) {
lastDate = lastDate?.plusDays(1)
statRepository.insertStat(Stat(lastDate!!, 0, 0, 0, 0, 0))
}
}
delay(1500)