Compare commits

...
7 changed files with 157 additions and 20 deletions

No files matched your search

@@ -32,6 +32,11 @@ class CalendarPrefs @Inject constructor(
suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(HIDDEN_IDS, ids)
/** Rewrite the drawer's hidden set atomically from its current value. */
suspend fun updateHiddenCalendarIds(transform: (Set<Long>) -> Set<Long>) {
prefs.update(HIDDEN_IDS, transform)
}
/**
* Switch-offs the provider does not know about yet, because writing
* `Calendars.VISIBLE` needs `WRITE_CALENDAR` (#75). Also inherits the
@@ -1,19 +1,29 @@
package de.jeanlucmakiola.calendula.ui.filter
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TriStateCheckbox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.toggleableState
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -46,7 +56,8 @@ fun CalendarFilterList(
is FilterUiState.Success -> FilterList(
groups = s.groups,
hidden = s.hidden,
onSetVisible = viewModel::setVisible,
onToggleCalendar = viewModel::toggleCalendar,
onToggleGroup = viewModel::toggleGroup,
modifier = modifier,
)
}
@@ -61,21 +72,17 @@ fun CalendarFilterList(
private fun FilterList(
groups: List<CalendarAccountGroup>,
hidden: Set<Long>,
onSetVisible: (Long, Boolean) -> Unit,
onToggleCalendar: (Long) -> Unit,
onToggleGroup: (List<Long>) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth()) {
groups.forEach { group ->
Text(
text = accountGroupTitle(group),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 12.dp,
bottom = 4.dp,
),
val ids = group.calendars.map { it.id }
GroupHeader(
title = accountGroupTitle(group),
state = groupVisibility(ids, hidden),
onToggle = { onToggleGroup(ids) },
)
group.calendars.forEachIndexed { index, cal ->
val visible = cal.id !in hidden
@@ -87,16 +94,54 @@ private fun FilterList(
trailing = {
Checkbox(
checked = visible,
onCheckedChange = { onSetVisible(cal.id, it) },
onCheckedChange = { onToggleCalendar(cal.id) },
)
},
onClick = { onSetVisible(cal.id, !visible) },
onClick = { onToggleCalendar(cal.id) },
)
}
}
}
}
/**
* An account's header doubling as the group's parent checkbox (#143): checked
* when every calendar shows, indeterminate when some do. Tapping it shows all,
* or hides all when the group is fully shown.
*/
@Composable
private fun GroupHeader(
title: String,
state: ToggleableState,
onToggle: () -> Unit,
) {
val label = stringResource(
if (state.nextGroupVisible()) R.string.filter_group_show_all else R.string.filter_group_hide_all,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = GroupedListInset / 2)
.clip(MaterialTheme.shapes.large)
.clickable(onClickLabel = label, role = Role.Checkbox) { onToggle() }
.semantics { toggleableState = state }
.padding(start = GroupedListInset / 2, end = GroupedListInset / 2 + 16.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
)
Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
TriStateCheckbox(state = state, onClick = null)
}
}
}
@Composable
private fun FilterLoading(modifier: Modifier = Modifier) {
Column(
@@ -14,7 +14,6 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -48,13 +47,19 @@ class FilterViewModel @Inject constructor(
initialValue = FilterUiState.Loading,
)
/** Show or hide a single calendar; persists the new hidden set. */
fun setVisible(calendarId: Long, visible: Boolean) {
/**
* Flip one calendar, or a whole group from its header (#143). Both decide
* against the stored set rather than the last drawn state, so quick repeated
* taps still alternate.
*/
fun toggleCalendar(calendarId: Long) {
viewModelScope.launch {
val current = prefs.hiddenCalendarIds.first()
val next = if (visible) current - calendarId else current + calendarId
if (next != current) prefs.setHiddenCalendarIds(next)
prefs.updateHiddenCalendarIds { if (calendarId in it) it - calendarId else it + calendarId }
}
}
fun toggleGroup(calendarIds: List<Long>) {
viewModelScope.launch { prefs.updateHiddenCalendarIds { toggledGroup(it, calendarIds) } }
}
}
@@ -0,0 +1,20 @@
package de.jeanlucmakiola.calendula.ui.filter
import androidx.compose.ui.state.ToggleableState
/** The drawer group header's checkbox state: on, off, or some of [calendarIds] shown. */
internal fun groupVisibility(calendarIds: Collection<Long>, hidden: Set<Long>): ToggleableState {
val shown = calendarIds.count { it !in hidden }
return when (shown) {
0 -> ToggleableState.Off
calendarIds.size -> ToggleableState.On
else -> ToggleableState.Indeterminate
}
}
/** What tapping the header does: a fully shown group hides, anything else shows all. */
internal fun ToggleableState.nextGroupVisible(): Boolean = this != ToggleableState.On
/** [hidden] after tapping the header of the group holding [calendarIds]. */
internal fun toggledGroup(hidden: Set<Long>, calendarIds: Collection<Long>): Set<Long> =
if (groupVisibility(calendarIds, hidden).nextGroupVisible()) hidden - calendarIds.toSet() else hidden + calendarIds
+3
View File
@@ -550,6 +550,9 @@
<!-- Calendar filter (M3) -->
<string name="filter_title">Calendars</string>
<!-- TalkBack action on a drawer account header that toggles all its calendars -->
<string name="filter_group_show_all">Show all calendars in this group</string>
<string name="filter_group_hide_all">Hide all calendars in this group</string>
<!-- Settings (M4) -->
<string name="settings_title">Settings</string>
@@ -34,6 +34,16 @@ class CalendarPrefsTest {
assertThat(prefs.hiddenCalendarIds.first()).isEmpty()
}
@Test
fun `updateHiddenCalendarIds transforms the stored set`(@TempDir tempDir: Path) = runTest {
val prefs = testPrefs(tempDir)
prefs.setHiddenCalendarIds(setOf(9L))
prefs.updateHiddenCalendarIds { it + listOf(1L, 2L) }
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 2L, 9L))
prefs.updateHiddenCalendarIds { emptySet() }
assertThat(prefs.hiddenCalendarIds.first()).isEmpty()
}
@Test
fun `garbage stored string is parsed defensively`(@TempDir tempDir: Path) = runTest {
val store = testDataStore(tempDir, "test_prefs")
@@ -0,0 +1,49 @@
package de.jeanlucmakiola.calendula.ui.filter
import androidx.compose.ui.state.ToggleableState
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class GroupVisibilityTest {
private val group = listOf(1L, 2L, 3L)
@Test
fun `all shown is on and hides on tap`() {
val state = groupVisibility(group, hidden = setOf(9L))
assertThat(state).isEqualTo(ToggleableState.On)
assertThat(state.nextGroupVisible()).isFalse()
}
@Test
fun `none shown is off and shows on tap`() {
val state = groupVisibility(group, hidden = setOf(1L, 2L, 3L))
assertThat(state).isEqualTo(ToggleableState.Off)
assertThat(state.nextGroupVisible()).isTrue()
}
@Test
fun `some shown is indeterminate and shows all on tap`() {
val state = groupVisibility(group, hidden = setOf(2L))
assertThat(state).isEqualTo(ToggleableState.Indeterminate)
assertThat(state.nextGroupVisible()).isTrue()
}
@Test
fun `empty group is off`() {
assertThat(groupVisibility(emptyList(), hidden = emptySet())).isEqualTo(ToggleableState.Off)
}
@Test
fun `toggling twice from the stored set alternates`() {
val hidden = setOf(9L)
val once = toggledGroup(hidden, group)
assertThat(once).isEqualTo(setOf(1L, 2L, 3L, 9L))
assertThat(toggledGroup(once, group)).isEqualTo(setOf(9L))
}
@Test
fun `toggling a partly shown group shows all`() {
assertThat(toggledGroup(setOf(2L, 9L), group)).isEqualTo(setOf(9L))
}
}