Compare commits

...

5 commits

Author SHA1 Message Date
041f9e2cf6 Release 0.6.0: bump versionCode 6 / versionName 0.6.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:43:13 +02:00
44ea4ed87b Reminders: one grouped card for co-timed meds, acked together
Meds due at the same wall-clock minute now share a single notification
instead of one card each — they resolve to an identical scheduledAtMillis,
which becomes the occurrence (and notification) key. Per the feature
request: "Tatt alle" / "Utsett alle" handle the whole morning batch in
one tap; a single med just renders as a normal card with "Tatt".

- Notification id is per-occurrence (scheduledAtMillis), not per-log, so
  co-timed doses collapse onto one card and re-nags replace it.
- DoseActionReceiver buttons carry the occurrence and call group ops
  (takeAll/snoozeAll/muteAll); DoseActions keeps a single per-dose state
  mutation (takeState/snoozeState/muteState) reused by both the group ops
  and the app's per-dose actions, so the two surfaces can't diverge.
- The card is rebuilt from getActiveAtOccurrence: still-unresolved doses
  with nagCount <= cap. "Ikke forstyrr" now sets nagCount above the cap so
  a muted dose drops off the card, while a naturally-capped dose (== cap)
  stays visible and actionable.
- refreshOccurrence re-renders silently after a single in-app action and
  never creates a card for a not-yet-due occurrence.

Adds DoseActionsGroupTest (instrumented) covering takeAll, muteAll, and
the muted-vs-capped distinction at the data layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:28:18 +02:00
778a6aec27 Reminders: global repeat toggle + per-dose "Ikke forstyrr"
Two ways to stop the every-10-min nag, per feature request:

- A global "Gjenta påminnelser" switch in Settings. Off → a due dose
  alerts once and goes quiet; only the automatic re-nag chain is gated,
  so an explicit "Utsett 15 min" still gives one more reminder. The gate
  sits at the three automatic arm points (fireOccurrence, escalate
  re-arm, armAll resume-after-reboot), never on Snooze.

- An "Ikke forstyrr" action that appears from the second reminder on
  (nagCount >= 1). It stops nagging this one dose but leaves it PENDING
  so it can still be taken later — distinct from Skip. Implemented by
  jumping to the existing terminal state (nagCount = NAG_CAP) rather than
  a new column, so it needs no migration and survives reboot for free
  (armAll re-arms only nagCount < cap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:16:16 +02:00
5b9a3d6877 Release 0.5.0: bump versionCode 5 / versionName 0.5.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:09:46 +02:00
d81a4fd926 Seasonal (yearly) dosing for allergy/pollen meds
Add a recurring month-day season window to DoseTime (seasonStartMmdd/
seasonEndMmdd): a med rests outside its window and re-arms automatically
every year. The pure ScheduleEngine gains a season filter plus one-year
overrides -- startEpochDay/endEpochDay act as early-start/extend brackets
for seasonal rows while staying hard bounds for tapers.

- Daily SupplyCheck nudges before a season opens (Start naa) and before it
  closes (Forleng), handled by the new SeasonActionReceiver.
- Med list lists resting seasonal meds in a "Sesong (hviler)" section.
- Dose-time editor gains a season block and a "scroll for more" hint so
  cyclic/season below the time picker are discoverable.
- Room migration v3->v4 (additive), backup DTO extended, engine/text tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:09:46 +02:00
24 changed files with 1470 additions and 169 deletions

View file

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

View file

@ -0,0 +1,389 @@
{
"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,6 +108,35 @@ 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 {
const val TEST_DB = "migration-test.db"
}

View file

@ -0,0 +1,104 @@
package no.naiv.meddetsamme.domain
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import no.naiv.meddetsamme.alarm.AlarmScheduler
import no.naiv.meddetsamme.data.DoseLog
import no.naiv.meddetsamme.data.DoseStatus
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 org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* The grouped-notification contract at the data layer: co-timed doses (an
* identical scheduledAtMillis) form one occurrence, "Tatt alle" resolves them
* together, and "Ikke forstyrr alle" drops them off the card without marking
* them skipped. Drives DoseActions directly the single Taken/Snooze/Skip path
* since the notification surface itself can't be driven deterministically.
*/
@RunWith(AndroidJUnit4::class)
class DoseActionsGroupTest {
private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
private val db get() = MedDatabase.get(context)
private val actions = DoseActions(context)
/** A fixed PAST instant both doses share — that shared value *is* the group. */
private val occurrence = 1_600_000_000_000L
@Before fun clear() = runBlocking { db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList()) }
@After fun cleanup() = runBlocking { db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList()) }
/** One med + one dose log due at [occurrence]; returns (logId, itemId). */
private suspend fun seedDose(name: String, stock: Double, amount: Double): Pair<Long, Long> {
val itemId = db.inventoryDao().insert(
InventoryItem(name = name, strength = "", unit = "tablett", form = MedForm.TABLET, stockUnits = stock),
)
val medId = db.medicationDao().insert(Medication(itemId = itemId))
val doseTimeId = db.doseTimeDao().insert(DoseTime(medId = medId, minuteOfDay = 480, amount = amount))
val logId = db.doseLogDao().insert(
DoseLog(medId = medId, doseTimeId = doseTimeId, scheduledAtMillis = occurrence, amount = amount),
)
return logId to itemId
}
private suspend fun active() =
db.doseLogDao().getActiveAtOccurrence(occurrence, AlarmScheduler.NAG_CAP)
@Test fun takeAllResolvesEveryCoTimedDoseAndDecrementsEach() = runBlocking {
val (logA, _) = seedDose("A", stock = 10.0, amount = 1.0)
val (logB, _) = seedDose("B", stock = 20.0, amount = 2.0)
assertEquals("both due at the same instant are one occurrence", 2, active().size)
actions.takeAll(occurrence)
assertEquals(DoseStatus.TAKEN, db.doseLogDao().getById(logA)!!.status)
assertEquals(DoseStatus.TAKEN, db.doseLogDao().getById(logB)!!.status)
assertTrue("nothing left to nag", active().isEmpty())
val stock = db.inventoryDao().getAll().associate { it.name to it.stockUnits }
assertEquals(9.0, stock["A"]!!, 1e-9)
assertEquals(18.0, stock["B"]!!, 1e-9)
}
@Test fun muteAllStopsNaggingButKeepsDosesPending() = runBlocking {
seedDose("A", 10.0, 1.0)
seedDose("B", 20.0, 1.0)
actions.muteAll(occurrence)
val logs = db.doseLogDao().getRange(occurrence, occurrence + 1)
assertTrue("muted doses stay unresolved, not skipped", logs.all { it.status == DoseStatus.PENDING })
assertTrue("nagCount above the cap = won't re-nag, won't resume on reboot", logs.all { it.nagCount > AlarmScheduler.NAG_CAP })
assertTrue("muted doses drop off the card", active().isEmpty())
}
@Test fun mutedDoseLeavesCoTimedSiblingActive() = runBlocking {
val (logA, _) = seedDose("A", 10.0, 1.0)
val (logB, _) = seedDose("B", 10.0, 1.0)
// Mute only A (its nagCount above the cap); B is untouched.
db.doseLogDao().setNagCount(logA, AlarmScheduler.NAG_CAP + 1)
val remaining = active()
assertEquals(1, remaining.size)
assertEquals("B still nags; A is silenced", logB, remaining.single().id)
}
@Test fun naturallyCappedDoseStaysActionableOnCard() = runBlocking {
val (logA, _) = seedDose("A", 10.0, 1.0)
// Reached the nag cap on its own — still shown (== cap), unlike a mute.
db.doseLogDao().setNagCount(logA, AlarmScheduler.NAG_CAP)
assertEquals(1, active().size)
}
}

