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:
@@ -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 {
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,6 +182,7 @@ 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(
|
||||||
|
combine(
|
||||||
prefs.specialDatesEnabled,
|
prefs.specialDatesEnabled,
|
||||||
prefs.specialDatesTypes,
|
prefs.specialDatesTypes,
|
||||||
prefs.specialDatesTitleTemplates,
|
prefs.specialDatesTitleTemplates,
|
||||||
@@ -198,6 +202,20 @@ class SettingsViewModel @Inject constructor(
|
|||||||
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
|
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
|
||||||
lastRun = status.lastRun,
|
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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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") =
|
||||||
|
|||||||
Reference in New Issue
Block a user