feat(calendars): add app-side enable/disable for calendars

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-25 13:55:10 +02:00
parent a1e8c05c7b
commit be6d10d126
11 changed files with 274 additions and 32 deletions

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
@@ -55,11 +56,12 @@ class CalendarRepositoryImpl @Inject constructor(
.reQuery { dataSource.calendars() } .reQuery { dataSource.calendars() }
.flowOn(io) .flowOn(io)
// Instances are filtered by the app-side hidden-calendar set (M3): an event // Instances are filtered by the app-side hidden disabled calendar sets
// is dropped whenever the user has hidden its calendar. Re-runs when the // (M3): an event is dropped whenever the user has hidden *or* disabled its
// provider ticks *or* the hidden set changes — toggling a calendar in the // calendar. Re-runs when the provider ticks *or* either set changes —
// filter sheet updates every view immediately. [calendars] stays unfiltered // toggling a calendar in the filter sheet or the calendar manager updates
// so the filter sheet can list and re-enable hidden calendars. // every view immediately. [calendars] stays unfiltered so those screens can
// list and re-enable hidden/disabled calendars.
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> = override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
combine( combine(
ticks ticks
@@ -71,10 +73,17 @@ class CalendarRepositoryImpl @Inject constructor(
) )
}, },
prefs.hiddenCalendarIds, prefs.hiddenCalendarIds,
) { instances, hidden -> prefs.disabledCalendarIds,
if (hidden.isEmpty()) instances ) { instances, hidden, disabled ->
else instances.filterNot { it.calendarId in hidden } val excluded = hidden + disabled
}.flowOn(io) 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) { override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId) dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
@@ -82,9 +91,9 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) { override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
if (query.isBlank()) return@withContext emptyList() if (query.isBlank()) return@withContext emptyList()
val hidden = prefs.hiddenCalendarIds.first() val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first()
dataSource.searchEvents(query) 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<EventColorOption> = override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =

View File

@@ -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<Set<Long>> = store.data.map { prefs ->
prefs[DISABLED_IDS_KEY].orEmpty()
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.toSet()
}
suspend fun setDisabledCalendarIds(ids: Set<Long>) {
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 * The calendar the user last created an event in; preselected in the
* event form. Null until the first event is created. * event form. Null until the first event is created.
@@ -53,6 +78,7 @@ class CalendarPrefs @Inject constructor(
companion object { companion object {
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids") 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") internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
} }
} }

View File

@@ -31,7 +31,6 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete 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.FileDownload
import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.Palette 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.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -59,11 +59,14 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -98,6 +101,7 @@ fun CalendarsScreen(
viewModel: CalendarsViewModel = hiltViewModel(), viewModel: CalendarsViewModel = hiltViewModel(),
) { ) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle() val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
@@ -134,6 +138,7 @@ fun CalendarsScreen(
CalendarsList( CalendarsList(
local = calendars.filter { it.isLocal }, local = calendars.filter { it.isLocal },
synced = calendars.filterNot { it.isLocal }, synced = calendars.filterNot { it.isLocal },
disabledIds = disabledIds,
error = error, error = error,
onConsumeError = viewModel::consumeError, onConsumeError = viewModel::consumeError,
backupResult = backupResult, backupResult = backupResult,
@@ -142,6 +147,7 @@ fun CalendarsScreen(
onBack = onBack, onBack = onBack,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id }, onEdit = { calendar -> editorSession++; editorId = calendar.id },
onSetDisabled = viewModel::setDisabled,
) )
} }
} }
@@ -150,6 +156,7 @@ fun CalendarsScreen(
private fun CalendarsList( private fun CalendarsList(
local: List<CalendarSource>, local: List<CalendarSource>,
synced: List<CalendarSource>, synced: List<CalendarSource>,
disabledIds: Set<Long>,
error: Boolean, error: Boolean,
onConsumeError: () -> Unit, onConsumeError: () -> Unit,
backupResult: BackupResult?, backupResult: BackupResult?,
@@ -158,6 +165,7 @@ private fun CalendarsList(
onBack: () -> Unit, onBack: () -> Unit,
onAdd: () -> Unit, onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit, onEdit: (CalendarSource) -> Unit,
onSetDisabled: (Long, Boolean) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -206,19 +214,21 @@ private fun CalendarsList(
if (local.isEmpty()) { if (local.isEmpty()) {
HintText(stringResource(R.string.calendars_local_empty)) HintText(stringResource(R.string.calendars_local_empty))
} }
HintText(stringResource(R.string.calendars_disable_hint))
val localCount = local.size + 1 val localCount = local.size + 1
local.forEachIndexed { index, calendar -> local.forEachIndexed { index, calendar ->
val disabled = calendar.id in disabledIds
GroupedRow( GroupedRow(
title = calendar.displayName, title = calendar.displayName,
summary = calendar.description, summary = calendar.description,
position = positionOf(index, localCount), position = positionOf(index, localCount),
leading = { CalendarColorChip(calendar.color) }, dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = { trailing = {
Icon( EnableSwitch(
Icons.Default.Edit, calendarName = calendar.displayName,
contentDescription = stringResource(R.string.calendars_edit_title), enabled = !disabled,
tint = MaterialTheme.colorScheme.onSurfaceVariant, onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
modifier = Modifier.size(20.dp),
) )
}, },
onClick = { onEdit(calendar) }, onClick = { onEdit(calendar) },
@@ -258,10 +268,19 @@ private fun CalendarsList(
.forEach { (account, cals) -> .forEach { (account, cals) ->
AccountHeader(account = account, accountType = cals.first().accountType) AccountHeader(account = account, accountType = cals.first().accountType)
cals.forEachIndexed { index, calendar -> cals.forEachIndexed { index, calendar ->
val disabled = calendar.id in disabledIds
GroupedRow( GroupedRow(
title = calendar.displayName, title = calendar.displayName,
position = positionOf(index, cals.size), 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). */ /** Tonal field card matching the event editor's design (icon + content). */
@Composable @Composable
private fun EditorCard( private fun EditorCard(

View File

@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter 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.CalendarSource
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -33,6 +35,7 @@ import javax.inject.Inject
class CalendarsViewModel @Inject constructor( class CalendarsViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
private val icsExporter: IcsExporter, private val icsExporter: IcsExporter,
private val prefs: CalendarPrefs,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -46,6 +49,20 @@ class CalendarsViewModel @Inject constructor(
initialValue = emptyList(), 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<Set<Long>> =
prefs.disabledCalendarIds
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(),
)
private val _error = MutableStateFlow(false) private val _error = MutableStateFlow(false)
val error: StateFlow<Boolean> = _error.asStateFlow() val error: StateFlow<Boolean> = _error.asStateFlow()
@@ -93,6 +110,19 @@ class CalendarsViewModel @Inject constructor(
repository.deleteCalendar(id) 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) { private inline fun write(crossinline block: suspend () -> Unit) {
viewModelScope.launch { viewModelScope.launch {
try { try {

View File

@@ -128,7 +128,9 @@ fun CollapsingScaffold(
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose * 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 * 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 * 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -138,6 +140,7 @@ fun GroupedRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
summary: String? = null, summary: String? = null,
selected: Boolean = false, selected: Boolean = false,
dimmed: Boolean = false,
minHeight: Dp = 72.dp, minHeight: Dp = 72.dp,
leading: @Composable (() -> Unit)? = null, leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null,
@@ -169,6 +172,16 @@ fun GroupedRow(
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer, supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
trailingIconColor = 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 { } else {
ListItemDefaults.colors(containerColor = Color.Transparent) ListItemDefaults.colors(containerColor = Color.Transparent)
} }

View File

@@ -121,10 +121,18 @@ class EventEditViewModel @Inject constructor(
val allowColorOnUnsupported: Boolean, val allowColorOnUnsupported: Boolean,
) )
/** Writable calendars — the only valid event targets. */ /**
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars() * Writable calendars — the only valid event targets. Disabled calendars are
.map { calendars -> calendars.filter { it.canModifyContents } } * excluded, so you can't create into a calendar you've removed from the app;
.catch { emit(emptyList()) } * 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<List<CalendarSource>> = 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. */ /** The target calendar id, resolved exactly as the form shows it. */
private val resolvedCalendarId: Flow<Long?> = combine( private val resolvedCalendarId: Flow<Long?> = combine(

View File

@@ -30,11 +30,16 @@ class FilterViewModel @Inject constructor(
combine( combine(
repository.calendars(), repository.calendars(),
prefs.hiddenCalendarIds, prefs.hiddenCalendarIds,
) { calendars, hidden -> prefs.disabledCalendarIds,
if (calendars.isEmpty()) { ) { 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) FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
} else { } else {
FilterUiState.Success(groupByAccount(calendars, hidden)) FilterUiState.Success(groupByAccount(enabled, hidden))
} }
} }
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }

View File

@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsImporter 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.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
@@ -56,6 +57,7 @@ sealed interface ImportUiState {
class ImportViewModel @Inject constructor( class ImportViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
private val importer: IcsImporter, private val importer: IcsImporter,
private val prefs: CalendarPrefs,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -79,11 +81,17 @@ class ImportViewModel @Inject constructor(
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()), form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
warnings = parsed.warnings, warnings = parsed.warnings,
) )
else -> ImportUiState.Many( else -> {
events = parsed.events, // A disabled calendar is removed from the app, so it can't be
warnings = parsed.warnings, // an import target — exclude it alongside the read-only ones.
calendars = repository.calendars().first().filter { it.canModifyContents }, val disabled = prefs.disabledCalendarIds.first()
) ImportUiState.Many(
events = parsed.events,
warnings = parsed.warnings,
calendars = repository.calendars().first()
.filter { it.canModifyContents && it.id !in disabled },
)
}
} }
} }
} }

View File

@@ -317,6 +317,8 @@
<string name="calendars_local_header">Your calendars</string> <string name="calendars_local_header">Your calendars</string>
<string name="calendars_local_empty">No local calendars yet. Create one to keep events on this device only.</string> <string name="calendars_local_empty">No local calendars yet. Create one to keep events on this device only.</string>
<string name="calendars_add">Add calendar</string> <string name="calendars_add">Add calendar</string>
<string name="calendars_disable_hint">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.</string>
<string name="calendars_show_in_app_a11y">Show \"%1$s\" in the app</string>
<string name="calendars_synced_header">Synced calendars</string> <string name="calendars_synced_header">Synced calendars</string>
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string> <string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
<string name="calendars_manage_in_app">Manage</string> <string name="calendars_manage_in_app">Manage</string>

View File

@@ -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 @Test
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest { fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L } val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }

View File

@@ -50,4 +50,34 @@ class CalendarPrefsTest {
} }
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 3L)) 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))
}
} }