RoomListFilters: extract logic of selection/deselection and tweak a bit the animations.

This commit is contained in:
ganfra
2024-03-05 11:29:58 +01:00
parent dc5f93ef2f
commit 2da9bc198a
11 changed files with 211 additions and 96 deletions

View File

@@ -20,12 +20,12 @@ import io.element.android.features.roomlist.impl.R
/**
* Enum class representing the different filters that can be applied to the room list.
* Order is important.
* Order is important, it'll be used as initial order in the UI.
*/
enum class RoomListFilter(val stringResource: Int) {
Rooms(R.string.screen_roomlist_filter_rooms),
People(R.string.screen_roomlist_filter_people),
Unread(R.string.screen_roomlist_filter_unreads),
People(R.string.screen_roomlist_filter_people),
Rooms(R.string.screen_roomlist_filter_rooms),
Favourites(R.string.screen_roomlist_filter_favourites);
val oppositeFilter: RoomListFilter?

View File

@@ -17,6 +17,6 @@
package io.element.android.features.roomlist.impl.filters
sealed interface RoomListFiltersEvents {
data object ClearSelectedFilters : RoomListFiltersEvents
data class ToggleFilter(val filter: RoomListFilter) : RoomListFiltersEvents
data object ClearSelectedFilters : RoomListFiltersEvents
}

View File

@@ -20,9 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionStrategy
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
@@ -34,50 +32,39 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as Matrix
class RoomListFiltersPresenter @Inject constructor(
private val roomListService: RoomListService,
private val featureFlagService: FeatureFlagService,
private val filterSelectionStrategy: FilterSelectionStrategy,
) : Presenter<RoomListFiltersState> {
@Composable
override fun present(): RoomListFiltersState {
val isFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomListFilters).collectAsState(false)
var unselectedFilters: Set<RoomListFilter> by rememberSaveable {
mutableStateOf(RoomListFilter.entries.toSet())
}
var selectedFilters: Set<RoomListFilter> by rememberSaveable {
mutableStateOf(emptySet())
}
fun updateFilters(newSelectedFilters: Set<RoomListFilter>) {
selectedFilters = newSelectedFilters
unselectedFilters = RoomListFilter.entries.toSet() -
selectedFilters -
selectedFilters.mapNotNull { it.oppositeFilter }.toSet()
}
val isFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomListFilters).collectAsState(false)
val filters by filterSelectionStrategy.filterSelectionStates.collectAsState()
fun handleEvents(event: RoomListFiltersEvents) {
when (event) {
is RoomListFiltersEvents.ToggleFilter -> {
val newSelectedFilters = if (selectedFilters.contains(event.filter)) {
selectedFilters - event.filter
} else {
selectedFilters + event.filter
}
updateFilters(newSelectedFilters)
}
RoomListFiltersEvents.ClearSelectedFilters -> {
updateFilters(newSelectedFilters = emptySet())
filterSelectionStrategy.clear()
}
is RoomListFiltersEvents.ToggleFilter -> {
filterSelectionStrategy.toggle(event.filter)
}
}
}
LaunchedEffect(isFeatureEnabled) {
if (!isFeatureEnabled) {
updateFilters(emptySet())
filterSelectionStrategy.clear()
}
}
LaunchedEffect(selectedFilters) {
LaunchedEffect(filters) {
val allRoomsFilter = MatrixRoomListFilter.All(
selectedFilters.map { roomListFilter ->
when (roomListFilter) {
filters
.filter { it.isSelected }
.map { roomListFilter ->
when (roomListFilter.filter) {
RoomListFilter.Rooms -> MatrixRoomListFilter.Category.Group
RoomListFilter.People -> MatrixRoomListFilter.Category.People
RoomListFilter.Unread -> MatrixRoomListFilter.Unread
@@ -88,9 +75,9 @@ class RoomListFiltersPresenter @Inject constructor(
roomListService.allRooms.updateFilter(allRoomsFilter)
}
return RoomListFiltersState(
unselectedFilters = unselectedFilters.toPersistentList(),
selectedFilters = selectedFilters.toPersistentList(),
filterSelectionStates = filters.toPersistentList(),
isFeatureEnabled = isFeatureEnabled,
eventSink = ::handleEvents
)

View File

@@ -16,13 +16,13 @@
package io.element.android.features.roomlist.impl.filters
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
import kotlinx.collections.immutable.ImmutableList
data class RoomListFiltersState(
val unselectedFilters: ImmutableList<RoomListFilter>,
val selectedFilters: ImmutableList<RoomListFilter>,
val filterSelectionStates: ImmutableList<FilterSelectionState>,
val isFeatureEnabled: Boolean,
val eventSink: (RoomListFiltersEvents) -> Unit,
) {
val hasAnyFilterSelected = selectedFilters.isNotEmpty()
val hasAnyFilterSelected = filterSelectionStates.any { it.isSelected }
}

View File

@@ -17,8 +17,7 @@
package io.element.android.features.roomlist.impl.filters
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
import kotlinx.collections.immutable.toImmutableList
class RoomListFiltersStateProvider : PreviewParameterProvider<RoomListFiltersState> {
@@ -26,20 +25,17 @@ class RoomListFiltersStateProvider : PreviewParameterProvider<RoomListFiltersSta
get() = sequenceOf(
aRoomListFiltersState(),
aRoomListFiltersState(
selectedFilters = persistentListOf(RoomListFilter.Rooms, RoomListFilter.Favourites),
unselectedFilters = persistentListOf(RoomListFilter.Unread),
filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) }
),
)
}
fun aRoomListFiltersState(
unselectedFilters: ImmutableList<RoomListFilter> = RoomListFilter.entries.toImmutableList(),
selectedFilters: ImmutableList<RoomListFilter> = persistentListOf(),
filterSelectionStates: List<FilterSelectionState> = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = false) },
isFeatureEnabled: Boolean = true,
eventSink: (RoomListFiltersEvents) -> Unit = {},
) = RoomListFiltersState(
unselectedFilters = unselectedFilters,
selectedFilters = selectedFilters,
filterSelectionStates = filterSelectionStates.toImmutableList(),
isFeatureEnabled = isFeatureEnabled,
eventSink = eventSink,
)

View File

@@ -17,6 +17,7 @@
package io.element.android.features.roomlist.impl.filters
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -25,7 +26,6 @@ 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.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -34,6 +34,7 @@ import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -51,18 +52,19 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RoomListFiltersView(
state: RoomListFiltersState,
modifier: Modifier = Modifier
) {
fun onClearFiltersClicked() {
state.eventSink(RoomListFiltersEvents.ClearSelectedFilters)
}
fun onFilterClicked(filter: RoomListFilter) {
fun onToggleFilter(filter: RoomListFilter) {
state.eventSink(RoomListFiltersEvents.ToggleFilter(filter))
}
@@ -71,12 +73,14 @@ fun RoomListFiltersView(
modifier = modifier.padding(start = startPadding, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedVisibility(visible = state.hasAnyFilterSelected) {
RoomListClearFiltersButton(
modifier = Modifier.testTag(TestTags.homeScreenClearFilters),
onClick = ::onClearFiltersClicked
)
}
val lazyListState = rememberLazyListState()
val fadingEdgesBrush = horizontalFadingEdgesBrush(
showLeft = lazyListState.canScrollBackward,
@@ -89,26 +93,25 @@ fun RoomListFiltersView(
state = lazyListState,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
roomListFilters(state.selectedFilters, selected = true, onClick = ::onFilterClicked)
roomListFilters(state.unselectedFilters, selected = false, onClick = ::onFilterClicked)
items(
items = state.filterSelectionStates,
key = { it.filter.ordinal }
) { filterWithSelection ->
RoomListFilterView(
modifier = Modifier.animateItemPlacement(
animationSpec = tween(200),
),
roomListFilter = filterWithSelection.filter,
selected = filterWithSelection.isSelected,
onClick = ::onToggleFilter,
)
}
}
LaunchedEffect(state.filterSelectionStates) {
if (lazyListState.canScrollBackward) {
lazyListState.animateScrollToItem(0)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
private fun LazyListScope.roomListFilters(
filters: ImmutableList<RoomListFilter>,
selected: Boolean,
onClick: (RoomListFilter) -> Unit,
) {
items(
items = filters,
) { filter ->
RoomListFilterView(
roomListFilter = filter,
selected = selected,
onClick = onClick,
)
}
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.filters.selection
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.roomlist.impl.filters.RoomListFilter
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultFilterSelectionStrategy @Inject constructor() : FilterSelectionStrategy {
private val selectedFilters = LinkedHashSet<RoomListFilter>()
override val filterSelectionStates = MutableStateFlow(buildFilters())
override fun select(filter: RoomListFilter) {
selectedFilters.add(filter)
filterSelectionStates.value = buildFilters()
}
override fun deselect(filter: RoomListFilter) {
selectedFilters.remove(filter)
filterSelectionStates.value = buildFilters()
}
override fun isSelected(filter: RoomListFilter): Boolean {
return selectedFilters.contains(filter)
}
override fun clear() {
selectedFilters.clear()
filterSelectionStates.value = buildFilters()
}
private fun buildFilters(): Set<FilterSelectionState> {
val selectedFilterStates = selectedFilters.map {
FilterSelectionState(
filter = it,
isSelected = true
)
}
val unselectedFilters = RoomListFilter.entries - selectedFilters - selectedFilters.mapNotNull { it.oppositeFilter }.toSet()
val unselectedFilterStates = unselectedFilters.map {
FilterSelectionState(
filter = it,
isSelected = false
)
}
return (selectedFilterStates + unselectedFilterStates).toSet()
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.filters.selection
import io.element.android.features.roomlist.impl.filters.RoomListFilter
data class FilterSelectionState(
val filter: RoomListFilter,
val isSelected: Boolean,
)

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.filters.selection
import io.element.android.features.roomlist.impl.filters.RoomListFilter
import kotlinx.coroutines.flow.StateFlow
interface FilterSelectionStrategy {
val filterSelectionStates: StateFlow<Set<FilterSelectionState>>
fun select(filter: RoomListFilter)
fun deselect(filter: RoomListFilter)
fun isSelected(filter: RoomListFilter): Boolean
fun clear()
fun toggle(filter: RoomListFilter) {
if (isSelected(filter)) {
deselect(filter)
} else {
select(filter)
}
}
}

View File

@@ -20,6 +20,8 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomlist.impl.filters.selection.DefaultFilterSelectionStrategy
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
@@ -37,13 +39,12 @@ class RoomListFiltersPresenterTests {
presenter.present()
}.test {
awaitItem().let { state ->
assertThat(state.selectedFilters).isEmpty()
assertThat(state.hasAnyFilterSelected).isFalse()
assertThat(state.unselectedFilters).containsExactly(
RoomListFilter.Rooms,
RoomListFilter.People,
RoomListFilter.Unread,
RoomListFilter.Favourites,
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.People, false),
filterSelectionState(RoomListFilter.Rooms, false),
filterSelectionState(RoomListFilter.Favourites, false),
)
}
cancelAndIgnoreRemainingEvents()
@@ -58,32 +59,29 @@ class RoomListFiltersPresenterTests {
presenter.present()
}.test {
awaitItem().eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters).containsExactly(RoomListFilter.Rooms)
assertThat(state.hasAnyFilterSelected).isTrue()
assertThat(state.unselectedFilters).containsExactly(
RoomListFilter.Unread,
RoomListFilter.Favourites,
)
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Rooms, true),
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.Favourites, false),
).inOrder()
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
assertThat(roomListCurrentFilter.filters).containsExactly(
MatrixRoomListFilter.Category.Group,
)
state.eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
}
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters).isEmpty()
assertThat(state.hasAnyFilterSelected).isFalse()
assertThat(state.unselectedFilters).containsExactly(
RoomListFilter.Rooms,
RoomListFilter.People,
RoomListFilter.Unread,
RoomListFilter.Favourites,
)
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.People, false),
filterSelectionState(RoomListFilter.Rooms, false),
filterSelectionState(RoomListFilter.Favourites, false),
).inOrder()
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
assertThat(roomListCurrentFilter.filters).isEmpty()
}
@@ -99,24 +97,28 @@ class RoomListFiltersPresenterTests {
}.test {
awaitItem().eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters).isNotEmpty()
assertThat(state.hasAnyFilterSelected).isTrue()
state.eventSink.invoke(RoomListFiltersEvents.ClearSelectedFilters)
}
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters).isEmpty()
assertThat(state.hasAnyFilterSelected).isFalse()
}
}
}
}
fun createRoomListFiltersPresenter(
private fun filterSelectionState(filter: RoomListFilter, selected: Boolean) = FilterSelectionState(
filter = filter,
isSelected = selected,
)
private fun createRoomListFiltersPresenter(
roomListService: RoomListService = FakeRoomListService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): RoomListFiltersPresenter {
return RoomListFiltersPresenter(
roomListService = roomListService,
featureFlagService = featureFlagService,
filterSelectionStrategy = DefaultFilterSelectionStrategy(),
)
}

View File

@@ -20,12 +20,11 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomlist.impl.R
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
import io.element.android.libraries.testtags.TestTags
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressTag
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -56,8 +55,7 @@ class RoomListFiltersViewTests {
rule.setContent {
RoomListFiltersView(
state = aRoomListFiltersState(
unselectedFilters = persistentListOf(),
selectedFilters = RoomListFilter.entries.toImmutableList(),
filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) },
eventSink = eventsRecorder
),
)