View file

@ -63,6 +63,7 @@
<receiver android:name=".alarm.DoseAlarmReceiver" android:exported="false" />
<receiver android:name=".alarm.DoseActionReceiver" 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
from the DB on boot and after app update. Both actions are protected

View file

@ -90,7 +90,9 @@ class AlarmScheduler(private val context: Context) {
// Resume nagging for doses that were unresolved when the device died —
// but only PAST-DUE ones. An unresolved log can also be a future dose
// (e.g. "Ta nå" + "Angre" before its time); its own occurrence alarm
// handles it when it actually falls due.
// handles it when it actually falls due. Resuming the nag chain is the
// automatic repeat the setting governs, so honour it here too.
if (!no.naiv.meddetsamme.settings.SettingsStore(context).repeatReminders) return
val now = System.currentTimeMillis()
db.doseLogDao().getUnresolved().forEach { log ->
if (log.nagCount < NAG_CAP && log.scheduledAtMillis <= now) {

View file

@ -9,42 +9,41 @@ import kotlinx.coroutines.launch
import no.naiv.meddetsamme.domain.DoseActions
/**
* Handles the notification actions (and later, the same actions from the UI).
* Handles the notification actions. The dose card groups every med due at the
* same wall-clock minute, so its buttons act on the whole occurrence (carried
* as EXTRA_SCHEDULED_AT), not a single log "Tatt alle" / "Utsett alle".
*
* Contract (CLAUDE.md, load-bearing):
* Contract (CLAUDE.md, load-bearing), applied to each dose in the occurrence:
* - Taken TAKEN, decrement inventory by the *logged* amount, stop escalating.
* - Snooze SNOOZED, next nag in 15 min, keeps escalating (and counting) after.
* - Skip SKIPPED, stop escalating, no inventory change.
* - Mute "Ikke forstyrr": stop escalating, dose stays unresolved.
* (Skip stays a per-dose UI action; the notification never offers it.)
*/
class DoseActionReceiver : BroadcastReceiver() {
companion object {
const val ACTION_TAKEN = "no.naiv.meddetsamme.action.TAKEN"
const val ACTION_SNOOZE = "no.naiv.meddetsamme.action.SNOOZE"
const val ACTION_SKIP = "no.naiv.meddetsamme.action.SKIP"
const val EXTRA_LOG_ID = "logId"
const val ACTION_MUTE = "no.naiv.meddetsamme.action.MUTE"
const val EXTRA_SCHEDULED_AT = "scheduledAt"
}
override fun onReceive(context: Context, intent: Intent) {
val logId = intent.getLongExtra(EXTRA_LOG_ID, -1)
if (logId < 0) return
val scheduledAt = intent.getLongExtra(EXTRA_SCHEDULED_AT, -1)
if (scheduledAt < 0) return
val action = intent.action ?: return
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
handle(context, action, logId)
val actions = DoseActions(context)
when (action) {
ACTION_TAKEN -> actions.takeAll(scheduledAt)
ACTION_SNOOZE -> actions.snoozeAll(scheduledAt)
ACTION_MUTE -> actions.muteAll(scheduledAt)
}
} finally {
pending.finish()
}
}
}
private suspend fun handle(context: Context, action: String, logId: Long) {
val actions = DoseActions(context)
when (action) {
ACTION_TAKEN -> actions.take(logId)
ACTION_SNOOZE -> actions.snooze(logId)
ACTION_SKIP -> actions.skip(logId)
}
}
}

View file

@ -80,12 +80,12 @@ class DoseAlarmReceiver : BroadcastReceiver() {
else -> existing
}
Notifications.post(
context,
Notifications.doseNotificationId(log.id),
Notifications.buildDoseNotification(context, med, log),
)
if (log.nagCount < AlarmScheduler.NAG_CAP) scheduler.armEscalation(log.id)
postOccurrence(context, db, scheduledAt)
// Start the automatic re-nag chain only if the user wants repeats; an
// explicit Snooze still re-arms regardless (DoseActions.snooze).
if (repeatReminders(context) && log.nagCount < AlarmScheduler.NAG_CAP) {
scheduler.armEscalation(log.id)
}
}
private suspend fun escalate(context: Context, logId: Long) {
@ -98,15 +98,36 @@ class DoseAlarmReceiver : BroadcastReceiver() {
// depth against any path that arms one too early).
if (log.scheduledAtMillis > System.currentTimeMillis()) return
val med = db.medicationDao().getById(log.medId) ?: return
val nagged = log.copy(nagCount = log.nagCount + 1)
db.doseLogDao().setNagCount(logId, nagged.nagCount)
Notifications.post(
context,
Notifications.doseNotificationId(logId),
Notifications.buildDoseNotification(context, med, nagged),
)
if (nagged.nagCount < AlarmScheduler.NAG_CAP) AlarmScheduler(context).armEscalation(logId)
// Re-post the whole occurrence so co-timed siblings stay on one card.
postOccurrence(context, db, log.scheduledAtMillis)
// Re-arm the next automatic nag only while repeats are enabled. A nag
// already in flight from a Snooze still fires once (this post), then
// stops here — Snooze stays a single deferral, not an endless chain.
if (repeatReminders(context) && nagged.nagCount < AlarmScheduler.NAG_CAP) {
AlarmScheduler(context).armEscalation(logId)
}
}
/**
* Post (or replace) the single grouped card for one wall-clock occurrence:
* every med due at that minute, still active (not taken/skipped/muted). If
* nothing's active it clears the card a redundant escalation that raced a
* "Tatt alle" then just tidies up.
*/
private suspend fun postOccurrence(context: Context, db: MedDatabase, scheduledAtMillis: Long) {
val active = db.doseLogDao().getActiveAtOccurrence(scheduledAtMillis, AlarmScheduler.NAG_CAP)
val id = Notifications.doseNotificationId(scheduledAtMillis)
val entries = active.mapNotNull { l -> db.medicationDao().getById(l.medId)?.let { it to l } }
if (entries.isEmpty()) {
Notifications.cancel(context, id)
return
}
Notifications.post(context, id, Notifications.buildDoseNotification(context, entries, scheduledAtMillis))
}
private fun repeatReminders(context: Context): Boolean =
no.naiv.meddetsamme.settings.SettingsStore(context).repeatReminders
}

