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.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database( @Database(
entities = [IntPreference::class, Stat::class], entities = [IntPreference::class, Stat::class],
version = 1 version = 1
) )
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun preferenceDao(): PreferenceDao 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.Entity
import androidx.room.PrimaryKey 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 * 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") @Entity(tableName = "stat")
data class Stat( data class Stat(
@PrimaryKey @PrimaryKey
val date: String, val date: LocalDate,
val focusTimeQ1: Long, val focusTimeQ1: Long,
val focusTimeQ2: Long, val focusTimeQ2: Long,
val focusTimeQ3: Long, val focusTimeQ3: Long,
@@ -27,7 +28,7 @@ data class Stat(
) )
data class StatSummary( data class StatSummary(
val date: String, val date: LocalDate,
val focusTime: Long, val focusTime: Long,
val breakTime: Long val breakTime: Long
) )

View File

@@ -12,6 +12,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.OnConflictStrategy.Companion.REPLACE
import androidx.room.Query import androidx.room.Query
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
@Dao @Dao
interface StatDao { interface StatDao {
@@ -19,22 +20,22 @@ interface StatDao {
suspend fun insertStat(stat: Stat) suspend fun insertStat(stat: Stat)
@Query("UPDATE stat SET focusTimeQ1 = focusTimeQ1 + :focusTime WHERE date = :date") @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") @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") @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") @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") @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") @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") @Query("SELECT date, focusTimeQ1 + focusTimeQ2 + focusTimeQ3 + focusTimeQ4 as focusTime, breakTime FROM stat ORDER BY date DESC LIMIT 7")
fun getLastWeekStatsSummary(): Flow<List<StatSummary>> fun getLastWeekStatsSummary(): Flow<List<StatSummary>>
@@ -43,8 +44,8 @@ interface StatDao {
fun getAvgFocusTimes(): Flow<StatFocusTime?> fun getAvgFocusTimes(): Flow<StatFocusTime?>
@Query("SELECT EXISTS (SELECT * FROM stat WHERE date = :date)") @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") @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?> 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 insertStat(stat: Stat) = statDao.insertStat(stat)
override suspend fun addFocusTime(focusTime: Long) = withContext(ioDispatcher) { override suspend fun addFocusTime(focusTime: Long) = withContext(ioDispatcher) {
val currentDate = LocalDate.now().toString() val currentDate = LocalDate.now()
val currentTime = LocalTime.now().toSecondOfDay() val currentTime = LocalTime.now().toSecondOfDay()
val secondsInDay = 24 * 60 * 60 val secondsInDay = 24 * 60 * 60
@@ -88,7 +88,7 @@ class AppStatRepository(
} }
override suspend fun addBreakTime(breakTime: Long) = withContext(ioDispatcher) { override suspend fun addBreakTime(breakTime: Long) = withContext(ioDispatcher) {
val currentDate = LocalDate.now().toString() val currentDate = LocalDate.now()
if (statDao.statExists(currentDate)) { if (statDao.statExists(currentDate)) {
statDao.addBreakTime(currentDate, breakTime) statDao.addBreakTime(currentDate, breakTime)
} else { } else {
@@ -97,7 +97,7 @@ class AppStatRepository(
} }
override fun getTodayStat(): Flow<Stat?> { override fun getTodayStat(): Flow<Stat?> {
val currentDate = LocalDate.now().toString() val currentDate = LocalDate.now()
return statDao.getStat(currentDate) return statDao.getStat(currentDate)
} }
@@ -106,5 +106,5 @@ class AppStatRepository(
override fun getAverageFocusTimes(): Flow<StatFocusTime?> = statDao.getAvgFocusTimes() 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> { 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 package org.nsh07.pomodoro.ui.statsScreen
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box 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.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.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -41,6 +41,7 @@ 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.draw.rotate
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource 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
@@ -64,7 +65,7 @@ fun StatsScreenRoot(
) { ) {
val todayStat by viewModel.todayStat.collectAsState(null) val todayStat by viewModel.todayStat.collectAsState(null)
StatsScreen( StatsScreen(
allStatsSummaryModelProducer = viewModel.allStatsSummaryModelProducer, lastWeekSummaryModelProducer = viewModel.lastWeekSummaryChartModelProducer,
todayStatModelProducer = viewModel.todayStatModelProducer, todayStatModelProducer = viewModel.todayStatModelProducer,
todayStat = todayStat, todayStat = todayStat,
modifier = modifier modifier = modifier
@@ -74,150 +75,150 @@ fun StatsScreenRoot(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun StatsScreen( fun StatsScreen(
allStatsSummaryModelProducer: CartesianChartModelProducer, lastWeekSummaryModelProducer: CartesianChartModelProducer,
todayStatModelProducer: CartesianChartModelProducer, todayStatModelProducer: CartesianChartModelProducer,
todayStat: Stat?, todayStat: Stat?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
var todayStatExpanded by rememberSaveable { mutableStateOf(false) } var todayStatExpanded by rememberSaveable { mutableStateOf(false) }
LazyColumn( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
) { ) {
item { TopAppBar(
TopAppBar( title = {
title = { Text(
Text( "Stats",
"Stats", style = LocalTextStyle.current.copy(
style = LocalTextStyle.current.copy( fontFamily = robotoFlexTopBar,
fontFamily = robotoFlexTopBar, fontSize = 32.sp,
fontSize = 32.sp, lineHeight = 32.sp
lineHeight = 32.sp
)
) )
}, )
subtitle = {}, },
titleHorizontalAlignment = Alignment.CenterHorizontally subtitle = {},
) titleHorizontalAlignment = Alignment.CenterHorizontally,
} scrollBehavior = scrollBehavior
item { )
Text(
"Today", LazyColumn(horizontalAlignment = Alignment.CenterHorizontally) {
style = typography.headlineSmall, item {
modifier = Modifier Text(
.fillMaxWidth() "Today",
.padding(horizontal = 16.dp) style = typography.headlineSmall,
)
Spacer(Modifier.height(16.dp))
}
item {
Row(modifier = Modifier.padding(horizontal = 16.dp)) {
Box(
modifier = Modifier modifier = Modifier
.background( .fillMaxWidth()
colorScheme.primaryContainer, .padding(16.dp)
MaterialTheme.shapes.largeIncreased )
) }
.weight(1f) item {
) { Row(modifier = Modifier.padding(horizontal = 16.dp)) {
Column(Modifier.padding(16.dp)) { Box(
Text( modifier = Modifier
"Focus", .background(
style = typography.titleMedium, colorScheme.primaryContainer,
color = colorScheme.onPrimaryContainer MaterialTheme.shapes.largeIncreased
) )
Text( .weight(1f)
if (todayStat != null) remember(todayStat) { ) {
millisecondsToHoursMinutes( Column(Modifier.padding(16.dp)) {
todayStat.focusTimeQ1 + Text(
todayStat.focusTimeQ2 + "Focus",
todayStat.focusTimeQ3 + style = typography.titleMedium,
todayStat.focusTimeQ4 color = colorScheme.onPrimaryContainer
) )
} else "0h 0m", Text(
style = typography.displaySmall, if (todayStat != null) remember(todayStat) {
fontFamily = openRundeClock, millisecondsToHoursMinutes(
color = colorScheme.onPrimaryContainer todayStat.focusTimeQ1 +
) todayStat.focusTimeQ2 +
todayStat.focusTimeQ3 +
todayStat.focusTimeQ4
)
} else "0h 0m",
style = typography.displaySmall,
fontFamily = openRundeClock,
color = colorScheme.onPrimaryContainer
)
}
} }
} Spacer(Modifier.width(8.dp))
Spacer(Modifier.width(8.dp)) Box(
Box( modifier = Modifier
modifier = Modifier .background(
.background( colorScheme.tertiaryContainer,
colorScheme.tertiaryContainer, MaterialTheme.shapes.largeIncreased
MaterialTheme.shapes.largeIncreased )
) .weight(1f)
.weight(1f) ) {
) { Column(Modifier.padding(16.dp)) {
Column(Modifier.padding(16.dp)) { Text(
Text( "Break",
"Break", style = typography.titleMedium,
style = typography.titleMedium, color = colorScheme.onTertiaryContainer
color = colorScheme.onTertiaryContainer )
) Text(
Text( if (todayStat != null) remember(todayStat) {
if (todayStat != null) remember(todayStat) { millisecondsToHoursMinutes(todayStat.breakTime)
millisecondsToHoursMinutes(todayStat.breakTime) } else "0h 0m",
} else "0h 0m", style = typography.displaySmall,
style = typography.displaySmall, fontFamily = openRundeClock,
fontFamily = openRundeClock, color = colorScheme.onTertiaryContainer
color = colorScheme.onTertiaryContainer )
) }
} }
} }
} }
} item {
item { val iconRotation by animateFloatAsState(
val iconRotation by animateFloatAsState( if (todayStatExpanded) 180f else 0f,
if (todayStatExpanded) 180f else 0f, animationSpec = motionScheme.defaultSpatialSpec()
animationSpec = motionScheme.defaultSpatialSpec() )
) Column(
Column( horizontalAlignment = Alignment.CenterHorizontally,
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()
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( FilledTonalIconToggleButton(
painterResource(R.drawable.arrow_down), checked = todayStatExpanded,
"More info", onCheckedChange = { todayStatExpanded = it },
modifier = Modifier.rotate(iconRotation) shapes = IconButtonDefaults.toggleableShapes(),
) modifier = Modifier
} .padding(horizontal = 16.dp)
AnimatedVisibility(todayStatExpanded) { .width(52.dp)
TimeColumnChart( .align(Alignment.End)
) {
Icon(
painterResource(R.drawable.arrow_down),
"More info",
modifier = Modifier.rotate(iconRotation)
)
}
ProductivityGraph(
todayStatExpanded,
todayStatModelProducer, todayStatModelProducer,
timeConverter = ::millisecondsToHoursMinutes, Modifier.padding(horizontal = 32.dp)
modifier = Modifier.padding(start = 16.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, modelProducer: CartesianChartModelProducer,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
thickness: Dp = 40.dp, 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) { val radius = with(LocalDensity.current) {
(thickness / 2).toPx() (thickness / 2).toPx()
@@ -82,20 +86,19 @@ internal fun TimeColumnChart(
) )
} }
), ),
columnCollectionSpacing = 4.dp columnCollectionSpacing = columnCollectionSpacing
), ),
startAxis = VerticalAxis.rememberStart( startAxis = VerticalAxis.rememberStart(
line = rememberLineComponent(Fill.Transparent), line = rememberLineComponent(Fill.Transparent),
tick = rememberLineComponent(Fill.Transparent), tick = rememberLineComponent(Fill.Transparent),
guideline = rememberLineComponent(Fill.Transparent), guideline = rememberLineComponent(Fill.Transparent),
valueFormatter = CartesianValueFormatter { measuringContext, value, _ -> valueFormatter = yValueFormatter
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),
valueFormatter = xValueFormatter
) )
), ),
modelProducer = modelProducer, modelProducer = modelProducer,

View File

@@ -28,15 +28,18 @@ class StatsViewModel(
private val allStatsSummary = statRepository.getLastWeekStatsSummary() private val allStatsSummary = statRepository.getLastWeekStatsSummary()
private val averageFocusTimes = statRepository.getAverageFocusTimes() private val averageFocusTimes = statRepository.getAverageFocusTimes()
val allStatsSummaryModelProducer = CartesianChartModelProducer() val lastWeekSummaryChartModelProducer = CartesianChartModelProducer()
val todayStatModelProducer = CartesianChartModelProducer() val todayStatModelProducer = CartesianChartModelProducer()
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
allStatsSummary allStatsSummary
.collect { list -> .collect { list ->
allStatsSummaryModelProducer.runTransaction { lastWeekSummaryChartModelProducer.runTransaction {
columnSeries { series(list.reversed().map { it.focusTime }) } 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() resetTimer()
var lastDate = LocalDate.parse(statRepository.getLastDate()) var lastDate = statRepository.getLastDate()
val today = LocalDate.now() val today = LocalDate.now()
// Fills dates between today and lastDate with 0s to ensure continuous history // Fills dates between today and lastDate with 0s to ensure continuous history
while (lastDate.until(today).days > 0) { lastDate?.until(today)?.days?.let {
lastDate = lastDate.plusDays(1) while (it > 0) {
statRepository.insertStat(Stat(lastDate.toString(), 0, 0, 0, 0, 0)) lastDate = lastDate?.plusDays(1)
statRepository.insertStat(Stat(lastDate!!, 0, 0, 0, 0, 0))
}
} }
delay(1500) delay(1500)