From 608d84236b1db9fa5a43e85469804c5343ddd797 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Thu, 11 Jun 2026 16:21:25 +0200 Subject: [PATCH] Rx-expiry ongoing alert (7-day lead), configurable morning check, Felleskatalogen links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daily digest splits in two: low stock stays an auto-cancel notification re-posted each morning while below the per-item limit; prescription renewal becomes a separate ONGOING notification starting 7 days before rx expiry — survives 'Clear all', swipe-dismissable on Android 14+, returns each morning until the rx date is updated, and cancels itself once renewed (post-or-cancel in SupplyCheckReceiver). Check time is now configurable (Innstillinger → Varsler, M3 TimePicker, default 10:00); same PendingIntent so re-arming replaces. 'Slå opp i Felleskatalogen' buttons on the item editor and the med editor's summary card open the browser — decision #8 clarified: the ban covers Felleskatalogen as a data source, not human-facing links. Emulator-verified: both notifications post with correct flags (ONGOING_EVENT vs AUTO_CANCEL), auto-clear after renewal/restock, alarm follows the configured time (10:00 → 07:30), FK button opens Chrome. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 + .../naiv/meddetsamme/alarm/AlarmScheduler.kt | 12 +++-- .../meddetsamme/alarm/SupplyCheckReceiver.kt | 31 ++++++++++--- .../naiv/meddetsamme/notify/Notifications.kt | 30 ++++++++++-- .../meddetsamme/settings/SettingsStore.kt | 5 ++ .../no/naiv/meddetsamme/ui/ItemEditScreen.kt | 11 +++++ .../java/no/naiv/meddetsamme/ui/ItemForm.kt | 14 ++++++ .../no/naiv/meddetsamme/ui/MedEditScreen.kt | 6 +++ .../no/naiv/meddetsamme/ui/SettingsScreen.kt | 46 +++++++++++++++++++ 9 files changed, 142 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 47cef29..24593d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,8 @@ The name says the brief: nag me to take the dose *now*. is a SOAP/WCF M30 XML dump. `tools/fest_flatten.py` (in-repo, amended 2026-06) flattens it into a slim JSON bundled as an APK asset; release builds auto-refresh it when >30 days old. Autocomplete is offline; a downloaded file via Settings can override the bundle. + The ban covers Felleskatalogen as a *data source* — browser links to felleskatalogen.no + for human reading are fine (added 2026-06). 9. Refill is **derived** from inventory + consumption. Prescription renewal (`rxExpiryEpochDay`, `refillsRemaining`) is tracked **separately** from stock. diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/AlarmScheduler.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/AlarmScheduler.kt index f9cfda5..9d1982c 100644 --- a/app/src/main/java/no/naiv/meddetsamme/alarm/AlarmScheduler.kt +++ b/app/src/main/java/no/naiv/meddetsamme/alarm/AlarmScheduler.kt @@ -26,8 +26,6 @@ class AlarmScheduler(private val context: Context) { const val NAG_CAP = 6 const val SNOOZE_MINUTES = 15L - /** Daily supply/renewal check; lateness is harmless so it's inexact. */ - val SUPPLY_CHECK_TIME: LocalTime = LocalTime.of(10, 0) private const val SUPPLY_REQUEST_CODE = 9_001 } @@ -127,9 +125,15 @@ class AlarmScheduler(private val context: Context) { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) - /** Inexact daily check at ~10:00; the receiver re-arms the next one. */ + /** + * Inexact daily check at the user-configured morning time (default 10:00); + * the receiver re-arms the next one. Same PendingIntent → re-arming after a + * settings change replaces the alarm, never stacks. + */ fun armSupplyCheck() { - var next = LocalDateTime.of(java.time.LocalDate.now(), SUPPLY_CHECK_TIME) + val minute = no.naiv.meddetsamme.settings.SettingsStore(context).supplyCheckMinuteOfDay + val time = LocalTime.of(minute / 60, minute % 60) + var next = LocalDateTime.of(java.time.LocalDate.now(), time) if (!next.isAfter(LocalDateTime.now())) next = next.plusDays(1) alarmManager.setAndAllowWhileIdle( AlarmManager.RTC_WAKEUP, diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/SupplyCheckReceiver.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/SupplyCheckReceiver.kt index 0c4a39e..d771308 100644 --- a/app/src/main/java/no/naiv/meddetsamme/alarm/SupplyCheckReceiver.kt +++ b/app/src/main/java/no/naiv/meddetsamme/alarm/SupplyCheckReceiver.kt @@ -30,10 +30,16 @@ class SupplyCheckReceiver : BroadcastReceiver() { } } + private companion object { + /** "About a week in advance" for prescription renewal. */ + const val RX_LEAD_DAYS = 7 + } + private suspend fun check(context: Context) { val db = MedDatabase.get(context) val today = LocalDate.now() - val lines = mutableListOf() + val lowStock = mutableListOf() + val rxRenewal = mutableListOf() // Per inventory item: consumption is the SUM across all active regimens // drawing from it — one shared box drains faster than either alone. @@ -44,20 +50,33 @@ class SupplyCheckReceiver : BroadcastReceiver() { for (med in meds) rate += ScheduleEngine.dailyConsumption(db.doseTimeDao().getForMed(med.med.id)) if (ScheduleEngine.needsRefill(item.stockUnits, rate, item.lowStockLeadDays)) { val days = ScheduleEngine.daysOfSupply(item.stockUnits, rate) - lines += "${item.name}: lager for ${days?.toInt() ?: 0} dager" + lowStock += "${item.name}: lager for ${days?.toInt() ?: 0} dager" } - if (ScheduleEngine.needsRenewal(item.rxExpiryEpochDay, today)) { + if (ScheduleEngine.needsRenewal(item.rxExpiryEpochDay, today, RX_LEAD_DAYS)) { val expired = item.rxExpiryEpochDay!! < today.toEpochDay() - lines += if (expired) "${item.name}: resept utløpt" else "${item.name}: resept må fornyes" + rxRenewal += if (expired) "${item.name}: resept utløpt" else "${item.name}: resept må fornyes" } } - if (lines.isNotEmpty()) { + // 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.buildSupplyNotification(context, lines), + 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) } } } diff --git a/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt b/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt index 45a5718..dbbd96d 100644 --- a/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt +++ b/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt @@ -26,6 +26,7 @@ object Notifications { 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") @@ -98,22 +99,41 @@ object Notifications { .build() } - fun buildSupplyNotification(context: Context, lines: List): Notification { + /** 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, - SUPPLY_NOTIFICATION_ID, + 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) - .setContentTitle(if (lines.size == 1) lines.first() else "Apotek-ærend (${lines.size})") .setContentText(lines.joinToString(" · ")) .setStyle(NotificationCompat.InboxStyle().also { s -> lines.forEach(s::addLine) }) .setCategory(NotificationCompat.CATEGORY_REMINDER) .setContentIntent(contentIntent) - .setAutoCancel(true) - .build() } /** Post if allowed; silently skipping is fine — channel-level blocks are user intent. */ diff --git a/app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt b/app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt index 3159bc4..3ccbd50 100644 --- a/app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt +++ b/app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt @@ -49,6 +49,11 @@ class SettingsStore(context: Context) { get() = prefs.getBoolean("backup_nudge_dismissed", false) set(v) = prefs.edit().putBoolean("backup_nudge_dismissed", v).apply() + /** Daily low-stock/rx check time, minutes after midnight. Default 10:00. */ + var supplyCheckMinuteOfDay: Int + get() = prefs.getInt("supply_check_minute", 600) + set(v) = prefs.edit().putInt("supply_check_minute", v.coerceIn(0, 1439)).apply() + val isBackupConfigured: Boolean get() = !s3Endpoint.isNullOrBlank() && !s3Bucket.isNullOrBlank() && !s3AccessKey.isNullOrBlank() && !s3SecretKey.isNullOrBlank() diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/ItemEditScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/ItemEditScreen.kt index a45e828..06b52c9 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/ItemEditScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/ItemEditScreen.kt @@ -27,6 +27,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import no.naiv.meddetsamme.R @@ -68,6 +70,15 @@ fun ItemEditScreen(nav: Nav, itemId: Long?) { ) { InventoryItemForm(form) + if (form.name.isNotBlank()) { + TextButton( + onClick = { context.startActivity(felleskatalogenIntent(form.name)) }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Slå opp ${form.name} i Felleskatalogen. Åpner nettleseren." }, + ) { Text("Slå opp i Felleskatalogen ↗") } + } + Button( onClick = { val item = form.buildItem(itemId ?: 0) ?: return@Button diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/ItemForm.kt b/app/src/main/java/no/naiv/meddetsamme/ui/ItemForm.kt index 4dd486b..26bd245 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/ItemForm.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/ItemForm.kt @@ -71,6 +71,20 @@ fun guessForm(festForm: String): MedForm = when { fun parseAmount(s: String): Double? = s.trim().replace(',', '.').toDoubleOrNull() +/** + * Browser link to Felleskatalogen's search for a preparation — patient-facing + * reading only. Decision #8 bans Felleskatalogen as a DATA source (licensed + * editorial content, no API); pointing the human at their website is fine. + */ +fun felleskatalogenIntent(name: String): android.content.Intent = + android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse( + "https://www.felleskatalogen.no/medisin/sok?sokord=" + + java.net.URLEncoder.encode(name.trim(), "UTF-8"), + ), + ) + /** * State + validation for an [InventoryItem] form — shared between the * inventory editor and inline item creation in the med editor, so the two diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt index c6891dd..d6b4310 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/MedEditScreen.kt @@ -164,6 +164,12 @@ fun MedEditScreen(nav: Nav, initialMedId: Long?) { "Lager: ${ScheduleText.amountText(item.stockUnits, item.unit)} — rediger varen på Lager-siden", style = MaterialTheme.typography.bodySmall, ) + TextButton( + onClick = { context.startActivity(felleskatalogenIntent(item.name)) }, + modifier = Modifier.semantics { + contentDescription = "Slå opp ${item.name} i Felleskatalogen. Åpner nettleseren." + }, + ) { Text("Slå opp i Felleskatalogen ↗") } } } } diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt index 9901332..6898673 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt @@ -173,6 +173,10 @@ fun SettingsScreen(nav: Nav) { modifier = Modifier.fillMaxWidth(), ) { Text("Importer fra fil …") } + HorizontalDivider() + Text("Varsler", style = MaterialTheme.typography.titleMedium) + SupplyCheckTimeRow(settings) + HorizontalDivider() Text("FEST-legemiddeldata", style = MaterialTheme.typography.titleMedium) OutlinedTextField( @@ -264,6 +268,48 @@ fun SettingsScreen(nav: Nav) { } } +/** Daily low-stock/rx notification time; re-arms the (replacing) alarm on change. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SupplyCheckTimeRow(settings: SettingsStore) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var minute by remember { mutableStateOf(settings.supplyCheckMinuteOfDay) } + var showPicker by remember { mutableStateOf(false) } + + androidx.compose.foundation.layout.Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Text( + "Daglig varsel om lager og resept: %02d:%02d".format(minute / 60, minute % 60), + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { showPicker = true }) { Text("Endre") } + } + + if (showPicker) { + val state = androidx.compose.material3.rememberTimePickerState( + initialHour = minute / 60, + initialMinute = minute % 60, + is24Hour = true, + ) + AlertDialog( + onDismissRequest = { showPicker = false }, + title = { Text("Tidspunkt for daglig varsel") }, + text = { androidx.compose.material3.TimePicker(state = state) }, + confirmButton = { + TextButton(onClick = { + minute = state.hour * 60 + state.minute + settings.supplyCheckMinuteOfDay = minute + showPicker = false + scope.launch { AlarmScheduler(context).armSupplyCheck() } + }) { Text("OK") } + }, + dismissButton = { TextButton(onClick = { showPicker = false }) { Text("Avbryt") } }, + ) + } +} + @Composable private fun PassphraseDialog( title: String,