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() val rxRenewal = mutableListOf() 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, 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) } } } }