Compare commits

..

No commits in common. "5b9a3d6877a28636c9d1ace9cb8d04e7d63fbd63" and "b4c929eca234f01c82281c7f7bb31c104996dda3" have entirely different histories.

16 changed files with 105 additions and 1100 deletions

View file

@ -33,8 +33,8 @@ android {
targetSdk = 35 targetSdk = 35
// Bump both for every release installed on the phone — versionName for // Bump both for every release installed on the phone — versionName for
// humans, versionCode so dumpsys/install history can tell builds apart. // humans, versionCode so dumpsys/install history can tell builds apart.
versionCode = 5 versionCode = 4
versionName = "0.5.0" versionName = "0.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View file

@ -1,389 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "56932b6862c83744cc926f80ab7875e0",
"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, `cycleActiveDays` INTEGER NOT NULL DEFAULT 0, `cycleLengthDays` INTEGER NOT NULL DEFAULT 0, `startEpochDay` INTEGER, `endEpochDay` INTEGER, `seasonStartMmdd` INTEGER NOT NULL DEFAULT 0, `seasonEndMmdd` INTEGER NOT NULL DEFAULT 0, 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"
},
{
"fieldPath": "cycleActiveDays",
"columnName": "cycleActiveDays",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "cycleLengthDays",
"columnName": "cycleLengthDays",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "startEpochDay",
"columnName": "startEpochDay",
"affinity": "INTEGER"
},
{
"fieldPath": "endEpochDay",
"columnName": "endEpochDay",
"affinity": "INTEGER"
},
{
"fieldPath": "seasonStartMmdd",
"columnName": "seasonStartMmdd",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "seasonEndMmdd",
"columnName": "seasonEndMmdd",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"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, '56932b6862c83744cc926f80ab7875e0')"
]
}
}

View file

@ -108,35 +108,6 @@ class MigrationTest {
} }
} }
@Test
fun migrate3To4_addsSeasonColumnsDefaultingToNoSeason() {
helper.createDatabase(TEST_DB, 3).use { db ->
db.execSQL(
"INSERT INTO inventory_item (id, name, strength, unit, form, stockUnits, " +
"lowStockLeadDays) VALUES (1, 'Cetirizin', '10 mg', 'tablett', 'TABLET', 30.0, 7)",
)
db.execSQL("INSERT INTO medication (id, itemId, withFood, notes, active) VALUES (1, 1, 0, '', 1)")
db.execSQL(
"INSERT INTO dose_time (id, medId, minuteOfDay, amount, daysOfWeekMask, " +
"intervalDays, anchorEpochDay, cycleActiveDays, cycleLengthDays) " +
"VALUES (3, 1, 480, 1.0, 127, 0, NULL, 0, 0)",
)
}
// Validates the migrated schema against 4.json AND checks the new columns
// default to "no season" while the existing row survives.
helper.runMigrationsAndValidate(TEST_DB, 4, true, MedDatabase.MIGRATION_3_4).use { db ->
db.query(
"SELECT amount, seasonStartMmdd, seasonEndMmdd FROM dose_time WHERE id = 3",
).use { c ->
assertTrue(c.moveToFirst())
assertEquals(1.0, c.getDouble(0), 0.0)
assertEquals(0, c.getInt(1)) // seasonStartMmdd
assertEquals(0, c.getInt(2)) // seasonEndMmdd
}
}
}
private companion object { private companion object {
const val TEST_DB = "migration-test.db" const val TEST_DB = "migration-test.db"
} }

View file

@ -63,7 +63,6 @@
<receiver android:name=".alarm.DoseAlarmReceiver" android:exported="false" /> <receiver android:name=".alarm.DoseAlarmReceiver" android:exported="false" />
<receiver android:name=".alarm.DoseActionReceiver" android:exported="false" /> <receiver android:name=".alarm.DoseActionReceiver" android:exported="false" />
<receiver android:name=".alarm.SupplyCheckReceiver" android:exported="false" /> <receiver android:name=".alarm.SupplyCheckReceiver" android:exported="false" />
<receiver android:name=".alarm.SeasonActionReceiver" android:exported="false" />
<!-- AlarmManager state dies with the OS process table; rebuild every alarm <!-- AlarmManager state dies with the OS process table; rebuild every alarm
from the DB on boot and after app update. Both actions are protected from the DB on boot and after app update. Both actions are protected

View file

@ -1,66 +0,0 @@
package no.naiv.meddetsamme.alarm
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import java.time.LocalDate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.notify.Notifications
/**
* Handles the seasonal-nudge actions: a resting allergy med whose pollen season
* is starting early, or one whose season should run a little longer.
*
* Both write a one-year override onto the med's seasonal dose-times (decision:
* "overstyr årets vindu"). The overrides are absolute days, so the nominal
* month-day season is untouched and rules again next year see ScheduleEngine.
* - Start startEpochDay = today (the season begins now, ahead of schedule).
* - Forleng endEpochDay = today + [EXTEND_DAYS] (the season ends later).
*/
class SeasonActionReceiver : BroadcastReceiver() {
companion object {
const val ACTION_START_NOW = "no.naiv.meddetsamme.action.SEASON_START_NOW"
const val ACTION_EXTEND = "no.naiv.meddetsamme.action.SEASON_EXTEND"
const val EXTRA_MED_ID = "medId"
/** "Forleng" adds two weeks from today — the preview's "Forleng 2 uker". */
const val EXTEND_DAYS = 14L
}
override fun onReceive(context: Context, intent: Intent) {
val medId = intent.getLongExtra(EXTRA_MED_ID, -1)
if (medId < 0) return
val action = intent.action ?: return
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
handle(context, action, medId)
} finally {
pending.finish()
}
}
}
private suspend fun handle(context: Context, action: String, medId: Long) {
val db = MedDatabase.get(context)
val today = LocalDate.now().toEpochDay()
val seasonal = db.doseTimeDao().getForMed(medId)
.filter { it.seasonStartMmdd > 0 && it.seasonEndMmdd > 0 }
for (dt in seasonal) {
val updated = when (action) {
ACTION_START_NOW -> dt.copy(startEpochDay = today)
ACTION_EXTEND -> dt.copy(endEpochDay = today + EXTEND_DAYS)
else -> return
}
db.doseTimeDao().update(updated)
}
// The nudge has been acted on; clear it and re-arm so the alarm picks up
// the now-active (or longer) window immediately.
Notifications.cancel(context, Notifications.seasonNotificationId(medId))
AlarmScheduler(context).armAll()
}
}

