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
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
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
|
|
@ -9,22 +8,21 @@ import androidx.compose.foundation.layout.Row
|
|||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
|
|
@ -33,10 +31,10 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -50,77 +48,37 @@ import androidx.compose.ui.semantics.contentDescription
|
|||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.LocalDate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import no.naiv.meddetsamme.R
|
||||
import no.naiv.meddetsamme.alarm.AlarmScheduler
|
||||
import no.naiv.meddetsamme.data.DoseTime
|
||||
import no.naiv.meddetsamme.data.InventoryItem
|
||||
import no.naiv.meddetsamme.data.MedDatabase
|
||||
import no.naiv.meddetsamme.data.MedForm
|
||||
import no.naiv.meddetsamme.data.Medication
|
||||
import no.naiv.meddetsamme.domain.ScheduleText
|
||||
import no.naiv.meddetsamme.fest.FestEntry
|
||||
import no.naiv.meddetsamme.fest.FestRepository
|
||||
|
||||
private val FORM_LABELS = mapOf(
|
||||
MedForm.TABLET to "Tablett", MedForm.CAPSULE to "Kapsel", MedForm.LIQUID to "Flytende",
|
||||
MedForm.INJECTION to "Injeksjon", MedForm.DROPS to "Dråper", MedForm.SPRAY to "Spray",
|
||||
MedForm.OTHER to "Annet",
|
||||
)
|
||||
|
||||
/**
|
||||
* Enhet is what doses and stock COUNT (tablett, ml, …); Form is the
|
||||
* pharmaceutical form. They coincide for tablets, which made the two fields
|
||||
* look redundant — so Enhet auto-follows Form while it still holds the
|
||||
* previous form's default, and stays untouched once manually overridden
|
||||
* (IU, penner, …).
|
||||
*/
|
||||
private val DEFAULT_UNIT = mapOf(
|
||||
MedForm.TABLET to "tablett", MedForm.CAPSULE to "kapsel", MedForm.LIQUID to "ml",
|
||||
MedForm.INJECTION to "ml", MedForm.DROPS to "dråper", MedForm.SPRAY to "doser",
|
||||
MedForm.OTHER to "dose",
|
||||
)
|
||||
|
||||
/** Best-effort mapping of FEST's free-form text to our enum; user can override. */
|
||||
private fun guessForm(festForm: String): MedForm = when {
|
||||
"tablett" in festForm.lowercase() -> MedForm.TABLET
|
||||
"kapsel" in festForm.lowercase() -> MedForm.CAPSULE
|
||||
"mikst" in festForm.lowercase() || "oppløsning" in festForm.lowercase() -> MedForm.LIQUID
|
||||
"inj" in festForm.lowercase() -> MedForm.INJECTION
|
||||
"dråp" in festForm.lowercase() -> MedForm.DROPS
|
||||
"spray" in festForm.lowercase() -> MedForm.SPRAY
|
||||
else -> MedForm.OTHER
|
||||
}
|
||||
/** Sentinel for "create a new inventory item inline". */
|
||||
private const val NEW_ITEM_ID = -1L
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MedEditScreen(nav: Nav, initialMedId: Long?) {
|
||||
val context = LocalContext.current
|
||||
val db = remember { MedDatabase.get(context) }
|
||||
val fest = remember { FestRepository(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// null until first save for a new med; schedule editing needs the row id.
|
||||
var medId by remember { mutableStateOf(initialMedId) }
|
||||
|
||||
var name by remember { mutableStateOf("") }
|
||||
var strength by remember { mutableStateOf("") }
|
||||
var unit by remember { mutableStateOf("tablett") }
|
||||
var form by remember { mutableStateOf(MedForm.TABLET) }
|
||||
val inventory by db.inventoryDao().observeAll().collectAsState(initial = emptyList())
|
||||
var selectedItemId by remember { mutableStateOf(NEW_ITEM_ID) }
|
||||
val newItemForm = remember { ItemFormState() }
|
||||
var withFood by remember { mutableStateOf(false) }
|
||||
var notes by remember { mutableStateOf("") }
|
||||
var inventory by remember { mutableStateOf("0") }
|
||||
var packageSize by remember { mutableStateOf("") }
|
||||
var leadDays by remember { mutableStateOf("7") }
|
||||
var rxExpiry by remember { mutableStateOf<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 error by remember { mutableStateOf<String?>(null) }
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
var medName by remember { mutableStateOf("") }
|
||||
|
||||
suspend fun reloadDoseTimes() {
|
||||
medId?.let { doseTimes = db.doseTimeDao().getForMed(it) }
|
||||
|
|
@ -129,47 +87,41 @@ fun MedEditScreen(nav: Nav, initialMedId: Long?) {
|
|||
LaunchedEffect(initialMedId) {
|
||||
initialMedId?.let { id ->
|
||||
db.medicationDao().getById(id)?.let { m ->
|
||||
name = m.name; strength = m.strength; unit = m.unit; form = m.form
|
||||
withFood = m.withFood; notes = m.notes
|
||||
inventory = ScheduleText.amountText(m.inventoryUnits, "").trim()
|
||||
packageSize = m.packageSize?.let { p -> ScheduleText.amountText(p, "").trim() } ?: ""
|
||||
leadDays = m.lowStockLeadDays.toString()
|
||||
rxExpiry = m.rxExpiryEpochDay?.let(LocalDate::ofEpochDay)
|
||||
refills = m.refillsRemaining?.toString() ?: ""
|
||||
atc = m.atcCode
|
||||
selectedItemId = m.item.id
|
||||
withFood = m.med.withFood
|
||||
notes = m.med.notes
|
||||
medName = m.displayName
|
||||
}
|
||||
reloadDoseTimes()
|
||||
}
|
||||
suppressSuggestions = initialMedId != null
|
||||
}
|
||||
|
||||
fun parseAmount(s: String): Double? = s.trim().replace(',', '.').toDoubleOrNull()
|
||||
|
||||
fun save() {
|
||||
val inv = parseAmount(inventory)
|
||||
if (name.isBlank()) { error = "Navn mangler"; return }
|
||||
if (inv == null || inv < 0) { error = "Ugyldig lagerbeholdning"; return }
|
||||
val med = Medication(
|
||||
id = medId ?: 0,
|
||||
name = name.trim(),
|
||||
strength = strength.trim(),
|
||||
unit = unit.trim().ifBlank { "dose" },
|
||||
form = form,
|
||||
withFood = withFood,
|
||||
notes = notes.trim(),
|
||||
inventoryUnits = inv,
|
||||
packageSize = parseAmount(packageSize),
|
||||
lowStockLeadDays = leadDays.toIntOrNull() ?: 7,
|
||||
rxExpiryEpochDay = rxExpiry?.toEpochDay(),
|
||||
refillsRemaining = refills.trim().toIntOrNull(),
|
||||
atcCode = atc,
|
||||
)
|
||||
scope.launch {
|
||||
val itemId = if (selectedItemId == NEW_ITEM_ID) {
|
||||
val item = newItemForm.buildItem(0) ?: run {
|
||||
error = newItemForm.error
|
||||
return@launch
|
||||
}
|
||||
db.inventoryDao().insert(item)
|
||||
} else {
|
||||
selectedItemId
|
||||
}
|
||||
val med = Medication(
|
||||
id = medId ?: 0,
|
||||
itemId = itemId,
|
||||
withFood = withFood,
|
||||
notes = notes.trim(),
|
||||
)
|
||||
medId = if (medId == null) {
|
||||
db.medicationDao().insert(med)
|
||||
} else {
|
||||
db.medicationDao().update(med); medId
|
||||
// Preserve active flag on update.
|
||||
val active = db.medicationDao().getRawById(medId!!)?.active ?: true
|
||||
db.medicationDao().update(med.copy(active = active))
|
||||
medId
|
||||
}
|
||||
selectedItemId = itemId
|
||||
AlarmScheduler(context).armAll()
|
||||
error = null
|
||||
}
|
||||
|
|
@ -195,66 +147,29 @@ fun MedEditScreen(nav: Nav, initialMedId: Long?) {
|
|||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = {
|
||||
name = it
|
||||
suppressSuggestions = false
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val hits = fest.search(it)
|
||||
withContext(Dispatchers.Main) { suggestions = hits }
|
||||
}
|
||||
},
|
||||
label = { Text("Navn") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
Text("Vare fra lager", style = MaterialTheme.typography.titleMedium)
|
||||
ItemPicker(
|
||||
inventory = inventory,
|
||||
selectedItemId = selectedItemId,
|
||||
onSelect = { selectedItemId = it },
|
||||
)
|
||||
if (!suppressSuggestions && suggestions.isNotEmpty()) {
|
||||
Card(Modifier.fillMaxWidth().semantics { contentDescription = "Forslag fra FEST" }) {
|
||||
Column {
|
||||
suggestions.take(6).forEach { s ->
|
||||
if (selectedItemId == NEW_ITEM_ID) {
|
||||
InventoryItemForm(newItemForm)
|
||||
} else {
|
||||
inventory.find { it.id == selectedItemId }?.let { item ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text("${item.name} ${item.strength}".trim(), style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"${s.name} ${s.strength}".trim(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
name = s.name
|
||||
strength = s.strength
|
||||
if (s.unit.isNotBlank()) unit = s.unit
|
||||
form = guessForm(s.form)
|
||||
atc = s.atc
|
||||
s.packageSize?.let { p ->
|
||||
packageSize = ScheduleText.amountText(p, "").trim()
|
||||
}
|
||||
suggestions = emptyList()
|
||||
suppressSuggestions = true
|
||||
}
|
||||
.padding(12.dp),
|
||||
"Lager: ${ScheduleText.amountText(item.stockUnits, item.unit)} — rediger varen på Lager-siden",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = strength, onValueChange = { strength = it },
|
||||
label = { Text("Styrke") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = unit, onValueChange = { unit = it },
|
||||
label = { Text("Enhet") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
FormDropdown(form) { newForm ->
|
||||
if (unit.isBlank() || unit == DEFAULT_UNIT[form]) {
|
||||
unit = DEFAULT_UNIT.getValue(newForm)
|
||||
}
|
||||
form = newForm
|
||||
}
|
||||
|
||||
Text("Regime", style = MaterialTheme.typography.titleMedium)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { withFood = !withFood },
|
||||
|
|
@ -262,27 +177,6 @@ fun MedEditScreen(nav: Nav, initialMedId: Long?) {
|
|||
Checkbox(checked = withFood, onCheckedChange = { withFood = it })
|
||||
Text("Tas med mat")
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = inventory, onValueChange = { inventory = it },
|
||||
label = { Text("Lager") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = packageSize, onValueChange = { packageSize = it },
|
||||
label = { Text("Pakningsstr.") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = leadDays, onValueChange = { leadDays = it },
|
||||
label = { Text("Varsle v/ dager") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
RxExpiryField(rxExpiry) { rxExpiry = it }
|
||||
OutlinedTextField(
|
||||
value = refills, onValueChange = { refills = it },
|
||||
label = { Text("Reit igjen") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = notes, onValueChange = { notes = it },
|
||||
label = { Text("Merknader") }, modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -329,8 +223,8 @@ fun MedEditScreen(nav: Nav, initialMedId: Long?) {
|
|||
if (confirmDelete) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmDelete = false },
|
||||
title = { Text("Slette ${name.ifBlank { "medisinen" }}?") },
|
||||
text = { Text("Sletter også all dosehistorikk. Dette kan ikke angres. «Avslutt medisin» beholder historikken.") },
|
||||
title = { Text("Slette ${medName.ifBlank { "medisinen" }}?") },
|
||||
text = { Text("Sletter også all dosehistorikk, men ikke lagervaren. «Avslutt medisin» beholder historikken.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
confirmDelete = false
|
||||
|
|
@ -348,64 +242,42 @@ fun MedEditScreen(nav: Nav, initialMedId: Long?) {
|
|||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun FormDropdown(value: MedForm, onChange: (MedForm) -> Unit) {
|
||||
private fun ItemPicker(
|
||||
inventory: List<InventoryItem>,
|
||||
selectedItemId: Long,
|
||||
onSelect: (Long) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val label = if (selectedItemId == NEW_ITEM_ID) {
|
||||
"Ny lagervare …"
|
||||
} else {
|
||||
inventory.find { it.id == selectedItemId }
|
||||
?.let { "${it.name} ${it.strength}".trim() } ?: "Velg vare"
|
||||
}
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = FORM_LABELS.getValue(value),
|
||||
value = label,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Form") },
|
||||
label = { Text("Vare") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
MedForm.entries.forEach { f ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("Ny lagervare …") },
|
||||
onClick = { onSelect(NEW_ITEM_ID); expanded = false },
|
||||
)
|
||||
inventory.forEach { item ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(FORM_LABELS.getValue(f)) },
|
||||
onClick = { onChange(f); expanded = false },
|
||||
text = { Text("${item.name} ${item.strength}".trim()) },
|
||||
onClick = { onSelect(item.id); expanded = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RxExpiryField(value: LocalDate?, onChange: (LocalDate?) -> Unit) {
|
||||
var showPicker by remember { mutableStateOf(false) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = value?.toString() ?: "",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Resept utløper") },
|
||||
modifier = Modifier.weight(1f).clickable { showPicker = true },
|
||||
)
|
||||
TextButton(onClick = { showPicker = true }) { Text("Velg dato") }
|
||||
if (value != null) {
|
||||
TextButton(onClick = { onChange(null) }) { Text("Fjern") }
|
||||
}
|
||||
}
|
||||
if (showPicker) {
|
||||
val state = rememberDatePickerState(
|
||||
initialSelectedDateMillis = value?.toEpochDay()?.times(86_400_000),
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { showPicker = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
state.selectedDateMillis?.let { onChange(LocalDate.ofEpochDay(it / 86_400_000)) }
|
||||
showPicker = false
|
||||
}) { Text("OK") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { showPicker = false }) { Text("Avbryt") } },
|
||||
) {
|
||||
DatePicker(state = state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- schedule (dose times) -------------------------------------------------
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
|
@ -525,7 +397,7 @@ private fun DoseTimeEditorDialog(
|
|||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val amt = amount.trim().replace(',', '.').toDoubleOrNull() ?: return@TextButton
|
||||
val amt = parseAmount(amount) ?: return@TextButton
|
||||
val interval = if (intervalMode) (intervalDays.toIntOrNull() ?: 2).coerceIn(2, 365) else 0
|
||||
val dt = DoseTime(
|
||||
id = existing?.id ?: 0,
|
||||
|
|
|
|||
|
|
@ -73,21 +73,21 @@ fun MedListScreen(nav: Nav) {
|
|||
)
|
||||
}
|
||||
}
|
||||
items(meds, key = { it.id }) { med ->
|
||||
items(meds, key = { it.med.id }) { med ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { nav.push(Screen.MedEdit(med.id)) }
|
||||
.clickable { nav.push(Screen.MedEdit(med.med.id)) }
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription =
|
||||
"${med.name} ${med.strength}, lager ${ScheduleText.amountText(med.inventoryUnits, med.unit)}. Trykk for å redigere."
|
||||
"${med.displayName}, lager ${ScheduleText.amountText(med.item.stockUnits, med.item.unit)}. Trykk for å redigere."
|
||||
},
|
||||
) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("${med.name} ${med.strength}".trim(), style = MaterialTheme.typography.titleMedium)
|
||||
Text(med.displayName, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Lager: ${ScheduleText.amountText(med.inventoryUnits, med.unit)}",
|
||||
"Lager: ${ScheduleText.amountText(med.item.stockUnits, med.item.unit)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ sealed interface Screen {
|
|||
data object MedList : Screen
|
||||
/** medId == null → create new. */
|
||||
data class MedEdit(val medId: Long?) : Screen
|
||||
data object Inventory : Screen
|
||||
/** itemId == null → create new. */
|
||||
data class ItemEdit(val itemId: Long?) : Screen
|
||||
data object Settings : Screen
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +47,8 @@ fun AppRoot() {
|
|||
is Screen.Today -> TodayScreen(nav)
|
||||
is Screen.MedList -> MedListScreen(nav)
|
||||
is Screen.MedEdit -> MedEditScreen(nav, screen.medId)
|
||||
is Screen.Inventory -> InventoryScreen(nav)
|
||||
is Screen.ItemEdit -> ItemEditScreen(nav, screen.itemId)
|
||||
is Screen.Settings -> SettingsScreen(nav)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ import no.naiv.meddetsamme.data.DoseLog
|
|||
import no.naiv.meddetsamme.data.DoseStatus
|
||||
import no.naiv.meddetsamme.data.DoseTime
|
||||
import no.naiv.meddetsamme.data.MedDatabase
|
||||
import no.naiv.meddetsamme.data.Medication
|
||||
import no.naiv.meddetsamme.data.MedWithItem
|
||||
import no.naiv.meddetsamme.domain.DoseActions
|
||||
import no.naiv.meddetsamme.domain.ScheduleEngine
|
||||
import no.naiv.meddetsamme.domain.ScheduleText
|
||||
|
|
@ -59,7 +59,7 @@ import no.naiv.meddetsamme.settings.SettingsStore
|
|||
|
||||
/** One row on the today list: an occurrence joined with its log (if any). */
|
||||
private data class TodayDose(
|
||||
val med: Medication,
|
||||
val med: MedWithItem,
|
||||
val doseTime: DoseTime,
|
||||
val at: LocalDateTime,
|
||||
val log: DoseLog?,
|
||||
|
|
@ -97,19 +97,25 @@ fun TodayScreen(nav: Nav) {
|
|||
) { meds, logs -> meds to logs }.collect { (meds, logs) ->
|
||||
val rows = mutableListOf<TodayDose>()
|
||||
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) {
|
||||
val doseTimes = db.doseTimeDao().getForMed(med.id)
|
||||
val doseTimes = db.doseTimeDao().getForMed(med.med.id)
|
||||
for ((dt, at) in ScheduleEngine.occurrencesOn(doseTimes, today)) {
|
||||
val atMillis = at.atZone(zone).toInstant().toEpochMilli()
|
||||
rows += TodayDose(med, dt, at, logs.find { it.doseTimeId == dt.id && it.scheduledAtMillis == atMillis })
|
||||
}
|
||||
val rate = ScheduleEngine.dailyConsumption(doseTimes)
|
||||
if (ScheduleEngine.needsRefill(med.inventoryUnits, rate, med.lowStockLeadDays)) {
|
||||
val days = ScheduleEngine.daysOfSupply(med.inventoryUnits, rate)?.toInt() ?: 0
|
||||
warn += "${med.name}: lager for $days dager"
|
||||
ratePerItem.merge(med.item.id, ScheduleEngine.dailyConsumption(doseTimes), Double::plus)
|
||||
}
|
||||
for (item in meds.map { it.item }.distinctBy { it.id }) {
|
||||
val rate = ratePerItem[item.id] ?: 0.0
|
||||
if (ScheduleEngine.needsRefill(item.stockUnits, rate, item.lowStockLeadDays)) {
|
||||
val days = ScheduleEngine.daysOfSupply(item.stockUnits, rate)?.toInt() ?: 0
|
||||
warn += "${item.name}: lager for $days dager"
|
||||
}
|
||||
if (ScheduleEngine.needsRenewal(med.rxExpiryEpochDay, today)) {
|
||||
warn += "${med.name}: resept må fornyes"
|
||||
if (ScheduleEngine.needsRenewal(item.rxExpiryEpochDay, today)) {
|
||||
warn += "${item.name}: resept må fornyes"
|
||||
}
|
||||
}
|
||||
doses = rows.sortedBy { it.at }
|
||||
|
|
@ -125,6 +131,9 @@ fun TodayScreen(nav: Nav) {
|
|||
IconButton(onClick = { nav.push(Screen.MedList) }) {
|
||||
Icon(painterResource(R.drawable.ic_meds), contentDescription = "Medisiner")
|
||||
}
|
||||
IconButton(onClick = { nav.push(Screen.Inventory) }) {
|
||||
Icon(painterResource(R.drawable.ic_inventory), contentDescription = "Lager")
|
||||
}
|
||||
IconButton(onClick = { nav.push(Screen.Settings) }) {
|
||||
Icon(painterResource(R.drawable.ic_settings), contentDescription = "Innstillinger")
|
||||
}
|
||||
|
|
@ -202,8 +211,8 @@ private fun DoseCard(
|
|||
Card(
|
||||
modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {
|
||||
contentDescription = buildString {
|
||||
append("$time, ${dose.med.name} ${dose.med.strength}, ")
|
||||
append(ScheduleText.amountText(dose.doseTime.amount, dose.med.unit))
|
||||
append("$time, ${dose.med.displayName}, ")
|
||||
append(ScheduleText.amountText(dose.doseTime.amount, dose.med.item.unit))
|
||||
statusText?.let { append(", status: $it") }
|
||||
}
|
||||
},
|
||||
|
|
@ -212,10 +221,10 @@ private fun DoseCard(
|
|||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(time, style = MaterialTheme.typography.titleLarge)
|
||||
Column(Modifier.padding(start = 12.dp).weight(1f)) {
|
||||
Text("${dose.med.name} ${dose.med.strength}".trim(), style = MaterialTheme.typography.titleMedium)
|
||||
val withFood = if (dose.med.withFood) " — tas med mat" else ""
|
||||
Text(dose.med.displayName, style = MaterialTheme.typography.titleMedium)
|
||||
val withFood = if (dose.med.med.withFood) " — tas med mat" else ""
|
||||
Text(
|
||||
ScheduleText.amountText(dose.doseTime.amount, dose.med.unit) + withFood,
|
||||
ScheduleText.amountText(dose.doseTime.amount, dose.med.item.unit) + withFood,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue