package no.naiv.meddetsamme.notify import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import no.naiv.meddetsamme.MainActivity import no.naiv.meddetsamme.R import no.naiv.meddetsamme.alarm.DoseActionReceiver import no.naiv.meddetsamme.data.DoseLog import no.naiv.meddetsamme.data.MedWithItem import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter object Notifications { const val CHANNEL_DOSES = "doses" const val CHANNEL_SUPPLY = "supply" /** Stable per-occurrence notification id: every re-nag *replaces* the card. */ fun doseNotificationId(logId: Long): Int = (logId % Int.MAX_VALUE).toInt() const val SUPPLY_NOTIFICATION_ID = 1_000_000_007 const val RX_NOTIFICATION_ID = 1_000_000_009 private val timeFmt = DateTimeFormatter.ofPattern("HH:mm") fun createChannels(context: Context) { val nm = context.getSystemService(NotificationManager::class.java) nm.createNotificationChannel( NotificationChannel( CHANNEL_DOSES, "Doser", // HIGH: heads-up + sound. The entire point of the app. NotificationManager.IMPORTANCE_HIGH, ).apply { description = "Påminnelser om å ta medisin" setBypassDnd(false) }, ) nm.createNotificationChannel( NotificationChannel( CHANNEL_SUPPLY, "Lager og resept", NotificationManager.IMPORTANCE_DEFAULT, ).apply { description = "Lite på lager eller resept som må fornyes" }, ) } /** * The dose reminder card. nagCount > 0 marks a re-nag — same id, so it * replaces the previous card but re-alerts (no setOnlyAlertOnce: re-alerting * is the escalation). */ fun buildDoseNotification(context: Context, med: MedWithItem, log: DoseLog): Notification { val scheduled = Instant.ofEpochMilli(log.scheduledAtMillis).atZone(ZoneId.systemDefault()) val title = med.displayName val body = buildString { append("${formatAmount(log.amount)} ${med.item.unit} kl. ${timeFmt.format(scheduled)}") if (med.med.withFood) append(" — tas med mat") if (log.nagCount > 0) append(" (påminnelse ${log.nagCount})") } fun action(action: String): PendingIntent = PendingIntent.getBroadcast( context, // Distinct requestCode per (log, action); same (log, action) replaces. (log.id * 10 + action.hashCode().mod(10)).toInt(), Intent(context, DoseActionReceiver::class.java) .setAction(action) .putExtra(DoseActionReceiver.EXTRA_LOG_ID, log.id), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val contentIntent = PendingIntent.getActivity( context, doseNotificationId(log.id), Intent(context, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) return NotificationCompat.Builder(context, CHANNEL_DOSES) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(title) .setContentText(body) .setCategory(NotificationCompat.CATEGORY_REMINDER) .setPriority(NotificationCompat.PRIORITY_HIGH) .setContentIntent(contentIntent) .setAutoCancel(false) .setOngoing(false) .addAction(0, "Tatt", action(DoseActionReceiver.ACTION_TAKEN)) .addAction(0, "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE)) .build() } /** Morning low-stock digest: plainly dismissable, re-posted daily while low. */ fun buildLowStockNotification(context: Context, lines: List): Notification = supplyDigestBuilder(context, SUPPLY_NOTIFICATION_ID, lines) .setContentTitle(if (lines.size == 1) lines.first() else "Lite på lager (${lines.size})") .setAutoCancel(true) .build() /** * Rx-renewal digest: setOngoing makes it survive "Clear all" — the * "persistent but dismissable" contract (deliberate swipe still works on * Android 14+, and the daily check re-posts it until the rx is renewed). */ fun buildRxNotification(context: Context, lines: List): Notification = supplyDigestBuilder(context, RX_NOTIFICATION_ID, lines) .setContentTitle(if (lines.size == 1) lines.first() else "Resepter må fornyes (${lines.size})") .setOngoing(true) .build() private fun supplyDigestBuilder( context: Context, requestCode: Int, lines: List, ): NotificationCompat.Builder { val contentIntent = PendingIntent.getActivity( context, requestCode, Intent(context, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) return NotificationCompat.Builder(context, CHANNEL_SUPPLY) .setSmallIcon(R.drawable.ic_notification) .setContentText(lines.joinToString(" · ")) .setStyle(NotificationCompat.InboxStyle().also { s -> lines.forEach(s::addLine) }) .setCategory(NotificationCompat.CATEGORY_REMINDER) .setContentIntent(contentIntent) } /** Post if allowed; silently skipping is fine — channel-level blocks are user intent. */ fun post(context: Context, id: Int, notification: Notification) { val nm = NotificationManagerCompat.from(context) if (nm.areNotificationsEnabled()) { try { nm.notify(id, notification) } catch (_: SecurityException) { // POST_NOTIFICATIONS revoked between check and post; nothing to do. } } } fun cancel(context: Context, id: Int) = NotificationManagerCompat.from(context).cancel(id) private fun formatAmount(amount: Double): String = if (amount == amount.toLong().toDouble()) amount.toLong().toString() else amount.toString() }