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.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) }
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user