From be6d10d12641716f2aae7d2a23db10519f6b31e0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 25 Jun 2026 13:55:10 +0200 Subject: [PATCH] feat(calendars): add app-side enable/disable for calendars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a second, heavier visibility level above the per-view hide filter. A disabled calendar is removed from the app's surfaces entirely — its events drop out of all views and search, and it disappears from the drawer filter list, the event-form calendar picker and the import target picker. It stays listed only in Settings → Calendars, where a per-row switch toggles it, so it can always be brought back. - CalendarPrefs: disabledCalendarIds set + setter, mirroring the hidden set (DataStore comma-separated string); never touches the system VISIBLE/SYNC_EVENTS flags, so it's app-local and reversible. - CalendarRepositoryImpl: instances()/searchEvents() exclude calendarId ∈ (hidden ∪ disabled). distinctUntilChanged() on instances collapses the transient duplicate emission both DataStore-derived sets produce when either is toggled. - FilterViewModel: drop disabled calendars from the drawer filter list. - EventEditViewModel: exclude disabled from writableCalendars; a last-used preselect on a now-disabled calendar falls back to the first remaining writable one. - ImportViewModel: exclude disabled from the import target list. - CalendarsScreen/ViewModel: per-row enable/disable Switch on both the local and synced groups; disabled rows render dimmed (new GroupedRow `dimmed` flag) while keeping the toggle live. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/calendar/CalendarRepositoryImpl.kt | 31 ++++++--- .../calendula/data/prefs/CalendarPrefs.kt | 26 +++++++ .../calendula/ui/calendars/CalendarsScreen.kt | 59 +++++++++++++--- .../ui/calendars/CalendarsViewModel.kt | 30 ++++++++ .../calendula/ui/common/GroupedList.kt | 15 +++- .../calendula/ui/edit/EventEditViewModel.kt | 16 +++-- .../calendula/ui/filter/FilterViewModel.kt | 11 ++- .../calendula/ui/imports/ImportViewModel.kt | 18 +++-- app/src/main/res/values/strings.xml | 2 + .../calendar/CalendarRepositoryImplTest.kt | 68 +++++++++++++++++++ .../calendula/data/prefs/CalendarPrefsTest.kt | 30 ++++++++ 11 files changed, 274 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index c7949ec..bde325a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn @@ -55,11 +56,12 @@ class CalendarRepositoryImpl @Inject constructor( .reQuery { dataSource.calendars() } .flowOn(io) - // Instances are filtered by the app-side hidden-calendar set (M3): an event - // is dropped whenever the user has hidden its calendar. Re-runs when the - // provider ticks *or* the hidden set changes — toggling a calendar in the - // filter sheet updates every view immediately. [calendars] stays unfiltered - // so the filter sheet can list and re-enable hidden calendars. + // Instances are filtered by the app-side hidden ∪ disabled calendar sets + // (M3): an event is dropped whenever the user has hidden *or* disabled its + // calendar. Re-runs when the provider ticks *or* either set changes — + // toggling a calendar in the filter sheet or the calendar manager updates + // every view immediately. [calendars] stays unfiltered so those screens can + // list and re-enable hidden/disabled calendars. override fun instances(range: ClosedRange): Flow> = combine( ticks @@ -71,10 +73,17 @@ class CalendarRepositoryImpl @Inject constructor( ) }, prefs.hiddenCalendarIds, - ) { instances, hidden -> - if (hidden.isEmpty()) instances - else instances.filterNot { it.calendarId in hidden } - }.flowOn(io) + prefs.disabledCalendarIds, + ) { instances, hidden, disabled -> + val excluded = hidden + disabled + if (excluded.isEmpty()) instances + else instances.filterNot { it.calendarId in excluded } + } + // hidden and disabled both derive from one DataStore, so toggling + // either makes both re-emit and combine briefly surfaces the same + // list twice — collapse the duplicate so views don't re-render for it. + .distinctUntilChanged() + .flowOn(io) override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) { dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId) @@ -82,9 +91,9 @@ class CalendarRepositoryImpl @Inject constructor( override suspend fun searchEvents(query: String): List = withContext(io) { if (query.isBlank()) return@withContext emptyList() - val hidden = prefs.hiddenCalendarIds.first() + val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first() dataSource.searchEvents(query) - .let { if (hidden.isEmpty()) it else it.filterNot { e -> e.calendarId in hidden } } + .let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } } } override suspend fun eventColorPalette(calendarId: Long): List = diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt index 8f724e5..bc01927 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt @@ -39,6 +39,31 @@ class CalendarPrefs @Inject constructor( } } + /** + * App-side preference for "calendars the user has disabled in this app" — a + * heavier level than [hiddenCalendarIds]. A disabled calendar is removed from + * every surface (drawer filter, event-form picker, import picker) and its + * events never appear; it stays listed only in Settings → Calendars so it can + * be re-enabled. Stored exactly like the hidden set; never touches the + * system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are unaffected. + */ + val disabledCalendarIds: Flow> = store.data.map { prefs -> + prefs[DISABLED_IDS_KEY].orEmpty() + .split(',') + .mapNotNull { it.trim().toLongOrNull() } + .toSet() + } + + suspend fun setDisabledCalendarIds(ids: Set) { + store.edit { prefs -> + if (ids.isEmpty()) { + prefs.remove(DISABLED_IDS_KEY) + } else { + prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",") + } + } + } + /** * The calendar the user last created an event in; preselected in the * event form. Null until the first event is created. @@ -53,6 +78,7 @@ class CalendarPrefs @Inject constructor( companion object { internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids") + internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids") internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id") } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index c0f02b1..82fe32b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -31,7 +31,6 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.Palette @@ -46,6 +45,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -59,11 +59,14 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp @@ -98,6 +101,7 @@ fun CalendarsScreen( viewModel: CalendarsViewModel = hiltViewModel(), ) { val calendars by viewModel.calendars.collectAsStateWithLifecycle() + val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle() val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() @@ -134,6 +138,7 @@ fun CalendarsScreen( CalendarsList( local = calendars.filter { it.isLocal }, synced = calendars.filterNot { it.isLocal }, + disabledIds = disabledIds, error = error, onConsumeError = viewModel::consumeError, backupResult = backupResult, @@ -142,6 +147,7 @@ fun CalendarsScreen( onBack = onBack, onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onEdit = { calendar -> editorSession++; editorId = calendar.id }, + onSetDisabled = viewModel::setDisabled, ) } } @@ -150,6 +156,7 @@ fun CalendarsScreen( private fun CalendarsList( local: List, synced: List, + disabledIds: Set, error: Boolean, onConsumeError: () -> Unit, backupResult: BackupResult?, @@ -158,6 +165,7 @@ private fun CalendarsList( onBack: () -> Unit, onAdd: () -> Unit, onEdit: (CalendarSource) -> Unit, + onSetDisabled: (Long, Boolean) -> Unit, ) { val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } @@ -206,19 +214,21 @@ private fun CalendarsList( if (local.isEmpty()) { HintText(stringResource(R.string.calendars_local_empty)) } + HintText(stringResource(R.string.calendars_disable_hint)) val localCount = local.size + 1 local.forEachIndexed { index, calendar -> + val disabled = calendar.id in disabledIds GroupedRow( title = calendar.displayName, summary = calendar.description, position = positionOf(index, localCount), - leading = { CalendarColorChip(calendar.color) }, + dimmed = disabled, + leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, trailing = { - Icon( - Icons.Default.Edit, - contentDescription = stringResource(R.string.calendars_edit_title), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), + EnableSwitch( + calendarName = calendar.displayName, + enabled = !disabled, + onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) }, ) }, onClick = { onEdit(calendar) }, @@ -258,10 +268,19 @@ private fun CalendarsList( .forEach { (account, cals) -> AccountHeader(account = account, accountType = cals.first().accountType) cals.forEachIndexed { index, calendar -> + val disabled = calendar.id in disabledIds GroupedRow( title = calendar.displayName, position = positionOf(index, cals.size), - leading = { CalendarColorChip(calendar.color) }, + dimmed = disabled, + leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, + trailing = { + EnableSwitch( + calendarName = calendar.displayName, + enabled = !disabled, + onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) }, + ) + }, ) } } @@ -425,6 +444,30 @@ private fun CalendarEditor( } } +/** + * The per-row enable/disable control. Checked = the calendar is shown in the + * app; unchecking disables it (events, filters and pickers all drop it) without + * touching any provider data. Carries its own content description so the toggle + * is self-describing to screen readers even on a dimmed row. + */ +@Composable +private fun EnableSwitch( + calendarName: String, + enabled: Boolean, + onToggle: (Boolean) -> Unit, +) { + val label = stringResource(R.string.calendars_show_in_app_a11y, calendarName) + Switch( + checked = enabled, + onCheckedChange = onToggle, + modifier = Modifier.semantics { contentDescription = label }, + ) +} + +/** Fade a leading element to the M3 disabled emphasis when [disabled]. */ +private fun dimIf(disabled: Boolean): Modifier = + if (disabled) Modifier.alpha(0.38f) else Modifier + /** Tonal field card matching the event editor's design (icon + content). */ @Composable private fun EditorCard( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt index c731d38..17451f7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.ics.IcsExporter +import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import kotlinx.coroutines.CoroutineDispatcher @@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -33,6 +35,7 @@ import javax.inject.Inject class CalendarsViewModel @Inject constructor( private val repository: CalendarRepository, private val icsExporter: IcsExporter, + private val prefs: CalendarPrefs, @IoDispatcher private val io: CoroutineDispatcher, ) : ViewModel() { @@ -46,6 +49,20 @@ class CalendarsViewModel @Inject constructor( initialValue = emptyList(), ) + /** + * Calendars the user has disabled in the app. This screen is the only + * surface that lists them, so it both reads the set (to dim the rows) and + * toggles it. Every other surface simply excludes these ids. + */ + val disabledCalendarIds: StateFlow> = + prefs.disabledCalendarIds + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = emptySet(), + ) + private val _error = MutableStateFlow(false) val error: StateFlow = _error.asStateFlow() @@ -93,6 +110,19 @@ class CalendarsViewModel @Inject constructor( repository.deleteCalendar(id) } + /** + * Enable or disable a calendar app-side. Disabling removes it from every + * surface but Settings → Calendars (and hides its events) without touching + * provider data — purely a reversible Calendula-local view choice. + */ + fun setDisabled(id: Long, disabled: Boolean) { + viewModelScope.launch { + val current = prefs.disabledCalendarIds.first() + val next = if (disabled) current + id else current - id + if (next != current) prefs.setDisabledCalendarIds(next) + } + } + private inline fun write(crossinline block: suspend () -> Unit) { viewModelScope.launch { try { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt index 445e38b..d7d489f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt @@ -128,7 +128,9 @@ fun CollapsingScaffold( * One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose * corner radii come from its [position] (so a run of rows reads as a single * rounded card). Corners round further on press. A null [onClick] makes the - * row non-interactive (e.g. read-only entries). + * row non-interactive (e.g. read-only entries). [dimmed] fades the headline and + * summary to the M3 disabled emphasis while leaving the [trailing] control at + * full opacity — for rows that are present but switched off. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -138,6 +140,7 @@ fun GroupedRow( modifier: Modifier = Modifier, summary: String? = null, selected: Boolean = false, + dimmed: Boolean = false, minHeight: Dp = 72.dp, leading: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null, @@ -169,6 +172,16 @@ fun GroupedRow( supportingColor = MaterialTheme.colorScheme.onSecondaryContainer, trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer, ) + } else if (dimmed) { + // M3 disabled emphasis (0.38α) on the text/leading; the trailing control + // stays full-opacity so the toggle reads as live even on a faded row. + val muted = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = muted, + leadingIconColor = muted, + supportingColor = muted, + ) } else { ListItemDefaults.colors(containerColor = Color.Transparent) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index e878330..dbf9cf1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -121,10 +121,18 @@ class EventEditViewModel @Inject constructor( val allowColorOnUnsupported: Boolean, ) - /** Writable calendars — the only valid event targets. */ - private val writableCalendars: Flow> = repository.calendars() - .map { calendars -> calendars.filter { it.canModifyContents } } - .catch { emit(emptyList()) } + /** + * Writable calendars — the only valid event targets. Disabled calendars are + * excluded, so you can't create into a calendar you've removed from the app; + * a last-used preselect landing on a now-disabled calendar falls back to the + * first remaining writable one (handled by [resolvedCalendarId] and [state]). + */ + private val writableCalendars: Flow> = combine( + repository.calendars(), + prefs.disabledCalendarIds, + ) { calendars, disabled -> + calendars.filter { it.canModifyContents && it.id !in disabled } + }.catch { emit(emptyList()) } /** The target calendar id, resolved exactly as the form shows it. */ private val resolvedCalendarId: Flow = combine( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt index 639c968..28dd615 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt @@ -30,11 +30,16 @@ class FilterViewModel @Inject constructor( combine( repository.calendars(), prefs.hiddenCalendarIds, - ) { calendars, hidden -> - if (calendars.isEmpty()) { + prefs.disabledCalendarIds, + ) { calendars, hidden, disabled -> + // Disabled calendars are gone from the app entirely — they don't + // belong in the drawer's hide/show list (you can't hide what's + // already disabled). They live only in Settings → Calendars. + val enabled = calendars.filterNot { it.id in disabled } + if (enabled.isEmpty()) { FilterUiState.Failure(FailureReason.NoCalendarsConfigured) } else { - FilterUiState.Success(groupByAccount(calendars, hidden)) + FilterUiState.Success(groupByAccount(enabled, hidden)) } } .catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt index 63debea..445a25f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt @@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.ics.IcsImporter +import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary @@ -56,6 +57,7 @@ sealed interface ImportUiState { class ImportViewModel @Inject constructor( private val repository: CalendarRepository, private val importer: IcsImporter, + private val prefs: CalendarPrefs, @IoDispatcher private val io: CoroutineDispatcher, ) : ViewModel() { @@ -79,11 +81,17 @@ class ImportViewModel @Inject constructor( form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()), warnings = parsed.warnings, ) - else -> ImportUiState.Many( - events = parsed.events, - warnings = parsed.warnings, - calendars = repository.calendars().first().filter { it.canModifyContents }, - ) + else -> { + // A disabled calendar is removed from the app, so it can't be + // an import target — exclude it alongside the read-only ones. + val disabled = prefs.disabledCalendarIds.first() + ImportUiState.Many( + events = parsed.events, + warnings = parsed.warnings, + calendars = repository.calendars().first() + .filter { it.canModifyContents && it.id !in disabled }, + ) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0a874c0..6401287 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -317,6 +317,8 @@ Your calendars No local calendars yet. Create one to keep events on this device only. Add calendar + Turn a calendar off to remove it from the app — its events, filters and pickers. Nothing is deleted, and you can turn it back on here anytime. + Show \"%1$s\" in the app Synced calendars These come from accounts on your device. Create and edit them in their own app. Manage diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt index 80c8d28..1fb9abd 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -170,6 +170,74 @@ class CalendarRepositoryImplTest { } } + @Test + fun `instances drops events whose calendar the user disabled`(@TempDir tempDir: Path) = runTest { + val prefs = newPrefs(tempDir) + prefs.setDisabledCalendarIds(setOf(2L)) + val fake = FakeCalendarDataSource().apply { + instancesResult = { _, _ -> + listOf( + makeEvent(10L, "Enabled", calendarId = 1L), + makeEvent(11L, "Disabled", calendarId = 2L), + ) + } + } + val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler)) + + val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L) + repo.instances(range).test { + assertThat(awaitItem().map { it.title }).containsExactly("Enabled") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `instances applies the union of hidden and disabled sets`(@TempDir tempDir: Path) = runTest { + val prefs = newPrefs(tempDir) + prefs.setHiddenCalendarIds(setOf(2L)) + prefs.setDisabledCalendarIds(setOf(3L)) + val fake = FakeCalendarDataSource().apply { + instancesResult = { _, _ -> + listOf( + makeEvent(10L, "Shown", calendarId = 1L), + makeEvent(11L, "Hidden", calendarId = 2L), + makeEvent(12L, "Disabled", calendarId = 3L), + ) + } + } + val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler)) + + val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L) + repo.instances(range).test { + assertThat(awaitItem().map { it.title }).containsExactly("Shown") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `instances re-emits when the disabled set changes`(@TempDir tempDir: Path) = runTest { + val prefs = newPrefs(tempDir) + val fake = FakeCalendarDataSource().apply { + instancesResult = { _, _ -> + listOf( + makeEvent(10L, "A", calendarId = 1L), + makeEvent(11L, "B", calendarId = 2L), + ) + } + } + val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler)) + + val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L) + repo.instances(range).test { + assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder() + + prefs.setDisabledCalendarIds(setOf(2L)) + + assertThat(awaitItem().map { it.title }).containsExactly("A") + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest { val fake = FakeCalendarDataSource().apply { nextInsertId = 77L } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefsTest.kt index a72daf3..a077bae 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefsTest.kt @@ -50,4 +50,34 @@ class CalendarPrefsTest { } assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 3L)) } + + @Test + fun `disabledCalendarIds defaults to empty when unset`(@TempDir tempDir: Path) = runTest { + val prefs = CalendarPrefs(newDataStore(tempDir)) + assertThat(prefs.disabledCalendarIds.first()).isEmpty() + } + + @Test + fun `setDisabledCalendarIds round-trips through DataStore`(@TempDir tempDir: Path) = runTest { + val prefs = CalendarPrefs(newDataStore(tempDir)) + prefs.setDisabledCalendarIds(setOf(1L, 42L, 7L)) + assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(1L, 42L, 7L)) + } + + @Test + fun `setting empty disabled set clears storage`(@TempDir tempDir: Path) = runTest { + val prefs = CalendarPrefs(newDataStore(tempDir)) + prefs.setDisabledCalendarIds(setOf(1L)) + prefs.setDisabledCalendarIds(emptySet()) + assertThat(prefs.disabledCalendarIds.first()).isEmpty() + } + + @Test + fun `hidden and disabled sets are stored independently`(@TempDir tempDir: Path) = runTest { + val prefs = CalendarPrefs(newDataStore(tempDir)) + prefs.setHiddenCalendarIds(setOf(1L)) + prefs.setDisabledCalendarIds(setOf(2L)) + assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L)) + assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(2L)) + } }