Compare commits

..
Author SHA1 Message Date
Jean-Luc Makiola 1a1df07dc3 Release v2.24.2 (#511)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 12s
Renovate / renovate (push) Successful in 1m53s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 17m38s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 34s
- Queued drops: moves made while an earlier one is still saving are queued instead of dropped, with messages for stale and skipped moves (#494)
- Day view title names the month (#501)
- Dependency updates: kotlinx-datetime, AppCompat, fastlane, Renovate image (#507–#510)
- Version 2.24.2 (2240299), CHANGELOG, What's New in all store locales

#494 stays open until the reporter answers; the fix covers the ways a quick move could get lost, the original report isn't reproduced.

Refs #494
Closes #501
2026-11-13 18:56:38 +01:00
makiolaj 75315d92a7 Merge main into release/2.24.2
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-13 16:10:53 +01:00
95ad3a30cf Release v2.24.1 (#504)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Renovate / renovate (push) Successful in 1m20s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 19m27s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m58s
- Agenda widget: Cards / Classic style option, opacity slider for all Solid/Tinted widgets (#489)
- Month widget fits events into short week rows (#491)
- Week numbers follow the locale's rules and the row's first day (#493)
- Readable dimmed events in dark widgets, pinned Light/Dark theme matches system colours (#475)
- Dropped unused WorkManager permissions: network state, wake lock, foreground service (#448)
- Weblate translations (#434), incl. the Arabic string from after the 2.24.0 squash
- Version 2.24.1 (2240199), CHANGELOG, What's New in all store locales

#489 deviates from the issue: the classic look comes back as a style option instead of replacing the new design.

Closes #489
Closes #491
Closes #493
Closes #475
Closes #448

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Louis Leblanc <sandraev@posteo.dk>
Co-authored-by: Dmitri Gabinski <dkgabi@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/504
2026-11-13 11:17:48 +01:00
Jean-Luc Makiola 8ce4831efa Translations update from Weblate (#434)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 12s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 2m59s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/434
2026-11-13 11:07:42 +01:00
24 changed files with 41 additions and 722 deletions

No files matched your search

@@ -165,7 +165,6 @@ class SettingsPrefs @Inject constructor(
val autofocusEventTitle: Flow<Boolean> = prefs.flow(SettingsKeys.autofocusEventTitle)
val defaultEventDurationMinutes: Flow<Int> = prefs.flow(SettingsKeys.defaultEventDurationMinutes)
val perCalendarEventDuration: Flow<Map<Long, Int>> = prefs.flow(SettingsKeys.perCalendarEventDuration)
val perCalendarHiddenViews: Flow<Map<Long, Set<CalendarView>>> = prefs.flow(SettingsKeys.perCalendarHiddenViews)
val remindersEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.remindersEnabled)
val allowColorOnUnsupportedCalendars: Flow<Boolean> =
prefs.flow(SettingsKeys.allowColorOnUnsupportedCalendars)
@@ -272,25 +271,6 @@ class SettingsPrefs @Inject constructor(
}
}
/** Show [calendarId] in every view again (#270), e.g. once it is deleted. */
suspend fun clearCalendarHiddenViews(calendarId: Long) {
prefs.update(SettingsKeys.perCalendarHiddenViews) { current -> current - calendarId }
}
/** Hide or show [calendarId] in one [view], against the stored set rather than a snapshot of it. */
suspend fun setCalendarHiddenIn(calendarId: Long, view: CalendarView, hidden: Boolean) =
applyCalendarHiddenChanges(calendarId, mapOf(view to hidden))
/** Hide (true) or show (false) [calendarId] in each view of [changes], in one write; other views keep theirs. */
suspend fun applyCalendarHiddenChanges(calendarId: Long, changes: Map<CalendarView, Boolean>) {
if (changes.isEmpty()) return
prefs.update(SettingsKeys.perCalendarHiddenViews) { current ->
val views = current[calendarId].orEmpty()
val next = views + changes.filterValues { it }.keys - changes.filterValues { !it }.keys
current.withHiddenViews(calendarId, next)
}
}
suspend fun setRemindersEnabled(enabled: Boolean) = prefs.set(SettingsKeys.remindersEnabled, enabled)
suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) =
@@ -608,15 +588,6 @@ object SettingsKeys {
val perCalendarEventDuration: Pref<Map<Long, Int>> =
nullableStringPref("per_calendar_event_duration").map(::parseDurationOverrides) { it.toStoredDurations() }
/**
* Views each calendar is hidden in (#270): a display-only filter on top of
* `Calendars.VISIBLE` and the drawer's filter, so reminders and search never
* see it. A calendar absent shows in every view. Serialised as
* `id=Month,Agenda;id=Week`.
*/
val perCalendarHiddenViews: Pref<Map<Long, Set<CalendarView>>> =
nullableStringPref("per_calendar_hidden_views").map(::parseHiddenViews) { it.toStoredHiddenViews() }
/**
* Whether Calendula posts reminder notifications (v1.4). On by default — for
* users whose only calendar app this is, reminders are essential.
@@ -846,11 +817,9 @@ private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored
private fun parseViewOrder(stored: String?): List<CalendarView> =
completeViewOrder(
stored?.split(',').orEmpty()
.mapNotNull { it.trim().toCalendarViewOrNull() },
.mapNotNull { name -> CalendarView.entries.firstOrNull { it.name == name.trim() } },
)
private fun String.toCalendarViewOrNull(): CalendarView? = CalendarView.entries.firstOrNull { it.name == this }
/** Parse the quick-switch config; "!"-prefixed names are disabled. */
private fun parseQuickSwitch(stored: String?): QuickSwitchConfig {
if (stored == null) return QuickSwitchConfig.Default
@@ -858,7 +827,7 @@ private fun parseQuickSwitch(stored: String?): QuickSwitchConfig {
val token = raw.trim()
val disabled = token.startsWith("!")
val name = if (disabled) token.drop(1) else token
name.toCalendarViewOrNull()?.let { it to disabled }
CalendarView.entries.firstOrNull { it.name == name }?.let { it to disabled }
}
val order = completeViewOrder(parsed.map { it.first })
// Only views explicitly stored disabled are excluded; anything appended
@@ -1008,33 +977,6 @@ private fun parseDurationOverrides(stored: String?): Map<Long, Int> =
calendarId to minutes
}.toMap()
/**
* Parse the per-calendar hidden-views map (`id=View,View` entries joined by `;`).
* Unknown view names and malformed entries are dropped; an entry left with no
* views is dropped with them.
*/
internal fun parseHiddenViews(stored: String?): Map<Long, Set<CalendarView>> =
stored?.split(ENTRY_SEP).orEmpty().mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP)
if (parts.size != 2) return@mapNotNull null
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
val views = parts[1].split(LIST_SEP)
.mapNotNullTo(mutableSetOf()) { it.trim().toCalendarViewOrNull() }
if (views.isEmpty()) null else calendarId to views
}.toMap()
private fun Map<Long, Set<CalendarView>>.withHiddenViews(
calendarId: Long,
views: Set<CalendarView>,
): Map<Long, Set<CalendarView>> = if (views.isEmpty()) this - calendarId else this + (calendarId to views)
internal fun Map<Long, Set<CalendarView>>.toStoredHiddenViews(): String? =
entries.filter { it.value.isNotEmpty() }.sortedBy { it.key }
.joinToString(ENTRY_SEP) { (id, views) ->
"$id$KEY_VALUE_SEP" + views.sortedBy { it.ordinal }.joinToString(LIST_SEP) { it.name }
}
.ifEmpty { null }
private fun Map<Long, Int>.toStoredDurations(): String =
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, minutes) ->
"$id$KEY_VALUE_SEP$minutes"
@@ -309,7 +309,3 @@ enum class FailureReason {
EventNotFound,
Unknown,
}
/** [this] without the events of [hidden] calendars. */
fun List<EventInstance>.withoutCalendars(hidden: Set<Long>): List<EventInstance> =
if (hidden.isEmpty()) this else filterNot { it.calendarId in hidden }
@@ -97,8 +97,6 @@ import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.TimelineScreen
import de.jeanlucmakiola.calendula.ui.filter.FilterViewModel
import de.jeanlucmakiola.calendula.ui.filter.ViewCalendarsPicker
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
@@ -194,15 +192,10 @@ fun CalendarHost(
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
val onOpenDetail: (EventInstance) -> Unit = { event -> detailKey = event.occurrenceKey() }
// The drawer's "Hide in this view" picker (#517). It is its own window over
// everything here, so each external request below closes it first.
var showViewCalendars by rememberSaveable { mutableStateOf(false) }
// A tapped reminder notification asks for a specific occurrence.
val requestedDetailKey = requests.detailKey
LaunchedEffect(requestedDetailKey) {
if (requestedDetailKey != null) {
showViewCalendars = false
detailKey = requestedDetailKey
requests.detailKey = null
}
@@ -218,7 +211,6 @@ fun CalendarHost(
// top bar. Sits below the detail/edit overlays so tapping a result reveals
// the detail on top and backing out returns to the results.
var showSearch by rememberSaveable { mutableStateOf(false) }
val filterViewModel: FilterViewModel = hiltViewModel()
val onOpenSearch = { showSearch = true }
// Calendar manager (reached from Settings) — its own overlay so it slides
@@ -324,7 +316,6 @@ fun CalendarHost(
requests.importUri = null
return@LaunchedEffect
}
showViewCalendars = false
importUri = requestedImportUri
importForceMany = false
importSession++
@@ -342,7 +333,6 @@ fun CalendarHost(
requests.insert = null
return@LaunchedEffect
}
showViewCalendars = false
if (importForm == null) {
openImportForm(requestedInsert.form, requestedInsert.source, fromRequest = true)
} else {
@@ -359,7 +349,6 @@ fun CalendarHost(
fun dismissCoveringOverlays(keepCreate: Boolean = false) {
showSettings = false
showSearch = false
showViewCalendars = false
showCalendars = false
showBackup = false
detailKey = null
@@ -569,15 +558,6 @@ fun CalendarHost(
// (#239); nothing else in the calendar surfaces navigates here.
LocalManageCalendars provides remember { { showCalendars = true } },
) {
if (showViewCalendars) {
val filterState by filterViewModel.state.collectAsStateWithLifecycle()
ViewCalendarsPicker(
view = view,
state = filterState,
onSetHidden = { id, hidden -> filterViewModel.setHiddenIn(id, view, hidden) },
onDismiss = { showViewCalendars = false },
)
}
// One drawer, top bar and FAB for every screen (#184): a switch changes
// what they say, from what the screen on show publishes, rather than
// swapping them for another screen's.
@@ -608,10 +588,6 @@ fun CalendarHost(
onOpenSettings()
switchScope.launch { drawerState.close() }
},
onHideInView = {
showViewCalendars = true
switchScope.launch { drawerState.close() }
},
)
},
) {
@@ -15,9 +15,6 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.calendarsHiddenIn
import de.jeanlucmakiola.calendula.ui.common.instancesWithout
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
@@ -49,8 +46,6 @@ class AgendaViewModel @Inject constructor(
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val hiddenHere = settingsPrefs.perCalendarHiddenViews.calendarsHiddenIn(CalendarView.Agenda, viewModelScope)
// The saved agenda range plus the range-bar visibility toggle.
private val agendaSettings = combine(
settingsPrefs.agendaScreenRange,
@@ -143,7 +138,7 @@ class AgendaViewModel @Inject constructor(
)
combine(
repository.calendars(),
repository.instancesWithout(window, hiddenHere),
repository.instances(window),
today,
) { calendars, instances, today ->
buildState(params, calendars, instances, today)
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.ui.calendars
import android.accounts.AccountManager
import android.content.Context
import android.content.Intent
import android.icu.text.ListFormatter
import android.provider.Settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
@@ -46,7 +45,6 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
@@ -56,7 +54,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
@@ -75,10 +72,6 @@ import de.jeanlucmakiola.calendula.domain.stateLabels
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.AccountKey
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.label
import de.jeanlucmakiola.calendula.ui.common.CalendarVisibilitySwitch
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
@@ -99,7 +92,6 @@ import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.MultiOptionPicker
import de.jeanlucmakiola.floret.components.Position
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.heightIn
@@ -138,19 +130,14 @@ fun CalendarsScreen(
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val loadedHiddenViews by viewModel.hiddenViews.collectAsStateWithLifecycle()
val hiddenViews = loadedHiddenViews.orEmpty()
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
// [editorSession] bumps on every open so the editor's field state resets for
// a fresh open while still surviving configuration changes within one open.
var editorId by rememberSaveable { mutableStateOf<Long?>(null) }
var editorSession by rememberSaveable { mutableStateOf(0) }
// A synced calendar has no editor; tapping it opens its hidden-views picker.
var hidingId by rememberSaveable { mutableStateOf<Long?>(null) }
// The editor and the picker seed from the stored hidden views, so both wait for the first read.
if (editorId != null && loadedHiddenViews != null) {
if (editorId != null) {
val editing = calendars.firstOrNull { it.id == editorId }
CalendarEditor(
sessionKey = editorSession,
@@ -158,15 +145,13 @@ fun CalendarsScreen(
initialName = editing?.displayName.orEmpty(),
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
initialDescription = editing?.description.orEmpty(),
showHiddenViews = true,
initialHiddenViews = editing?.let { hiddenViews[it.id] }.orEmpty(),
deleteLocked = editing != null && editing.id in deleteLockedIds,
onSave = { name, color, description, views ->
onSave = { name, color, description ->
val id = editorId
if (id == null || id == NEW_CALENDAR_ID) {
viewModel.createCalendar(name, color, description, views)
viewModel.createCalendar(name, color, description)
} else {
viewModel.updateCalendar(id, name, color, description, views)
viewModel.updateCalendar(id, name, color, description)
}
editorId = null
},
@@ -180,7 +165,6 @@ fun CalendarsScreen(
CalendarsList(
local = calendars.filter { it.isLocal },
synced = calendars.filterNot { it.isLocal },
hiddenViews = hiddenViews,
error = error,
onConsumeError = viewModel::consumeError,
onOpenBackup = onOpenBackup,
@@ -188,26 +172,9 @@ fun CalendarsScreen(
visible = visible,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id },
onEditHiddenViews = { calendar -> hidingId = calendar.id },
onSetVisible = viewModel::setCalendarVisible,
onSetAccountVisible = viewModel::setAccountVisible,
)
hidingId?.let { id ->
val calendar = calendars.firstOrNull { it.id == id }
// Gone while the picker was up; an empty list is one still loading, not a deletion.
if (calendar == null && calendars.isNotEmpty()) {
LaunchedEffect(id) { hidingId = null }
} else if (calendar != null && loadedHiddenViews != null) {
key(id) {
HiddenViewsPicker(
calendarName = calendar.displayName.orUnnamed(),
hidden = hiddenViews[id].orEmpty(),
onToggle = { view, hide -> viewModel.setHiddenIn(id, view, hide) },
onDismiss = { hidingId = null },
)
}
}
}
}
}
@@ -215,7 +182,6 @@ fun CalendarsScreen(
private fun CalendarsList(
local: List<CalendarSource>,
synced: List<CalendarSource>,
hiddenViews: Map<Long, Set<CalendarView>>,
error: Boolean,
onConsumeError: () -> Unit,
onOpenBackup: () -> Unit,
@@ -223,7 +189,6 @@ private fun CalendarsList(
visible: Boolean,
onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit,
onEditHiddenViews: (CalendarSource) -> Unit,
onSetVisible: (Long, Boolean) -> Unit,
onSetAccountVisible: (Collection<Long>, Boolean) -> Unit,
) {
@@ -276,7 +241,6 @@ private fun CalendarsList(
local.forEachIndexed { index, calendar ->
CalendarManagerRow(
calendar = calendar,
hiddenViews = hiddenViews[calendar.id].orEmpty(),
last = index == local.lastIndex,
onSetVisible = onSetVisible,
onClick = { onEdit(calendar) },
@@ -351,10 +315,8 @@ private fun CalendarsList(
ordered.forEachIndexed { index, calendar ->
CalendarManagerRow(
calendar = calendar,
hiddenViews = hiddenViews[calendar.id].orEmpty(),
last = index == ordered.lastIndex,
onSetVisible = onSetVisible,
onClick = { onEditHiddenViews(calendar) },
)
}
}
@@ -377,32 +339,19 @@ internal fun CalendarEditor(
initialName: String,
initialColor: Int,
initialDescription: String,
onSave: (name: String, color: Int, description: String?, hiddenChanges: Map<CalendarView, Boolean>) -> Unit,
onSave: (name: String, color: Int, description: String?) -> Unit,
onDelete: () -> Unit,
onClose: () -> Unit,
deleteLocked: Boolean = false,
showHiddenViews: Boolean = false,
initialHiddenViews: Set<CalendarView> = emptySet(),
) {
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
val seededHiddenViews = rememberSaveable(sessionKey) { initialHiddenViews }
var hiddenViews by rememberSaveable(sessionKey) { mutableStateOf(initialHiddenViews) }
var pickHiddenViews by rememberSaveable(sessionKey) { mutableStateOf(false) }
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
var confirmDelete by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val valid = name.isNotBlank()
val commit = {
if (valid) {
// Only the views toggled here are written, so a save never resets ones it didn't load.
val changes = IMPLEMENTED_VIEWS
.filter { (it in hiddenViews) != (it in seededHiddenViews) }
.associateWith { it in hiddenViews }
onSave(name.trim(), color, description.trim().ifEmpty { null }, changes)
}
}
val commit = { if (valid) onSave(name.trim(), color, description.trim().ifEmpty { null }) }
FullScreenPicker(
title = stringResource(
@@ -454,16 +403,6 @@ internal fun CalendarEditor(
}
}
if (showHiddenViews) {
Spacer(Modifier.height(20.dp))
GroupedRow(
title = stringResource(R.string.calendars_hide_in_views),
summary = hiddenViewsSummary(hiddenViews),
position = Position.Alone,
onClick = { pickHiddenViews = true },
)
}
if (!isNew && !deleteLocked) {
Spacer(Modifier.height(24.dp))
DeleteCalendarRow(onClick = { confirmDelete = true })
@@ -471,17 +410,6 @@ internal fun CalendarEditor(
Spacer(Modifier.height(24.dp))
}
if (pickHiddenViews) {
HiddenViewsPicker(
calendarName = name.trim().orUnnamed(),
hidden = hiddenViews,
onToggle = { view, hide ->
hiddenViews = if (hide) hiddenViews + view else hiddenViews - view
},
onDismiss = { pickHiddenViews = false },
)
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
@@ -708,7 +636,7 @@ private fun EditorSectionLabel(text: String) {
* plain writable one (#76), then its own description.
*/
@Composable
private fun calendarRowSummary(calendar: CalendarSource, hiddenViews: Set<CalendarView>): String? {
private fun calendarRowSummary(calendar: CalendarSource): String? {
val states = calendar.stateLabels().map { label ->
stringResource(
when (label) {
@@ -718,8 +646,7 @@ private fun calendarRowSummary(calendar: CalendarSource, hiddenViews: Set<Calend
},
)
}
val hidden = if (hiddenViews.isEmpty()) null else hiddenViewsSummary(hiddenViews)
val parts = states + listOfNotNull(hidden, calendar.description?.takeIf { it.isNotBlank() })
val parts = states + listOfNotNull(calendar.description?.takeIf { it.isNotBlank() })
return parts.joinToString(" · ").ifEmpty { null }
}
@@ -735,7 +662,6 @@ private fun calendarRowSummary(calendar: CalendarSource, hiddenViews: Set<Calend
@Composable
private fun CalendarManagerRow(
calendar: CalendarSource,
hiddenViews: Set<CalendarView>,
last: Boolean,
onSetVisible: (Long, Boolean) -> Unit,
onClick: (() -> Unit)? = null,
@@ -743,7 +669,7 @@ private fun CalendarManagerRow(
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
GroupedRow(
title = calendar.displayName.orUnnamed(),
summary = calendarRowSummary(calendar, hiddenViews),
summary = calendarRowSummary(calendar),
position = if (last) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
@@ -763,51 +689,6 @@ private fun CalendarManagerRow(
)
}
/**
* Which views one calendar stays out of (#270); display only, so reminders and
* search keep the calendar. [onToggle] decides when a toggle lands: at once for
* a synced calendar, on Save inside the local editor.
*/
@Composable
private fun HiddenViewsPicker(
calendarName: String,
hidden: Set<CalendarView>,
onToggle: (CalendarView, Boolean) -> Unit,
onDismiss: () -> Unit,
) {
// Seeded once and then ahead of the store: the picker is the only writer while
// it is open, so following each write back would only flicker a quick second tap.
var shown by remember { mutableStateOf(hidden) }
MultiOptionPicker(
title = stringResource(R.string.calendars_hide_in_views),
options = IMPLEMENTED_VIEWS,
selected = shown,
label = { it.label() },
onToggle = { view, hide ->
shown = if (hide) shown + view else shown - view
onToggle(view, hide)
},
onDismiss = onDismiss,
leading = { Icon(it.icon, contentDescription = null) },
header = {
GroupedHint(
stringResource(R.string.calendars_hide_in_views_hint, calendarName),
verticalPadding = 8.dp,
)
},
predictiveBack = true,
)
}
/** "Shown in every view", or the views [hidden] names, in the app's view order. */
@Composable
private fun hiddenViewsSummary(hidden: Set<CalendarView>): String {
if (hidden.isEmpty()) return stringResource(R.string.calendars_hide_in_views_none)
val names = IMPLEMENTED_VIEWS.filter { it in hidden }.map { it.label() }
val locale = LocalConfiguration.current.locales[0]
return stringResource(R.string.calendars_hidden_in, ListFormatter.getInstance(locale).format(names))
}
/** 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
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.ui.calendars
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -16,8 +15,6 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.hidingNothingOnError
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
@@ -107,19 +104,6 @@ class CalendarsViewModel @Inject constructor(
initialValue = emptySet(),
)
/** The views each calendar is hidden in (#270); null until first read. */
val hiddenViews: StateFlow<Map<Long, Set<CalendarView>>?> =
settingsPrefs.perCalendarHiddenViews
.hidingNothingOnError()
.stateWhileSubscribed(
scope = viewModelScope,
initialValue = null,
)
fun setHiddenIn(id: Long, view: CalendarView, hidden: Boolean) = write {
settingsPrefs.setCalendarHiddenIn(id, view, hidden)
}
private val _error = MutableStateFlow(false)
val error: StateFlow<Boolean> = _error.asStateFlow()
@@ -155,41 +139,16 @@ class CalendarsViewModel @Inject constructor(
}
}
fun createCalendar(
displayName: String,
color: Int,
description: String?,
hiddenChanges: Map<CalendarView, Boolean>,
) = write {
val id = repository.createLocalCalendar(displayName, color, description)
settingsPrefs.applyCalendarHiddenChanges(id, hiddenChanges)
fun createCalendar(displayName: String, color: Int, description: String?) = write {
repository.createLocalCalendar(displayName, color, description)
}
fun updateCalendar(
id: Long,
displayName: String,
color: Int,
description: String?,
hiddenChanges: Map<CalendarView, Boolean>,
) = write {
fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) = write {
repository.updateCalendar(id, displayName, color, description)
settingsPrefs.applyCalendarHiddenChanges(id, hiddenChanges)
}
fun deleteCalendar(id: Long) = write {
repository.deleteCalendar(id)
// Tidy-up only: the delete already landed, so a failure here isn't its failure.
bestEffort { settingsPrefs.clearCalendarHiddenViews(id) }
}
private suspend fun bestEffort(block: suspend () -> Unit) {
try {
block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("CalendarsViewModel", "Hidden-views write failed", e)
}
}
/**
@@ -70,7 +70,6 @@ fun CalendarDrawer(
onSelectView: (CalendarView) -> Unit,
onJumpToDate: (LocalDate) -> Unit,
onSettings: () -> Unit,
onHideInView: () -> Unit,
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
) {
var showDatePicker by remember { mutableStateOf(false) }
@@ -113,7 +112,7 @@ fun CalendarDrawer(
Spacer(Modifier.height(16.dp))
DrawerSectionHeader(stringResource(R.string.filter_title))
CalendarFilterList(currentView = currentView, onHideInView = onHideInView)
CalendarFilterList()
Spacer(Modifier.height(16.dp))
GroupedRow(
@@ -1,63 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.withoutCalendars
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.shareIn
import java.io.IOException
import kotlin.time.Instant
/** The calendars hidden in [view] under a per-calendar hidden-views map (#270). */
fun Map<Long, Set<CalendarView>>.calendarsHiddenIn(view: CalendarView): Set<Long> =
filterValues { view in it }.keys
/**
* The calendars hidden in [view], shared by everything one view model shows so
* the setting is read once rather than per page. A failed read hides nothing
* rather than failing the view; the replay expires with the last subscriber, so
* a returning view filters against a fresh read.
*/
fun Flow<Map<Long, Set<CalendarView>>>.calendarsHiddenIn(
view: CalendarView,
scope: CoroutineScope,
): Flow<Set<Long>> =
hidingNothingOnError()
.map { it.calendarsHiddenIn(view) }
.distinctUntilChanged()
// The grace period outlasts a flatMapLatest swap, so paging doesn't re-read it.
.shareIn(scope, SharingStarted.WhileSubscribed(5_000, replayExpirationMillis = 0), replay = 1)
/**
* A failed read of the per-view setting hides nothing rather than failing what
* shows it, and keeps reading, so the next change still lands.
*/
fun Flow<Map<Long, Set<CalendarView>>>.hidingNothingOnError(): Flow<Map<Long, Set<CalendarView>>> =
retryWhen { cause, attempt ->
if (cause !is IOException) return@retryWhen false
emit(emptyMap())
// Backs off, so a file that keeps failing (corrupt) isn't re-read every second.
delay((RETRY_BASE_DELAY_MS shl attempt.coerceAtMost(6).toInt()).coerceAtMost(RETRY_MAX_DELAY_MS))
true
}
private const val RETRY_BASE_DELAY_MS = 1_000L
private const val RETRY_MAX_DELAY_MS = 60_000L
/**
* [CalendarRepository.instances] as one view shows them: also without the
* [hidden] calendars the user keeps out of it. Display only — reminders, search
* and widgets read the repository directly.
*/
fun CalendarRepository.instancesWithout(
range: ClosedRange<Instant>,
hidden: Flow<Set<Long>>,
): Flow<List<EventInstance>> =
combine(instances(range), hidden) { instances, ids -> instances.withoutCalendars(ids) }
@@ -4,23 +4,15 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -30,26 +22,20 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.CalendarAccountGroup
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
/**
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
* Every calendar grouped by account, each with a colour swatch and a visibility
* switch; toggling writes straight to DataStore and every calendar view
* re-filters live. Three states (Loading / Failure / Success). A calendar hidden
* in [currentView] through its per-view setting (#270) says so on its row, and
* the entry below the list opens that setting for the view (#517).
* re-filters live. Three states (Loading / Failure / Success).
*/
@Composable
fun CalendarFilterList(
currentView: CalendarView,
onHideInView: () -> Unit,
modifier: Modifier = Modifier,
viewModel: FilterViewModel = hiltViewModel(),
) {
@@ -57,18 +43,12 @@ fun CalendarFilterList(
when (val s = state) {
FilterUiState.Loading -> FilterLoading(modifier)
is FilterUiState.Failure -> FilterMessage(s.reason, modifier)
is FilterUiState.Success -> {
val hiddenInView = remember(s, currentView) { s.hiddenOnlyIn(currentView) }
FilterList(
groups = s.groups,
hidden = s.hidden,
hiddenInView = hiddenInView,
hiddenOnlyHere = hiddenInView.size,
onHideInView = onHideInView,
onSetVisible = viewModel::setVisible,
modifier = modifier,
)
}
is FilterUiState.Success -> FilterList(
groups = s.groups,
hidden = s.hidden,
onSetVisible = viewModel::setVisible,
modifier = modifier,
)
}
}
@@ -81,9 +61,6 @@ fun CalendarFilterList(
private fun FilterList(
groups: List<CalendarAccountGroup>,
hidden: Set<Long>,
hiddenInView: Set<Long>,
hiddenOnlyHere: Int,
onHideInView: () -> Unit,
onSetVisible: (Long, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -104,7 +81,6 @@ private fun FilterList(
val visible = cal.id !in hidden
GroupedRow(
title = cal.displayName.orUnnamed(),
summary = if (cal.id in hiddenInView) stringResource(R.string.filter_hidden_in_view) else null,
position = positionOf(index, group.calendars.size),
minHeight = 56.dp,
leading = { CalendarColorChip(cal.color) },
@@ -118,32 +94,11 @@ private fun FilterList(
)
}
}
// The per-view hide (#517): the drawer filter above hides everywhere, this only here.
Spacer(Modifier.height(8.dp))
GroupedRow(
title = stringResource(R.string.view_calendars_title),
summary = if (hiddenOnlyHere > 0) {
pluralStringResource(R.plurals.drawer_hidden_in_view_count, hiddenOnlyHere, hiddenOnlyHere)
} else {
stringResource(R.string.drawer_hidden_in_view_none)
},
position = Position.Alone,
minHeight = 56.dp,
// Tinted, not "selected": it is an entry point, not a chosen option.
container = if (hiddenOnlyHere > 0) MaterialTheme.colorScheme.secondaryContainer else null,
contentColor = if (hiddenOnlyHere > 0) MaterialTheme.colorScheme.onSecondaryContainer else null,
leading = {
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
Icon(Icons.Filled.FilterList, contentDescription = null)
}
},
onClick = onHideInView,
)
}
}
@Composable
internal fun FilterLoading(modifier: Modifier = Modifier) {
private fun FilterLoading(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -164,7 +119,7 @@ internal fun FilterLoading(modifier: Modifier = Modifier) {
}
@Composable
internal fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
val msg = when (reason) {
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
@@ -2,8 +2,6 @@ package de.jeanlucmakiola.calendula.ui.filter
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.CalendarAccountGroup
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.calendarsHiddenIn
/**
* State for the calendar-filter sheet (M3). The user toggles per-calendar
@@ -13,22 +11,9 @@ import de.jeanlucmakiola.calendula.ui.common.calendarsHiddenIn
sealed interface FilterUiState {
data object Loading : FilterUiState
data class Failure(val reason: FailureReason) : FilterUiState
/**
* A calendar shows when its id is *not* in [hidden]; [hiddenViews] are the
* views each calendar is hidden in regardless (#270).
*/
/** A calendar shows when its id is *not* in [hidden]. */
data class Success(
val groups: List<CalendarAccountGroup>,
val hidden: Set<Long>,
val hiddenViews: Map<Long, Set<CalendarView>> = emptyMap(),
) : FilterUiState {
/**
* The listed calendars [view] hides on its own (#517); ones already
* hidden everywhere from the drawer, or not listed, aren't included.
*/
fun hiddenOnlyIn(view: CalendarView): Set<Long> {
val listed = groups.flatMapTo(mutableSetOf()) { group -> group.calendars.map { it.id } }
return hiddenViews.calendarsHiddenIn(view).filterTo(mutableSetOf()) { it in listed && it !in hidden }
}
}
) : FilterUiState
}
@@ -1,20 +1,15 @@
package de.jeanlucmakiola.calendula.ui.filter
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.hidingNothingOnError
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
@@ -28,7 +23,6 @@ import javax.inject.Inject
class FilterViewModel @Inject constructor(
private val repository: CalendarRepository,
private val prefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -36,9 +30,7 @@ class FilterViewModel @Inject constructor(
combine(
repository.calendars(),
prefs.hiddenCalendarIds,
// Only feeds a note on the rows; a failed read leaves it off rather than failing the list.
settingsPrefs.perCalendarHiddenViews.hidingNothingOnError(),
) { calendars, hidden, hiddenViews ->
) { calendars, hidden ->
// Calendars switched off device-wide don't belong in the drawer's
// hide/show list; they live only in Settings → Calendars.
val enabled = calendars.filter { it.isVisibleInSystem }
@@ -46,7 +38,7 @@ class FilterViewModel @Inject constructor(
if (failure != null) {
FilterUiState.Failure(failure)
} else {
FilterUiState.Success(enabled.groupByAccount(), hidden, hiddenViews)
FilterUiState.Success(enabled.groupByAccount(), hidden)
}
}
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
@@ -56,19 +48,6 @@ class FilterViewModel @Inject constructor(
initialValue = FilterUiState.Loading,
)
/** Hide or show one calendar in [view] only (#517), the same setting as Settings → Calendars. */
fun setHiddenIn(calendarId: Long, view: CalendarView, hidden: Boolean) {
viewModelScope.launch {
try {
settingsPrefs.setCalendarHiddenIn(calendarId, view, hidden)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("FilterViewModel", "Could not change hidden views of $calendarId", e)
}
}
}
/** Show or hide a single calendar; persists the new hidden set. */
fun setVisible(calendarId: Long, visible: Boolean) {
viewModelScope.launch {
@@ -1,65 +0,0 @@
package de.jeanlucmakiola.calendula.ui.filter
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.calendarsHiddenIn
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.MultiOptionRows
/**
* The calendars hidden in [view] (#517), opened from the drawer while on it: the
* per-view setting from Settings → Calendars (#270), seen from the view's side.
* Toggles apply at once.
*/
@Composable
fun ViewCalendarsPicker(
view: CalendarView,
state: FilterUiState,
onSetHidden: (calendarId: Long, hidden: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
FullScreenPicker(
title = stringResource(R.string.view_calendars_title),
onDismiss = onDismiss,
predictiveBack = true,
) {
when (state) {
FilterUiState.Loading -> FilterLoading()
is FilterUiState.Failure -> FilterMessage(state.reason)
is FilterUiState.Success -> {
// Seeded once, then ahead of the store: the picker is the only
// writer while open, so a quick second tap still flips.
var hidden by remember(view) { mutableStateOf(state.hiddenViews.calendarsHiddenIn(view)) }
GroupedHint(stringResource(R.string.view_calendars_hint), verticalPadding = 8.dp)
state.groups.forEach { group ->
GroupedSectionHeader(accountGroupTitle(group))
MultiOptionRows(
options = group.calendars,
selected = group.calendars.filterTo(mutableSetOf()) { it.id in hidden },
label = { it.displayName.orUnnamed() },
onToggle = { calendar, hide ->
hidden = if (hide) hidden + calendar.id else hidden - calendar.id
onSetHidden(calendar.id, hide)
},
summary = { calendar ->
if (calendar.id in state.hidden) stringResource(R.string.view_calendars_hidden_everywhere) else null
},
leading = { CalendarColorChip(it.color) },
)
}
}
}
}
}
@@ -19,10 +19,7 @@ import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
import de.jeanlucmakiola.calendula.ui.common.CalendarSpan
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.ui.common.calendarsHiddenIn
import de.jeanlucmakiola.calendula.ui.common.instancesWithout
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
import de.jeanlucmakiola.calendula.ui.week.coversDay
@@ -71,8 +68,6 @@ class MonthViewModel @Inject constructor(
private val zone = TimeZone.currentSystemDefault()
private val hiddenHere = settingsPrefs.perCalendarHiddenViews.calendarsHiddenIn(CalendarView.Month, viewModelScope)
/** First day of the week, from the Settings preference (AUTO → locale). */
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
@@ -157,7 +152,7 @@ class MonthViewModel @Inject constructor(
) { (ym, ws) ->
combine(
repository.calendars(),
repository.instancesWithout(monthGridRange(ym, ws, zone), hiddenHere),
repository.instances(monthGridRange(ym, ws, zone)),
today,
) { calendars, instances, today ->
buildState(ym, ws, calendars, instances, today)
@@ -229,7 +224,7 @@ class MonthViewModel @Inject constructor(
val range = dayRange(first, last, zone)
combine(
repository.calendars(),
repository.instancesWithout(range, hiddenHere),
repository.instances(range),
today,
) { calendars, instances, today ->
buildContinuousState(window, ws, style, calendars, instances, today)
@@ -89,7 +89,7 @@ internal fun CalendarsStep(
initialName = "",
initialColor = CalendarColorPalette.all.first(),
initialDescription = "",
onSave = { name, color, description, _ ->
onSave = { name, color, description ->
if (!submitted) {
submitted = true
onCreateLocal(name, color, description)
@@ -19,10 +19,7 @@ import de.jeanlucmakiola.calendula.domain.displayPriority
import de.jeanlucmakiola.calendula.domain.displayStart
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.ui.common.calendarsHiddenIn
import de.jeanlucmakiola.calendula.ui.common.instancesWithout
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
@@ -92,11 +89,8 @@ abstract class PagedTimelineViewModel(
private val io: CoroutineDispatcher,
gridRule: Flow<(LocalDate) -> DaysGrid>,
initialRule: (LocalDate) -> DaysGrid,
view: CalendarView,
) : ViewModel() {
private val hiddenHere = settingsPrefs.perCalendarHiddenViews.calendarsHiddenIn(view, viewModelScope)
/**
* The zone every page is laid out in; drags and drops resolve in it too, and
* pages lay out again when the device moves zones (#387).
@@ -173,7 +167,7 @@ abstract class PagedTimelineViewModel(
zone.flatMapLatest { zone ->
combine(
repository.calendars(),
repository.instancesWithout(spanRange(span.start, span.count, zone), hiddenHere),
repository.instances(spanRange(span.start, span.count, zone)),
today,
) { calendars, instances, today ->
buildState(span, calendars, instances, today, zone)
@@ -246,7 +240,6 @@ class WeekViewModel @Inject constructor(
rule
},
initialRule = { DaysGrid.weeks(DayOfWeek.MONDAY) },
view = CalendarView.Week,
)
/**
@@ -271,7 +264,6 @@ class MultiDayViewModel @Inject constructor(
{ anchor: LocalDate -> DaysGrid.startingAt(anchor, count) }
},
initialRule = { DaysGrid.startingAt(it, SettingsPrefs.DEFAULT_MULTI_DAY_COUNT) },
view = CalendarView.MultiDay,
)
/** The day view: the timeline one day at a time. */
@@ -290,7 +282,6 @@ class DayViewModel @Inject constructor(
io = io,
gridRule = flowOf { _: LocalDate -> DaysGrid.days },
initialRule = { DaysGrid.days },
view = CalendarView.Day,
)
/**
@@ -11,6 +11,7 @@ import androidx.glance.GlanceId
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
@@ -115,5 +116,8 @@ internal fun MutablePreferences.putHiddenCalendars(ids: Set<Long>) {
this[HiddenCalendarsKey] = ids.map(Long::toString).toSet()
}
internal fun List<EventInstance>.withoutCalendars(hidden: Set<Long>): List<EventInstance> =
if (hidden.isEmpty()) this else filterNot { it.calendarId in hidden }
internal suspend fun Context.widgetState(id: GlanceId): Preferences =
getAppWidgetState(this, PreferencesGlanceStateDefinition, id)
@@ -14,7 +14,6 @@ import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.withoutCalendars
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
@@ -12,7 +12,6 @@ import de.jeanlucmakiola.floret.glance.updateWithRefresh
import kotlinx.coroutines.CancellationException
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.withoutCalendars
import de.jeanlucmakiola.calendula.widget.Themed
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
@@ -23,6 +22,7 @@ import de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.calendula.widget.widgetState
import de.jeanlucmakiola.calendula.widget.withoutCalendars
import de.jeanlucmakiola.floret.prefs.toEnum
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
+1 -14
View File
@@ -849,7 +849,7 @@
<string name="calendars_state_managed">Filled from your contacts</string>
<string name="calendars_managed_delete_locked">This calendar is filled from your contacts, so Calendula would create it again on the next sync. Turn special dates off under Settings → Special dates to delete it.</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; tap one here to hide it in some views.</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 in app</string>
<!-- Account header when two accounts share a name: %1$s is the account, %2$s the app it comes from. -->
<string name="calendars_account_from_source">%1$s (%2$s)</string>
@@ -863,19 +863,6 @@
<string name="calendars_color_label">Color</string>
<string name="calendars_description_label">Description</string>
<string name="calendars_description_hint">Add a description</string>
<string name="calendars_hide_in_views">Hide in views</string>
<string name="calendars_hide_in_views_hint">Events from “%1$s” stay out of the views checked here. Reminders, search and widgets still include them.</string>
<string name="calendars_hide_in_views_none">Shown in every view</string>
<string name="calendars_hidden_in">Hidden in %1$s</string>
<string name="filter_hidden_in_view">Hidden in this view</string>
<string name="view_calendars_title">Hide in this view</string>
<plurals name="drawer_hidden_in_view_count">
<item quantity="one">%d calendar hidden in this view</item>
<item quantity="other">%d calendars hidden in this view</item>
</plurals>
<string name="drawer_hidden_in_view_none">Leave calendars out of this view only</string>
<string name="view_calendars_hint">Checked calendars stay out of this view only. Reminders, search and widgets still include them.</string>
<string name="view_calendars_hidden_everywhere">Hidden in every view from the menu</string>
<string name="color_name_mauve">Mauve</string>
<string name="color_name_red">Red</string>
<string name="color_name_orange">Orange</string>
@@ -616,66 +616,6 @@ class SettingsPrefsTest {
assertThat(prefs.perCalendarEventDuration.first()).containsExactly(9L, 45)
}
@Test
fun `per-calendar hidden views round-trip and clear back to every view`(
@TempDir tempDir: Path,
) = runTest {
val prefs = testSettings(tempDir)
assertThat(prefs.perCalendarHiddenViews.first()).isEmpty()
prefs.applyCalendarHiddenChanges(7L, listOf(CalendarView.Month, CalendarView.Agenda).associateWith { true })
prefs.applyCalendarHiddenChanges(9L, listOf(CalendarView.MultiDay).associateWith { true })
assertThat(prefs.perCalendarHiddenViews.first()).containsExactly(
7L, setOf(CalendarView.Month, CalendarView.Agenda),
9L, setOf(CalendarView.MultiDay),
)
prefs.clearCalendarHiddenViews(7L)
assertThat(prefs.perCalendarHiddenViews.first()).containsExactly(9L, setOf(CalendarView.MultiDay))
// One write can hide some views and show others, leaving the rest alone.
prefs.applyCalendarHiddenChanges(9L, mapOf(CalendarView.MultiDay to false, CalendarView.Day to true))
assertThat(prefs.perCalendarHiddenViews.first()).containsExactly(9L, setOf(CalendarView.Day))
}
@Test
fun `toggling one hidden view keeps the others and clears the entry when empty`(
@TempDir tempDir: Path,
) = runTest {
val prefs = testSettings(tempDir)
prefs.setCalendarHiddenIn(7L, CalendarView.Month, hidden = true)
prefs.setCalendarHiddenIn(7L, CalendarView.Week, hidden = true)
assertThat(prefs.perCalendarHiddenViews.first())
.containsExactly(7L, setOf(CalendarView.Month, CalendarView.Week))
prefs.setCalendarHiddenIn(7L, CalendarView.Month, hidden = false)
prefs.setCalendarHiddenIn(7L, CalendarView.Week, hidden = false)
assertThat(prefs.perCalendarHiddenViews.first()).isEmpty()
}
@Test
fun `garbage hidden-views entries are dropped, the rest survive`(
@TempDir tempDir: Path,
) = runTest {
val store = testDataStore(tempDir, "test_settings")
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
// A bad id, only an unknown view, no separator, and a good entry with one unknown view.
m[stringPreferencesKey("per_calendar_hidden_views")] = "x=Month;7=Year;8;9=Month,Year,Agenda"
m
}
assertThat(prefs.perCalendarHiddenViews.first())
.containsExactly(9L, setOf(CalendarView.Month, CalendarView.Agenda))
}
@Test
fun `hidden views serialise in view order and drop empty entries`() {
val stored = mapOf(
3L to setOf(CalendarView.Agenda, CalendarView.Month),
1L to emptySet(),
).toStoredHiddenViews()
assertThat(stored).isEqualTo("3=Month,Agenda")
assertThat(emptyMap<Long, Set<CalendarView>>().toStoredHiddenViews()).isNull()
assertThat(parseHiddenViews(stored)).containsExactly(3L, setOf(CalendarView.Month, CalendarView.Agenda))
}
@Test
fun `resolveDefaultEventDuration prefers the calendar's own length`() {
val overrides = mapOf(7L to 480)
@@ -1,100 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.testEvent
import de.jeanlucmakiola.calendula.testRepository
import de.jeanlucmakiola.calendula.testSettings
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Instant
@OptIn(ExperimentalCoroutinesApi::class)
class ViewHiddenCalendarsTest {
private val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000_000_000L)
private fun event(id: Long, calendarId: Long) = testEvent(
id = id,
calendarId = calendarId,
title = "E $id",
start = Instant.fromEpochMilliseconds(1_000_000_000L),
end = Instant.fromEpochMilliseconds(1_000_003_600L),
color = 0,
)
private fun calendar(id: Long) = CalendarSource(id, "Cal $id", "x@y", "LOCAL", 0, true)
@Test
fun `calendarsHiddenIn picks only the calendars hidden in that view`() {
val map = mapOf(
1L to setOf(CalendarView.Month),
2L to setOf(CalendarView.Month, CalendarView.Agenda),
3L to setOf(CalendarView.Week),
)
assertThat(map.calendarsHiddenIn(CalendarView.Month)).containsExactly(1L, 2L)
assertThat(map.calendarsHiddenIn(CalendarView.Agenda)).containsExactly(2L)
assertThat(map.calendarsHiddenIn(CalendarView.Day)).isEmpty()
}
@Test
fun `instancesWithout drops a calendar only in the views it is hidden in`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(calendar(1L), calendar(2L))
instancesResult = { _, _ -> listOf(event(10L, 1L), event(20L, 2L)) }
}
val settings = testSettings(tempDir)
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler), settings = settings)
settings.applyCalendarHiddenChanges(2L, listOf(CalendarView.Month).associateWith { true })
val hidden = settings.perCalendarHiddenViews
assertThat(repo.instancesWithout(range, hidden.calendarsHiddenIn(CalendarView.Month, backgroundScope)).first().map { it.eventId })
.containsExactly(10L)
assertThat(repo.instancesWithout(range, hidden.calendarsHiddenIn(CalendarView.Week, backgroundScope)).first().map { it.eventId })
.containsExactly(10L, 20L)
// The repository itself, which reminders and search build on, is untouched.
assertThat(repo.instances(range).first().map { it.eventId }).containsExactly(10L, 20L)
}
@Test
fun `instancesWithout re-filters live when the setting changes`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(calendar(1L), calendar(2L))
instancesResult = { _, _ -> listOf(event(10L, 1L), event(20L, 2L)) }
}
val settings = testSettings(tempDir)
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler), settings = settings)
val hidden = settings.perCalendarHiddenViews.calendarsHiddenIn(CalendarView.Agenda, backgroundScope)
repo.instancesWithout(range, hidden).test {
assertThat(awaitItem().map { it.eventId }).containsExactly(10L, 20L)
settings.setCalendarHiddenIn(1L, CalendarView.Agenda, hidden = true)
assertThat(awaitItem().map { it.eventId }).containsExactly(20L)
settings.setCalendarHiddenIn(1L, CalendarView.Agenda, hidden = false)
assertThat(awaitItem().map { it.eventId }).containsExactly(10L, 20L)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `a failed read hides nothing and keeps reading`() = runTest {
var reads = 0
val source = kotlinx.coroutines.flow.flow {
reads++
if (reads == 1) throw java.io.IOException("disk")
emit(mapOf(1L to setOf(CalendarView.Month)))
}
source.hidingNothingOnError().test {
assertThat(awaitItem()).isEmpty()
assertThat(awaitItem()).containsExactly(1L, setOf(CalendarView.Month))
awaitComplete()
}
}
}
@@ -1,31 +0,0 @@
package de.jeanlucmakiola.calendula.ui.filter
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import org.junit.jupiter.api.Test
class FilterUiStateTest {
private fun calendar(id: Long) = CalendarSource(id, "Cal $id", "x@y", "LOCAL", 0, true)
@Test
fun `hiddenOnlyIn lists the calendars the view hides on its own`() {
val state = FilterUiState.Success(
groups = listOf(calendar(1L), calendar(2L), calendar(3L)).groupByAccount(),
// 3 is hidden everywhere from the drawer already.
hidden = setOf(3L),
hiddenViews = mapOf(
1L to setOf(CalendarView.Month),
2L to setOf(CalendarView.Month, CalendarView.Week),
3L to setOf(CalendarView.Month),
// Not listed (switched off device-wide).
9L to setOf(CalendarView.Month),
),
)
assertThat(state.hiddenOnlyIn(CalendarView.Month)).containsExactly(1L, 2L)
assertThat(state.hiddenOnlyIn(CalendarView.Week)).containsExactly(2L)
assertThat(state.hiddenOnlyIn(CalendarView.Agenda)).isEmpty()
}
}
+1 -6
View File
@@ -389,12 +389,7 @@ pre-permission launch); `CalendarVisibilityReconciler` drains it entry by entry,
and until it does, the repository and `ReminderNotifier.post` honour it. That
gate also covers a snooze re-shown from our own alarm after its calendar was
switched off. The drawer's filter sheet (`CalendarPrefs.hiddenCalendarIds`) is a
separate in-app declutter that never touches reminders. Narrower still is the per-view hide
(`SettingsPrefs.perCalendarHiddenViews`, #270): each view model asks for its
events through `instancesWithout(range, calendarsHiddenIn(view, …))`, which drops
the calendars hidden in that one view on top of the repository's filters; reminders, search and widgets read
the repository and never see it. The drawer's "Hide in this view" entry (#517) edits the same
set for the view on show, and the drawer's per-row "Hidden in this view" notes read it.
separate in-app declutter that never touches reminders.
Deliberately absent: a fallback to the provider's `EVENT_REMINDER` broadcast.
Keeping both would double-post wherever the provider works, and Etar's way out —