feat(android): add Room database persistence, Hilt DI module, timer entity mapper

This commit is contained in:
saravanakumardb1 2026-02-27 23:14:49 -08:00
parent 91b0bb6d63
commit 8c7e64fab5
4 changed files with 286 additions and 1 deletions

View File

@ -0,0 +1,90 @@
package com.chronomind.app.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import java.util.Date
// ── Room Entity ───────────────────────────────────────────────
@Entity(tableName = "timers")
data class TimerEntity(
@PrimaryKey val id: String,
val label: String,
val description: String?,
val type: String, // alarm, countdown, pomodoro
val state: String, // idle, active, warning, firing, paused, snoozed, completed, dismissed
val urgency: String, // critical, important, standard, gentle, passive
val duration: Double,
val targetTime: Long, // epoch millis
val createdAt: Long,
val startedAt: Long?,
val pausedAt: Long?,
val firedAt: Long?,
val dismissedAt: Long?,
val completedAt: Long?,
val snoozedUntil: Long?,
val snoozeCount: Int,
val elapsedBeforePause: Double,
val category: String?,
val cascadePreset: String,
val cascadeIntervalsJson: String, // JSON array of ints
val warningsJson: String, // JSON array of warning objects
val pomodoroWorkMinutes: Int?,
val pomodoroBreakMinutes: Int?,
val pomodoroLongBreakMinutes: Int?,
val pomodoroRounds: Int?,
val pomodoroCurrentRound: Int?,
val pomodoroIsBreak: Boolean?,
val pomodoroIsLongBreak: Boolean?,
val pomodoroCompletedRounds: Int?,
val isCalendarSync: Boolean,
val calendarEventId: String?
)
// ── DAO ───────────────────────────────────────────────────────
@Dao
interface TimerDao {
@Query("SELECT * FROM timers ORDER BY targetTime ASC")
fun getAllTimers(): Flow<List<TimerEntity>>
@Query("SELECT * FROM timers WHERE state IN ('active', 'warning', 'snoozed', 'paused') ORDER BY targetTime ASC")
fun getActiveTimers(): Flow<List<TimerEntity>>
@Query("SELECT * FROM timers WHERE state IN ('completed', 'dismissed') ORDER BY completedAt DESC, dismissedAt DESC")
fun getCompletedTimers(): Flow<List<TimerEntity>>
@Query("SELECT * FROM timers WHERE id = :id")
suspend fun getTimer(id: String): TimerEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(timer: TimerEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(timers: List<TimerEntity>)
@Delete
suspend fun delete(timer: TimerEntity)
@Query("DELETE FROM timers WHERE id = :id")
suspend fun deleteById(id: String)
@Query("DELETE FROM timers")
suspend fun deleteAll()
@Query("SELECT COUNT(*) FROM timers WHERE state = 'completed'")
suspend fun completedCount(): Int
@Query("SELECT COUNT(*) FROM timers WHERE state = 'dismissed'")
suspend fun dismissedCount(): Int
@Query("SELECT SUM(snoozeCount) FROM timers")
suspend fun totalSnoozes(): Int
}
// ── Database ──────────────────────────────────────────────────
@Database(entities = [TimerEntity::class], version = 1, exportSchema = false)
abstract class TimerDatabase : RoomDatabase() {
abstract fun timerDao(): TimerDao
}

View File

@ -0,0 +1,119 @@
package com.chronomind.app.data
import com.chronomind.app.engine.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.Date
// ── CMTimer ↔ TimerEntity Mapping ─────────────────────────────
private val json = Json { ignoreUnknownKeys = true }
fun CMTimer.toEntity(): TimerEntity = TimerEntity(
id = id,
label = label,
description = description,
type = type.value,
state = state.value,
urgency = urgency.value,
duration = duration,
targetTime = targetTime.time,
createdAt = createdAt.time,
startedAt = startedAt?.time,
pausedAt = pausedAt?.time,
firedAt = firedAt?.time,
dismissedAt = dismissedAt?.time,
completedAt = completedAt?.time,
snoozedUntil = snoozedUntil?.time,
snoozeCount = snoozeCount,
elapsedBeforePause = elapsedBeforePause,
category = category,
cascadePreset = cascade.preset.value,
cascadeIntervalsJson = json.encodeToString(cascade.intervals),
warningsJson = json.encodeToString(warnings.map { WarningDto(it.id, it.minutesBefore, it.fired, it.firedAt?.time, it.scheduledTime.time) }),
pomodoroWorkMinutes = pomodoroConfig?.workMinutes,
pomodoroBreakMinutes = pomodoroConfig?.breakMinutes,
pomodoroLongBreakMinutes = pomodoroConfig?.longBreakMinutes,
pomodoroRounds = pomodoroConfig?.rounds,
pomodoroCurrentRound = pomodoroState?.currentRound,
pomodoroIsBreak = pomodoroState?.isBreak,
pomodoroIsLongBreak = pomodoroState?.isLongBreak,
pomodoroCompletedRounds = pomodoroState?.completedRounds,
isCalendarSync = isCalendarSync,
calendarEventId = calendarEventId
)
fun TimerEntity.toTimer(): CMTimer {
val warningDtos: List<WarningDto> = try {
json.decodeFromString(warningsJson)
} catch (_: Exception) {
emptyList()
}
val intervals: List<Int> = try {
json.decodeFromString(cascadeIntervalsJson)
} catch (_: Exception) {
emptyList()
}
return CMTimer(
id = id,
label = label,
description = description,
type = CMTimerType.entries.first { it.value == type },
state = CMTimerState.entries.first { it.value == state },
urgency = UrgencyLevel.entries.first { it.value == urgency },
duration = duration,
targetTime = Date(targetTime),
createdAt = Date(createdAt),
startedAt = startedAt?.let { Date(it) },
pausedAt = pausedAt?.let { Date(it) },
firedAt = firedAt?.let { Date(it) },
dismissedAt = dismissedAt?.let { Date(it) },
completedAt = completedAt?.let { Date(it) },
snoozedUntil = snoozedUntil?.let { Date(it) },
snoozeCount = snoozeCount,
elapsedBeforePause = elapsedBeforePause,
category = category,
cascade = CascadeConfig(
preset = CascadePreset.entries.first { it.value == cascadePreset },
intervals = intervals
),
warnings = warningDtos.map {
CascadeWarning(
id = it.id,
minutesBefore = it.minutesBefore,
fired = it.fired,
firedAt = it.firedAt?.let { ts -> Date(ts) },
scheduledTime = Date(it.scheduledTime)
)
}.toMutableList(),
pomodoroConfig = pomodoroWorkMinutes?.let {
PomodoroConfig(
workMinutes = it,
breakMinutes = pomodoroBreakMinutes ?: 5,
longBreakMinutes = pomodoroLongBreakMinutes ?: 15,
rounds = pomodoroRounds ?: 4
)
},
pomodoroState = pomodoroCurrentRound?.let {
PomodoroState(
currentRound = it,
isBreak = pomodoroIsBreak ?: false,
isLongBreak = pomodoroIsLongBreak ?: false,
completedRounds = pomodoroCompletedRounds ?: 0
)
},
isCalendarSync = isCalendarSync,
calendarEventId = calendarEventId
)
}
@kotlinx.serialization.Serializable
data class WarningDto(
val id: String,
val minutesBefore: Int,
val fired: Boolean,
val firedAt: Long?,
val scheduledTime: Long
)

View File

@ -0,0 +1,42 @@
package com.chronomind.app.di
import android.content.Context
import androidx.room.Room
import com.chronomind.app.data.TimerDao
import com.chronomind.app.data.TimerDatabase
import com.chronomind.app.notifications.TimerNotificationManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideTimerDatabase(@ApplicationContext context: Context): TimerDatabase {
return Room.databaseBuilder(
context,
TimerDatabase::class.java,
"chronomind_timers"
).fallbackToDestructiveMigration().build()
}
@Provides
@Singleton
fun provideTimerDao(database: TimerDatabase): TimerDao {
return database.timerDao()
}
@Provides
@Singleton
fun provideTimerNotificationManager(@ApplicationContext context: Context): TimerNotificationManager {
return TimerNotificationManager(context).also {
it.createNotificationChannels()
}
}
}

