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 { sealed interface RoomSelectEvent {
data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent
data object ToggleSearchActive : RoomSelectEvent
data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent
data object ToggleAccountListExpanded : RoomSelectEvent data object ToggleAccountListExpanded : RoomSelectEvent
data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent
@@ -63,7 +63,6 @@ class RoomSelectPresenter(
override fun present(): RoomSelectState { override fun present(): RoomSelectState {
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) } var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
val queryState = rememberTextFieldState() val queryState = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
var isAccountListExpanded by remember { mutableStateOf(false) } var isAccountListExpanded by remember { mutableStateOf(false) }
var selectedSessionId by remember { mutableStateOf(initialSessionId) } var selectedSessionId by remember { mutableStateOf(initialSessionId) }
val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = emptyList()) val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = emptyList())
@@ -136,7 +135,7 @@ class RoomSelectPresenter(
derivedStateOf { derivedStateOf {
when { when {
roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList()) roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList())
isSearchActive -> SearchBarResultState.NoResultsFound queryState.text.isNotBlank() -> SearchBarResultState.NoResultsFound
else -> SearchBarResultState.Initial else -> SearchBarResultState.Initial
} }
} }
@@ -152,7 +151,6 @@ class RoomSelectPresenter(
selectedRooms.adding(event.room) selectedRooms.adding(event.room)
} }
} }
RoomSelectEvent.ToggleSearchActive -> isSearchActive = !isSearchActive
RoomSelectEvent.ToggleAccountListExpanded -> isAccountListExpanded = !isAccountListExpanded RoomSelectEvent.ToggleAccountListExpanded -> isAccountListExpanded = !isAccountListExpanded
is RoomSelectEvent.SelectAccount -> { is RoomSelectEvent.SelectAccount -> {
selectedSessionId = event.sessionId selectedSessionId = event.sessionId
@@ -172,7 +170,6 @@ class RoomSelectPresenter(
maxNumberOfRooms = maxNumberOfRooms, maxNumberOfRooms = maxNumberOfRooms,
resultState = searchResults, resultState = searchResults,
searchQuery = queryState, searchQuery = queryState,
isSearchActive = isSearchActive,
selectedRooms = selectedRooms, selectedRooms = selectedRooms,
selectedAccount = selectedAccount, selectedAccount = selectedAccount,
hasRoomListError = hasRoomListError, hasRoomListError = hasRoomListError,
@@ -20,7 +20,6 @@ data class RoomSelectState(
val maxNumberOfRooms: Int, val maxNumberOfRooms: Int,
val resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>>, val resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>>,
val searchQuery: TextFieldState, val searchQuery: TextFieldState,
val isSearchActive: Boolean,
val selectedRooms: ImmutableList<SelectRoomInfo>, val selectedRooms: ImmutableList<SelectRoomInfo>,
val selectedAccount: MatrixUser, val selectedAccount: MatrixUser,
/** /**
@@ -26,17 +26,15 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider<RoomSelectStat
override val values: Sequence<RoomSelectState> override val values: Sequence<RoomSelectState>
get() = sequenceOf( get() = sequenceOf(
aRoomSelectState(), aRoomSelectState(),
aRoomSelectState(searchQuery = "Test", isSearchActive = true), aRoomSelectState(searchQuery = "Test", resultState = SearchBarResultState.NoResultsFound),
aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())), aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())),
aRoomSelectState( aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()), resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
searchQuery = "Test", searchQuery = "Test",
isSearchActive = true,
), ),
aRoomSelectState( aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()), resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
searchQuery = "Test", searchQuery = "Test",
isSearchActive = true,
selectedRooms = aRoomSelectRoomList().subList(0, 1), selectedRooms = aRoomSelectRoomList().subList(0, 1),
), ),
aRoomSelectState( aRoomSelectState(
@@ -76,7 +74,6 @@ internal fun aRoomSelectState(
maxNumberOfRooms: Int = 10, maxNumberOfRooms: Int = 10,
resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial, resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial,
searchQuery: String = "", searchQuery: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(), selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")), selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")),
hasRoomListError: Boolean = false, hasRoomListError: Boolean = false,
@@ -88,7 +85,6 @@ internal fun aRoomSelectState(
maxNumberOfRooms = maxNumberOfRooms, maxNumberOfRooms = maxNumberOfRooms,
resultState = resultState, resultState = resultState,
searchQuery = TextFieldState(initialText = searchQuery), searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
selectedRooms = selectedRooms, selectedRooms = selectedRooms,
selectedAccount = selectedAccount, selectedAccount = selectedAccount,
hasRoomListError = hasRoomListError, hasRoomListError = hasRoomListError,
@@ -49,6 +49,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontStyle 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp 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.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader 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.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.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.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -123,10 +124,8 @@ fun RoomSelectView(
} }
var canHandleBack by remember { mutableStateOf(true) } var canHandleBack by remember { mutableStateOf(true) }
fun onBackButton(state: RoomSelectState) { fun onBackButton() {
if (state.isSearchActive) { if (canHandleBack) {
state.eventSink(RoomSelectEvent.ToggleSearchActive)
} else if (canHandleBack) {
canHandleBack = false canHandleBack = false
onDismiss() onDismiss()
} }
@@ -134,7 +133,7 @@ fun RoomSelectView(
BackHandler( BackHandler(
enabled = canHandleBack, 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 // 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 = { navigationIcon = {
BackButton( BackButton(
enabled = canHandleBack, enabled = canHandleBack,
onClick = { onBackButton(state) } onClick = ::onBackButton,
) )
}, },
actions = { actions = {
@@ -179,62 +178,27 @@ fun RoomSelectView(
.consumeWindowInsets(paddingValues) .consumeWindowInsets(paddingValues)
.then( .then(
// Only when the account switch section is displayed, else its unknown height would consume all the scroll events // 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) Modifier.nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection)
} else { } else {
Modifier Modifier
} }
) )
) { ) {
SearchBar( SearchField(
state = state.searchQuery,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding( .padding(
WindowInsets.safeDrawing WindowInsets.safeDrawing
.only(WindowInsetsSides.Horizontal) .only(WindowInsetsSides.Horizontal)
.asPaddingValues() .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,
) )
} .padding(horizontal = 16.dp, vertical = 8.dp),
} placeholder = stringResource(CommonStrings.action_search),
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,
)
}
}
}
if (!state.isSearchActive) {
if (state.showAccountSwitch) { if (state.showAccountSwitch) {
// Keep this space outside the collapsing section, so that the section does not touch the search bar when it is collapsing // 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)) Spacer(modifier = Modifier.height(8.dp))
TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) { TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) {
AccountSwitchSection( AccountSwitchSection(
@@ -251,9 +215,10 @@ fun RoomSelectView(
// showVerticalSpace only if there is no other accounts // showVerticalSpace only if there is no other accounts
showVerticalSpace = !state.showAccountSwitch, showVerticalSpace = !state.showAccountSwitch,
) )
if (state.hasRoomListError) { when {
RoomListError() state.hasRoomListError -> RoomListError()
} else if (state.resultState is SearchBarResultState.Results) { state.resultState is SearchBarResultState.NoResultsFound -> NoResultsFound()
state.resultState is SearchBarResultState.Results -> {
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
contentPadding = lazyColumnContentPadding, 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 @Composable
private fun RoomListError() { private fun RoomListError() {
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
@@ -59,7 +59,6 @@ class RoomSelectPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedRooms).isEmpty() assertThat(initialState.selectedRooms).isEmpty()
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java) assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.maxNumberOfRooms).isEqualTo(10) assertThat(initialState.maxNumberOfRooms).isEqualTo(10)
assertThat(initialState.canSelectMoreRooms).isTrue() assertThat(initialState.canSelectMoreRooms).isTrue()
assertThat(initialState.selectedAccount).isEqualTo(MatrixUser(A_SESSION_ID)) 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 @Test
fun `present - update query`() = runTest { fun `present - update query`() = runTest {
val roomSummary = aRoomSummary() 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 // 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 val result = (awaitItem().resultState as SearchBarResultState.Results).results
assertThat(result).isEqualTo(listOf(expectedRoomInfo)) assertThat(result).isEqualTo(listOf(expectedRoomInfo))
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
skipItems(1)
initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained") initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained")
assertThat( assertThat(
roomList.currentFilter.value roomList.currentFilter.value
@@ -155,6 +155,17 @@ class RoomSelectViewTest : RobolectricTest() {
onNodeWithText("Room 0").assertIsNotDisplayed() 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 @Test
fun `an error is displayed instead of the rooms if they cannot be loaded`() = runAndroidComposeUiTest<ComponentActivity> { fun `an error is displayed instead of the rooms if they cannot be loaded`() = runAndroidComposeUiTest<ComponentActivity> {
setRoomSelectView( setRoomSelectView(