Inventory split: InventoryItem entity, schema v2 with tested migration, Lager screen
Stock, package size and prescription state move to inventory_item; medication is now a regimen (itemId FK RESTRICT — deleting a stock item can never silently take adherence history with it). Supply warnings aggregate consumption across all regimens sharing an item. New Lager screen with one-tap '+1 pakning' restock; med editor picks an existing item or creates one inline via the shared ItemFormState (one form, no drift). Backup format v2 mirrors the split and still restores v1 files by performing the same conversion the DB migration does. MIGRATION_1_2 statements are copied from the exported 2.json and proven by an instrumented MigrationTestHelper test on the emulator (validates against the schema AND asserts data survival) before it ever touches the phone's medical record. Heads-up: connectedAndroidTest must target the emulator (ANDROID_SERIAL) — the phone's release signature rejects debug test APKs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f8e8dca081
commit
9b5817fcef
27 changed files with 1451 additions and 342 deletions
|
|
@ -33,6 +33,7 @@ android {
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0"
|
versionName = "0.1.0"
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
@ -65,6 +66,9 @@ android {
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MigrationTestHelper reads the exported schema JSONs as androidTest assets.
|
||||||
|
sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
@ -88,4 +92,7 @@ dependencies {
|
||||||
implementation(libs.kage)
|
implementation(libs.kage)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
androidTestImplementation(libs.androidx.test.junit)
|
||||||
|
androidTestImplementation(libs.androidx.test.runner)
|
||||||
|
androidTestImplementation(libs.room.testing)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
351
app/schemas/no.naiv.meddetsamme.data.MedDatabase/2.json
Normal file
351
app/schemas/no.naiv.meddetsamme.data.MedDatabase/2.json
Normal file
|
|
@ -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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -65,7 +65,7 @@ class AlarmScheduler(private val context: Context) {
|
||||||
val db = MedDatabase.get(context)
|
val db = MedDatabase.get(context)
|
||||||
val doseTime = db.doseTimeDao().getById(doseTimeId) ?: return
|
val doseTime = db.doseTimeDao().getById(doseTimeId) ?: return
|
||||||
val med = db.medicationDao().getById(doseTime.medId)
|
val med = db.medicationDao().getById(doseTime.medId)
|
||||||
if (med == null || !med.active) {
|
if (med == null || !med.med.active) {
|
||||||
cancelDoseTime(doseTimeId)
|
cancelDoseTime(doseTimeId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,14 +60,14 @@ class DoseAlarmReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
val doseTime = db.doseTimeDao().getById(doseTimeId) ?: return
|
val doseTime = db.doseTimeDao().getById(doseTimeId) ?: return
|
||||||
val med = db.medicationDao().getById(doseTime.medId) ?: 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 existing = db.doseLogDao().getForOccurrence(doseTimeId, scheduledAt)
|
||||||
val log = when {
|
val log = when {
|
||||||
existing == null -> {
|
existing == null -> {
|
||||||
val id = db.doseLogDao().insert(
|
val id = db.doseLogDao().insert(
|
||||||
DoseLog(
|
DoseLog(
|
||||||
medId = med.id,
|
medId = med.med.id,
|
||||||
doseTimeId = doseTimeId,
|
doseTimeId = doseTimeId,
|
||||||
scheduledAtMillis = scheduledAt,
|
scheduledAtMillis = scheduledAt,
|
||||||
amount = doseTime.amount,
|
amount = doseTime.amount,
|
||||||
|
|
|
||||||
|
|
@ -35,16 +35,20 @@ class SupplyCheckReceiver : BroadcastReceiver() {
|
||||||
val today = LocalDate.now()
|
val today = LocalDate.now()
|
||||||
val lines = mutableListOf<String>()
|
val lines = mutableListOf<String>()
|
||||||
|
|
||||||
for (med in db.medicationDao().getActive()) {
|
// Per inventory item: consumption is the SUM across all active regimens
|
||||||
val doseTimes = db.doseTimeDao().getForMed(med.id)
|
// drawing from it — one shared box drains faster than either alone.
|
||||||
val rate = ScheduleEngine.dailyConsumption(doseTimes)
|
val byItem = db.medicationDao().getActive().groupBy { it.item.id }
|
||||||
if (ScheduleEngine.needsRefill(med.inventoryUnits, rate, med.lowStockLeadDays)) {
|
for ((_, meds) in byItem) {
|
||||||
val days = ScheduleEngine.daysOfSupply(med.inventoryUnits, rate)
|
val item = meds.first().item
|
||||||
lines += "${med.name}: lager for ${days?.toInt() ?: 0} dager"
|
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)) {
|
if (ScheduleEngine.needsRenewal(item.rxExpiryEpochDay, today)) {
|
||||||
val expired = med.rxExpiryEpochDay!! < today.toEpochDay()
|
val expired = item.rxExpiryEpochDay!! < today.toEpochDay()
|
||||||
lines += if (expired) "${med.name}: resept utløpt" else "${med.name}: resept må fornyes"
|
lines += if (expired) "${item.name}: resept utløpt" else "${item.name}: resept må fornyes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,12 @@ class BackupManager(private val context: Context) {
|
||||||
/** Plaintext JSON export (caller decides whether/how to encrypt). */
|
/** Plaintext JSON export (caller decides whether/how to encrypt). */
|
||||||
suspend fun exportJson(): String {
|
suspend fun exportJson(): String {
|
||||||
val dao = db.backupDao()
|
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). */
|
/** Export encrypted with a *typed* passphrase (never stored — manual path). */
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import kotlinx.serialization.json.Json
|
||||||
import no.naiv.meddetsamme.data.DoseLog
|
import no.naiv.meddetsamme.data.DoseLog
|
||||||
import no.naiv.meddetsamme.data.DoseStatus
|
import no.naiv.meddetsamme.data.DoseStatus
|
||||||
import no.naiv.meddetsamme.data.DoseTime
|
import no.naiv.meddetsamme.data.DoseTime
|
||||||
|
import no.naiv.meddetsamme.data.InventoryItem
|
||||||
import no.naiv.meddetsamme.data.MedDatabase
|
import no.naiv.meddetsamme.data.MedDatabase
|
||||||
import no.naiv.meddetsamme.data.MedForm
|
import no.naiv.meddetsamme.data.MedForm
|
||||||
import no.naiv.meddetsamme.data.Medication
|
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).
|
* 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
|
* 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
|
* future versions of this app (and with `age -d` + jq on a laptop). Version 2
|
||||||
* schema change must be a *deliberate* format change — bump [BackupFile.VERSION]
|
* mirrors the schema-v2 inventory split; version-1 files (med fields inline)
|
||||||
* and keep reading old versions — never an accident.
|
* are still read and converted on restore — old backups must never go dark.
|
||||||
*/
|
*/
|
||||||
object BackupSerializer {
|
object BackupSerializer {
|
||||||
|
|
||||||
|
|
@ -24,11 +25,17 @@ object BackupSerializer {
|
||||||
ignoreUnknownKeys = true // older app reading newer minor additions
|
ignoreUnknownKeys = true // older app reading newer minor additions
|
||||||
}
|
}
|
||||||
|
|
||||||
fun export(meds: List<Medication>, doseTimes: List<DoseTime>, logs: List<DoseLog>): String =
|
fun export(
|
||||||
|
items: List<InventoryItem>,
|
||||||
|
meds: List<Medication>,
|
||||||
|
doseTimes: List<DoseTime>,
|
||||||
|
logs: List<DoseLog>,
|
||||||
|
): String =
|
||||||
json.encodeToString(
|
json.encodeToString(
|
||||||
BackupFile(
|
BackupFile(
|
||||||
version = BackupFile.VERSION,
|
version = BackupFile.VERSION,
|
||||||
exportedAtMillis = System.currentTimeMillis(),
|
exportedAtMillis = System.currentTimeMillis(),
|
||||||
|
inventoryItems = items.map { it.toDto() },
|
||||||
medications = meds.map { it.toDto() },
|
medications = meds.map { it.toDto() },
|
||||||
doseTimes = doseTimes.map { it.toDto() },
|
doseTimes = doseTimes.map { it.toDto() },
|
||||||
doseLogs = logs.map { it.toDto() },
|
doseLogs = logs.map { it.toDto() },
|
||||||
|
|
@ -51,43 +58,94 @@ object BackupSerializer {
|
||||||
* worse than a failed import. Caller re-arms alarms afterwards.
|
* worse than a failed import. Caller re-arms alarms afterwards.
|
||||||
*/
|
*/
|
||||||
suspend fun restore(db: MedDatabase, file: BackupFile) {
|
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(
|
db.restoreAll(
|
||||||
file.medications.map { it.toEntity() },
|
items,
|
||||||
|
meds,
|
||||||
file.doseTimes.map { it.toEntity() },
|
file.doseTimes.map { it.toEntity() },
|
||||||
file.doseLogs.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<MedicationDto>): Pair<List<InventoryItem>, List<Medication>> {
|
||||||
|
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
|
@Serializable
|
||||||
data class BackupFile(
|
data class BackupFile(
|
||||||
val version: Int,
|
val version: Int,
|
||||||
val exportedAtMillis: Long,
|
val exportedAtMillis: Long,
|
||||||
|
val inventoryItems: List<InventoryItemDto> = emptyList(), // absent in v1
|
||||||
val medications: List<MedicationDto>,
|
val medications: List<MedicationDto>,
|
||||||
val doseTimes: List<DoseTimeDto>,
|
val doseTimes: List<DoseTimeDto>,
|
||||||
val doseLogs: List<DoseLogDto>,
|
val doseLogs: List<DoseLogDto>,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val VERSION = 1
|
const val VERSION = 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MedicationDto(
|
data class InventoryItemDto(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val name: String,
|
val name: String,
|
||||||
val strength: String,
|
val strength: String = "",
|
||||||
val unit: String,
|
val unit: String,
|
||||||
val form: String,
|
val form: String,
|
||||||
val withFood: Boolean,
|
val atcCode: String? = null,
|
||||||
val notes: String,
|
|
||||||
val inventoryUnits: Double,
|
|
||||||
val packageSize: Double? = 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 rxExpiryEpochDay: Long? = null,
|
||||||
val refillsRemaining: Int? = null,
|
val refillsRemaining: Int? = null,
|
||||||
val atcCode: String? = null,
|
val atcCode: String? = null,
|
||||||
val active: Boolean,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -115,14 +173,24 @@ data class DoseLogDto(
|
||||||
|
|
||||||
// Enum round-trip via name: stable as long as enum constants are never renamed
|
// Enum round-trip via name: stable as long as enum constants are never renamed
|
||||||
// (they are part of the backup contract too).
|
// (they are part of the backup contract too).
|
||||||
private fun Medication.toDto() = MedicationDto(
|
private fun InventoryItem.toDto() = InventoryItemDto(
|
||||||
id, name, strength, unit, form.name, withFood, notes, inventoryUnits,
|
id, name, strength, unit, form.name, atcCode, packageSize, stockUnits,
|
||||||
packageSize, lowStockLeadDays, rxExpiryEpochDay, refillsRemaining, atcCode, active,
|
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(
|
private fun MedicationDto.toEntity() = Medication(
|
||||||
id, name, strength, unit, MedForm.valueOf(form), withFood, notes, inventoryUnits,
|
id = id,
|
||||||
packageSize, lowStockLeadDays, rxExpiryEpochDay, refillsRemaining, atcCode, active,
|
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)
|
private fun DoseTime.toDto() = DoseTimeDto(id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ import androidx.room.Transaction
|
||||||
@Dao
|
@Dao
|
||||||
interface BackupDao {
|
interface BackupDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM inventory_item")
|
||||||
|
suspend fun allInventoryItems(): List<InventoryItem>
|
||||||
|
|
||||||
@Query("SELECT * FROM medication")
|
@Query("SELECT * FROM medication")
|
||||||
suspend fun allMedications(): List<Medication>
|
suspend fun allMedications(): List<Medication>
|
||||||
|
|
||||||
|
|
@ -24,6 +27,12 @@ interface BackupDao {
|
||||||
@Query("DELETE FROM medication")
|
@Query("DELETE FROM medication")
|
||||||
suspend fun clearMedications()
|
suspend fun clearMedications()
|
||||||
|
|
||||||
|
@Query("DELETE FROM inventory_item")
|
||||||
|
suspend fun clearInventoryItems()
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
suspend fun insertInventoryItems(items: List<InventoryItem>)
|
||||||
|
|
||||||
@Insert
|
@Insert
|
||||||
suspend fun insertMedications(meds: List<Medication>)
|
suspend fun insertMedications(meds: List<Medication>)
|
||||||
|
|
||||||
|
|
@ -34,16 +43,19 @@ interface BackupDao {
|
||||||
suspend fun insertDoseLogs(logs: List<DoseLog>)
|
suspend fun insertDoseLogs(logs: List<DoseLog>)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace-all restore. Deleting medication CASCADEs to dose_time and
|
* Replace-all restore. medication cleared first (its itemId is RESTRICT),
|
||||||
* dose_log, so one delete clears everything; insertion order respects FKs.
|
* which CASCADEs to dose_time/dose_log; then items; then insert in FK order.
|
||||||
*/
|
*/
|
||||||
@Transaction
|
@Transaction
|
||||||
suspend fun replaceAll(
|
suspend fun replaceAll(
|
||||||
|
items: List<InventoryItem>,
|
||||||
meds: List<Medication>,
|
meds: List<Medication>,
|
||||||
doseTimes: List<DoseTime>,
|
doseTimes: List<DoseTime>,
|
||||||
logs: List<DoseLog>,
|
logs: List<DoseLog>,
|
||||||
) {
|
) {
|
||||||
clearMedications()
|
clearMedications()
|
||||||
|
clearInventoryItems()
|
||||||
|
insertInventoryItems(items)
|
||||||
insertMedications(meds)
|
insertMedications(meds)
|
||||||
insertDoseTimes(doseTimes)
|
insertDoseTimes(doseTimes)
|
||||||
insertDoseLogs(logs)
|
insertDoseLogs(logs)
|
||||||
|
|
|
||||||
43
app/src/main/java/no/naiv/meddetsamme/data/InventoryDao.kt
Normal file
43
app/src/main/java/no/naiv/meddetsamme/data/InventoryDao.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 InventoryDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM inventory_item ORDER BY name")
|
||||||
|
fun observeAll(): Flow<List<InventoryItem>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM inventory_item ORDER BY name")
|
||||||
|
suspend fun getAll(): List<InventoryItem>
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
36
app/src/main/java/no/naiv/meddetsamme/data/InventoryItem.kt
Normal file
36
app/src/main/java/no/naiv/meddetsamme/data/InventoryItem.kt
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -4,42 +4,87 @@ import android.content.Context
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
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,
|
* exportSchema=true with schemas/ committed: this DB holds a medical record,
|
||||||
* so every future schema change ships as a tested Migration against the
|
* so every schema change ships as a tested Migration against the exported
|
||||||
* exported baseline — never destructive fallback.
|
* baseline — never destructive fallback.
|
||||||
*
|
*
|
||||||
* Enums are stored as TEXT (Room's built-in enum converter); all other columns
|
* Enums are stored as TEXT (Room's built-in enum converter); all other columns
|
||||||
* are primitives, so no TypeConverters are needed yet.
|
* are primitives, so no TypeConverters are needed yet.
|
||||||
*/
|
*/
|
||||||
@Database(
|
@Database(
|
||||||
entities = [Medication::class, DoseTime::class, DoseLog::class],
|
entities = [InventoryItem::class, Medication::class, DoseTime::class, DoseLog::class],
|
||||||
version = 1,
|
version = 2,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
abstract class MedDatabase : RoomDatabase() {
|
abstract class MedDatabase : RoomDatabase() {
|
||||||
|
abstract fun inventoryDao(): InventoryDao
|
||||||
abstract fun medicationDao(): MedicationDao
|
abstract fun medicationDao(): MedicationDao
|
||||||
abstract fun doseTimeDao(): DoseTimeDao
|
abstract fun doseTimeDao(): DoseTimeDao
|
||||||
abstract fun doseLogDao(): DoseLogDao
|
abstract fun doseLogDao(): DoseLogDao
|
||||||
abstract fun backupDao(): BackupDao
|
abstract fun backupDao(): BackupDao
|
||||||
|
|
||||||
suspend fun restoreAll(
|
suspend fun restoreAll(
|
||||||
|
items: List<InventoryItem>,
|
||||||
meds: List<Medication>,
|
meds: List<Medication>,
|
||||||
doseTimes: List<DoseTime>,
|
doseTimes: List<DoseTime>,
|
||||||
logs: List<DoseLog>,
|
logs: List<DoseLog>,
|
||||||
) = backupDao().replaceAll(meds, doseTimes, logs)
|
) = backupDao().replaceAll(items, meds, doseTimes, logs)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@Volatile private var instance: MedDatabase? = null
|
@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 =
|
fun get(context: Context): MedDatabase =
|
||||||
instance ?: synchronized(this) {
|
instance ?: synchronized(this) {
|
||||||
instance ?: Room.databaseBuilder(
|
instance ?: Room.databaseBuilder(
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
MedDatabase::class.java,
|
MedDatabase::class.java,
|
||||||
"med-det-samme.db",
|
"med-det-samme.db",
|
||||||
).build().also { instance = it }
|
).addMigrations(MIGRATION_1_2).build().also { instance = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,48 @@
|
||||||
package no.naiv.meddetsamme.data
|
package no.naiv.meddetsamme.data
|
||||||
|
|
||||||
|
import androidx.room.Embedded
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
import androidx.room.Relation
|
||||||
|
|
||||||
enum class MedForm { TABLET, CAPSULE, LIQUID, INJECTION, DROPS, SPRAY, OTHER }
|
enum class MedForm { TABLET, CAPSULE, LIQUID, INJECTION, DROPS, SPRAY, OTHER }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One medication. Inventory and prescription state live here because both are
|
* A dosing regimen: HOW an [InventoryItem] is taken. Identity, stock and
|
||||||
* single-valued per med; consumption history lives in [DoseLog].
|
* prescription state live on the item (schema v2 split); schedule rows live
|
||||||
|
* in [DoseTime].
|
||||||
*
|
*
|
||||||
* Deliberate split (brief decision #9): [inventoryUnits] tracks physical stock,
|
* itemId is RESTRICT, not CASCADE: deleting a stock item must never silently
|
||||||
* while [rxExpiryEpochDay]/[refillsRemaining] track the e-resept — you can have a
|
* take a regimen and its adherence history with it — delete the regimen first.
|
||||||
* full box and a dead prescription, or vice versa. Refill warnings are *derived*
|
|
||||||
* (stock ÷ consumption rate), never a manually scheduled reminder.
|
|
||||||
*/
|
*/
|
||||||
@Entity(tableName = "medication")
|
@Entity(
|
||||||
|
tableName = "medication",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = InventoryItem::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["itemId"],
|
||||||
|
onDelete = ForeignKey.RESTRICT,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("itemId")],
|
||||||
|
)
|
||||||
data class Medication(
|
data class Medication(
|
||||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
val name: String,
|
val itemId: Long,
|
||||||
/** 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 withFood: Boolean = false,
|
||||||
val notes: String = "",
|
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. */
|
/** Soft delete: history must survive a med being discontinued. */
|
||||||
val active: Boolean = true,
|
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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,35 @@ package no.naiv.meddetsamme.data
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
import androidx.room.Update
|
import androidx.room.Update
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface MedicationDao {
|
interface MedicationDao {
|
||||||
|
|
||||||
@Query("SELECT * FROM medication WHERE active = 1 ORDER BY name")
|
@Transaction
|
||||||
fun observeActive(): Flow<List<Medication>>
|
@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<List<MedWithItem>>
|
||||||
|
|
||||||
@Query("SELECT * FROM medication WHERE active = 1 ORDER BY name")
|
@Transaction
|
||||||
suspend fun getActive(): List<Medication>
|
@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<MedWithItem>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM medication WHERE id = :id")
|
||||||
|
suspend fun getById(id: Long): MedWithItem?
|
||||||
|
|
||||||
@Query("SELECT * FROM medication WHERE id = :id")
|
@Query("SELECT * FROM medication WHERE id = :id")
|
||||||
suspend fun getById(id: Long): Medication?
|
suspend fun getRawById(id: Long): Medication?
|
||||||
|
|
||||||
@Insert
|
@Insert
|
||||||
suspend fun insert(medication: Medication): Long
|
suspend fun insert(medication: Medication): Long
|
||||||
|
|
@ -24,17 +39,6 @@ interface MedicationDao {
|
||||||
@Update
|
@Update
|
||||||
suspend fun update(medication: Medication)
|
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")
|
@Query("UPDATE medication SET active = 0 WHERE id = :id")
|
||||||
suspend fun deactivate(id: Long)
|
suspend fun deactivate(id: Long)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,9 @@ class DoseActions(private val context: Context) {
|
||||||
val log = db.doseLogDao().getById(logId) ?: return
|
val log = db.doseLogDao().getById(logId) ?: return
|
||||||
if (log.status == DoseStatus.TAKEN) return // double-tap must not decrement twice
|
if (log.status == DoseStatus.TAKEN) return // double-tap must not decrement twice
|
||||||
db.doseLogDao().setStatus(logId, DoseStatus.TAKEN, System.currentTimeMillis())
|
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)
|
scheduler.cancelEscalation(logId)
|
||||||
Notifications.cancel(context, Notifications.doseNotificationId(logId))
|
Notifications.cancel(context, Notifications.doseNotificationId(logId))
|
||||||
}
|
}
|
||||||
|
|
@ -56,11 +58,13 @@ class DoseActions(private val context: Context) {
|
||||||
Notifications.cancel(context, Notifications.doseNotificationId(logId))
|
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) {
|
suspend fun undo(logId: Long) {
|
||||||
val log = db.doseLogDao().getById(logId) ?: return
|
val log = db.doseLogDao().getById(logId) ?: return
|
||||||
if (log.status == DoseStatus.TAKEN) {
|
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)
|
db.doseLogDao().setStatus(logId, DoseStatus.PENDING, null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import no.naiv.meddetsamme.MainActivity
|
||||||
import no.naiv.meddetsamme.R
|
import no.naiv.meddetsamme.R
|
||||||
import no.naiv.meddetsamme.alarm.DoseActionReceiver
|
import no.naiv.meddetsamme.alarm.DoseActionReceiver
|
||||||
import no.naiv.meddetsamme.data.DoseLog
|
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.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
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
|
* replaces the previous card but re-alerts (no setOnlyAlertOnce: re-alerting
|
||||||
* is the escalation).
|
* 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 scheduled = Instant.ofEpochMilli(log.scheduledAtMillis).atZone(ZoneId.systemDefault())
|
||||||
val title = "${med.name} ${med.strength}".trim()
|
val title = med.displayName
|
||||||
val body = buildString {
|
val body = buildString {
|
||||||
append("${formatAmount(log.amount)} ${med.unit} kl. ${timeFmt.format(scheduled)}")
|
append("${formatAmount(log.amount)} ${med.item.unit} kl. ${timeFmt.format(scheduled)}")
|
||||||
if (med.withFood) append(" — tas med mat")
|
if (med.med.withFood) append(" — tas med mat")
|
||||||
if (log.nagCount > 0) append(" (påminnelse ${log.nagCount})")
|
if (log.nagCount > 0) append(" (påminnelse ${log.nagCount})")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,46 +91,47 @@ class DoctorSummaryPdf(private val context: Context) {
|
||||||
line("Generert ${dateFmt.format(today)} — Med det samme", small, 14f)
|
line("Generert ${dateFmt.format(today)} — Med det samme", small, 14f)
|
||||||
|
|
||||||
for (med in meds) {
|
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 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())
|
val logs = db.doseLogDao().getRange(windowStart, System.currentTimeMillis())
|
||||||
.filter { it.medId == med.id }
|
.filter { it.medId == med.med.id }
|
||||||
val adherence = ScheduleEngine.adherencePercent(logs)
|
val adherence = ScheduleEngine.adherencePercent(logs)
|
||||||
|
|
||||||
newPageIfNeeded(80f) // keep a med's block from straddling pages when possible
|
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 {
|
val formLine = buildString {
|
||||||
append(formLabels[med.form] ?: med.form.name)
|
append(formLabels[item.form] ?: item.form.name)
|
||||||
if (med.withFood) append(" — tas med mat")
|
if (med.med.withFood) append(" — tas med mat")
|
||||||
med.atcCode?.let { append(" · ATC $it") }
|
item.atcCode?.let { append(" · ATC $it") }
|
||||||
}
|
}
|
||||||
line(formLine, small, 3f)
|
line(formLine, small, 3f)
|
||||||
|
|
||||||
for (dt in doseTimes) {
|
for (dt in doseTimes) {
|
||||||
line(
|
line(
|
||||||
"${ScheduleText.amountText(dt.amount, med.unit)} kl. ${ScheduleText.describe(dt)}",
|
"${ScheduleText.amountText(dt.amount, item.unit)} kl. ${ScheduleText.describe(dt)}",
|
||||||
body,
|
body,
|
||||||
2f,
|
2f,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val supplyText = buildString {
|
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") }
|
supplyDays?.let { append(" ≈ ${it.toInt()} dager") }
|
||||||
}
|
}
|
||||||
line(supplyText, body, 2f)
|
line(supplyText, body, 2f)
|
||||||
|
|
||||||
med.rxExpiryEpochDay?.let { epochDay ->
|
item.rxExpiryEpochDay?.let { epochDay ->
|
||||||
val expiry = LocalDate.ofEpochDay(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)
|
line("Resept utløper ${dateFmt.format(expiry)}$reit", body, 2f)
|
||||||
}
|
}
|
||||||
|
|
||||||
adherence?.let { line("Etterlevelse siste 30 dager: $it %", 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
|
y += 10f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
109
app/src/main/java/no/naiv/meddetsamme/ui/InventoryScreen.kt
Normal file
109
app/src/main/java/no/naiv/meddetsamme/ui/InventoryScreen.kt
Normal file
|
|
@ -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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
app/src/main/java/no/naiv/meddetsamme/ui/ItemEditScreen.kt
Normal file
124
app/src/main/java/no/naiv/meddetsamme/ui/ItemEditScreen.kt
Normal file
|
|
@ -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") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
284
app/src/main/java/no/naiv/meddetsamme/ui/ItemForm.kt
Normal file
284
app/src/main/java/no/naiv/meddetsamme/ui/ItemForm.kt
Normal file
|
|
@ -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<LocalDate?>(null)
|
||||||
|
var refills by mutableStateOf("")
|
||||||
|
var atc by mutableStateOf<String?>(null)
|
||||||
|
var error by mutableStateOf<String?>(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<List<FestEntry>>(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package no.naiv.meddetsamme.ui
|
package no.naiv.meddetsamme.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.DatePicker
|
|
||||||
import androidx.compose.material3.DatePickerDialog
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
|
@ -33,10 +31,10 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TimePicker
|
import androidx.compose.material3.TimePicker
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.rememberDatePickerState
|
|
||||||
import androidx.compose.material3.rememberTimePickerState
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.semantics.semantics
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import no.naiv.meddetsamme.R
|
import no.naiv.meddetsamme.R
|
||||||
import no.naiv.meddetsamme.alarm.AlarmScheduler
|
import no.naiv.meddetsamme.alarm.AlarmScheduler
|
||||||
import no.naiv.meddetsamme.data.DoseTime
|
import no.naiv.meddetsamme.data.DoseTime
|
||||||
|
import no.naiv.meddetsamme.data.InventoryItem
|
||||||
import no.naiv.meddetsamme.data.MedDatabase
|
import no.naiv.meddetsamme.data.MedDatabase
|
||||||
import no.naiv.meddetsamme.data.MedForm
|
|
||||||
import no.naiv.meddetsamme.data.Medication
|
import no.naiv.meddetsamme.data.Medication
|
||||||
import no.naiv.meddetsamme.domain.ScheduleText
|
import no.naiv.meddetsamme.domain.ScheduleText
|
||||||
import no.naiv.meddetsamme.fest.FestEntry
|
|
||||||
import no.naiv.meddetsamme.fest.FestRepository
|
|
||||||
|
|
||||||
private val FORM_LABELS = mapOf(
|
/** Sentinel for "create a new inventory item inline". */
|
||||||
MedForm.TABLET to "Tablett", MedForm.CAPSULE to "Kapsel", MedForm.LIQUID to "Flytende",
|
private const val NEW_ITEM_ID = -1L
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MedEditScreen(nav: Nav, initialMedId: Long?) {
|
fun MedEditScreen(nav: Nav, initialMedId: Long?) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val db = remember { MedDatabase.get(context) }
|
val db = remember { MedDatabase.get(context) }
|
||||||
val fest = remember { FestRepository(context) }
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// null until first save for a new med; schedule editing needs the row id.
|
// null until first save for a new med; schedule editing needs the row id.
|
||||||
var medId by remember { mutableStateOf(initialMedId) }
|
var medId by remember { mutableStateOf(initialMedId) }
|
||||||
|
|
||||||
var name by remember { mutableStateOf("") }
|
val inventory by db.inventoryDao().observeAll().collectAsState(initial = emptyList())
|
||||||
var strength by remember { mutableStateOf("") }
|
var selectedItemId by remember { mutableStateOf(NEW_ITEM_ID) }
|
||||||
var unit by remember { mutableStateOf("tablett") }
|
val newItemForm = remember { ItemFormState() }
|
||||||
var form by remember { mutableStateOf(MedForm.TABLET) }
|
|
||||||
var withFood by remember { mutableStateOf(false) }
|
var withFood by remember { mutableStateOf(false) }
|
||||||
var notes by remember { mutableStateOf("") }
|
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<LocalDate?>(null) }
|
|
||||||
var refills by remember { mutableStateOf("") }
|
|
||||||
var atc by remember { mutableStateOf<String?>(null) }
|
|
||||||
var suggestions by remember { mutableStateOf<List<FestEntry>>(emptyList()) }
|
|
||||||
var suppressSuggestions by remember { mutableStateOf(false) }
|
|
||||||
var doseTimes by remember { mutableStateOf<List<DoseTime>>(emptyList()) }
|
var doseTimes by remember { mutableStateOf<List<DoseTime>>(emptyList()) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var confirmDelete by remember { mutableStateOf(false) }
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
|
var medName by remember { mutableStateOf("") }
|
||||||
|
|
||||||
suspend fun reloadDoseTimes() {
|
suspend fun reloadDoseTimes() {
|
||||||
medId?.let { doseTimes = db.doseTimeDao().getForMed(it) }
|
medId?.let { doseTimes = db.doseTimeDao().getForMed(it) }
|
||||||
|
|
@ -129,47 +87,41 @@ fun MedEditScreen(nav: Nav, initialMedId: Long?) {
|
||||||
LaunchedEffect(initialMedId) {
|
LaunchedEffect(initialMedId) {
|
||||||
initialMedId?.let { id ->
|
initialMedId?.let { id ->
|
||||||
db.medicationDao().getById(id)?.let { m ->
|
db.medicationDao().getById(id)?.let { m ->
|
||||||
name = m.name; strength = m.strength; unit = m.unit; form = m.form
|
selectedItemId = m.item.id
|
||||||
withFood = m.withFood; notes = m.notes
|
withFood = m.med.withFood
|
||||||
inventory = ScheduleText.amountText(m.inventoryUnits, "").trim()
|
notes = m.med.notes
|
||||||
packageSize = m.packageSize?.let { p -> ScheduleText.amountText(p, "").trim() } ?: ""
|
medName = m.displayName
|
||||||
leadDays = m.lowStockLeadDays.toString()
|
|
||||||
rxExpiry = m.rxExpiryEpochDay?.let(LocalDate::ofEpochDay)
|
|
||||||
refills = m.refillsRemaining?.toString() ?: ""
|
|
||||||
atc = m.atcCode
|
|
||||||
}
|
}
|
||||||
reloadDoseTimes()
|
reloadDoseTimes()
|
||||||
}
|
}
|
||||||
suppressSuggestions = initialMedId != null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseAmount(s: String): Double? = s.trim().replace(',', '.').toDoubleOrNull()
|
|
||||||
|
|
||||||
fun save() {
|
fun save() {
|
||||||
val inv = parseAmount(inventory)
|
scope.launch {
|
||||||
if (name.isBlank()) { error = "Navn mangler"; return }
|
val itemId = if (selectedItemId == NEW_ITEM_ID) {
|
||||||
if (inv == null || inv < 0) { error = "Ugyldig lagerbeholdning"; return }
|
val item = newItemForm.buildItem(0) ?: run {
|
||||||
|
error = newItemForm.error
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
db.inventoryDao().insert(item)
|
||||||
|
} else {
|
||||||
|
selectedItemId
|
||||||
|
}
|
||||||
val med = Medication(
|
val med = Medication(
|
||||||
id = medId ?: 0,
|
id = medId ?: 0,
|
||||||
name = name.trim(),
|
itemId = itemId,
|
||||||
strength = strength.trim(),
|
|
||||||
unit = unit.trim().ifBlank { "dose" },
|
|
||||||
form = form,
|
|
||||||
withFood = withFood,
|
withFood = withFood,
|
||||||
notes = notes.trim(),
|
notes = notes.trim(),
|
||||||
inventoryUnits = inv,
|
|
||||||
packageSize = parseAmount(packageSize),
|
|
||||||
lowStockLeadDays = leadDays.toIntOrNull() ?: 7,
|
|
||||||
rxExpiryEpochDay = rxExpiry?.toEpochDay(),
|
|
||||||
refillsRemaining = refills.trim().toIntOrNull(),
|
|
||||||
atcCode = atc,
|
|
||||||
)
|
)
|
||||||
scope.launch {
|
|
||||||
medId = if (medId == null) {
|
medId = if (medId == null) {
|
||||||
db.medicationDao().insert(med)
|
db.medicationDao().insert(med)
|
||||||
} else {
|
} 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()
|
AlarmScheduler(context).armAll()
|
||||||
error = null
|
error = null
|
||||||
}
|
}
|
||||||
|
|
@ -195,66 +147,29 @@ fun MedEditScreen(nav: Nav, initialMedId: Long?) {
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
Text("Vare fra lager", style = MaterialTheme.typography.titleMedium)
|
||||||
value = name,
|
ItemPicker(
|
||||||
onValueChange = {
|
inventory = inventory,
|
||||||
name = it
|
selectedItemId = selectedItemId,
|
||||||
suppressSuggestions = false
|
onSelect = { selectedItemId = it },
|
||||||
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()) {
|
if (selectedItemId == NEW_ITEM_ID) {
|
||||||
Card(Modifier.fillMaxWidth().semantics { contentDescription = "Forslag fra FEST" }) {
|
InventoryItemForm(newItemForm)
|
||||||
Column {
|
} else {
|
||||||
suggestions.take(6).forEach { s ->
|
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(
|
Text(
|
||||||
"${s.name} ${s.strength}".trim(),
|
"Lager: ${ScheduleText.amountText(item.stockUnits, item.unit)} — rediger varen på Lager-siden",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Text("Regime", style = MaterialTheme.typography.titleMedium)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.clickable { withFood = !withFood },
|
modifier = Modifier.clickable { withFood = !withFood },
|
||||||
|
|
@ -262,27 +177,6 @@ fun MedEditScreen(nav: Nav, initialMedId: Long?) {
|
||||||
Checkbox(checked = withFood, onCheckedChange = { withFood = it })
|
Checkbox(checked = withFood, onCheckedChange = { withFood = it })
|
||||||
Text("Tas med mat")
|
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(
|
OutlinedTextField(
|
||||||
value = notes, onValueChange = { notes = it },
|
value = notes, onValueChange = { notes = it },
|
||||||
label = { Text("Merknader") }, modifier = Modifier.fillMaxWidth(),
|
label = { Text("Merknader") }, modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -329,8 +223,8 @@ fun MedEditScreen(nav: Nav, initialMedId: Long?) {
|
||||||
if (confirmDelete) {
|
if (confirmDelete) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { confirmDelete = false },
|
onDismissRequest = { confirmDelete = false },
|
||||||
title = { Text("Slette ${name.ifBlank { "medisinen" }}?") },
|
title = { Text("Slette ${medName.ifBlank { "medisinen" }}?") },
|
||||||
text = { Text("Sletter også all dosehistorikk. Dette kan ikke angres. «Avslutt medisin» beholder historikken.") },
|
text = { Text("Sletter også all dosehistorikk, men ikke lagervaren. «Avslutt medisin» beholder historikken.") },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
confirmDelete = false
|
confirmDelete = false
|
||||||
|
|
@ -348,64 +242,42 @@ fun MedEditScreen(nav: Nav, initialMedId: Long?) {
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun FormDropdown(value: MedForm, onChange: (MedForm) -> Unit) {
|
private fun ItemPicker(
|
||||||
|
inventory: List<InventoryItem>,
|
||||||
|
selectedItemId: Long,
|
||||||
|
onSelect: (Long) -> Unit,
|
||||||
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
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 }) {
|
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = FORM_LABELS.getValue(value),
|
value = label,
|
||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
readOnly = true,
|
readOnly = true,
|
||||||
label = { Text("Form") },
|
label = { Text("Vare") },
|
||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
||||||
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||||
)
|
)
|
||||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
MedForm.entries.forEach { f ->
|
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(FORM_LABELS.getValue(f)) },
|
text = { Text("Ny lagervare …") },
|
||||||
onClick = { onChange(f); expanded = false },
|
onClick = { onSelect(NEW_ITEM_ID); expanded = false },
|
||||||
|
)
|
||||||
|
inventory.forEach { item ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
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) -------------------------------------------------
|
// ---- schedule (dose times) -------------------------------------------------
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -525,7 +397,7 @@ private fun DoseTimeEditorDialog(
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
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 interval = if (intervalMode) (intervalDays.toIntOrNull() ?: 2).coerceIn(2, 365) else 0
|
||||||
val dt = DoseTime(
|
val dt = DoseTime(
|
||||||
id = existing?.id ?: 0,
|
id = existing?.id ?: 0,
|
||||||
|
|
|
||||||
|
|
@ -73,21 +73,21 @@ fun MedListScreen(nav: Nav) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(meds, key = { it.id }) { med ->
|
items(meds, key = { it.med.id }) { med ->
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { nav.push(Screen.MedEdit(med.id)) }
|
.clickable { nav.push(Screen.MedEdit(med.med.id)) }
|
||||||
.semantics(mergeDescendants = true) {
|
.semantics(mergeDescendants = true) {
|
||||||
contentDescription =
|
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) {
|
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text("${med.name} ${med.strength}".trim(), style = MaterialTheme.typography.titleMedium)
|
Text(med.displayName, style = MaterialTheme.typography.titleMedium)
|
||||||
Text(
|
Text(
|
||||||
"Lager: ${ScheduleText.amountText(med.inventoryUnits, med.unit)}",
|
"Lager: ${ScheduleText.amountText(med.item.stockUnits, med.item.unit)}",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ sealed interface Screen {
|
||||||
data object MedList : Screen
|
data object MedList : Screen
|
||||||
/** medId == null → create new. */
|
/** medId == null → create new. */
|
||||||
data class MedEdit(val medId: Long?) : Screen
|
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
|
data object Settings : Screen
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,6 +47,8 @@ fun AppRoot() {
|
||||||
is Screen.Today -> TodayScreen(nav)
|
is Screen.Today -> TodayScreen(nav)
|
||||||
is Screen.MedList -> MedListScreen(nav)
|
is Screen.MedList -> MedListScreen(nav)
|
||||||
is Screen.MedEdit -> MedEditScreen(nav, screen.medId)
|
is Screen.MedEdit -> MedEditScreen(nav, screen.medId)
|
||||||
|
is Screen.Inventory -> InventoryScreen(nav)
|
||||||
|
is Screen.ItemEdit -> ItemEditScreen(nav, screen.itemId)
|
||||||
is Screen.Settings -> SettingsScreen(nav)
|
is Screen.Settings -> SettingsScreen(nav)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ import no.naiv.meddetsamme.data.DoseLog
|
||||||
import no.naiv.meddetsamme.data.DoseStatus
|
import no.naiv.meddetsamme.data.DoseStatus
|
||||||
import no.naiv.meddetsamme.data.DoseTime
|
import no.naiv.meddetsamme.data.DoseTime
|
||||||
import no.naiv.meddetsamme.data.MedDatabase
|
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.DoseActions
|
||||||
import no.naiv.meddetsamme.domain.ScheduleEngine
|
import no.naiv.meddetsamme.domain.ScheduleEngine
|
||||||
import no.naiv.meddetsamme.domain.ScheduleText
|
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). */
|
/** One row on the today list: an occurrence joined with its log (if any). */
|
||||||
private data class TodayDose(
|
private data class TodayDose(
|
||||||
val med: Medication,
|
val med: MedWithItem,
|
||||||
val doseTime: DoseTime,
|
val doseTime: DoseTime,
|
||||||
val at: LocalDateTime,
|
val at: LocalDateTime,
|
||||||
val log: DoseLog?,
|
val log: DoseLog?,
|
||||||
|
|
@ -97,19 +97,25 @@ fun TodayScreen(nav: Nav) {
|
||||||
) { meds, logs -> meds to logs }.collect { (meds, logs) ->
|
) { meds, logs -> meds to logs }.collect { (meds, logs) ->
|
||||||
val rows = mutableListOf<TodayDose>()
|
val rows = mutableListOf<TodayDose>()
|
||||||
val warn = mutableListOf<String>()
|
val warn = mutableListOf<String>()
|
||||||
|
// Supply warnings are per inventory item; consumption sums across
|
||||||
|
// every active regimen drawing from the same stock.
|
||||||
|
val ratePerItem = mutableMapOf<Long, Double>()
|
||||||
for (med in meds) {
|
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)) {
|
for ((dt, at) in ScheduleEngine.occurrencesOn(doseTimes, today)) {
|
||||||
val atMillis = at.atZone(zone).toInstant().toEpochMilli()
|
val atMillis = at.atZone(zone).toInstant().toEpochMilli()
|
||||||
rows += TodayDose(med, dt, at, logs.find { it.doseTimeId == dt.id && it.scheduledAtMillis == atMillis })
|
rows += TodayDose(med, dt, at, logs.find { it.doseTimeId == dt.id && it.scheduledAtMillis == atMillis })
|
||||||
}
|
}
|
||||||
val rate = ScheduleEngine.dailyConsumption(doseTimes)
|
ratePerItem.merge(med.item.id, ScheduleEngine.dailyConsumption(doseTimes), Double::plus)
|
||||||
if (ScheduleEngine.needsRefill(med.inventoryUnits, rate, med.lowStockLeadDays)) {
|
|
||||||
val days = ScheduleEngine.daysOfSupply(med.inventoryUnits, rate)?.toInt() ?: 0
|
|
||||||
warn += "${med.name}: lager for $days dager"
|
|
||||||
}
|
}
|
||||||
if (ScheduleEngine.needsRenewal(med.rxExpiryEpochDay, today)) {
|
for (item in meds.map { it.item }.distinctBy { it.id }) {
|
||||||
warn += "${med.name}: resept må fornyes"
|
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(item.rxExpiryEpochDay, today)) {
|
||||||
|
warn += "${item.name}: resept må fornyes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
doses = rows.sortedBy { it.at }
|
doses = rows.sortedBy { it.at }
|
||||||
|
|
@ -125,6 +131,9 @@ fun TodayScreen(nav: Nav) {
|
||||||
IconButton(onClick = { nav.push(Screen.MedList) }) {
|
IconButton(onClick = { nav.push(Screen.MedList) }) {
|
||||||
Icon(painterResource(R.drawable.ic_meds), contentDescription = "Medisiner")
|
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) }) {
|
IconButton(onClick = { nav.push(Screen.Settings) }) {
|
||||||
Icon(painterResource(R.drawable.ic_settings), contentDescription = "Innstillinger")
|
Icon(painterResource(R.drawable.ic_settings), contentDescription = "Innstillinger")
|
||||||
}
|
}
|
||||||
|
|
@ -202,8 +211,8 @@ private fun DoseCard(
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {
|
modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {
|
||||||
contentDescription = buildString {
|
contentDescription = buildString {
|
||||||
append("$time, ${dose.med.name} ${dose.med.strength}, ")
|
append("$time, ${dose.med.displayName}, ")
|
||||||
append(ScheduleText.amountText(dose.doseTime.amount, dose.med.unit))
|
append(ScheduleText.amountText(dose.doseTime.amount, dose.med.item.unit))
|
||||||
statusText?.let { append(", status: $it") }
|
statusText?.let { append(", status: $it") }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -212,10 +221,10 @@ private fun DoseCard(
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(time, style = MaterialTheme.typography.titleLarge)
|
Text(time, style = MaterialTheme.typography.titleLarge)
|
||||||
Column(Modifier.padding(start = 12.dp).weight(1f)) {
|
Column(Modifier.padding(start = 12.dp).weight(1f)) {
|
||||||
Text("${dose.med.name} ${dose.med.strength}".trim(), style = MaterialTheme.typography.titleMedium)
|
Text(dose.med.displayName, style = MaterialTheme.typography.titleMedium)
|
||||||
val withFood = if (dose.med.withFood) " — tas med mat" else ""
|
val withFood = if (dose.med.med.withFood) " — tas med mat" else ""
|
||||||
Text(
|
Text(
|
||||||
ScheduleText.amountText(dose.doseTime.amount, dose.med.unit) + withFood,
|
ScheduleText.amountText(dose.doseTime.amount, dose.med.item.unit) + withFood,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
app/src/main/res/drawable/ic_inventory.xml
Normal file
13
app/src/main/res/drawable/ic_inventory.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24"
|
||||||
|
>
|
||||||
|
<!-- Material "inventory_2" box -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M20,2L4,2c-1,0 -2,0.9 -2,2v3.01c0,0.72 0.43,1.34 1,1.69L3,20c0,1.1 1.1,2 2,2h14c0.9,0 2,-0.9 2,-2L21,8.7c0.57,-0.35 1,-0.97 1,-1.69L22,4c0,-1.1 -1,-2 -2,-2zM19,20L5,20L5,9h14v11zM20,7L4,7L4,4h16v3z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M9,12h6v2h-6z" />
|
||||||
|
</vector>
|
||||||
|
|
@ -3,6 +3,7 @@ package no.naiv.meddetsamme.backup
|
||||||
import no.naiv.meddetsamme.data.DoseLog
|
import no.naiv.meddetsamme.data.DoseLog
|
||||||
import no.naiv.meddetsamme.data.DoseStatus
|
import no.naiv.meddetsamme.data.DoseStatus
|
||||||
import no.naiv.meddetsamme.data.DoseTime
|
import no.naiv.meddetsamme.data.DoseTime
|
||||||
|
import no.naiv.meddetsamme.data.InventoryItem
|
||||||
import no.naiv.meddetsamme.data.MedForm
|
import no.naiv.meddetsamme.data.MedForm
|
||||||
import no.naiv.meddetsamme.data.Medication
|
import no.naiv.meddetsamme.data.Medication
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
|
@ -12,22 +13,20 @@ import org.junit.Test
|
||||||
|
|
||||||
class BackupSerializerTest {
|
class BackupSerializerTest {
|
||||||
|
|
||||||
private val med = Medication(
|
private val item = InventoryItem(
|
||||||
id = 7,
|
id = 4,
|
||||||
name = "Levaxin",
|
name = "Levaxin",
|
||||||
strength = "50 µg",
|
strength = "50 µg",
|
||||||
unit = "tablett",
|
unit = "tablett",
|
||||||
form = MedForm.TABLET,
|
form = MedForm.TABLET,
|
||||||
withFood = false,
|
atcCode = "H03AA01",
|
||||||
notes = "tas fastende",
|
|
||||||
inventoryUnits = 42.5,
|
|
||||||
packageSize = 100.0,
|
packageSize = 100.0,
|
||||||
|
stockUnits = 42.5,
|
||||||
lowStockLeadDays = 10,
|
lowStockLeadDays = 10,
|
||||||
rxExpiryEpochDay = 20800,
|
rxExpiryEpochDay = 20800,
|
||||||
refillsRemaining = 2,
|
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(
|
private val doseTime = DoseTime(
|
||||||
id = 3, medId = 7, minuteOfDay = 7 * 60 + 30, amount = 1.5,
|
id = 3, medId = 7, minuteOfDay = 7 * 60 + 30, amount = 1.5,
|
||||||
daysOfWeekMask = 0x3E, intervalDays = 0, anchorEpochDay = null,
|
daysOfWeekMask = 0x3E, intervalDays = 0, anchorEpochDay = null,
|
||||||
|
|
@ -39,22 +38,42 @@ class BackupSerializerTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `export-parse round trip preserves every field`() {
|
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)
|
val parsed = BackupSerializer.parse(json)
|
||||||
|
|
||||||
assertEquals(BackupFile.VERSION, parsed.version)
|
assertEquals(BackupFile.VERSION, parsed.version)
|
||||||
assertEquals(1, parsed.medications.size)
|
val itemBack = parsed.inventoryItems.single()
|
||||||
// DTO → entity → compare against original: catches silently dropped fields.
|
assertEquals(item.name, itemBack.name)
|
||||||
val medBack = parsed.medications.first()
|
assertEquals(item.strength, itemBack.strength)
|
||||||
assertEquals(med.name, medBack.name)
|
assertEquals(item.form.name, itemBack.form)
|
||||||
assertEquals(med.strength, medBack.strength)
|
assertEquals(item.rxExpiryEpochDay, itemBack.rxExpiryEpochDay)
|
||||||
assertEquals(med.form.name, medBack.form)
|
assertEquals(item.refillsRemaining, itemBack.refillsRemaining)
|
||||||
assertEquals(med.rxExpiryEpochDay, medBack.rxExpiryEpochDay)
|
assertEquals(item.stockUnits, itemBack.stockUnits, 0.0)
|
||||||
assertEquals(med.refillsRemaining, medBack.refillsRemaining)
|
|
||||||
assertEquals(med.inventoryUnits, medBack.inventoryUnits, 0.0)
|
val medBack = parsed.medications.single()
|
||||||
assertEquals(doseTime.daysOfWeekMask, parsed.doseTimes.first().daysOfWeekMask)
|
assertEquals(med.itemId, medBack.itemId)
|
||||||
assertEquals(log.status.name, parsed.doseLogs.first().status)
|
assertEquals(med.notes, medBack.notes)
|
||||||
assertEquals(log.nagCount, parsed.doseLogs.first().nagCount)
|
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
|
@Test
|
||||||
|
|
@ -71,7 +90,7 @@ class BackupSerializerTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `export is human readable json`() {
|
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
|
assertTrue(json.contains("\"name\": \"Levaxin\"")) // pretty-printed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ coroutines = "1.11.0"
|
||||||
serializationJson = "1.11.0"
|
serializationJson = "1.11.0"
|
||||||
kage = "0.4.0"
|
kage = "0.4.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
|
androidxTestJunit = "1.3.0"
|
||||||
|
androidxTestRunner = "1.7.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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" }
|
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" }
|
kage = { group = "com.github.android-password-store", name = "kage", version.ref = "kage" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue