feat(calendars): reminder settings (enable + lead time) and store tests

#5 Configurable reminder lead time + #6 enable/disable toggle:
- CalendarReminderPrefs is a device-level SharedPreferences wrapper
  with isEnabled / leadMinutes accessors. Device scope (rather than
  per-account) because the worker that consults it runs globally —
  per-account preferences would require account-context plumbing into
  WorkManager that the rest of the app doesn't have today.
- CalendarReminderWorker now reads enabled + lead-minutes on each
  cycle. When disabled the worker short-circuits to Result.success()
  rather than cancelling itself — flipping the toggle back on takes
  effect immediately without a relaunch.
- New CalendarReminderSettingsScreen: a Switch for enabled, a row of
  FilterChips for the 5/15/30/60-minute lead-time choices. The lead-
  time row disables when reminders are off.
- New Route.CalendarReminderSettings wired through AppNavigation and
  surfaced as an entry in the existing AllSettingsScreen list under
  the notification settings divider.

#7 Tests:
- CalendarReminderPrefsTest exercises the prefs round-trip
  (defaults, set/get of enabled and lead-minutes) and the store
  contract (wasNotified false-by-default, true after markNotified,
  false again when start changes, and forgetBefore pruning).
- Backed by an in-memory FakeSharedPreferences so the tests run on
  the JVM without Robolectric. mockk stubs the Context to hand back
  the fake prefs.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