View File

@ -2,6 +2,9 @@ package com.chronomind.app.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.chronomind.app.data.TimerDao
import com.chronomind.app.data.toEntity
import com.chronomind.app.data.toTimer
import com.chronomind.app.engine.*
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
@ -15,7 +18,9 @@ import java.util.Date
import javax.inject.Inject
@HiltViewModel
class TimerViewModel @Inject constructor() : ViewModel() {
class TimerViewModel @Inject constructor(
private val timerDao: TimerDao
) : ViewModel() {
private val _timers = MutableStateFlow<List<CMTimer>>(emptyList())
val timers: StateFlow<List<CMTimer>> = _timers
@ -28,24 +33,50 @@ class TimerViewModel @Inject constructor() : ViewModel() {
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
init {
loadFromDatabase()
startTicking()
}
// MARK: - Database
private fun loadFromDatabase() {
viewModelScope.launch {
timerDao.getAllTimers().collect { entities ->
_timers.value = entities.map { it.toTimer() }
}
}
}
private fun persist(timer: CMTimer) {
viewModelScope.launch {
timerDao.upsert(timer.toEntity())
}
}
private fun persistAll() {
viewModelScope.launch {
timerDao.upsertAll(_timers.value.map { it.toEntity() })
}
}
// MARK: - CRUD
fun addCountdown(label: String, durationSeconds: Double) {
val timer = createCountdown(label, durationSeconds)
_timers.value = _timers.value + timer
persist(timer)
}
fun addAlarm(label: String, targetTime: Date, urgency: UrgencyLevel = UrgencyLevel.STANDARD) {
val timer = createAlarm(label, targetTime, urgency)
_timers.value = _timers.value + timer
persist(timer)
}
fun addPomodoro(label: String = "Focus Session", config: PomodoroConfig = PomodoroConfig()) {
val timer = createPomodoro(label, config)
_timers.value = _timers.value + timer
persist(timer)
}
fun pause(id: String) = updateTimer(id) { pauseTimer(it) }
@ -56,6 +87,7 @@ class TimerViewModel @Inject constructor() : ViewModel() {
fun removeTimer(id: String) {
_timers.value = _timers.value.filter { it.id != id }
viewModelScope.launch { timerDao.deleteById(id) }
}
// MARK: - Tick
@ -92,6 +124,7 @@ class TimerViewModel @Inject constructor() : ViewModel() {
if (changed) {
_timers.value = updated
persistAll()
}
}
@ -101,5 +134,6 @@ class TimerViewModel @Inject constructor() : ViewModel() {
_timers.value = _timers.value.map {
if (it.id == id) transform(it) else it
}
_timers.value.find { it.id == id }?.let { persist(it) }
}
}