diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 4fed5ad..96d95c3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue @@ -427,6 +428,95 @@ class SettingsPrefs @Inject constructor( } } + // --- Contact special dates (issue #15) ------------------------------ + + /** Master switch for the contact special-dates mirror. Default OFF (opt-in). */ + val specialDatesEnabled: Flow = store.data.map { it[SPECIAL_DATES_ENABLED_KEY] ?: false } + + suspend fun setSpecialDatesEnabled(enabled: Boolean) { + store.edit { it[SPECIAL_DATES_ENABLED_KEY] = enabled } + } + + /** The date types the user wants mirrored (default: all three). */ + val specialDatesTypes: Flow> = store.data.map { prefs -> + SpecialDateType.entries.filterTo(mutableSetOf()) { prefs[typeEnabledKey(it)] ?: true } + } + + suspend fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) { + store.edit { it[typeEnabledKey(type)] = enabled } + } + + /** The managed calendar id per type, for the ones that currently exist. */ + val specialDatesCalendars: Flow> = store.data.map { prefs -> + SpecialDateType.entries.mapNotNull { type -> + prefs[calendarIdKey(type)]?.let { type to it } + }.toMap() + } + + /** Every managed calendar id — the editor locks title/date/recurrence for their events. */ + val managedCalendarIds: Flow> = specialDatesCalendars.map { it.values.toSet() } + + suspend fun setSpecialDatesCalendarId(type: SpecialDateType, calendarId: Long?) { + store.edit { prefs -> + if (calendarId == null) prefs.remove(calendarIdKey(type)) else prefs[calendarIdKey(type)] = calendarId + } + } + + /** + * The title template per type ("{name}'s birthday"); an empty string means + * unset, so the settings screen can seed the localized default. `{name}` is + * the contact name, `{age}` the age at the upcoming date (empty when the + * birth year is unknown or [specialDatesShowAge] is off). + */ + val specialDatesTitleTemplates: Flow> = store.data.map { prefs -> + SpecialDateType.entries.associateWith { prefs[titleTemplateKey(it)].orEmpty() } + } + + suspend fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) { + store.edit { it[titleTemplateKey(type)] = template } + } + + /** Whether `{age}` resolves in the title (only meaningful when the year is known). Default ON. */ + val specialDatesShowAge: Flow = store.data.map { it[SPECIAL_DATES_SHOW_AGE_KEY] ?: true } + + suspend fun setSpecialDatesShowAge(enabled: Boolean) { + store.edit { it[SPECIAL_DATES_SHOW_AGE_KEY] = enabled } + } + + /** Outcome of the last mirror sync, for the settings status line. */ + val specialDatesStatus: Flow = store.data.map { prefs -> + SpecialDatesStatus( + lastRun = prefs[SPECIAL_DATES_LAST_RUN_KEY] ?: 0L, + stalled = prefs[SPECIAL_DATES_STALLED_KEY] + ?.let { name -> SpecialDatesStalledReason.entries.firstOrNull { it.name == name } }, + ) + } + + /** Record a sync outcome; [stalled] non-null marks the feature paused (e.g. permission revoked). */ + suspend fun recordSpecialDatesRun(atMillis: Long, stalled: SpecialDatesStalledReason?) { + store.edit { prefs -> + prefs[SPECIAL_DATES_LAST_RUN_KEY] = atMillis + if (stalled == null) prefs.remove(SPECIAL_DATES_STALLED_KEY) else prefs[SPECIAL_DATES_STALLED_KEY] = stalled.name + } + } + + /** Epoch millis of the last foreground-triggered sync, to debounce ON_RESUME. */ + val specialDatesLastForegroundSync: Flow = + store.data.map { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] ?: 0L } + + suspend fun setSpecialDatesLastForegroundSync(atMillis: Long) { + store.edit { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] = atMillis } + } + + private fun typeEnabledKey(type: SpecialDateType) = + booleanPreferencesKey("special_dates_type_${type.name}") + + private fun calendarIdKey(type: SpecialDateType) = + longPreferencesKey("special_dates_calendar_${type.name}") + + private fun titleTemplateKey(type: SpecialDateType) = + stringPreferencesKey("special_dates_title_${type.name}") + private fun parseFormFields(stored: String?): Set = when (stored) { null -> DEFAULT_FORM_FIELDS else -> stored.split(',') @@ -479,9 +569,30 @@ class SettingsPrefs @Inject constructor( const val DEFAULT_BACKUP_INTERVAL = 1_440L /** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */ const val MIN_BACKUP_INTERVAL = 30L + + internal val SPECIAL_DATES_ENABLED_KEY = booleanPreferencesKey("special_dates_enabled") + internal val SPECIAL_DATES_SHOW_AGE_KEY = booleanPreferencesKey("special_dates_show_age") + internal val SPECIAL_DATES_LAST_RUN_KEY = longPreferencesKey("special_dates_last_run") + internal val SPECIAL_DATES_STALLED_KEY = stringPreferencesKey("special_dates_stalled_reason") + internal val SPECIAL_DATES_LAST_FG_SYNC_KEY = + longPreferencesKey("special_dates_last_foreground_sync") } } +/** Snapshot of the special-dates mirror's last outcome (see [SettingsPrefs.specialDatesStatus]). */ +data class SpecialDatesStatus( + /** Epoch millis of the last sync, or 0 if it has never run. */ + val lastRun: Long, + /** Non-null when the mirror is paused and why; null when healthy. */ + val stalled: SpecialDatesStalledReason?, +) + +/** Why the special-dates mirror is paused. */ +enum class SpecialDatesStalledReason { + /** READ_CONTACTS was revoked in system settings after the feature was enabled. */ + PermissionRevoked, +} + /** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */ data class BackupStatus( /** Epoch millis of the last run, or 0 if it has never run. */ 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 900c9bc..b2832b8 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 @@ -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 = emptyList() + var nextManagedEventId: Long = 1_000L + private var managedCalendarSeq: Long = 700L + private val managedEvents = mutableMapOf>() + + data class CreatedManagedCalendar(val displayName: String, val color: Int, val type: SpecialDateType) + val createdManagedCalendars = mutableListOf() + val insertedManagedEvents = mutableListOf>() + val updatedManagedFields = mutableListOf>>() + + 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 = findManagedCalendarsResult + + override fun queryManagedEvents(calendarId: Long): List = + 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) { + 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) { diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 4ae1c9b..f39c5cb 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -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)) + } }