This commit is contained in:
Claude
2026-05-19 21:58:33 +00:00
parent a5f03b21cc
commit 3315ccccec
8 changed files with 472 additions and 7 deletions
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.calendar
import android.content.Context
import android.content.SharedPreferences
/**
* Device-wide preferences for the calendar reminder worker.
*
* Stored at device scope (rather than per-account) because the worker that consults them runs
* globally — multiplexing per-account preferences would require account-context plumbing into
* WorkManager that the rest of the app doesn't have. A user who flips between two accounts on
* the same device shares the same lead-time and enabled-state. Per-account preferences could be
* a follow-up if anyone asks.
*/
class CalendarReminderPrefs(
context: Context,
) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)
fun setEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_ENABLED, enabled).apply()
}
fun leadMinutes(): Int = prefs.getInt(KEY_LEAD_MINUTES, DEFAULT_LEAD_MINUTES)
fun setLeadMinutes(minutes: Int) {
prefs.edit().putInt(KEY_LEAD_MINUTES, minutes).apply()
}
companion object {
const val DEFAULT_LEAD_MINUTES = 15
const val DEFAULT_ENABLED = true
// Choices presented in the settings UI. Anchored to the worker cadence — lead times
// smaller than the cadence (15 min) can't be honoured reliably; 60 is the largest the
// UX shape supports without an extra "hours" picker.
val LEAD_TIME_CHOICES = listOf(5, 15, 30, 60)
private const val PREF_NAME = "amethyst_calendar_reminder_prefs"
private const val KEY_ENABLED = "enabled"
private const val KEY_LEAD_MINUTES = "lead_minutes"
}
}
@@ -52,8 +52,13 @@ class CalendarReminderWorker(
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val prefs = CalendarReminderPrefs(applicationContext)
if (!prefs.isEnabled()) {
Log.d(TAG) { "Reminders disabled; skipping scan." }
return Result.success()
}
val now = TimeUtils.now()
val windowEnd = now + LEAD_TIME_SECONDS
val windowEnd = now + prefs.leadMinutes() * 60L
val store = CalendarReminderStore(applicationContext)
// Walk every kind-31925 RSVP authored by an account on this device. We don't have a
@@ -67,7 +72,7 @@ class CalendarReminderWorker(
e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED
}.mapNotNull { it.event as? CalendarRSVPEvent }
Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${LEAD_TIME_SECONDS}s)" }
Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${prefs.leadMinutes()}m)" }
acceptedRsvps.forEach { rsvp ->
val targetAddress = rsvp.calendarEventAddress() ?: return@forEach
@@ -110,11 +115,6 @@ class CalendarReminderWorker(
private const val TAG = "CalendarReminderWorker"
private const val WORK_NAME = "calendar_reminder_worker"
// 15 minutes — the WorkManager periodic minimum is also 15 min, so the worst-case
// latency is one full cycle. Calendar apps typically use 5/10/15 min lead options;
// we hard-code 15 to match the worker cadence.
private const val LEAD_TIME_SECONDS = 15L * 60L
// Don't bother remembering "I notified for this" entries for events whose start was
// more than a day ago; they can't fire again so the entry is pure overhead.
private const val PRUNE_AGE_SECONDS = 24L * 60L * 60L
@@ -74,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipMa
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarCollectionScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarEventScreen
@@ -259,6 +260,7 @@ fun BuildNavigation(
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarCollections> { CalendarCollectionsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarReminderSettings> { CalendarReminderSettingsScreen(nav) }
composableFromEndArgs<Route.CalendarEventDetail> {
CalendarEventDetailScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav)
}
@@ -282,6 +282,8 @@ sealed class Route {
@Serializable object NotificationSettings : Route()
@Serializable object CalendarReminderSettings : Route()
@Serializable object Lists : Route()
@Serializable data class MyPeopleListView(
@@ -0,0 +1,147 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.stringRes
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarReminderSettingsScreen(nav: INav) {
val context = LocalContext.current
val prefs = remember { CalendarReminderPrefs(context) }
var enabled by remember { mutableStateOf(prefs.isEnabled()) }
var leadMinutes by remember { mutableIntStateOf(prefs.leadMinutes()) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.calendar_reminder_settings_title)) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(R.string.back),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
},
)
},
) { pad ->
Column(
modifier =
Modifier
.padding(pad)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.calendar_reminder_settings_enabled_title),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.calendar_reminder_settings_enabled_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = enabled,
onCheckedChange = {
enabled = it
prefs.setEnabled(it)
// Toggling off doesn't cancel the worker — the worker itself short-
// circuits when isEnabled() returns false. Keeping the schedule alive
// means flipping it back on takes effect immediately without needing a
// re-launch via AppModules.
if (it) CalendarReminderWorker.schedule(context)
},
)
}
Text(
text = stringRes(R.string.calendar_reminder_settings_lead_title),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 8.dp),
)
Text(
text = stringRes(R.string.calendar_reminder_settings_lead_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CalendarReminderPrefs.LEAD_TIME_CHOICES.forEach { choice ->
FilterChip(
selected = choice == leadMinutes,
onClick = {
leadMinutes = choice
prefs.setLeadMinutes(choice)
},
enabled = enabled,
label = {
Text(stringRes(R.string.calendar_reminder_settings_lead_choice, choice))
},
)
}
}
}
}
}
@@ -224,6 +224,12 @@ fun AllSettingsScreen(
onClick = { nav.nav(Route.NotificationSettings) },
)
SettingsDivider()
SettingsItem(
title = R.string.calendar_reminder_settings_title,
icon = MaterialSymbols.CalendarMonth,
onClick = { nav.nav(Route.CalendarReminderSettings) },
)
SettingsDivider()
SettingsItem(
title = R.string.compose_settings,
icon = MaterialSymbols.Edit,
+6
View File
@@ -1999,6 +1999,12 @@
<string name="calendar_event_participant_input">npub or hex pubkey</string>
<string name="calendar_event_participant_invalid">Enter a valid npub… or 64-character hex pubkey.</string>
<string name="calendar_event_participant_remove">Remove participant</string>
<string name="calendar_reminder_settings_title">Calendar reminders</string>
<string name="calendar_reminder_settings_enabled_title">Send reminders</string>
<string name="calendar_reminder_settings_enabled_subtitle">A notification fires when an event you\'re attending is about to start.</string>
<string name="calendar_reminder_settings_lead_title">Reminder lead time</string>
<string name="calendar_reminder_settings_lead_subtitle">How many minutes before the event you want to be notified.</string>
<string name="calendar_reminder_settings_lead_choice">%1$d min</string>
<string name="calendar_open_in_maps">Open in maps</string>
<string name="route_calendar_event_detail">Event details</string>
<string name="new_short_video">New Short Video</string>
@@ -0,0 +1,237 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.calendar
import android.content.Context
import android.content.SharedPreferences
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderStore
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Unit tests for the device-level reminder preferences and the per-event "already notified"
* store. Backed by an in-memory fake [SharedPreferences] so the test runs on the JVM without
* needing Robolectric.
*/
class CalendarReminderPrefsTest {
private lateinit var fakePrefs: FakeSharedPreferences
private lateinit var ctx: Context
@Before
fun setUp() {
fakePrefs = FakeSharedPreferences()
ctx = mockk()
every { ctx.getSharedPreferences(any(), any()) } returns fakePrefs
}
@Test
fun prefs_defaultsMatchPublicConstants() {
val prefs = CalendarReminderPrefs(ctx)
// Defaults are the contract callers in AppModules rely on — flipping these without an
// explicit migration would silently re-enable reminders for users who had turned them
// off (or vice versa).
assertEquals(CalendarReminderPrefs.DEFAULT_ENABLED, prefs.isEnabled())
assertEquals(CalendarReminderPrefs.DEFAULT_LEAD_MINUTES, prefs.leadMinutes())
}
@Test
fun prefs_setEnabled_roundTrips() {
val prefs = CalendarReminderPrefs(ctx)
prefs.setEnabled(false)
assertFalse(prefs.isEnabled())
prefs.setEnabled(true)
assertTrue(prefs.isEnabled())
}
@Test
fun prefs_setLeadMinutes_roundTrips() {
val prefs = CalendarReminderPrefs(ctx)
prefs.setLeadMinutes(30)
assertEquals(30, prefs.leadMinutes())
}
@Test
fun store_wasNotified_isFalseByDefault() {
val store = CalendarReminderStore(ctx)
assertFalse(store.wasNotified("event-a", 1_000_000L))
}
@Test
fun store_markNotified_makesWasNotifiedTrueForSameStart() {
val store = CalendarReminderStore(ctx)
store.markNotified("event-a", 1_000_000L)
assertTrue(store.wasNotified("event-a", 1_000_000L))
}
@Test
fun store_wasNotified_isFalseWhenStartChanges() {
// Regression test for the "moved meeting" case: if the author updates the appointment
// with a new start, the store should not silently swallow the new reminder.
val store = CalendarReminderStore(ctx)
store.markNotified("event-a", 1_000_000L)
assertFalse(store.wasNotified("event-a", 2_000_000L))
}
@Test
fun store_forgetBefore_dropsOldEntries() {
val store = CalendarReminderStore(ctx)
store.markNotified("old", 1_000_000L)
store.markNotified("recent", 5_000_000L)
store.forgetBefore(3_000_000L)
assertFalse(store.wasNotified("old", 1_000_000L))
assertTrue(store.wasNotified("recent", 5_000_000L))
}
}
/**
* Bare-bones in-memory implementation of [SharedPreferences] sufficient for the prefs/store
* round-trip tests. apply() is synchronous here fine because the production code never relies
* on apply()'s async semantics.
*/
private class FakeSharedPreferences : SharedPreferences {
private val data = mutableMapOf<String, Any?>()
override fun getAll(): MutableMap<String, *> = data
override fun getString(
key: String,
defValue: String?,
): String? = data[key] as? String ?: defValue
override fun getStringSet(
key: String,
defValues: MutableSet<String>?,
): MutableSet<String>? {
@Suppress("UNCHECKED_CAST")
return data[key] as? MutableSet<String> ?: defValues
}
override fun getInt(
key: String,
defValue: Int,
): Int = (data[key] as? Int) ?: defValue
override fun getLong(
key: String,
defValue: Long,
): Long = (data[key] as? Long) ?: defValue
override fun getFloat(
key: String,
defValue: Float,
): Float = (data[key] as? Float) ?: defValue
override fun getBoolean(
key: String,
defValue: Boolean,
): Boolean = (data[key] as? Boolean) ?: defValue
override fun contains(key: String): Boolean = data.containsKey(key)
override fun edit(): SharedPreferences.Editor = FakeEditor(data)
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
}
private class FakeEditor(
private val data: MutableMap<String, Any?>,
) : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
private val removed = mutableSetOf<String>()
private var clearAll = false
override fun putString(
key: String,
value: String?,
): SharedPreferences.Editor {
pending[key] = value
return this
}
override fun putStringSet(
key: String,
values: MutableSet<String>?,
): SharedPreferences.Editor {
pending[key] = values
return this
}
override fun putInt(
key: String,
value: Int,
): SharedPreferences.Editor {
pending[key] = value
return this
}
override fun putLong(
key: String,
value: Long,
): SharedPreferences.Editor {
pending[key] = value
return this
}
override fun putFloat(
key: String,
value: Float,
): SharedPreferences.Editor {
pending[key] = value
return this
}
override fun putBoolean(
key: String,
value: Boolean,
): SharedPreferences.Editor {
pending[key] = value
return this
}
override fun remove(key: String): SharedPreferences.Editor {
removed.add(key)
return this
}
override fun clear(): SharedPreferences.Editor {
clearAll = true
return this
}
override fun commit(): Boolean {
apply()
return true
}
override fun apply() {
if (clearAll) data.clear()
removed.forEach { data.remove(it) }
data.putAll(pending)
}
}