Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
754745586d | ||
|
|
1a1df07dc3 | ||
|
|
75315d92a7 | ||
|
|
95ad3a30cf | ||
|
|
8ce4831efa |
No files matched your search
@@ -318,13 +318,16 @@ jobs:
|
|||||||
# not provide node inside custom job containers, so JavaScript actions
|
# not provide node inside custom job containers, so JavaScript actions
|
||||||
# (checkout, download-artifact) can't run there. The Renovate job gets away
|
# (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
|
# 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.
|
# 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.
|
||||||
play:
|
play:
|
||||||
needs: [detect, release]
|
needs: [detect, release]
|
||||||
# workflow_dispatch is the F-Droid re-sign recovery path — it must never
|
# workflow_dispatch is the F-Droid re-sign recovery path — it must never
|
||||||
# touch Play, so gate on a real release only.
|
# touch Play, so gate on a real release only.
|
||||||
if: needs.detect.outputs.is_release == 'true'
|
if: needs.detect.outputs.is_release == 'true'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: node:22-trixie
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.detect.outputs.version }}
|
VERSION: ${{ needs.detect.outputs.version }}
|
||||||
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
|
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
|
||||||
|
|||||||
@@ -32,11 +32,6 @@ class CalendarPrefs @Inject constructor(
|
|||||||
|
|
||||||
suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(HIDDEN_IDS, ids)
|
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
|
* Switch-offs the provider does not know about yet, because writing
|
||||||
* `Calendars.VISIBLE` needs `WRITE_CALENDAR` (#75). Also inherits the
|
* `Calendars.VISIBLE` needs `WRITE_CALENDAR` (#75). Also inherits the
|
||||||
|
|||||||
@@ -1,29 +1,19 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.filter
|
package de.jeanlucmakiola.calendula.ui.filter
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TriStateCheckbox
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.res.stringResource
|
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.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@@ -56,8 +46,7 @@ fun CalendarFilterList(
|
|||||||
is FilterUiState.Success -> FilterList(
|
is FilterUiState.Success -> FilterList(
|
||||||
groups = s.groups,
|
groups = s.groups,
|
||||||
hidden = s.hidden,
|
hidden = s.hidden,
|
||||||
onToggleCalendar = viewModel::toggleCalendar,
|
onSetVisible = viewModel::setVisible,
|
||||||
onToggleGroup = viewModel::toggleGroup,
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -72,17 +61,21 @@ fun CalendarFilterList(
|
|||||||
private fun FilterList(
|
private fun FilterList(
|
||||||
groups: List<CalendarAccountGroup>,
|
groups: List<CalendarAccountGroup>,
|
||||||
hidden: Set<Long>,
|
hidden: Set<Long>,
|
||||||
onToggleCalendar: (Long) -> Unit,
|
onSetVisible: (Long, Boolean) -> Unit,
|
||||||
onToggleGroup: (List<Long>) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
groups.forEach { group ->
|
groups.forEach { group ->
|
||||||
val ids = group.calendars.map { it.id }
|
Text(
|
||||||
GroupHeader(
|
text = accountGroupTitle(group),
|
||||||
title = accountGroupTitle(group),
|
style = MaterialTheme.typography.labelMedium,
|
||||||
state = groupVisibility(ids, hidden),
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
onToggle = { onToggleGroup(ids) },
|
modifier = Modifier.padding(
|
||||||
|
start = GroupedListInset,
|
||||||
|
end = GroupedListInset,
|
||||||
|
top = 12.dp,
|
||||||
|
bottom = 4.dp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
group.calendars.forEachIndexed { index, cal ->
|
group.calendars.forEachIndexed { index, cal ->
|
||||||
val visible = cal.id !in hidden
|
val visible = cal.id !in hidden
|
||||||
@@ -94,54 +87,16 @@ private fun FilterList(
|
|||||||
trailing = {
|
trailing = {
|
||||||
Checkbox(
|
Checkbox(
|
||||||
checked = visible,
|
checked = visible,
|
||||||
onCheckedChange = { onToggleCalendar(cal.id) },
|
onCheckedChange = { onSetVisible(cal.id, it) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { onToggleCalendar(cal.id) },
|
onClick = { onSetVisible(cal.id, !visible) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
@Composable
|
||||||
private fun FilterLoading(modifier: Modifier = Modifier) {
|
private fun FilterLoading(modifier: Modifier = Modifier) {
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -47,19 +48,13 @@ class FilterViewModel @Inject constructor(
|
|||||||
initialValue = FilterUiState.Loading,
|
initialValue = FilterUiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/** Show or hide a single calendar; persists the new hidden set. */
|
||||||
* Flip one calendar, or a whole group from its header (#143). Both decide
|
fun setVisible(calendarId: Long, visible: Boolean) {
|
||||||
* against the stored set rather than the last drawn state, so quick repeated
|
|
||||||
* taps still alternate.
|
|
||||||
*/
|
|
||||||
fun toggleCalendar(calendarId: Long) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.updateHiddenCalendarIds { if (calendarId in it) it - calendarId else it + calendarId }
|
val current = prefs.hiddenCalendarIds.first()
|
||||||
|
val next = if (visible) current - calendarId else current + calendarId
|
||||||
|
if (next != current) prefs.setHiddenCalendarIds(next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleGroup(calendarIds: List<Long>) {
|
|
||||||
viewModelScope.launch { prefs.updateHiddenCalendarIds { toggledGroup(it, calendarIds) } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -550,9 +550,6 @@
|
|||||||
|
|
||||||
<!-- Calendar filter (M3) -->
|
<!-- Calendar filter (M3) -->
|
||||||
<string name="filter_title">Calendars</string>
|
<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) -->
|
<!-- Settings (M4) -->
|
||||||
<string name="settings_title">Settings</string>
|
<string name="settings_title">Settings</string>
|
||||||
|
|||||||
@@ -34,16 +34,6 @@ class CalendarPrefsTest {
|
|||||||
assertThat(prefs.hiddenCalendarIds.first()).isEmpty()
|
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
|
@Test
|
||||||
fun `garbage stored string is parsed defensively`(@TempDir tempDir: Path) = runTest {
|
fun `garbage stored string is parsed defensively`(@TempDir tempDir: Path) = runTest {
|
||||||
val store = testDataStore(tempDir, "test_prefs")
|
val store = testDataStore(tempDir, "test_prefs")
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user