diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 528dfce..da51173 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,6 +33,7 @@ android { targetSdk = 35 versionCode = 1 versionName = "0.1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } signingConfigs { @@ -65,6 +66,9 @@ android { buildFeatures { compose = true } + + // MigrationTestHelper reads the exported schema JSONs as androidTest assets. + sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas") } dependencies { @@ -88,4 +92,7 @@ dependencies { implementation(libs.kage) testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.room.testing) } diff --git a/app/schemas/no.naiv.meddetsamme.data.MedDatabase/2.json b/app/schemas/no.naiv.meddetsamme.data.MedDatabase/2.json new file mode 100644 index 0000000..11fdf2d --- /dev/null +++ b/app/schemas/no.naiv.meddetsamme.data.MedDatabase/2.json @@ -0,0 +1,351 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "9db9677c3732d2974727b2ffb1be788b", + "entities": [ + { + "tableName": "inventory_item", + "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, `atcCode` TEXT, `packageSize` REAL, `stockUnits` REAL NOT NULL, `lowStockLeadDays` INTEGER NOT NULL, `rxExpiryEpochDay` INTEGER, `refillsRemaining` INTEGER)", + "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": "atcCode", + "columnName": "atcCode", + "affinity": "TEXT" + }, + { + "fieldPath": "packageSize", + "columnName": "packageSize", + "affinity": "REAL" + }, + { + "fieldPath": "stockUnits", + "columnName": "stockUnits", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "lowStockLeadDays", + "columnName": "lowStockLeadDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rxExpiryEpochDay", + "columnName": "rxExpiryEpochDay", + "affinity": "INTEGER" + }, + { + "fieldPath": "refillsRemaining", + "columnName": "refillsRemaining", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "medication", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `itemId` INTEGER NOT NULL, `withFood` INTEGER NOT NULL, `notes` TEXT NOT NULL, `active` INTEGER NOT NULL, FOREIGN KEY(`itemId`) REFERENCES `inventory_item`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "withFood", + "columnName": "withFood", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_medication_itemId", + "unique": false, + "columnNames": [ + "itemId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_medication_itemId` ON `${TABLE_NAME}` (`itemId`)" + } + ], + "foreignKeys": [ + { + "table": "inventory_item", + "onDelete": "RESTRICT", + "onUpdate": "NO ACTION", + "columns": [ + "itemId" + ], + "referencedColumns": [ + "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, `nagCount` INTEGER NOT NULL, 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" + }, + { + "fieldPath": "nagCount", + "columnName": "nagCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "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, '9db9677c3732d2974727b2ffb1be788b')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/no/naiv/meddetsamme/data/MigrationTest.kt b/app/src/androidTest/java/no/naiv/meddetsamme/data/MigrationTest.kt new file mode 100644 index 0000000..eba6497 --- /dev/null +++ b/app/src/androidTest/java/no/naiv/meddetsamme/data/MigrationTest.kt @@ -0,0 +1,83 @@ +package no.naiv.meddetsamme.data + +import androidx.room.testing.MigrationTestHelper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Proves MIGRATION_1_2 against the exported v1 schema: the helper creates a + * REAL v1 database, we fill it like the app would have, migrate, and Room + * validates the result against 2.json. This runs before the migration ever + * touches the phone's medical record. + */ +@RunWith(AndroidJUnit4::class) +class MigrationTest { + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + MedDatabase::class.java, + ) + + @Test + fun migrate1To2_splitsItemsAndKeepsHistory() { + helper.createDatabase(TEST_DB, 1).use { db -> + db.execSQL( + "INSERT INTO medication (id, name, strength, unit, form, withFood, notes, " + + "inventoryUnits, packageSize, lowStockLeadDays, rxExpiryEpochDay, " + + "refillsRemaining, atcCode, active) VALUES " + + "(7, 'Levaxin', '50 µg', 'tablett', 'TABLET', 0, 'fastende', " + + "42.5, 100.0, 10, 20800, 2, 'H03AA01', 1)", + ) + db.execSQL( + "INSERT INTO dose_time (id, medId, minuteOfDay, amount, daysOfWeekMask, " + + "intervalDays, anchorEpochDay) VALUES (3, 7, 450, 1.5, 127, 0, NULL)", + ) + db.execSQL( + "INSERT INTO dose_log (id, medId, doseTimeId, scheduledAtMillis, amount, " + + "status, actionedAtMillis, nagCount) VALUES " + + "(11, 7, 3, 1780000000000, 1.5, 'TAKEN', 1780000123000, 2)", + ) + } + + // Migrates AND validates the result against the exported 2.json schema. + helper.runMigrationsAndValidate(TEST_DB, 2, true, MedDatabase.MIGRATION_1_2).use { db -> + db.query("SELECT id, name, stockUnits, rxExpiryEpochDay FROM inventory_item").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(7, c.getLong(0)) // item id mirrors the old med id + assertEquals("Levaxin", c.getString(1)) + assertEquals(42.5, c.getDouble(2), 0.0) + assertEquals(20800, c.getLong(3)) + assertEquals(1, c.count) + } + db.query("SELECT id, itemId, withFood, notes, active FROM medication").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(7, c.getLong(0)) + assertEquals(7, c.getLong(1)) + assertEquals("fastende", c.getString(3)) + assertEquals(1, c.getInt(4)) + } + // The medical record must come through untouched. + db.query("SELECT medId, doseTimeId, status, nagCount FROM dose_log").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(7, c.getLong(0)) + assertEquals(3, c.getLong(1)) + assertEquals("TAKEN", c.getString(2)) + assertEquals(2, c.getInt(3)) + } + db.query("SELECT COUNT(*) FROM dose_time").use { c -> + c.moveToFirst() + assertEquals(1, c.getInt(0)) + } + } + } + + private companion object { + const val TEST_DB = "migration-test.db" + } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/AlarmScheduler.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/AlarmScheduler.kt index 32bbfdf..f9cfda5 100644 --- a/app/src/main/java/no/naiv/meddetsamme/alarm/AlarmScheduler.kt +++ b/app/src/main/java/no/naiv/meddetsamme/alarm/AlarmScheduler.kt @@ -65,7 +65,7 @@ class AlarmScheduler(private val context: Context) { val db = MedDatabase.get(context) val doseTime = db.doseTimeDao().getById(doseTimeId) ?: return val med = db.medicationDao().getById(doseTime.medId) - if (med == null || !med.active) { + if (med == null || !med.med.active) { cancelDoseTime(doseTimeId) return } diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/DoseAlarmReceiver.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/DoseAlarmReceiver.kt index 91b6d76..74d1428 100644 --- a/app/src/main/java/no/naiv/meddetsamme/alarm/DoseAlarmReceiver.kt +++ b/app/src/main/java/no/naiv/meddetsamme/alarm/DoseAlarmReceiver.kt @@ -60,14 +60,14 @@ class DoseAlarmReceiver : BroadcastReceiver() { val doseTime = db.doseTimeDao().getById(doseTimeId) ?: return val med = db.medicationDao().getById(doseTime.medId) ?: return - if (!med.active) return + if (!med.med.active) return val existing = db.doseLogDao().getForOccurrence(doseTimeId, scheduledAt) val log = when { existing == null -> { val id = db.doseLogDao().insert( DoseLog( - medId = med.id, + medId = med.med.id, doseTimeId = doseTimeId, scheduledAtMillis = scheduledAt, amount = doseTime.amount, diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/SupplyCheckReceiver.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/SupplyCheckReceiver.kt index b003dff..0c4a39e 100644 --- a/app/src/main/java/no/naiv/meddetsamme/alarm/SupplyCheckReceiver.kt +++ b/app/src/main/java/no/naiv/meddetsamme/alarm/SupplyCheckReceiver.kt @@ -35,16 +35,20 @@ class SupplyCheckReceiver : BroadcastReceiver() { val today = LocalDate.now() val lines = mutableListOf() - for (med in db.medicationDao().getActive()) { - val doseTimes = db.doseTimeDao().getForMed(med.id) - val rate = ScheduleEngine.dailyConsumption(doseTimes) - if (ScheduleEngine.needsRefill(med.inventoryUnits, rate, med.lowStockLeadDays)) { - val days = ScheduleEngine.daysOfSupply(med.inventoryUnits, rate) - lines += "${med.name}: lager for ${days?.toInt() ?: 0} dager" + // Per inventory item: consumption is the SUM across all active regimens + // drawing from it — one shared box drains faster than either alone. + val byItem = db.medicationDao().getActive().groupBy { it.item.id } + for ((_, meds) in byItem) { + val item = meds.first().item + var rate = 0.0 + for (med in meds) rate += ScheduleEngine.dailyConsumption(db.doseTimeDao().getForMed(med.med.id)) + if (ScheduleEngine.needsRefill(item.stockUnits, rate, item.lowStockLeadDays)) { + val days = ScheduleEngine.daysOfSupply(item.stockUnits, rate) + lines += "${item.name}: lager for ${days?.toInt() ?: 0} dager" } - if (ScheduleEngine.needsRenewal(med.rxExpiryEpochDay, today)) { - val expired = med.rxExpiryEpochDay!! < today.toEpochDay() - lines += if (expired) "${med.name}: resept utløpt" else "${med.name}: resept må fornyes" + if (ScheduleEngine.needsRenewal(item.rxExpiryEpochDay, today)) { + val expired = item.rxExpiryEpochDay!! < today.toEpochDay() + lines += if (expired) "${item.name}: resept utløpt" else "${item.name}: resept må fornyes" } } diff --git a/app/src/main/java/no/naiv/meddetsamme/backup/BackupManager.kt b/app/src/main/java/no/naiv/meddetsamme/backup/BackupManager.kt index 0c15ba5..c4737ee 100644 --- a/app/src/main/java/no/naiv/meddetsamme/backup/BackupManager.kt +++ b/app/src/main/java/no/naiv/meddetsamme/backup/BackupManager.kt @@ -18,7 +18,12 @@ class BackupManager(private val context: Context) { /** Plaintext JSON export (caller decides whether/how to encrypt). */ suspend fun exportJson(): String { val dao = db.backupDao() - return BackupSerializer.export(dao.allMedications(), dao.allDoseTimes(), dao.allDoseLogs()) + return BackupSerializer.export( + dao.allInventoryItems(), + dao.allMedications(), + dao.allDoseTimes(), + dao.allDoseLogs(), + ) } /** Export encrypted with a *typed* passphrase (never stored — manual path). */ diff --git a/app/src/main/java/no/naiv/meddetsamme/backup/BackupSerializer.kt b/app/src/main/java/no/naiv/meddetsamme/backup/BackupSerializer.kt index 2ffe34b..5192bd6 100644 --- a/app/src/main/java/no/naiv/meddetsamme/backup/BackupSerializer.kt +++ b/app/src/main/java/no/naiv/meddetsamme/backup/BackupSerializer.kt @@ -5,6 +5,7 @@ import kotlinx.serialization.json.Json import no.naiv.meddetsamme.data.DoseLog import no.naiv.meddetsamme.data.DoseStatus import no.naiv.meddetsamme.data.DoseTime +import no.naiv.meddetsamme.data.InventoryItem import no.naiv.meddetsamme.data.MedDatabase import no.naiv.meddetsamme.data.MedForm import no.naiv.meddetsamme.data.Medication @@ -13,9 +14,9 @@ import no.naiv.meddetsamme.data.Medication * The ONE serializer for export, import and auto-backup (brief decision #6). * * Explicit DTOs, not the Room entities: the backup format is a contract with - * future versions of this app (and with `age -d` + jq on a laptop), so a Room - * schema change must be a *deliberate* format change — bump [BackupFile.VERSION] - * and keep reading old versions — never an accident. + * future versions of this app (and with `age -d` + jq on a laptop). Version 2 + * mirrors the schema-v2 inventory split; version-1 files (med fields inline) + * are still read and converted on restore — old backups must never go dark. */ object BackupSerializer { @@ -24,11 +25,17 @@ object BackupSerializer { ignoreUnknownKeys = true // older app reading newer minor additions } - fun export(meds: List, doseTimes: List, logs: List): String = + fun export( + items: List, + meds: List, + doseTimes: List, + logs: List, + ): String = json.encodeToString( BackupFile( version = BackupFile.VERSION, exportedAtMillis = System.currentTimeMillis(), + inventoryItems = items.map { it.toDto() }, medications = meds.map { it.toDto() }, doseTimes = doseTimes.map { it.toDto() }, doseLogs = logs.map { it.toDto() }, @@ -51,43 +58,94 @@ object BackupSerializer { * worse than a failed import. Caller re-arms alarms afterwards. */ suspend fun restore(db: MedDatabase, file: BackupFile) { + val (items, meds) = when { + file.version >= 2 -> file.inventoryItems.map { it.toEntity() } to + file.medications.map { it.toEntity() } + else -> convertV1(file.medications) + } db.restoreAll( - file.medications.map { it.toEntity() }, + items, + meds, file.doseTimes.map { it.toEntity() }, file.doseLogs.map { it.toEntity() }, ) } + + /** v1 files carried item fields inline on the medication — same split the DB migration does. */ + private fun convertV1(meds: List): Pair, List> { + val items = meds.map { m -> + InventoryItem( + id = m.id, + name = m.name ?: "", + strength = m.strength ?: "", + unit = m.unit ?: "dose", + form = MedForm.valueOf(m.form ?: MedForm.OTHER.name), + atcCode = m.atcCode, + packageSize = m.packageSize, + stockUnits = m.inventoryUnits ?: 0.0, + lowStockLeadDays = m.lowStockLeadDays ?: 7, + rxExpiryEpochDay = m.rxExpiryEpochDay, + refillsRemaining = m.refillsRemaining, + ) + } + val regimens = meds.map { m -> + Medication(id = m.id, itemId = m.id, withFood = m.withFood, notes = m.notes, active = m.active) + } + return items to regimens + } } @Serializable data class BackupFile( val version: Int, val exportedAtMillis: Long, + val inventoryItems: List = emptyList(), // absent in v1 val medications: List, val doseTimes: List, val doseLogs: List, ) { companion object { - const val VERSION = 1 + const val VERSION = 2 } } @Serializable -data class MedicationDto( +data class InventoryItemDto( val id: Long, val name: String, - val strength: String, + val strength: String = "", val unit: String, val form: String, - val withFood: Boolean, - val notes: String, - val inventoryUnits: Double, + val atcCode: String? = null, val packageSize: Double? = null, - val lowStockLeadDays: Int, + val stockUnits: Double = 0.0, + val lowStockLeadDays: Int = 7, + val rxExpiryEpochDay: Long? = null, + val refillsRemaining: Int? = null, +) + +/** + * v2 writes id/itemId/withFood/notes/active. The remaining nullable fields + * exist only so v1 files (item data inline) still parse — never written. + */ +@Serializable +data class MedicationDto( + val id: Long, + val itemId: Long? = null, + val withFood: Boolean = false, + val notes: String = "", + val active: Boolean = true, + // v1 legacy fields: + val name: String? = null, + val strength: String? = null, + val unit: String? = null, + val form: String? = null, + val inventoryUnits: Double? = null, + val packageSize: Double? = null, + val lowStockLeadDays: Int? = null, val rxExpiryEpochDay: Long? = null, val refillsRemaining: Int? = null, val atcCode: String? = null, - val active: Boolean, ) @Serializable @@ -115,14 +173,24 @@ data class DoseLogDto( // Enum round-trip via name: stable as long as enum constants are never renamed // (they are part of the backup contract too). -private fun Medication.toDto() = MedicationDto( - id, name, strength, unit, form.name, withFood, notes, inventoryUnits, - packageSize, lowStockLeadDays, rxExpiryEpochDay, refillsRemaining, atcCode, active, +private fun InventoryItem.toDto() = InventoryItemDto( + id, name, strength, unit, form.name, atcCode, packageSize, stockUnits, + lowStockLeadDays, rxExpiryEpochDay, refillsRemaining, ) +private fun InventoryItemDto.toEntity() = InventoryItem( + id, name, strength, unit, MedForm.valueOf(form), atcCode, packageSize, stockUnits, + lowStockLeadDays, rxExpiryEpochDay, refillsRemaining, +) + +private fun Medication.toDto() = MedicationDto(id = id, itemId = itemId, withFood = withFood, notes = notes, active = active) + private fun MedicationDto.toEntity() = Medication( - id, name, strength, unit, MedForm.valueOf(form), withFood, notes, inventoryUnits, - packageSize, lowStockLeadDays, rxExpiryEpochDay, refillsRemaining, atcCode, active, + id = id, + itemId = requireNotNull(itemId) { "medication $id mangler itemId i v2-fil" }, + withFood = withFood, + notes = notes, + active = active, ) private fun DoseTime.toDto() = DoseTimeDto(id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay) diff --git a/app/src/main/java/no/naiv/meddetsamme/data/BackupDao.kt b/app/src/main/java/no/naiv/meddetsamme/data/BackupDao.kt index fb74e64..5a54b3a 100644 --- a/app/src/main/java/no/naiv/meddetsamme/data/BackupDao.kt +++ b/app/src/main/java/no/naiv/meddetsamme/data/BackupDao.kt @@ -12,6 +12,9 @@ import androidx.room.Transaction @Dao interface BackupDao { + @Query("SELECT * FROM inventory_item") + suspend fun allInventoryItems(): List + @Query("SELECT * FROM medication") suspend fun allMedications(): List @@ -24,6 +27,12 @@ interface BackupDao { @Query("DELETE FROM medication") suspend fun clearMedications() + @Query("DELETE FROM inventory_item") + suspend fun clearInventoryItems() + + @Insert + suspend fun insertInventoryItems(items: List) + @Insert suspend fun insertMedications(meds: List) @@ -34,16 +43,19 @@ interface BackupDao { suspend fun insertDoseLogs(logs: List) /** - * Replace-all restore. Deleting medication CASCADEs to dose_time and - * dose_log, so one delete clears everything; insertion order respects FKs. + * Replace-all restore. medication cleared first (its itemId is RESTRICT), + * which CASCADEs to dose_time/dose_log; then items; then insert in FK order. */ @Transaction suspend fun replaceAll( + items: List, meds: List, doseTimes: List, logs: List, ) { clearMedications() + clearInventoryItems() + insertInventoryItems(items) insertMedications(meds) insertDoseTimes(doseTimes) insertDoseLogs(logs) diff --git a/app/src/main/java/no/naiv/meddetsamme/data/InventoryDao.kt b/app/src/main/java/no/naiv/meddetsamme/data/InventoryDao.kt new file mode 100644 index 0000000..c8c8306 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/data/InventoryDao.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 InventoryDao { + + @Query("SELECT * FROM inventory_item ORDER BY name") + fun observeAll(): Flow> + + @Query("SELECT * FROM inventory_item ORDER BY name") + suspend fun getAll(): List + + @Query("SELECT * FROM inventory_item WHERE id = :id") + suspend fun getById(id: Long): InventoryItem? + + @Insert + suspend fun insert(item: InventoryItem): Long + + @Update + suspend fun update(item: InventoryItem) + + /** + * Atomic, clamped at 0 (phantom negative stock would poison days-of-supply). + * SQL, not read-modify-write, so racing notification taps can't lose updates. + */ + @Query("UPDATE inventory_item SET stockUnits = MAX(0, stockUnits - :amount) WHERE id = :id") + suspend fun decrementStock(id: Long, amount: Double) + + @Query("UPDATE inventory_item SET stockUnits = stockUnits + :amount WHERE id = :id") + suspend fun addStock(id: Long, amount: Double) + + /** Regimens referencing this item — the FK is RESTRICT, so check before delete. */ + @Query("SELECT COUNT(*) FROM medication WHERE itemId = :id") + suspend fun referencingMedCount(id: Long): Int + + @Query("DELETE FROM inventory_item WHERE id = :id") + suspend fun delete(id: Long) +} diff --git a/app/src/main/java/no/naiv/meddetsamme/data/InventoryItem.kt b/app/src/main/java/no/naiv/meddetsamme/data/InventoryItem.kt new file mode 100644 index 0000000..431fd50 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/data/InventoryItem.kt @@ -0,0 +1,36 @@ +package no.naiv.meddetsamme.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * A physical preparation in stock — what's actually in the medicine cabinet. + * Split out of Medication (schema v2) so stock, package and prescription state + * live independently of any dosing regimen: a paused regimen keeps its stock, + * and the same item could back several regimens. + * + * Decision #9 still holds: [stockUnits] is physical inventory; + * [rxExpiryEpochDay]/[refillsRemaining] track the e-resept separately. + */ +@Entity(tableName = "inventory_item") +data class InventoryItem( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + /** Per-unit strength as displayed, e.g. "500 mg". */ + val strength: String, + /** What doses and stock count: "tablett", "ml", "doser"… */ + val unit: String, + val form: MedForm, + /** ATC code from FEST autocomplete. */ + val atcCode: String? = null, + /** Units per package, for "add one package" restock. */ + val packageSize: Double? = null, + /** Current physical stock, in [unit]s. Decremented when a dose is TAKEN. */ + val stockUnits: Double = 0.0, + /** 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, +) diff --git a/app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt b/app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt index adcff52..78725a6 100644 --- a/app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt +++ b/app/src/main/java/no/naiv/meddetsamme/data/MedDatabase.kt @@ -4,42 +4,87 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase /** * 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. + * so every 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, + entities = [InventoryItem::class, Medication::class, DoseTime::class, DoseLog::class], + version = 2, exportSchema = true, ) abstract class MedDatabase : RoomDatabase() { + abstract fun inventoryDao(): InventoryDao abstract fun medicationDao(): MedicationDao abstract fun doseTimeDao(): DoseTimeDao abstract fun doseLogDao(): DoseLogDao abstract fun backupDao(): BackupDao suspend fun restoreAll( + items: List, meds: List, doseTimes: List, logs: List, - ) = backupDao().replaceAll(meds, doseTimes, logs) + ) = backupDao().replaceAll(items, meds, doseTimes, logs) companion object { @Volatile private var instance: MedDatabase? = null + /** + * v1 → v2: split InventoryItem out of medication. Each v1 medication + * becomes one item (same id, so FK wiring is trivial) plus one slim + * regimen row. CREATE statements copied from the exported 2.json — + * Room validates the migrated schema verbatim against it. + */ + val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `inventory_item` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`name` TEXT NOT NULL, `strength` TEXT NOT NULL, " + + "`unit` TEXT NOT NULL, `form` TEXT NOT NULL, `atcCode` TEXT, " + + "`packageSize` REAL, `stockUnits` REAL NOT NULL, " + + "`lowStockLeadDays` INTEGER NOT NULL, " + + "`rxExpiryEpochDay` INTEGER, `refillsRemaining` INTEGER)", + ) + db.execSQL( + "INSERT INTO inventory_item (id, name, strength, unit, form, atcCode, " + + "packageSize, stockUnits, lowStockLeadDays, rxExpiryEpochDay, refillsRemaining) " + + "SELECT id, name, strength, unit, form, atcCode, packageSize, " + + "inventoryUnits, lowStockLeadDays, rxExpiryEpochDay, refillsRemaining FROM medication", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `medication_new` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`itemId` INTEGER NOT NULL, `withFood` INTEGER NOT NULL, " + + "`notes` TEXT NOT NULL, `active` INTEGER NOT NULL, " + + "FOREIGN KEY(`itemId`) REFERENCES `inventory_item`(`id`) " + + "ON UPDATE NO ACTION ON DELETE RESTRICT)", + ) + db.execSQL( + "INSERT INTO medication_new (id, itemId, withFood, notes, active) " + + "SELECT id, id, withFood, notes, active FROM medication", + ) + db.execSQL("DROP TABLE medication") + db.execSQL("ALTER TABLE medication_new RENAME TO medication") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_medication_itemId` ON `medication` (`itemId`)") + } + } + fun get(context: Context): MedDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, MedDatabase::class.java, "med-det-samme.db", - ).build().also { instance = it } + ).addMigrations(MIGRATION_1_2).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 index f8d29a9..5e4d747 100644 --- a/app/src/main/java/no/naiv/meddetsamme/data/Medication.kt +++ b/app/src/main/java/no/naiv/meddetsamme/data/Medication.kt @@ -1,42 +1,48 @@ package no.naiv.meddetsamme.data +import androidx.room.Embedded import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index import androidx.room.PrimaryKey +import androidx.room.Relation 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]. + * A dosing regimen: HOW an [InventoryItem] is taken. Identity, stock and + * prescription state live on the item (schema v2 split); schedule rows live + * in [DoseTime]. * - * 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. + * itemId is RESTRICT, not CASCADE: deleting a stock item must never silently + * take a regimen and its adherence history with it — delete the regimen first. */ -@Entity(tableName = "medication") +@Entity( + tableName = "medication", + foreignKeys = [ + ForeignKey( + entity = InventoryItem::class, + parentColumns = ["id"], + childColumns = ["itemId"], + onDelete = ForeignKey.RESTRICT, + ), + ], + indices = [Index("itemId")], +) 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 itemId: Long, 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, ) + +/** A regimen joined with its stock item — what almost every consumer needs. */ +data class MedWithItem( + @Embedded val med: Medication, + @Relation(parentColumn = "itemId", entityColumn = "id") + val item: InventoryItem, +) { + val displayName: String get() = "${item.name} ${item.strength}".trim() +} diff --git a/app/src/main/java/no/naiv/meddetsamme/data/MedicationDao.kt b/app/src/main/java/no/naiv/meddetsamme/data/MedicationDao.kt index aa712d7..2b14697 100644 --- a/app/src/main/java/no/naiv/meddetsamme/data/MedicationDao.kt +++ b/app/src/main/java/no/naiv/meddetsamme/data/MedicationDao.kt @@ -3,20 +3,35 @@ package no.naiv.meddetsamme.data 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 interface MedicationDao { - @Query("SELECT * FROM medication WHERE active = 1 ORDER BY name") - fun observeActive(): Flow> + @Transaction + @Query( + """SELECT medication.* FROM medication + JOIN inventory_item ON inventory_item.id = medication.itemId + WHERE medication.active = 1 ORDER BY inventory_item.name""", + ) + fun observeActive(): Flow> - @Query("SELECT * FROM medication WHERE active = 1 ORDER BY name") - suspend fun getActive(): List + @Transaction + @Query( + """SELECT medication.* FROM medication + JOIN inventory_item ON inventory_item.id = medication.itemId + WHERE medication.active = 1 ORDER BY inventory_item.name""", + ) + suspend fun getActive(): List + + @Transaction + @Query("SELECT * FROM medication WHERE id = :id") + suspend fun getById(id: Long): MedWithItem? @Query("SELECT * FROM medication WHERE id = :id") - suspend fun getById(id: Long): Medication? + suspend fun getRawById(id: Long): Medication? @Insert suspend fun insert(medication: Medication): Long @@ -24,17 +39,6 @@ interface MedicationDao { @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) diff --git a/app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt b/app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt index cd93d5a..58f79b8 100644 --- a/app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt +++ b/app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt @@ -39,7 +39,9 @@ class DoseActions(private val context: Context) { val log = db.doseLogDao().getById(logId) ?: return if (log.status == DoseStatus.TAKEN) return // double-tap must not decrement twice db.doseLogDao().setStatus(logId, DoseStatus.TAKEN, System.currentTimeMillis()) - db.medicationDao().decrementInventory(log.medId, log.amount) + db.medicationDao().getRawById(log.medId)?.let { + db.inventoryDao().decrementStock(it.itemId, log.amount) + } scheduler.cancelEscalation(logId) Notifications.cancel(context, Notifications.doseNotificationId(logId)) } @@ -56,11 +58,13 @@ class DoseActions(private val context: Context) { Notifications.cancel(context, Notifications.doseNotificationId(logId)) } - /** Undo a mis-tap: back to PENDING, restoring inventory if it was TAKEN. */ + /** Undo a mis-tap: back to PENDING, restoring stock if it was TAKEN. */ suspend fun undo(logId: Long) { val log = db.doseLogDao().getById(logId) ?: return if (log.status == DoseStatus.TAKEN) { - db.medicationDao().addInventory(log.medId, log.amount) + db.medicationDao().getRawById(log.medId)?.let { + db.inventoryDao().addStock(it.itemId, log.amount) + } } db.doseLogDao().setStatus(logId, DoseStatus.PENDING, null) } diff --git a/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt b/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt index 2e02d76..45a5718 100644 --- a/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt +++ b/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt @@ -12,7 +12,7 @@ import no.naiv.meddetsamme.MainActivity import no.naiv.meddetsamme.R import no.naiv.meddetsamme.alarm.DoseActionReceiver import no.naiv.meddetsamme.data.DoseLog -import no.naiv.meddetsamme.data.Medication +import no.naiv.meddetsamme.data.MedWithItem import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -54,16 +54,16 @@ object Notifications { } /** - * The dose reminder card. [nagCount] > 0 marks a re-nag — same id, so it + * The dose reminder card. nagCount > 0 marks a re-nag — same id, so it * replaces the previous card but re-alerts (no setOnlyAlertOnce: re-alerting * is the escalation). */ - fun buildDoseNotification(context: Context, med: Medication, log: DoseLog): Notification { + fun buildDoseNotification(context: Context, med: MedWithItem, log: DoseLog): Notification { val scheduled = Instant.ofEpochMilli(log.scheduledAtMillis).atZone(ZoneId.systemDefault()) - val title = "${med.name} ${med.strength}".trim() + val title = med.displayName val body = buildString { - append("${formatAmount(log.amount)} ${med.unit} kl. ${timeFmt.format(scheduled)}") - if (med.withFood) append(" — tas med mat") + append("${formatAmount(log.amount)} ${med.item.unit} kl. ${timeFmt.format(scheduled)}") + if (med.med.withFood) append(" — tas med mat") if (log.nagCount > 0) append(" (påminnelse ${log.nagCount})") } diff --git a/app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt b/app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt index 88c211c..eeac265 100644 --- a/app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt +++ b/app/src/main/java/no/naiv/meddetsamme/pdf/DoctorSummaryPdf.kt @@ -91,46 +91,47 @@ class DoctorSummaryPdf(private val context: Context) { line("Generert ${dateFmt.format(today)} — Med det samme", small, 14f) for (med in meds) { - val doseTimes = db.doseTimeDao().getForMed(med.id) + val item = med.item + val doseTimes = db.doseTimeDao().getForMed(med.med.id) val rate = ScheduleEngine.dailyConsumption(doseTimes) - val supplyDays = ScheduleEngine.daysOfSupply(med.inventoryUnits, rate) + val supplyDays = ScheduleEngine.daysOfSupply(item.stockUnits, rate) val logs = db.doseLogDao().getRange(windowStart, System.currentTimeMillis()) - .filter { it.medId == med.id } + .filter { it.medId == med.med.id } val adherence = ScheduleEngine.adherencePercent(logs) newPageIfNeeded(80f) // keep a med's block from straddling pages when possible - line("${med.name} ${med.strength}".trim(), heading, 3f) + line(med.displayName, heading, 3f) val formLine = buildString { - append(formLabels[med.form] ?: med.form.name) - if (med.withFood) append(" — tas med mat") - med.atcCode?.let { append(" · ATC $it") } + append(formLabels[item.form] ?: item.form.name) + if (med.med.withFood) append(" — tas med mat") + item.atcCode?.let { append(" · ATC $it") } } line(formLine, small, 3f) for (dt in doseTimes) { line( - "${ScheduleText.amountText(dt.amount, med.unit)} kl. ${ScheduleText.describe(dt)}", + "${ScheduleText.amountText(dt.amount, item.unit)} kl. ${ScheduleText.describe(dt)}", body, 2f, ) } val supplyText = buildString { - append("Lager: ${ScheduleText.amountText(med.inventoryUnits, med.unit)}") + append("Lager: ${ScheduleText.amountText(item.stockUnits, item.unit)}") supplyDays?.let { append(" ≈ ${it.toInt()} dager") } } line(supplyText, body, 2f) - med.rxExpiryEpochDay?.let { epochDay -> + item.rxExpiryEpochDay?.let { epochDay -> val expiry = LocalDate.ofEpochDay(epochDay) - val reit = med.refillsRemaining?.let { ", $it reit igjen" } ?: "" + val reit = item.refillsRemaining?.let { ", $it reit igjen" } ?: "" line("Resept utløper ${dateFmt.format(expiry)}$reit", body, 2f) } adherence?.let { line("Etterlevelse siste 30 dager: $it %", body, 2f) } - if (med.notes.isNotBlank()) line("Merknad: ${med.notes}", small, 2f) + if (med.med.notes.isNotBlank()) line("Merknad: ${med.med.notes}", small, 2f) y += 10f } diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/InventoryScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/InventoryScreen.kt new file mode 100644 index 0000000..0080f79 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/ui/InventoryScreen.kt @@ -0,0 +1,109 @@ +package no.naiv.meddetsamme.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import no.naiv.meddetsamme.R +import no.naiv.meddetsamme.data.MedDatabase +import no.naiv.meddetsamme.domain.ScheduleText + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InventoryScreen(nav: Nav) { + val context = LocalContext.current + val db = remember { MedDatabase.get(context) } + val scope = rememberCoroutineScope() + val items by db.inventoryDao().observeAll().collectAsState(initial = emptyList()) + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Lager") }, + navigationIcon = { + IconButton(onClick = { nav.pop() }) { + Icon(painterResource(R.drawable.ic_back), contentDescription = "Tilbake") + } + }, + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { nav.push(Screen.ItemEdit(itemId = null)) }, + text = { Text("Ny vare") }, + icon = { Text("+", style = MaterialTheme.typography.titleLarge) }, + modifier = Modifier.semantics { contentDescription = "Legg til ny lagervare" }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (items.isEmpty()) { + item { + Text( + "Tomt lager. Varer opprettes her eller fra medisin-redigering.", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(vertical = 24.dp), + ) + } + } + items(items, key = { it.id }) { item -> + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { nav.push(Screen.ItemEdit(item.id)) } + .semantics(mergeDescendants = true) { + contentDescription = + "${item.name} ${item.strength}, ${ScheduleText.amountText(item.stockUnits, item.unit)} på lager. Trykk for å redigere." + }, + ) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("${item.name} ${item.strength}".trim(), style = MaterialTheme.typography.titleMedium) + Text( + ScheduleText.amountText(item.stockUnits, item.unit), + style = MaterialTheme.typography.bodyMedium, + ) + } + // One-tap restock when the package size is known. + item.packageSize?.let { size -> + TextButton(onClick = { + scope.launch { db.inventoryDao().addStock(item.id, size) } + }) { Text("+1 pakning") } + } + } + } + } + } + } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/ItemEditScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/ItemEditScreen.kt new file mode 100644 index 0000000..a45e828 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/ui/ItemEditScreen.kt @@ -0,0 +1,124 @@ +package no.naiv.meddetsamme.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import no.naiv.meddetsamme.R +import no.naiv.meddetsamme.data.MedDatabase + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ItemEditScreen(nav: Nav, itemId: Long?) { + val context = LocalContext.current + val db = remember { MedDatabase.get(context) } + val scope = rememberCoroutineScope() + val form = remember { ItemFormState() } + var confirmDelete by remember { mutableStateOf(false) } + var deleteBlocked by remember { mutableStateOf(false) } + + LaunchedEffect(itemId) { + itemId?.let { id -> db.inventoryDao().getById(id)?.let(form::loadFrom) } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (itemId == null) "Ny lagervare" else "Rediger lagervare") }, + navigationIcon = { + IconButton(onClick = { nav.pop() }) { + Icon(painterResource(R.drawable.ic_back), contentDescription = "Tilbake") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + InventoryItemForm(form) + + Button( + onClick = { + val item = form.buildItem(itemId ?: 0) ?: return@Button + scope.launch { + if (itemId == null) db.inventoryDao().insert(item) else db.inventoryDao().update(item) + nav.pop() + } + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Lagre") } + + if (itemId != null) { + TextButton( + onClick = { + scope.launch { + if (db.inventoryDao().referencingMedCount(itemId) > 0) { + deleteBlocked = true + } else { + confirmDelete = true + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Slett vare", color = MaterialTheme.colorScheme.error) } + } + } + } + + if (deleteBlocked) { + AlertDialog( + onDismissRequest = { deleteBlocked = false }, + title = { Text("Varen er i bruk") }, + text = { Text("En medisin bruker denne varen. Slett eller avslutt medisinen først.") }, + confirmButton = { TextButton(onClick = { deleteBlocked = false }) { Text("OK") } }, + ) + } + if (confirmDelete) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text("Slette ${form.name.ifBlank { "varen" }}?") }, + text = { Text("Dette kan ikke angres.") }, + confirmButton = { + TextButton(onClick = { + confirmDelete = false + scope.launch { + itemId?.let { db.inventoryDao().delete(it) } + nav.pop() + } + }) { Text("Slett", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Avbryt") } }, + ) + } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/ItemForm.kt b/app/src/main/java/no/naiv/meddetsamme/ui/ItemForm.kt new file mode 100644 index 0000000..4dd486b --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/ui/ItemForm.kt @@ -0,0 +1,284 @@ +package no.naiv.meddetsamme.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import java.time.LocalDate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import no.naiv.meddetsamme.data.InventoryItem +import no.naiv.meddetsamme.data.MedForm +import no.naiv.meddetsamme.domain.ScheduleText +import no.naiv.meddetsamme.fest.FestEntry +import no.naiv.meddetsamme.fest.FestRepository + +val FORM_LABELS = mapOf( + MedForm.TABLET to "Tablett", MedForm.CAPSULE to "Kapsel", MedForm.LIQUID to "Flytende", + MedForm.INJECTION to "Injeksjon", MedForm.DROPS to "Dråper", MedForm.SPRAY to "Spray", + MedForm.OTHER to "Annet", +) + +/** + * Enhet is what doses and stock COUNT (tablett, ml, …); Form is the + * pharmaceutical form. They coincide for tablets, which made the two fields + * look redundant — so Enhet auto-follows Form while it still holds the + * previous form's default, and stays untouched once manually overridden. + */ +val DEFAULT_UNIT = mapOf( + MedForm.TABLET to "tablett", MedForm.CAPSULE to "kapsel", MedForm.LIQUID to "ml", + MedForm.INJECTION to "ml", MedForm.DROPS to "dråper", MedForm.SPRAY to "doser", + MedForm.OTHER to "dose", +) + +/** Best-effort mapping of FEST's free-form text to our enum; user can override. */ +fun guessForm(festForm: String): MedForm = when { + "tablett" in festForm.lowercase() -> MedForm.TABLET + "kapsel" in festForm.lowercase() -> MedForm.CAPSULE + "mikst" in festForm.lowercase() || "oppløsning" in festForm.lowercase() -> MedForm.LIQUID + "inj" in festForm.lowercase() -> MedForm.INJECTION + "dråp" in festForm.lowercase() -> MedForm.DROPS + "spray" in festForm.lowercase() -> MedForm.SPRAY + else -> MedForm.OTHER +} + +fun parseAmount(s: String): Double? = s.trim().replace(',', '.').toDoubleOrNull() + +/** + * State + validation for an [InventoryItem] form — shared between the + * inventory editor and inline item creation in the med editor, so the two + * can't drift apart. + */ +class ItemFormState { + var name by mutableStateOf("") + var strength by mutableStateOf("") + var unit by mutableStateOf("tablett") + var form by mutableStateOf(MedForm.TABLET) + var stock by mutableStateOf("0") + var packageSize by mutableStateOf("") + var leadDays by mutableStateOf("7") + var rxExpiry by mutableStateOf(null) + var refills by mutableStateOf("") + var atc by mutableStateOf(null) + var error by mutableStateOf(null) + + fun loadFrom(item: InventoryItem) { + name = item.name + strength = item.strength + unit = item.unit + form = item.form + stock = ScheduleText.amountText(item.stockUnits, "").trim() + packageSize = item.packageSize?.let { ScheduleText.amountText(it, "").trim() } ?: "" + leadDays = item.lowStockLeadDays.toString() + rxExpiry = item.rxExpiryEpochDay?.let(LocalDate::ofEpochDay) + refills = item.refillsRemaining?.toString() ?: "" + atc = item.atcCode + } + + /** Validated entity, or null with [error] set. */ + fun buildItem(id: Long): InventoryItem? { + val stockUnits = parseAmount(stock) + if (name.isBlank()) { + error = "Navn mangler"; return null + } + if (stockUnits == null || stockUnits < 0) { + error = "Ugyldig lagerbeholdning"; return null + } + error = null + return InventoryItem( + id = id, + name = name.trim(), + strength = strength.trim(), + unit = unit.trim().ifBlank { "dose" }, + form = form, + atcCode = atc, + packageSize = parseAmount(packageSize), + stockUnits = stockUnits, + lowStockLeadDays = leadDays.toIntOrNull() ?: 7, + rxExpiryEpochDay = rxExpiry?.toEpochDay(), + refillsRemaining = refills.trim().toIntOrNull(), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InventoryItemForm(state: ItemFormState) { + val scope = rememberCoroutineScope() + val context = androidx.compose.ui.platform.LocalContext.current + val fest = remember { FestRepository(context) } + var suggestions by remember { mutableStateOf>(emptyList()) } + var suppressSuggestions by remember { mutableStateOf(true) } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = state.name, + onValueChange = { + state.name = it + suppressSuggestions = false + scope.launch(Dispatchers.IO) { + val hits = fest.search(it) + withContext(Dispatchers.Main) { suggestions = hits } + } + }, + label = { Text("Navn") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + if (!suppressSuggestions && suggestions.isNotEmpty()) { + Card(Modifier.fillMaxWidth().semantics { contentDescription = "Forslag fra FEST" }) { + Column { + suggestions.take(6).forEach { s -> + Text( + "${s.name} ${s.strength}".trim(), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .clickable { + state.name = s.name + state.strength = s.strength + if (s.unit.isNotBlank()) state.unit = s.unit + state.form = guessForm(s.form) + state.atc = s.atc + s.packageSize?.let { p -> + state.packageSize = ScheduleText.amountText(p, "").trim() + } + suggestions = emptyList() + suppressSuggestions = true + } + .padding(12.dp), + ) + } + } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = state.strength, onValueChange = { state.strength = it }, + label = { Text("Styrke") }, singleLine = true, modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = state.unit, onValueChange = { state.unit = it }, + label = { Text("Enhet") }, singleLine = true, modifier = Modifier.weight(1f), + ) + } + + FormDropdown(state.form) { newForm -> + if (state.unit.isBlank() || state.unit == DEFAULT_UNIT[state.form]) { + state.unit = DEFAULT_UNIT.getValue(newForm) + } + state.form = newForm + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = state.stock, onValueChange = { state.stock = it }, + label = { Text("Lager") }, singleLine = true, modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = state.packageSize, onValueChange = { state.packageSize = it }, + label = { Text("Pakningsstr.") }, singleLine = true, modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = state.leadDays, onValueChange = { state.leadDays = it }, + label = { Text("Varsle v/ dager") }, singleLine = true, modifier = Modifier.weight(1f), + ) + } + + RxExpiryField(state.rxExpiry) { state.rxExpiry = it } + OutlinedTextField( + value = state.refills, onValueChange = { state.refills = it }, + label = { Text("Reit igjen") }, singleLine = true, modifier = Modifier.fillMaxWidth(), + ) + + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FormDropdown(value: MedForm, onChange: (MedForm) -> Unit) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = FORM_LABELS.getValue(value), + onValueChange = {}, + readOnly = true, + label = { Text("Form") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + MedForm.entries.forEach { f -> + DropdownMenuItem( + text = { Text(FORM_LABELS.getValue(f)) }, + onClick = { onChange(f); expanded = false }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RxExpiryField(value: LocalDate?, onChange: (LocalDate?) -> Unit) { + var showPicker by remember { mutableStateOf(false) } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = value?.toString() ?: "", + onValueChange = {}, + readOnly = true, + label = { Text("Resept utløper") }, + modifier = Modifier.weight(1f).clickable { showPicker = true }, + ) + TextButton(onClick = { showPicker = true }) { Text("Velg dato") } + if (value != null) { + TextButton(onClick = { onChange(null) }) { Text("Fjern") } + } + } + if (showPicker) { + val state = rememberDatePickerState( + initialSelectedDateMillis = value?.toEpochDay()?.times(86_400_000), + ) + DatePickerDialog( + onDismissRequest = { showPicker = false }, + confirmButton = { + TextButton(onClick = { + state.selectedDateMillis?.let { onChange(LocalDate.ofEpochDay(it / 86_400_000)) } + showPicker = false + }) { Text("OK") } + }, + dismissButton = { TextButton(onClick = { showPicker = false }) { Text("Avbryt") } }, + ) { + DatePicker(state = state) + } + } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt index 17ac40e..c6891dd 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt @@ -1,6 +1,5 @@ package no.naiv.meddetsamme.ui -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -9,22 +8,21 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.clickable import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.Checkbox -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -33,10 +31,10 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TimePicker import androidx.compose.material3.TopAppBar -import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -50,77 +48,37 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import java.time.LocalDate -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import no.naiv.meddetsamme.R import no.naiv.meddetsamme.alarm.AlarmScheduler import no.naiv.meddetsamme.data.DoseTime +import no.naiv.meddetsamme.data.InventoryItem import no.naiv.meddetsamme.data.MedDatabase -import no.naiv.meddetsamme.data.MedForm import no.naiv.meddetsamme.data.Medication import no.naiv.meddetsamme.domain.ScheduleText -import no.naiv.meddetsamme.fest.FestEntry -import no.naiv.meddetsamme.fest.FestRepository -private val FORM_LABELS = mapOf( - MedForm.TABLET to "Tablett", MedForm.CAPSULE to "Kapsel", MedForm.LIQUID to "Flytende", - MedForm.INJECTION to "Injeksjon", MedForm.DROPS to "Dråper", MedForm.SPRAY to "Spray", - MedForm.OTHER to "Annet", -) - -/** - * Enhet is what doses and stock COUNT (tablett, ml, …); Form is the - * pharmaceutical form. They coincide for tablets, which made the two fields - * look redundant — so Enhet auto-follows Form while it still holds the - * previous form's default, and stays untouched once manually overridden - * (IU, penner, …). - */ -private val DEFAULT_UNIT = mapOf( - MedForm.TABLET to "tablett", MedForm.CAPSULE to "kapsel", MedForm.LIQUID to "ml", - MedForm.INJECTION to "ml", MedForm.DROPS to "dråper", MedForm.SPRAY to "doser", - MedForm.OTHER to "dose", -) - -/** Best-effort mapping of FEST's free-form text to our enum; user can override. */ -private fun guessForm(festForm: String): MedForm = when { - "tablett" in festForm.lowercase() -> MedForm.TABLET - "kapsel" in festForm.lowercase() -> MedForm.CAPSULE - "mikst" in festForm.lowercase() || "oppløsning" in festForm.lowercase() -> MedForm.LIQUID - "inj" in festForm.lowercase() -> MedForm.INJECTION - "dråp" in festForm.lowercase() -> MedForm.DROPS - "spray" in festForm.lowercase() -> MedForm.SPRAY - else -> MedForm.OTHER -} +/** Sentinel for "create a new inventory item inline". */ +private const val NEW_ITEM_ID = -1L @OptIn(ExperimentalMaterial3Api::class) @Composable fun MedEditScreen(nav: Nav, initialMedId: Long?) { val context = LocalContext.current val db = remember { MedDatabase.get(context) } - val fest = remember { FestRepository(context) } val scope = rememberCoroutineScope() // null until first save for a new med; schedule editing needs the row id. var medId by remember { mutableStateOf(initialMedId) } - var name by remember { mutableStateOf("") } - var strength by remember { mutableStateOf("") } - var unit by remember { mutableStateOf("tablett") } - var form by remember { mutableStateOf(MedForm.TABLET) } + val inventory by db.inventoryDao().observeAll().collectAsState(initial = emptyList()) + var selectedItemId by remember { mutableStateOf(NEW_ITEM_ID) } + val newItemForm = remember { ItemFormState() } var withFood by remember { mutableStateOf(false) } var notes by remember { mutableStateOf("") } - var inventory by remember { mutableStateOf("0") } - var packageSize by remember { mutableStateOf("") } - var leadDays by remember { mutableStateOf("7") } - var rxExpiry by remember { mutableStateOf(null) } - var refills by remember { mutableStateOf("") } - var atc by remember { mutableStateOf(null) } - var suggestions by remember { mutableStateOf>(emptyList()) } - var suppressSuggestions by remember { mutableStateOf(false) } var doseTimes by remember { mutableStateOf>(emptyList()) } var error by remember { mutableStateOf(null) } var confirmDelete by remember { mutableStateOf(false) } + var medName by remember { mutableStateOf("") } suspend fun reloadDoseTimes() { medId?.let { doseTimes = db.doseTimeDao().getForMed(it) } @@ -129,47 +87,41 @@ fun MedEditScreen(nav: Nav, initialMedId: Long?) { LaunchedEffect(initialMedId) { initialMedId?.let { id -> db.medicationDao().getById(id)?.let { m -> - name = m.name; strength = m.strength; unit = m.unit; form = m.form - withFood = m.withFood; notes = m.notes - inventory = ScheduleText.amountText(m.inventoryUnits, "").trim() - packageSize = m.packageSize?.let { p -> ScheduleText.amountText(p, "").trim() } ?: "" - leadDays = m.lowStockLeadDays.toString() - rxExpiry = m.rxExpiryEpochDay?.let(LocalDate::ofEpochDay) - refills = m.refillsRemaining?.toString() ?: "" - atc = m.atcCode + selectedItemId = m.item.id + withFood = m.med.withFood + notes = m.med.notes + medName = m.displayName } reloadDoseTimes() } - suppressSuggestions = initialMedId != null } - fun parseAmount(s: String): Double? = s.trim().replace(',', '.').toDoubleOrNull() - fun save() { - val inv = parseAmount(inventory) - if (name.isBlank()) { error = "Navn mangler"; return } - if (inv == null || inv < 0) { error = "Ugyldig lagerbeholdning"; return } - val med = Medication( - id = medId ?: 0, - name = name.trim(), - strength = strength.trim(), - unit = unit.trim().ifBlank { "dose" }, - form = form, - withFood = withFood, - notes = notes.trim(), - inventoryUnits = inv, - packageSize = parseAmount(packageSize), - lowStockLeadDays = leadDays.toIntOrNull() ?: 7, - rxExpiryEpochDay = rxExpiry?.toEpochDay(), - refillsRemaining = refills.trim().toIntOrNull(), - atcCode = atc, - ) scope.launch { + val itemId = if (selectedItemId == NEW_ITEM_ID) { + val item = newItemForm.buildItem(0) ?: run { + error = newItemForm.error + return@launch + } + db.inventoryDao().insert(item) + } else { + selectedItemId + } + val med = Medication( + id = medId ?: 0, + itemId = itemId, + withFood = withFood, + notes = notes.trim(), + ) medId = if (medId == null) { db.medicationDao().insert(med) } else { - db.medicationDao().update(med); medId + // Preserve active flag on update. + val active = db.medicationDao().getRawById(medId!!)?.active ?: true + db.medicationDao().update(med.copy(active = active)) + medId } + selectedItemId = itemId AlarmScheduler(context).armAll() error = null } @@ -195,66 +147,29 @@ fun MedEditScreen(nav: Nav, initialMedId: Long?) { .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - OutlinedTextField( - value = name, - onValueChange = { - name = it - suppressSuggestions = false - scope.launch(Dispatchers.IO) { - val hits = fest.search(it) - withContext(Dispatchers.Main) { suggestions = hits } - } - }, - label = { Text("Navn") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), + Text("Vare fra lager", style = MaterialTheme.typography.titleMedium) + ItemPicker( + inventory = inventory, + selectedItemId = selectedItemId, + onSelect = { selectedItemId = it }, ) - if (!suppressSuggestions && suggestions.isNotEmpty()) { - Card(Modifier.fillMaxWidth().semantics { contentDescription = "Forslag fra FEST" }) { - Column { - suggestions.take(6).forEach { s -> + if (selectedItemId == NEW_ITEM_ID) { + InventoryItemForm(newItemForm) + } else { + inventory.find { it.id == selectedItemId }?.let { item -> + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text("${item.name} ${item.strength}".trim(), style = MaterialTheme.typography.titleSmall) Text( - "${s.name} ${s.strength}".trim(), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier - .fillMaxWidth() - .clickable { - name = s.name - strength = s.strength - if (s.unit.isNotBlank()) unit = s.unit - form = guessForm(s.form) - atc = s.atc - s.packageSize?.let { p -> - packageSize = ScheduleText.amountText(p, "").trim() - } - suggestions = emptyList() - suppressSuggestions = true - } - .padding(12.dp), + "Lager: ${ScheduleText.amountText(item.stockUnits, item.unit)} — rediger varen på Lager-siden", + style = MaterialTheme.typography.bodySmall, ) } } } } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = strength, onValueChange = { strength = it }, - label = { Text("Styrke") }, singleLine = true, modifier = Modifier.weight(1f), - ) - OutlinedTextField( - value = unit, onValueChange = { unit = it }, - label = { Text("Enhet") }, singleLine = true, modifier = Modifier.weight(1f), - ) - } - - FormDropdown(form) { newForm -> - if (unit.isBlank() || unit == DEFAULT_UNIT[form]) { - unit = DEFAULT_UNIT.getValue(newForm) - } - form = newForm - } - + Text("Regime", style = MaterialTheme.typography.titleMedium) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { withFood = !withFood }, @@ -262,27 +177,6 @@ fun MedEditScreen(nav: Nav, initialMedId: Long?) { Checkbox(checked = withFood, onCheckedChange = { withFood = it }) Text("Tas med mat") } - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = inventory, onValueChange = { inventory = it }, - label = { Text("Lager") }, singleLine = true, modifier = Modifier.weight(1f), - ) - OutlinedTextField( - value = packageSize, onValueChange = { packageSize = it }, - label = { Text("Pakningsstr.") }, singleLine = true, modifier = Modifier.weight(1f), - ) - OutlinedTextField( - value = leadDays, onValueChange = { leadDays = it }, - label = { Text("Varsle v/ dager") }, singleLine = true, modifier = Modifier.weight(1f), - ) - } - - RxExpiryField(rxExpiry) { rxExpiry = it } - OutlinedTextField( - value = refills, onValueChange = { refills = it }, - label = { Text("Reit igjen") }, singleLine = true, modifier = Modifier.fillMaxWidth(), - ) OutlinedTextField( value = notes, onValueChange = { notes = it }, label = { Text("Merknader") }, modifier = Modifier.fillMaxWidth(), @@ -329,8 +223,8 @@ fun MedEditScreen(nav: Nav, initialMedId: Long?) { if (confirmDelete) { AlertDialog( onDismissRequest = { confirmDelete = false }, - title = { Text("Slette ${name.ifBlank { "medisinen" }}?") }, - text = { Text("Sletter også all dosehistorikk. Dette kan ikke angres. «Avslutt medisin» beholder historikken.") }, + title = { Text("Slette ${medName.ifBlank { "medisinen" }}?") }, + text = { Text("Sletter også all dosehistorikk, men ikke lagervaren. «Avslutt medisin» beholder historikken.") }, confirmButton = { TextButton(onClick = { confirmDelete = false @@ -348,64 +242,42 @@ fun MedEditScreen(nav: Nav, initialMedId: Long?) { @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun FormDropdown(value: MedForm, onChange: (MedForm) -> Unit) { +private fun ItemPicker( + inventory: List, + selectedItemId: Long, + onSelect: (Long) -> Unit, +) { var expanded by remember { mutableStateOf(false) } + val label = if (selectedItemId == NEW_ITEM_ID) { + "Ny lagervare …" + } else { + inventory.find { it.id == selectedItemId } + ?.let { "${it.name} ${it.strength}".trim() } ?: "Velg vare" + } ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { OutlinedTextField( - value = FORM_LABELS.getValue(value), + value = label, onValueChange = {}, readOnly = true, - label = { Text("Form") }, + label = { Text("Vare") }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), ) ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - MedForm.entries.forEach { f -> + DropdownMenuItem( + text = { Text("Ny lagervare …") }, + onClick = { onSelect(NEW_ITEM_ID); expanded = false }, + ) + inventory.forEach { item -> DropdownMenuItem( - text = { Text(FORM_LABELS.getValue(f)) }, - onClick = { onChange(f); expanded = false }, + text = { Text("${item.name} ${item.strength}".trim()) }, + onClick = { onSelect(item.id); expanded = false }, ) } } } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun RxExpiryField(value: LocalDate?, onChange: (LocalDate?) -> Unit) { - var showPicker by remember { mutableStateOf(false) } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = value?.toString() ?: "", - onValueChange = {}, - readOnly = true, - label = { Text("Resept utløper") }, - modifier = Modifier.weight(1f).clickable { showPicker = true }, - ) - TextButton(onClick = { showPicker = true }) { Text("Velg dato") } - if (value != null) { - TextButton(onClick = { onChange(null) }) { Text("Fjern") } - } - } - if (showPicker) { - val state = rememberDatePickerState( - initialSelectedDateMillis = value?.toEpochDay()?.times(86_400_000), - ) - DatePickerDialog( - onDismissRequest = { showPicker = false }, - confirmButton = { - TextButton(onClick = { - state.selectedDateMillis?.let { onChange(LocalDate.ofEpochDay(it / 86_400_000)) } - showPicker = false - }) { Text("OK") } - }, - dismissButton = { TextButton(onClick = { showPicker = false }) { Text("Avbryt") } }, - ) { - DatePicker(state = state) - } - } -} - // ---- schedule (dose times) ------------------------------------------------- @OptIn(ExperimentalMaterial3Api::class) @@ -525,7 +397,7 @@ private fun DoseTimeEditorDialog( }, confirmButton = { TextButton(onClick = { - val amt = amount.trim().replace(',', '.').toDoubleOrNull() ?: return@TextButton + val amt = parseAmount(amount) ?: return@TextButton val interval = if (intervalMode) (intervalDays.toIntOrNull() ?: 2).coerceIn(2, 365) else 0 val dt = DoseTime( id = existing?.id ?: 0, diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/MedListScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/MedListScreen.kt index b44752a..c598a52 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/MedListScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/MedListScreen.kt @@ -73,21 +73,21 @@ fun MedListScreen(nav: Nav) { ) } } - items(meds, key = { it.id }) { med -> + items(meds, key = { it.med.id }) { med -> Card( modifier = Modifier .fillMaxWidth() - .clickable { nav.push(Screen.MedEdit(med.id)) } + .clickable { nav.push(Screen.MedEdit(med.med.id)) } .semantics(mergeDescendants = true) { contentDescription = - "${med.name} ${med.strength}, lager ${ScheduleText.amountText(med.inventoryUnits, med.unit)}. Trykk for å redigere." + "${med.displayName}, lager ${ScheduleText.amountText(med.item.stockUnits, med.item.unit)}. Trykk for å redigere." }, ) { Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { - Text("${med.name} ${med.strength}".trim(), style = MaterialTheme.typography.titleMedium) + Text(med.displayName, style = MaterialTheme.typography.titleMedium) Text( - "Lager: ${ScheduleText.amountText(med.inventoryUnits, med.unit)}", + "Lager: ${ScheduleText.amountText(med.item.stockUnits, med.item.unit)}", style = MaterialTheme.typography.bodyMedium, ) } diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt b/app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt index f4b6095..5e6b4b1 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/Nav.kt @@ -17,6 +17,9 @@ sealed interface Screen { data object MedList : Screen /** medId == null → create new. */ data class MedEdit(val medId: Long?) : Screen + data object Inventory : Screen + /** itemId == null → create new. */ + data class ItemEdit(val itemId: Long?) : Screen data object Settings : Screen } @@ -44,6 +47,8 @@ fun AppRoot() { is Screen.Today -> TodayScreen(nav) is Screen.MedList -> MedListScreen(nav) is Screen.MedEdit -> MedEditScreen(nav, screen.medId) + is Screen.Inventory -> InventoryScreen(nav) + is Screen.ItemEdit -> ItemEditScreen(nav, screen.itemId) is Screen.Settings -> SettingsScreen(nav) } } diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt index f99dae1..f75b894 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt @@ -51,7 +51,7 @@ import no.naiv.meddetsamme.data.DoseLog import no.naiv.meddetsamme.data.DoseStatus import no.naiv.meddetsamme.data.DoseTime import no.naiv.meddetsamme.data.MedDatabase -import no.naiv.meddetsamme.data.Medication +import no.naiv.meddetsamme.data.MedWithItem import no.naiv.meddetsamme.domain.DoseActions import no.naiv.meddetsamme.domain.ScheduleEngine import no.naiv.meddetsamme.domain.ScheduleText @@ -59,7 +59,7 @@ import no.naiv.meddetsamme.settings.SettingsStore /** One row on the today list: an occurrence joined with its log (if any). */ private data class TodayDose( - val med: Medication, + val med: MedWithItem, val doseTime: DoseTime, val at: LocalDateTime, val log: DoseLog?, @@ -97,19 +97,25 @@ fun TodayScreen(nav: Nav) { ) { meds, logs -> meds to logs }.collect { (meds, logs) -> val rows = mutableListOf() val warn = mutableListOf() + // Supply warnings are per inventory item; consumption sums across + // every active regimen drawing from the same stock. + val ratePerItem = mutableMapOf() for (med in meds) { - val doseTimes = db.doseTimeDao().getForMed(med.id) + val doseTimes = db.doseTimeDao().getForMed(med.med.id) for ((dt, at) in ScheduleEngine.occurrencesOn(doseTimes, today)) { val atMillis = at.atZone(zone).toInstant().toEpochMilli() rows += TodayDose(med, dt, at, logs.find { it.doseTimeId == dt.id && it.scheduledAtMillis == atMillis }) } - val rate = ScheduleEngine.dailyConsumption(doseTimes) - if (ScheduleEngine.needsRefill(med.inventoryUnits, rate, med.lowStockLeadDays)) { - val days = ScheduleEngine.daysOfSupply(med.inventoryUnits, rate)?.toInt() ?: 0 - warn += "${med.name}: lager for $days dager" + ratePerItem.merge(med.item.id, ScheduleEngine.dailyConsumption(doseTimes), Double::plus) + } + for (item in meds.map { it.item }.distinctBy { it.id }) { + val rate = ratePerItem[item.id] ?: 0.0 + if (ScheduleEngine.needsRefill(item.stockUnits, rate, item.lowStockLeadDays)) { + val days = ScheduleEngine.daysOfSupply(item.stockUnits, rate)?.toInt() ?: 0 + warn += "${item.name}: lager for $days dager" } - if (ScheduleEngine.needsRenewal(med.rxExpiryEpochDay, today)) { - warn += "${med.name}: resept må fornyes" + if (ScheduleEngine.needsRenewal(item.rxExpiryEpochDay, today)) { + warn += "${item.name}: resept må fornyes" } } doses = rows.sortedBy { it.at } @@ -125,6 +131,9 @@ fun TodayScreen(nav: Nav) { IconButton(onClick = { nav.push(Screen.MedList) }) { Icon(painterResource(R.drawable.ic_meds), contentDescription = "Medisiner") } + IconButton(onClick = { nav.push(Screen.Inventory) }) { + Icon(painterResource(R.drawable.ic_inventory), contentDescription = "Lager") + } IconButton(onClick = { nav.push(Screen.Settings) }) { Icon(painterResource(R.drawable.ic_settings), contentDescription = "Innstillinger") } @@ -202,8 +211,8 @@ private fun DoseCard( Card( modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) { contentDescription = buildString { - append("$time, ${dose.med.name} ${dose.med.strength}, ") - append(ScheduleText.amountText(dose.doseTime.amount, dose.med.unit)) + append("$time, ${dose.med.displayName}, ") + append(ScheduleText.amountText(dose.doseTime.amount, dose.med.item.unit)) statusText?.let { append(", status: $it") } } }, @@ -212,10 +221,10 @@ private fun DoseCard( Row(verticalAlignment = Alignment.CenterVertically) { Text(time, style = MaterialTheme.typography.titleLarge) Column(Modifier.padding(start = 12.dp).weight(1f)) { - Text("${dose.med.name} ${dose.med.strength}".trim(), style = MaterialTheme.typography.titleMedium) - val withFood = if (dose.med.withFood) " — tas med mat" else "" + Text(dose.med.displayName, style = MaterialTheme.typography.titleMedium) + val withFood = if (dose.med.med.withFood) " — tas med mat" else "" Text( - ScheduleText.amountText(dose.doseTime.amount, dose.med.unit) + withFood, + ScheduleText.amountText(dose.doseTime.amount, dose.med.item.unit) + withFood, style = MaterialTheme.typography.bodyMedium, ) } diff --git a/app/src/main/res/drawable/ic_inventory.xml b/app/src/main/res/drawable/ic_inventory.xml new file mode 100644 index 0000000..ebb6991 --- /dev/null +++ b/app/src/main/res/drawable/ic_inventory.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/test/java/no/naiv/meddetsamme/backup/BackupSerializerTest.kt b/app/src/test/java/no/naiv/meddetsamme/backup/BackupSerializerTest.kt index 6dec495..e47fde5 100644 --- a/app/src/test/java/no/naiv/meddetsamme/backup/BackupSerializerTest.kt +++ b/app/src/test/java/no/naiv/meddetsamme/backup/BackupSerializerTest.kt @@ -3,6 +3,7 @@ package no.naiv.meddetsamme.backup import no.naiv.meddetsamme.data.DoseLog import no.naiv.meddetsamme.data.DoseStatus import no.naiv.meddetsamme.data.DoseTime +import no.naiv.meddetsamme.data.InventoryItem import no.naiv.meddetsamme.data.MedForm import no.naiv.meddetsamme.data.Medication import org.junit.Assert.assertEquals @@ -12,22 +13,20 @@ import org.junit.Test class BackupSerializerTest { - private val med = Medication( - id = 7, + private val item = InventoryItem( + id = 4, name = "Levaxin", strength = "50 µg", unit = "tablett", form = MedForm.TABLET, - withFood = false, - notes = "tas fastende", - inventoryUnits = 42.5, + atcCode = "H03AA01", packageSize = 100.0, + stockUnits = 42.5, lowStockLeadDays = 10, rxExpiryEpochDay = 20800, refillsRemaining = 2, - atcCode = "H03AA01", - active = true, ) + private val med = Medication(id = 7, itemId = 4, withFood = false, notes = "tas fastende", active = true) private val doseTime = DoseTime( id = 3, medId = 7, minuteOfDay = 7 * 60 + 30, amount = 1.5, daysOfWeekMask = 0x3E, intervalDays = 0, anchorEpochDay = null, @@ -39,22 +38,42 @@ class BackupSerializerTest { @Test fun `export-parse round trip preserves every field`() { - val json = BackupSerializer.export(listOf(med), listOf(doseTime), listOf(log)) + val json = BackupSerializer.export(listOf(item), listOf(med), listOf(doseTime), listOf(log)) val parsed = BackupSerializer.parse(json) assertEquals(BackupFile.VERSION, parsed.version) - assertEquals(1, parsed.medications.size) - // DTO → entity → compare against original: catches silently dropped fields. - val medBack = parsed.medications.first() - assertEquals(med.name, medBack.name) - assertEquals(med.strength, medBack.strength) - assertEquals(med.form.name, medBack.form) - assertEquals(med.rxExpiryEpochDay, medBack.rxExpiryEpochDay) - assertEquals(med.refillsRemaining, medBack.refillsRemaining) - assertEquals(med.inventoryUnits, medBack.inventoryUnits, 0.0) - assertEquals(doseTime.daysOfWeekMask, parsed.doseTimes.first().daysOfWeekMask) - assertEquals(log.status.name, parsed.doseLogs.first().status) - assertEquals(log.nagCount, parsed.doseLogs.first().nagCount) + val itemBack = parsed.inventoryItems.single() + assertEquals(item.name, itemBack.name) + assertEquals(item.strength, itemBack.strength) + assertEquals(item.form.name, itemBack.form) + assertEquals(item.rxExpiryEpochDay, itemBack.rxExpiryEpochDay) + assertEquals(item.refillsRemaining, itemBack.refillsRemaining) + assertEquals(item.stockUnits, itemBack.stockUnits, 0.0) + + val medBack = parsed.medications.single() + assertEquals(med.itemId, medBack.itemId) + assertEquals(med.notes, medBack.notes) + assertEquals(doseTime.daysOfWeekMask, parsed.doseTimes.single().daysOfWeekMask) + assertEquals(log.status.name, parsed.doseLogs.single().status) + assertEquals(log.nagCount, parsed.doseLogs.single().nagCount) + } + + @Test + fun `version 1 files with inline med fields still parse`() { + // Frozen v1 shape: item data lived on the medication. Old backups must + // never go dark (the restore path converts them to the v2 split). + val v1 = """ + {"version":1,"exportedAtMillis":0, + "medications":[{"id":7,"name":"Levaxin","strength":"50 µg","unit":"tablett", + "form":"TABLET","withFood":false,"notes":"","inventoryUnits":42.5, + "lowStockLeadDays":10,"rxExpiryEpochDay":20800,"refillsRemaining":2,"active":true}], + "doseTimes":[],"doseLogs":[]} + """.trimIndent() + val parsed = BackupSerializer.parse(v1) + assertEquals(1, parsed.version) + assertEquals("Levaxin", parsed.medications.single().name) + assertEquals(42.5, parsed.medications.single().inventoryUnits!!, 0.0) + assertTrue(parsed.inventoryItems.isEmpty()) } @Test @@ -71,7 +90,7 @@ class BackupSerializerTest { @Test fun `export is human readable json`() { - val json = BackupSerializer.export(listOf(med), emptyList(), emptyList()) + val json = BackupSerializer.export(listOf(item), listOf(med), emptyList(), emptyList()) assertTrue(json.contains("\"name\": \"Levaxin\"")) // pretty-printed } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a97398e..91886d4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,8 @@ coroutines = "1.11.0" serializationJson = "1.11.0" kage = "0.4.0" junit = "4.13.2" +androidxTestJunit = "1.3.0" +androidxTestRunner = "1.7.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -36,6 +38,9 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx- kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serializationJson" } kage = { group = "com.github.android-password-store", name = "kage", version.ref = "kage" } junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestJunit" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } +room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }