feat(contacts): special-dates preferences + managed-calendar identity

Adds the SettingsPrefs surface the mirror is configured and reconciled through:
master enable, per-type toggles (default all three on), per-type managed calendar
id (source of truth for the editor's managedCalendarIds and the sync targets),
editable per-type title template, show-age, and a run/stalled status plus a
foreground-sync debounce timestamp.

Calendar ids are stored per type under their own long key (no serialization/
escaping); templates likewise per type. SpecialDatesStatus/StalledReason model
the paused (permission-revoked) state for the settings banner.

Extends the test FakeCalendarDataSource with a stateful managed-event surface
(inserts reflected in later queries) so the sync engine can be exercised for
idempotency. Round-trip tests for every new pref.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 10:27:36 +02:00
parent 9a8d9b2177
commit f7c0b8a957
3 changed files with 225 additions and 0 deletions

View File

@@ -1,12 +1,15 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import java.time.ZoneId
/**
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
@@ -139,6 +142,61 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun deleteEvent(eventId: Long) {
writeError?.let { throw it }
deletedEventIds += eventId
managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } }
}
// --- Managed special-dates surface (stateful, so a re-sync sees inserts) ---
var findManagedCalendarsResult: List<ManagedCalendarRow> = emptyList()
var nextManagedEventId: Long = 1_000L
private var managedCalendarSeq: Long = 700L
private val managedEvents = mutableMapOf<Long, MutableList<ManagedEventRow>>()
data class CreatedManagedCalendar(val displayName: String, val color: Int, val type: SpecialDateType)
val createdManagedCalendars = mutableListOf<CreatedManagedCalendar>()
val insertedManagedEvents = mutableListOf<Pair<EventForm, String>>()
val updatedManagedFields = mutableListOf<Pair<Long, Map<String, Any?>>>()
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
writeError?.let { throw it }
createdManagedCalendars += CreatedManagedCalendar(displayName, color, type)
val id = managedCalendarSeq++
managedEvents.getOrPut(id) { mutableListOf() }
return id
}
override fun findManagedCalendars(): List<ManagedCalendarRow> = findManagedCalendarsResult
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> =
managedEvents[calendarId]?.toList() ?: emptyList()
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
writeError?.let { throw it }
insertedManagedEvents += form to uid
allDayReminderTimes += allDayReminderTimeMinutes
val id = nextManagedEventId++
val times = form.toWriteTimes(ZoneId.systemDefault())
managedEvents.getOrPut(requireNotNull(form.calendarId)) { mutableListOf() } +=
ManagedEventRow(id, uid, form.title.trim(), times.dtStartMillis, form.rrule)
return id
}
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
writeError?.let { throw it }
if (values.isEmpty()) return
updatedManagedFields += eventId to values
managedEvents.values.forEach { rows ->
val idx = rows.indexOfFirst { it.eventId == eventId }
if (idx >= 0) {
var row = rows[idx]
(values[CalendarContract.Events.TITLE] as? String)?.let { row = row.copy(title = it) }
(values[CalendarContract.Events.DTSTART] as? Long)?.let { row = row.copy(dtStartMillis = it) }
if (values.containsKey(CalendarContract.Events.RRULE)) {
row = row.copy(rrule = values[CalendarContract.Events.RRULE] as? String)
}
rows[idx] = row
}
}
}
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {

View File

@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
@@ -340,4 +341,59 @@ class SettingsPrefsTest {
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
}
@Test
fun `special dates feature is off with all types on by default`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.specialDatesEnabled.first()).isFalse()
assertThat(prefs.specialDatesTypes.first()).isEqualTo(SpecialDateType.entries.toSet())
assertThat(prefs.specialDatesCalendars.first()).isEmpty()
assertThat(prefs.managedCalendarIds.first()).isEmpty()
assertThat(prefs.specialDatesShowAge.first()).isTrue()
}
@Test
fun `special date type toggles round-trip`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
assertThat(prefs.specialDatesTypes.first())
.isEqualTo(setOf(SpecialDateType.Birthday, SpecialDateType.Custom))
}
@Test
fun `managed calendar ids round-trip and drive managedCalendarIds`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, 7L)
prefs.setSpecialDatesCalendarId(SpecialDateType.Anniversary, 9L)
assertThat(prefs.specialDatesCalendars.first())
.isEqualTo(mapOf(SpecialDateType.Birthday to 7L, SpecialDateType.Anniversary to 9L))
assertThat(prefs.managedCalendarIds.first()).isEqualTo(setOf(7L, 9L))
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, null)
assertThat(prefs.specialDatesCalendars.first())
.isEqualTo(mapOf(SpecialDateType.Anniversary to 9L))
}
@Test
fun `title template round-trips per type`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday]).isEmpty()
prefs.setSpecialDatesTitleTemplate(SpecialDateType.Birthday, "{name}'s birthday")
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday])
.isEqualTo("{name}'s birthday")
}
@Test
fun `sync status records run and stalled reason`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.specialDatesStatus.first())
.isEqualTo(SpecialDatesStatus(lastRun = 0L, stalled = null))
prefs.recordSpecialDatesRun(1000L, SpecialDatesStalledReason.PermissionRevoked)
assertThat(prefs.specialDatesStatus.first())
.isEqualTo(SpecialDatesStatus(1000L, SpecialDatesStalledReason.PermissionRevoked))
prefs.recordSpecialDatesRun(2000L, null)
assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null))
}
}