Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dad1f2cd9c | ||
|
|
db8d649467 | ||
|
|
91fdaba6a2 | ||
|
|
e3e9865d3f |
No files matched your search
@@ -318,16 +318,13 @@ jobs:
|
||||
# not provide node inside custom job containers, so JavaScript actions
|
||||
# (checkout, download-artifact) can't run there. The Renovate job gets away
|
||||
# with a container because its only step is a shell command. Ruby is installed
|
||||
# the same way sshpass, jq and fdroidserver are in the job above, from trixie:
|
||||
# fastlane >= 2.241 needs Ruby 3.2+, and bookworm's apt only has 3.1.
|
||||
# the same way sshpass, jq and fdroidserver are in the job above.
|
||||
play:
|
||||
needs: [detect, release]
|
||||
# workflow_dispatch is the F-Droid re-sign recovery path — it must never
|
||||
# touch Play, so gate on a real release only.
|
||||
if: needs.detect.outputs.is_release == 'true'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: node:22-trixie
|
||||
env:
|
||||
VERSION: ${{ needs.detect.outputs.version }}
|
||||
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
|
||||
|
||||
@@ -165,6 +165,7 @@ 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)
|
||||
@@ -271,6 +272,25 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Hide [calendarId] in [views] only (#270); an empty set shows it everywhere again. */
|
||||
suspend fun setCalendarHiddenViews(calendarId: Long, views: Set<CalendarView>) {
|
||||
prefs.update(SettingsKeys.perCalendarHiddenViews) { current -> current.withHiddenViews(calendarId, views) }
|
||||
}
|
||||
|
||||
/** 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) =
|
||||
@@ -588,6 +608,15 @@ 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.
|
||||
@@ -817,9 +846,11 @@ private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored
|
||||
private fun parseViewOrder(stored: String?): List<CalendarView> =
|
||||
completeViewOrder(
|
||||
stored?.split(',').orEmpty()
|
||||
.mapNotNull { name -> CalendarView.entries.firstOrNull { it.name == name.trim() } },
|
||||
.mapNotNull { it.trim().toCalendarViewOrNull() },
|
||||
)
|
||||
|
||||
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
|
||||
@@ -827,7 +858,7 @@ private fun parseQuickSwitch(stored: String?): QuickSwitchConfig {
|
||||
val token = raw.trim()
|
||||
val disabled = token.startsWith("!")
|
||||
val name = if (disabled) token.drop(1) else token
|
||||
CalendarView.entries.firstOrNull { it.name == name }?.let { it to disabled }
|
||||
name.toCalendarViewOrNull()?.let { it to disabled }
|
||||
}
|
||||
val order = completeViewOrder(parsed.map { it.first })
|
||||
// Only views explicitly stored disabled are excluded; anything appended
|
||||
@@ -977,6 +1008,33 @@ 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,3 +309,7 @@ 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 }
|
||||
@@ -15,6 +15,9 @@ 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
|
||||
@@ -46,6 +49,8 @@ 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,
|
||||
@@ -138,7 +143,7 @@ class AgendaViewModel @Inject constructor(
|
||||
)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(window),
|
||||
repository.instancesWithout(window, hiddenHere),
|
||||
today,
|
||||
) { calendars, instances, today ->
|
||||
buildState(params, calendars, instances, today)
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -54,6 +55,7 @@ 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
|
||||
@@ -72,6 +74,11 @@ 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.floret.components.MultiOptionPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarVisibilitySwitch
|
||||
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
@@ -130,12 +137,15 @@ fun CalendarsScreen(
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
|
||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||
val hiddenViews by viewModel.hiddenViews.collectAsStateWithLifecycle()
|
||||
|
||||
// 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) }
|
||||
|
||||
if (editorId != null) {
|
||||
val editing = calendars.firstOrNull { it.id == editorId }
|
||||
@@ -145,13 +155,15 @@ 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 ->
|
||||
onSave = { name, color, description, views ->
|
||||
val id = editorId
|
||||
if (id == null || id == NEW_CALENDAR_ID) {
|
||||
viewModel.createCalendar(name, color, description)
|
||||
viewModel.createCalendar(name, color, description, views)
|
||||
} else {
|
||||
viewModel.updateCalendar(id, name, color, description)
|
||||
viewModel.updateCalendar(id, name, color, description, views)
|
||||
}
|
||||
editorId = null
|
||||
},
|
||||
@@ -165,6 +177,7 @@ fun CalendarsScreen(
|
||||
CalendarsList(
|
||||
local = calendars.filter { it.isLocal },
|
||||
synced = calendars.filterNot { it.isLocal },
|
||||
hiddenViews = hiddenViews,
|
||||
error = error,
|
||||
onConsumeError = viewModel::consumeError,
|
||||
onOpenBackup = onOpenBackup,
|
||||
@@ -172,9 +185,24 @@ 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) {
|
||||
HiddenViewsPicker(
|
||||
calendarName = calendar.displayName.orUnnamed(),
|
||||
hidden = hiddenViews[id].orEmpty(),
|
||||
onToggle = { view, hide -> viewModel.setHiddenIn(id, view, hide) },
|
||||
onDismiss = { hidingId = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +210,7 @@ fun CalendarsScreen(
|
||||
private fun CalendarsList(
|
||||
local: List<CalendarSource>,
|
||||
synced: List<CalendarSource>,
|
||||
hiddenViews: Map<Long, Set<CalendarView>>,
|
||||
error: Boolean,
|
||||
onConsumeError: () -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
@@ -189,6 +218,7 @@ private fun CalendarsList(
|
||||
visible: Boolean,
|
||||
onAdd: () -> Unit,
|
||||
onEdit: (CalendarSource) -> Unit,
|
||||
onEditHiddenViews: (CalendarSource) -> Unit,
|
||||
onSetVisible: (Long, Boolean) -> Unit,
|
||||
onSetAccountVisible: (Collection<Long>, Boolean) -> Unit,
|
||||
) {
|
||||
@@ -241,6 +271,7 @@ private fun CalendarsList(
|
||||
local.forEachIndexed { index, calendar ->
|
||||
CalendarManagerRow(
|
||||
calendar = calendar,
|
||||
hiddenViews = hiddenViews[calendar.id].orEmpty(),
|
||||
last = index == local.lastIndex,
|
||||
onSetVisible = onSetVisible,
|
||||
onClick = { onEdit(calendar) },
|
||||
@@ -315,8 +346,10 @@ private fun CalendarsList(
|
||||
ordered.forEachIndexed { index, calendar ->
|
||||
CalendarManagerRow(
|
||||
calendar = calendar,
|
||||
hiddenViews = hiddenViews[calendar.id].orEmpty(),
|
||||
last = index == ordered.lastIndex,
|
||||
onSetVisible = onSetVisible,
|
||||
onClick = { onEditHiddenViews(calendar) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -339,19 +372,32 @@ internal fun CalendarEditor(
|
||||
initialName: String,
|
||||
initialColor: Int,
|
||||
initialDescription: String,
|
||||
onSave: (name: String, color: Int, description: String?) -> Unit,
|
||||
onSave: (name: String, color: Int, description: String?, hiddenChanges: Map<CalendarView, Boolean>) -> 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) onSave(name.trim(), color, description.trim().ifEmpty { null }) }
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(
|
||||
@@ -403,6 +449,16 @@ 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 })
|
||||
@@ -410,6 +466,17 @@ 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 },
|
||||
@@ -636,7 +703,7 @@ private fun EditorSectionLabel(text: String) {
|
||||
* plain writable one (#76), then its own description.
|
||||
*/
|
||||
@Composable
|
||||
private fun calendarRowSummary(calendar: CalendarSource): String? {
|
||||
private fun calendarRowSummary(calendar: CalendarSource, hiddenViews: Set<CalendarView>): String? {
|
||||
val states = calendar.stateLabels().map { label ->
|
||||
stringResource(
|
||||
when (label) {
|
||||
@@ -646,7 +713,8 @@ private fun calendarRowSummary(calendar: CalendarSource): String? {
|
||||
},
|
||||
)
|
||||
}
|
||||
val parts = states + listOfNotNull(calendar.description?.takeIf { it.isNotBlank() })
|
||||
val hidden = if (hiddenViews.isEmpty()) null else hiddenViewsSummary(hiddenViews)
|
||||
val parts = states + listOfNotNull(hidden, calendar.description?.takeIf { it.isNotBlank() })
|
||||
return parts.joinToString(" · ").ifEmpty { null }
|
||||
}
|
||||
|
||||
@@ -662,6 +730,7 @@ private fun calendarRowSummary(calendar: CalendarSource): String? {
|
||||
@Composable
|
||||
private fun CalendarManagerRow(
|
||||
calendar: CalendarSource,
|
||||
hiddenViews: Set<CalendarView>,
|
||||
last: Boolean,
|
||||
onSetVisible: (Long, Boolean) -> Unit,
|
||||
onClick: (() -> Unit)? = null,
|
||||
@@ -669,7 +738,7 @@ private fun CalendarManagerRow(
|
||||
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = calendarRowSummary(calendar),
|
||||
summary = calendarRowSummary(calendar, hiddenViews),
|
||||
position = if (last) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
@@ -689,6 +758,51 @@ 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,6 +3,7 @@ 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
|
||||
@@ -15,6 +16,7 @@ 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.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -104,6 +106,19 @@ class CalendarsViewModel @Inject constructor(
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
/** The views each calendar is hidden in (#270). */
|
||||
val hiddenViews: StateFlow<Map<Long, Set<CalendarView>>> =
|
||||
settingsPrefs.perCalendarHiddenViews
|
||||
.catch { emit(emptyMap()) }
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
initialValue = emptyMap(),
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
@@ -139,16 +154,43 @@ class CalendarsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun createCalendar(displayName: String, color: Int, description: String?) = write {
|
||||
repository.createLocalCalendar(displayName, color, description)
|
||||
fun createCalendar(
|
||||
displayName: String,
|
||||
color: Int,
|
||||
description: String?,
|
||||
hiddenChanges: Map<CalendarView, Boolean>,
|
||||
) = write {
|
||||
val id = repository.createLocalCalendar(displayName, color, description)
|
||||
// The calendar exists now; a failed pref write must not read as a failed create.
|
||||
bestEffort { settingsPrefs.applyCalendarHiddenChanges(id, hiddenChanges) }
|
||||
}
|
||||
|
||||
fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) = write {
|
||||
fun updateCalendar(
|
||||
id: Long,
|
||||
displayName: String,
|
||||
color: Int,
|
||||
description: String?,
|
||||
hiddenChanges: Map<CalendarView, Boolean>,
|
||||
) = write {
|
||||
// First: it doesn't depend on the provider, so a failed provider write shouldn't lose it.
|
||||
settingsPrefs.applyCalendarHiddenChanges(id, hiddenChanges)
|
||||
repository.updateCalendar(id, displayName, color, description)
|
||||
}
|
||||
|
||||
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.setCalendarHiddenViews(id, emptySet()) }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -112,7 +112,7 @@ fun CalendarDrawer(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
DrawerSectionHeader(stringResource(R.string.filter_title))
|
||||
CalendarFilterList()
|
||||
CalendarFilterList(currentView = currentView)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
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>> =
|
||||
catch { emit(emptyMap()) }
|
||||
.map { it.calendarsHiddenIn(view) }
|
||||
.distinctUntilChanged()
|
||||
.shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1)
|
||||
|
||||
/**
|
||||
* [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) }
|
||||
@@ -22,6 +22,8 @@ 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.calendarsHiddenIn
|
||||
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
@@ -32,10 +34,12 @@ 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).
|
||||
* re-filters live. Three states (Loading / Failure / Success). A calendar hidden
|
||||
* in [currentView] through its per-view setting (#270) says so on its row.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarFilterList(
|
||||
currentView: CalendarView,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: FilterViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -46,6 +50,7 @@ fun CalendarFilterList(
|
||||
is FilterUiState.Success -> FilterList(
|
||||
groups = s.groups,
|
||||
hidden = s.hidden,
|
||||
hiddenInView = s.hiddenViews.calendarsHiddenIn(currentView),
|
||||
onSetVisible = viewModel::setVisible,
|
||||
modifier = modifier,
|
||||
)
|
||||
@@ -61,6 +66,7 @@ fun CalendarFilterList(
|
||||
private fun FilterList(
|
||||
groups: List<CalendarAccountGroup>,
|
||||
hidden: Set<Long>,
|
||||
hiddenInView: Set<Long>,
|
||||
onSetVisible: (Long, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -81,6 +87,7 @@ 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) },
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
|
||||
/**
|
||||
* State for the calendar-filter sheet (M3). The user toggles per-calendar
|
||||
@@ -11,9 +12,13 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarAccountGroup
|
||||
sealed interface FilterUiState {
|
||||
data object Loading : FilterUiState
|
||||
data class Failure(val reason: FailureReason) : FilterUiState
|
||||
/** A calendar shows when its id is *not* in [hidden]. */
|
||||
/**
|
||||
* A calendar shows when its id is *not* in [hidden]; [hiddenViews] are the
|
||||
* views each calendar is hidden in regardless (#270).
|
||||
*/
|
||||
data class Success(
|
||||
val groups: List<CalendarAccountGroup>,
|
||||
val hidden: Set<Long>,
|
||||
val hiddenViews: Map<Long, Set<CalendarView>> = emptyMap(),
|
||||
) : FilterUiState
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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.groupByAccount
|
||||
@@ -23,6 +24,7 @@ import javax.inject.Inject
|
||||
class FilterViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val prefs: CalendarPrefs,
|
||||
settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -30,7 +32,9 @@ class FilterViewModel @Inject constructor(
|
||||
combine(
|
||||
repository.calendars(),
|
||||
prefs.hiddenCalendarIds,
|
||||
) { calendars, hidden ->
|
||||
// Only feeds a note on the rows; a failed read leaves it off rather than failing the list.
|
||||
settingsPrefs.perCalendarHiddenViews.catch { emit(emptyMap()) },
|
||||
) { calendars, hidden, hiddenViews ->
|
||||
// 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 }
|
||||
@@ -38,7 +42,7 @@ class FilterViewModel @Inject constructor(
|
||||
if (failure != null) {
|
||||
FilterUiState.Failure(failure)
|
||||
} else {
|
||||
FilterUiState.Success(enabled.groupByAccount(), hidden)
|
||||
FilterUiState.Success(enabled.groupByAccount(), hidden, hiddenViews)
|
||||
}
|
||||
}
|
||||
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
|
||||
@@ -19,7 +19,10 @@ 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
|
||||
@@ -68,6 +71,8 @@ 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)
|
||||
|
||||
@@ -152,7 +157,7 @@ class MonthViewModel @Inject constructor(
|
||||
) { (ym, ws) ->
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(monthGridRange(ym, ws, zone)),
|
||||
repository.instancesWithout(monthGridRange(ym, ws, zone), hiddenHere),
|
||||
today,
|
||||
) { calendars, instances, today ->
|
||||
buildState(ym, ws, calendars, instances, today)
|
||||
@@ -224,7 +229,7 @@ class MonthViewModel @Inject constructor(
|
||||
val range = dayRange(first, last, zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
repository.instancesWithout(range, hiddenHere),
|
||||
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,7 +19,10 @@ 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
|
||||
@@ -89,8 +92,11 @@ 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).
|
||||
@@ -167,7 +173,7 @@ abstract class PagedTimelineViewModel(
|
||||
zone.flatMapLatest { zone ->
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(spanRange(span.start, span.count, zone)),
|
||||
repository.instancesWithout(spanRange(span.start, span.count, zone), hiddenHere),
|
||||
today,
|
||||
) { calendars, instances, today ->
|
||||
buildState(span, calendars, instances, today, zone)
|
||||
@@ -240,6 +246,7 @@ class WeekViewModel @Inject constructor(
|
||||
rule
|
||||
},
|
||||
initialRule = { DaysGrid.weeks(DayOfWeek.MONDAY) },
|
||||
view = CalendarView.Week,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -264,6 +271,7 @@ 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. */
|
||||
@@ -282,6 +290,7 @@ class DayViewModel @Inject constructor(
|
||||
io = io,
|
||||
gridRule = flowOf { _: LocalDate -> DaysGrid.days },
|
||||
initialRule = { DaysGrid.days },
|
||||
view = CalendarView.Day,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,6 @@ 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
|
||||
@@ -116,8 +115,5 @@ 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,6 +14,7 @@ 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,6 +12,7 @@ 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
|
||||
@@ -22,7 +23,6 @@ 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
|
||||
|
||||
@@ -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.</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_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,6 +863,11 @@
|
||||
<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="color_name_mauve">Mauve</string>
|
||||
<string name="color_name_red">Red</string>
|
||||
<string name="color_name_orange">Orange</string>
|
||||
|
||||
@@ -616,6 +616,63 @@ 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.setCalendarHiddenViews(7L, setOf(CalendarView.Month, CalendarView.Agenda))
|
||||
prefs.setCalendarHiddenViews(9L, setOf(CalendarView.MultiDay))
|
||||
assertThat(prefs.perCalendarHiddenViews.first()).containsExactly(
|
||||
7L, setOf(CalendarView.Month, CalendarView.Agenda),
|
||||
9L, setOf(CalendarView.MultiDay),
|
||||
)
|
||||
prefs.setCalendarHiddenViews(7L, emptySet())
|
||||
assertThat(prefs.perCalendarHiddenViews.first()).containsExactly(9L, setOf(CalendarView.MultiDay))
|
||||
}
|
||||
|
||||
@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)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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.setCalendarHiddenViews(2L, setOf(CalendarView.Month))
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,7 +389,11 @@ 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.
|
||||
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.
|
||||
|
||||
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 —
|
||||
|
||||
+1
-1
Submodule floret-kit updated: a039ab5bd4...dd77a16b5e.
Reference in new issue
Block a user