View file

@ -4,13 +4,11 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import java.time.LocalDate import java.time.LocalDate
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import no.naiv.meddetsamme.data.MedDatabase import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.domain.ScheduleEngine import no.naiv.meddetsamme.domain.ScheduleEngine
import no.naiv.meddetsamme.domain.ScheduleText
import no.naiv.meddetsamme.notify.Notifications import no.naiv.meddetsamme.notify.Notifications
/** /**
@ -35,12 +33,6 @@ class SupplyCheckReceiver : BroadcastReceiver() {
private companion object { private companion object {
/** "About a week in advance" for prescription renewal. */ /** "About a week in advance" for prescription renewal. */
const val RX_LEAD_DAYS = 7 const val RX_LEAD_DAYS = 7
/** How early to nudge before a resting seasonal med's window opens. */
const val SEASON_START_LEAD_DAYS = 7L
/** How early to offer "Forleng" before the current season closes. */
const val SEASON_END_LEAD_DAYS = 5L
} }
private suspend fun check(context: Context) { private suspend fun check(context: Context) {
@ -49,19 +41,16 @@ class SupplyCheckReceiver : BroadcastReceiver() {
val lowStock = mutableListOf<String>() val lowStock = mutableListOf<String>()
val rxRenewal = mutableListOf<String>() val rxRenewal = mutableListOf<String>()
val meds = db.medicationDao().getActive()
// Dose-times feed both the supply rate and the season check; fetch once.
val dtsByMed = meds.associate { it.med.id to db.doseTimeDao().getForMed(it.med.id) }
// Per inventory item: consumption is the SUM across all active regimens // Per inventory item: consumption is the SUM across all active regimens
// drawing from it — one shared box drains faster than either alone. // drawing from it — one shared box drains faster than either alone.
for ((_, group) in meds.groupBy { it.item.id }) { val byItem = db.medicationDao().getActive().groupBy { it.item.id }
val item = group.first().item for ((_, meds) in byItem) {
val item = meds.first().item
var rate = 0.0 var rate = 0.0
for (med in group) rate += ScheduleEngine.dailyConsumption(dtsByMed[med.med.id].orEmpty(), today) for (med in meds) rate += ScheduleEngine.dailyConsumption(db.doseTimeDao().getForMed(med.med.id), today)
if (ScheduleEngine.needsRefill(item.stockUnits, rate, item.lowStockLeadDays)) { if (ScheduleEngine.needsRefill(item.stockUnits, rate, item.lowStockLeadDays)) {
val days = ScheduleEngine.daysOfSupply(item.stockUnits, rate) val days = ScheduleEngine.daysOfSupply(item.stockUnits, rate)
lowStock += "${item.name}: lager for ${ScheduleText.daysCount(days?.toInt() ?: 0)}" lowStock += "${item.name}: lager for ${no.naiv.meddetsamme.domain.ScheduleText.daysCount(days?.toInt() ?: 0)}"
} }
if (ScheduleEngine.needsRenewal(item.rxExpiryEpochDay, today, RX_LEAD_DAYS)) { if (ScheduleEngine.needsRenewal(item.rxExpiryEpochDay, today, RX_LEAD_DAYS)) {
val expired = item.rxExpiryEpochDay!! < today.toEpochDay() val expired = item.rxExpiryEpochDay!! < today.toEpochDay()
@ -69,8 +58,6 @@ class SupplyCheckReceiver : BroadcastReceiver() {
} }
} }
for (med in meds) checkSeason(context, med.displayName, med.med.id, dtsByMed[med.med.id].orEmpty(), today)
// Post-or-cancel: cancelling when the condition clears is what lets the // Post-or-cancel: cancelling when the condition clears is what lets the
// ongoing rx notification disappear by itself after a renewal. // ongoing rx notification disappear by itself after a renewal.
if (lowStock.isNotEmpty()) { if (lowStock.isNotEmpty()) {
@ -92,55 +79,4 @@ class SupplyCheckReceiver : BroadcastReceiver() {
Notifications.cancel(context, Notifications.RX_NOTIFICATION_ID) Notifications.cancel(context, Notifications.RX_NOTIFICATION_ID)
} }
} }
/**
* Per-med seasonal nudge, post-or-cancel like the digests above:
* - resting and within [SEASON_START_LEAD_DAYS] of the next window "Start nå";
* - in season and within [SEASON_END_LEAD_DAYS] of its close "Forleng".
* Acting on either (or the season simply passing) clears it the next day.
*/
private fun checkSeason(
context: Context,
name: String,
medId: Long,
doseTimes: List<no.naiv.meddetsamme.data.DoseTime>,
today: LocalDate,
) {
val notifId = Notifications.seasonNotificationId(medId)
if (!ScheduleEngine.isSeasonal(doseTimes)) {
Notifications.cancel(context, notifId)
return
}
if (!ScheduleEngine.inSeasonNow(doseTimes, today)) {
val start = ScheduleEngine.nextSeasonStart(doseTimes, today)
val days = start?.let { ChronoUnit.DAYS.between(today, it) }
if (start != null && days != null && days in 0..SEASON_START_LEAD_DAYS) {
Notifications.post(
context, notifId,
Notifications.buildSeasonNotification(
context, medId, name,
"Sesongen nærmer seg (fra ${ScheduleText.dateLabel(start)}). Vil du starte nå?",
starting = true,
),
)
} else {
Notifications.cancel(context, notifId)
}
} else {
val end = ScheduleEngine.currentSeasonEnd(doseTimes, today)
val days = end?.let { ChronoUnit.DAYS.between(today, it) }
if (end != null && days != null && days in 0..SEASON_END_LEAD_DAYS) {
Notifications.post(
context, notifId,
Notifications.buildSeasonNotification(
context, medId, name,
"Sesongen er snart over (${ScheduleText.dateLabel(end)}). Fortsatt plaget? Forleng to uker.",
starting = false,
),
)
} else {
Notifications.cancel(context, notifId)
}
}
}
} }

View file

@ -162,9 +162,6 @@ data class DoseTimeDto(
val cycleLengthDays: Int = 0, val cycleLengthDays: Int = 0,
val startEpochDay: Long? = null, val startEpochDay: Long? = null,
val endEpochDay: Long? = null, val endEpochDay: Long? = null,
// Added in schema v4; defaults keep older backup files parsing.
val seasonStartMmdd: Int = 0,
val seasonEndMmdd: Int = 0,
) )
@Serializable @Serializable
@ -204,13 +201,11 @@ private fun MedicationDto.toEntity() = Medication(
private fun DoseTime.toDto() = DoseTimeDto( private fun DoseTime.toDto() = DoseTimeDto(
id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay, id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay,
cycleActiveDays, cycleLengthDays, startEpochDay, endEpochDay, cycleActiveDays, cycleLengthDays, startEpochDay, endEpochDay,
seasonStartMmdd, seasonEndMmdd,
) )
private fun DoseTimeDto.toEntity() = DoseTime( private fun DoseTimeDto.toEntity() = DoseTime(
id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay, id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay,
cycleActiveDays, cycleLengthDays, startEpochDay, endEpochDay, cycleActiveDays, cycleLengthDays, startEpochDay, endEpochDay,
seasonStartMmdd, seasonEndMmdd,
) )
private fun DoseLog.toDto() = DoseLogDto(id, medId, doseTimeId, scheduledAtMillis, amount, status.name, actionedAtMillis, nagCount) private fun DoseLog.toDto() = DoseLogDto(id, medId, doseTimeId, scheduledAtMillis, amount, status.name, actionedAtMillis, nagCount)

View file

@ -16,19 +16,14 @@ import androidx.room.PrimaryKey
* - otherwise weekly pattern from [daysOfWeekMask], bit 0 = Sunday bit 6 = * - otherwise weekly pattern from [daysOfWeekMask], bit 0 = Sunday bit 6 =
* Saturday. 0x7F = daily. * Saturday. 0x7F = daily.
* *
* Three orthogonal filters layer on top (the brief's "cyclic/taper extend from * Two orthogonal filters layer on top (the brief's "cyclic/taper extend from
* the same model"): * the same model"):
* - **Cyclic** ([cycleLengthDays] > 0): the dose only occurs in the first * - **Cyclic** ([cycleLengthDays] > 0): the dose only occurs in the first
* [cycleActiveDays] of each [cycleLengthDays]-day cycle counted from * [cycleActiveDays] of each [cycleLengthDays]-day cycle counted from
* [anchorEpochDay] e.g. 21-on/7-off contraception is 21/28. * [anchorEpochDay] e.g. 21-on/7-off contraception is 21/28.
* - **Validity window** ([startEpochDay]/[endEpochDay], inclusive): the dose * - **Validity window** ([startEpochDay]/[endEpochDay], inclusive): the dose
* only occurs within the date range. A taper is several rows with descending * only occurs within the date range. A taper is several rows with descending
* [amount] and adjacent windows. Also used as a one-year override of the * [amount] and adjacent windows.
* seasonal window (see [seasonStartMmdd]).
* - **Seasonal window** ([seasonStartMmdd]/[seasonEndMmdd]): a calendar window
* that repeats *every year* e.g. an allergy med for the pollen season.
* Encoded as month×100+day (0301 = 1 Mar). Both 0 = no season. A start after
* the end wraps the new year (e.g. a winter season NovFeb).
*/ */
@Entity( @Entity(
tableName = "dose_time", tableName = "dose_time",
@ -65,8 +60,4 @@ data class DoseTime(
val startEpochDay: Long? = null, val startEpochDay: Long? = null,
/** Inclusive validity-window end; null = no upper bound. Taper rows set this. */ /** Inclusive validity-window end; null = no upper bound. Taper rows set this. */
val endEpochDay: Long? = null, val endEpochDay: Long? = null,
/** Yearly season start as month×100+day (0301 = 1 Mar). 0 = no season. */
@ColumnInfo(defaultValue = "0") val seasonStartMmdd: Int = 0,
/** Yearly season end as month×100+day (0531 = 31 May). 0 = no season. */
@ColumnInfo(defaultValue = "0") val seasonEndMmdd: Int = 0,
) )

View file

@ -17,7 +17,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
*/ */
@Database( @Database(
entities = [InventoryItem::class, Medication::class, DoseTime::class, DoseLog::class], entities = [InventoryItem::class, Medication::class, DoseTime::class, DoseLog::class],
version = 4, version = 3,
exportSchema = true, exportSchema = true,
) )
abstract class MedDatabase : RoomDatabase() { abstract class MedDatabase : RoomDatabase() {
@ -92,24 +92,13 @@ abstract class MedDatabase : RoomDatabase() {
} }
} }
/**
* v3 v4: yearly seasonal-window columns on dose_time. Additive ALTERs
* defaulting to 0 ("no season") existing rows keep behaving as before.
*/
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE dose_time ADD COLUMN `seasonStartMmdd` INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE dose_time ADD COLUMN `seasonEndMmdd` INTEGER NOT NULL DEFAULT 0")
}
}
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",
).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4).build().also { instance = it } ).addMigrations(MIGRATION_1_2, MIGRATION_2_3).build().also { instance = it }
} }
} }
} }

