Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 041f9e2cf6 | |||
| 44ea4ed87b | |||
| 778a6aec27 | |||
| 5b9a3d6877 | |||
| d81a4fd926 | |||
| b4c929eca2 | |||
| bbf4d0af9c | |||
| 7eac153740 |
30 changed files with 1556 additions and 171 deletions
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ole-Morten Duesund
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -132,3 +132,11 @@ build is unsigned rather than failing.
|
|||
|
||||
Caregiver alerts, multi-profile, Health Connect, streaks/gamification,
|
||||
symptom diary. This is one person's medication reminder, not a platform.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) © Ole-Morten Duesund.
|
||||
|
||||
The bundled FEST extract (`app/src/main/assets/fest/slim.json`) is derived from
|
||||
DMP's openly published drug data and remains subject to DMP's own terms, not
|
||||
the MIT license above.
|
||||
|
|
|
|||
|
|
@ -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 = 3
|
||||
versionName = "0.3.0"
|
||||
versionCode = 6
|
||||
versionName = "0.6.0"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
|
|
|
|||
389
app/schemas/no.naiv.meddetsamme.data.MedDatabase/4.json
Normal file
389
app/schemas/no.naiv.meddetsamme.data.MedDatabase/4.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 nå → 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 Nov→Feb).
|
||||
*/
|
||||
@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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 1101→0228 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 {
|
||||
|
|
|
|||
|
|
@ -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. mar–31. 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)"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() &&
|
||||
|
|
|
|||
|
|
@ -37,11 +37,21 @@ import java.time.format.DateTimeFormatter
|
|||
import java.util.Locale
|
||||
import no.naiv.meddetsamme.R
|
||||
import no.naiv.meddetsamme.data.DoseLog
|
||||
import no.naiv.meddetsamme.data.DoseStatus
|
||||
import no.naiv.meddetsamme.data.MedDatabase
|
||||
import no.naiv.meddetsamme.domain.ScheduleEngine
|
||||
|
||||
private const val HISTORY_DAYS = 30L
|
||||
|
||||
/**
|
||||
* Status text in a *past* context. Differs from [label] only for PENDING: on
|
||||
* the Today screen a due dose is legitimately "Venter", but a dose from days
|
||||
* ago that was never acted on was simply not taken — "Venter" there is the
|
||||
* nonsense this screen used to show.
|
||||
*/
|
||||
private val DoseStatus.historyLabel: String
|
||||
get() = if (this == DoseStatus.PENDING) "Ikke tatt" else label
|
||||
|
||||
/**
|
||||
* Adherence history: the last 30 days of dose logs, newest first, with
|
||||
* 7- and 30-day adherence percentages on top. Read-only — corrections happen
|
||||
|
|
@ -130,22 +140,36 @@ fun HistoryScreen(nav: Nav) {
|
|||
}
|
||||
items(dayLogs, key = { it.first.id }) { (log, medName) ->
|
||||
val time = timeFmt.format(Instant.ofEpochMilli(log.scheduledAtMillis).atZone(zone))
|
||||
val taken = log.status == DoseStatus.TAKEN
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {
|
||||
contentDescription = "$time, $medName, ${log.status.label}"
|
||||
contentDescription = "$time, $medName, ${log.status.historyLabel}"
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Green ✓ taken / red ✕ not-taken, first on the line for an
|
||||
// at-a-glance scan down the day. Decorative here — the row's
|
||||
// merged contentDescription already speaks the status.
|
||||
Icon(
|
||||
painterResource(if (taken) R.drawable.ic_check else R.drawable.ic_close),
|
||||
contentDescription = null,
|
||||
tint = if (taken) takenGreen() else missedRed(),
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
)
|
||||
Text(time, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
medName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 12.dp).weight(1f),
|
||||
)
|
||||
DoseStatusBadge(log.status)
|
||||
Text(
|
||||
log.status.historyLabel,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 Mar–May.
|
||||
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 mars–mai. 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -37,6 +37,19 @@ fun MedDetSammeTheme(content: @Composable () -> Unit) {
|
|||
MaterialTheme(colorScheme = scheme, typography = AppTypography, content = content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed adherence semantics for the history glance icons: green = taken,
|
||||
* red = not taken. Deliberately *not* derived from the (possibly dynamic)
|
||||
* brand palette — a wallpaper-blue "primary" must never make a "taken" tick
|
||||
* read as anything but green. Paired with a distinct shape (✓ vs ✕) and text
|
||||
* so meaning never rests on colour alone (WCAG 1.4.1).
|
||||
*/
|
||||
@Composable
|
||||
fun takenGreen(): Color = if (isSystemInDarkTheme()) Color(0xFF7CC97F) else Color(0xFF2E7D32)
|
||||
|
||||
@Composable
|
||||
fun missedRed(): Color = if (isSystemInDarkTheme()) Color(0xFFFFB4AB) else Color(0xFFBA1A1A)
|
||||
|
||||
// Manrope for headings/titles (friendly, confident), Inter for body/labels
|
||||
// (built for UI legibility at small sizes). Both OFL, full æøå/µ coverage.
|
||||
private val Manrope = FontFamily(
|
||||
|
|
|
|||
9
app/src/main/res/drawable/ic_check.xml
Normal file
9
app/src/main/res/drawable/ic_check.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24"
|
||||
>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_close.xml
Normal file
9
app/src/main/res/drawable/ic_close.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24"
|
||||
>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
|
||||
</vector>
|
||||
|
|
@ -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 Feb–Mar 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 Mar–May; "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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue