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:
Ole-Morten Duesund 2026-06-10 15:13:28 +02:00
commit 9b5817fcef
27 changed files with 1451 additions and 342 deletions

View 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") } },
)
}
}