From b884885e407dac587ce2be1528ae9048bf3ae13a Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 10 Jun 2026 13:34:41 +0200 Subject: [PATCH] Data layer: Room entities, DAOs, database with exported schema Milestone 2. Medication / DoseTime / DoseLog per the brief's model. DoseLog.doseTimeId is SET_NULL (schedule edits must not erase adherence history) while medId CASCADEs; DoseLog.amount is snapshotted at logging time so later dose changes don't rewrite the record. Inventory decrement is a single SQL UPDATE clamped at 0 to stay race-free under escalating notifications. Enums stored as TEXT; schema exported to app/schemas as the migration baseline. Co-Authored-By: Claude Fable 5 --- app/build.gradle.kts | 9 + .../1.json | 297 ++++++++++++++++++ .../java/no/naiv/meddetsamme/data/DoseLog.kt | 57 ++++ .../no/naiv/meddetsamme/data/DoseLogDao.kt | 46 +++ .../java/no/naiv/meddetsamme/data/DoseTime.kt | 44 +++ .../no/naiv/meddetsamme/data/DoseTimeDao.kt | 37 +++ .../no/naiv/meddetsamme/data/MedDatabase.kt | 38 +++ .../no/naiv/meddetsamme/data/Medication.kt | 42 +++ .../no/naiv/meddetsamme/data/MedicationDao.kt | 43 +++ gradle/libs.versions.toml | 3 +- 10 files changed, 614 insertions(+), 2 deletions(-) create mode 100644 app/schemas/no.naiv.meddetsamme.data.MedDatabase/1.json create mode 100644 app/src/main/java/no/naiv/meddetsamme/data/DoseLog.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/data/DoseTime.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/data/DoseTimeDao.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/data/Medication.kt create mode 100644 app/src/main/java/no/naiv/meddetsamme/data/MedicationDao.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bd4f8ea..0d9f1c1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,12 @@ plugins { // AGP 9 has built-in Kotlin (org.jetbrains.kotlin.android is gone); only the // compiler sub-plugins are applied separately, pinned to AGP's bundled KGP version. alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) +} + +ksp { + // Commit exported schemas: migration baseline for a DB that is a medical record. + arg("room.schemaLocation", "$projectDir/schemas") } android { @@ -48,5 +54,8 @@ dependencies { implementation(libs.compose.ui.tooling.preview) debugImplementation(libs.compose.ui.tooling) + implementation(libs.room.runtime) + ksp(libs.room.compiler) + testImplementation(libs.junit) } diff --git a/app/schemas/no.naiv.meddetsamme.data.MedDatabase/1.json b/app/schemas/no.naiv.meddetsamme.data.MedDatabase/1.json new file mode 100644 index 0000000..ce5cf6c --- /dev/null +++ b/app/schemas/no.naiv.meddetsamme.data.MedDatabase/1.json @@ -0,0 +1,297 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "d4412a7aa766343df6b08ea6aa76a83a", + "entities": [ + { + "tableName": "medication", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `strength` TEXT NOT NULL, `unit` TEXT NOT NULL, `form` TEXT NOT NULL, `withFood` INTEGER NOT NULL, `notes` TEXT NOT NULL, `inventoryUnits` REAL NOT NULL, `packageSize` REAL, `lowStockLeadDays` INTEGER NOT NULL, `rxExpiryEpochDay` INTEGER, `refillsRemaining` INTEGER, `atcCode` TEXT, `active` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "strength", + "columnName": "strength", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unit", + "columnName": "unit", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "form", + "columnName": "form", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "withFood", + "columnName": "withFood", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inventoryUnits", + "columnName": "inventoryUnits", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "packageSize", + "columnName": "packageSize", + "affinity": "REAL" + }, + { + "fieldPath": "lowStockLeadDays", + "columnName": "lowStockLeadDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rxExpiryEpochDay", + "columnName": "rxExpiryEpochDay", + "affinity": "INTEGER" + }, + { + "fieldPath": "refillsRemaining", + "columnName": "refillsRemaining", + "affinity": "INTEGER" + }, + { + "fieldPath": "atcCode", + "columnName": "atcCode", + "affinity": "TEXT" + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "dose_time", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `medId` INTEGER NOT NULL, `minuteOfDay` INTEGER NOT NULL, `amount` REAL NOT NULL, `daysOfWeekMask` INTEGER NOT NULL, `intervalDays` INTEGER NOT NULL, `anchorEpochDay` INTEGER, FOREIGN KEY(`medId`) REFERENCES `medication`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "medId", + "columnName": "medId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minuteOfDay", + "columnName": "minuteOfDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "daysOfWeekMask", + "columnName": "daysOfWeekMask", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "intervalDays", + "columnName": "intervalDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "anchorEpochDay", + "columnName": "anchorEpochDay", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_dose_time_medId", + "unique": false, + "columnNames": [ + "medId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dose_time_medId` ON `${TABLE_NAME}` (`medId`)" + } + ], + "foreignKeys": [ + { + "table": "medication", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "medId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "dose_log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `medId` INTEGER NOT NULL, `doseTimeId` INTEGER, `scheduledAtMillis` INTEGER NOT NULL, `amount` REAL NOT NULL, `status` TEXT NOT NULL, `actionedAtMillis` INTEGER, FOREIGN KEY(`medId`) REFERENCES `medication`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`doseTimeId`) REFERENCES `dose_time`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "medId", + "columnName": "medId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "doseTimeId", + "columnName": "doseTimeId", + "affinity": "INTEGER" + }, + { + "fieldPath": "scheduledAtMillis", + "columnName": "scheduledAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionedAtMillis", + "columnName": "actionedAtMillis", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_dose_log_medId", + "unique": false, + "columnNames": [ + "medId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_medId` ON `${TABLE_NAME}` (`medId`)" + }, + { + "name": "index_dose_log_doseTimeId", + "unique": false, + "columnNames": [ + "doseTimeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_doseTimeId` ON `${TABLE_NAME}` (`doseTimeId`)" + }, + { + "name": "index_dose_log_doseTimeId_scheduledAtMillis", + "unique": false, + "columnNames": [ + "doseTimeId", + "scheduledAtMillis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_doseTimeId_scheduledAtMillis` ON `${TABLE_NAME}` (`doseTimeId`, `scheduledAtMillis`)" + }, + { + "name": "index_dose_log_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "medication", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "medId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "dose_time", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "doseTimeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd4412a7aa766343df6b08ea6aa76a83a')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/no/naiv/meddetsamme/data/DoseLog.kt b/app/src/main/java/no/naiv/meddetsamme/data/DoseLog.kt new file mode 100644 index 0000000..30c33b9 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/data/DoseLog.kt @@ -0,0 +1,57 @@ +package no.naiv.meddetsamme.data + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +enum class DoseStatus { PENDING, TAKEN, SKIPPED, SNOOZED } + +/** + * One scheduled occurrence of a dose and what happened to it. Created PENDING + * when the reminder fires (the escalation anchor), resolved by user action. + * + * SNOOZED is a *resting* state between nags — the alarm layer treats it like + * PENDING (keeps nagging after the snooze interval) but the UI can distinguish + * "I saw it and deferred" from "never reacted". + * + * [doseTimeId] is nullable and SET_NULL on delete: editing a schedule away must + * not erase adherence history. [medId] CASCADE instead — deleting a med (vs. + * deactivating) is an explicit "forget everything" action. + */ +@Entity( + tableName = "dose_log", + foreignKeys = [ + ForeignKey( + entity = Medication::class, + parentColumns = ["id"], + childColumns = ["medId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = DoseTime::class, + parentColumns = ["id"], + childColumns = ["doseTimeId"], + onDelete = ForeignKey.SET_NULL, + ), + ], + indices = [ + Index("medId"), + Index("doseTimeId"), + // The reminder layer's hot path: "is there already a log for this occurrence?" + Index(value = ["doseTimeId", "scheduledAtMillis"]), + Index("status"), + ], +) +data class DoseLog( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val medId: Long, + val doseTimeId: Long?, + /** The occurrence this log is for (epoch millis of the scheduled local time). */ + val scheduledAtMillis: Long, + /** Dose size at the time of logging — schedule edits must not rewrite history. */ + val amount: Double, + val status: DoseStatus = DoseStatus.PENDING, + /** When the user last acted on it (Taken/Skipped/Snoozed), null while untouched. */ + val actionedAtMillis: Long? = null, +) diff --git a/app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt b/app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt new file mode 100644 index 0000000..33522b0 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt @@ -0,0 +1,46 @@ +package no.naiv.meddetsamme.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface DoseLogDao { + + @Query("SELECT * FROM dose_log WHERE id = :id") + suspend fun getById(id: Long): DoseLog? + + /** + * The escalation anchor: at most one log per (doseTimeId, scheduledAtMillis) + * occurrence — the alarm receiver looks it up before creating a duplicate. + */ + @Query("SELECT * FROM dose_log WHERE doseTimeId = :doseTimeId AND scheduledAtMillis = :scheduledAtMillis") + suspend fun getForOccurrence(doseTimeId: Long, scheduledAtMillis: Long): DoseLog? + + /** Unresolved doses (PENDING or SNOOZED) — what's still nagging. */ + @Query("SELECT * FROM dose_log WHERE status IN ('PENDING', 'SNOOZED') ORDER BY scheduledAtMillis") + suspend fun getUnresolved(): List + + @Query("SELECT * FROM dose_log WHERE scheduledAtMillis >= :fromMillis AND scheduledAtMillis < :toMillis ORDER BY scheduledAtMillis") + fun observeRange(fromMillis: Long, toMillis: Long): Flow> + + @Query("SELECT * FROM dose_log WHERE scheduledAtMillis >= :fromMillis AND scheduledAtMillis < :toMillis ORDER BY scheduledAtMillis") + suspend fun getRange(fromMillis: Long, toMillis: Long): List + + /** + * Consumption ground truth for days-of-supply: sum of actually TAKEN amounts + * per med since [fromMillis]. Skipped/snoozed doses consume nothing. + */ + @Query( + """SELECT COALESCE(SUM(amount), 0) FROM dose_log + WHERE medId = :medId AND status = 'TAKEN' AND scheduledAtMillis >= :fromMillis""", + ) + suspend fun takenAmountSince(medId: Long, fromMillis: Long): Double + + @Insert + suspend fun insert(log: DoseLog): Long + + @Query("UPDATE dose_log SET status = :status, actionedAtMillis = :actionedAtMillis WHERE id = :id") + suspend fun setStatus(id: Long, status: DoseStatus, actionedAtMillis: Long?) +} diff --git a/app/src/main/java/no/naiv/meddetsamme/data/DoseTime.kt b/app/src/main/java/no/naiv/meddetsamme/data/DoseTime.kt new file mode 100644 index 0000000..18168e0 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/data/DoseTime.kt @@ -0,0 +1,44 @@ +package no.naiv.meddetsamme.data + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * One scheduled time-of-day for a medication. Multiple rows per med give + * different times on different days (e.g. weekend row + weekday row). + * + * Schedule semantics (enforced by the pure ScheduleEngine, not the DB): + * - [intervalDays] > 1 → dose every N days counted from [anchorEpochDay]; + * [daysOfWeekMask] is ignored. + * - otherwise → weekly pattern from [daysOfWeekMask], bit 0 = Sunday … bit 6 = + * Saturday (matches java.time DayOfWeek.SUNDAY rotated; engine owns the mapping). + * 0x7F = daily. Cyclic/taper schedules extend from the same two fields. + */ +@Entity( + tableName = "dose_time", + foreignKeys = [ + ForeignKey( + entity = Medication::class, + parentColumns = ["id"], + childColumns = ["medId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("medId")], +) +data class DoseTime( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val medId: Long, + /** Minutes after local midnight, 0..1439. 24h clock everywhere. */ + val minuteOfDay: Int, + /** Dose size in the medication's [Medication.unit]s. */ + val amount: Double, + /** Bit 0 = Sunday … bit 6 = Saturday. Used when [intervalDays] <= 1. */ + val daysOfWeekMask: Int = 0x7F, + /** 0 or 1 = use the weekly mask; N > 1 = every N days from [anchorEpochDay]. */ + val intervalDays: Int = 0, + /** Day 0 of an every-N-days cycle (LocalDate.toEpochDay). Required if intervalDays > 1. */ + val anchorEpochDay: Long? = null, +) diff --git a/app/src/main/java/no/naiv/meddetsamme/data/DoseTimeDao.kt b/app/src/main/java/no/naiv/meddetsamme/data/DoseTimeDao.kt new file mode 100644 index 0000000..2b63cc6 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/data/DoseTimeDao.kt @@ -0,0 +1,37 @@ +package no.naiv.meddetsamme.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +@Dao +interface DoseTimeDao { + + @Query("SELECT * FROM dose_time WHERE medId = :medId ORDER BY minuteOfDay") + suspend fun getForMed(medId: Long): List + + @Query("SELECT * FROM dose_time WHERE medId = :medId ORDER BY minuteOfDay") + fun observeForMed(medId: Long): Flow> + + /** Everything the alarm layer needs to (re)arm: all times for active meds. */ + @Query( + """SELECT dose_time.* FROM dose_time + JOIN medication ON medication.id = dose_time.medId + WHERE medication.active = 1""", + ) + suspend fun getAllForActiveMeds(): List + + @Query("SELECT * FROM dose_time WHERE id = :id") + suspend fun getById(id: Long): DoseTime? + + @Insert + suspend fun insert(doseTime: DoseTime): Long + + @Update + suspend fun update(doseTime: DoseTime) + + @Query("DELETE FROM dose_time WHERE id = :id") + suspend fun delete(id: Long) +} diff --git a/app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt b/app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt new file mode 100644 index 0000000..ce8f377 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt @@ -0,0 +1,38 @@ +package no.naiv.meddetsamme.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +/** + * exportSchema=true with schemas/ committed: this DB holds a medical record, + * so every future schema change ships as a tested Migration against the + * exported baseline — never destructive fallback. + * + * Enums are stored as TEXT (Room's built-in enum converter); all other columns + * are primitives, so no TypeConverters are needed yet. + */ +@Database( + entities = [Medication::class, DoseTime::class, DoseLog::class], + version = 1, + exportSchema = true, +) +abstract class MedDatabase : RoomDatabase() { + abstract fun medicationDao(): MedicationDao + abstract fun doseTimeDao(): DoseTimeDao + abstract fun doseLogDao(): DoseLogDao + + companion object { + @Volatile private var instance: MedDatabase? = null + + fun get(context: Context): MedDatabase = + instance ?: synchronized(this) { + instance ?: Room.databaseBuilder( + context.applicationContext, + MedDatabase::class.java, + "med-det-samme.db", + ).build().also { instance = it } + } + } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/data/Medication.kt b/app/src/main/java/no/naiv/meddetsamme/data/Medication.kt new file mode 100644 index 0000000..f8d29a9 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/data/Medication.kt @@ -0,0 +1,42 @@ +package no.naiv.meddetsamme.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +enum class MedForm { TABLET, CAPSULE, LIQUID, INJECTION, DROPS, SPRAY, OTHER } + +/** + * One medication. Inventory and prescription state live here because both are + * single-valued per med; consumption history lives in [DoseLog]. + * + * Deliberate split (brief decision #9): [inventoryUnits] tracks physical stock, + * while [rxExpiryEpochDay]/[refillsRemaining] track the e-resept — you can have a + * full box and a dead prescription, or vice versa. Refill warnings are *derived* + * (stock ÷ consumption rate), never a manually scheduled reminder. + */ +@Entity(tableName = "medication") +data class Medication( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + /** Per-unit strength as displayed, e.g. "500 mg" or "50 µg/dose". */ + val strength: String, + /** What [DoseTime.amount] and [inventoryUnits] count: "tablett", "ml", "doser"… */ + val unit: String, + val form: MedForm, + val withFood: Boolean = false, + val notes: String = "", + /** Current physical stock, in [unit]s. Decremented when a dose is TAKEN. */ + val inventoryUnits: Double = 0.0, + /** Units per package, for "add one package" restock and the doctor summary. */ + val packageSize: Double? = null, + /** Warn when days-of-supply drops below this. */ + val lowStockLeadDays: Int = 7, + /** e-resept expiry (LocalDate.toEpochDay), independent of stock. */ + val rxExpiryEpochDay: Long? = null, + /** Reiterutleveringer left on the prescription. */ + val refillsRemaining: Int? = null, + /** ATC code from FEST autocomplete, for the doctor summary. */ + val atcCode: String? = null, + /** Soft delete: history must survive a med being discontinued. */ + val active: Boolean = true, +) diff --git a/app/src/main/java/no/naiv/meddetsamme/data/MedicationDao.kt b/app/src/main/java/no/naiv/meddetsamme/data/MedicationDao.kt new file mode 100644 index 0000000..aa712d7 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/data/MedicationDao.kt @@ -0,0 +1,43 @@ +package no.naiv.meddetsamme.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +@Dao +interface MedicationDao { + + @Query("SELECT * FROM medication WHERE active = 1 ORDER BY name") + fun observeActive(): Flow> + + @Query("SELECT * FROM medication WHERE active = 1 ORDER BY name") + suspend fun getActive(): List + + @Query("SELECT * FROM medication WHERE id = :id") + suspend fun getById(id: Long): Medication? + + @Insert + suspend fun insert(medication: Medication): Long + + @Update + suspend fun update(medication: Medication) + + /** + * Atomic stock decrement on "Taken". Clamped at 0: phantom negative stock + * would poison the derived days-of-supply maths. Done in SQL, not + * read-modify-write, so a nag-notification race can't lose an update. + */ + @Query("UPDATE medication SET inventoryUnits = MAX(0, inventoryUnits - :amount) WHERE id = :id") + suspend fun decrementInventory(id: Long, amount: Double) + + @Query("UPDATE medication SET inventoryUnits = inventoryUnits + :amount WHERE id = :id") + suspend fun addInventory(id: Long, amount: Double) + + @Query("UPDATE medication SET active = 0 WHERE id = :id") + suspend fun deactivate(id: Long) + + @Query("DELETE FROM medication WHERE id = :id") + suspend fun delete(id: Long) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1aba489..bf22e0f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,8 +26,7 @@ compose-material3 = { group = "androidx.compose.material3", name = "material3" } compose-ui = { group = "androidx.compose.ui", name = "ui" } compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } -room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } # ktx merged into runtime in 2.7+ room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } work-runtime = { group = "androidx.work", name = "work-runtime", version.ref = "work" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }