Compare commits
No commits in common. "main" and "v0.3.0" have entirely different histories.
30 changed files with 171 additions and 1556 deletions
21
LICENSE
21
LICENSE
|
|
@ -1,21 +0,0 @@
|
||||||
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,11 +132,3 @@ build is unsigned rather than failing.
|
||||||
|
|
||||||
Caregiver alerts, multi-profile, Health Connect, streaks/gamification,
|
Caregiver alerts, multi-profile, Health Connect, streaks/gamification,
|
||||||
symptom diary. This is one person's medication reminder, not a platform.
|
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
|
targetSdk = 35
|
||||||
// Bump both for every release installed on the phone — versionName for
|
// Bump both for every release installed on the phone — versionName for
|
||||||
// humans, versionCode so dumpsys/install history can tell builds apart.
|
// humans, versionCode so dumpsys/install history can tell builds apart.
|
||||||
versionCode = 6
|
versionCode = 3
|
||||||
versionName = "0.6.0"
|
versionName = "0.3.0"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,389 +0,0 @@
|
||||||
{
|
|
||||||
"formatVersion": 1,
|
|
||||||
"database": {
|
|
||||||
"version": 4,
|
|
||||||
"identityHash": "56932b6862c83744cc926f80ab7875e0",
|
|
||||||
"entities": [
|
|
||||||
{
|
|
||||||
"tableName": "inventory_item",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `strength` TEXT NOT NULL, `unit` TEXT NOT NULL, `form` TEXT NOT NULL, `atcCode` TEXT, `packageSize` REAL, `stockUnits` REAL NOT NULL, `lowStockLeadDays` INTEGER NOT NULL, `rxExpiryEpochDay` INTEGER, `refillsRemaining` INTEGER)",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "name",
|
|
||||||
"columnName": "name",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "strength",
|
|
||||||
"columnName": "strength",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "unit",
|
|
||||||
"columnName": "unit",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "form",
|
|
||||||
"columnName": "form",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "atcCode",
|
|
||||||
"columnName": "atcCode",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "packageSize",
|
|
||||||
"columnName": "packageSize",
|
|
||||||
"affinity": "REAL"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "stockUnits",
|
|
||||||
"columnName": "stockUnits",
|
|
||||||
"affinity": "REAL",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lowStockLeadDays",
|
|
||||||
"columnName": "lowStockLeadDays",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "rxExpiryEpochDay",
|
|
||||||
"columnName": "rxExpiryEpochDay",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "refillsRemaining",
|
|
||||||
"columnName": "refillsRemaining",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "medication",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `itemId` INTEGER NOT NULL, `withFood` INTEGER NOT NULL, `notes` TEXT NOT NULL, `active` INTEGER NOT NULL, FOREIGN KEY(`itemId`) REFERENCES `inventory_item`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "itemId",
|
|
||||||
"columnName": "itemId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "withFood",
|
|
||||||
"columnName": "withFood",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "notes",
|
|
||||||
"columnName": "notes",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "active",
|
|
||||||
"columnName": "active",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_medication_itemId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"itemId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_medication_itemId` ON `${TABLE_NAME}` (`itemId`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "inventory_item",
|
|
||||||
"onDelete": "RESTRICT",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"itemId"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "dose_time",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `medId` INTEGER NOT NULL, `minuteOfDay` INTEGER NOT NULL, `amount` REAL NOT NULL, `daysOfWeekMask` INTEGER NOT NULL, `intervalDays` INTEGER NOT NULL, `anchorEpochDay` INTEGER, `cycleActiveDays` INTEGER NOT NULL DEFAULT 0, `cycleLengthDays` INTEGER NOT NULL DEFAULT 0, `startEpochDay` INTEGER, `endEpochDay` INTEGER, `seasonStartMmdd` INTEGER NOT NULL DEFAULT 0, `seasonEndMmdd` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`medId`) REFERENCES `medication`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "medId",
|
|
||||||
"columnName": "medId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "minuteOfDay",
|
|
||||||
"columnName": "minuteOfDay",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "amount",
|
|
||||||
"columnName": "amount",
|
|
||||||
"affinity": "REAL",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "daysOfWeekMask",
|
|
||||||
"columnName": "daysOfWeekMask",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "intervalDays",
|
|
||||||
"columnName": "intervalDays",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "anchorEpochDay",
|
|
||||||
"columnName": "anchorEpochDay",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "cycleActiveDays",
|
|
||||||
"columnName": "cycleActiveDays",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "cycleLengthDays",
|
|
||||||
"columnName": "cycleLengthDays",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "startEpochDay",
|
|
||||||
"columnName": "startEpochDay",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "endEpochDay",
|
|
||||||
"columnName": "endEpochDay",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "seasonStartMmdd",
|
|
||||||
"columnName": "seasonStartMmdd",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "seasonEndMmdd",
|
|
||||||
"columnName": "seasonEndMmdd",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_dose_time_medId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"medId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_dose_time_medId` ON `${TABLE_NAME}` (`medId`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "medication",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"medId"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "dose_log",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `medId` INTEGER NOT NULL, `doseTimeId` INTEGER, `scheduledAtMillis` INTEGER NOT NULL, `amount` REAL NOT NULL, `status` TEXT NOT NULL, `actionedAtMillis` INTEGER, `nagCount` INTEGER NOT NULL, FOREIGN KEY(`medId`) REFERENCES `medication`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`doseTimeId`) REFERENCES `dose_time`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "medId",
|
|
||||||
"columnName": "medId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "doseTimeId",
|
|
||||||
"columnName": "doseTimeId",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "scheduledAtMillis",
|
|
||||||
"columnName": "scheduledAtMillis",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "amount",
|
|
||||||
"columnName": "amount",
|
|
||||||
"affinity": "REAL",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "status",
|
|
||||||
"columnName": "status",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "actionedAtMillis",
|
|
||||||
"columnName": "actionedAtMillis",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "nagCount",
|
|
||||||
"columnName": "nagCount",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_dose_log_medId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"medId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_medId` ON `${TABLE_NAME}` (`medId`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_dose_log_doseTimeId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"doseTimeId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_doseTimeId` ON `${TABLE_NAME}` (`doseTimeId`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_dose_log_doseTimeId_scheduledAtMillis",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"doseTimeId",
|
|
||||||
"scheduledAtMillis"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_doseTimeId_scheduledAtMillis` ON `${TABLE_NAME}` (`doseTimeId`, `scheduledAtMillis`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_dose_log_status",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"status"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_status` ON `${TABLE_NAME}` (`status`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "medication",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"medId"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"table": "dose_time",
|
|
||||||
"onDelete": "SET NULL",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"doseTimeId"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"setupQueries": [
|
|
||||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
|
||||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '56932b6862c83744cc926f80ab7875e0')"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -108,35 +108,6 @@ class MigrationTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun migrate3To4_addsSeasonColumnsDefaultingToNoSeason() {
|
|
||||||
helper.createDatabase(TEST_DB, 3).use { db ->
|
|
||||||
db.execSQL(
|
|
||||||
"INSERT INTO inventory_item (id, name, strength, unit, form, stockUnits, " +
|
|
||||||
"lowStockLeadDays) VALUES (1, 'Cetirizin', '10 mg', 'tablett', 'TABLET', 30.0, 7)",
|
|
||||||
)
|
|
||||||
db.execSQL("INSERT INTO medication (id, itemId, withFood, notes, active) VALUES (1, 1, 0, '', 1)")
|
|
||||||
db.execSQL(
|
|
||||||
"INSERT INTO dose_time (id, medId, minuteOfDay, amount, daysOfWeekMask, " +
|
|
||||||
"intervalDays, anchorEpochDay, cycleActiveDays, cycleLengthDays) " +
|
|
||||||
"VALUES (3, 1, 480, 1.0, 127, 0, NULL, 0, 0)",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validates the migrated schema against 4.json AND checks the new columns
|
|
||||||
// default to "no season" while the existing row survives.
|
|
||||||
helper.runMigrationsAndValidate(TEST_DB, 4, true, MedDatabase.MIGRATION_3_4).use { db ->
|
|
||||||
db.query(
|
|
||||||
"SELECT amount, seasonStartMmdd, seasonEndMmdd FROM dose_time WHERE id = 3",
|
|
||||||
).use { c ->
|
|
||||||
assertTrue(c.moveToFirst())
|
|
||||||
assertEquals(1.0, c.getDouble(0), 0.0)
|
|
||||||
assertEquals(0, c.getInt(1)) // seasonStartMmdd
|
|
||||||
assertEquals(0, c.getInt(2)) // seasonEndMmdd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TEST_DB = "migration-test.db"
|
const val TEST_DB = "migration-test.db"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
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,7 +63,6 @@
|
||||||
<receiver android:name=".alarm.DoseAlarmReceiver" android:exported="false" />
|
<receiver android:name=".alarm.DoseAlarmReceiver" android:exported="false" />
|
||||||
<receiver android:name=".alarm.DoseActionReceiver" android:exported="false" />
|
<receiver android:name=".alarm.DoseActionReceiver" android:exported="false" />
|
||||||
<receiver android:name=".alarm.SupplyCheckReceiver" android:exported="false" />
|
<receiver android:name=".alarm.SupplyCheckReceiver" android:exported="false" />
|
||||||
<receiver android:name=".alarm.SeasonActionReceiver" android:exported="false" />
|
|
||||||
|
|
||||||
<!-- AlarmManager state dies with the OS process table; rebuild every alarm
|
<!-- AlarmManager state dies with the OS process table; rebuild every alarm
|
||||||
from the DB on boot and after app update. Both actions are protected
|
from the DB on boot and after app update. Both actions are protected
|
||||||
|
|
|
||||||
|
|
@ -90,9 +90,7 @@ class AlarmScheduler(private val context: Context) {
|
||||||
// Resume nagging for doses that were unresolved when the device died —
|
// 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
|
// 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
|
// (e.g. "Ta nå" + "Angre" before its time); its own occurrence alarm
|
||||||
// handles it when it actually falls due. Resuming the nag chain is the
|
// handles it when it actually falls due.
|
||||||
// automatic repeat the setting governs, so honour it here too.
|
|
||||||
if (!no.naiv.meddetsamme.settings.SettingsStore(context).repeatReminders) return
|
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
db.doseLogDao().getUnresolved().forEach { log ->
|
db.doseLogDao().getUnresolved().forEach { log ->
|
||||||
if (log.nagCount < NAG_CAP && log.scheduledAtMillis <= now) {
|
if (log.nagCount < NAG_CAP && log.scheduledAtMillis <= now) {
|
||||||
|
|
|
||||||
|
|
@ -9,41 +9,42 @@ import kotlinx.coroutines.launch
|
||||||
import no.naiv.meddetsamme.domain.DoseActions
|
import no.naiv.meddetsamme.domain.DoseActions
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the notification actions. The dose card groups every med due at the
|
* Handles the notification actions (and later, the same actions from the UI).
|
||||||
* 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), applied to each dose in the occurrence:
|
* Contract (CLAUDE.md, load-bearing):
|
||||||
* - Taken → TAKEN, decrement inventory by the *logged* amount, stop escalating.
|
* - Taken → TAKEN, decrement inventory by the *logged* amount, stop escalating.
|
||||||
* - Snooze → SNOOZED, next nag in 15 min, keeps escalating (and counting) after.
|
* - Snooze → SNOOZED, next nag in 15 min, keeps escalating (and counting) after.
|
||||||
* - Mute → "Ikke forstyrr": stop escalating, dose stays unresolved.
|
* - Skip → SKIPPED, stop escalating, no inventory change.
|
||||||
* (Skip stays a per-dose UI action; the notification never offers it.)
|
|
||||||
*/
|
*/
|
||||||
class DoseActionReceiver : BroadcastReceiver() {
|
class DoseActionReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val ACTION_TAKEN = "no.naiv.meddetsamme.action.TAKEN"
|
const val ACTION_TAKEN = "no.naiv.meddetsamme.action.TAKEN"
|
||||||
const val ACTION_SNOOZE = "no.naiv.meddetsamme.action.SNOOZE"
|
const val ACTION_SNOOZE = "no.naiv.meddetsamme.action.SNOOZE"
|
||||||
const val ACTION_MUTE = "no.naiv.meddetsamme.action.MUTE"
|
const val ACTION_SKIP = "no.naiv.meddetsamme.action.SKIP"
|
||||||
const val EXTRA_SCHEDULED_AT = "scheduledAt"
|
const val EXTRA_LOG_ID = "logId"
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
val scheduledAt = intent.getLongExtra(EXTRA_SCHEDULED_AT, -1)
|
val logId = intent.getLongExtra(EXTRA_LOG_ID, -1)
|
||||||
if (scheduledAt < 0) return
|
if (logId < 0) return
|
||||||
val action = intent.action ?: return
|
val action = intent.action ?: return
|
||||||
val pending = goAsync()
|
val pending = goAsync()
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
val actions = DoseActions(context)
|
handle(context, action, logId)
|
||||||
when (action) {
|
|
||||||
ACTION_TAKEN -> actions.takeAll(scheduledAt)
|
|
||||||
ACTION_SNOOZE -> actions.snoozeAll(scheduledAt)
|
|
||||||
ACTION_MUTE -> actions.muteAll(scheduledAt)
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
pending.finish()
|
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
|
else -> existing
|
||||||
}
|
}
|
||||||
|
|
||||||
postOccurrence(context, db, scheduledAt)
|
Notifications.post(
|
||||||
// Start the automatic re-nag chain only if the user wants repeats; an
|
context,
|
||||||
// explicit Snooze still re-arms regardless (DoseActions.snooze).
|
Notifications.doseNotificationId(log.id),
|
||||||
if (repeatReminders(context) && log.nagCount < AlarmScheduler.NAG_CAP) {
|
Notifications.buildDoseNotification(context, med, log),
|
||||||
scheduler.armEscalation(log.id)
|
)
|
||||||
}
|
if (log.nagCount < AlarmScheduler.NAG_CAP) scheduler.armEscalation(log.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun escalate(context: Context, logId: Long) {
|
private suspend fun escalate(context: Context, logId: Long) {
|
||||||
|
|
@ -98,36 +98,15 @@ class DoseAlarmReceiver : BroadcastReceiver() {
|
||||||
// depth against any path that arms one too early).
|
// depth against any path that arms one too early).
|
||||||
if (log.scheduledAtMillis > System.currentTimeMillis()) return
|
if (log.scheduledAtMillis > System.currentTimeMillis()) return
|
||||||
|
|
||||||
|
val med = db.medicationDao().getById(log.medId) ?: return
|
||||||
val nagged = log.copy(nagCount = log.nagCount + 1)
|
val nagged = log.copy(nagCount = log.nagCount + 1)
|
||||||
db.doseLogDao().setNagCount(logId, nagged.nagCount)
|
db.doseLogDao().setNagCount(logId, nagged.nagCount)
|
||||||
|
|
||||||
// Re-post the whole occurrence so co-timed siblings stay on one card.
|
Notifications.post(
|
||||||
postOccurrence(context, db, log.scheduledAtMillis)
|
context,
|
||||||
// Re-arm the next automatic nag only while repeats are enabled. A nag
|
Notifications.doseNotificationId(logId),
|
||||||
// already in flight from a Snooze still fires once (this post), then
|
Notifications.buildDoseNotification(context, med, nagged),
|
||||||
// stops here — Snooze stays a single deferral, not an endless chain.
|
)
|
||||||
if (repeatReminders(context) && nagged.nagCount < AlarmScheduler.NAG_CAP) {
|
if (nagged.nagCount < AlarmScheduler.NAG_CAP) AlarmScheduler(context).armEscalation(logId)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
package no.naiv.meddetsamme.alarm
|
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import java.time.LocalDate
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import no.naiv.meddetsamme.data.MedDatabase
|
|
||||||
import no.naiv.meddetsamme.notify.Notifications
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the seasonal-nudge actions: a resting allergy med whose pollen season
|
|
||||||
* is starting early, or one whose season should run a little longer.
|
|
||||||
*
|
|
||||||
* Both write a one-year override onto the med's seasonal dose-times (decision:
|
|
||||||
* "overstyr årets vindu"). The overrides are absolute days, so the nominal
|
|
||||||
* month-day season is untouched and rules again next year — see ScheduleEngine.
|
|
||||||
* - Start 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,13 +4,11 @@ import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.temporal.ChronoUnit
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import no.naiv.meddetsamme.data.MedDatabase
|
import no.naiv.meddetsamme.data.MedDatabase
|
||||||
import no.naiv.meddetsamme.domain.ScheduleEngine
|
import no.naiv.meddetsamme.domain.ScheduleEngine
|
||||||
import no.naiv.meddetsamme.domain.ScheduleText
|
|
||||||
import no.naiv.meddetsamme.notify.Notifications
|
import no.naiv.meddetsamme.notify.Notifications
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -35,12 +33,6 @@ class SupplyCheckReceiver : BroadcastReceiver() {
|
||||||
private companion object {
|
private companion object {
|
||||||
/** "About a week in advance" for prescription renewal. */
|
/** "About a week in advance" for prescription renewal. */
|
||||||
const val RX_LEAD_DAYS = 7
|
const val RX_LEAD_DAYS = 7
|
||||||
|
|
||||||
/** How early to nudge before a resting seasonal med's window opens. */
|
|
||||||
const val SEASON_START_LEAD_DAYS = 7L
|
|
||||||
|
|
||||||
/** How early to offer "Forleng" before the current season closes. */
|
|
||||||
const val SEASON_END_LEAD_DAYS = 5L
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun check(context: Context) {
|
private suspend fun check(context: Context) {
|
||||||
|
|
@ -49,19 +41,16 @@ class SupplyCheckReceiver : BroadcastReceiver() {
|
||||||
val lowStock = mutableListOf<String>()
|
val lowStock = mutableListOf<String>()
|
||||||
val rxRenewal = mutableListOf<String>()
|
val rxRenewal = mutableListOf<String>()
|
||||||
|
|
||||||
val meds = db.medicationDao().getActive()
|
|
||||||
// Dose-times feed both the supply rate and the season check; fetch once.
|
|
||||||
val dtsByMed = meds.associate { it.med.id to db.doseTimeDao().getForMed(it.med.id) }
|
|
||||||
|
|
||||||
// Per inventory item: consumption is the SUM across all active regimens
|
// Per inventory item: consumption is the SUM across all active regimens
|
||||||
// drawing from it — one shared box drains faster than either alone.
|
// drawing from it — one shared box drains faster than either alone.
|
||||||
for ((_, group) in meds.groupBy { it.item.id }) {
|
val byItem = db.medicationDao().getActive().groupBy { it.item.id }
|
||||||
val item = group.first().item
|
for ((_, meds) in byItem) {
|
||||||
|
val item = meds.first().item
|
||||||
var rate = 0.0
|
var rate = 0.0
|
||||||
for (med in group) rate += ScheduleEngine.dailyConsumption(dtsByMed[med.med.id].orEmpty(), today)
|
for (med in meds) rate += ScheduleEngine.dailyConsumption(db.doseTimeDao().getForMed(med.med.id), today)
|
||||||
if (ScheduleEngine.needsRefill(item.stockUnits, rate, item.lowStockLeadDays)) {
|
if (ScheduleEngine.needsRefill(item.stockUnits, rate, item.lowStockLeadDays)) {
|
||||||
val days = ScheduleEngine.daysOfSupply(item.stockUnits, rate)
|
val days = ScheduleEngine.daysOfSupply(item.stockUnits, rate)
|
||||||
lowStock += "${item.name}: lager for ${ScheduleText.daysCount(days?.toInt() ?: 0)}"
|
lowStock += "${item.name}: lager for ${no.naiv.meddetsamme.domain.ScheduleText.daysCount(days?.toInt() ?: 0)}"
|
||||||
}
|
}
|
||||||
if (ScheduleEngine.needsRenewal(item.rxExpiryEpochDay, today, RX_LEAD_DAYS)) {
|
if (ScheduleEngine.needsRenewal(item.rxExpiryEpochDay, today, RX_LEAD_DAYS)) {
|
||||||
val expired = item.rxExpiryEpochDay!! < today.toEpochDay()
|
val expired = item.rxExpiryEpochDay!! < today.toEpochDay()
|
||||||
|
|
@ -69,8 +58,6 @@ class SupplyCheckReceiver : BroadcastReceiver() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (med in meds) checkSeason(context, med.displayName, med.med.id, dtsByMed[med.med.id].orEmpty(), today)
|
|
||||||
|
|
||||||
// Post-or-cancel: cancelling when the condition clears is what lets the
|
// Post-or-cancel: cancelling when the condition clears is what lets the
|
||||||
// ongoing rx notification disappear by itself after a renewal.
|
// ongoing rx notification disappear by itself after a renewal.
|
||||||
if (lowStock.isNotEmpty()) {
|
if (lowStock.isNotEmpty()) {
|
||||||
|
|
@ -92,55 +79,4 @@ class SupplyCheckReceiver : BroadcastReceiver() {
|
||||||
Notifications.cancel(context, Notifications.RX_NOTIFICATION_ID)
|
Notifications.cancel(context, Notifications.RX_NOTIFICATION_ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-med seasonal nudge, post-or-cancel like the digests above:
|
|
||||||
* - resting and within [SEASON_START_LEAD_DAYS] of the next window → "Start nå";
|
|
||||||
* - in season and within [SEASON_END_LEAD_DAYS] of its close → "Forleng".
|
|
||||||
* Acting on either (or the season simply passing) clears it the next day.
|
|
||||||
*/
|
|
||||||
private fun checkSeason(
|
|
||||||
context: Context,
|
|
||||||
name: String,
|
|
||||||
medId: Long,
|
|
||||||
doseTimes: List<no.naiv.meddetsamme.data.DoseTime>,
|
|
||||||
today: LocalDate,
|
|
||||||
) {
|
|
||||||
val notifId = Notifications.seasonNotificationId(medId)
|
|
||||||
if (!ScheduleEngine.isSeasonal(doseTimes)) {
|
|
||||||
Notifications.cancel(context, notifId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!ScheduleEngine.inSeasonNow(doseTimes, today)) {
|
|
||||||
val start = ScheduleEngine.nextSeasonStart(doseTimes, today)
|
|
||||||
val days = start?.let { ChronoUnit.DAYS.between(today, it) }
|
|
||||||
if (start != null && days != null && days in 0..SEASON_START_LEAD_DAYS) {
|
|
||||||
Notifications.post(
|
|
||||||
context, notifId,
|
|
||||||
Notifications.buildSeasonNotification(
|
|
||||||
context, medId, name,
|
|
||||||
"Sesongen nærmer seg (fra ${ScheduleText.dateLabel(start)}). Vil du starte nå?",
|
|
||||||
starting = true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Notifications.cancel(context, notifId)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val end = ScheduleEngine.currentSeasonEnd(doseTimes, today)
|
|
||||||
val days = end?.let { ChronoUnit.DAYS.between(today, it) }
|
|
||||||
if (end != null && days != null && days in 0..SEASON_END_LEAD_DAYS) {
|
|
||||||
Notifications.post(
|
|
||||||
context, notifId,
|
|
||||||
Notifications.buildSeasonNotification(
|
|
||||||
context, medId, name,
|
|
||||||
"Sesongen er snart over (${ScheduleText.dateLabel(end)}). Fortsatt plaget? Forleng to uker.",
|
|
||||||
starting = false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Notifications.cancel(context, notifId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,9 +162,6 @@ data class DoseTimeDto(
|
||||||
val cycleLengthDays: Int = 0,
|
val cycleLengthDays: Int = 0,
|
||||||
val startEpochDay: Long? = null,
|
val startEpochDay: Long? = null,
|
||||||
val endEpochDay: Long? = null,
|
val endEpochDay: Long? = null,
|
||||||
// Added in schema v4; defaults keep older backup files parsing.
|
|
||||||
val seasonStartMmdd: Int = 0,
|
|
||||||
val seasonEndMmdd: Int = 0,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -204,13 +201,11 @@ private fun MedicationDto.toEntity() = Medication(
|
||||||
private fun DoseTime.toDto() = DoseTimeDto(
|
private fun DoseTime.toDto() = DoseTimeDto(
|
||||||
id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay,
|
id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay,
|
||||||
cycleActiveDays, cycleLengthDays, startEpochDay, endEpochDay,
|
cycleActiveDays, cycleLengthDays, startEpochDay, endEpochDay,
|
||||||
seasonStartMmdd, seasonEndMmdd,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun DoseTimeDto.toEntity() = DoseTime(
|
private fun DoseTimeDto.toEntity() = DoseTime(
|
||||||
id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay,
|
id, medId, minuteOfDay, amount, daysOfWeekMask, intervalDays, anchorEpochDay,
|
||||||
cycleActiveDays, cycleLengthDays, startEpochDay, endEpochDay,
|
cycleActiveDays, cycleLengthDays, startEpochDay, endEpochDay,
|
||||||
seasonStartMmdd, seasonEndMmdd,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun DoseLog.toDto() = DoseLogDto(id, medId, doseTimeId, scheduledAtMillis, amount, status.name, actionedAtMillis, nagCount)
|
private fun DoseLog.toDto() = DoseLogDto(id, medId, doseTimeId, scheduledAtMillis, amount, status.name, actionedAtMillis, nagCount)
|
||||||
|
|
|
||||||
|
|
@ -22,22 +22,6 @@ interface DoseLogDao {
|
||||||
@Query("SELECT * FROM dose_log WHERE status IN ('PENDING', 'SNOOZED') ORDER BY scheduledAtMillis")
|
@Query("SELECT * FROM dose_log WHERE status IN ('PENDING', 'SNOOZED') ORDER BY scheduledAtMillis")
|
||||||
suspend fun getUnresolved(): List<DoseLog>
|
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")
|
@Query("SELECT * FROM dose_log WHERE scheduledAtMillis >= :fromMillis AND scheduledAtMillis < :toMillis ORDER BY scheduledAtMillis")
|
||||||
fun observeRange(fromMillis: Long, toMillis: Long): Flow<List<DoseLog>>
|
fun observeRange(fromMillis: Long, toMillis: Long): Flow<List<DoseLog>>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,14 @@ import androidx.room.PrimaryKey
|
||||||
* - otherwise → weekly pattern from [daysOfWeekMask], bit 0 = Sunday … bit 6 =
|
* - otherwise → weekly pattern from [daysOfWeekMask], bit 0 = Sunday … bit 6 =
|
||||||
* Saturday. 0x7F = daily.
|
* Saturday. 0x7F = daily.
|
||||||
*
|
*
|
||||||
* Three orthogonal filters layer on top (the brief's "cyclic/taper extend from
|
* Two orthogonal filters layer on top (the brief's "cyclic/taper extend from
|
||||||
* the same model"):
|
* the same model"):
|
||||||
* - **Cyclic** ([cycleLengthDays] > 0): the dose only occurs in the first
|
* - **Cyclic** ([cycleLengthDays] > 0): the dose only occurs in the first
|
||||||
* [cycleActiveDays] of each [cycleLengthDays]-day cycle counted from
|
* [cycleActiveDays] of each [cycleLengthDays]-day cycle counted from
|
||||||
* [anchorEpochDay] — e.g. 21-on/7-off contraception is 21/28.
|
* [anchorEpochDay] — e.g. 21-on/7-off contraception is 21/28.
|
||||||
* - **Validity window** ([startEpochDay]/[endEpochDay], inclusive): the dose
|
* - **Validity window** ([startEpochDay]/[endEpochDay], inclusive): the dose
|
||||||
* only occurs within the date range. A taper is several rows with descending
|
* only occurs within the date range. A taper is several rows with descending
|
||||||
* [amount] and adjacent windows. Also used as a one-year override of the
|
* [amount] and adjacent windows.
|
||||||
* seasonal window (see [seasonStartMmdd]).
|
|
||||||
* - **Seasonal window** ([seasonStartMmdd]/[seasonEndMmdd]): a calendar window
|
|
||||||
* that repeats *every year* — e.g. an allergy med for the pollen season.
|
|
||||||
* Encoded as month×100+day (0301 = 1 Mar). Both 0 = no season. A start after
|
|
||||||
* the end wraps the new year (e.g. a winter season Nov→Feb).
|
|
||||||
*/
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "dose_time",
|
tableName = "dose_time",
|
||||||
|
|
@ -65,8 +60,4 @@ data class DoseTime(
|
||||||
val startEpochDay: Long? = null,
|
val startEpochDay: Long? = null,
|
||||||
/** Inclusive validity-window end; null = no upper bound. Taper rows set this. */
|
/** Inclusive validity-window end; null = no upper bound. Taper rows set this. */
|
||||||
val endEpochDay: Long? = null,
|
val endEpochDay: Long? = null,
|
||||||
/** Yearly season start as month×100+day (0301 = 1 Mar). 0 = no season. */
|
|
||||||
@ColumnInfo(defaultValue = "0") val seasonStartMmdd: Int = 0,
|
|
||||||
/** Yearly season end as month×100+day (0531 = 31 May). 0 = no season. */
|
|
||||||
@ColumnInfo(defaultValue = "0") val seasonEndMmdd: Int = 0,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
*/
|
*/
|
||||||
@Database(
|
@Database(
|
||||||
entities = [InventoryItem::class, Medication::class, DoseTime::class, DoseLog::class],
|
entities = [InventoryItem::class, Medication::class, DoseTime::class, DoseLog::class],
|
||||||
version = 4,
|
version = 3,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
abstract class MedDatabase : RoomDatabase() {
|
abstract class MedDatabase : RoomDatabase() {
|
||||||
|
|
@ -92,24 +92,13 @@ abstract class MedDatabase : RoomDatabase() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* v3 → v4: yearly seasonal-window columns on dose_time. Additive ALTERs
|
|
||||||
* defaulting to 0 ("no season") — existing rows keep behaving as before.
|
|
||||||
*/
|
|
||||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE dose_time ADD COLUMN `seasonStartMmdd` INTEGER NOT NULL DEFAULT 0")
|
|
||||||
db.execSQL("ALTER TABLE dose_time ADD COLUMN `seasonEndMmdd` INTEGER NOT NULL DEFAULT 0")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun get(context: Context): MedDatabase =
|
fun get(context: Context): MedDatabase =
|
||||||
instance ?: synchronized(this) {
|
instance ?: synchronized(this) {
|
||||||
instance ?: Room.databaseBuilder(
|
instance ?: Room.databaseBuilder(
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
MedDatabase::class.java,
|
MedDatabase::class.java,
|
||||||
"med-det-samme.db",
|
"med-det-samme.db",
|
||||||
).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4).build().also { instance = it }
|
).addMigrations(MIGRATION_1_2, MIGRATION_2_3).build().also { instance = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,44 +35,27 @@ class DoseActions(private val context: Context) {
|
||||||
return db.doseLogDao().getById(id)!!
|
return db.doseLogDao().getById(id)!!
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- single-dose actions (the app's Today screen acts per dose) --------
|
|
||||||
|
|
||||||
suspend fun take(logId: Long) {
|
suspend fun take(logId: Long) {
|
||||||
val log = db.doseLogDao().getById(logId) ?: return
|
val log = db.doseLogDao().getById(logId) ?: return
|
||||||
takeState(log)
|
if (log.status == DoseStatus.TAKEN) return // double-tap must not decrement twice
|
||||||
refreshOccurrence(log.scheduledAtMillis)
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun snooze(logId: Long) {
|
suspend fun snooze(logId: Long) {
|
||||||
val log = db.doseLogDao().getById(logId) ?: return
|
db.doseLogDao().setStatus(logId, DoseStatus.SNOOZED, System.currentTimeMillis())
|
||||||
snoozeState(log)
|
scheduler.armEscalation(logId, delayMinutes = AlarmScheduler.SNOOZE_MINUTES)
|
||||||
// Snooze dismisses now; the re-nag brings the card back in 15 min.
|
Notifications.cancel(context, Notifications.doseNotificationId(logId))
|
||||||
Notifications.cancel(context, Notifications.doseNotificationId(log.scheduledAtMillis))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun skip(logId: Long) {
|
suspend fun skip(logId: Long) {
|
||||||
val log = db.doseLogDao().getById(logId) ?: return
|
|
||||||
db.doseLogDao().setStatus(logId, DoseStatus.SKIPPED, System.currentTimeMillis())
|
db.doseLogDao().setStatus(logId, DoseStatus.SKIPPED, System.currentTimeMillis())
|
||||||
scheduler.cancelEscalation(logId)
|
scheduler.cancelEscalation(logId)
|
||||||
refreshOccurrence(log.scheduledAtMillis)
|
Notifications.cancel(context, Notifications.doseNotificationId(logId))
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 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. */
|
/** Undo a mis-tap: back to PENDING, restoring stock if it was TAKEN. */
|
||||||
|
|
@ -89,62 +72,4 @@ class DoseActions(private val context: Context) {
|
||||||
db.doseLogDao().setNagCount(logId, 0)
|
db.doseLogDao().setNagCount(logId, 0)
|
||||||
scheduler.cancelEscalation(logId)
|
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,9 +20,8 @@ import no.naiv.meddetsamme.data.DoseTime
|
||||||
* - intervalDays > 1 → every N days counted from anchorEpochDay; mask ignored.
|
* - intervalDays > 1 → every N days counted from anchorEpochDay; mask ignored.
|
||||||
* - otherwise → weekly daysOfWeekMask, bit 0 = Sunday … bit 6 = Saturday.
|
* - otherwise → weekly daysOfWeekMask, bit 0 = Sunday … bit 6 = Saturday.
|
||||||
* Mask 0 selects nothing: no occurrences, never an error.
|
* Mask 0 selects nothing: no occurrences, never an error.
|
||||||
* - cyclic ([cycleLengthDays] > 0), validity window ([startEpochDay]/
|
* - cyclic ([cycleLengthDays] > 0) and validity window ([startEpochDay]/
|
||||||
* [endEpochDay]) and yearly season ([seasonStartMmdd]/[seasonEndMmdd])
|
* [endEpochDay]) further restrict the above — see [occursOn].
|
||||||
* further restrict the above — see [occursOn].
|
|
||||||
*/
|
*/
|
||||||
object ScheduleEngine {
|
object ScheduleEngine {
|
||||||
|
|
||||||
|
|
@ -54,63 +53,13 @@ object ScheduleEngine {
|
||||||
return Math.floorMod(day - anchor, length.toLong()) < active
|
return Math.floorMod(day - anchor, length.toLong()) < active
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isSeasonal(doseTime: DoseTime): Boolean =
|
/** Does this dose-time occur on [date]? Base rule ∧ validity window ∧ cycle. */
|
||||||
doseTime.seasonStartMmdd > 0 && doseTime.seasonEndMmdd > 0
|
fun occursOn(doseTime: DoseTime, date: LocalDate): Boolean {
|
||||||
|
|
||||||
/**
|
|
||||||
* Is [date] inside this dose-time's *nominal* yearly season window? A season
|
|
||||||
* is a pair of month×100+day marks that repeat every year. A start after the
|
|
||||||
* end wraps the new year, e.g. a winter season 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()
|
val day = date.toEpochDay()
|
||||||
if (doseTime.startEpochDay != null && day < doseTime.startEpochDay) return false
|
doseTime.startEpochDay?.let { if (day < it) return false }
|
||||||
if (doseTime.endEpochDay != null && day > doseTime.endEpochDay) return false
|
doseTime.endEpochDay?.let { if (day > it) return false }
|
||||||
return true
|
return baseOccursOn(doseTime, date) && inCycleWindow(doseTime, date)
|
||||||
}
|
}
|
||||||
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
|
* Earliest occurrence strictly after [after], or null if the schedule never
|
||||||
|
|
@ -133,43 +82,6 @@ object ScheduleEngine {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if any row is bounded to a yearly season window (an allergy med, say). */
|
|
||||||
fun isSeasonal(doseTimes: List<DoseTime>): Boolean = doseTimes.any { isSeasonal(it) }
|
|
||||||
|
|
||||||
/** Is any seasonal row of this med active on [today] (nominal window or an override)? */
|
|
||||||
fun inSeasonNow(doseTimes: List<DoseTime>, today: LocalDate): Boolean =
|
|
||||||
doseTimes.any { isSeasonal(it) && withinDateBounds(it, today) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The date a resting seasonal med next enters its window — the season start,
|
|
||||||
* not the first dose (which the weekday mask could push later). Null if the
|
|
||||||
* med isn't seasonal or never re-opens within the horizon. Scans the tested
|
|
||||||
* [withinDateBounds] so override/wrap behaviour stays in one place.
|
|
||||||
*/
|
|
||||||
fun nextSeasonStart(doseTimes: List<DoseTime>, today: LocalDate): LocalDate? {
|
|
||||||
val rows = doseTimes.filter { isSeasonal(it) }
|
|
||||||
if (rows.isEmpty()) return null
|
|
||||||
var date = today
|
|
||||||
repeat(SEARCH_HORIZON_DAYS) {
|
|
||||||
if (rows.any { withinDateBounds(it, date) }) return date
|
|
||||||
date = date.plusDays(1)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The last date of the in-season stretch [today] sits in, or null if resting. */
|
|
||||||
fun currentSeasonEnd(doseTimes: List<DoseTime>, today: LocalDate): LocalDate? {
|
|
||||||
val active = doseTimes.filter { isSeasonal(it) && withinDateBounds(it, today) }
|
|
||||||
if (active.isEmpty()) return null
|
|
||||||
var last = today
|
|
||||||
repeat(SEARCH_HORIZON_DAYS) {
|
|
||||||
val next = last.plusDays(1)
|
|
||||||
if (active.none { withinDateBounds(it, next) }) return last
|
|
||||||
last = next
|
|
||||||
}
|
|
||||||
return last
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Earliest next occurrence across all of a med's dose-times (what to arm). */
|
/** Earliest next occurrence across all of a med's dose-times (what to arm). */
|
||||||
fun nextOccurrence(doseTimes: List<DoseTime>, after: LocalDateTime): Pair<DoseTime, LocalDateTime>? =
|
fun nextOccurrence(doseTimes: List<DoseTime>, after: LocalDateTime): Pair<DoseTime, LocalDateTime>? =
|
||||||
doseTimes
|
doseTimes
|
||||||
|
|
@ -191,10 +103,10 @@ object ScheduleEngine {
|
||||||
* not historical, because days-of-supply is a forward prediction.
|
* not historical, because days-of-supply is a forward prediction.
|
||||||
*/
|
*/
|
||||||
fun dailyConsumption(doseTimes: List<DoseTime>, today: LocalDate): Double {
|
fun dailyConsumption(doseTimes: List<DoseTime>, today: LocalDate): Double {
|
||||||
|
val day = today.toEpochDay()
|
||||||
return doseTimes.sumOf { dt ->
|
return doseTimes.sumOf { dt ->
|
||||||
// One predicate for taper windows AND seasonal windows: off-window rows
|
if (dt.startEpochDay != null && day < dt.startEpochDay) return@sumOf 0.0
|
||||||
// (an elapsed taper step, an out-of-season allergy med) consume nothing.
|
if (dt.endEpochDay != null && day > dt.endEpochDay) return@sumOf 0.0
|
||||||
if (!withinDateBounds(dt, today)) return@sumOf 0.0
|
|
||||||
var rate = if (dt.intervalDays > 1) {
|
var rate = if (dt.intervalDays > 1) {
|
||||||
if (dt.anchorEpochDay == null) 0.0 else dt.amount / dt.intervalDays
|
if (dt.anchorEpochDay == null) 0.0 else dt.amount / dt.intervalDays
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ object ScheduleText {
|
||||||
|
|
||||||
fun describe(doseTime: DoseTime): String =
|
fun describe(doseTime: DoseTime): String =
|
||||||
"${"%02d:%02d".format(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)} " +
|
"${"%02d:%02d".format(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)} " +
|
||||||
"${daysText(doseTime)}${cycleText(doseTime)}${windowText(doseTime)}${seasonText(doseTime)}"
|
"${daysText(doseTime)}${cycleText(doseTime)}${windowText(doseTime)}"
|
||||||
|
|
||||||
/** " · syklus 21/28" when cyclic, else "". */
|
/** " · syklus 21/28" when cyclic, else "". */
|
||||||
fun cycleText(doseTime: DoseTime): String {
|
fun cycleText(doseTime: DoseTime): String {
|
||||||
|
|
@ -41,21 +41,6 @@ object ScheduleText {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** " · sesong 1. 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 {
|
fun daysText(doseTime: DoseTime): String {
|
||||||
if (doseTime.intervalDays > 1) {
|
if (doseTime.intervalDays > 1) {
|
||||||
return "(hver ${doseTime.intervalDays}. dag)"
|
return "(hver ${doseTime.intervalDays}. dag)"
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import androidx.core.app.NotificationManagerCompat
|
||||||
import no.naiv.meddetsamme.MainActivity
|
import no.naiv.meddetsamme.MainActivity
|
||||||
import no.naiv.meddetsamme.R
|
import no.naiv.meddetsamme.R
|
||||||
import no.naiv.meddetsamme.alarm.DoseActionReceiver
|
import no.naiv.meddetsamme.alarm.DoseActionReceiver
|
||||||
import no.naiv.meddetsamme.alarm.SeasonActionReceiver
|
|
||||||
import no.naiv.meddetsamme.data.DoseLog
|
import no.naiv.meddetsamme.data.DoseLog
|
||||||
import no.naiv.meddetsamme.data.MedWithItem
|
import no.naiv.meddetsamme.data.MedWithItem
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
@ -23,21 +22,12 @@ object Notifications {
|
||||||
const val CHANNEL_DOSES = "doses"
|
const val CHANNEL_DOSES = "doses"
|
||||||
const val CHANNEL_SUPPLY = "supply"
|
const val CHANNEL_SUPPLY = "supply"
|
||||||
|
|
||||||
/**
|
/** Stable per-occurrence notification id: every re-nag *replaces* the card. */
|
||||||
* Stable per-OCCURRENCE notification id (minute resolution): every med due
|
fun doseNotificationId(logId: Long): Int = (logId % Int.MAX_VALUE).toInt()
|
||||||
* 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 SUPPLY_NOTIFICATION_ID = 1_000_000_007
|
||||||
const val RX_NOTIFICATION_ID = 1_000_000_009
|
const val RX_NOTIFICATION_ID = 1_000_000_009
|
||||||
|
|
||||||
/** One season nudge per med, so its action button can target that med. */
|
|
||||||
fun seasonNotificationId(medId: Long): Int = (2_000_000_000L + medId).toInt()
|
|
||||||
|
|
||||||
private val timeFmt = DateTimeFormatter.ofPattern("HH:mm")
|
private val timeFmt = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
|
||||||
fun createChannels(context: Context) {
|
fun createChannels(context: Context) {
|
||||||
|
|
@ -65,67 +55,37 @@ object Notifications {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The dose reminder card for one occurrence. [entries] are all the meds due
|
* The dose reminder card. nagCount > 0 marks a re-nag — same id, so it
|
||||||
* at the same wall-clock minute (one entry = a normal single card; several =
|
* replaces the previous card but re-alerts (no setOnlyAlertOnce: re-alerting
|
||||||
* a grouped card with "alle" actions). Actions carry the scheduledAtMillis,
|
* is the escalation).
|
||||||
* 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(
|
fun buildDoseNotification(context: Context, med: MedWithItem, log: DoseLog): Notification {
|
||||||
context: Context,
|
val scheduled = Instant.ofEpochMilli(log.scheduledAtMillis).atZone(ZoneId.systemDefault())
|
||||||
entries: List<Pair<MedWithItem, DoseLog>>,
|
val title = med.displayName
|
||||||
scheduledAtMillis: Long,
|
val body = buildString {
|
||||||
alertAgain: Boolean = true,
|
append("${formatAmount(log.amount)} ${med.item.unit} kl. ${timeFmt.format(scheduled)}")
|
||||||
): Notification {
|
if (med.med.withFood) append(" — tas med mat")
|
||||||
require(entries.isNotEmpty()) { "Dose notification needs at least one med" }
|
if (log.nagCount > 0) append(" (påminnelse ${log.nagCount})")
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val title: String
|
fun action(action: String): PendingIntent = PendingIntent.getBroadcast(
|
||||||
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,
|
context,
|
||||||
// One requestCode per occurrence; the Intent action keeps the
|
// Distinct requestCode per (log, action); same (log, action) replaces.
|
||||||
// Tatt/Utsett/Ikke-forstyrr PendingIntents distinct (filterEquals).
|
(log.id * 10 + action.hashCode().mod(10)).toInt(),
|
||||||
doseNotificationId(scheduledAtMillis),
|
|
||||||
Intent(context, DoseActionReceiver::class.java)
|
Intent(context, DoseActionReceiver::class.java)
|
||||||
.setAction(actionStr)
|
.setAction(action)
|
||||||
.putExtra(DoseActionReceiver.EXTRA_SCHEDULED_AT, scheduledAtMillis),
|
.putExtra(DoseActionReceiver.EXTRA_LOG_ID, log.id),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
val contentIntent = PendingIntent.getActivity(
|
val contentIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
doseNotificationId(scheduledAtMillis),
|
doseNotificationId(log.id),
|
||||||
Intent(context, MainActivity::class.java),
|
Intent(context, MainActivity::class.java),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
val builder = NotificationCompat.Builder(context, CHANNEL_DOSES)
|
return NotificationCompat.Builder(context, CHANNEL_DOSES)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(body)
|
.setContentText(body)
|
||||||
|
|
@ -134,22 +94,9 @@ object Notifications {
|
||||||
.setContentIntent(contentIntent)
|
.setContentIntent(contentIntent)
|
||||||
.setAutoCancel(false)
|
.setAutoCancel(false)
|
||||||
.setOngoing(false)
|
.setOngoing(false)
|
||||||
.setOnlyAlertOnce(!alertAgain)
|
.addAction(0, "Tatt", action(DoseActionReceiver.ACTION_TAKEN))
|
||||||
.addAction(0, if (grouped) "Tatt alle" else "Tatt", action(DoseActionReceiver.ACTION_TAKEN))
|
.addAction(0, "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE))
|
||||||
.addAction(0, if (grouped) "Utsett alle" else "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE))
|
.build()
|
||||||
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. */
|
/** Morning low-stock digest: plainly dismissable, re-posted daily while low. */
|
||||||
|
|
@ -170,48 +117,6 @@ object Notifications {
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
/**
|
|
||||||
* Seasonal nudge for one med. [starting] = true is the pre-season "Start nå"
|
|
||||||
* card; false is the end-of-season "Forleng" card. Posted on the supply
|
|
||||||
* channel and dismissable — acting on it (or the season simply passing)
|
|
||||||
* cancels it on the next daily check.
|
|
||||||
*/
|
|
||||||
fun buildSeasonNotification(
|
|
||||||
context: Context,
|
|
||||||
medId: Long,
|
|
||||||
title: String,
|
|
||||||
body: String,
|
|
||||||
starting: Boolean,
|
|
||||||
): Notification {
|
|
||||||
val notifId = seasonNotificationId(medId)
|
|
||||||
val action = if (starting) SeasonActionReceiver.ACTION_START_NOW else SeasonActionReceiver.ACTION_EXTEND
|
|
||||||
val actionLabel = if (starting) "Start nå" else "Forleng 2 uker"
|
|
||||||
val actionIntent = PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
notifId,
|
|
||||||
Intent(context, SeasonActionReceiver::class.java)
|
|
||||||
.setAction(action)
|
|
||||||
.putExtra(SeasonActionReceiver.EXTRA_MED_ID, medId),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
||||||
)
|
|
||||||
val contentIntent = PendingIntent.getActivity(
|
|
||||||
context,
|
|
||||||
notifId,
|
|
||||||
Intent(context, MainActivity::class.java),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
||||||
)
|
|
||||||
return NotificationCompat.Builder(context, CHANNEL_SUPPLY)
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setContentText(body)
|
|
||||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
|
||||||
.setContentIntent(contentIntent)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.addAction(0, actionLabel, actionIntent)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun supplyDigestBuilder(
|
private fun supplyDigestBuilder(
|
||||||
context: Context,
|
context: Context,
|
||||||
requestCode: Int,
|
requestCode: Int,
|
||||||
|
|
|
||||||
|
|
@ -55,17 +55,6 @@ class SettingsStore(context: Context) {
|
||||||
get() = prefs.getInt("supply_check_minute", 600)
|
get() = prefs.getInt("supply_check_minute", 600)
|
||||||
set(v) = prefs.edit { putInt("supply_check_minute", v.coerceIn(0, 1439)) }
|
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. */
|
/** S3/Garage credentials complete — gates the S3 upload path only. */
|
||||||
val isS3Configured: Boolean
|
val isS3Configured: Boolean
|
||||||
get() = !s3Endpoint.isNullOrBlank() && !s3Bucket.isNullOrBlank() &&
|
get() = !s3Endpoint.isNullOrBlank() && !s3Bucket.isNullOrBlank() &&
|
||||||
|
|
|
||||||
|
|
@ -37,21 +37,11 @@ import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import no.naiv.meddetsamme.R
|
import no.naiv.meddetsamme.R
|
||||||
import no.naiv.meddetsamme.data.DoseLog
|
import no.naiv.meddetsamme.data.DoseLog
|
||||||
import no.naiv.meddetsamme.data.DoseStatus
|
|
||||||
import no.naiv.meddetsamme.data.MedDatabase
|
import no.naiv.meddetsamme.data.MedDatabase
|
||||||
import no.naiv.meddetsamme.domain.ScheduleEngine
|
import no.naiv.meddetsamme.domain.ScheduleEngine
|
||||||
|
|
||||||
private const val HISTORY_DAYS = 30L
|
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
|
* Adherence history: the last 30 days of dose logs, newest first, with
|
||||||
* 7- and 30-day adherence percentages on top. Read-only — corrections happen
|
* 7- and 30-day adherence percentages on top. Read-only — corrections happen
|
||||||
|
|
@ -140,36 +130,22 @@ fun HistoryScreen(nav: Nav) {
|
||||||
}
|
}
|
||||||
items(dayLogs, key = { it.first.id }) { (log, medName) ->
|
items(dayLogs, key = { it.first.id }) { (log, medName) ->
|
||||||
val time = timeFmt.format(Instant.ofEpochMilli(log.scheduledAtMillis).atZone(zone))
|
val time = timeFmt.format(Instant.ofEpochMilli(log.scheduledAtMillis).atZone(zone))
|
||||||
val taken = log.status == DoseStatus.TAKEN
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {
|
modifier = Modifier.fillMaxWidth().semantics(mergeDescendants = true) {
|
||||||
contentDescription = "$time, $medName, ${log.status.historyLabel}"
|
contentDescription = "$time, $medName, ${log.status.label}"
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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(time, style = MaterialTheme.typography.titleSmall)
|
||||||
Text(
|
Text(
|
||||||
medName,
|
medName,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
modifier = Modifier.padding(start = 12.dp).weight(1f),
|
modifier = Modifier.padding(start = 12.dp).weight(1f),
|
||||||
)
|
)
|
||||||
Text(
|
DoseStatusBadge(log.status)
|
||||||
log.status.historyLabel,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
package no.naiv.meddetsamme.ui
|
package no.naiv.meddetsamme.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
|
@ -10,7 +8,6 @@ import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
|
@ -46,13 +43,10 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -462,25 +456,15 @@ private fun DoseTimeEditorDialog(
|
||||||
var offDays by remember {
|
var offDays by remember {
|
||||||
mutableStateOf((if (existingCyclic) existing!!.cycleLengthDays - existing.cycleActiveDays else 7).toString())
|
mutableStateOf((if (existingCyclic) existing!!.cycleLengthDays - existing.cycleActiveDays else 7).toString())
|
||||||
}
|
}
|
||||||
// Yearly season (e.g. pollen): both marks > 0 means seasonal. Default 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(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(if (existing == null) "Ny doseringstid" else "Rediger doseringstid") },
|
title = { Text(if (existing == null) "Ny doseringstid" else "Rediger doseringstid") },
|
||||||
text = {
|
text = {
|
||||||
// Scrollable: the dialog now has time + amount + mode + cycle + season
|
// Scrollable: the dialog now has time + amount + mode + cycle and would
|
||||||
// and overflows on most screens. The time picker fills the first view,
|
// otherwise overflow on small screens.
|
||||||
// so cyclic/season sit below the fold — hence the "scroll for more" hint.
|
|
||||||
val schedScroll = rememberScrollState()
|
|
||||||
Box {
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.verticalScroll(schedScroll),
|
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
TimePicker(state = timeState)
|
TimePicker(state = timeState)
|
||||||
|
|
@ -549,46 +533,6 @@ private fun DoseTimeEditorDialog(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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(
|
|
||||||
"⌄ 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
|
|
@ -600,12 +544,6 @@ private fun DoseTimeEditorDialog(
|
||||||
val cycleActive = if (cyclic && on > 0 && off > 0) on else 0
|
val cycleActive = if (cyclic && on > 0 && off > 0) on else 0
|
||||||
val cycleLength = if (cycleActive > 0) on + off else 0
|
val cycleLength = if (cycleActive > 0) on + off else 0
|
||||||
val needsAnchor = intervalMode || cycleActive > 0
|
val needsAnchor = intervalMode || cycleActive > 0
|
||||||
// Clamp the day to the month's max (leap-safe: Feb → 29) so we never
|
|
||||||
// store an impossible MonthDay that would crash the renderer.
|
|
||||||
fun mmdd(month: Int, dayStr: String): Int {
|
|
||||||
val day = (dayStr.toIntOrNull() ?: 1).coerceIn(1, java.time.Month.of(month).maxLength())
|
|
||||||
return month * 100 + day
|
|
||||||
}
|
|
||||||
val dt = DoseTime(
|
val dt = DoseTime(
|
||||||
id = existing?.id ?: 0,
|
id = existing?.id ?: 0,
|
||||||
medId = medId,
|
medId = medId,
|
||||||
|
|
@ -620,8 +558,6 @@ private fun DoseTimeEditorDialog(
|
||||||
cycleLengthDays = cycleLength,
|
cycleLengthDays = cycleLength,
|
||||||
startEpochDay = existing?.startEpochDay,
|
startEpochDay = existing?.startEpochDay,
|
||||||
endEpochDay = existing?.endEpochDay,
|
endEpochDay = existing?.endEpochDay,
|
||||||
seasonStartMmdd = if (seasonal) mmdd(startMonth, startDay) else 0,
|
|
||||||
seasonEndMmdd = if (seasonal) mmdd(endMonth, endDay) else 0,
|
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (existing == null) db.doseTimeDao().insert(dt) else db.doseTimeDao().update(dt)
|
if (existing == null) db.doseTimeDao().insert(dt) else db.doseTimeDao().update(dt)
|
||||||
|
|
@ -635,50 +571,3 @@ private fun DoseTimeEditorDialog(
|
||||||
|
|
||||||
private fun dayName(bit: Int): String =
|
private fun dayName(bit: Int): String =
|
||||||
listOf("søndag", "mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag")[bit]
|
listOf("søndag", "mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag")[bit]
|
||||||
|
|
||||||
private val MONTH_NAMES = listOf(
|
|
||||||
"januar", "februar", "mars", "april", "mai", "juni",
|
|
||||||
"juli", "august", "september", "oktober", "november", "desember",
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Month dropdown + day field for one end of a yearly season window. */
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun MonthDayRow(
|
|
||||||
label: String,
|
|
||||||
month: Int,
|
|
||||||
day: String,
|
|
||||||
onMonth: (Int) -> Unit,
|
|
||||||
onDay: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
var expanded by remember { mutableStateOf(false) }
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Text(label, modifier = Modifier.width(36.dp))
|
|
||||||
ExposedDropdownMenuBox(
|
|
||||||
expanded = expanded,
|
|
||||||
onExpandedChange = { expanded = it },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = MONTH_NAMES[month - 1],
|
|
||||||
onValueChange = {},
|
|
||||||
readOnly = true,
|
|
||||||
label = { Text("Måned") },
|
|
||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
|
||||||
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
|
||||||
)
|
|
||||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
|
||||||
MONTH_NAMES.forEachIndexed { i, name ->
|
|
||||||
DropdownMenuItem(text = { Text(name) }, onClick = { onMonth(i + 1); expanded = false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
OutlinedTextField(
|
|
||||||
value = day,
|
|
||||||
onValueChange = onDay,
|
|
||||||
label = { Text("Dag") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.width(88.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,7 @@ import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
@ -32,12 +29,8 @@ import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.time.LocalDate
|
|
||||||
import no.naiv.meddetsamme.R
|
import no.naiv.meddetsamme.R
|
||||||
import no.naiv.meddetsamme.data.DoseTime
|
|
||||||
import no.naiv.meddetsamme.data.MedDatabase
|
import no.naiv.meddetsamme.data.MedDatabase
|
||||||
import no.naiv.meddetsamme.data.MedWithItem
|
|
||||||
import no.naiv.meddetsamme.domain.ScheduleEngine
|
|
||||||
import no.naiv.meddetsamme.domain.ScheduleText
|
import no.naiv.meddetsamme.domain.ScheduleText
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -47,20 +40,6 @@ fun MedListScreen(nav: Nav) {
|
||||||
val db = remember { MedDatabase.get(context) }
|
val db = remember { MedDatabase.get(context) }
|
||||||
val meds by db.medicationDao().observeActive().collectAsState(initial = emptyList())
|
val meds by db.medicationDao().observeActive().collectAsState(initial = emptyList())
|
||||||
|
|
||||||
// Dose times aren't on MedWithItem, but we need them to tell a resting
|
|
||||||
// seasonal med (out of its yearly window) from a normally-active one.
|
|
||||||
var doseTimesByMed by remember { mutableStateOf<Map<Long, List<DoseTime>>>(emptyMap()) }
|
|
||||||
LaunchedEffect(meds) {
|
|
||||||
doseTimesByMed = meds.associate { it.med.id to db.doseTimeDao().getForMed(it.med.id) }
|
|
||||||
}
|
|
||||||
val today = LocalDate.now()
|
|
||||||
val restingIds = meds.filter { m ->
|
|
||||||
val dts = doseTimesByMed[m.med.id].orEmpty()
|
|
||||||
ScheduleEngine.isSeasonal(dts) && !ScheduleEngine.inSeasonNow(dts, today)
|
|
||||||
}.map { it.med.id }.toSet()
|
|
||||||
val activeMeds = meds.filterNot { it.med.id in restingIds }
|
|
||||||
val restingMeds = meds.filter { it.med.id in restingIds }
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|
@ -94,49 +73,14 @@ fun MedListScreen(nav: Nav) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(activeMeds, key = { it.med.id }) { med ->
|
items(meds, 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(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onClick)
|
.clickable { nav.push(Screen.MedEdit(med.med.id)) }
|
||||||
.semantics(mergeDescendants = true) {
|
.semantics(mergeDescendants = true) {
|
||||||
contentDescription =
|
contentDescription =
|
||||||
"${med.displayName}, ${FORM_LABELS[med.item.form]}, $subtitle. Trykk for å redigere."
|
"${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) {
|
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|
@ -144,11 +88,14 @@ private fun MedCard(med: MedWithItem, subtitle: String, onClick: () -> Unit) {
|
||||||
Column(Modifier.padding(start = 12.dp).weight(1f)) {
|
Column(Modifier.padding(start = 12.dp).weight(1f)) {
|
||||||
Text(med.displayName, style = MaterialTheme.typography.titleMedium)
|
Text(med.displayName, style = MaterialTheme.typography.titleMedium)
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
"Lager: ${ScheduleText.amountText(med.item.stockUnits, med.item.unit)}",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TimePicker
|
import androidx.compose.material3.TimePicker
|
||||||
|
|
@ -118,7 +117,6 @@ fun SettingsScreen(nav: Nav) {
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Text("Varsler", style = MaterialTheme.typography.titleMedium)
|
Text("Varsler", style = MaterialTheme.typography.titleMedium)
|
||||||
RepeatRemindersRow(settings)
|
|
||||||
SupplyCheckTimeRow(settings)
|
SupplyCheckTimeRow(settings)
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
@ -155,33 +153,6 @@ 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. */
|
/** Daily low-stock/rx notification time; re-arms the (replacing) alarm on change. */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -37,19 +37,6 @@ fun MedDetSammeTheme(content: @Composable () -> Unit) {
|
||||||
MaterialTheme(colorScheme = scheme, typography = AppTypography, content = content)
|
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
|
// Manrope for headings/titles (friendly, confident), Inter for body/labels
|
||||||
// (built for UI legibility at small sizes). Both OFL, full æøå/µ coverage.
|
// (built for UI legibility at small sizes). Both OFL, full æøå/µ coverage.
|
||||||
private val Manrope = FontFamily(
|
private val Manrope = FontFamily(
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<?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,8 +32,6 @@ class ScheduleEngineTest {
|
||||||
cycleLengthDays: Int = 0,
|
cycleLengthDays: Int = 0,
|
||||||
startEpochDay: Long? = null,
|
startEpochDay: Long? = null,
|
||||||
endEpochDay: Long? = null,
|
endEpochDay: Long? = null,
|
||||||
seasonStartMmdd: Int = 0,
|
|
||||||
seasonEndMmdd: Int = 0,
|
|
||||||
id: Long = 1,
|
id: Long = 1,
|
||||||
) = DoseTime(
|
) = DoseTime(
|
||||||
id = id,
|
id = id,
|
||||||
|
|
@ -47,8 +45,6 @@ class ScheduleEngineTest {
|
||||||
cycleLengthDays = cycleLengthDays,
|
cycleLengthDays = cycleLengthDays,
|
||||||
startEpochDay = startEpochDay,
|
startEpochDay = startEpochDay,
|
||||||
endEpochDay = endEpochDay,
|
endEpochDay = endEpochDay,
|
||||||
seasonStartMmdd = seasonStartMmdd,
|
|
||||||
seasonEndMmdd = seasonEndMmdd,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun at(date: LocalDate, hour: Int, minute: Int = 0): LocalDateTime =
|
private fun at(date: LocalDate, hour: Int, minute: Int = 0): LocalDateTime =
|
||||||
|
|
@ -226,98 +222,6 @@ class ScheduleEngineTest {
|
||||||
assertTrue(ScheduleEngine.occurrencesOn(rows, wednesday.plusDays(6)).isEmpty())
|
assertTrue(ScheduleEngine.occurrencesOn(rows, wednesday.plusDays(6)).isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- yearly season (allergy / pollen) --------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `season bounds occurrences within the calendar window each year`() {
|
|
||||||
// Pollen season 1 Mar – 31 May, daily dose.
|
|
||||||
val dt = doseTime(seasonStartMmdd = 301, seasonEndMmdd = 531)
|
|
||||||
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 2, 28))) // day before
|
|
||||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 3, 1))) // start, inclusive
|
|
||||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 4, 15)))
|
|
||||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 5, 31))) // end, inclusive
|
|
||||||
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 6, 1))) // day after
|
|
||||||
// …and it repeats the next year, no edit needed.
|
|
||||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2027, 4, 15)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `nextOccurrence off-season jumps to next season start`() {
|
|
||||||
val dt = doseTime(seasonStartMmdd = 301, seasonEndMmdd = 531)
|
|
||||||
// From 10 June 2026 (out of season) the next dose is 1 Mar 2027 at 08:00.
|
|
||||||
val next = ScheduleEngine.nextOccurrence(dt, at(wednesday, 12, 0))
|
|
||||||
assertEquals(at(LocalDate.of(2027, 3, 1), 8), next)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `season wrapping the new year is in season on both sides of december`() {
|
|
||||||
// Winter season 1 Nov – 28 Feb (start > end → wraps).
|
|
||||||
val dt = doseTime(seasonStartMmdd = 1101, seasonEndMmdd = 228)
|
|
||||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 12, 15)))
|
|
||||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2027, 1, 10)))
|
|
||||||
assertTrue(ScheduleEngine.occursOn(dt, LocalDate.of(2027, 2, 28)))
|
|
||||||
assertFalse(ScheduleEngine.occursOn(dt, LocalDate.of(2026, 7, 1)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `season comparison is calendar-correct across month boundaries`() {
|
|
||||||
// 29 Feb (a real leap day) sits inside a 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 --------------------------------------------
|
// ---- multiple dose-times --------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -32,18 +32,6 @@ class ScheduleTextTest {
|
||||||
assertEquals("22:30 (daglig)", ScheduleText.describe(dt(minute = 22 * 60 + 30)))
|
assertEquals("22:30 (daglig)", ScheduleText.describe(dt(minute = 22 * 60 + 30)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `seasonal window reads as a calendar range`() {
|
|
||||||
val pollen = DoseTime(
|
|
||||||
medId = 1, minuteOfDay = 480, amount = 1.0, anchorEpochDay = 0,
|
|
||||||
seasonStartMmdd = 301, seasonEndMmdd = 531,
|
|
||||||
)
|
|
||||||
// nb abbreviates March to "mar." (trailing dot); May is already short.
|
|
||||||
assertEquals("08:00 (daglig) · sesong 1. mar.–31. mai", ScheduleText.describe(pollen))
|
|
||||||
// No season set → no suffix.
|
|
||||||
assertEquals("", ScheduleText.seasonText(dt()))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `amounts use norwegian decimal comma and drop trailing zeroes`() {
|
fun `amounts use norwegian decimal comma and drop trailing zeroes`() {
|
||||||
assertEquals("1 tablett", ScheduleText.amountText(1.0, "tablett"))
|
assertEquals("1 tablett", ScheduleText.amountText(1.0, "tablett"))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue