Compare commits

..

No commits in common. "041f9e2cf68b63435f989c617f364d28846781e5" and "5b9a3d6877a28636c9d1ace9cb8d04e7d63fbd63" have entirely different histories.

10 changed files with 66 additions and 372 deletions

View file

@ -33,8 +33,8 @@ android {
targetSdk = 35
// Bump both for every release installed on the phone — versionName for
// humans, versionCode so dumpsys/install history can tell builds apart.
versionCode = 6
versionName = "0.6.0"
versionCode = 5
versionName = "0.5.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View file

@ -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)
}
}

View file

@ -90,9 +90,7 @@ class AlarmScheduler(private val context: Context) {
// 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
// (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
// automatic repeat the setting governs, so honour it here too.
if (!no.naiv.meddetsamme.settings.SettingsStore(context).repeatReminders) return
// handles it when it actually falls due.
val now = System.currentTimeMillis()
db.doseLogDao().getUnresolved().forEach { log ->
if (log.nagCount < NAG_CAP && log.scheduledAtMillis <= now) {

View file

@ -9,41 +9,42 @@ import kotlinx.coroutines.launch
import no.naiv.meddetsamme.domain.DoseActions
/**
* Handles the notification actions. The dose card groups every med due at the
* 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".
* Handles the notification actions (and later, the same actions from the UI).
*
* 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.
* - Snooze SNOOZED, next nag in 15 min, keeps escalating (and counting) after.
* - Mute "Ikke forstyrr": stop escalating, dose stays unresolved.
* (Skip stays a per-dose UI action; the notification never offers it.)
* - Skip SKIPPED, stop escalating, no inventory change.
*/
class DoseActionReceiver : BroadcastReceiver() {
companion object {
const val ACTION_TAKEN = "no.naiv.meddetsamme.action.TAKEN"
const val ACTION_SNOOZE = "no.naiv.meddetsamme.action.SNOOZE"
const val ACTION_MUTE = "no.naiv.meddetsamme.action.MUTE"
const val EXTRA_SCHEDULED_AT = "scheduledAt"
const val ACTION_SKIP = "no.naiv.meddetsamme.action.SKIP"
const val EXTRA_LOG_ID = "logId"
}
override fun onReceive(context: Context, intent: Intent) {
val scheduledAt = intent.getLongExtra(EXTRA_SCHEDULED_AT, -1)
if (scheduledAt < 0) return
val logId = intent.getLongExtra(EXTRA_LOG_ID, -1)
if (logId < 0) return
val action = intent.action ?: return
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
val actions = DoseActions(context)
when (action) {
ACTION_TAKEN -> actions.takeAll(scheduledAt)
ACTION_SNOOZE -> actions.snoozeAll(scheduledAt)
ACTION_MUTE -> actions.muteAll(scheduledAt)
}
handle(context, action, logId)
} finally {
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)
}
}
}

View file

@ -80,12 +80,12 @@ class DoseAlarmReceiver : BroadcastReceiver() {
else -> existing
}
postOccurrence(context, db, scheduledAt)
// Start the automatic re-nag chain only if the user wants repeats; an
// explicit Snooze still re-arms regardless (DoseActions.snooze).
if (repeatReminders(context) && log.nagCount < AlarmScheduler.NAG_CAP) {
scheduler.armEscalation(log.id)
}
Notifications.post(
context,
Notifications.doseNotificationId(log.id),
Notifications.buildDoseNotification(context, med, log),
)
if (log.nagCount < AlarmScheduler.NAG_CAP) scheduler.armEscalation(log.id)
}
private suspend fun escalate(context: Context, logId: Long) {
@ -98,36 +98,15 @@ class DoseAlarmReceiver : BroadcastReceiver() {
// depth against any path that arms one too early).
if (log.scheduledAtMillis > System.currentTimeMillis()) return
val med = db.medicationDao().getById(log.medId) ?: return
val nagged = log.copy(nagCount = log.nagCount + 1)
db.doseLogDao().setNagCount(logId, nagged.nagCount)
// Re-post the whole occurrence so co-timed siblings stay on one card.
postOccurrence(context, db, log.scheduledAtMillis)
// Re-arm the next automatic nag only while repeats are enabled. A nag
// already in flight from a Snooze still fires once (this post), then
// stops here — Snooze stays a single deferral, not an endless chain.
if (repeatReminders(context) && nagged.nagCount < AlarmScheduler.NAG_CAP) {
AlarmScheduler(context).armEscalation(logId)
Notifications.post(
context,
Notifications.doseNotificationId(logId),
Notifications.buildDoseNotification(context, med, nagged),
)
if (nagged.nagCount < AlarmScheduler.NAG_CAP) 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
}

View file

@ -22,22 +22,6 @@ interface DoseLogDao {
@Query("SELECT * FROM dose_log WHERE status IN ('PENDING', 'SNOOZED') ORDER BY scheduledAtMillis")
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")
fun observeRange(fromMillis: Long, toMillis: Long): Flow<List<DoseLog>>

View file

@ -35,44 +35,27 @@ class DoseActions(private val context: Context) {
return db.doseLogDao().getById(id)!!
}
// ---- single-dose actions (the app's Today screen acts per dose) --------
suspend fun take(logId: Long) {
val log = db.doseLogDao().getById(logId) ?: return
takeState(log)
refreshOccurrence(log.scheduledAtMillis)
if (log.status == DoseStatus.TAKEN) return // double-tap must not decrement twice
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) {
val log = db.doseLogDao().getById(logId) ?: return
snoozeState(log)
// Snooze dismisses now; the re-nag brings the card back in 15 min.
Notifications.cancel(context, Notifications.doseNotificationId(log.scheduledAtMillis))
db.doseLogDao().setStatus(logId, DoseStatus.SNOOZED, System.currentTimeMillis())
scheduler.armEscalation(logId, delayMinutes = AlarmScheduler.SNOOZE_MINUTES)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
}
suspend fun skip(logId: Long) {
val log = db.doseLogDao().getById(logId) ?: return
db.doseLogDao().setStatus(logId, DoseStatus.SKIPPED, System.currentTimeMillis())
scheduler.cancelEscalation(logId)
refreshOccurrence(log.scheduledAtMillis)
}
// ---- 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)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
}
/** 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)
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))
}
}

View file

@ -23,14 +23,8 @@ object Notifications {
const val CHANNEL_DOSES = "doses"
const val CHANNEL_SUPPLY = "supply"
/**
* Stable per-OCCURRENCE notification id (minute resolution): every med due
* 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()
/** 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
@ -65,67 +59,37 @@ object Notifications {
}
/**
* The dose reminder card for one occurrence. [entries] are all the meds due
* at the same wall-clock minute (one entry = a normal single card; several =
* a grouped card with "alle" actions). Actions carry the scheduledAtMillis,
* 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.
* 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,
entries: List<Pair<MedWithItem, DoseLog>>,
scheduledAtMillis: Long,
alertAgain: Boolean = true,
): Notification {
require(entries.isNotEmpty()) { "Dose notification needs at least one med" }
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")
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})")
}
val title: String
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(
fun action(action: String): PendingIntent = PendingIntent.getBroadcast(
context,
// One requestCode per occurrence; the Intent action keeps the
// Tatt/Utsett/Ikke-forstyrr PendingIntents distinct (filterEquals).
doseNotificationId(scheduledAtMillis),
// Distinct requestCode per (log, action); same (log, action) replaces.
(log.id * 10 + action.hashCode().mod(10)).toInt(),
Intent(context, DoseActionReceiver::class.java)
.setAction(actionStr)
.putExtra(DoseActionReceiver.EXTRA_SCHEDULED_AT, scheduledAtMillis),
.setAction(action)
.putExtra(DoseActionReceiver.EXTRA_LOG_ID, log.id),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val contentIntent = PendingIntent.getActivity(
context,
doseNotificationId(scheduledAtMillis),
doseNotificationId(log.id),
Intent(context, MainActivity::class.java),
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)
.setContentTitle(title)
.setContentText(body)
@ -134,22 +98,9 @@ object Notifications {
.setContentIntent(contentIntent)
.setAutoCancel(false)
.setOngoing(false)
.setOnlyAlertOnce(!alertAgain)
.addAction(0, if (grouped) "Tatt alle" else "Tatt", action(DoseActionReceiver.ACTION_TAKEN))
.addAction(0, if (grouped) "Utsett alle" else "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE))
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()
.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. */

View file

@ -55,17 +55,6 @@ class SettingsStore(context: Context) {
get() = prefs.getInt("supply_check_minute", 600)
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. */
val isS3Configured: Boolean
get() = !s3Endpoint.isNullOrBlank() && !s3Bucket.isNullOrBlank() &&

View file

@ -21,7 +21,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
@ -118,7 +117,6 @@ fun SettingsScreen(nav: Nav) {
HorizontalDivider()
Text("Varsler", style = MaterialTheme.typography.titleMedium)
RepeatRemindersRow(settings)
SupplyCheckTimeRow(settings)
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. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable