Merge pull request #2422 from element-hq/feature/fga/room_list_filters
[Feature] Room list filters
This commit is contained in:
@@ -37,6 +37,7 @@ import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.features.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource
|
||||
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
|
||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchState
|
||||
@@ -82,6 +83,7 @@ class RoomListPresenter @Inject constructor(
|
||||
private val roomListDataSource: RoomListDataSource,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val indicatorService: IndicatorService,
|
||||
private val filtersPresenter: Presenter<RoomListFiltersState>,
|
||||
private val searchPresenter: Presenter<RoomListSearchState>,
|
||||
private val migrationScreenPresenter: MigrationScreenPresenter,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
@@ -102,6 +104,8 @@ class RoomListPresenter @Inject constructor(
|
||||
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
|
||||
}
|
||||
val networkConnectionStatus by networkMonitor.connectivity.collectAsState()
|
||||
|
||||
val filtersState = filtersPresenter.present()
|
||||
val searchState = searchPresenter.present()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -168,9 +172,10 @@ class RoomListPresenter @Inject constructor(
|
||||
invitesState = inviteStateDataSource.inviteState(),
|
||||
contextMenu = contextMenu.value,
|
||||
leaveRoomState = leaveRoomState,
|
||||
filtersState = filtersState,
|
||||
searchState = searchState,
|
||||
displayMigrationStatus = isMigrating,
|
||||
eventSink = ::handleEvents
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ package io.element.android.features.roomlist.impl
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
@@ -37,10 +38,12 @@ data class RoomListState(
|
||||
val invitesState: InvitesState,
|
||||
val contextMenu: ContextMenu,
|
||||
val leaveRoomState: LeaveRoomState,
|
||||
val filtersState: RoomListFiltersState,
|
||||
val searchState: RoomListSearchState,
|
||||
val displayMigrationStatus: Boolean,
|
||||
val eventSink: (RoomListEvents) -> Unit,
|
||||
) {
|
||||
val displayFilters = filtersState.isFeatureEnabled && !displayMigrationStatus
|
||||
val displayEmptyState = roomList is AsyncData.Success && roomList.data.isEmpty()
|
||||
|
||||
sealed interface ContextMenu {
|
||||
|
||||
@@ -20,6 +20,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.leaveroom.api.aLeaveRoomState
|
||||
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
|
||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchState
|
||||
@@ -39,18 +41,19 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
|
||||
override val values: Sequence<RoomListState>
|
||||
get() = sequenceOf(
|
||||
aRoomListState(),
|
||||
aRoomListState(securityBannerState = SecurityBannerState.SessionVerification),
|
||||
aRoomListState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)),
|
||||
aRoomListState(hasNetworkConnection = false),
|
||||
aRoomListState(invitesState = InvitesState.SeenInvites),
|
||||
aRoomListState(invitesState = InvitesState.NewInvites),
|
||||
aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")),
|
||||
aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)),
|
||||
aRoomListState(securityBannerState = SecurityBannerState.SessionVerification),
|
||||
aRoomListState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
|
||||
aRoomListState(roomList = AsyncData.Success(persistentListOf())),
|
||||
aRoomListState(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())),
|
||||
aRoomListState(matrixUser = null, displayMigrationStatus = true),
|
||||
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
|
||||
aRoomListState(filtersState = aRoomListFiltersState(isFeatureEnabled = true)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,6 +68,7 @@ internal fun aRoomListState(
|
||||
contextMenu: RoomListState.ContextMenu = RoomListState.ContextMenu.Hidden,
|
||||
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
|
||||
searchState: RoomListSearchState = aRoomListSearchState(),
|
||||
filtersState: RoomListFiltersState = aRoomListFiltersState(isFeatureEnabled = false),
|
||||
displayMigrationStatus: Boolean = false,
|
||||
eventSink: (RoomListEvents) -> Unit = {}
|
||||
) = RoomListState(
|
||||
@@ -78,6 +82,7 @@ internal fun aRoomListState(
|
||||
contextMenu = contextMenu,
|
||||
leaveRoomState = leaveRoomState,
|
||||
searchState = searchState,
|
||||
filtersState = filtersState,
|
||||
displayMigrationStatus = displayMigrationStatus,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
@@ -55,6 +55,7 @@ import io.element.android.features.roomlist.impl.components.RequestVerificationH
|
||||
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
|
||||
import io.element.android.features.roomlist.impl.components.RoomListTopBar
|
||||
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
|
||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersView
|
||||
import io.element.android.features.roomlist.impl.migration.MigrationScreenView
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchView
|
||||
@@ -207,16 +208,21 @@ private fun RoomListContent(
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
RoomListTopBar(
|
||||
matrixUser = state.matrixUser,
|
||||
showAvatarIndicator = state.showAvatarIndicator,
|
||||
areSearchResultsDisplayed = state.searchState.isSearchActive,
|
||||
onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) },
|
||||
onMenuActionClicked = onMenuActionClicked,
|
||||
onOpenSettings = onOpenSettings,
|
||||
scrollBehavior = scrollBehavior,
|
||||
displayMenuItems = !state.displayMigrationStatus,
|
||||
)
|
||||
Column {
|
||||
RoomListTopBar(
|
||||
matrixUser = state.matrixUser,
|
||||
showAvatarIndicator = state.showAvatarIndicator,
|
||||
areSearchResultsDisplayed = state.searchState.isSearchActive,
|
||||
onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) },
|
||||
onMenuActionClicked = onMenuActionClicked,
|
||||
onOpenSettings = onOpenSettings,
|
||||
scrollBehavior = scrollBehavior,
|
||||
displayMenuItems = !state.displayMigrationStatus,
|
||||
)
|
||||
if (state.displayFilters) {
|
||||
RoomListFiltersView(state = state.filtersState)
|
||||
}
|
||||
}
|
||||
},
|
||||
content = { padding ->
|
||||
LazyColumn(
|
||||
@@ -272,7 +278,11 @@ private fun RoomListContent(
|
||||
}
|
||||
}
|
||||
if (state.displayEmptyState) {
|
||||
EmptyRoomListView(onCreateRoomClicked)
|
||||
if (state.filtersState.hasAnyFilterSelected) {
|
||||
// TODO add empty state for filtered rooms
|
||||
} else {
|
||||
EmptyRoomListView(onCreateRoomClicked)
|
||||
}
|
||||
}
|
||||
MigrationScreenView(isMigrating = state.displayMigrationStatus)
|
||||
},
|
||||
|
||||
@@ -19,6 +19,8 @@ package io.element.android.features.roomlist.impl.di
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersPresenter
|
||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchPresenter
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
@@ -29,4 +31,7 @@ import io.element.android.libraries.di.SessionScope
|
||||
interface RoomListModule {
|
||||
@Binds
|
||||
fun bindSearchPresenter(presenter: RoomListSearchPresenter): Presenter<RoomListSearchState>
|
||||
|
||||
@Binds
|
||||
fun bindFiltersPresenter(presenter: RoomListFiltersPresenter): Presenter<RoomListFiltersState>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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.
|
||||
*/
|
||||
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),
|
||||
Favourites(R.string.screen_roomlist_filter_favourites);
|
||||
|
||||
val oppositeFilter: RoomListFilter?
|
||||
get() = when (this) {
|
||||
Rooms -> People
|
||||
People -> Rooms
|
||||
Unread -> null
|
||||
Favourites -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
sealed interface RoomListFiltersEvents {
|
||||
data object ClearSelectedFilters : RoomListFiltersEvents
|
||||
data class ToggleFilter(val filter: RoomListFilter) : RoomListFiltersEvents
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
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.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import javax.inject.Inject
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
|
||||
|
||||
class RoomListFiltersPresenter @Inject constructor(
|
||||
private val roomListService: RoomListService,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : 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()
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isFeatureEnabled) {
|
||||
if (!isFeatureEnabled) {
|
||||
updateFilters(emptySet())
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedFilters) {
|
||||
val allRoomsFilter = MatrixRoomListFilter.All(
|
||||
selectedFilters.map { roomListFilter ->
|
||||
when (roomListFilter) {
|
||||
RoomListFilter.Rooms -> MatrixRoomListFilter.Category.Group
|
||||
RoomListFilter.People -> MatrixRoomListFilter.Category.People
|
||||
RoomListFilter.Unread -> MatrixRoomListFilter.Unread
|
||||
RoomListFilter.Favourites -> MatrixRoomListFilter.Favorite
|
||||
}
|
||||
}.plus(MatrixRoomListFilter.NonLeft)
|
||||
)
|
||||
roomListService.allRooms.updateFilter(allRoomsFilter)
|
||||
}
|
||||
|
||||
return RoomListFiltersState(
|
||||
unselectedFilters = unselectedFilters.toPersistentList(),
|
||||
selectedFilters = selectedFilters.toPersistentList(),
|
||||
isFeatureEnabled = isFeatureEnabled,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class RoomListFiltersState(
|
||||
val unselectedFilters: ImmutableList<RoomListFilter>,
|
||||
val selectedFilters: ImmutableList<RoomListFilter>,
|
||||
val isFeatureEnabled: Boolean,
|
||||
val eventSink: (RoomListFiltersEvents) -> Unit,
|
||||
) {
|
||||
val hasAnyFilterSelected = selectedFilters.isNotEmpty()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
class RoomListFiltersStateProvider : PreviewParameterProvider<RoomListFiltersState> {
|
||||
override val values: Sequence<RoomListFiltersState>
|
||||
get() = sequenceOf(
|
||||
aRoomListFiltersState(),
|
||||
aRoomListFiltersState(
|
||||
selectedFilters = persistentListOf(RoomListFilter.Rooms, RoomListFilter.Favourites),
|
||||
unselectedFilters = persistentListOf(RoomListFilter.Unread),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aRoomListFiltersState(
|
||||
unselectedFilters: ImmutableList<RoomListFilter> = RoomListFilter.entries.toImmutableList(),
|
||||
selectedFilters: ImmutableList<RoomListFilter> = persistentListOf(),
|
||||
isFeatureEnabled: Boolean = true,
|
||||
eventSink: (RoomListFiltersEvents) -> Unit = {},
|
||||
) = RoomListFiltersState(
|
||||
unselectedFilters = unselectedFilters,
|
||||
selectedFilters = selectedFilters,
|
||||
isFeatureEnabled = isFeatureEnabled,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
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.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.modifiers.fadingEdge
|
||||
import io.element.android.libraries.designsystem.modifiers.horizontalFadingEdgesBrush
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun RoomListFiltersView(
|
||||
state: RoomListFiltersState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
fun onClearFiltersClicked() {
|
||||
state.eventSink(RoomListFiltersEvents.ClearSelectedFilters)
|
||||
}
|
||||
|
||||
fun onFilterClicked(filter: RoomListFilter) {
|
||||
state.eventSink(RoomListFiltersEvents.ToggleFilter(filter))
|
||||
}
|
||||
|
||||
val startPadding = if (state.hasAnyFilterSelected) 4.dp else 16.dp
|
||||
Row(
|
||||
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,
|
||||
showRight = lazyListState.canScrollForward
|
||||
)
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fadingEdge(fadingEdgesBrush),
|
||||
state = lazyListState,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
roomListFilters(state.selectedFilters, selected = true, onClick = ::onFilterClicked)
|
||||
roomListFilters(state.unselectedFilters, selected = false, onClick = ::onFilterClicked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun LazyListScope.roomListFilters(
|
||||
filters: ImmutableList<RoomListFilter>,
|
||||
selected: Boolean,
|
||||
onClick: (RoomListFilter) -> Unit,
|
||||
) {
|
||||
items(
|
||||
items = filters,
|
||||
key = { it.ordinal },
|
||||
) { filter ->
|
||||
RoomListFilterView(
|
||||
modifier = Modifier.animateItemPlacement(),
|
||||
roomListFilter = filter,
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomListClearFiltersButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
IconButton(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(ElementTheme.colors.bgActionPrimaryRest)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
imageVector = CompoundIcons.Close(),
|
||||
tint = ElementTheme.colors.iconOnSolidPrimary,
|
||||
contentDescription = stringResource(id = io.element.android.libraries.ui.strings.R.string.action_clear),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomListFilterView(
|
||||
roomListFilter: RoomListFilter,
|
||||
selected: Boolean,
|
||||
onClick: (RoomListFilter) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { onClick(roomListFilter) },
|
||||
modifier = modifier
|
||||
.minimumInteractiveComponentSize()
|
||||
.height(36.dp),
|
||||
shape = CircleShape,
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
containerColor = ElementTheme.colors.bgCanvasDefault,
|
||||
selectedContainerColor = ElementTheme.colors.bgActionPrimaryRest,
|
||||
labelColor = ElementTheme.colors.textPrimary,
|
||||
selectedLabelColor = ElementTheme.colors.textOnSolidPrimary,
|
||||
),
|
||||
label = {
|
||||
Text(text = stringResource(id = roomListFilter.stringResource))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RoomListFiltersViewPreview(@PreviewParameter(RoomListFiltersStateProvider::class) state: RoomListFiltersState) = ElementPreview {
|
||||
RoomListFiltersView(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import io.element.android.features.roomlist.impl.datasource.FakeInviteDataSource
|
||||
import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource
|
||||
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
|
||||
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
|
||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.migration.InMemoryMigrationScreenStore
|
||||
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
|
||||
import io.element.android.features.roomlist.impl.model.createRoomListRoomSummary
|
||||
@@ -612,6 +614,7 @@ class RoomListPresenterTests {
|
||||
migrationScreenStore = InMemoryMigrationScreenStore(),
|
||||
),
|
||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||
filtersPresenter: Presenter<RoomListFiltersState> = Presenter { aRoomListFiltersState() },
|
||||
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },
|
||||
) = RoomListPresenter(
|
||||
client = client,
|
||||
@@ -637,6 +640,7 @@ class RoomListPresenterTests {
|
||||
migrationScreenPresenter = migrationScreenPresenter,
|
||||
searchPresenter = searchPresenter,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
filtersPresenter = filtersPresenter,
|
||||
analyticsService = analyticsService,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
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.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
|
||||
|
||||
class RoomListFiltersPresenterTests {
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createRoomListFiltersPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - toggle rooms filter`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
val presenter = createRoomListFiltersPresenter(roomListService)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
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,
|
||||
)
|
||||
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
|
||||
assertThat(roomListCurrentFilter.filters).containsExactly(
|
||||
MatrixRoomListFilter.NonLeft,
|
||||
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,
|
||||
)
|
||||
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
|
||||
assertThat(roomListCurrentFilter.filters).containsExactly(
|
||||
MatrixRoomListFilter.NonLeft,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - clear filters event`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
val presenter = createRoomListFiltersPresenter(roomListService)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.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(
|
||||
roomListService: RoomListService = FakeRoomListService(),
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||
): RoomListFiltersPresenter {
|
||||
return RoomListFiltersPresenter(
|
||||
roomListService = roomListService,
|
||||
featureFlagService = featureFlagService,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
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.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
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RoomListFiltersViewTests {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on filters generates expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomListFiltersEvents>()
|
||||
rule.setContent {
|
||||
RoomListFiltersView(
|
||||
state = aRoomListFiltersState(eventSink = eventsRecorder),
|
||||
)
|
||||
}
|
||||
rule.clickOn(R.string.screen_roomlist_filter_rooms)
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on clear filters generates expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomListFiltersEvents>()
|
||||
rule.setContent {
|
||||
RoomListFiltersView(
|
||||
state = aRoomListFiltersState(
|
||||
unselectedFilters = persistentListOf(),
|
||||
selectedFilters = RoomListFilter.entries.toImmutableList(),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
}
|
||||
rule.pressTag(TestTags.homeScreenClearFilters.value)
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
RoomListFiltersEvents.ClearSelectedFilters,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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.libraries.designsystem.modifiers
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
|
||||
@Composable
|
||||
fun horizontalFadingEdgesBrush(
|
||||
showLeft: Boolean,
|
||||
showRight: Boolean,
|
||||
percent: Float = 0.1f,
|
||||
): Brush {
|
||||
val leftColor by animateColorAsState(
|
||||
targetValue = if (showLeft) Color.Transparent else Color.White,
|
||||
label = "AnimateLeftColor",
|
||||
)
|
||||
val rightColor by animateColorAsState(
|
||||
targetValue = if (showRight) Color.Transparent else Color.White,
|
||||
label = "AnimateRightColor",
|
||||
)
|
||||
return Brush.horizontalGradient(
|
||||
0f to leftColor,
|
||||
percent to Color.White,
|
||||
1f - percent to Color.White,
|
||||
1f to rightColor
|
||||
)
|
||||
}
|
||||
|
||||
fun Modifier.fadingEdge(brush: Brush) = this
|
||||
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawRect(brush = brush, blendMode = BlendMode.DstIn)
|
||||
}
|
||||
@@ -75,4 +75,11 @@ enum class FeatureFlags(
|
||||
defaultValue = true,
|
||||
isFinished = false,
|
||||
),
|
||||
RoomListFilters(
|
||||
key = "feature.roomlistfilters",
|
||||
title = "Room list filters",
|
||||
description = "Allow user to filter the room list",
|
||||
defaultValue = true,
|
||||
isFinished = false,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ class StaticFeatureFlagProvider @Inject constructor() :
|
||||
FeatureFlags.PinUnlock -> true
|
||||
FeatureFlags.Mentions -> true
|
||||
FeatureFlags.MarkAsUnread -> false
|
||||
FeatureFlags.RoomListFilters -> false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
|
||||
@@ -57,6 +57,11 @@ sealed interface RoomListFilter {
|
||||
*/
|
||||
data object Unread : RoomListFilter
|
||||
|
||||
/**
|
||||
* A filter that matches rooms that are marked as favorite.
|
||||
*/
|
||||
data object Favorite : RoomListFilter
|
||||
|
||||
/**
|
||||
* A filter that matches either Group or People rooms.
|
||||
*/
|
||||
|
||||
@@ -31,5 +31,6 @@ fun RoomListFilter.toRustFilter(): RoomListEntriesDynamicFilterKind {
|
||||
RoomListFilter.None -> RoomListEntriesDynamicFilterKind.None
|
||||
is RoomListFilter.NormalizedMatchRoomName -> RoomListEntriesDynamicFilterKind.NormalizedMatchRoomName(pattern)
|
||||
RoomListFilter.Unread -> RoomListEntriesDynamicFilterKind.Unread
|
||||
RoomListFilter.Favorite -> RoomListEntriesDynamicFilterKind.Favourite
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ object TestTags {
|
||||
* Room list / Home screen.
|
||||
*/
|
||||
val homeScreenSettings = TestTag("home_screen-settings")
|
||||
val homeScreenClearFilters = TestTag("home_screen-clear_filters")
|
||||
|
||||
/**
|
||||
* Room detail screen.
|
||||
|
||||
@@ -28,6 +28,7 @@ import io.element.android.features.roomlist.impl.RoomListView
|
||||
import io.element.android.features.roomlist.impl.datasource.DefaultInviteStateDataSource
|
||||
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
|
||||
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
|
||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersPresenter
|
||||
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
|
||||
import io.element.android.features.roomlist.impl.migration.SharedPrefsMigrationScreenStore
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchPresenter
|
||||
@@ -120,6 +121,10 @@ class RoomListScreen(
|
||||
sessionId = matrixClient.sessionId,
|
||||
sessionCoroutineScope = Singleton.appScope
|
||||
),
|
||||
filtersPresenter = RoomListFiltersPresenter(
|
||||
roomListService = matrixClient.roomListService,
|
||||
featureFlagService = featureFlagService,
|
||||
),
|
||||
analyticsService = NoopAnalyticsService(),
|
||||
)
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user