View file

@ -20,9 +20,8 @@ import no.naiv.meddetsamme.data.DoseTime
* - intervalDays > 1 every N days counted from anchorEpochDay; mask ignored. * - intervalDays > 1 every N days counted from anchorEpochDay; mask ignored.
* - otherwise weekly daysOfWeekMask, bit 0 = Sunday bit 6 = Saturday. * - otherwise weekly daysOfWeekMask, bit 0 = Sunday bit 6 = Saturday.
* Mask 0 selects nothing: no occurrences, never an error. * Mask 0 selects nothing: no occurrences, never an error.
* - cyclic ([cycleLengthDays] > 0), validity window ([startEpochDay]/ * - cyclic ([cycleLengthDays] > 0) and validity window ([startEpochDay]/
* [endEpochDay]) and yearly season ([seasonStartMmdd]/[seasonEndMmdd]) * [endEpochDay]) further restrict the above see [occursOn].
* further restrict the above see [occursOn].
*/ */
object ScheduleEngine { object ScheduleEngine {
@ -54,64 +53,14 @@ object ScheduleEngine {
return Math.floorMod(day - anchor, length.toLong()) < active return Math.floorMod(day - anchor, length.toLong()) < active
} }
private fun isSeasonal(doseTime: DoseTime): Boolean = /** Does this dose-time occur on [date]? Base rule ∧ validity window ∧ cycle. */
doseTime.seasonStartMmdd > 0 && doseTime.seasonEndMmdd > 0 fun occursOn(doseTime: DoseTime, date: LocalDate): Boolean {
/**
* Is [date] inside this dose-time's *nominal* yearly season window? A season
* is a pair of month×100+day marks that repeat every year. A start after the
* end wraps the new year, e.g. a winter season 11010228 is in season in
* December *and* in February. Assumes the row is seasonal (caller checks).
*/
private fun inSeason(doseTime: DoseTime, date: LocalDate): Boolean {
val start = doseTime.seasonStartMmdd
val end = doseTime.seasonEndMmdd
val mmdd = date.monthValue * 100 + date.dayOfMonth
return if (start <= end) mmdd in start..end else mmdd >= start || mmdd <= end
}
/** The month×100+day mark as a real date in [year] (leap-safe: 0229 → 28 Feb off-years). */
private fun mmddToDate(mmdd: Int, year: Int): LocalDate =
java.time.MonthDay.of(mmdd / 100, mmdd % 100).atYear(year)
/**
* Whether [date] falls in the date bounds of this dose-time. For a **taper /
* course** row [startEpochDay]/[endEpochDay] are hard bounds (a finite
* window). For a **seasonal** row they instead *extend* the current year's
* season: [startEpochDay] pulls the start earlier ("Start nå"), [endEpochDay]
* pushes the end later ("Forleng"). Because the overrides are absolute days,
* they apply to exactly one season instance and are ignored in later years.
*/
private fun withinDateBounds(doseTime: DoseTime, date: LocalDate): Boolean {
if (!isSeasonal(doseTime)) {
val day = date.toEpochDay()
if (doseTime.startEpochDay != null && day < doseTime.startEpochDay) return false
if (doseTime.endEpochDay != null && day > doseTime.endEpochDay) return false
return true
}
if (inSeason(doseTime, date)) return true
val day = date.toEpochDay() val day = date.toEpochDay()
// Early-start bridge: [override start, this year's nominal start) — inSeason continues from there. doseTime.startEpochDay?.let { if (day < it) return false }
doseTime.startEpochDay?.let { os -> doseTime.endEpochDay?.let { if (day > it) return false }
val d = LocalDate.ofEpochDay(os) return baseOccursOn(doseTime, date) && inCycleWindow(doseTime, date)
var nominalStart = mmddToDate(doseTime.seasonStartMmdd, d.year)
if (nominalStart.toEpochDay() < os) nominalStart = mmddToDate(doseTime.seasonStartMmdd, d.year + 1)
if (day in os until nominalStart.toEpochDay()) return true
}
// Extend bridge: (this year's nominal end, override end] — inSeason covers up to the end.
doseTime.endEpochDay?.let { oe ->
val d = LocalDate.ofEpochDay(oe)
var nominalEnd = mmddToDate(doseTime.seasonEndMmdd, d.year)
if (nominalEnd.toEpochDay() > oe) nominalEnd = mmddToDate(doseTime.seasonEndMmdd, d.year - 1)
if (day in (nominalEnd.toEpochDay() + 1)..oe) return true
}
return false
} }
/** Does this dose-time occur on [date]? Date bounds ∧ base rule ∧ cycle. */
fun occursOn(doseTime: DoseTime, date: LocalDate): Boolean =
withinDateBounds(doseTime, date) && baseOccursOn(doseTime, date) && inCycleWindow(doseTime, date)
/** /**
* Earliest occurrence strictly after [after], or null if the schedule never * Earliest occurrence strictly after [after], or null if the schedule never
* fires within the search horizon (empty mask / missing anchor / window * fires within the search horizon (empty mask / missing anchor / window
@ -133,43 +82,6 @@ object ScheduleEngine {
return null return null
} }
/** True if any row is bounded to a yearly season window (an allergy med, say). */
fun isSeasonal(doseTimes: List<DoseTime>): Boolean = doseTimes.any { isSeasonal(it) }
/** Is any seasonal row of this med active on [today] (nominal window or an override)? */
fun inSeasonNow(doseTimes: List<DoseTime>, today: LocalDate): Boolean =
doseTimes.any { isSeasonal(it) && withinDateBounds(it, today) }
/**
* The date a resting seasonal med next enters its window the season start,
* not the first dose (which the weekday mask could push later). Null if the
* med isn't seasonal or never re-opens within the horizon. Scans the tested
* [withinDateBounds] so override/wrap behaviour stays in one place.
*/
fun nextSeasonStart(doseTimes: List<DoseTime>, today: LocalDate): LocalDate? {
val rows = doseTimes.filter { isSeasonal(it) }
if (rows.isEmpty()) return null
var date = today
repeat(SEARCH_HORIZON_DAYS) {
if (rows.any { withinDateBounds(it, date) }) return date
date = date.plusDays(1)
}
return null
}
/** The last date of the in-season stretch [today] sits in, or null if resting. */
fun currentSeasonEnd(doseTimes: List<DoseTime>, today: LocalDate): LocalDate? {
val active = doseTimes.filter { isSeasonal(it) && withinDateBounds(it, today) }
if (active.isEmpty()) return null
var last = today
repeat(SEARCH_HORIZON_DAYS) {
val next = last.plusDays(1)
if (active.none { withinDateBounds(it, next) }) return last
last = next
}
return last
}
/** Earliest next occurrence across all of a med's dose-times (what to arm). */ /** Earliest next occurrence across all of a med's dose-times (what to arm). */
fun nextOccurrence(doseTimes: List<DoseTime>, after: LocalDateTime): Pair<DoseTime, LocalDateTime>? = fun nextOccurrence(doseTimes: List<DoseTime>, after: LocalDateTime): Pair<DoseTime, LocalDateTime>? =
doseTimes doseTimes
@ -191,10 +103,10 @@ object ScheduleEngine {
* not historical, because days-of-supply is a forward prediction. * not historical, because days-of-supply is a forward prediction.
*/ */
fun dailyConsumption(doseTimes: List<DoseTime>, today: LocalDate): Double { fun dailyConsumption(doseTimes: List<DoseTime>, today: LocalDate): Double {
val day = today.toEpochDay()
return doseTimes.sumOf { dt -> return doseTimes.sumOf { dt ->
// One predicate for taper windows AND seasonal windows: off-window rows if (dt.startEpochDay != null && day < dt.startEpochDay) return@sumOf 0.0
// (an elapsed taper step, an out-of-season allergy med) consume nothing. if (dt.endEpochDay != null && day > dt.endEpochDay) return@sumOf 0.0
if (!withinDateBounds(dt, today)) return@sumOf 0.0
var rate = if (dt.intervalDays > 1) { var rate = if (dt.intervalDays > 1) {
if (dt.anchorEpochDay == null) 0.0 else dt.amount / dt.intervalDays if (dt.anchorEpochDay == null) 0.0 else dt.amount / dt.intervalDays
} else { } else {

View file

@ -20,7 +20,7 @@ object ScheduleText {
fun describe(doseTime: DoseTime): String = fun describe(doseTime: DoseTime): String =
"${"%02d:%02d".format(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)} " + "${"%02d:%02d".format(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)} " +
"${daysText(doseTime)}${cycleText(doseTime)}${windowText(doseTime)}${seasonText(doseTime)}" "${daysText(doseTime)}${cycleText(doseTime)}${windowText(doseTime)}"
/** " · syklus 21/28" when cyclic, else "". */ /** " · syklus 21/28" when cyclic, else "". */
fun cycleText(doseTime: DoseTime): String { fun cycleText(doseTime: DoseTime): String {
@ -41,21 +41,6 @@ object ScheduleText {
} }
} }
/** " · sesong 1. mar31. mai" for a yearly seasonal window (month×100+day). */
fun seasonText(doseTime: DoseTime): String {
val start = doseTime.seasonStartMmdd
val end = doseTime.seasonEndMmdd
if (start <= 0 || end <= 0) return ""
return " · sesong ${mmddText(start)}${mmddText(end)}"
}
/** Formats a month×100+day mark as "1. mar". A leap-year-safe MonthDay so 0229 renders. */
private fun mmddText(mmdd: Int): String =
java.time.MonthDay.of(mmdd / 100, mmdd % 100).format(dateFmt)
/** A single date as "1. mar" (nb-pinned), e.g. for "next season starts …". */
fun dateLabel(date: LocalDate): String = dateFmt.format(date)
fun daysText(doseTime: DoseTime): String { fun daysText(doseTime: DoseTime): String {
if (doseTime.intervalDays > 1) { if (doseTime.intervalDays > 1) {
return "(hver ${doseTime.intervalDays}. dag)" return "(hver ${doseTime.intervalDays}. dag)"

View file

@ -11,7 +11,6 @@ import androidx.core.app.NotificationManagerCompat
import no.naiv.meddetsamme.MainActivity 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.alarm.SeasonActionReceiver
import no.naiv.meddetsamme.data.DoseLog import no.naiv.meddetsamme.data.DoseLog
import no.naiv.meddetsamme.data.MedWithItem import no.naiv.meddetsamme.data.MedWithItem
import java.time.Instant import java.time.Instant
@ -29,9 +28,6 @@ object Notifications {
const val SUPPLY_NOTIFICATION_ID = 1_000_000_007 const val SUPPLY_NOTIFICATION_ID = 1_000_000_007
const val RX_NOTIFICATION_ID = 1_000_000_009 const val RX_NOTIFICATION_ID = 1_000_000_009
/** One season nudge per med, so its action button can target that med. */
fun seasonNotificationId(medId: Long): Int = (2_000_000_000L + medId).toInt()
private val timeFmt = DateTimeFormatter.ofPattern("HH:mm") private val timeFmt = DateTimeFormatter.ofPattern("HH:mm")
fun createChannels(context: Context) { fun createChannels(context: Context) {
@ -121,48 +117,6 @@ object Notifications {
.setOngoing(true) .setOngoing(true)
.build() .build()
/**
* Seasonal nudge for one med. [starting] = true is the pre-season "Start nå"
* card; false is the end-of-season "Forleng" card. Posted on the supply
* channel and dismissable acting on it (or the season simply passing)
* cancels it on the next daily check.
*/
fun buildSeasonNotification(
context: Context,
medId: Long,
title: String,
body: String,
starting: Boolean,
): Notification {
val notifId = seasonNotificationId(medId)
val action = if (starting) SeasonActionReceiver.ACTION_START_NOW else SeasonActionReceiver.ACTION_EXTEND
val actionLabel = if (starting) "Start nå" else "Forleng 2 uker"
val actionIntent = PendingIntent.getBroadcast(
context,
notifId,
Intent(context, SeasonActionReceiver::class.java)
.setAction(action)
.putExtra(SeasonActionReceiver.EXTRA_MED_ID, medId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val contentIntent = PendingIntent.getActivity(
context,
notifId,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(context, CHANNEL_SUPPLY)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setContentIntent(contentIntent)
.setAutoCancel(true)
.addAction(0, actionLabel, actionIntent)
.build()
}
private fun supplyDigestBuilder( private fun supplyDigestBuilder(
context: Context, context: Context,
requestCode: Int, requestCode: Int,

View file

@ -1,8 +1,6 @@
package no.naiv.meddetsamme.ui package no.naiv.meddetsamme.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
@ -10,7 +8,6 @@ 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.layout.width
import androidx.compose.foundation.clickable 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
@ -46,13 +43,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.time.LocalDate import java.time.LocalDate
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -462,131 +456,81 @@ private fun DoseTimeEditorDialog(
var offDays by remember { var offDays by remember {
mutableStateOf((if (existingCyclic) existing!!.cycleLengthDays - existing.cycleActiveDays else 7).toString()) mutableStateOf((if (existingCyclic) existing!!.cycleLengthDays - existing.cycleActiveDays else 7).toString())
} }
// Yearly season (e.g. pollen): both marks > 0 means seasonal. Default MarMay.
val existingSeasonal = (existing?.seasonStartMmdd ?: 0) > 0 && (existing?.seasonEndMmdd ?: 0) > 0
var seasonal by remember { mutableStateOf(existingSeasonal) }
var startMonth by remember { mutableStateOf(if (existingSeasonal) existing!!.seasonStartMmdd / 100 else 3) }
var startDay by remember { mutableStateOf((if (existingSeasonal) existing!!.seasonStartMmdd % 100 else 1).toString()) }
var endMonth by remember { mutableStateOf(if (existingSeasonal) existing!!.seasonEndMmdd / 100 else 5) }
var endDay by remember { mutableStateOf((if (existingSeasonal) existing!!.seasonEndMmdd % 100 else 31).toString()) }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(if (existing == null) "Ny doseringstid" else "Rediger doseringstid") }, title = { Text(if (existing == null) "Ny doseringstid" else "Rediger doseringstid") },
text = { text = {
// Scrollable: the dialog now has time + amount + mode + cycle + season // Scrollable: the dialog now has time + amount + mode + cycle and would
// and overflows on most screens. The time picker fills the first view, // otherwise overflow on small screens.
// so cyclic/season sit below the fold — hence the "scroll for more" hint. Column(
val schedScroll = rememberScrollState() modifier = Modifier.verticalScroll(rememberScrollState()),
Box { verticalArrangement = Arrangement.spacedBy(10.dp),
Column( ) {
modifier = Modifier.verticalScroll(schedScroll), TimePicker(state = timeState)
verticalArrangement = Arrangement.spacedBy(10.dp), OutlinedTextField(
) { value = amount, onValueChange = { amount = it },
TimePicker(state = timeState) label = { Text("Mengde per dose") }, singleLine = true,
OutlinedTextField( )
value = amount, onValueChange = { amount = it }, Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
label = { Text("Mengde per dose") }, singleLine = true, FilterChip(
selected = !intervalMode, onClick = { intervalMode = false },
label = { Text("Ukedager") },
) )
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip(
FilterChip( selected = intervalMode, onClick = { intervalMode = true },
selected = !intervalMode, onClick = { intervalMode = false }, label = { Text("Hver N. dag") },
label = { Text("Ukedager") }, )
) }
FilterChip( if (intervalMode) {
selected = intervalMode, onClick = { intervalMode = true }, OutlinedTextField(
label = { Text("Hver N. dag") }, value = intervalDays, onValueChange = { intervalDays = it },
) label = { Text("Antall dager mellom doser") }, singleLine = true,
} )
if (intervalMode) { } else {
OutlinedTextField( // Monday-first toggles; bit 0 = Sunday underneath. FlowRow wraps
value = intervalDays, onValueChange = { intervalDays = it }, // so the whole week is visible — a plain Row overflowed the dialog.
label = { Text("Antall dager mellom doser") }, singleLine = true, FlowRow(
) horizontalArrangement = Arrangement.spacedBy(4.dp),
} else {
// Monday-first toggles; bit 0 = Sunday underneath. FlowRow wraps
// so the whole week is visible — a plain Row overflowed the dialog.
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
listOf(1 to "man", 2 to "tir", 3 to "ons", 4 to "tor", 5 to "fre", 6 to "lør", 0 to "søn")
.forEach { (bit, label) ->
val selected = mask and (1 shl bit) != 0
FilterChip(
selected = selected,
onClick = { mask = mask xor (1 shl bit) },
label = { Text(label) },
modifier = Modifier.semantics {
contentDescription = dayName(bit) + if (selected) ", valgt" else ", ikke valgt"
},
)
}
}
}
HorizontalDivider()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { cyclic = !cyclic },
) { ) {
Checkbox(checked = cyclic, onCheckedChange = { cyclic = it }) listOf(1 to "man", 2 to "tir", 3 to "ons", 4 to "tor", 5 to "fre", 6 to "lør", 0 to "søn")
Text("Syklisk (på/av-perioder)") .forEach { (bit, label) ->
} val selected = mask and (1 shl bit) != 0
if (cyclic) { FilterChip(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { selected = selected,
OutlinedTextField( onClick = { mask = mask xor (1 shl bit) },
value = onDays, onValueChange = { onDays = it }, label = { Text(label) },
label = { Text("Dager på") }, singleLine = true, modifier = Modifier.weight(1f), modifier = Modifier.semantics {
) contentDescription = dayName(bit) + if (selected) ", valgt" else ", ikke valgt"
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,
)
}
HorizontalDivider()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { seasonal = !seasonal },
) {
Checkbox(checked = seasonal, onCheckedChange = { seasonal = it })
Text("Sesong (samme periode hvert år)")
}
if (seasonal) {
MonthDayRow("Fra", startMonth, startDay, { startMonth = it }, { startDay = it })
MonthDayRow("Til", endMonth, endDay, { endMonth = it }, { endDay = it })
Text(
"F.eks. pollensesong marsmai. Doseringen hviler utenom sesong " +
"og gjentas automatisk hvert år.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
// While anything is still below the fold, name what's down there so
// cyclic/season are discoverable; clears itself at the bottom. HorizontalDivider()
if (schedScroll.canScrollForward) { 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( Text(
"⌄ Bla ned for syklus og sesong", "F.eks. 21 på / 7 av. Syklusen starter i dag.",
modifier = Modifier style = MaterialTheme.typography.bodySmall,
.align(Alignment.BottomCenter) color = MaterialTheme.colorScheme.onSurfaceVariant,
.fillMaxWidth()
.background(
Brush.verticalGradient(
0f to Color.Transparent,
1f to MaterialTheme.colorScheme.surfaceContainerHigh,
),
)
.padding(top = 18.dp, bottom = 4.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
) )
} }
} }
@ -600,12 +544,6 @@ private fun DoseTimeEditorDialog(
val cycleActive = if (cyclic && on > 0 && off > 0) on else 0 val cycleActive = if (cyclic && on > 0 && off > 0) on else 0
val cycleLength = if (cycleActive > 0) on + off else 0 val cycleLength = if (cycleActive > 0) on + off else 0
val needsAnchor = intervalMode || cycleActive > 0 val needsAnchor = intervalMode || cycleActive > 0
// Clamp the day to the month's max (leap-safe: Feb → 29) so we never
// store an impossible MonthDay that would crash the renderer.
fun mmdd(month: Int, dayStr: String): Int {
val day = (dayStr.toIntOrNull() ?: 1).coerceIn(1, java.time.Month.of(month).maxLength())
return month * 100 + day
}
val dt = DoseTime( val dt = DoseTime(
id = existing?.id ?: 0, id = existing?.id ?: 0,
medId = medId, medId = medId,
@ -620,8 +558,6 @@ private fun DoseTimeEditorDialog(
cycleLengthDays = cycleLength, cycleLengthDays = cycleLength,
startEpochDay = existing?.startEpochDay, startEpochDay = existing?.startEpochDay,
endEpochDay = existing?.endEpochDay, endEpochDay = existing?.endEpochDay,
seasonStartMmdd = if (seasonal) mmdd(startMonth, startDay) else 0,
seasonEndMmdd = if (seasonal) mmdd(endMonth, endDay) else 0,
) )
scope.launch { scope.launch {
if (existing == null) db.doseTimeDao().insert(dt) else db.doseTimeDao().update(dt) if (existing == null) db.doseTimeDao().insert(dt) else db.doseTimeDao().update(dt)
@ -635,50 +571,3 @@ private fun DoseTimeEditorDialog(
private fun dayName(bit: Int): String = private fun dayName(bit: Int): String =
listOf("søndag", "mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag")[bit] listOf("søndag", "mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag")[bit]
private val MONTH_NAMES = listOf(
"januar", "februar", "mars", "april", "mai", "juni",
"juli", "august", "september", "oktober", "november", "desember",
)
/** Month dropdown + day field for one end of a yearly season window. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MonthDayRow(
label: String,
month: Int,
day: String,
onMonth: (Int) -> Unit,
onDay: (String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(label, modifier = Modifier.width(36.dp))
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = Modifier.weight(1f),
) {
OutlinedTextField(
value = MONTH_NAMES[month - 1],
onValueChange = {},
readOnly = true,
label = { Text("Måned") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
MONTH_NAMES.forEachIndexed { i, name ->
DropdownMenuItem(text = { Text(name) }, onClick = { onMonth(i + 1); expanded = false })
}
}
}
OutlinedTextField(
value = day,
onValueChange = onDay,
label = { Text("Dag") },
singleLine = true,
modifier = Modifier.width(88.dp),
)
}
}

View file

@ -21,10 +21,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -32,12 +29,8 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription 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 no.naiv.meddetsamme.R import no.naiv.meddetsamme.R
import no.naiv.meddetsamme.data.DoseTime
import no.naiv.meddetsamme.data.MedDatabase import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.data.MedWithItem
import no.naiv.meddetsamme.domain.ScheduleEngine
import no.naiv.meddetsamme.domain.ScheduleText import no.naiv.meddetsamme.domain.ScheduleText
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -47,20 +40,6 @@ fun MedListScreen(nav: Nav) {
val db = remember { MedDatabase.get(context) } val db = remember { MedDatabase.get(context) }
val meds by db.medicationDao().observeActive().collectAsState(initial = emptyList()) val meds by db.medicationDao().observeActive().collectAsState(initial = emptyList())
// Dose times aren't on MedWithItem, but we need them to tell a resting
// seasonal med (out of its yearly window) from a normally-active one.
var doseTimesByMed by remember { mutableStateOf<Map<Long, List<DoseTime>>>(emptyMap()) }
LaunchedEffect(meds) {
doseTimesByMed = meds.associate { it.med.id to db.doseTimeDao().getForMed(it.med.id) }
}
val today = LocalDate.now()
val restingIds = meds.filter { m ->
val dts = doseTimesByMed[m.med.id].orEmpty()
ScheduleEngine.isSeasonal(dts) && !ScheduleEngine.inSeasonNow(dts, today)
}.map { it.med.id }.toSet()
val activeMeds = meds.filterNot { it.med.id in restingIds }
val restingMeds = meds.filter { it.med.id in restingIds }
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@ -94,61 +73,29 @@ fun MedListScreen(nav: Nav) {
) )
} }
} }
items(activeMeds, key = { it.med.id }) { med -> items(meds, key = { it.med.id }) { med ->
MedCard( Card(
med = med, modifier = Modifier
subtitle = "Lager: ${ScheduleText.amountText(med.item.stockUnits, med.item.unit)}", .fillMaxWidth()
onClick = { nav.push(Screen.MedEdit(med.med.id)) }, .clickable { nav.push(Screen.MedEdit(med.med.id)) }
) .semantics(mergeDescendants = true) {
} contentDescription =
"${med.displayName}, ${FORM_LABELS[med.item.form]}, lager ${ScheduleText.amountText(med.item.stockUnits, med.item.unit)}. Trykk for å redigere."
if (restingMeds.isNotEmpty()) { },
item { ) {
Text( Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
"Sesong (hviler)", FormAvatar(med.item.form)
style = MaterialTheme.typography.titleSmall, Column(Modifier.padding(start = 12.dp).weight(1f)) {
color = MaterialTheme.colorScheme.onSurfaceVariant, Text(med.displayName, style = MaterialTheme.typography.titleMedium)
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp), Text(
) "Lager: ${ScheduleText.amountText(med.item.stockUnits, med.item.unit)}",
} style = MaterialTheme.typography.bodyMedium,
items(restingMeds, key = { it.med.id }) { med -> color = MaterialTheme.colorScheme.onSurfaceVariant,
val nextStart = ScheduleEngine.nextSeasonStart(doseTimesByMed[med.med.id].orEmpty(), today) )
val subtitle = nextStart }
?.let { "Hviler · neste sesong fra ${ScheduleText.dateLabel(it)}" } }
?: "Hviler utenom sesong"
MedCard(
med = med,
subtitle = subtitle,
onClick = { nav.push(Screen.MedEdit(med.med.id)) },
)
} }
} }
} }
} }
} }
/** One medication row: form avatar, name, and a one-line subtitle. */
@Composable
private fun MedCard(med: MedWithItem, subtitle: String, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.semantics(mergeDescendants = true) {
contentDescription =
"${med.displayName}, ${FORM_LABELS[med.item.form]}, $subtitle. Trykk for å redigere."
},
) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
FormAvatar(med.item.form)
Column(Modifier.padding(start = 12.dp).weight(1f)) {
Text(med.displayName, style = MaterialTheme.typography.titleMedium)
Text(
subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}

View file

@ -32,8 +32,6 @@ class ScheduleEngineTest {
cycleLengthDays: Int = 0, cycleLengthDays: Int = 0,
startEpochDay: Long? = null, startEpochDay: Long? = null,
endEpochDay: Long? = null, endEpochDay: Long? = null,
seasonStartMmdd: Int = 0,
seasonEndMmdd: Int = 0,
id: Long = 1, id: Long = 1,
) = DoseTime( ) = DoseTime(
id = id, id = id,
@ -47,8 +45,6 @@ class ScheduleEngineTest {
cycleLengthDays = cycleLengthDays, cycleLengthDays = cycleLengthDays,
startEpochDay = startEpochDay, startEpochDay = startEpochDay,
endEpochDay = endEpochDay, endEpochDay = endEpochDay,
seasonStartMmdd = seasonStartMmdd,
seasonEndMmdd = seasonEndMmdd,
) )
private fun at(date: LocalDate, hour: Int, minute: Int = 0): LocalDateTime = private fun at(date: LocalDate, hour: Int, minute: Int = 0): LocalDateTime =
@ -226,98 +222,6 @@ class ScheduleEngineTest {
assertTrue(ScheduleEngine.occurrencesOn(rows, wednesday.plusDays(6)).isEmpty()) assertTrue(ScheduleEngine.occurrencesOn(rows, wednesday.plusDays(6)).isEmpty())
} }
// ---- yearly season (allergy / pollen) --------------------------------
@Test
fun `season bounds occurrences within the calendar window each year`() {
// Pollen season 1 Mar 31 May, daily dose.
val dt = doseTime(seasonStartMmdd = 301, seasonEndMmdd = 531)
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 2, 28))) // day before
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 3, 1))) // start, inclusive
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 4, 15)))
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 5, 31))) // end, inclusive
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 6, 1))) // day after
// …and it repeats the next year, no edit needed.
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2027, 4, 15)))
}
@Test
fun `nextOccurrence off-season jumps to next season start`() {
val dt = doseTime(seasonStartMmdd = 301, seasonEndMmdd = 531)
// From 10 June 2026 (out of season) the next dose is 1 Mar 2027 at 08:00.
val next = ScheduleEngine.nextOccurrence(dt, at(wednesday, 12, 0))
assertEquals(at(LocalDate.of(2027, 3, 1), 8), next)
}
@Test
fun `season wrapping the new year is in season on both sides of december`() {
// Winter season 1 Nov 28 Feb (start > end → wraps).
val dt = doseTime(seasonStartMmdd = 1101, seasonEndMmdd = 228)
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 12, 15)))
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2027, 1, 10)))
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2027, 2, 28)))
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 7, 1)))
}
@Test
fun `season comparison is calendar-correct across month boundaries`() {
// 29 Feb (a real leap day) sits inside a FebMar season without special-casing.
val dt = doseTime(seasonStartMmdd = 201, seasonEndMmdd = 331)
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2028, 2, 29)))
}
@Test
fun `daily consumption is the active rate in season and zero out of season`() {
val dt = doseTime(amount = 1.0, seasonStartMmdd = 301, seasonEndMmdd = 531)
assertEquals(1.0, ScheduleEngine.dailyConsumption(listOf(dt), LocalDate.of(2026, 4, 15)), 1e-9)
assertEquals(0.0, ScheduleEngine.dailyConsumption(listOf(dt), wednesday), 1e-9) // June, off-season
}
// ---- seasonal one-year overrides ("Start nå" / "Forleng") ------------
@Test
fun `early-start override pulls this year's season earlier then expires`() {
// Season MarMay; "Start nå" on 25 Feb 2026 sets startEpochDay there.
val dt = doseTime(
seasonStartMmdd = 301, seasonEndMmdd = 531,
startEpochDay = LocalDate.of(2026, 2, 25).toEpochDay(),
)
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 2, 24))) // before override
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 2, 25))) // override start
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 2, 28))) // bridge to nominal
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 3, 1))) // nominal start
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 5, 31))) // nominal end
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 6, 1))) // no extend
// Next year: the absolute override is in the past, so the nominal window rules again.
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2027, 2, 25)))
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2027, 3, 1)))
}
@Test
fun `extend override pushes this year's season end later then expires`() {
val dt = doseTime(
seasonStartMmdd = 301, seasonEndMmdd = 531,
endEpochDay = LocalDate.of(2026, 6, 14).toEpochDay(),
)
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 5, 31))) // nominal end
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 6, 1))) // bridge past nominal end
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 6, 14))) // override end
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 6, 15)))
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2027, 6, 10))) // expired next year
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2027, 4, 1))) // nominal repeats
}
@Test
fun `an early-started season counts as in-season for consumption and the resting check`() {
val dt = doseTime(
amount = 1.0, seasonStartMmdd = 301, seasonEndMmdd = 531,
startEpochDay = LocalDate.of(2026, 2, 25).toEpochDay(),
)
assertEquals(1.0, ScheduleEngine.dailyConsumption(listOf(dt), LocalDate.of(2026, 2, 26)), 1e-9)
assertTrue(ScheduleEngine.inSeasonNow(listOf(dt), LocalDate.of(2026, 2, 26)))
assertFalse(ScheduleEngine.inSeasonNow(listOf(dt), LocalDate.of(2026, 1, 1))) // still resting
}
// ---- multiple dose-times -------------------------------------------- // ---- multiple dose-times --------------------------------------------
@Test @Test

View file

@ -32,18 +32,6 @@ class ScheduleTextTest {
assertEquals("22:30 (daglig)", ScheduleText.describe(dt(minute = 22 * 60 + 30))) assertEquals("22:30 (daglig)", ScheduleText.describe(dt(minute = 22 * 60 + 30)))
} }
@Test
fun `seasonal window reads as a calendar range`() {
val pollen = DoseTime(
medId = 1, minuteOfDay = 480, amount = 1.0, anchorEpochDay = 0,
seasonStartMmdd = 301, seasonEndMmdd = 531,
)
// nb abbreviates March to "mar." (trailing dot); May is already short.
assertEquals("08:00 (daglig) · sesong 1. mar.31. mai", ScheduleText.describe(pollen))
// No season set → no suffix.
assertEquals("", ScheduleText.seasonText(dt()))
}
@Test @Test
fun `amounts use norwegian decimal comma and drop trailing zeroes`() { fun `amounts use norwegian decimal comma and drop trailing zeroes`() {
assertEquals("1 tablett", ScheduleText.amountText(1.0, "tablett")) assertEquals("1 tablett", ScheduleText.amountText(1.0, "tablett"))