Merge pull request #6072 from element-hq/feature/fga/search_bar_text_field_state

Let SearchBar/SearchField use TextFieldState
This commit is contained in:
ganfra
2026-01-23 11:07:37 +01:00
committed by GitHub
48 changed files with 197 additions and 298 deletions

View File

@@ -13,6 +13,5 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface DefaultInvitePeopleEvents : InvitePeopleEvents {
data class ToggleUser(val user: MatrixUser) : DefaultInvitePeopleEvents
data class UpdateSearchQuery(val query: String) : DefaultInvitePeopleEvents
data class OnSearchActiveChanged(val active: Boolean) : DefaultInvitePeopleEvents
}

View File

@@ -8,6 +8,8 @@
package io.element.android.features.invitepeople.impl
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -80,7 +82,7 @@ class DefaultInvitePeoplePresenter(
val roomMembers = remember { mutableStateOf<AsyncData<ImmutableList<RoomMember>>>(AsyncData.Loading()) }
val selectedUsers = remember { mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf()) }
val searchResults = remember { mutableStateOf<SearchBarResultState<ImmutableList<InvitableUser>>>(SearchBarResultState.Initial()) }
var searchQuery by rememberSaveable { mutableStateOf("") }
val queryState = rememberTextFieldState()
var searchActive by rememberSaveable { mutableStateOf(false) }
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
val sendInvitesAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
@@ -129,6 +131,7 @@ class DefaultInvitePeoplePresenter(
fetchMembers(it, roomMembers)
}
}
val searchQuery = queryState.text.toString()
LaunchedEffect(searchQuery, roomMembers) {
performSearch(
searchResults = searchResults,
@@ -143,11 +146,9 @@ class DefaultInvitePeoplePresenter(
when (event) {
is DefaultInvitePeopleEvents.OnSearchActiveChanged -> {
searchActive = event.active
searchQuery = ""
}
is DefaultInvitePeopleEvents.UpdateSearchQuery -> {
searchQuery = event.query
if (!event.active) {
queryState.clearText()
}
}
is DefaultInvitePeopleEvents.ToggleUser -> {
@@ -162,7 +163,7 @@ class DefaultInvitePeoplePresenter(
}
is InvitePeopleEvents.CloseSearch -> {
searchActive = false
searchQuery = ""
queryState.clearText()
}
}
}
@@ -171,7 +172,7 @@ class DefaultInvitePeoplePresenter(
room = room.map { },
canInvite = selectedUsers.value.isNotEmpty() && !sendInvitesAction.value.isLoading(),
selectedUsers = selectedUsers.value,
searchQuery = searchQuery,
searchQuery = queryState,
isSearchActive = searchActive,
searchResults = searchResults.value,
showSearchLoader = showSearchLoader.value,

View File

@@ -8,6 +8,7 @@
package io.element.android.features.invitepeople.impl
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.architecture.AsyncAction
@@ -19,7 +20,7 @@ import kotlinx.collections.immutable.ImmutableList
data class DefaultInvitePeopleState(
val room: AsyncData<Unit>,
override val canInvite: Boolean,
val searchQuery: String,
val searchQuery: TextFieldState,
val showSearchLoader: Boolean,
val searchResults: SearchBarResultState<ImmutableList<InvitableUser>>,
val selectedUsers: ImmutableList<MatrixUser>,

View File

@@ -8,6 +8,7 @@
package io.element.android.features.invitepeople.impl
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
@@ -108,7 +109,7 @@ private fun aDefaultInvitePeopleState(
return DefaultInvitePeopleState(
room = room,
canInvite = canInvite,
searchQuery = searchQuery,
searchQuery = TextFieldState(initialText = searchQuery),
searchResults = searchResults,
selectedUsers = selectedUsers,
isSearchActive = isSearchActive,

View File

@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -90,7 +91,7 @@ private fun InvitePeopleContentView(
InvitePeopleSearchBar(
modifier = Modifier.fillMaxWidth(),
query = state.searchQuery,
queryState = state.searchQuery,
showLoader = state.showSearchLoader,
selectedUsers = state.selectedUsers,
state = state.searchResults,
@@ -102,7 +103,6 @@ private fun InvitePeopleContentView(
)
)
},
onTextChange = { state.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery(it)) },
onToggleUser = ::toggleUser,
)
@@ -149,20 +149,18 @@ private fun InvitePeopleContentView(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun InvitePeopleSearchBar(
query: String,
queryState: TextFieldState,
state: SearchBarResultState<ImmutableList<InvitableUser>>,
showLoader: Boolean,
selectedUsers: ImmutableList<MatrixUser>,
active: Boolean,
onActiveChange: (Boolean) -> Unit,
onTextChange: (String) -> Unit,
onToggleUser: (MatrixUser) -> Unit,
modifier: Modifier = Modifier,
placeHolderTitle: String = stringResource(CommonStrings.common_search_for_someone),
) {
SearchBar(
query = query,
onQueryChange = onTextChange,
queryState = queryState,
active = active,
onActiveChange = onActiveChange,
modifier = modifier,

View File

@@ -8,6 +8,7 @@
package io.element.android.features.invitepeople.impl
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.features.invitepeople.api.InvitePeopleEvents
@@ -68,7 +69,7 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.canInvite).isFalse()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.searchQuery.text.toString()).isEmpty()
cancelAndIgnoreRemainingEvents()
}
@@ -85,15 +86,15 @@ internal class DefaultInvitePeoplePresenterTest {
initialState.eventSink(DefaultInvitePeopleEvents.OnSearchActiveChanged(true))
val resultState = awaitItem()
val resultState = awaitItemAsDefault()
assertThat(resultState.isSearchActive).isTrue()
resultState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query"))
assertThat(awaitItemAsDefault().searchQuery).isEqualTo("some query")
resultState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
assertThat(awaitItemAsDefault().searchQuery.text.toString()).isEqualTo("some query")
resultState.eventSink(InvitePeopleEvents.CloseSearch)
skipItems(2)
awaitItemAsDefault().also {
assertThat(it.isSearchActive).isFalse()
assertThat(it.searchQuery).isEmpty()
assertThat(it.searchQuery.text.toString()).isEmpty()
}
cancelAndIgnoreRemainingEvents()
}
@@ -107,8 +108,8 @@ internal class DefaultInvitePeoplePresenterTest {
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query"))
val initialState = awaitItemAsDefault()
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitState(UserSearchResultState(results = emptyList(), isSearching = true))
skipItems(3)
@@ -132,10 +133,10 @@ internal class DefaultInvitePeoplePresenterTest {
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
presenter.test {
val initialState = awaitItem()
val initialState = awaitItemAsDefault()
skipItems(1)
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query"))
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
@@ -185,10 +186,10 @@ internal class DefaultInvitePeoplePresenterTest {
coroutineDispatchers = coroutineDispatchers,
)
presenter.test {
val initialState = awaitItem()
val initialState = awaitItemAsDefault()
skipItems(1)
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query"))
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
@@ -245,10 +246,10 @@ internal class DefaultInvitePeoplePresenterTest {
)
presenter.test {
val initialState = awaitItem()
val initialState = awaitItemAsDefault()
skipItems(1)
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query"))
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
@@ -312,14 +313,14 @@ internal class DefaultInvitePeoplePresenterTest {
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
presenter.test {
val initialState = awaitItem()
val initialState = awaitItemAsDefault()
skipItems(1)
val selectedUser = aMatrixUser()
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(selectedUser))
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query"))
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
@@ -350,13 +351,13 @@ internal class DefaultInvitePeoplePresenterTest {
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
presenter.test {
val initialState = awaitItem()
val initialState = awaitItemAsDefault()
skipItems(1)
val selectedUser = aMatrixUser()
// Given a query is made
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query"))
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")

View File

@@ -32,7 +32,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.emojibasebindings.Emoji
import io.element.android.features.messages.impl.timeline.components.customreaction.EmojiItem
import io.element.android.features.messages.impl.timeline.components.customreaction.icon
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toSp
@@ -58,8 +57,7 @@ fun EmojiPicker(
Column(modifier) {
SearchBar(
modifier = Modifier.padding(bottom = 10.dp),
query = state.searchQuery,
onQueryChange = { state.eventSink(EmojiPickerEvents.UpdateSearchQuery(it)) },
queryState = state.searchQuery,
resultState = state.searchResults,
active = state.isSearchActive,
onActiveChange = { state.eventSink(EmojiPickerEvents.ToggleSearchActive(it)) },

View File

@@ -10,5 +10,4 @@ package io.element.android.features.messages.impl.timeline.components.customreac
sealed interface EmojiPickerEvents {
data class ToggleSearchActive(val isActive: Boolean) : EmojiPickerEvents
data class UpdateSearchQuery(val query: String) : EmojiPickerEvents
}

View File

@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -38,7 +39,7 @@ class EmojiPickerPresenter(
) : Presenter<EmojiPickerState> {
@Composable
override fun present(): EmojiPickerState {
var searchQuery by remember { mutableStateOf("") }
val queryState = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
var emojiResults by remember { mutableStateOf<SearchBarResultState<ImmutableList<Emoji>>>(SearchBarResultState.Initial()) }
@@ -67,6 +68,7 @@ class EmojiPickerPresenter(
}
}
val searchQuery = queryState.text.toString()
LaunchedEffect(searchQuery) {
emojiResults = if (searchQuery.isEmpty()) {
SearchBarResultState.Initial()
@@ -97,14 +99,13 @@ class EmojiPickerPresenter(
is EmojiPickerEvents.ToggleSearchActive -> if (!isInPreview) {
isSearchActive = event.isActive
}
is EmojiPickerEvents.UpdateSearchQuery -> searchQuery = event.query
}
}
return EmojiPickerState(
categories = categories,
allEmojis = emojibaseStore.allEmojis,
searchQuery = searchQuery,
searchQuery = queryState,
isSearchActive = isSearchActive,
searchResults = emojiResults,
eventSink = ::handleEvent,

View File

@@ -9,6 +9,7 @@
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
import androidx.annotation.StringRes
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Immutable
import io.element.android.emojibasebindings.Emoji
import io.element.android.libraries.designsystem.theme.components.IconSource
@@ -20,7 +21,7 @@ import kotlinx.collections.immutable.ImmutableList
data class EmojiPickerState(
val categories: ImmutableList<EmojiCategory>,
val allEmojis: ImmutableList<Emoji>,
val searchQuery: String,
val searchQuery: TextFieldState,
val isSearchActive: Boolean,
val searchResults: SearchBarResultState<ImmutableList<Emoji>>,
val eventSink: (EmojiPickerEvents) -> Unit,

View File

@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory
@@ -76,7 +77,7 @@ internal fun anEmojiPickerState(
) = EmojiPickerState(
categories = categories,
allEmojis = allEmojis,
searchQuery = searchQuery,
searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
searchResults = searchResults,
eventSink = eventSink,

View File

@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.InternalComposeApi
import androidx.compose.runtime.currentComposer
import androidx.compose.ui.platform.LocalConfiguration
@@ -40,19 +41,19 @@ class EmojiPickerPresenterTest {
val warmUpRule = WarmUpRule()
@Test
fun `UpdateSearchQuery loads new results`() = runTest {
fun `updating search query loads new results`() = runTest {
testPresenter {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.searchQuery.text.toString()).isEmpty()
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
initialState.eventSink(EmojiPickerEvents.UpdateSearchQuery("smile"))
assertThat(awaitItem().searchQuery).isEqualTo("smile")
initialState.searchQuery.setTextAndPlaceCursorAtEnd("smile")
assertThat(awaitItem().searchQuery.text.toString()).isEqualTo("smile")
val stateWithResults = awaitItem()
assertThat(stateWithResults.searchQuery).isEqualTo("smile")
assertThat(stateWithResults.searchQuery.text.toString()).isEqualTo("smile")
assertThat(stateWithResults.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
}
}

View File

@@ -12,7 +12,6 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface ChangeRolesEvent {
data object ToggleSearchActive : ChangeRolesEvent
data class QueryChanged(val query: String?) : ChangeRolesEvent
data class UserSelectionToggled(val matrixUser: MatrixUser) : ChangeRolesEvent
data object Save : ChangeRolesEvent
data object Exit : ChangeRolesEvent

View File

@@ -8,6 +8,7 @@
package io.element.android.features.rolesandpermissions.impl.roles
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -68,7 +69,7 @@ class ChangeRolesPresenter(
@Composable
override fun present(): ChangeRolesState {
var query by rememberSaveable { mutableStateOf<String?>(null) }
val queryState = rememberTextFieldState()
var searchActive by rememberSaveable { mutableStateOf(false) }
var searchResults by remember {
mutableStateOf<SearchBarResultState<MembersByRole>>(SearchBarResultState.Initial())
@@ -105,9 +106,10 @@ class ChangeRolesPresenter(
val roomMemberState by room.membersStateFlow.collectAsState()
// Update search results for every query change
val query = queryState.text.toString()
LaunchedEffect(query, roomMemberState) {
val results = dataSource
.search(query.orEmpty())
.search(query)
.groupedByRole()
searchResults = if (results.isEmpty()) {
@@ -136,9 +138,6 @@ class ChangeRolesPresenter(
is ChangeRolesEvent.ToggleSearchActive -> {
searchActive = !searchActive
}
is ChangeRolesEvent.QueryChanged -> {
query = event.query
}
is ChangeRolesEvent.UserSelectionToggled -> {
val newList = selectedUsers.value.toMutableList()
val index = newList.indexOfFirst { it.userId == event.matrixUser.userId }
@@ -188,7 +187,7 @@ class ChangeRolesPresenter(
}
return ChangeRolesState(
role = role,
query = query,
searchQuery = queryState,
isSearchActive = searchActive,
searchResults = searchResults,
selectedUsers = selectedUsers.value,

View File

@@ -8,6 +8,7 @@
package io.element.android.features.rolesandpermissions.impl.roles
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId
@@ -19,7 +20,7 @@ import kotlinx.collections.immutable.toImmutableList
data class ChangeRolesState(
val role: RoomMember.Role,
val query: String?,
val searchQuery: TextFieldState,
val isSearchActive: Boolean,
val searchResults: SearchBarResultState<MembersByRole>,
val selectedUsers: ImmutableList<MatrixUser>,

View File

@@ -8,6 +8,7 @@
package io.element.android.features.rolesandpermissions.impl.roles
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
@@ -34,8 +35,8 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
aChangeRolesStateWithSelectedUsers().copy(
selectedUsers = aMatrixUserList().take(2).toImmutableList(),
),
aChangeRolesStateWithSelectedUsers().copy(
query = "Alice",
aChangeRolesState(
searchQuery = "Alice",
isSearchActive = true,
searchResults = SearchBarResultState.Results(
MembersByRole(
@@ -44,6 +45,8 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
)
),
selectedUsers = aMatrixUserList().take(1).toImmutableList(),
hasPendingChanges = true,
canRemoveMember = { it != UserId("@alice:server.org") },
),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.ConfirmingCancellation),
aChangeRolesStateWithSelectedUsers().copy(savingState = ConfirmingModifyingAdmins),
@@ -59,7 +62,7 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
internal fun aChangeRolesState(
role: RoomMember.Role = RoomMember.Role.Admin,
query: String? = null,
searchQuery: String = "",
isSearchActive: Boolean = false,
searchResults: SearchBarResultState<MembersByRole> = SearchBarResultState.NoResultsFound(),
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
@@ -69,7 +72,7 @@ internal fun aChangeRolesState(
eventSink: (ChangeRolesEvent) -> Unit = {},
) = ChangeRolesState(
role = role,
query = query,
searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
searchResults = searchResults,
selectedUsers = selectedUsers,

View File

@@ -118,8 +118,7 @@ fun ChangeRolesView(
.fillMaxWidth()
.padding(bottom = 16.dp),
placeHolderTitle = stringResource(CommonStrings.common_search_for_someone),
query = state.query.orEmpty(),
onQueryChange = { state.eventSink(ChangeRolesEvent.QueryChanged(it)) },
queryState = state.searchQuery,
active = state.isSearchActive,
onActiveChange = { state.eventSink(ChangeRolesEvent.ToggleSearchActive) },
resultState = state.searchResults,

View File

@@ -8,6 +8,7 @@
package io.element.android.features.rolesandpermissions.impl.roles
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.features.rolesandpermissions.impl.RoomMemberListDataSource
@@ -49,7 +50,7 @@ class ChangeRolesPresenterTest {
presenter.test {
with(awaitItem()) {
assertThat(role).isEqualTo(RoomMember.Role.Admin)
assertThat(query).isNull()
assertThat(searchQuery.text.toString()).isEmpty()
assertThat(isSearchActive).isFalse()
assertThat(searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(selectedUsers).isEmpty()
@@ -206,7 +207,7 @@ class ChangeRolesPresenterTest {
}
@Test
fun `present - QueryChanged produces new results`() = runTest {
fun `present - updating query produces new results`() = runTest {
val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
}
@@ -219,7 +220,7 @@ class ChangeRolesPresenterTest {
assertThat(initialResults?.moderators).hasSize(1)
assertThat(initialResults?.admins).hasSize(1)
initialState.eventSink(ChangeRolesEvent.QueryChanged("Alice"))
initialState.searchQuery.setTextAndPlaceCursorAtEnd("Alice")
skipItems(1)
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results

View File

@@ -76,7 +76,7 @@ class ChangeRolesViewTest {
),
)
rule.pressBackKey()
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Exit))
eventsRecorder.assertSingle(ChangeRolesEvent.Exit)
}
@Test
@@ -89,7 +89,7 @@ class ChangeRolesViewTest {
),
)
rule.pressBack()
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Exit))
eventsRecorder.assertSingle(ChangeRolesEvent.Exit)
}
@Test
@@ -102,7 +102,7 @@ class ChangeRolesViewTest {
),
)
rule.clickOn(CommonStrings.action_save)
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Save))
eventsRecorder.assertSingle(ChangeRolesEvent.Save)
}
@Test
@@ -115,7 +115,7 @@ class ChangeRolesViewTest {
),
)
rule.clickOn(CommonStrings.action_save)
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged("")))
eventsRecorder.assertEmpty()
}
@Test
@@ -238,12 +238,7 @@ class ChangeRolesViewTest {
label = contentDescription,
useUnmergedTree = true,
).performClick()
eventsRecorder.assertList(
listOf(
ChangeRolesEvent.QueryChanged(""),
ChangeRolesEvent.UserSelectionToggled(userToDeselect),
)
)
eventsRecorder.assertSingle(ChangeRolesEvent.UserSelectionToggled(userToDeselect))
}
@Test
@@ -262,12 +257,7 @@ class ChangeRolesViewTest {
)
// Select the user from the user list
rule.onNodeWithText("Carol").performClick()
eventsRecorder.assertList(
listOf(
ChangeRolesEvent.QueryChanged(""),
ChangeRolesEvent.UserSelectionToggled(userToSelect),
)
)
eventsRecorder.assertSingle(ChangeRolesEvent.UserSelectionToggled(userToSelect))
}
@Test
@@ -288,12 +278,7 @@ class ChangeRolesViewTest {
text = "Bob",
useUnmergedTree = true,
)[1].performClick()
eventsRecorder.assertList(
listOf(
ChangeRolesEvent.QueryChanged(""),
ChangeRolesEvent.UserSelectionToggled(userToSelect),
)
)
eventsRecorder.assertSingle(ChangeRolesEvent.UserSelectionToggled(userToSelect))
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChangeRolesContent(

View File

@@ -12,6 +12,5 @@ import io.element.android.libraries.matrix.api.room.RoomMember
sealed interface RoomMemberListEvents {
data class ChangeSelectedSection(val section: SelectedSection) : RoomMemberListEvents
data class UpdateSearchQuery(val query: String) : RoomMemberListEvents
data class RoomMemberSelected(val roomMember: RoomMember) : RoomMemberListEvents
}

View File

@@ -8,6 +8,7 @@
package io.element.android.features.roomdetails.impl.members
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
@@ -16,7 +17,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents.ShowActionsForUser
@@ -56,7 +56,7 @@ class RoomMemberListPresenter(
@Composable
override fun present(): RoomMemberListState {
var searchQuery by rememberSaveable { mutableStateOf("") }
val searchQuery = rememberTextFieldState()
val membersState by room.membersStateFlow.collectAsState()
val canInvite by room.permissionsAsState(false) { perms -> perms.canOwnUserInvite() }
val roomModerationState = roomMembersModerationPresenter.present()
@@ -117,17 +117,16 @@ class RoomMemberListPresenter(
}
}
LaunchedEffect(searchQuery, roomMembers) {
LaunchedEffect(searchQuery.text, roomMembers) {
filteredRoomMembers = roomMembers.map { members ->
withContext(coroutineDispatchers.io) {
members.filter(searchQuery)
members.filter(searchQuery.text.toString())
}
}
}
fun handleEvent(event: RoomMemberListEvents) {
when (event) {
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
is RoomMemberListEvents.RoomMemberSelected ->
roomModerationState.eventSink(ShowActionsForUser(event.roomMember.toMatrixUser()))
is RoomMemberListEvents.ChangeSelectedSection -> selectedSection = event.section

View File

@@ -8,6 +8,7 @@
package io.element.android.features.roomdetails.impl.members
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.bool.orFalse
@@ -20,7 +21,7 @@ data class RoomMemberListState(
// Only used to know if we can show the banned section
private val roomMembers: AsyncData<RoomMembers>,
val filteredRoomMembers: AsyncData<RoomMembers>,
val searchQuery: String,
val searchQuery: TextFieldState,
val canInvite: Boolean,
val selectedSection: SelectedSection,
val moderationState: RoomMemberModerationState,

View File

@@ -8,6 +8,7 @@
package io.element.android.features.roomdetails.impl.members
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions
@@ -88,7 +89,7 @@ internal fun aRoomMemberListState(
) = RoomMemberListState(
roomMembers = roomMembers,
filteredRoomMembers = roomMembers.map { it.filter(searchQuery) },
searchQuery = searchQuery,
searchQuery = TextFieldState(searchQuery),
canInvite = canInvite,
moderationState = moderationState,
selectedSection = selectedSection,

View File

@@ -42,7 +42,6 @@ import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubti
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.form.textFieldState
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
@@ -89,13 +88,8 @@ fun RoomMemberListView(
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
var searchQuery by textFieldState(state.searchQuery)
SearchField(
value = searchQuery,
onValueChange = { newQuery ->
searchQuery = newQuery
state.eventSink(RoomMemberListEvents.UpdateSearchQuery(newQuery))
},
state = state.searchQuery,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
@@ -105,7 +99,7 @@ fun RoomMemberListView(
roomMembersData = state.filteredRoomMembers,
selectedSection = state.selectedSection,
showBannedSection = state.showBannedSection,
searchQuery = state.searchQuery,
searchQuery = state.searchQuery.text.toString(),
onSelectedSectionChange = { state.eventSink(RoomMemberListEvents.ChangeSelectedSection(it)) },
onSelectUser = ::onSelectUser,
)

View File

@@ -8,6 +8,7 @@
package io.element.android.features.roomdetails.impl.members
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.Presenter
@@ -42,7 +43,7 @@ class RoomMemberListPresenterTest {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.filteredRoomMembers.isLoading()).isTrue()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.searchQuery.text.toString()).isEmpty()
assertThat(initialState.selectedSection).isEqualTo(SelectedSection.MEMBERS)
}
}
@@ -87,7 +88,7 @@ class RoomMemberListPresenterTest {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.filteredRoomMembers.isLoading()).isTrue()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.searchQuery.text.toString()).isEmpty()
room.givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
// Skip items while the new members state is processed
skipItems(2)
@@ -116,9 +117,9 @@ class RoomMemberListPresenterTest {
assertThat(loadedRoomMembers.invited).isNotEmpty()
assertThat(loadedRoomMembers.isEmpty(SelectedSection.MEMBERS)).isFalse()
assertThat(loadedRoomMembers.isEmpty(SelectedSection.BANNED)).isFalse()
loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("something"))
loadedState.searchQuery.setTextAndPlaceCursorAtEnd("something")
val searchQueryUpdatedState = awaitItem()
assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("something")
assertThat(searchQueryUpdatedState.searchQuery.text).isEqualTo("something")
val searchSearchResultDelivered = awaitItem()
val emptyRoomMembers = searchSearchResultDelivered.filteredRoomMembers.dataOrNull()!!
assertThat(emptyRoomMembers.joined).isEmpty()
@@ -144,9 +145,9 @@ class RoomMemberListPresenterTest {
assertThat(loadedRoomMembers.invited).isNotEmpty()
assertThat(loadedRoomMembers.isEmpty(SelectedSection.MEMBERS)).isFalse()
assertThat(loadedRoomMembers.isEmpty(SelectedSection.BANNED)).isFalse()
loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("alice"))
loadedState.searchQuery.setTextAndPlaceCursorAtEnd("alice")
val searchQueryUpdatedState = awaitItem()
assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("alice")
assertThat(searchQueryUpdatedState.searchQuery.text).isEqualTo("alice")
val searchSearchResultDelivered = awaitItem()
val emptyRoomMembers = searchSearchResultDelivered.filteredRoomMembers.dataOrNull()!!
assertThat(emptyRoomMembers.joined).isNotEmpty()

View File

@@ -11,9 +11,7 @@ import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
sealed interface AddRoomToSpaceEvent {
data class ToggleRoom(val room: SelectRoomInfo) : AddRoomToSpaceEvent
data class UpdateSearchQuery(val query: String) : AddRoomToSpaceEvent
data class OnSearchActiveChanged(val active: Boolean) : AddRoomToSpaceEvent
data object Save : AddRoomToSpaceEvent
data object CloseSearch : AddRoomToSpaceEvent
data object ResetSaveAction : AddRoomToSpaceEvent
}

View File

@@ -7,6 +7,8 @@
package io.element.android.features.space.impl.addroom
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -43,7 +45,7 @@ class AddRoomToSpacePresenter(
@Composable
override fun present(): AddRoomToSpaceState {
var selectedRooms: ImmutableList<SelectRoomInfo> by remember { mutableStateOf(persistentListOf()) }
var searchQuery by remember { mutableStateOf("") }
var searchQuery = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
@@ -51,8 +53,8 @@ class AddRoomToSpacePresenter(
val dataSource = remember { dataSourceFactory.create(coroutineScope) }
// Update search query in data source
LaunchedEffect(searchQuery) {
dataSource.setSearchQuery(searchQuery)
LaunchedEffect(searchQuery.text) {
dataSource.setSearchQuery(searchQuery.text.toString())
}
LaunchedEffect(isSearchActive) {
dataSource.setIsActive(isSearchActive)
@@ -65,7 +67,7 @@ class AddRoomToSpacePresenter(
derivedStateOf {
when {
filteredRooms.isNotEmpty() -> SearchBarResultState.Results(filteredRooms)
isSearchActive && searchQuery.isNotEmpty() -> SearchBarResultState.NoResultsFound()
isSearchActive && searchQuery.text.isNotEmpty() -> SearchBarResultState.NoResultsFound()
else -> SearchBarResultState.Initial()
}
}
@@ -80,19 +82,12 @@ class AddRoomToSpacePresenter(
(selectedRooms + event.room).toImmutableList()
}
}
is AddRoomToSpaceEvent.UpdateSearchQuery -> {
searchQuery = event.query
}
is AddRoomToSpaceEvent.OnSearchActiveChanged -> {
isSearchActive = event.active
if (!event.active) {
searchQuery = ""
searchQuery.clearText()
}
}
AddRoomToSpaceEvent.CloseSearch -> {
isSearchActive = false
searchQuery = ""
}
AddRoomToSpaceEvent.Save -> {
coroutineScope.addRoomsToSpace(
selectedRooms = selectedRooms,

View File

@@ -7,13 +7,14 @@
package io.element.android.features.space.impl.addroom
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import kotlinx.collections.immutable.ImmutableList
data class AddRoomToSpaceState(
val searchQuery: String,
val searchQuery: TextFieldState,
val isSearchActive: Boolean,
val searchResults: SearchBarResultState<ImmutableList<SelectRoomInfo>>,
val selectedRooms: ImmutableList<SelectRoomInfo>,

View File

@@ -7,6 +7,7 @@
package io.element.android.features.space.impl.addroom
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
@@ -69,7 +70,7 @@ internal fun anAddRoomToSpaceState(
eventSink: (AddRoomToSpaceEvent) -> Unit = {},
): AddRoomToSpaceState {
return AddRoomToSpaceState(
searchQuery = searchQuery,
searchQuery = TextFieldState(searchQuery),
searchResults = searchResults,
selectedRooms = selectedRooms,
isSearchActive = isSearchActive,

View File

@@ -64,7 +64,7 @@ fun AddRoomToSpaceView(
fun onBack() {
if (state.isSearchActive) {
state.eventSink(AddRoomToSpaceEvent.CloseSearch)
state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(false))
} else {
onBackClick()
}
@@ -105,12 +105,11 @@ fun AddRoomToSpaceView(
SearchBar(
modifier = Modifier.fillMaxWidth(),
placeHolderTitle = stringResource(CommonStrings.action_search),
query = state.searchQuery,
onQueryChange = { state.eventSink(AddRoomToSpaceEvent.UpdateSearchQuery(it)) },
queryState = state.searchQuery,
active = state.isSearchActive,
onActiveChange = { state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(it)) },
resultState = state.searchResults,
showBackButton = false,
resultState = state.searchResults,
contentPrefix = {
if (state.selectedRooms.isNotEmpty()) {
SelectedRoomsRow(

View File

@@ -9,6 +9,7 @@
package io.element.android.features.space.impl.addroom
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
@@ -38,7 +39,7 @@ class AddRoomToSpacePresenterTest {
presenter.test {
val state = awaitItem()
assertThat(state.selectedRooms).isEmpty()
assertThat(state.searchQuery).isEmpty()
assertThat(state.searchQuery.text.toString()).isEmpty()
assertThat(state.isSearchActive).isFalse()
assertThat(state.saveAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(state.canSave).isFalse()
@@ -77,17 +78,6 @@ class AddRoomToSpacePresenterTest {
}
}
@Test
fun `present - UpdateSearchQuery updates query`() = runTest {
val presenter = createAddRoomToSpacePresenter()
presenter.test {
val state = awaitItem()
state.eventSink(AddRoomToSpaceEvent.UpdateSearchQuery("test"))
val updatedState = awaitItem()
assertThat(updatedState.searchQuery).isEqualTo("test")
}
}
@Test
fun `present - OnSearchActiveChanged activates search`() = runTest {
val presenter = createAddRoomToSpacePresenter()
@@ -107,33 +97,14 @@ class AddRoomToSpacePresenterTest {
// Activate search and set query
state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(true))
awaitItem()
state.eventSink(AddRoomToSpaceEvent.UpdateSearchQuery("test"))
state.searchQuery.setTextAndPlaceCursorAtEnd("test")
awaitItem()
// Deactivate search
state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(false))
advanceUntilIdle()
val finalState = expectMostRecentItem()
assertThat(finalState.isSearchActive).isFalse()
assertThat(finalState.searchQuery).isEmpty()
}
}
@Test
fun `present - CloseSearch deactivates and clears query`() = runTest {
val presenter = createAddRoomToSpacePresenter()
presenter.test {
val state = awaitItem()
// Activate search and set query
state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(true))
awaitItem()
state.eventSink(AddRoomToSpaceEvent.UpdateSearchQuery("test"))
awaitItem()
// Close search
state.eventSink(AddRoomToSpaceEvent.CloseSearch)
advanceUntilIdle()
val finalState = expectMostRecentItem()
assertThat(finalState.isSearchActive).isFalse()
assertThat(finalState.searchQuery).isEmpty()
assertThat(finalState.searchQuery.text.toString()).isEmpty()
}
}
@@ -168,11 +139,11 @@ class AddRoomToSpacePresenterTest {
val state = awaitItem()
state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(true))
awaitItem()
state.eventSink(AddRoomToSpaceEvent.UpdateSearchQuery("nonexistent"))
state.searchQuery.setTextAndPlaceCursorAtEnd("nonexistent")
advanceUntilIdle()
val finalState = expectMostRecentItem()
assertThat(finalState.isSearchActive).isTrue()
assertThat(finalState.searchQuery).isEqualTo("nonexistent")
assertThat(finalState.searchQuery.text).isEqualTo("nonexistent")
assertThat(finalState.searchResults).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
}
}

View File

@@ -54,7 +54,7 @@ class AddRoomToSpaceViewTest {
),
)
rule.pressBack()
eventsRecorder.assertSingle(AddRoomToSpaceEvent.CloseSearch)
eventsRecorder.assertSingle(AddRoomToSpaceEvent.OnSearchActiveChanged(false))
}
@Test
@@ -67,12 +67,7 @@ class AddRoomToSpaceViewTest {
),
)
rule.clickOn(CommonStrings.action_save)
eventsRecorder.assertList(
listOf(
AddRoomToSpaceEvent.UpdateSearchQuery(""), // SearchBar initialization
AddRoomToSpaceEvent.Save,
)
)
eventsRecorder.assertSingle(AddRoomToSpaceEvent.Save)
}
@Config(qualifiers = "h1024dp")
@@ -87,12 +82,7 @@ class AddRoomToSpaceViewTest {
),
)
rule.onNodeWithText(suggestions.first().name!!).performClick()
eventsRecorder.assertList(
listOf(
AddRoomToSpaceEvent.UpdateSearchQuery(""), // SearchBar initialization
AddRoomToSpaceEvent.ToggleRoom(suggestions.first()),
)
)
eventsRecorder.assertSingle(AddRoomToSpaceEvent.ToggleRoom(suggestions.first()))
}
@Test

View File

@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
@@ -40,14 +41,13 @@ import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchUserBar(
query: String,
state: SearchBarResultState<ImmutableList<UserSearchResult>>,
queryState: TextFieldState,
resultState: SearchBarResultState<ImmutableList<UserSearchResult>>,
showLoader: Boolean,
selectedUsers: ImmutableList<MatrixUser>,
active: Boolean,
isMultiSelectionEnable: Boolean,
onActiveChange: (Boolean) -> Unit,
onTextChange: (String) -> Unit,
onUserSelect: (MatrixUser) -> Unit,
onUserDeselect: (MatrixUser) -> Unit,
modifier: Modifier = Modifier,
@@ -57,8 +57,7 @@ fun SearchUserBar(
val columnState = rememberLazyListState()
SearchBar(
query = query,
onQueryChange = onTextChange,
queryState = queryState,
active = active,
onActiveChange = onActiveChange,
modifier = modifier,
@@ -98,7 +97,7 @@ fun SearchUserBar(
AsyncLoading()
}
},
resultState = state,
resultState = resultState,
resultHandler = { users ->
LazyColumn(state = columnState) {
if (isMultiSelectionEnable) {

View File

@@ -46,15 +46,14 @@ fun UserListView(
) {
SearchUserBar(
modifier = Modifier.fillMaxWidth(),
query = state.searchQuery,
state = state.searchResults,
queryState = state.searchQuery,
resultState = state.searchResults,
selectedUsers = state.selectedUsers,
active = state.isSearchActive,
showLoader = state.showSearchLoader,
isMultiSelectionEnable = state.isMultiSelectionEnabled,
showBackButton = showBackButton,
onActiveChange = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) },
onTextChange = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) },
onUserSelect = {
state.eventSink(UserListEvents.AddToSelection(it))
onSelectUser(it)

View File

@@ -27,10 +27,10 @@ open class StartChatStateProvider : PreviewParameterProvider<StartChatState> {
aCreateRoomRootState(
startDmAction = AsyncAction.Loading,
userListState = aMatrixUser().let {
aUserListState().copy(
aUserListState(
searchQuery = it.userId.value,
searchResults = SearchBarResultState.Results(persistentListOf(UserSearchResult(it, false))),
selectedUsers = persistentListOf(it),
selectedUsers = listOf(it),
isSearchActive = true,
)
}
@@ -38,10 +38,10 @@ open class StartChatStateProvider : PreviewParameterProvider<StartChatState> {
aCreateRoomRootState(
startDmAction = AsyncAction.Failure(RuntimeException("error")),
userListState = aMatrixUser().let {
aUserListState().copy(
aUserListState(
searchQuery = it.userId.value,
searchResults = SearchBarResultState.Results(persistentListOf(UserSearchResult(it, false))),
selectedUsers = persistentListOf(it),
selectedUsers = listOf(it),
isSearchActive = true,
)
}

View File

@@ -8,6 +8,7 @@
package io.element.android.features.startchat.impl.userlist
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -64,12 +65,13 @@ class DefaultUserListPresenter(
}
var isSearchActive by rememberSaveable { mutableStateOf(false) }
val selectedUsers by userListDataStore.selectedUsers.collectAsState(emptyList())
var searchQuery by rememberSaveable { mutableStateOf("") }
val queryState = rememberTextFieldState()
var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember {
mutableStateOf(SearchBarResultState.Initial())
}
var showSearchLoader by remember { mutableStateOf(false) }
val searchQuery = queryState.text.toString()
LaunchedEffect(searchQuery) {
searchResults = SearchBarResultState.Initial()
showSearchLoader = false
@@ -86,14 +88,13 @@ class DefaultUserListPresenter(
fun handleEvent(event: UserListEvents) {
when (event) {
is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active
is UserListEvents.UpdateSearchQuery -> searchQuery = event.query
is UserListEvents.AddToSelection -> userListDataStore.selectUser(event.matrixUser)
is UserListEvents.RemoveFromSelection -> userListDataStore.removeUserFromSelection(event.matrixUser)
}
}
return UserListState(
searchQuery = searchQuery,
searchQuery = queryState,
searchResults = searchResults,
selectedUsers = selectedUsers.toImmutableList(),
isSearchActive = isSearchActive,

View File

@@ -11,7 +11,6 @@ package io.element.android.features.startchat.impl.userlist
import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface UserListEvents {
data class UpdateSearchQuery(val query: String) : UserListEvents
data class AddToSelection(val matrixUser: MatrixUser) : UserListEvents
data class RemoveFromSelection(val matrixUser: MatrixUser) : UserListEvents
data class OnSearchActiveChanged(val active: Boolean) : UserListEvents

View File

@@ -8,6 +8,7 @@
package io.element.android.features.startchat.impl.userlist
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.recent.RecentDirectRoom
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -15,7 +16,7 @@ import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList
data class UserListState(
val searchQuery: String,
val searchQuery: TextFieldState,
val searchResults: SearchBarResultState<ImmutableList<UserSearchResult>>,
val showSearchLoader: Boolean,
val selectedUsers: ImmutableList<MatrixUser>,

View File

@@ -8,6 +8,7 @@
package io.element.android.features.startchat.impl.userlist
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomId
@@ -69,7 +70,7 @@ fun aUserListState(
recentDirectRooms: List<RecentDirectRoom> = emptyList(),
eventSink: (UserListEvents) -> Unit = {},
) = UserListState(
searchQuery = searchQuery,
searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
searchResults = searchResults,
selectedUsers = selectedUsers.toImmutableList(),

View File

@@ -8,6 +8,7 @@
package io.element.android.features.startchat.impl.userlist
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -41,7 +42,7 @@ class DefaultUserListPresenterTest {
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.searchQuery.text.toString()).isEmpty()
assertThat(initialState.isMultiSelectionEnabled).isFalse()
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.selectedUsers).isEmpty()
@@ -61,7 +62,7 @@ class DefaultUserListPresenterTest {
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.searchQuery.text.toString()).isEmpty()
assertThat(initialState.isMultiSelectionEnabled).isTrue()
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.selectedUsers).isEmpty()
@@ -86,14 +87,14 @@ class DefaultUserListPresenterTest {
assertThat(awaitItem().isSearchActive).isTrue()
val matrixIdQuery = "@name:matrix.org"
initialState.eventSink(UserListEvents.UpdateSearchQuery(matrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(matrixIdQuery)
initialState.searchQuery.setTextAndPlaceCursorAtEnd(matrixIdQuery)
assertThat(awaitItem().searchQuery.text.toString()).isEqualTo(matrixIdQuery)
assertThat(userRepository.providedQuery).isEqualTo(matrixIdQuery)
skipItems(1)
val notMatrixIdQuery = "name"
initialState.eventSink(UserListEvents.UpdateSearchQuery(notMatrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(notMatrixIdQuery)
initialState.searchQuery.setTextAndPlaceCursorAtEnd(notMatrixIdQuery)
assertThat(awaitItem().searchQuery.text.toString()).isEqualTo(notMatrixIdQuery)
assertThat(userRepository.providedQuery).isEqualTo(notMatrixIdQuery)
skipItems(1)
@@ -117,7 +118,7 @@ class DefaultUserListPresenterTest {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(UserListEvents.UpdateSearchQuery("alice"))
initialState.searchQuery.setTextAndPlaceCursorAtEnd("alice")
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(userRepository.providedQuery).isEqualTo("alice")
skipItems(2)
@@ -168,7 +169,7 @@ class DefaultUserListPresenterTest {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(UserListEvents.UpdateSearchQuery("alice"))
initialState.searchQuery.setTextAndPlaceCursorAtEnd("alice")
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(userRepository.providedQuery).isEqualTo("alice")
skipItems(2)

View File

@@ -17,6 +17,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarColors
@@ -25,9 +28,7 @@ import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
@@ -51,8 +52,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <T> SearchBar(
query: String,
onQueryChange: (String) -> Unit,
queryState: TextFieldState,
active: Boolean,
onActiveChange: (Boolean) -> Unit,
placeHolderTitle: String,
@@ -72,10 +72,9 @@ fun <T> SearchBar(
) {
val focusManager = LocalFocusManager.current
val colors = if (active) activeBarColors else inactiveBarColors
val updatedOnQueryChange by rememberUpdatedState(onQueryChange)
LaunchedEffect(active) {
if (!active) {
updatedOnQueryChange("")
queryState.clearText()
focusManager.clearFocus()
}
}
@@ -83,8 +82,7 @@ fun <T> SearchBar(
SearchBar(
inputField = {
SearchBarDefaults.InputField(
query = query,
onQueryChange = updatedOnQueryChange,
state = queryState,
onSearch = { focusManager.clearFocus() },
expanded = active,
onExpandedChange = onActiveChange,
@@ -98,9 +96,9 @@ fun <T> SearchBar(
null
},
trailingIcon = when {
active && query.isNotEmpty() -> {
active && queryState.text.isNotEmpty() -> {
{
IconButton(onClick = { onQueryChange("") }) {
IconButton(onClick = { queryState.clearText() }) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_clear),
@@ -221,7 +219,7 @@ internal fun SearchBarInactivePreview() = ElementThemedPreview { ContentToPrevie
@Composable
internal fun SearchBarActiveNoneQueryPreview() = ElementThemedPreview {
ContentToPreview(
query = "",
initialQuery = "",
active = true,
)
}
@@ -230,7 +228,7 @@ internal fun SearchBarActiveNoneQueryPreview() = ElementThemedPreview {
@Composable
internal fun SearchBarActiveWithQueryPreview() = ElementThemedPreview {
ContentToPreview(
query = "search term",
initialQuery = "search term",
active = true,
)
}
@@ -239,7 +237,7 @@ internal fun SearchBarActiveWithQueryPreview() = ElementThemedPreview {
@Composable
internal fun SearchBarActiveWithQueryNoBackButtonPreview() = ElementThemedPreview {
ContentToPreview(
query = "search term",
initialQuery = "search term",
active = true,
showBackButton = false,
)
@@ -249,7 +247,7 @@ internal fun SearchBarActiveWithQueryNoBackButtonPreview() = ElementThemedPrevie
@Composable
internal fun SearchBarActiveWithNoResultsPreview() = ElementThemedPreview {
ContentToPreview(
query = "search term",
initialQuery = "search term",
active = true,
resultState = SearchBarResultState.NoResultsFound<String>(),
)
@@ -259,7 +257,7 @@ internal fun SearchBarActiveWithNoResultsPreview() = ElementThemedPreview {
@Composable
internal fun SearchBarActiveWithContentPreview() = ElementThemedPreview {
ContentToPreview(
query = "search term",
initialQuery = "search term",
active = true,
resultState = SearchBarResultState.Results("result!"),
contentPrefix = {
@@ -292,7 +290,7 @@ internal fun SearchBarActiveWithContentPreview() = ElementThemedPreview {
@Composable
@ExcludeFromCoverage
private fun ContentToPreview(
query: String = "",
initialQuery: String = "",
active: Boolean = false,
showBackButton: Boolean = true,
resultState: SearchBarResultState<String> = SearchBarResultState.Initial(),
@@ -300,13 +298,13 @@ private fun ContentToPreview(
contentSuffix: @Composable ColumnScope.() -> Unit = {},
resultHandler: @Composable ColumnScope.(String) -> Unit = {},
) {
val queryState = rememberTextFieldState(initialText = initialQuery)
SearchBar(
modifier = Modifier.heightIn(max = 200.dp),
query = query,
queryState = queryState,
active = active,
resultState = resultState,
showBackButton = showBackButton,
onQueryChange = {},
onActiveChange = {},
placeHolderTitle = "Search for things",
contentPrefix = contentPrefix,

View File

@@ -22,8 +22,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -34,7 +36,6 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@@ -50,8 +51,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
*/
@Composable
fun SearchField(
value: String,
onValueChange: (String) -> Unit,
state: TextFieldState,
modifier: Modifier = Modifier,
placeholder: String? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
@@ -59,67 +59,28 @@ fun SearchField(
val focusManager = LocalFocusManager.current
val isFocused by interactionSource.collectIsFocusedAsState()
BasicTextField(
value = value,
onValueChange = onValueChange,
state = state,
modifier = modifier,
textStyle = textFieldStyle(),
singleLine = true,
lineLimits = TextFieldLineLimits.SingleLine,
interactionSource = interactionSource,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Search,
),
keyboardActions = KeyboardActions(
onSearch = {
focusManager.clearFocus()
}
),
onKeyboardAction = {
focusManager.clearFocus()
},
cursorBrush = SolidColor(ElementTheme.colors.textActionAccent),
) { innerTextField ->
DecorationBox(
isFocused = isFocused,
placeholder = placeholder,
isTextEmpty = value.isEmpty(),
innerTextField = innerTextField,
onClear = { onValueChange("") },
)
}
}
@Composable
fun SearchField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
placeholder: String? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val focusManager = LocalFocusManager.current
val isFocused by interactionSource.collectIsFocusedAsState()
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
textStyle = textFieldStyle(),
singleLine = true,
interactionSource = interactionSource,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Search,
),
keyboardActions = KeyboardActions(
onSearch = {
focusManager.clearFocus()
}
),
cursorBrush = SolidColor(ElementTheme.colors.textActionAccent),
) { innerTextField ->
DecorationBox(
isFocused = isFocused,
placeholder = placeholder,
isTextEmpty = value.text.isEmpty(),
innerTextField = innerTextField,
onClear = { TextFieldValue() }
)
}
decorator = { innerTextField ->
DecorationBox(
isFocused = isFocused,
placeholder = placeholder,
isTextEmpty = state.text.isEmpty(),
innerTextField = innerTextField,
onClear = { state.clearText() },
)
}
)
}
@Composable
@@ -211,14 +172,12 @@ private fun ContentToPreview() {
verticalArrangement = spacedBy(8.dp)
) {
SearchField(
onValueChange = {},
placeholder = "Search",
value = "",
state = TextFieldState(""),
)
SearchField(
onValueChange = {},
placeholder = "Search",
value = "Search term",
state = TextFieldState("Search term"),
)
}
}

View File

@@ -16,5 +16,4 @@ sealed interface RoomSelectEvents {
// TODO remove to restore multi-selection
data object RemoveSelectedRoom : RoomSelectEvents
data object ToggleSearchActive : RoomSelectEvents
data class UpdateQuery(val query: String) : RoomSelectEvents
}

View File

@@ -8,6 +8,7 @@
package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
@@ -42,12 +43,13 @@ class RoomSelectPresenter(
@Composable
override fun present(): RoomSelectState {
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
var searchQuery by remember { mutableStateOf("") }
val queryState = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val dataSource = remember { dataSourceFactory.create(coroutineScope) }
val searchQuery = queryState.text.toString()
LaunchedEffect(searchQuery) {
dataSource.setSearchQuery(searchQuery)
}
@@ -77,7 +79,6 @@ class RoomSelectPresenter(
// }
}
RoomSelectEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf()
is RoomSelectEvents.UpdateQuery -> searchQuery = event.query
RoomSelectEvents.ToggleSearchActive -> isSearchActive = !isSearchActive
}
}
@@ -85,7 +86,7 @@ class RoomSelectPresenter(
return RoomSelectState(
mode = mode,
resultState = searchResults,
query = searchQuery,
searchQuery = queryState,
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
eventSink = ::handleEvent,

View File

@@ -8,6 +8,7 @@
package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
@@ -16,7 +17,7 @@ import kotlinx.collections.immutable.ImmutableList
data class RoomSelectState(
val mode: RoomSelectMode,
val resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>>,
val query: String,
val searchQuery: TextFieldState,
val isSearchActive: Boolean,
val selectedRooms: ImmutableList<SelectRoomInfo>,
val eventSink: (RoomSelectEvents) -> Unit

View File

@@ -8,6 +8,7 @@
package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomAlias
@@ -22,16 +23,16 @@ open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> {
override val values: Sequence<RoomSelectState>
get() = sequenceOf(
aRoomSelectState(),
aRoomSelectState(query = "Test", isSearchActive = true),
aRoomSelectState(searchQuery = "Test", isSearchActive = true),
aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())),
aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
query = "Test",
searchQuery = "Test",
isSearchActive = true,
),
aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
query = "Test",
searchQuery = "Test",
isSearchActive = true,
selectedRooms = aRoomSelectRoomList().subList(0, 1),
),
@@ -45,13 +46,13 @@ open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> {
private fun aRoomSelectState(
mode: RoomSelectMode = RoomSelectMode.Forward,
resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial(),
query: String = "",
searchQuery: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
) = RoomSelectState(
mode = mode,
resultState = resultState,
query = query,
searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
eventSink = {}

View File

@@ -132,8 +132,7 @@ fun RoomSelectView(
SearchBar(
modifier = Modifier.fillMaxWidth(),
placeHolderTitle = stringResource(CommonStrings.action_search),
query = state.query,
onQueryChange = { state.eventSink(RoomSelectEvents.UpdateQuery(it)) },
queryState = state.searchQuery,
active = state.isSearchActive,
onActiveChange = { state.eventSink(RoomSelectEvents.ToggleSearchActive) },
resultState = state.resultState,

View File

@@ -8,6 +8,7 @@
package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
@@ -78,13 +79,13 @@ class RoomSelectPresenterTest {
assertThat(result).isEqualTo(listOf(expectedRoomInfo))
initialState.eventSink(RoomSelectEvents.ToggleSearchActive)
skipItems(1)
initialState.eventSink(RoomSelectEvents.UpdateQuery("string not contained"))
initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained")
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
RoomListFilter.NormalizedMatchRoomName("string not contained")
)
assertThat(awaitItem().query).isEqualTo("string not contained")
assertThat(awaitItem().searchQuery.text.toString()).isEqualTo("string not contained")
roomListService.postAllRooms(
emptyList()
)