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:
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user