feat(android): add Room database persistence, Hilt DI module, timer entity mapper
This commit is contained in:
parent
91b0bb6d63
commit
8c7e64fab5
@ -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
|
||||||
|
}
|
||||||
119
android/app/src/main/java/com/chronomind/app/data/TimerMapper.kt
Normal file
119
android/app/src/main/java/com/chronomind/app/data/TimerMapper.kt
Normal 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
|
||||||
|
)
|
||||||
42
android/app/src/main/java/com/chronomind/app/di/AppModule.kt
Normal file
42
android/app/src/main/java/com/chronomind/app/di/AppModule.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,9 @@ package com.chronomind.app.viewmodel
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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 com.chronomind.app.engine.*
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@ -15,7 +18,9 @@ import java.util.Date
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class TimerViewModel @Inject constructor() : ViewModel() {
|
class TimerViewModel @Inject constructor(
|
||||||
|
private val timerDao: TimerDao
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _timers = MutableStateFlow<List<CMTimer>>(emptyList())
|
private val _timers = MutableStateFlow<List<CMTimer>>(emptyList())
|
||||||
val timers: StateFlow<List<CMTimer>> = _timers
|
val timers: StateFlow<List<CMTimer>> = _timers
|
||||||
@ -28,24 +33,50 @@ class TimerViewModel @Inject constructor() : ViewModel() {
|
|||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
loadFromDatabase()
|
||||||
startTicking()
|
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
|
// MARK: - CRUD
|
||||||
|
|
||||||
fun addCountdown(label: String, durationSeconds: Double) {
|
fun addCountdown(label: String, durationSeconds: Double) {
|
||||||
val timer = createCountdown(label, durationSeconds)
|
val timer = createCountdown(label, durationSeconds)
|
||||||
_timers.value = _timers.value + timer
|
_timers.value = _timers.value + timer
|
||||||
|
persist(timer)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addAlarm(label: String, targetTime: Date, urgency: UrgencyLevel = UrgencyLevel.STANDARD) {
|
fun addAlarm(label: String, targetTime: Date, urgency: UrgencyLevel = UrgencyLevel.STANDARD) {
|
||||||
val timer = createAlarm(label, targetTime, urgency)
|
val timer = createAlarm(label, targetTime, urgency)
|
||||||
_timers.value = _timers.value + timer
|
_timers.value = _timers.value + timer
|
||||||
|
persist(timer)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addPomodoro(label: String = "Focus Session", config: PomodoroConfig = PomodoroConfig()) {
|
fun addPomodoro(label: String = "Focus Session", config: PomodoroConfig = PomodoroConfig()) {
|
||||||
val timer = createPomodoro(label, config)
|
val timer = createPomodoro(label, config)
|
||||||
_timers.value = _timers.value + timer
|
_timers.value = _timers.value + timer
|
||||||
|
persist(timer)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause(id: String) = updateTimer(id) { pauseTimer(it) }
|
fun pause(id: String) = updateTimer(id) { pauseTimer(it) }
|
||||||
@ -56,6 +87,7 @@ class TimerViewModel @Inject constructor() : ViewModel() {
|
|||||||
|
|
||||||
fun removeTimer(id: String) {
|
fun removeTimer(id: String) {
|
||||||
_timers.value = _timers.value.filter { it.id != id }
|
_timers.value = _timers.value.filter { it.id != id }
|
||||||
|
viewModelScope.launch { timerDao.deleteById(id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Tick
|
// MARK: - Tick
|
||||||
@ -92,6 +124,7 @@ class TimerViewModel @Inject constructor() : ViewModel() {
|
|||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
_timers.value = updated
|
_timers.value = updated
|
||||||
|
persistAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,5 +134,6 @@ class TimerViewModel @Inject constructor() : ViewModel() {
|
|||||||
_timers.value = _timers.value.map {
|
_timers.value = _timers.value.map {
|
||||||
if (it.id == id) transform(it) else it
|
if (it.id == id) transform(it) else it
|
||||||
}
|
}
|
||||||
|
_timers.value.find { it.id == id }?.let { persist(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user