feat(data): the Room database, its four tables and their DAOs
alarms, timers, world_clocks and stopwatch_laps, with the schema exported to app/schemas and committed so migrations can be tested from v1 onwards. The DAOs return Flows for reads and keep every multi-step write inside a @Transaction, so a read-modify-write cannot lose a concurrent one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
package de.jeanlucmakiola.clockula.data.alarms
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface AlarmDao {
|
||||
@Query("SELECT * FROM alarms ORDER BY hour, minute, id")
|
||||
fun observeAll(): Flow<List<AlarmEntity>>
|
||||
|
||||
@Query("SELECT * FROM alarms WHERE id = :id LIMIT 1")
|
||||
fun observeById(id: Long): Flow<AlarmEntity?>
|
||||
|
||||
@Query("SELECT * FROM alarms WHERE id = :id LIMIT 1")
|
||||
suspend fun findById(id: Long): AlarmEntity?
|
||||
|
||||
@Query("SELECT * FROM alarms WHERE enabled = 1 ORDER BY hour, minute, id")
|
||||
suspend fun enabled(): List<AlarmEntity>
|
||||
|
||||
@Insert
|
||||
suspend fun insert(alarm: AlarmEntity): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(alarm: AlarmEntity)
|
||||
|
||||
@Query("DELETE FROM alarms WHERE id = :id")
|
||||
suspend fun deleteById(id: Long)
|
||||
|
||||
@Query("UPDATE alarms SET enabled = :enabled, updated_at = :updatedAt WHERE id = :id")
|
||||
suspend fun setEnabled(id: Long, enabled: Boolean, updatedAt: Long)
|
||||
|
||||
@Query("UPDATE alarms SET skip_next_occurrence = :skip, updated_at = :updatedAt WHERE id = :id")
|
||||
suspend fun setSkipNextOccurrence(id: Long, skip: Boolean, updatedAt: Long)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.jeanlucmakiola.clockula.data.alarms
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* The `alarms` row. Primitives only — every enum is stored as [Enum.name], every
|
||||
* instant as epoch millis and the repeat set as a 7-bit mask, so the whole
|
||||
* entity/domain translation lives in [AlarmMapper] where it can be tested.
|
||||
*/
|
||||
@Entity(tableName = "alarms", indices = [Index("enabled")])
|
||||
data class AlarmEntity(
|
||||
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L,
|
||||
@ColumnInfo(name = "hour") val hour: Int,
|
||||
@ColumnInfo(name = "minute") val minute: Int,
|
||||
@ColumnInfo(name = "label") val label: String,
|
||||
@ColumnInfo(name = "enabled") val enabled: Boolean,
|
||||
@ColumnInfo(name = "repeat_days") val repeatDays: Int,
|
||||
@ColumnInfo(name = "skip_next_occurrence") val skipNextOccurrence: Boolean,
|
||||
@ColumnInfo(name = "ringtone_uri") val ringtoneUri: String?,
|
||||
@ColumnInfo(name = "vibrate") val vibrate: Boolean?,
|
||||
@ColumnInfo(name = "snooze_minutes") val snoozeMinutes: Int?,
|
||||
@ColumnInfo(name = "snooze_limit") val snoozeLimit: Int?,
|
||||
@ColumnInfo(name = "volume_ramp_seconds") val volumeRampSeconds: Int?,
|
||||
@ColumnInfo(name = "dismiss_challenge") val dismissChallenge: String?,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.jeanlucmakiola.clockula.data.db
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmDao
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmEntity
|
||||
import de.jeanlucmakiola.clockula.data.stopwatch.LapDao
|
||||
import de.jeanlucmakiola.clockula.data.stopwatch.LapEntity
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerDao
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerEntity
|
||||
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockDao
|
||||
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity
|
||||
|
||||
/**
|
||||
* Clockula's own storage. Version 1; the schema is exported to `app/schemas/`
|
||||
* and committed, so every later migration is reviewable and testable.
|
||||
*
|
||||
* No `TypeConverters` — every column is a primitive and the whole translation
|
||||
* lives in the mappers — and no destructive-migration fallback: a schema change
|
||||
* that arrived without a migration would otherwise eat the user's alarms.
|
||||
*/
|
||||
@Database(
|
||||
entities = [
|
||||
AlarmEntity::class,
|
||||
TimerEntity::class,
|
||||
WorldClockEntity::class,
|
||||
LapEntity::class,
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class ClockulaDatabase : RoomDatabase() {
|
||||
abstract fun alarmDao(): AlarmDao
|
||||
abstract fun timerDao(): TimerDao
|
||||
abstract fun worldClockDao(): WorldClockDao
|
||||
abstract fun lapDao(): LapDao
|
||||
|
||||
companion object {
|
||||
const val NAME: String = "clockula.db"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.jeanlucmakiola.clockula.data.stopwatch
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
abstract class LapDao {
|
||||
@Query("SELECT * FROM stopwatch_laps ORDER BY lap_index")
|
||||
abstract fun observeAll(): Flow<List<LapEntity>>
|
||||
|
||||
@Query("SELECT * FROM stopwatch_laps ORDER BY lap_index DESC LIMIT 1")
|
||||
abstract suspend fun latest(): LapEntity?
|
||||
|
||||
@Insert
|
||||
abstract suspend fun insert(lap: LapEntity): Long
|
||||
|
||||
@Query("DELETE FROM stopwatch_laps")
|
||||
abstract suspend fun deleteAll()
|
||||
|
||||
/**
|
||||
* Appends a lap at [cumulativeMillis], deriving its 1-based index and its
|
||||
* split from the previous lap. Transactional so two concurrent laps cannot
|
||||
* collide on the unique lap_index.
|
||||
*/
|
||||
@Transaction
|
||||
open suspend fun appendLap(cumulativeMillis: Long): LapEntity {
|
||||
val previous = latest()
|
||||
val entity = LapEntity(
|
||||
lapIndex = (previous?.lapIndex ?: 0) + 1,
|
||||
splitMillis = (cumulativeMillis - (previous?.cumulativeMillis ?: 0L)).coerceAtLeast(0L),
|
||||
cumulativeMillis = cumulativeMillis.coerceAtLeast(0L),
|
||||
)
|
||||
val id = insert(entity)
|
||||
return entity.copy(id = id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.jeanlucmakiola.clockula.data.stopwatch
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/** The `stopwatch_laps` row; `lap_index` carries a unique index. */
|
||||
@Entity(tableName = "stopwatch_laps", indices = [Index(value = ["lap_index"], unique = true)])
|
||||
data class LapEntity(
|
||||
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L,
|
||||
@ColumnInfo(name = "lap_index") val lapIndex: Int,
|
||||
@ColumnInfo(name = "split_millis") val splitMillis: Long,
|
||||
@ColumnInfo(name = "cumulative_millis") val cumulativeMillis: Long,
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.jeanlucmakiola.clockula.data.timers
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
abstract class TimerDao {
|
||||
@Query("SELECT * FROM timers ORDER BY sort_order, id")
|
||||
abstract fun observeAll(): Flow<List<TimerEntity>>
|
||||
|
||||
@Query("SELECT * FROM timers WHERE id = :id LIMIT 1")
|
||||
abstract fun observeById(id: Long): Flow<TimerEntity?>
|
||||
|
||||
@Query("SELECT * FROM timers WHERE id = :id LIMIT 1")
|
||||
abstract suspend fun findById(id: Long): TimerEntity?
|
||||
|
||||
@Query("SELECT MAX(sort_order) FROM timers")
|
||||
abstract suspend fun maxSortOrder(): Int?
|
||||
|
||||
@Insert
|
||||
abstract suspend fun insert(timer: TimerEntity): Long
|
||||
|
||||
@Update
|
||||
abstract suspend fun update(timer: TimerEntity)
|
||||
|
||||
@Query("DELETE FROM timers WHERE id = :id")
|
||||
abstract suspend fun deleteById(id: Long)
|
||||
|
||||
@Query("UPDATE timers SET sort_order = :sortOrder WHERE id = :id")
|
||||
abstract suspend fun setSortOrder(id: Long, sortOrder: Int)
|
||||
|
||||
/**
|
||||
* Reads the row, applies [transform] and writes the result back **inside one
|
||||
* transaction**, so two writers — the ringing service marking a timer
|
||||
* expired and the user adding a minute to it — cannot both read the same row
|
||||
* and have one of the two writes disappear. A missing row, or a [transform]
|
||||
* that returns null, writes nothing.
|
||||
*
|
||||
* [transform] must be pure and must not touch the database: it runs while
|
||||
* the transaction is open.
|
||||
*/
|
||||
@Transaction
|
||||
open suspend fun updateWithin(id: Long, transform: (TimerEntity) -> TimerEntity?) {
|
||||
val current = findById(id) ?: return
|
||||
val updated = transform(current) ?: return
|
||||
update(updated)
|
||||
}
|
||||
|
||||
/** Rewrites sort_order to the position of each id. Unknown ids match no row and are ignored. */
|
||||
@Transaction
|
||||
open suspend fun reorder(idsInOrder: List<Long>) {
|
||||
idsInOrder.forEachIndexed { index, id -> setSortOrder(id, index) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.jeanlucmakiola.clockula.data.timers
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/** The `timers` row. Primitives only; all three anchors are millis. */
|
||||
@Entity(tableName = "timers", indices = [Index("sort_order")])
|
||||
data class TimerEntity(
|
||||
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L,
|
||||
@ColumnInfo(name = "label") val label: String,
|
||||
@ColumnInfo(name = "duration_millis") val durationMillis: Long,
|
||||
@ColumnInfo(name = "state") val state: String,
|
||||
@ColumnInfo(name = "remaining_millis") val remainingMillis: Long,
|
||||
@ColumnInfo(name = "started_at_elapsed_realtime_millis") val startedAtElapsedRealtimeMillis: Long?,
|
||||
@ColumnInfo(name = "ends_at_elapsed_realtime_millis") val endsAtElapsedRealtimeMillis: Long?,
|
||||
@ColumnInfo(name = "ends_at_wall_clock_millis") val endsAtWallClockMillis: Long?,
|
||||
@ColumnInfo(name = "ringtone_uri") val ringtoneUri: String?,
|
||||
@ColumnInfo(name = "sort_order") val sortOrder: Int,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
package de.jeanlucmakiola.clockula.data.worldclocks
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
abstract class WorldClockDao {
|
||||
@Query("SELECT * FROM world_clocks ORDER BY sort_order, id")
|
||||
abstract fun observeAll(): Flow<List<WorldClockEntity>>
|
||||
|
||||
@Query("SELECT * FROM world_clocks WHERE zone_id = :zoneId LIMIT 1")
|
||||
abstract suspend fun findByZoneId(zoneId: String): WorldClockEntity?
|
||||
|
||||
@Query("SELECT MAX(sort_order) FROM world_clocks")
|
||||
abstract suspend fun maxSortOrder(): Int?
|
||||
|
||||
/** Returns -1 on a `zone_id` conflict. */
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun insert(clock: WorldClockEntity): Long
|
||||
|
||||
@Query("UPDATE world_clocks SET label = :label WHERE id = :id")
|
||||
abstract suspend fun setLabel(id: Long, label: String?)
|
||||
|
||||
@Query("UPDATE world_clocks SET sort_order = :sortOrder WHERE id = :id")
|
||||
abstract suspend fun setSortOrder(id: Long, sortOrder: Int)
|
||||
|
||||
@Query("DELETE FROM world_clocks WHERE id = :id")
|
||||
abstract suspend fun deleteById(id: Long)
|
||||
|
||||
/**
|
||||
* The idempotent add, as one transaction: look the zone up, and only insert
|
||||
* it — at the next sort order — when it really is absent. Doing the lookup,
|
||||
* the sort-order read and the insert in one transaction means no other
|
||||
* writer can slip a row in, or delete the row that won, between them, so
|
||||
* the id this returns is always a row that exists and never the insert
|
||||
* sentinel.
|
||||
*/
|
||||
@Transaction
|
||||
open suspend fun addIfAbsent(zoneId: String, label: String?): Long {
|
||||
findByZoneId(zoneId)?.let { return it.id }
|
||||
val id = insert(
|
||||
WorldClockEntity(zoneId = zoneId, label = label, sortOrder = (maxSortOrder() ?: -1) + 1),
|
||||
)
|
||||
if (id != -1L) return id
|
||||
// Belt and braces: -1 means the unique index rejected the insert, which
|
||||
// the lookup above has just ruled out for this transaction.
|
||||
return findByZoneId(zoneId)?.id
|
||||
?: error("world clock for $zoneId was neither inserted nor found")
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun reorder(idsInOrder: List<Long>) {
|
||||
idsInOrder.forEachIndexed { index, id -> setSortOrder(id, index) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.jeanlucmakiola.clockula.data.worldclocks
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/** The `world_clocks` row; `zone_id` carries a unique index. */
|
||||
@Entity(
|
||||
tableName = "world_clocks",
|
||||
indices = [Index(value = ["zone_id"], unique = true), Index("sort_order")],
|
||||
)
|
||||
data class WorldClockEntity(
|
||||
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L,
|
||||
@ColumnInfo(name = "zone_id") val zoneId: String,
|
||||
@ColumnInfo(name = "label") val label: String?,
|
||||
@ColumnInfo(name = "sort_order") val sortOrder: Int,
|
||||
)
|
||||
Reference in New Issue
Block a user