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 <noreply@anthropic.com>
This commit is contained in:
parent
7256c49112
commit
b884885e40
10 changed files with 614 additions and 2 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
297
app/schemas/no.naiv.meddetsamme.data.MedDatabase/1.json
Normal file
297
app/schemas/no.naiv.meddetsamme.data.MedDatabase/1.json
Normal file
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
57
app/src/main/java/no/naiv/meddetsamme/data/DoseLog.kt
Normal file
57
app/src/main/java/no/naiv/meddetsamme/data/DoseLog.kt
Normal file
|
|
@ -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,
|
||||
)
|
||||
46
app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt
Normal file
46
app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt
Normal file
|
|
@ -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<DoseLog>
|
||||
|
||||
@Query("SELECT * FROM dose_log WHERE scheduledAtMillis >= :fromMillis AND scheduledAtMillis < :toMillis ORDER BY scheduledAtMillis")
|
||||
fun observeRange(fromMillis: Long, toMillis: Long): Flow<List<DoseLog>>
|
||||
|
||||
@Query("SELECT * FROM dose_log WHERE scheduledAtMillis >= :fromMillis AND scheduledAtMillis < :toMillis ORDER BY scheduledAtMillis")
|
||||
suspend fun getRange(fromMillis: Long, toMillis: Long): List<DoseLog>
|
||||
|
||||
/**
|
||||
* 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?)
|
||||
}
|
||||
44
app/src/main/java/no/naiv/meddetsamme/data/DoseTime.kt
Normal file
44
app/src/main/java/no/naiv/meddetsamme/data/DoseTime.kt
Normal file
|
|
@ -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,
|
||||
)
|
||||
37
app/src/main/java/no/naiv/meddetsamme/data/DoseTimeDao.kt
Normal file
37
app/src/main/java/no/naiv/meddetsamme/data/DoseTimeDao.kt
Normal file
|
|
@ -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<DoseTime>
|
||||
|
||||
@Query("SELECT * FROM dose_time WHERE medId = :medId ORDER BY minuteOfDay")
|
||||
fun observeForMed(medId: Long): Flow<List<DoseTime>>
|
||||
|
||||
/** 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<DoseTime>
|
||||
|
||||
@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)
|
||||
}
|
||||
38
app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt
Normal file
38
app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt
Normal file
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
42
app/src/main/java/no/naiv/meddetsamme/data/Medication.kt
Normal file
42
app/src/main/java/no/naiv/meddetsamme/data/Medication.kt
Normal file
|
|
@ -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,
|
||||
)
|
||||
43
app/src/main/java/no/naiv/meddetsamme/data/MedicationDao.kt
Normal file
43
app/src/main/java/no/naiv/meddetsamme/data/MedicationDao.kt
Normal file
|
|
@ -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<List<Medication>>
|
||||
|
||||
@Query("SELECT * FROM medication WHERE active = 1 ORDER BY name")
|
||||
suspend fun getActive(): List<Medication>
|
||||
|
||||
@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)
|
||||
}
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue