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.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<Instant>): Flow<List<EventInstance>> =
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<EventInstance> = 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<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
* 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")
}
}

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.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<CalendarSource>,
synced: List<CalendarSource>,
disabledIds: Set<Long>,
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(

View File

@@ -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<Set<Long>> =
prefs.disabledCalendarIds
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(),
)
private val _error = MutableStateFlow(false)
val error: StateFlow<Boolean> = _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 {

View File

@@ -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)
}

View File

@@ -121,10 +121,18 @@ class EventEditViewModel @Inject constructor(
val allowColorOnUnsupported: Boolean,
)
/** Writable calendars — the only valid event targets. */
private val writableCalendars: Flow<List<CalendarSource>> = 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<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. */
private val resolvedCalendarId: Flow<Long?> = combine(

View File

@@ -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)) }

View File

@@ -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 },
)
}
}
}
}