View file

@ -0,0 +1,66 @@
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,11 +4,13 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import java.time.LocalDate
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.domain.ScheduleEngine
import no.naiv.meddetsamme.domain.ScheduleText
import no.naiv.meddetsamme.notify.Notifications
/**
@ -33,6 +35,12 @@ class SupplyCheckReceiver : BroadcastReceiver() {
private companion object {
/** "About a week in advance" for prescription renewal. */
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) {
@ -41,16 +49,19 @@ class SupplyCheckReceiver : BroadcastReceiver() {
val lowStock = 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
// drawing from it — one shared box drains faster than either alone.
val byItem = db.medicationDao().getActive().groupBy { it.item.id }
for ((_, meds) in byItem) {
val item = meds.first().item
for ((_, group) in meds.groupBy { it.item.id }) {
val item = group.first().item
var rate = 0.0
for (med in meds) rate += ScheduleEngine.dailyConsumption(db.doseTimeDao().getForMed(med.med.id), today)
for (med in group) rate += ScheduleEngine.dailyConsumption(dtsByMed[med.med.id].orEmpty(), 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)}"
lowStock += "${item.name}: lager for ${ScheduleText.daysCount(days?.toInt() ?: 0)}"
}
if (ScheduleEngine.needsRenewal(item.rxExpiryEpochDay, today, RX_LEAD_DAYS)) {
val expired = item.rxExpiryEpochDay!! < today.toEpochDay()
@ -58,6 +69,8 @@ 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
// ongoing rx notification disappear by itself after a renewal.
if (lowStock.isNotEmpty()) {
@ -79,4 +92,55 @@ class SupplyCheckReceiver : BroadcastReceiver() {
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,6 +162,9 @@ data class DoseTimeDto(
val cycleLengthDays: Int = 0,
val startEpochDay: 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
@ -201,11 +204,13 @@ private fun MedicationDto.toEntity() = Medication(
private fun DoseTime.toDto() = DoseTimeDto(
id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay,
cycleActiveDays, cycleLengthDays, startEpochDay, endEpochDay,
seasonStartMmdd, seasonEndMmdd,
)
private fun DoseTimeDto.toEntity() = DoseTime(
id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay,
cycleActiveDays, cycleLengthDays, startEpochDay, endEpochDay,
seasonStartMmdd, seasonEndMmdd,
)
private fun DoseLog.toDto() = DoseLogDto(id, medId, doseTimeId, scheduledAtMillis, amount, status.name, actionedAtMillis, nagCount)

View file

@ -22,6 +22,22 @@ interface DoseLogDao {
@Query("SELECT * FROM dose_log WHERE status IN ('PENDING', 'SNOOZED') ORDER BY scheduledAtMillis")
suspend fun getUnresolved(): List<DoseLog>
/**
* Co-timed doses to show on one card: same wall-clock occurrence, still
* unresolved, nagCount <= [maxNag]. Pass NAG_CAP so a naturally-exhausted
* dose (== cap) still shows and stays actionable, while "Ikke forstyrr"
* (which sets nagCount above the cap) drops out otherwise a muted dose
* would reappear via a co-timed sibling's re-nag.
*/
@Query(
"""SELECT * FROM dose_log
WHERE scheduledAtMillis = :scheduledAtMillis
AND status IN ('PENDING', 'SNOOZED')
AND nagCount <= :maxNag
ORDER BY id""",
)
suspend fun getActiveAtOccurrence(scheduledAtMillis: Long, maxNag: Int): List<DoseLog>
@Query("SELECT * FROM dose_log WHERE scheduledAtMillis >= :fromMillis AND scheduledAtMillis < :toMillis ORDER BY scheduledAtMillis")
fun observeRange(fromMillis: Long, toMillis: Long): Flow<List<DoseLog>>

View file

@ -16,14 +16,19 @@ import androidx.room.PrimaryKey
* - otherwise weekly pattern from [daysOfWeekMask], bit 0 = Sunday bit 6 =
* Saturday. 0x7F = daily.
*
* Two orthogonal filters layer on top (the brief's "cyclic/taper extend from
* Three 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.
* [amount] and adjacent windows. Also used as a one-year override of the
* 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(
tableName = "dose_time",
@ -60,4 +65,8 @@ data class DoseTime(
val startEpochDay: Long? = null,
/** Inclusive validity-window end; null = no upper bound. Taper rows set this. */
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(
entities = [InventoryItem::class, Medication::class, DoseTime::class, DoseLog::class],
version = 3,
version = 4,
exportSchema = true,
)
abstract class MedDatabase : RoomDatabase() {
@ -92,13 +92,24 @@ 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 =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
MedDatabase::class.java,
"med-det-samme.db",
).addMigrations(MIGRATION_1_2, MIGRATION_2_3).build().also { instance = it }
).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4).build().also { instance = it }
}
}
}

View file

@ -35,27 +35,44 @@ class DoseActions(private val context: Context) {
return db.doseLogDao().getById(id)!!
}
// ---- single-dose actions (the app's Today screen acts per dose) --------
suspend fun take(logId: Long) {
val log = db.doseLogDao().getById(logId) ?: return
if (log.status == DoseStatus.TAKEN) return // double-tap must not decrement twice
db.doseLogDao().setStatus(logId, DoseStatus.TAKEN, System.currentTimeMillis())
db.medicationDao().getRawById(log.medId)?.let {
db.inventoryDao().decrementStock(it.itemId, log.amount)
}
scheduler.cancelEscalation(logId)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
takeState(log)
refreshOccurrence(log.scheduledAtMillis)
}
suspend fun snooze(logId: Long) {
db.doseLogDao().setStatus(logId, DoseStatus.SNOOZED, System.currentTimeMillis())
scheduler.armEscalation(logId, delayMinutes = AlarmScheduler.SNOOZE_MINUTES)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
val log = db.doseLogDao().getById(logId) ?: return
snoozeState(log)
// Snooze dismisses now; the re-nag brings the card back in 15 min.
Notifications.cancel(context, Notifications.doseNotificationId(log.scheduledAtMillis))
}
suspend fun skip(logId: Long) {
val log = db.doseLogDao().getById(logId) ?: return
db.doseLogDao().setStatus(logId, DoseStatus.SKIPPED, System.currentTimeMillis())
scheduler.cancelEscalation(logId)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
refreshOccurrence(log.scheduledAtMillis)
}
// ---- occurrence (group) actions — the notification buttons -------------
// Every med due at the same wall-clock minute, handled in one tap.
suspend fun takeAll(scheduledAtMillis: Long) {
activeAt(scheduledAtMillis).forEach { takeState(it) }
refreshOccurrence(scheduledAtMillis)
}
suspend fun snoozeAll(scheduledAtMillis: Long) {
activeAt(scheduledAtMillis).forEach { snoozeState(it) }
Notifications.cancel(context, Notifications.doseNotificationId(scheduledAtMillis))
}
suspend fun muteAll(scheduledAtMillis: Long) {
activeAt(scheduledAtMillis).forEach { muteState(it) }
refreshOccurrence(scheduledAtMillis)
}
/** Undo a mis-tap: back to PENDING, restoring stock if it was TAKEN. */
@ -72,4 +89,62 @@ class DoseActions(private val context: Context) {
db.doseLogDao().setNagCount(logId, 0)
scheduler.cancelEscalation(logId)
}
// ---- shared state mutations (single source of truth per dose) ----------
private suspend fun takeState(log: DoseLog) {
if (log.status == DoseStatus.TAKEN) return // double-tap must not decrement twice
db.doseLogDao().setStatus(log.id, DoseStatus.TAKEN, System.currentTimeMillis())
db.medicationDao().getRawById(log.medId)?.let {
db.inventoryDao().decrementStock(it.itemId, log.amount)
}
scheduler.cancelEscalation(log.id)
}
private suspend fun snoozeState(log: DoseLog) {
db.doseLogDao().setStatus(log.id, DoseStatus.SNOOZED, System.currentTimeMillis())
scheduler.armEscalation(log.id, delayMinutes = AlarmScheduler.SNOOZE_MINUTES)
}
/**
* "Ikke forstyrr": stop nagging this dose but leave it unresolved the user
* takes over. Setting nagCount *above* the cap stops escalation, keeps
* armAll() from resuming it after a reboot (it re-arms only nagCount < cap),
* and drops it from the grouped card while a naturally-capped dose
* (== cap) stays visible and actionable. Status stays PENDING, so the dose
* can still be taken from the app later.
*/
private suspend fun muteState(log: DoseLog) {
if (log.status != DoseStatus.PENDING && log.status != DoseStatus.SNOOZED) return
db.doseLogDao().setNagCount(log.id, AlarmScheduler.NAG_CAP + 1)
scheduler.cancelEscalation(log.id)
}
private suspend fun activeAt(scheduledAtMillis: Long): List<DoseLog> =
db.doseLogDao().getActiveAtOccurrence(scheduledAtMillis, AlarmScheduler.NAG_CAP)
/**
* Re-render the single occurrence card to match DB state, or clear it when
* nothing's left to act on. Silent (no re-alert) this fires after the user
* already handled a dose, so buzzing for the untouched ones would be wrong.
* Never *creates* a card for a not-yet-due occurrence (taking tomorrow's
* dose early from the app must not post a reminder for today).
*/
private suspend fun refreshOccurrence(scheduledAtMillis: Long) {
val id = Notifications.doseNotificationId(scheduledAtMillis)
val active = activeAt(scheduledAtMillis)
if (active.isEmpty()) {
Notifications.cancel(context, id)
return
}
if (scheduledAtMillis > System.currentTimeMillis()) return // future: no card to refresh
val entries = active.mapNotNull { log ->
db.medicationDao().getById(log.medId)?.let { it to log }
}
if (entries.isEmpty()) {
Notifications.cancel(context, id)
return
}
Notifications.post(context, id, Notifications.buildDoseNotification(context, entries, scheduledAtMillis, alertAgain = false))
}
}

View file

@ -20,8 +20,9 @@ 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].
* - cyclic ([cycleLengthDays] > 0), validity window ([startEpochDay]/
* [endEpochDay]) and yearly season ([seasonStartMmdd]/[seasonEndMmdd])
* further restrict the above see [occursOn].
*/
object ScheduleEngine {
@ -53,14 +54,64 @@ object ScheduleEngine {
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)
private fun isSeasonal(doseTime: DoseTime): Boolean =
doseTime.seasonStartMmdd > 0 && doseTime.seasonEndMmdd > 0
/**
* 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()
// Early-start bridge: [override start, this year's nominal start) — inSeason continues from there.
doseTime.startEpochDay?.let { os ->
val d = LocalDate.ofEpochDay(os)
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
* fires within the search horizon (empty mask / missing anchor / window
@ -82,6 +133,43 @@ object ScheduleEngine {
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). */
fun nextOccurrence(doseTimes: List<DoseTime>, after: LocalDateTime): Pair<DoseTime, LocalDateTime>? =
doseTimes
@ -103,10 +191,10 @@ object ScheduleEngine {
* not historical, because days-of-supply is a forward prediction.
*/
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
// One predicate for taper windows AND seasonal windows: off-window rows
// (an elapsed taper step, an out-of-season allergy med) consume nothing.
if (!withinDateBounds(dt, today)) return@sumOf 0.0
var rate = if (dt.intervalDays > 1) {
if (dt.anchorEpochDay == null) 0.0 else dt.amount / dt.intervalDays
} else {

View file

@ -20,7 +20,7 @@ object ScheduleText {
fun describe(doseTime: DoseTime): String =
"${"%02d:%02d".format(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)} " +
"${daysText(doseTime)}${cycleText(doseTime)}${windowText(doseTime)}"
"${daysText(doseTime)}${cycleText(doseTime)}${windowText(doseTime)}${seasonText(doseTime)}"
/** " · syklus 21/28" when cyclic, else "". */
fun cycleText(doseTime: DoseTime): String {
@ -41,6 +41,21 @@ 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 {
if (doseTime.intervalDays > 1) {
return "(hver ${doseTime.intervalDays}. dag)"

View file

@ -11,6 +11,7 @@ import androidx.core.app.NotificationManagerCompat
import no.naiv.meddetsamme.MainActivity
import no.naiv.meddetsamme.R
import no.naiv.meddetsamme.alarm.DoseActionReceiver
import no.naiv.meddetsamme.alarm.SeasonActionReceiver
import no.naiv.meddetsamme.data.DoseLog
import no.naiv.meddetsamme.data.MedWithItem
import java.time.Instant
@ -22,12 +23,21 @@ object Notifications {
const val CHANNEL_DOSES = "doses"
const val CHANNEL_SUPPLY = "supply"
/** Stable per-occurrence notification id: every re-nag *replaces* the card. */
fun doseNotificationId(logId: Long): Int = (logId % Int.MAX_VALUE).toInt()
/**
* Stable per-OCCURRENCE notification id (minute resolution): every med due
* at the same wall-clock minute shares one card, and every re-nag replaces
* it. Keyed on scheduledAtMillis because co-timed doses resolve to an
* identical value that's the whole grouping mechanism.
*/
fun doseNotificationId(scheduledAtMillis: Long): Int =
((scheduledAtMillis / 60_000) % Int.MAX_VALUE).toInt()
const val SUPPLY_NOTIFICATION_ID = 1_000_000_007
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")
fun createChannels(context: Context) {
@ -55,37 +65,67 @@ object Notifications {
}
/**
* The dose reminder card. nagCount > 0 marks a re-nag same id, so it
* replaces the previous card but re-alerts (no setOnlyAlertOnce: re-alerting
* is the escalation).
* The dose reminder card for one occurrence. [entries] are all the meds due
* at the same wall-clock minute (one entry = a normal single card; several =
* a grouped card with "alle" actions). Actions carry the scheduledAtMillis,
* not a log id, so they act on the whole occurrence.
*
* [alertAgain] = false (setOnlyAlertOnce) is for silent in-place refreshes
* after a single dose is handled in the app re-alerting there would buzz
* for the doses you *didn't* touch. Fire and escalation leave it true:
* re-alerting is the escalation.
*/
fun buildDoseNotification(context: Context, med: MedWithItem, log: DoseLog): Notification {
val scheduled = Instant.ofEpochMilli(log.scheduledAtMillis).atZone(ZoneId.systemDefault())
val title = med.displayName
val body = buildString {
append("${formatAmount(log.amount)} ${med.item.unit} kl. ${timeFmt.format(scheduled)}")
if (med.med.withFood) append(" — tas med mat")
if (log.nagCount > 0) append(" (påminnelse ${log.nagCount})")
fun buildDoseNotification(
context: Context,
entries: List<Pair<MedWithItem, DoseLog>>,
scheduledAtMillis: Long,
alertAgain: Boolean = true,
): Notification {
require(entries.isNotEmpty()) { "Dose notification needs at least one med" }
val time = timeFmt.format(Instant.ofEpochMilli(scheduledAtMillis).atZone(ZoneId.systemDefault()))
val grouped = entries.size > 1
val maxNag = entries.maxOf { it.second.nagCount }
val nagSuffix = if (maxNag > 0) " (påminnelse $maxNag)" else ""
fun lineFor(med: MedWithItem, log: DoseLog) = buildString {
append("${med.displayName}: ${formatAmount(log.amount)} ${med.item.unit}")
if (med.med.withFood) append(" — med mat")
}
fun action(action: String): PendingIntent = PendingIntent.getBroadcast(
val title: String
val body: String
if (grouped) {
title = "${entries.size} medisiner kl. $time"
body = entries.joinToString(" · ") { (m, l) -> "${m.displayName} ${formatAmount(l.amount)} ${m.item.unit}" } + nagSuffix
} else {
val (m, l) = entries.first()
title = m.displayName
body = buildString {
append("${formatAmount(l.amount)} ${m.item.unit} kl. $time")
if (m.med.withFood) append(" — tas med mat")
append(nagSuffix)
}
}
fun action(actionStr: String): PendingIntent = PendingIntent.getBroadcast(
context,
// Distinct requestCode per (log, action); same (log, action) replaces.
(log.id * 10 + action.hashCode().mod(10)).toInt(),
// One requestCode per occurrence; the Intent action keeps the
// Tatt/Utsett/Ikke-forstyrr PendingIntents distinct (filterEquals).
doseNotificationId(scheduledAtMillis),
Intent(context, DoseActionReceiver::class.java)
.setAction(action)
.putExtra(DoseActionReceiver.EXTRA_LOG_ID, log.id),
.setAction(actionStr)
.putExtra(DoseActionReceiver.EXTRA_SCHEDULED_AT, scheduledAtMillis),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val contentIntent = PendingIntent.getActivity(
context,
doseNotificationId(log.id),
doseNotificationId(scheduledAtMillis),
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(context, CHANNEL_DOSES)
val builder = NotificationCompat.Builder(context, CHANNEL_DOSES)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
@ -94,9 +134,22 @@ object Notifications {
.setContentIntent(contentIntent)
.setAutoCancel(false)
.setOngoing(false)
.addAction(0, "Tatt", action(DoseActionReceiver.ACTION_TAKEN))
.addAction(0, "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE))
.build()
.setOnlyAlertOnce(!alertAgain)
.addAction(0, if (grouped) "Tatt alle" else "Tatt", action(DoseActionReceiver.ACTION_TAKEN))
.addAction(0, if (grouped) "Utsett alle" else "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE))
if (grouped) {
builder.setStyle(
NotificationCompat.InboxStyle()
.setBigContentTitle("Medisiner kl. $time$nagSuffix")
.also { s -> entries.forEach { (m, l) -> s.addLine(lineFor(m, l)) } },
)
}
// From the second reminder on, offer an escape hatch: stop nagging this
// occurrence without marking it skipped (the user takes over).
if (maxNag >= 1) {
builder.addAction(0, if (grouped) "Ikke forstyrr alle" else "Ikke forstyrr", action(DoseActionReceiver.ACTION_MUTE))
}
return builder.build()
}
/** Morning low-stock digest: plainly dismissable, re-posted daily while low. */
@ -117,6 +170,48 @@ object Notifications {
.setOngoing(true)
.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(
context: Context,
requestCode: Int,

View file

@ -55,6 +55,17 @@ class SettingsStore(context: Context) {
get() = prefs.getInt("supply_check_minute", 600)
set(v) = prefs.edit { putInt("supply_check_minute", v.coerceIn(0, 1439)) }
/**
* Whether a due dose re-nags automatically every ~10 min until resolved.
* Off the dose notifies once and goes quiet (no escalation chain). An
* explicit "Utsett 15 min" still re-arms one reminder regardless that's a
* user request, not the automatic repeat this gates. Default on: dropped
* doses are the failure mode this app exists to prevent.
*/
var repeatReminders: Boolean
get() = prefs.getBoolean("repeat_reminders", true)
set(v) = prefs.edit { putBoolean("repeat_reminders", v) }
/** S3/Garage credentials complete — gates the S3 upload path only. */
val isS3Configured: Boolean
get() = !s3Endpoint.isNullOrBlank() && !s3Bucket.isNullOrBlank() &&

View file

@ -1,6 +1,8 @@
package no.naiv.meddetsamme.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
@ -8,6 +10,7 @@ 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.layout.width
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@ -43,10 +46,13 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.res.painterResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import java.time.LocalDate
import kotlinx.coroutines.launch
@ -456,81 +462,131 @@ private fun DoseTimeEditorDialog(
var offDays by remember {
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(
onDismissRequest = onDismiss,
title = { Text(if (existing == null) "Ny doseringstid" else "Rediger doseringstid") },
text = {
// 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 },
label = { Text("Mengde per dose") }, singleLine = true,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = !intervalMode, onClick = { intervalMode = false },
label = { Text("Ukedager") },
)
FilterChip(
selected = intervalMode, onClick = { intervalMode = true },
label = { Text("Hver N. dag") },
)
}
if (intervalMode) {
OutlinedTextField(
value = intervalDays, onValueChange = { intervalDays = it },
label = { Text("Antall dager mellom doser") }, singleLine = true,
)
} 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 },
// Scrollable: the dialog now has time + amount + mode + cycle + season
// and overflows on most screens. The time picker fills the first view,
// so cyclic/season sit below the fold — hence the "scroll for more" hint.
val schedScroll = rememberScrollState()
Box {
Column(
modifier = Modifier.verticalScroll(schedScroll),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Checkbox(checked = cyclic, onCheckedChange = { cyclic = it })
Text("Syklisk (på/av-perioder)")
}
if (cyclic) {
TimePicker(state = timeState)
OutlinedTextField(
value = amount, onValueChange = { amount = it },
label = { Text("Mengde per dose") }, singleLine = true,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = onDays, onValueChange = { onDays = it },
label = { Text("Dager på") }, singleLine = true, modifier = Modifier.weight(1f),
FilterChip(
selected = !intervalMode, onClick = { intervalMode = false },
label = { Text("Ukedager") },
)
OutlinedTextField(
value = offDays, onValueChange = { offDays = it },
label = { Text("Dager av") }, singleLine = true, modifier = Modifier.weight(1f),
FilterChip(
selected = intervalMode, onClick = { intervalMode = true },
label = { Text("Hver N. dag") },
)
}
if (intervalMode) {
OutlinedTextField(
value = intervalDays, onValueChange = { intervalDays = it },
label = { Text("Antall dager mellom doser") }, singleLine = true,
)
} 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 })
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,
)
}
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.
if (schedScroll.canScrollForward) {
Text(
"F.eks. 21 på / 7 av. Syklusen starter i dag.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
"⌄ Bla ned for syklus og sesong",
modifier = Modifier
.align(Alignment.BottomCenter)
.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,
)
}
}
@ -544,6 +600,12 @@ private fun DoseTimeEditorDialog(
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
// 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(
id = existing?.id ?: 0,
medId = medId,
@ -558,6 +620,8 @@ private fun DoseTimeEditorDialog(
cycleLengthDays = cycleLength,
startEpochDay = existing?.startEpochDay,
endEpochDay = existing?.endEpochDay,
seasonStartMmdd = if (seasonal) mmdd(startMonth, startDay) else 0,
seasonEndMmdd = if (seasonal) mmdd(endMonth, endDay) else 0,
)
scope.launch {
if (existing == null) db.doseTimeDao().insert(dt) else db.doseTimeDao().update(dt)
@ -571,3 +635,50 @@ private fun DoseTimeEditorDialog(
private fun dayName(bit: Int): String =
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,7 +21,10 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@ -29,8 +32,12 @@ 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 java.time.LocalDate
import no.naiv.meddetsamme.R
import no.naiv.meddetsamme.data.DoseTime
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.data.MedWithItem
import no.naiv.meddetsamme.domain.ScheduleEngine
import no.naiv.meddetsamme.domain.ScheduleText
@OptIn(ExperimentalMaterial3Api::class)
@ -40,6 +47,20 @@ fun MedListScreen(nav: Nav) {
val db = remember { MedDatabase.get(context) }
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(
topBar = {
TopAppBar(
@ -73,29 +94,61 @@ fun MedListScreen(nav: Nav) {
)
}
}
items(meds, key = { it.med.id }) { med ->
Card(
modifier = Modifier
.fillMaxWidth()
.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."
},
) {
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(
"Lager: ${ScheduleText.amountText(med.item.stockUnits, med.item.unit)}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
items(activeMeds, key = { it.med.id }) { med ->
MedCard(
med = med,
subtitle = "Lager: ${ScheduleText.amountText(med.item.stockUnits, med.item.unit)}",
onClick = { nav.push(Screen.MedEdit(med.med.id)) },
)
}
if (restingMeds.isNotEmpty()) {
item {
Text(
"Sesong (hviler)",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
)
}
items(restingMeds, key = { it.med.id }) { med ->
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

@ -21,6 +21,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
@ -117,6 +118,7 @@ fun SettingsScreen(nav: Nav) {
HorizontalDivider()
Text("Varsler", style = MaterialTheme.typography.titleMedium)
RepeatRemindersRow(settings)
SupplyCheckTimeRow(settings)
HorizontalDivider()
@ -153,6 +155,33 @@ fun SettingsScreen(nav: Nav) {
}
}
/**
* Global toggle for automatic re-nagging. Off a due dose alerts once and goes
* quiet (an explicit "Utsett 15 min" still gives one more reminder). Takes
* effect for the next dose that fires; no need to touch alarms already armed.
*/
@Composable
private fun RepeatRemindersRow(settings: SettingsStore) {
var enabled by remember { mutableStateOf(settings.repeatReminders) }
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("Gjenta påminnelser")
Text(
"Mas hvert 10. min til dosen er tatt. Av: ett varsel, så stille.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = enabled,
onCheckedChange = {
enabled = it
settings.repeatReminders = it
},
)
}
}
/** Daily low-stock/rx notification time; re-arms the (replacing) alarm on change. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable

View file

@ -32,6 +32,8 @@ class ScheduleEngineTest {
cycleLengthDays: Int = 0,
startEpochDay: Long? = null,
endEpochDay: Long? = null,
seasonStartMmdd: Int = 0,
seasonEndMmdd: Int = 0,
id: Long = 1,
) = DoseTime(
id = id,
@ -45,6 +47,8 @@ class ScheduleEngineTest {
cycleLengthDays = cycleLengthDays,
startEpochDay = startEpochDay,
endEpochDay = endEpochDay,
seasonStartMmdd = seasonStartMmdd,
seasonEndMmdd = seasonEndMmdd,
)
private fun at(date: LocalDate, hour: Int, minute: Int = 0): LocalDateTime =
@ -222,6 +226,98 @@ class ScheduleEngineTest {
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 --------------------------------------------
@Test

View file

@ -32,6 +32,18 @@ class ScheduleTextTest {
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
fun `amounts use norwegian decimal comma and drop trailing zeroes`() {
assertEquals("1 tablett", ScheduleText.amountText(1.0, "tablett"))