SupplyCheckReceiver re-posted the auto-cancel "Forleng" card on every daily check for the whole 5-day window, so a dismissed nudge came back each morning. Remember the season-end epoch day nudged per med (plaintext pref) and skip re-posting for the same close; a new end date (next year, or after Forleng) still earns one fresh nudge.
153 lines
6.5 KiB
Kotlin
153 lines
6.5 KiB
Kotlin
package no.naiv.meddetsamme.alarm
|
|
|
|
import android.content.BroadcastReceiver
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import java.time.LocalDate
|
|
import java.time.temporal.ChronoUnit
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import no.naiv.meddetsamme.data.MedDatabase
|
|
import no.naiv.meddetsamme.domain.ScheduleEngine
|
|
import no.naiv.meddetsamme.domain.ScheduleText
|
|
import no.naiv.meddetsamme.notify.Notifications
|
|
import no.naiv.meddetsamme.settings.SettingsStore
|
|
|
|
/**
|
|
* Daily derived-state check: low stock (inventory ÷ scheduled rate) and
|
|
* prescription renewal (rx expiry, independent of stock — decision #9).
|
|
* One digest notification, not one per med.
|
|
*/
|
|
class SupplyCheckReceiver : BroadcastReceiver() {
|
|
|
|
override fun onReceive(context: Context, intent: Intent) {
|
|
val pending = goAsync()
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
check(context)
|
|
} finally {
|
|
AlarmScheduler(context).armSupplyCheck() // chain the next daily check
|
|
pending.finish()
|
|
}
|
|
}
|
|
}
|
|
|
|
private companion object {
|
|
/** "About a week in advance" for prescription renewal. */
|
|
const val RX_LEAD_DAYS = 7
|
|
|
|
/** How early to nudge before a resting seasonal med's window opens. */
|
|
const val SEASON_START_LEAD_DAYS = 7L
|
|
|
|
/** How early to offer "Forleng" before the current season closes. */
|
|
const val SEASON_END_LEAD_DAYS = 5L
|
|
}
|
|
|
|
private suspend fun check(context: Context) {
|
|
val db = MedDatabase.get(context)
|
|
val today = LocalDate.now()
|
|
val lowStock = mutableListOf<String>()
|
|
val rxRenewal = mutableListOf<String>()
|
|
|
|
val meds = db.medicationDao().getActive()
|
|
// Dose-times feed both the supply rate and the season check; fetch once.
|
|
val dtsByMed = meds.associate { it.med.id to db.doseTimeDao().getForMed(it.med.id) }
|
|
|
|
// Per inventory item: consumption is the SUM across all active regimens
|
|
// drawing from it — one shared box drains faster than either alone.
|
|
for ((_, group) in meds.groupBy { it.item.id }) {
|
|
val item = group.first().item
|
|
var rate = 0.0
|
|
for (med in group) rate += ScheduleEngine.dailyConsumption(dtsByMed[med.med.id].orEmpty(), today)
|
|
if (ScheduleEngine.needsRefill(item.stockUnits, rate, item.lowStockLeadDays)) {
|
|
val days = ScheduleEngine.daysOfSupply(item.stockUnits, rate)
|
|
lowStock += "${item.name}: lager for ${ScheduleText.daysCount(days?.toInt() ?: 0)}"
|
|
}
|
|
if (ScheduleEngine.needsRenewal(item.rxExpiryEpochDay, today, RX_LEAD_DAYS)) {
|
|
val expired = item.rxExpiryEpochDay!! < today.toEpochDay()
|
|
rxRenewal += if (expired) "${item.name}: resept utløpt" else "${item.name}: resept må fornyes"
|
|
}
|
|
}
|
|
|
|
for (med in meds) checkSeason(context, med.displayName, med.med.id, dtsByMed[med.med.id].orEmpty(), today)
|
|
|
|
// Post-or-cancel: cancelling when the condition clears is what lets the
|
|
// ongoing rx notification disappear by itself after a renewal.
|
|
if (lowStock.isNotEmpty()) {
|
|
Notifications.post(
|
|
context,
|
|
Notifications.SUPPLY_NOTIFICATION_ID,
|
|
Notifications.buildLowStockNotification(context, lowStock),
|
|
)
|
|
} else {
|
|
Notifications.cancel(context, Notifications.SUPPLY_NOTIFICATION_ID)
|
|
}
|
|
if (rxRenewal.isNotEmpty()) {
|
|
Notifications.post(
|
|
context,
|
|
Notifications.RX_NOTIFICATION_ID,
|
|
Notifications.buildRxNotification(context, rxRenewal),
|
|
)
|
|
} else {
|
|
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" (once per close).
|
|
* 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) {
|
|
// Once per season close, not daily: a dismissed card stays dismissed.
|
|
// Keyed on the end date, so "Forleng" (new end) earns one fresh nudge.
|
|
val settings = SettingsStore(context)
|
|
if (settings.seasonEndNudgedEpochDay(medId) != end.toEpochDay()) {
|
|
Notifications.post(
|
|
context, notifId,
|
|
Notifications.buildSeasonNotification(
|
|
context, medId, name,
|
|
"Sesongen er snart over (${ScheduleText.dateLabel(end)}). Fortsatt plaget? Forleng to uker.",
|
|
starting = false,
|
|
),
|
|
)
|
|
settings.setSeasonEndNudgedEpochDay(medId, end.toEpochDay())
|
|
}
|
|
} else {
|
|
Notifications.cancel(context, notifId)
|
|
}
|
|
}
|
|
}
|
|
}
|