Cyclic and tapering dose schedules (schema v3)
Extends DoseTime with two orthogonal filters, as the brief foresaw
('cyclic/taper extend from the same model'):
- Cyclic (cycleActiveDays/cycleLengthDays from anchor): N-on/M-off, e.g.
21/28 contraception. occursOn gains a cycle-window check; nextOccurrence
is now a uniform day-by-day scan over occursOn (replacing the interval
fast-path) so weekly/interval/cyclic/windowed all fall out of one
correct path.
- Validity window (startEpochDay/endEpochDay, inclusive): a taper is
several daily rows with descending amount and adjacent windows.
dailyConsumption now takes : cyclic scales by active/length, and
rows outside their window contribute 0 so a finished taper step stops
inflating days-of-supply.
UI: dose-time dialog gains a Syklisk section (på/av days) and is now
scrollable; a 'Lag nedtrapping' generator creates the windowed rows from
start dose / step-down / days-per-step / step-count. ScheduleText renders
'· syklus 21/28' and '· 11. juni–13. juni'.
Migration 2→3 is additive ADD COLUMNs with defaults matching the entity
(@ColumnInfo defaultValue), proven by an instrumented MigrationTestHelper
test. Backup DTO extended with defaults so v2 files still parse. 9 new
engine unit tests; versionCode 3 / 0.3.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
dae09ff9fb
commit
eeed098b1a
13 changed files with 782 additions and 50 deletions
|
|
@ -47,7 +47,7 @@ class SupplyCheckReceiver : BroadcastReceiver() {
|
|||
for ((_, meds) in byItem) {
|
||||
val item = meds.first().item
|
||||
var rate = 0.0
|
||||
for (med in meds) rate += ScheduleEngine.dailyConsumption(db.doseTimeDao().getForMed(med.med.id))
|
||||
for (med in meds) rate += ScheduleEngine.dailyConsumption(db.doseTimeDao().getForMed(med.med.id), today)
|
||||
if (ScheduleEngine.needsRefill(item.stockUnits, rate, item.lowStockLeadDays)) {
|
||||
val days = ScheduleEngine.daysOfSupply(item.stockUnits, rate)
|
||||
lowStock += "${item.name}: lager for ${no.naiv.meddetsamme.domain.ScheduleText.daysCount(days?.toInt() ?: 0)}"
|
||||
|
|
|
|||
|
|
@ -157,6 +157,11 @@ data class DoseTimeDto(
|
|||
val daysOfWeekMask: Int,
|
||||
val intervalDays: Int,
|
||||
val anchorEpochDay: Long? = null,
|
||||
// Added in schema v3; defaults keep older (v2) backup files parsing.
|
||||
val cycleActiveDays: Int = 0,
|
||||
val cycleLengthDays: Int = 0,
|
||||
val startEpochDay: Long? = null,
|
||||
val endEpochDay: Long? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
|
@ -193,9 +198,15 @@ private fun MedicationDto.toEntity() = Medication(
|
|||
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,
|
||||
cycleActiveDays, cycleLengthDays, startEpochDay, endEpochDay,
|
||||
)
|
||||
|
||||
private fun DoseTimeDto.toEntity() = DoseTime(id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay)
|
||||
private fun DoseTimeDto.toEntity() = DoseTime(
|
||||
id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay,
|
||||
cycleActiveDays, cycleLengthDays, startEpochDay, endEpochDay,
|
||||
)
|
||||
|
||||
private fun DoseLog.toDto() = DoseLogDto(id, medId, doseTimeId, scheduledAtMillis, amount, status.name, actionedAtMillis, nagCount)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package no.naiv.meddetsamme.data
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
|
|
@ -7,14 +8,22 @@ import androidx.room.PrimaryKey
|
|||
|
||||
/**
|
||||
* One scheduled time-of-day for a medication. Multiple rows per med give
|
||||
* different times on different days (e.g. weekend row + weekday row).
|
||||
* different times on different days (e.g. weekend row + weekend row).
|
||||
*
|
||||
* Schedule semantics (enforced by the pure ScheduleEngine, not the DB):
|
||||
* - [intervalDays] > 1 → dose every N days counted from [anchorEpochDay];
|
||||
* [daysOfWeekMask] is ignored.
|
||||
* - otherwise → weekly pattern from [daysOfWeekMask], bit 0 = Sunday … bit 6 =
|
||||
* Saturday (matches java.time DayOfWeek.SUNDAY rotated; engine owns the mapping).
|
||||
* 0x7F = daily. Cyclic/taper schedules extend from the same two fields.
|
||||
* Saturday. 0x7F = daily.
|
||||
*
|
||||
* Two orthogonal filters layer on top (the brief's "cyclic/taper extend from
|
||||
* the same model"):
|
||||
* - **Cyclic** ([cycleLengthDays] > 0): the dose only occurs in the first
|
||||
* [cycleActiveDays] of each [cycleLengthDays]-day cycle counted from
|
||||
* [anchorEpochDay] — e.g. 21-on/7-off contraception is 21/28.
|
||||
* - **Validity window** ([startEpochDay]/[endEpochDay], inclusive): the dose
|
||||
* only occurs within the date range. A taper is several rows with descending
|
||||
* [amount] and adjacent windows.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "dose_time",
|
||||
|
|
@ -39,6 +48,16 @@ data class DoseTime(
|
|||
val daysOfWeekMask: Int = 0x7F,
|
||||
/** 0 or 1 = use the weekly mask; N > 1 = every N days from [anchorEpochDay]. */
|
||||
val intervalDays: Int = 0,
|
||||
/** Day 0 of an every-N-days cycle (LocalDate.toEpochDay). Required if intervalDays > 1. */
|
||||
/** Day 0 of an every-N-days OR cyclic schedule (LocalDate.toEpochDay). */
|
||||
val anchorEpochDay: Long? = null,
|
||||
// defaultValue "0" makes the entity schema match the v2→v3 ADD COLUMN … DEFAULT 0,
|
||||
// so Room's migration validation passes.
|
||||
/** Days "on" at the start of each cycle. 0 = not cyclic. Needs [anchorEpochDay]. */
|
||||
@ColumnInfo(defaultValue = "0") val cycleActiveDays: Int = 0,
|
||||
/** Total cycle length. 0 = not cyclic. Cyclic requires this > [cycleActiveDays]. */
|
||||
@ColumnInfo(defaultValue = "0") val cycleLengthDays: Int = 0,
|
||||
/** Inclusive validity-window start (LocalDate.toEpochDay); null = no lower bound. */
|
||||
val startEpochDay: Long? = null,
|
||||
/** Inclusive validity-window end; null = no upper bound. Taper rows set this. */
|
||||
val endEpochDay: Long? = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||
*/
|
||||
@Database(
|
||||
entities = [InventoryItem::class, Medication::class, DoseTime::class, DoseLog::class],
|
||||
version = 2,
|
||||
version = 3,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class MedDatabase : RoomDatabase() {
|
||||
|
|
@ -78,13 +78,27 @@ abstract class MedDatabase : RoomDatabase() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 → v3: cyclic + validity-window columns on dose_time. Pure additive
|
||||
* ALTERs with defaults that mean "no cycle, no window" — existing rows
|
||||
* keep behaving exactly as before.
|
||||
*/
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE dose_time ADD COLUMN `cycleActiveDays` INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE dose_time ADD COLUMN `cycleLengthDays` INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE dose_time ADD COLUMN `startEpochDay` INTEGER")
|
||||
db.execSQL("ALTER TABLE dose_time ADD COLUMN `endEpochDay` INTEGER")
|
||||
}
|
||||
}
|
||||
|
||||
fun get(context: Context): MedDatabase =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
MedDatabase::class.java,
|
||||
"med-det-samme.db",
|
||||
).addMigrations(MIGRATION_1_2).build().also { instance = it }
|
||||
).addMigrations(MIGRATION_1_2, MIGRATION_2_3).build().also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,54 +20,66 @@ import no.naiv.meddetsamme.data.DoseTime
|
|||
* - intervalDays > 1 → every N days counted from anchorEpochDay; mask ignored.
|
||||
* - otherwise → weekly daysOfWeekMask, bit 0 = Sunday … bit 6 = Saturday.
|
||||
* Mask 0 selects nothing: no occurrences, never an error.
|
||||
* - cyclic ([cycleLengthDays] > 0) and validity window ([startEpochDay]/
|
||||
* [endEpochDay]) further restrict the above — see [occursOn].
|
||||
*/
|
||||
object ScheduleEngine {
|
||||
|
||||
/** How far ahead [nextOccurrence] searches — covers any realistic cycle/window. */
|
||||
private const val SEARCH_HORIZON_DAYS = 366
|
||||
|
||||
/** Bit position for a day-of-week: Sunday=0 … Saturday=6 (brief's convention). */
|
||||
fun bitFor(day: DayOfWeek): Int = day.value % 7 // java.time: MON=1..SUN=7
|
||||
|
||||
/** Does this dose-time occur on [date]? */
|
||||
fun occursOn(doseTime: DoseTime, date: LocalDate): Boolean {
|
||||
return if (doseTime.intervalDays > 1) {
|
||||
/** The base weekly/interval rule, before cyclic and window filters. */
|
||||
private fun baseOccursOn(doseTime: DoseTime, date: LocalDate): Boolean =
|
||||
if (doseTime.intervalDays > 1) {
|
||||
val anchor = doseTime.anchorEpochDay ?: return false
|
||||
val delta = date.toEpochDay() - anchor
|
||||
delta >= 0 && delta % doseTime.intervalDays == 0L
|
||||
} else {
|
||||
doseTime.daysOfWeekMask and (1 shl bitFor(date.dayOfWeek)) != 0
|
||||
}
|
||||
|
||||
/** Is [date] inside this dose-time's "on" window of its cycle? */
|
||||
private fun inCycleWindow(doseTime: DoseTime, date: LocalDate): Boolean {
|
||||
val length = doseTime.cycleLengthDays
|
||||
val active = doseTime.cycleActiveDays
|
||||
// Degenerate configs (no anchor, full or empty active span) = no cycle filter.
|
||||
if (length <= 0 || active !in 1 until length) return true
|
||||
val anchor = doseTime.anchorEpochDay ?: return true
|
||||
val day = date.toEpochDay()
|
||||
if (day < anchor) return false // a cycle can't run before it starts
|
||||
return Math.floorMod(day - anchor, length.toLong()) < active
|
||||
}
|
||||
|
||||
/** Does this dose-time occur on [date]? Base rule ∧ validity window ∧ cycle. */
|
||||
fun occursOn(doseTime: DoseTime, date: LocalDate): Boolean {
|
||||
val day = date.toEpochDay()
|
||||
doseTime.startEpochDay?.let { if (day < it) return false }
|
||||
doseTime.endEpochDay?.let { if (day > it) return false }
|
||||
return baseOccursOn(doseTime, date) && inCycleWindow(doseTime, date)
|
||||
}
|
||||
|
||||
/**
|
||||
* Earliest occurrence strictly after [after], or null if the schedule never
|
||||
* fires (empty mask / missing anchor). Strict-after is load-bearing: the
|
||||
* alarm receiver passes the occurrence it just fired, and must get the
|
||||
* *next* one — never the same minute again.
|
||||
* fires within the search horizon (empty mask / missing anchor / window
|
||||
* elapsed). Strict-after is load-bearing: the alarm receiver passes the
|
||||
* occurrence it just fired, and must get the *next* one — never the same
|
||||
* minute again.
|
||||
*
|
||||
* A uniform day-by-day scan over [occursOn] so weekly, interval, cyclic and
|
||||
* windowed schedules all fall out of one correct path.
|
||||
*/
|
||||
fun nextOccurrence(doseTime: DoseTime, after: LocalDateTime): LocalDateTime? {
|
||||
val time = LocalTime.of(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)
|
||||
// First candidate day: today if the time is still ahead of us, else tomorrow.
|
||||
var date = if (time > after.toLocalTime()) after.toLocalDate() else after.toLocalDate().plusDays(1)
|
||||
|
||||
if (doseTime.intervalDays > 1) {
|
||||
val anchor = doseTime.anchorEpochDay ?: return null
|
||||
val interval = doseTime.intervalDays.toLong()
|
||||
val delta = date.toEpochDay() - anchor
|
||||
date = when {
|
||||
delta <= 0 -> LocalDate.ofEpochDay(anchor) // cycle starts in the future
|
||||
else -> {
|
||||
val intoCycle = delta % interval
|
||||
if (intoCycle == 0L) date else date.plusDays(interval - intoCycle)
|
||||
}
|
||||
}
|
||||
return LocalDateTime.of(date, time)
|
||||
}
|
||||
|
||||
if (doseTime.daysOfWeekMask and 0x7F == 0) return null
|
||||
repeat(7) {
|
||||
repeat(SEARCH_HORIZON_DAYS) {
|
||||
if (occursOn(doseTime, date)) return LocalDateTime.of(date, time)
|
||||
date = date.plusDays(1)
|
||||
}
|
||||
return null // unreachable with a non-empty mask; defensive
|
||||
return null
|
||||
}
|
||||
|
||||
/** Earliest next occurrence across all of a med's dose-times (what to arm). */
|
||||
|
|
@ -84,18 +96,28 @@ object ScheduleEngine {
|
|||
.sortedBy { it.second }
|
||||
|
||||
/**
|
||||
* Scheduled (prospective) consumption in units/day. Weekly: amount × selected
|
||||
* days ÷ 7; every-N-days: amount ÷ N. Prospective, not historical, because
|
||||
* days-of-supply is a forward prediction.
|
||||
* Scheduled (prospective) consumption in units/day on [today]. Weekly:
|
||||
* amount × selected days ÷ 7; every-N-days: amount ÷ N; cyclic scales by
|
||||
* activeDays ÷ cycleLength. Rows outside their validity window contribute 0
|
||||
* — a finished taper step shouldn't inflate the rate forever. Prospective,
|
||||
* not historical, because days-of-supply is a forward prediction.
|
||||
*/
|
||||
fun dailyConsumption(doseTimes: List<DoseTime>): Double =
|
||||
doseTimes.sumOf { dt ->
|
||||
if (dt.intervalDays > 1) {
|
||||
fun dailyConsumption(doseTimes: List<DoseTime>, today: LocalDate): Double {
|
||||
val day = today.toEpochDay()
|
||||
return doseTimes.sumOf { dt ->
|
||||
if (dt.startEpochDay != null && day < dt.startEpochDay) return@sumOf 0.0
|
||||
if (dt.endEpochDay != null && day > dt.endEpochDay) return@sumOf 0.0
|
||||
var rate = if (dt.intervalDays > 1) {
|
||||
if (dt.anchorEpochDay == null) 0.0 else dt.amount / dt.intervalDays
|
||||
} else {
|
||||
dt.amount * Integer.bitCount(dt.daysOfWeekMask and 0x7F) / 7.0
|
||||
}
|
||||
if (dt.cycleLengthDays > 0 && dt.cycleActiveDays in 1 until dt.cycleLengthDays) {
|
||||
rate *= dt.cycleActiveDays.toDouble() / dt.cycleLengthDays
|
||||
}
|
||||
rate
|
||||
}
|
||||
}
|
||||
|
||||
/** Days until stock runs out at the scheduled rate; null if nothing is consumed. */
|
||||
fun daysOfSupply(inventoryUnits: Double, dailyConsumption: Double): Double? =
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
package no.naiv.meddetsamme.domain
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import no.naiv.meddetsamme.data.DoseTime
|
||||
|
||||
/**
|
||||
|
|
@ -12,9 +15,30 @@ object ScheduleText {
|
|||
/** Mask bit order matches ScheduleEngine: bit 0 = Sunday. */
|
||||
private val DAY_ABBREV = listOf("søn", "man", "tir", "ons", "tor", "fre", "lør")
|
||||
|
||||
fun describe(doseTime: DoseTime): String {
|
||||
val time = "%02d:%02d".format(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)
|
||||
return "$time ${daysText(doseTime)}"
|
||||
// Locale-pinned to nb so month names don't come out English on a foreign locale.
|
||||
private val dateFmt = DateTimeFormatter.ofPattern("d. MMM", Locale.forLanguageTag("nb"))
|
||||
|
||||
fun describe(doseTime: DoseTime): String =
|
||||
"${"%02d:%02d".format(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)} " +
|
||||
"${daysText(doseTime)}${cycleText(doseTime)}${windowText(doseTime)}"
|
||||
|
||||
/** " · syklus 21/28" when cyclic, else "". */
|
||||
fun cycleText(doseTime: DoseTime): String {
|
||||
val length = doseTime.cycleLengthDays
|
||||
val active = doseTime.cycleActiveDays
|
||||
return if (length > 0 && active in 1 until length) " · syklus $active/$length" else ""
|
||||
}
|
||||
|
||||
/** " · 1.–14. jun", " · f.o.m. 1. jun", " · t.o.m. 14. jun" for a validity window. */
|
||||
fun windowText(doseTime: DoseTime): String {
|
||||
val start = doseTime.startEpochDay?.let { LocalDate.ofEpochDay(it) }
|
||||
val end = doseTime.endEpochDay?.let { LocalDate.ofEpochDay(it) }
|
||||
return when {
|
||||
start != null && end != null -> " · ${dateFmt.format(start)}–${dateFmt.format(end)}"
|
||||
start != null -> " · f.o.m. ${dateFmt.format(start)}"
|
||||
end != null -> " · t.o.m. ${dateFmt.format(end)}"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
fun daysText(doseTime: DoseTime): String {
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ class DoctorSummaryPdf(private val context: Context) {
|
|||
for (med in meds) {
|
||||
val item = med.item
|
||||
val doseTimes = db.doseTimeDao().getForMed(med.med.id)
|
||||
val rate = ScheduleEngine.dailyConsumption(doseTimes)
|
||||
val rate = ScheduleEngine.dailyConsumption(doseTimes, today)
|
||||
val supplyDays = ScheduleEngine.daysOfSupply(item.stockUnits, rate)
|
||||
val logs = db.doseLogDao().getRange(windowStart, System.currentTimeMillis())
|
||||
.filter { it.medId == med.med.id }
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
|||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -294,6 +295,7 @@ private fun ScheduleSection(medId: Long, doseTimes: List<DoseTime>, onChanged: (
|
|||
val scope = rememberCoroutineScope()
|
||||
var editing by remember { mutableStateOf<DoseTime?>(null) }
|
||||
var showEditor by remember { mutableStateOf(false) }
|
||||
var showTaper by remember { mutableStateOf(false) }
|
||||
|
||||
Text("Doseringstider", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
|
|
@ -321,6 +323,9 @@ private fun ScheduleSection(medId: Long, doseTimes: List<DoseTime>, onChanged: (
|
|||
OutlinedButton(onClick = { editing = null; showEditor = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Legg til doseringstid")
|
||||
}
|
||||
OutlinedButton(onClick = { showTaper = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Lag nedtrapping …")
|
||||
}
|
||||
|
||||
if (showEditor) {
|
||||
DoseTimeEditorDialog(
|
||||
|
|
@ -330,6 +335,98 @@ private fun ScheduleSection(medId: Long, doseTimes: List<DoseTime>, onChanged: (
|
|||
onSaved = { showEditor = false; onChanged() },
|
||||
)
|
||||
}
|
||||
if (showTaper) {
|
||||
TaperDialog(
|
||||
medId = medId,
|
||||
onDismiss = { showTaper = false },
|
||||
onSaved = { showTaper = false; onChanged() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a tapering schedule: one daily dose-time row per step, each with a
|
||||
* descending amount and an adjacent validity window. Far less tedious than
|
||||
* hand-entering N windowed rows. Decreasing prednisolone is the canonical case.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TaperDialog(medId: Long, onDismiss: () -> Unit, onSaved: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val db = remember { MedDatabase.get(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val timeState = androidx.compose.material3.rememberTimePickerState(initialHour = 8, initialMinute = 0, is24Hour = true)
|
||||
var startAmount by remember { mutableStateOf("4") }
|
||||
var stepDown by remember { mutableStateOf("1") }
|
||||
var daysPerStep by remember { mutableStateOf("3") }
|
||||
var steps by remember { mutableStateOf("4") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Lag nedtrapping") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
androidx.compose.material3.TimePicker(state = timeState)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = startAmount, onValueChange = { startAmount = it },
|
||||
label = { Text("Startdose") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = stepDown, onValueChange = { stepDown = it },
|
||||
label = { Text("Ned per trinn") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = daysPerStep, onValueChange = { daysPerStep = it },
|
||||
label = { Text("Dager per trinn") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = steps, onValueChange = { steps = it },
|
||||
label = { Text("Antall trinn") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val start = parseAmount(startAmount)
|
||||
val step = parseAmount(stepDown)
|
||||
val perStep = daysPerStep.toIntOrNull()
|
||||
val n = steps.toIntOrNull()
|
||||
if (start == null || step == null || perStep == null || n == null || start <= 0 || perStep < 1 || n < 1) {
|
||||
error = "Fyll inn gyldige tall"
|
||||
return@TextButton
|
||||
}
|
||||
val minute = timeState.hour * 60 + timeState.minute
|
||||
val today = LocalDate.now().toEpochDay()
|
||||
val rows = (0 until n).mapNotNull { k ->
|
||||
val amt = start - k * step
|
||||
if (amt <= 0) return@mapNotNull null // stop once the dose would hit zero
|
||||
DoseTime(
|
||||
medId = medId,
|
||||
minuteOfDay = minute,
|
||||
amount = amt,
|
||||
daysOfWeekMask = 0x7F,
|
||||
startEpochDay = today + k.toLong() * perStep,
|
||||
endEpochDay = today + (k + 1).toLong() * perStep - 1,
|
||||
)
|
||||
}
|
||||
scope.launch {
|
||||
rows.forEach { db.doseTimeDao().insert(it) }
|
||||
onSaved()
|
||||
}
|
||||
}) { Text("Opprett ${steps.toIntOrNull() ?: 0} trinn") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Avbryt") } },
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
|
|
@ -353,12 +450,23 @@ private fun DoseTimeEditorDialog(
|
|||
var intervalMode by remember { mutableStateOf((existing?.intervalDays ?: 0) > 1) }
|
||||
var mask by remember { mutableStateOf(existing?.daysOfWeekMask ?: 0x7F) }
|
||||
var intervalDays by remember { mutableStateOf((existing?.intervalDays ?: 2).coerceAtLeast(2).toString()) }
|
||||
val existingCyclic = (existing?.cycleLengthDays ?: 0) > 0
|
||||
var cyclic by remember { mutableStateOf(existingCyclic) }
|
||||
var onDays by remember { mutableStateOf((if (existingCyclic) existing!!.cycleActiveDays else 21).toString()) }
|
||||
var offDays by remember {
|
||||
mutableStateOf((if (existingCyclic) existing!!.cycleLengthDays - existing.cycleActiveDays else 7).toString())
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (existing == null) "Ny doseringstid" else "Rediger doseringstid") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
// Scrollable: the dialog now has time + amount + mode + cycle and would
|
||||
// otherwise overflow on small screens.
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
TimePicker(state = timeState)
|
||||
OutlinedTextField(
|
||||
value = amount, onValueChange = { amount = it },
|
||||
|
|
@ -399,12 +507,43 @@ private fun DoseTimeEditorDialog(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { cyclic = !cyclic },
|
||||
) {
|
||||
Checkbox(checked = cyclic, onCheckedChange = { cyclic = it })
|
||||
Text("Syklisk (på/av-perioder)")
|
||||
}
|
||||
if (cyclic) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = onDays, onValueChange = { onDays = it },
|
||||
label = { Text("Dager på") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = offDays, onValueChange = { offDays = it },
|
||||
label = { Text("Dager av") }, singleLine = true, modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"F.eks. 21 på / 7 av. Syklusen starter i dag.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val amt = parseAmount(amount) ?: return@TextButton
|
||||
val interval = if (intervalMode) (intervalDays.toIntOrNull() ?: 2).coerceIn(2, 365) else 0
|
||||
val on = onDays.toIntOrNull()?.coerceAtLeast(1) ?: 0
|
||||
val off = offDays.toIntOrNull()?.coerceAtLeast(0) ?: 0
|
||||
val cycleActive = if (cyclic && on > 0 && off > 0) on else 0
|
||||
val cycleLength = if (cycleActive > 0) on + off else 0
|
||||
val needsAnchor = intervalMode || cycleActive > 0
|
||||
val dt = DoseTime(
|
||||
id = existing?.id ?: 0,
|
||||
medId = medId,
|
||||
|
|
@ -412,9 +551,13 @@ private fun DoseTimeEditorDialog(
|
|||
amount = amt,
|
||||
daysOfWeekMask = if (intervalMode) 0 else mask,
|
||||
intervalDays = interval,
|
||||
anchorEpochDay = if (intervalMode) {
|
||||
anchorEpochDay = if (needsAnchor) {
|
||||
existing?.anchorEpochDay ?: LocalDate.now().toEpochDay()
|
||||
} else null,
|
||||
cycleActiveDays = cycleActive,
|
||||
cycleLengthDays = cycleLength,
|
||||
startEpochDay = existing?.startEpochDay,
|
||||
endEpochDay = existing?.endEpochDay,
|
||||
)
|
||||
scope.launch {
|
||||
if (existing == null) db.doseTimeDao().insert(dt) else db.doseTimeDao().update(dt)
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ fun TodayScreen(nav: Nav) {
|
|||
val atMillis = at.atZone(zone).toInstant().toEpochMilli()
|
||||
rows += TodayDose(med, dt, at, logs.find { it.doseTimeId == dt.id && it.scheduledAtMillis == atMillis })
|
||||
}
|
||||
ratePerItem.merge(med.item.id, ScheduleEngine.dailyConsumption(doseTimes), Double::plus)
|
||||
ratePerItem.merge(med.item.id, ScheduleEngine.dailyConsumption(doseTimes, today), Double::plus)
|
||||
}
|
||||
for (item in meds.map { it.item }.distinctBy { it.id }) {
|
||||
val rate = ratePerItem[item.id] ?: 0.0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue