feat(contacts): calendar-wide reminders for special-dates calendars

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 12:41:52 +02:00
parent 0fd579b19b
commit cbb241bc4d
9 changed files with 226 additions and 28 deletions

View File

@@ -29,6 +29,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
@@ -133,6 +134,19 @@ interface CalendarDataSource {
*/ */
fun updateManagedFields(eventId: Long, values: Map<String, Any?>) fun updateManagedFields(eventId: Long, values: Map<String, Any?>)
/**
* 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<Int>,
)
/** /**
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes] * Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
* (minutes from local midnight) is the wall-clock time all-day reminders * (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") if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
} }
override fun applyManagedCalendarReminders(
calendarId: Long,
allDayReminderTimeMinutes: Int,
minutes: List<Int>,
) {
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<EventInstance> { override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
ensureObserversRegistered() ensureObserversRegistered()
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {

View File

@@ -90,6 +90,28 @@ class SpecialDatesSyncEngine @Inject constructor(
return SpecialDatesSyncResult.Success 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. */ /** Delete every managed calendar and forget its id — used when the feature is turned off. */
suspend fun teardown() { suspend fun teardown() {
calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) } calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) }

View File

@@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.BugReport
import androidx.compose.material.icons.filled.Cake import androidx.compose.material.icons.filled.Cake
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
@@ -180,7 +181,12 @@ fun SettingsScreen(
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), 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( AnimatedVisibility(
visible = section == SettingsSection.SpecialDates, visible = section == SettingsSection.SpecialDates,
@@ -795,6 +801,7 @@ private fun NotificationsScreen(
state: SettingsUiState, state: SettingsUiState,
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
onOpenSpecialDates: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
@@ -919,6 +926,25 @@ private fun NotificationsScreen(
Column { Column {
state.writableCalendars.forEach { calendar -> state.writableCalendars.forEach { calendar ->
Spacer(Modifier.height(16.dp)) 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 val expanded = calendar.id in expandedCalendars
// Calendar card; tapping expands it into a grouped list // Calendar card; tapping expands it into a grouped list
// of three (the card + the timed and all-day rows). // of three (the card + the timed and all-day rows).
@@ -1074,6 +1100,7 @@ private fun SpecialDatesScreen(
var confirmDisableAll by remember { mutableStateOf(false) } var confirmDisableAll by remember { mutableStateOf(false) }
var confirmDisableType by remember { mutableStateOf<SpecialDateType?>(null) } var confirmDisableType by remember { mutableStateOf<SpecialDateType?>(null) }
var editTemplate by remember { mutableStateOf<SpecialDateType?>(null) } var editTemplate by remember { mutableStateOf<SpecialDateType?>(null) }
var reminderPickerType by remember { mutableStateOf<SpecialDateType?>(null) }
CollapsingScaffold( CollapsingScaffold(
title = stringResource(R.string.settings_section_special_dates), title = stringResource(R.string.settings_section_special_dates),
@@ -1120,13 +1147,13 @@ private fun SpecialDatesScreen(
) )
if (state.enabled) { if (state.enabled) {
Spacer(Modifier.height(24.dp)) // Per-type card: the toggle, and while on, its calendar-wide reminder.
// Per-type toggles + their title templates.
SpecialDateType.entries.forEachIndexed { index, type -> SpecialDateType.entries.forEachIndexed { index, type ->
Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp))
val on = type in state.types val on = type in state.types
GroupedRow( GroupedRow(
title = stringResource(specialDateTypeLabel(type)), title = stringResource(specialDateTypeLabel(type)),
position = if (index == 0) Position.Top else Position.Middle, position = if (on) Position.Top else Position.Alone,
trailing = { trailing = {
Switch( Switch(
checked = on, checked = on,
@@ -1140,11 +1167,21 @@ private fun SpecialDatesScreen(
if (on) confirmDisableType = type else viewModel.setSpecialDateTypeEnabled(type, true) 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( GroupedRow(
title = stringResource(R.string.settings_special_dates_template), title = stringResource(R.string.settings_special_dates_template),
summary = state.titleTemplates[SpecialDateType.Birthday].orEmpty(), summary = state.titleTemplates[SpecialDateType.Birthday].orEmpty(),
position = Position.Bottom, position = Position.Alone,
onClick = { editTemplate = SpecialDateType.Birthday }, onClick = { editTemplate = SpecialDateType.Birthday },
) )
@@ -1202,8 +1239,23 @@ private fun SpecialDatesScreen(
onDismiss = { editTemplate = null }, 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<Int> =
(choice as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
@Composable @Composable
private fun SpecialDatesDisableDialog( private fun SpecialDatesDisableDialog(
message: String, message: String,

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.settings 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.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode 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). * colour palette (the colour may then not survive their next sync).
*/ */
val allowColorOnUnsupportedCalendars: Boolean = false, 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<Long> = emptySet(),
) )
/** /**
@@ -82,6 +89,8 @@ data class SpecialDatesUiState(
val enabled: Boolean = false, val enabled: Boolean = false,
val types: Set<SpecialDateType> = SpecialDateType.entries.toSet(), val types: Set<SpecialDateType> = SpecialDateType.entries.toSet(),
val titleTemplates: Map<SpecialDateType, String> = emptyMap(), val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
/** The reminder choice per type's managed calendar (applied to all its events). */
val reminderChoices: Map<SpecialDateType, CalendarReminderOverride> = emptyMap(),
val showAge: Boolean = true, val showAge: Boolean = true,
/** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */ /** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
val stalledPermission: Boolean = false, val stalledPermission: Boolean = false,

View File

@@ -97,8 +97,9 @@ class SettingsViewModel @Inject constructor(
prefs.perCalendarReminderOverride, prefs.perCalendarReminderOverride,
prefs.perCalendarAllDayReminderOverride, prefs.perCalendarAllDayReminderOverride,
writableCalendars, writableCalendars,
) { overrides, allDayOverrides, calendars -> prefs.managedCalendarIds,
ReminderOverrides(overrides, allDayOverrides, calendars) ) { overrides, allDayOverrides, calendars, managedIds ->
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
}, },
combine( combine(
prefs.defaultView, prefs.defaultView,
@@ -135,6 +136,7 @@ class SettingsViewModel @Inject constructor(
perCalendarReminderOverride = overrides.timed, perCalendarReminderOverride = overrides.timed,
perCalendarAllDayReminderOverride = overrides.allDay, perCalendarAllDayReminderOverride = overrides.allDay,
writableCalendars = overrides.calendars, writableCalendars = overrides.calendars,
managedCalendarIds = overrides.managedIds,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -160,6 +162,7 @@ class SettingsViewModel @Inject constructor(
val timed: Map<Long, List<Int>>, val timed: Map<Long, List<Int>>,
val allDay: Map<Long, List<Int>>, val allDay: Map<Long, List<Int>>,
val calendars: List<CalendarSource>, val calendars: List<CalendarSource>,
val managedIds: Set<Long>,
) )
private data class ViewSettings( 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. */ /** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
val specialDatesState: StateFlow<SpecialDatesUiState> = combine( val specialDatesState: StateFlow<SpecialDatesUiState> = combine(
prefs.specialDatesEnabled, combine(
prefs.specialDatesTypes, prefs.specialDatesEnabled,
prefs.specialDatesTitleTemplates, prefs.specialDatesTypes,
prefs.specialDatesShowAge, prefs.specialDatesTitleTemplates,
prefs.specialDatesStatus, prefs.specialDatesShowAge,
) { enabled, types, templates, showAge, status -> prefs.specialDatesStatus,
SpecialDatesUiState( ) { enabled, types, templates, showAge, status ->
enabled = enabled, SpecialDatesUiState(
types = types, enabled = enabled,
// A blank stored template resolves to the localized default so the types = types,
// editor always shows something sensible. // A blank stored template resolves to the localized default so the
titleTemplates = SpecialDateType.entries.associateWith { type -> // editor always shows something sensible.
templates[type]?.takeIf { it.isNotBlank() } titleTemplates = SpecialDateType.entries.associateWith { type ->
?: specialDatesSpec.defaultTitleTemplate(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<Int>() -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(minutes)
}
}, },
showAge = showAge,
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
lastRun = status.lastRun,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, 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). */ /** Kick off an immediate reconcile (the "Sync now" action). */
fun syncSpecialDatesNow() = SpecialDatesScheduler.runNow(appContext) fun syncSpecialDatesNow() = SpecialDatesScheduler.runNow(appContext)

View File

@@ -428,13 +428,15 @@
<string name="settings_special_dates_type_custom">Weitere Tage</string> <string name="settings_special_dates_type_custom">Weitere Tage</string>
<string name="settings_special_dates_template">Titelformat</string> <string name="settings_special_dates_template">Titelformat</string>
<string name="settings_special_dates_template_hint">Verwende {name} für den Kontakt und {age} für das Alter (ausgeblendet, wenn das Jahr unbekannt ist).</string> <string name="settings_special_dates_template_hint">Verwende {name} für den Kontakt und {age} für das Alter (ausgeblendet, wenn das Jahr unbekannt ist).</string>
<string name="settings_special_dates_template_age_note">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.</string> <string name="settings_special_dates_template_age_note">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.</string>
<string name="settings_special_dates_reminders">Erinnerungen</string>
<string name="settings_special_dates_show_age">Alter anzeigen</string> <string name="settings_special_dates_show_age">Alter anzeigen</string>
<string name="settings_special_dates_show_age_hint">{age} in Titeln anzeigen, wenn das Geburtsjahr bekannt ist</string> <string name="settings_special_dates_show_age_hint">{age} in Titeln anzeigen, wenn das Geburtsjahr bekannt ist</string>
<string name="settings_special_dates_sync_now">Jetzt synchronisieren</string> <string name="settings_special_dates_sync_now">Jetzt synchronisieren</string>
<string name="settings_special_dates_never_synced">Noch nicht synchronisiert</string> <string name="settings_special_dates_never_synced">Noch nicht synchronisiert</string>
<string name="settings_special_dates_last_synced">Zuletzt synchronisiert %1$s</string> <string name="settings_special_dates_last_synced">Zuletzt synchronisiert %1$s</string>
<string name="settings_special_dates_calendar_hint">Farbe, Sichtbarkeit und Erinnerungen jedes Kalenders legst du in den Kalender-Einstellungen fest.</string> <string name="settings_special_dates_calendar_hint">Farbe und Sichtbarkeit jedes Kalenders legst du in den Kalender-Einstellungen fest.</string>
<string name="settings_calendar_reminders_managed_hint">In Besondere Kontakttage festgelegt</string>
<string name="settings_special_dates_paused_title">Pausiert</string> <string name="settings_special_dates_paused_title">Pausiert</string>
<string name="settings_special_dates_paused_hint">Calendula kann deine Kontakte nicht mehr lesen, daher werden diese Kalender nicht aktualisiert.</string> <string name="settings_special_dates_paused_hint">Calendula kann deine Kontakte nicht mehr lesen, daher werden diese Kalender nicht aktualisiert.</string>
<string name="settings_special_dates_grant">Zugriff erlauben</string> <string name="settings_special_dates_grant">Zugriff erlauben</string>

View File

@@ -357,14 +357,16 @@
<string name="settings_special_dates_type_custom">Other dates</string> <string name="settings_special_dates_type_custom">Other dates</string>
<string name="settings_special_dates_template">Title format</string> <string name="settings_special_dates_template">Title format</string>
<string name="settings_special_dates_template_hint">Use {name} for the contact and {age} for their age (hidden when the year is unknown).</string> <string name="settings_special_dates_template_hint">Use {name} for the contact and {age} for their age (hidden when the year is unknown).</string>
<string name="settings_special_dates_template_age_note">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.</string> <string name="settings_special_dates_template_age_note">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.</string>
<string name="settings_special_dates_reminders">Reminders</string>
<string name="settings_special_dates_show_age">Show age</string> <string name="settings_special_dates_show_age">Show age</string>
<string name="settings_special_dates_show_age_hint">Include {age} in titles when the birth year is known</string> <string name="settings_special_dates_show_age_hint">Include {age} in titles when the birth year is known</string>
<string name="settings_special_dates_sync_now">Sync now</string> <string name="settings_special_dates_sync_now">Sync now</string>
<string name="settings_special_dates_never_synced">Not synced yet</string> <string name="settings_special_dates_never_synced">Not synced yet</string>
<!-- %1$s is a relative time, e.g. "5 minutes ago". --> <!-- %1$s is a relative time, e.g. "5 minutes ago". -->
<string name="settings_special_dates_last_synced">Last synced %1$s</string> <string name="settings_special_dates_last_synced">Last synced %1$s</string>
<string name="settings_special_dates_calendar_hint">Set each calendar\'s colour, visibility and reminders in Calendars settings.</string> <string name="settings_special_dates_calendar_hint">Set each calendar\'s colour and visibility in Calendars settings.</string>
<string name="settings_calendar_reminders_managed_hint">Set in Contact special dates</string>
<string name="settings_special_dates_paused_title">Paused</string> <string name="settings_special_dates_paused_title">Paused</string>
<string name="settings_special_dates_paused_hint">Calendula can no longer read your contacts, so these calendars aren\'t updating.</string> <string name="settings_special_dates_paused_hint">Calendula can no longer read your contacts, so these calendars aren\'t updating.</string>
<string name="settings_special_dates_grant">Grant access</string> <string name="settings_special_dates_grant">Grant access</string>

View File

@@ -190,6 +190,18 @@ internal class FakeCalendarDataSource : CalendarDataSource {
return id return id
} }
/** (calendarId, allDayTime, minutes) recorded from applyManagedCalendarReminders. */
val appliedManagedReminders = mutableListOf<Triple<Long, Int, List<Int>>>()
override fun applyManagedCalendarReminders(
calendarId: Long,
allDayReminderTimeMinutes: Int,
minutes: List<Int>,
) {
writeError?.let { throw it }
appliedManagedReminders += Triple(calendarId, allDayReminderTimeMinutes, minutes)
}
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) { override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
writeError?.let { throw it } writeError?.let { throw it }
if (values.isEmpty()) return if (values.isEmpty()) return

View File

@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow 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.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
@@ -163,6 +164,38 @@ class SpecialDatesSyncEngineTest {
assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary) 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 --- // --- pure diff ---
private fun desired(uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") = private fun desired(uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =