diff --git a/android/app/src/main/java/com/chronomind/app/data/TimerDatabase.kt b/android/app/src/main/java/com/chronomind/app/data/TimerDatabase.kt new file mode 100644 index 0000000..166ebc0 --- /dev/null +++ b/android/app/src/main/java/com/chronomind/app/data/TimerDatabase.kt @@ -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> + + @Query("SELECT * FROM timers WHERE state IN ('active', 'warning', 'snoozed', 'paused') ORDER BY targetTime ASC") + fun getActiveTimers(): Flow> + + @Query("SELECT * FROM timers WHERE state IN ('completed', 'dismissed') ORDER BY completedAt DESC, dismissedAt DESC") + fun getCompletedTimers(): Flow> + + @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) + + @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 +} diff --git a/android/app/src/main/java/com/chronomind/app/data/TimerMapper.kt b/android/app/src/main/java/com/chronomind/app/data/TimerMapper.kt new file mode 100644 index 0000000..b411a83 --- /dev/null +++ b/android/app/src/main/java/com/chronomind/app/data/TimerMapper.kt @@ -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 = try { + json.decodeFromString(warningsJson) + } catch (_: Exception) { + emptyList() + } + + val intervals: List = 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 +) diff --git a/android/app/src/main/java/com/chronomind/app/di/AppModule.kt b/android/app/src/main/java/com/chronomind/app/di/AppModule.kt new file mode 100644 index 0000000..b39e264 --- /dev/null +++ b/android/app/src/main/java/com/chronomind/app/di/AppModule.kt @@ -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() + } + } +} diff --git a/android/app/src/main/java/com/chronomind/app/viewmodel/TimerViewModel.kt b/android/app/src/main/java/com/chronomind/app/viewmodel/TimerViewModel.kt index 1a0d997..06137db 100644 --- a/android/app/src/main/java/com/chronomind/app/viewmodel/TimerViewModel.kt +++ b/android/app/src/main/java/com/chronomind/app/viewmodel/TimerViewModel.kt @@ -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>(emptyList()) val timers: StateFlow> = _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) } } }