Replace the SearchBar by a SearchField in the room select screen

The room select screen was using a SearchBar, with an active mode displaying the search results instead of the room
list. Use a SearchField instead, displayed above the room list, which filters the rooms directly. The account switch
section and the selected rooms are now also displayed while searching.

- Remove isSearchActive from the state, and the ToggleSearchActive event.
- The "No results" message is displayed when the query is not blank and no room matches it.
- The back button now always closes the screen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-08 10:08:34 +02:00
parent 6cb8a85539
commit 59bc387c7d
7 changed files with 59 additions and 94 deletions
@@ -13,7 +13,6 @@ import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
sealed interface RoomSelectEvent {
data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent
data object ToggleSearchActive : RoomSelectEvent
data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent
data object ToggleAccountListExpanded : RoomSelectEvent
data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent
@@ -63,7 +63,6 @@ class RoomSelectPresenter(
override fun present(): RoomSelectState {
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
val queryState = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
var isAccountListExpanded by remember { mutableStateOf(false) }
var selectedSessionId by remember { mutableStateOf(initialSessionId) }
val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = emptyList())
@@ -136,7 +135,7 @@ class RoomSelectPresenter(
derivedStateOf {
when {
roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList())
isSearchActive -> SearchBarResultState.NoResultsFound
queryState.text.isNotBlank() -> SearchBarResultState.NoResultsFound
else -> SearchBarResultState.Initial
}
}
@@ -152,7 +151,6 @@ class RoomSelectPresenter(
selectedRooms.adding(event.room)
}
}
RoomSelectEvent.ToggleSearchActive -> isSearchActive = !isSearchActive
RoomSelectEvent.ToggleAccountListExpanded -> isAccountListExpanded = !isAccountListExpanded
is RoomSelectEvent.SelectAccount -> {
selectedSessionId = event.sessionId
@@ -172,7 +170,6 @@ class RoomSelectPresenter(
maxNumberOfRooms = maxNumberOfRooms,
resultState = searchResults,
searchQuery = queryState,
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
selectedAccount = selectedAccount,
hasRoomListError = hasRoomListError,
@@ -20,7 +20,6 @@ data class RoomSelectState(
val maxNumberOfRooms: Int,
val resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>>,
val searchQuery: TextFieldState,
val isSearchActive: Boolean,
val selectedRooms: ImmutableList<SelectRoomInfo>,
val selectedAccount: MatrixUser,
/**
@@ -26,17 +26,15 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider<RoomSelectStat
override val values: Sequence<RoomSelectState>
get() = sequenceOf(
aRoomSelectState(),
aRoomSelectState(searchQuery = "Test", isSearchActive = true),
aRoomSelectState(searchQuery = "Test", resultState = SearchBarResultState.NoResultsFound),
aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())),
aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
searchQuery = "Test",
isSearchActive = true,
),
aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
searchQuery = "Test",
isSearchActive = true,
selectedRooms = aRoomSelectRoomList().subList(0, 1),
),
aRoomSelectState(
@@ -76,7 +74,6 @@ internal fun aRoomSelectState(
maxNumberOfRooms: Int = 10,
resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial,
searchQuery: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")),
hasRoomListError: Boolean = false,
@@ -88,7 +85,6 @@ internal fun aRoomSelectState(
maxNumberOfRooms = maxNumberOfRooms,
resultState = resultState,
searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
selectedAccount = selectedAccount,
hasRoomListError = hasRoomListError,
@@ -49,6 +49,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -70,8 +71,8 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.SearchField
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -123,10 +124,8 @@ fun RoomSelectView(
}
var canHandleBack by remember { mutableStateOf(true) }
fun onBackButton(state: RoomSelectState) {
if (state.isSearchActive) {
state.eventSink(RoomSelectEvent.ToggleSearchActive)
} else if (canHandleBack) {
fun onBackButton() {
if (canHandleBack) {
canHandleBack = false
onDismiss()
}
@@ -134,7 +133,7 @@ fun RoomSelectView(
BackHandler(
enabled = canHandleBack,
onBack = { onBackButton(state) }
onBack = ::onBackButton,
)
// Use a new list state when the selected account changes, so that the list of rooms is scrolled to the top
@@ -159,7 +158,7 @@ fun RoomSelectView(
navigationIcon = {
BackButton(
enabled = canHandleBack,
onClick = { onBackButton(state) }
onClick = ::onBackButton,
)
},
actions = {
@@ -179,81 +178,47 @@ fun RoomSelectView(
.consumeWindowInsets(paddingValues)
.then(
// Only when the account switch section is displayed, else its unknown height would consume all the scroll events
if (state.showAccountSwitch && !state.isSearchActive) {
if (state.showAccountSwitch) {
Modifier.nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection)
} else {
Modifier
}
)
) {
SearchBar(
SearchField(
state = state.searchQuery,
modifier = Modifier
.fillMaxWidth()
.padding(
WindowInsets.safeDrawing
.only(WindowInsetsSides.Horizontal)
.asPaddingValues()
),
placeHolderTitle = stringResource(CommonStrings.action_search),
queryState = state.searchQuery,
active = state.isSearchActive,
onActiveChange = { state.eventSink(RoomSelectEvent.ToggleSearchActive) },
resultState = state.resultState,
showBackButton = false,
) { summaries ->
LazyColumn(
state = lazyListState,
contentPadding = lazyColumnContentPadding,
) {
if (summaries.isNotEmpty()) {
item {
ListSectionHeader(
title = stringResource(CommonStrings.common_header_rooms),
hasDivider = false,
)
}
}
item {
SelectedRoomsHelper(
selectedRooms = state.selectedRooms,
showVerticalSpace = false,
)
}
items(summaries, key = { it.roomId.value }) { roomSummary ->
RoomSummaryView(
roomSummary,
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
onSelection = { roomSummary ->
state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary))
},
canBeSelected = state.canSelectMoreRooms,
)
}
)
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = stringResource(CommonStrings.action_search),
)
if (state.showAccountSwitch) {
// Keep this space outside the collapsing section, so that the section does not touch the search field when it is collapsing
Spacer(modifier = Modifier.height(8.dp))
TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) {
AccountSwitchSection(
selectedAccount = state.selectedAccount,
otherAccounts = state.otherAccounts,
isExpanded = state.isAccountListExpanded,
onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) },
onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) },
)
}
}
if (!state.isSearchActive) {
if (state.showAccountSwitch) {
// Keep this space outside the collapsing section, so that the section does not touch the search bar when it is collapsing
Spacer(modifier = Modifier.height(8.dp))
TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) {
AccountSwitchSection(
selectedAccount = state.selectedAccount,
otherAccounts = state.otherAccounts,
isExpanded = state.isAccountListExpanded,
onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) },
onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) },
)
}
}
SelectedRoomsHelper(
selectedRooms = state.selectedRooms,
// showVerticalSpace only if there is no other accounts
showVerticalSpace = !state.showAccountSwitch,
)
if (state.hasRoomListError) {
RoomListError()
} else if (state.resultState is SearchBarResultState.Results) {
SelectedRoomsHelper(
selectedRooms = state.selectedRooms,
// showVerticalSpace only if there is no other accounts
showVerticalSpace = !state.showAccountSwitch,
)
when {
state.hasRoomListError -> RoomListError()
state.resultState is SearchBarResultState.NoResultsFound -> NoResultsFound()
state.resultState is SearchBarResultState.Results -> {
LazyColumn(
state = lazyListState,
contentPadding = lazyColumnContentPadding,
@@ -278,11 +243,24 @@ fun RoomSelectView(
}
}
}
else -> Unit
}
}
}
}
@Composable
private fun NoResultsFound() {
Text(
text = stringResource(CommonStrings.common_no_results),
textAlign = TextAlign.Center,
color = ElementTheme.colors.textSecondary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 80.dp),
)
}
@Composable
private fun RoomListError() {
IconTitleSubtitleMolecule(
@@ -59,7 +59,6 @@ class RoomSelectPresenterTest {
val initialState = awaitItem()
assertThat(initialState.selectedRooms).isEmpty()
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.maxNumberOfRooms).isEqualTo(10)
assertThat(initialState.canSelectMoreRooms).isTrue()
assertThat(initialState.selectedAccount).isEqualTo(MatrixUser(A_SESSION_ID))
@@ -284,18 +283,6 @@ class RoomSelectPresenterTest {
),
)
@Test
fun `present - toggle search active`() = runTest {
val presenter = createRoomSelectPresenter()
presenter.test {
val initialState = awaitItem()
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
assertThat(awaitItem().isSearchActive).isTrue()
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
assertThat(awaitItem().isSearchActive).isFalse()
}
}
@Test
fun `present - update query`() = runTest {
val roomSummary = aRoomSummary()
@@ -314,8 +301,6 @@ class RoomSelectPresenterTest {
// Do not compare the lambda because they will be different. So copy the lambda from expectedRoomSummary to result
val result = (awaitItem().resultState as SearchBarResultState.Results).results
assertThat(result).isEqualTo(listOf(expectedRoomInfo))
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
skipItems(1)
initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained")
assertThat(
roomList.currentFilter.value
@@ -155,6 +155,17 @@ class RoomSelectViewTest : RobolectricTest() {
onNodeWithText("Room 0").assertIsNotDisplayed()
}
@Test
fun `a message is displayed when no room matches the search query`() = runAndroidComposeUiTest {
setRoomSelectView(
aRoomSelectState(
searchQuery = "Test",
resultState = SearchBarResultState.NoResultsFound,
),
)
onNodeWithText(activity!!.getString(CommonStrings.common_no_results)).assertIsDisplayed()
}
@Test
fun `an error is displayed instead of the rooms if they cannot be loaded`() = runAndroidComposeUiTest<ComponentActivity> {
setRoomSelectView(