From cbb241bc4d85a47d53ba2a58673f5bad23d0d1ec Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 12:41:52 +0200 Subject: [PATCH] feat(contacts): calendar-wide reminders for special-dates calendars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Managed calendars now treat reminders as a calendar-level setting instead of the seed-once-per-event model, matching how these homogeneous birthday/anniversary calendars are actually used. - New per-type "Reminders" control in the Contact special dates section. Changing it persists the per-calendar all-day override (so new events match) AND re-applies the set to *all existing events* in that calendar (CalendarDataSource.applyManagedCalendarReminders → SpecialDatesSyncEngine .applyReminders), encoding each event's all-day offset from its own date. - Settings → Notifications: the contact-date calendars no longer offer a per-calendar override row; they show a link that jumps to the Contact special dates section (managedCalendarIds now in SettingsUiState). Tests cover applyReminders (override persisted + bulk-apply invoked, and None clears). lint + unit tests + assembleDebug green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/calendar/CalendarDataSource.kt | 40 ++++++++++++ .../data/contacts/SpecialDatesSyncEngine.kt | 22 +++++++ .../calendula/ui/settings/SettingsScreen.kt | 62 ++++++++++++++++-- .../calendula/ui/settings/SettingsUiState.kt | 9 +++ .../ui/settings/SettingsViewModel.kt | 64 +++++++++++++------ app/src/main/res/values-de/strings.xml | 6 +- app/src/main/res/values/strings.xml | 6 +- .../data/calendar/FakeCalendarDataSource.kt | 12 ++++ .../contacts/SpecialDatesSyncEngineTest.kt | 33 ++++++++++ 9 files changed, 226 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 74e6d86..2f56ffa 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -29,6 +29,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt import kotlinx.datetime.toJavaLocalDate +import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset import java.util.UUID @@ -133,6 +134,19 @@ interface CalendarDataSource { */ fun updateManagedFields(eventId: Long, values: Map) + /** + * Replace the reminders on **every** event in [calendarId] with [minutes] + * (all-day lead times), each encoded from its own date so it fires at + * [allDayReminderTimeMinutes]. Used by the special-dates section so a reminder + * change applies to existing events too, not just future ones — the managed + * calendars treat reminders as a calendar-level setting. + */ + fun applyManagedCalendarReminders( + calendarId: Long, + allDayReminderTimeMinutes: Int, + minutes: List, + ) + /** * Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes] * (minutes from local midnight) is the wall-clock time all-day reminders @@ -456,6 +470,32 @@ class AndroidCalendarDataSource @Inject constructor( if (rows == 0) throw WriteFailedException("update managed event id=$eventId") } + override fun applyManagedCalendarReminders( + calendarId: Long, + allDayReminderTimeMinutes: Int, + minutes: List, + ) { + val zone = ZoneId.systemDefault() + resolver.query( + CalendarContract.Events.CONTENT_URI, + arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART), + "${CalendarContract.Events.CALENDAR_ID} = ? AND ${CalendarContract.Events.DELETED} = 0", + arrayOf(calendarId.toString()), + null, + )?.use { c -> + while (c.moveToNext()) { + val eventId = c.getLong(0) + // Managed events are all-day: DTSTART is a UTC midnight. + val startDate = Instant.ofEpochMilli(c.getLong(1)) + .atZone(ZoneOffset.UTC).toLocalDate() + val encoded = minutes + .map { toProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes) } + .distinct() + reconcileReminders(eventId, encoded) + } + } + } + override fun instances(beginMillis: Long, endMillis: Long): List { ensureObserversRegistered() val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt index 4a4b370..655a7a1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt @@ -90,6 +90,28 @@ class SpecialDatesSyncEngine @Inject constructor( return SpecialDatesSyncResult.Success } + /** + * Set the reminder default for a type's managed calendar and apply it to + * **all** its existing events too (not just future ones) — for managed + * calendars the reminder is a calendar-level setting. Persists the per-calendar + * all-day override so new events keep matching. No-op if the calendar for + * [type] doesn't exist yet. + */ + suspend fun applyReminders(type: SpecialDateType, override: CalendarReminderOverride) { + val calendarId = prefs.specialDatesCalendars.first()[type] ?: return + prefs.setCalendarAllDayReminderOverride(calendarId, override) + val minutes = when (override) { + CalendarReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first() + CalendarReminderOverride.None -> emptyList() + is CalendarReminderOverride.Minutes -> override.minutes + } + calendars.applyManagedCalendarReminders( + calendarId = calendarId, + allDayReminderTimeMinutes = prefs.allDayReminderTimeMinutes.first(), + minutes = minutes, + ) + } + /** Delete every managed calendar and forget its id — used when the feature is turned off. */ suspend fun teardown() { calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 9e096fd..4c0b4a3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Cake import androidx.compose.material.icons.filled.CalendarMonth @@ -180,7 +181,12 @@ fun SettingsScreen( enter = slideInHorizontally(slideSpec) { it } + fadeIn(), exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), ) { - NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null }) + NotificationsScreen( + state = state, + viewModel = viewModel, + onBack = { section = null }, + onOpenSpecialDates = { section = SettingsSection.SpecialDates }, + ) } AnimatedVisibility( visible = section == SettingsSection.SpecialDates, @@ -795,6 +801,7 @@ private fun NotificationsScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + onOpenSpecialDates: () -> Unit, ) { val context = LocalContext.current val launcher = rememberLauncherForActivityResult( @@ -919,6 +926,25 @@ private fun NotificationsScreen( Column { state.writableCalendars.forEach { calendar -> Spacer(Modifier.height(16.dp)) + // A contact special-dates calendar owns its reminders in + // its own section — link there instead of an override. + if (calendar.id in state.managedCalendarIds) { + GroupedRow( + title = calendar.displayName, + summary = stringResource(R.string.settings_calendar_reminders_managed_hint), + position = Position.Alone, + leading = { CalendarColorChip(calendar.color) }, + trailing = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = onOpenSpecialDates, + ) + return@forEach + } val expanded = calendar.id in expandedCalendars // Calendar card; tapping expands it into a grouped list // of three (the card + the timed and all-day rows). @@ -1074,6 +1100,7 @@ private fun SpecialDatesScreen( var confirmDisableAll by remember { mutableStateOf(false) } var confirmDisableType by remember { mutableStateOf(null) } var editTemplate by remember { mutableStateOf(null) } + var reminderPickerType by remember { mutableStateOf(null) } CollapsingScaffold( title = stringResource(R.string.settings_section_special_dates), @@ -1120,13 +1147,13 @@ private fun SpecialDatesScreen( ) if (state.enabled) { - Spacer(Modifier.height(24.dp)) - // Per-type toggles + their title templates. + // Per-type card: the toggle, and while on, its calendar-wide reminder. SpecialDateType.entries.forEachIndexed { index, type -> + Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp)) val on = type in state.types GroupedRow( title = stringResource(specialDateTypeLabel(type)), - position = if (index == 0) Position.Top else Position.Middle, + position = if (on) Position.Top else Position.Alone, trailing = { Switch( checked = on, @@ -1140,11 +1167,21 @@ private fun SpecialDatesScreen( if (on) confirmDisableType = type else viewModel.setSpecialDateTypeEnabled(type, true) }, ) + if (on) { + GroupedRow( + title = stringResource(R.string.settings_special_dates_reminders), + summary = reminderChoiceLabel(specialDatesReminderMinutes(state.reminderChoices[type])), + position = Position.Bottom, + onClick = { reminderPickerType = type }, + ) + } } + + Spacer(Modifier.height(24.dp)) GroupedRow( title = stringResource(R.string.settings_special_dates_template), summary = state.titleTemplates[SpecialDateType.Birthday].orEmpty(), - position = Position.Bottom, + position = Position.Alone, onClick = { editTemplate = SpecialDateType.Birthday }, ) @@ -1202,8 +1239,23 @@ private fun SpecialDatesScreen( onDismiss = { editTemplate = null }, ) } + reminderPickerType?.let { type -> + ReminderDefaultPicker( + title = stringResource(R.string.settings_special_dates_reminders), + presets = ALLDAY_REMINDER_PRESETS, + selected = state.reminderChoices[type] ?: CalendarReminderOverride.None, + // Managed calendars own their reminders outright — no "inherit global". + allowInherit = false, + onSelect = { viewModel.setSpecialDatesReminders(type, it) }, + onDismiss = { reminderPickerType = null }, + ) + } } +/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */ +private fun specialDatesReminderMinutes(choice: CalendarReminderOverride?): List = + (choice as? CalendarReminderOverride.Minutes)?.minutes.orEmpty() + @Composable private fun SpecialDatesDisableDialog( message: String, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index ad153ce..e516ef7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.settings +import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.ThemeMode @@ -70,6 +71,12 @@ data class SettingsUiState( * colour palette (the colour may then not survive their next sync). */ val allowColorOnUnsupportedCalendars: Boolean = false, + /** + * Ids of the contact special-dates calendars. Their reminders are owned by + * the Contact special dates section, so the per-calendar override list links + * out to it instead of offering an override here. + */ + val managedCalendarIds: Set = emptySet(), ) /** @@ -82,6 +89,8 @@ data class SpecialDatesUiState( val enabled: Boolean = false, val types: Set = SpecialDateType.entries.toSet(), val titleTemplates: Map = emptyMap(), + /** The reminder choice per type's managed calendar (applied to all its events). */ + val reminderChoices: Map = emptyMap(), val showAge: Boolean = true, /** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */ val stalledPermission: Boolean = false, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 9e80d2e..f029392 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -97,8 +97,9 @@ class SettingsViewModel @Inject constructor( prefs.perCalendarReminderOverride, prefs.perCalendarAllDayReminderOverride, writableCalendars, - ) { overrides, allDayOverrides, calendars -> - ReminderOverrides(overrides, allDayOverrides, calendars) + prefs.managedCalendarIds, + ) { overrides, allDayOverrides, calendars, managedIds -> + ReminderOverrides(overrides, allDayOverrides, calendars, managedIds) }, combine( prefs.defaultView, @@ -135,6 +136,7 @@ class SettingsViewModel @Inject constructor( perCalendarReminderOverride = overrides.timed, perCalendarAllDayReminderOverride = overrides.allDay, writableCalendars = overrides.calendars, + managedCalendarIds = overrides.managedIds, ) }.stateIn( scope = viewModelScope, @@ -160,6 +162,7 @@ class SettingsViewModel @Inject constructor( val timed: Map>, val allDay: Map>, val calendars: List, + val managedIds: Set, ) private data class ViewSettings( @@ -179,24 +182,39 @@ class SettingsViewModel @Inject constructor( /** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */ val specialDatesState: StateFlow = combine( - prefs.specialDatesEnabled, - prefs.specialDatesTypes, - prefs.specialDatesTitleTemplates, - prefs.specialDatesShowAge, - prefs.specialDatesStatus, - ) { enabled, types, templates, showAge, status -> - SpecialDatesUiState( - enabled = enabled, - types = types, - // A blank stored template resolves to the localized default so the - // editor always shows something sensible. - titleTemplates = SpecialDateType.entries.associateWith { type -> - templates[type]?.takeIf { it.isNotBlank() } - ?: specialDatesSpec.defaultTitleTemplate(type) + combine( + prefs.specialDatesEnabled, + prefs.specialDatesTypes, + prefs.specialDatesTitleTemplates, + prefs.specialDatesShowAge, + prefs.specialDatesStatus, + ) { enabled, types, templates, showAge, status -> + SpecialDatesUiState( + enabled = enabled, + types = types, + // A blank stored template resolves to the localized default so the + // editor always shows something sensible. + titleTemplates = SpecialDateType.entries.associateWith { type -> + templates[type]?.takeIf { it.isNotBlank() } + ?: specialDatesSpec.defaultTitleTemplate(type) + }, + showAge = showAge, + stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked, + lastRun = status.lastRun, + ) + }, + prefs.specialDatesCalendars, + prefs.perCalendarAllDayReminderOverride, + ) { base, calendars, allDayOverrides -> + base.copy( + reminderChoices = calendars.mapValues { (_, calendarId) -> + // An override is always seeded on creation; present-empty = None. + when (val minutes = allDayOverrides[calendarId]) { + null -> CalendarReminderOverride.Inherit + emptyList() -> CalendarReminderOverride.None + else -> CalendarReminderOverride.Minutes(minutes) + } }, - showAge = showAge, - stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked, - lastRun = status.lastRun, ) }.stateIn( scope = viewModelScope, @@ -245,6 +263,14 @@ class SettingsViewModel @Inject constructor( } } + /** + * Set a type's managed-calendar reminder default and apply it to all its + * existing events (managed calendars own their reminders calendar-wide). + */ + fun setSpecialDatesReminders(type: SpecialDateType, override: CalendarReminderOverride) { + viewModelScope.launch { withContext(io) { specialDatesEngine.applyReminders(type, override) } } + } + /** Kick off an immediate reconcile (the "Sync now" action). */ fun syncSpecialDatesNow() = SpecialDatesScheduler.runNow(appContext) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d8246bb..93dbdf6 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -428,13 +428,15 @@ Weitere Tage Titelformat Verwende {name} für den Kontakt und {age} für das Alter (ausgeblendet, wenn das Jahr unbekannt ist). - Hinweis: {age} ist das Alter zum nächsten Termin beim letzten Sync. Da ein jährlicher Termin nur einen Titel hat, kann es bei weit entfernten Terminen unpassend wirken. + Hinweis: Das Alter ist ein einzelner Wert vom letzten Sync — es wird nicht für jedes Jahr aktualisiert. Jeder jährliche Termin zeigt daher dieselbe Zahl, was bei weiter zurück- oder vorausliegenden Terminen falsch sein kann. + Erinnerungen Alter anzeigen {age} in Titeln anzeigen, wenn das Geburtsjahr bekannt ist Jetzt synchronisieren Noch nicht synchronisiert Zuletzt synchronisiert %1$s - Farbe, Sichtbarkeit und Erinnerungen jedes Kalenders legst du in den Kalender-Einstellungen fest. + Farbe und Sichtbarkeit jedes Kalenders legst du in den Kalender-Einstellungen fest. + In Besondere Kontakttage festgelegt Pausiert Calendula kann deine Kontakte nicht mehr lesen, daher werden diese Kalender nicht aktualisiert. Zugriff erlauben diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b4c8f0c..6dbd4b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -357,14 +357,16 @@ Other dates Title format Use {name} for the contact and {age} for their age (hidden when the year is unknown). - Note: {age} is the age at the next date as of the last sync. Because a yearly event has one title, it can look off on occurrences far in the future. + Note: the age is a single value set at the last sync — it isn\'t updated for each year, so every yearly occurrence shows the same number and it can be wrong for dates further in the past or future. + Reminders Show age Include {age} in titles when the birth year is known Sync now Not synced yet Last synced %1$s - Set each calendar\'s colour, visibility and reminders in Calendars settings. + Set each calendar\'s colour and visibility in Calendars settings. + Set in Contact special dates Paused Calendula can no longer read your contacts, so these calendars aren\'t updating. Grant access diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index edbff7f..f5a023a 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -190,6 +190,18 @@ internal class FakeCalendarDataSource : CalendarDataSource { return id } + /** (calendarId, allDayTime, minutes) recorded from applyManagedCalendarReminders. */ + val appliedManagedReminders = mutableListOf>>() + + override fun applyManagedCalendarReminders( + calendarId: Long, + allDayReminderTimeMinutes: Int, + minutes: List, + ) { + writeError?.let { throw it } + appliedManagedReminders += Triple(calendarId, allDayReminderTimeMinutes, minutes) + } + override fun updateManagedFields(eventId: Long, values: Map) { writeError?.let { throw it } if (values.isEmpty()) return diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt index c24a411..ad953bc 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt @@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.Preferences import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow +import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType @@ -163,6 +164,38 @@ class SpecialDatesSyncEngineTest { assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary) } + @Test + fun `applyReminders persists the override and applies it to all events`(@TempDir tempDir: Path) = runTest { + val calendars = FakeCalendarDataSource() + val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990))) + val (engine, settings) = engineWith(tempDir, contacts, calendars) + engine.sync(today) + val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday) + + engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.Minutes(listOf(0, 1440))) + + // Persisted as the per-calendar all-day override (so new events match)... + assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal]) + .isEqualTo(listOf(0, 1440)) + // ...and applied to the existing events in that calendar. + assertThat(calendars.appliedManagedReminders).contains( + Triple(birthdayCal, settings.allDayReminderTimeMinutes.first(), listOf(0, 1440)), + ) + } + + @Test + fun `applyReminders with None clears the reminders`(@TempDir tempDir: Path) = runTest { + val calendars = FakeCalendarDataSource() + val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars) + engine.sync(today) + val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday) + + engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.None) + + assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal]).isEmpty() + assertThat(calendars.appliedManagedReminders.last().third).isEmpty() + } + // --- pure diff --- private fun desired(uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =