Merge pull request #223 from vector-im/feature/mna/select-members-ui

[Create room] Select members before creating a room (UI for selection)
This commit is contained in:
Florian Renaud
2023-03-23 14:46:57 +01:00
committed by GitHub
48 changed files with 1392 additions and 251 deletions

1
changelog.d/108.wip Normal file
View File

@@ -0,0 +1 @@
[Create and join rooms] Select members before creating a room (UI for selection)

View File

@@ -38,9 +38,8 @@ anvil {
}
dependencies {
anvil(projects.anvilcodegen)
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
@@ -48,6 +47,7 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings)
implementation(projects.features.selectusers.api)
api(projects.features.createroom.api)
testImplementation(libs.test.junit)
@@ -56,6 +56,7 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.selectusers.impl)
androidTestImplementation(libs.test.junitext)

View File

@@ -24,9 +24,11 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
import io.element.android.features.createroom.impl.root.CreateRoomRootNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@@ -50,11 +52,22 @@ class CreateRoomFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable {
@Parcelize
object Root : NavTarget
@Parcelize
object NewRoom : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> createNode<CreateRoomRootNode>(buildContext)
NavTarget.Root -> {
val callback = object : CreateRoomRootNode.Callback {
override fun onCreateNewRoom() {
backstack.push(NavTarget.NewRoom)
}
}
createNode<CreateRoomRootNode>(buildContext, plugins = listOf(callback))
}
NavTarget.NewRoom -> createNode<AddPeopleNode>(buildContext)
}
}

View File

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

View File

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.createroom.impl.addpeople
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class AddPeopleNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: AddPeoplePresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
AddPeopleView(
state = state,
modifier = modifier,
onBackPressed = { navigateUp() },
onNextPressed = { },
)
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.createroom.impl.addpeople
import androidx.compose.runtime.Composable
import io.element.android.features.selectusers.api.SelectUsersPresenter
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.selectusers.api.SelectionMode
import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject
class AddPeoplePresenter @Inject constructor(
private val selectUsersPresenterFactory: SelectUsersPresenter.Factory,
) : Presenter<AddPeopleState> {
private val selectUsersPresenter by lazy {
selectUsersPresenterFactory.create(SelectUsersPresenterArgs(SelectionMode.Multiple))
}
@Composable
override fun present(): AddPeopleState {
val selectUsersState = selectUsersPresenter.present()
fun handleEvents(event: AddPeopleEvents) {
// do nothing for now
}
return AddPeopleState(
selectUsersState = selectUsersState,
eventSink = ::handleEvents,
)
}
}

View File

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

View File

@@ -0,0 +1,47 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.createroom.impl.addpeople
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.selectusers.api.SelectionMode
import io.element.android.features.selectusers.api.aListOfSelectedUsers
import io.element.android.features.selectusers.api.aSelectUsersState
open class AddPeopleStateProvider : PreviewParameterProvider<AddPeopleState> {
override val values: Sequence<AddPeopleState>
get() = sequenceOf(
aAddPeopleState(),
aAddPeopleState().copy(
selectUsersState = aSelectUsersState().copy(
selectedUsers = aListOfSelectedUsers(),
selectionMode = SelectionMode.Multiple,
)
),
aAddPeopleState().copy(
selectUsersState = aSelectUsersState().copy(
selectedUsers = aListOfSelectedUsers(),
isSearchActive = true,
selectionMode = SelectionMode.Multiple,
)
)
)
}
fun aAddPeopleState() = AddPeopleState(
selectUsersState = aSelectUsersState(),
eventSink = {}
)

View File

@@ -0,0 +1,122 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.createroom.impl.addpeople
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.selectusers.api.SelectUsersView
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddPeopleView(
state: AddPeopleState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
onNextPressed: () -> Unit = {},
) {
val eventSink = state.eventSink
Scaffold(
topBar = {
if (!state.selectUsersState.isSearchActive) {
AddPeopleViewTopBar(
hasSelectedUsers = state.selectUsersState.selectedUsers.isNotEmpty(),
onBackPressed = onBackPressed,
onNextPressed = onNextPressed,
)
}
}
) { padding ->
Column(
modifier = modifier
.fillMaxSize()
.padding(padding),
) {
SelectUsersView(
modifier = Modifier.fillMaxWidth(),
state = state.selectUsersState,
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddPeopleViewTopBar(
hasSelectedUsers: Boolean,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
onNextPressed: () -> Unit = {},
) {
CenterAlignedTopAppBar(
modifier = modifier,
title = {
Text(
text = stringResource(id = StringR.string.add_people),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
},
navigationIcon = { BackButton(onClick = onBackPressed) },
actions = {
TextButton(
modifier = Modifier.padding(horizontal = 8.dp),
onClick = onNextPressed,
) {
val textActionResId = if (hasSelectedUsers) StringR.string.action_next else StringR.string.action_skip
Text(
text = stringResource(id = textActionResId),
fontSize = 16.sp,
)
}
}
)
}
@Preview
@Composable
internal fun AddPeopleViewLightPreview(@PreviewParameter(AddPeopleStateProvider::class) state: AddPeopleState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun AddPeopleViewDarkPreview(@PreviewParameter(AddPeopleStateProvider::class) state: AddPeopleState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: AddPeopleState) {
AddPeopleView(state = state)
}

View File

@@ -19,9 +19,6 @@ package io.element.android.features.createroom.impl.root
import io.element.android.libraries.matrix.ui.model.MatrixUser
sealed interface CreateRoomRootEvents {
data class UpdateSearchQuery(val query: String) : CreateRoomRootEvents
data class StartDM(val matrixUser: MatrixUser) : CreateRoomRootEvents
object CreateRoom : CreateRoomRootEvents
object InvitePeople : CreateRoomRootEvents
data class OnSearchActiveChanged(val active: Boolean) : CreateRoomRootEvents
}

View File

@@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
@@ -35,6 +36,14 @@ class CreateRoomRootNode @AssistedInject constructor(
private val presenter: CreateRoomRootPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onCreateNewRoom()
}
private fun onCreateNewRoom() {
plugins<Callback>().forEach { it.onCreateNewRoom() }
}
sealed interface NavTarget : Parcelable {
@Parcelize
object Root : NavTarget
@@ -47,6 +56,7 @@ class CreateRoomRootNode @AssistedInject constructor(
state = state,
modifier = modifier,
onClosePressed = this::navigateUp,
onNewRoomClicked = this::onCreateNewRoom,
)
}
}

View File

@@ -17,75 +17,39 @@
package io.element.android.features.createroom.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.selectusers.api.SelectUsersPresenter
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.selectusers.api.SelectionMode
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import timber.log.Timber
import javax.inject.Inject
class CreateRoomRootPresenter @Inject constructor() : Presenter<CreateRoomRootState> {
class CreateRoomRootPresenter @Inject constructor(
private val presenterFactory: SelectUsersPresenter.Factory,
) : Presenter<CreateRoomRootState> {
private val presenter by lazy {
presenterFactory.create(SelectUsersPresenterArgs(SelectionMode.Single))
}
@Composable
override fun present(): CreateRoomRootState {
var isSearchActive by rememberSaveable { mutableStateOf(false) }
var searchQuery by rememberSaveable { mutableStateOf("") }
val searchResults: MutableState<ImmutableList<MatrixUser>> = remember {
mutableStateOf(persistentListOf())
}
val selectUsersState = presenter.present()
fun handleEvents(event: CreateRoomRootEvents) {
when (event) {
is CreateRoomRootEvents.OnSearchActiveChanged -> isSearchActive = event.active
is CreateRoomRootEvents.UpdateSearchQuery -> searchQuery = event.query
is CreateRoomRootEvents.StartDM -> handleStartDM(event.matrixUser)
CreateRoomRootEvents.CreateRoom -> Unit // Todo Handle create room action
CreateRoomRootEvents.InvitePeople -> Unit // Todo Handle invite people action
}
}
LaunchedEffect(searchQuery) {
// Clear the search results before performing the search, manually add a fake result with the matrixId, if any
searchResults.value = if (MatrixPatterns.isUserId(searchQuery)) {
persistentListOf(MatrixUser(UserId(searchQuery)))
} else {
persistentListOf()
}
// Perform the search asynchronously
if (searchQuery.isNotEmpty()) {
searchResults.value = performSearch(searchQuery)
}
}
return CreateRoomRootState(
selectUsersState = selectUsersState,
eventSink = ::handleEvents,
isSearchActive = isSearchActive,
searchQuery = searchQuery,
searchResults = searchResults.value,
)
}
private fun performSearch(query: String): ImmutableList<MatrixUser> {
val isMatrixId = MatrixPatterns.isUserId(query)
val results = mutableListOf<MatrixUser>()// TODO trigger /search request
if (isMatrixId && results.none { it.id.value == query }) {
val getProfileResult: MatrixUser? = null // TODO trigger /profile request
val profile = getProfileResult ?: MatrixUser(UserId(query))
results.add(0, profile)
}
return results.toImmutableList()
}
private fun handleStartDM(matrixUser: MatrixUser) {
Timber.d("handleStartDM: $matrixUser") // Todo handle start DM action
}

View File

@@ -16,13 +16,9 @@
package io.element.android.features.createroom.impl.root
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import io.element.android.features.selectusers.api.SelectUsersState
// Do not use default value, so no member get forgotten in the presenters.
data class CreateRoomRootState(
val selectUsersState: SelectUsersState,
val eventSink: (CreateRoomRootEvents) -> Unit,
val isSearchActive: Boolean,
val searchQuery: String,
val searchResults: ImmutableList<MatrixUser>,
)

View File

@@ -17,43 +17,16 @@
package io.element.android.features.createroom.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.persistentListOf
import io.element.android.features.selectusers.api.aSelectUsersState
open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRootState> {
override val values: Sequence<CreateRoomRootState>
get() = sequenceOf(
aCreateRoomRootState(),
aCreateRoomRootState().copy(isSearchActive = true),
aCreateRoomRootState().copy(isSearchActive = true, searchQuery = "someone"),
aCreateRoomRootState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
searchResults = persistentListOf(
MatrixUser(id = UserId("@someone:matrix.org")),
MatrixUser(id = UserId("@someone:matrix.org"), username = "someone"),
MatrixUser(
id = UserId("@someone_with_a_very_long_matrix_identifier:a_very_long_domain.org"),
username = "hey, I am someone with a very long display name"
),
MatrixUser(id = UserId("@someone_2:matrix.org"), username = "someone 2"),
MatrixUser(id = UserId("@someone_3:matrix.org"), username = "someone 3"),
MatrixUser(id = UserId("@someone_4:matrix.org"), username = "someone 4"),
MatrixUser(id = UserId("@someone_5:matrix.org"), username = "someone 5"),
MatrixUser(id = UserId("@someone_6:matrix.org"), username = "someone 6"),
MatrixUser(id = UserId("@someone_7:matrix.org"), username = "someone 7"),
MatrixUser(id = UserId("@someone_8:matrix.org"), username = "someone 8"),
MatrixUser(id = UserId("@someone_9:matrix.org"), username = "someone 9"),
MatrixUser(id = UserId("@someone_10:matrix.org"), username = "someone 10"),
)
),
)
}
fun aCreateRoomRootState() = CreateRoomRootState(
eventSink = {},
isSearchActive = false,
searchQuery = "",
searchResults = persistentListOf(),
selectUsersState = aSelectUsersState(),
)

View File

@@ -24,38 +24,27 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.features.selectusers.api.SelectUsersView
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import io.element.android.libraries.designsystem.R as DrawableR
import io.element.android.libraries.ui.strings.R as StringR
@@ -65,11 +54,12 @@ fun CreateRoomRootView(
state: CreateRoomRootState,
modifier: Modifier = Modifier,
onClosePressed: () -> Unit = {},
onNewRoomClicked: () -> Unit = {},
) {
Scaffold(
modifier = modifier.fillMaxWidth(),
topBar = {
if (!state.isSearchActive) {
if (!state.selectUsersState.isSearchActive) {
CreateRoomRootViewTopBar(onClosePressed = onClosePressed)
}
}
@@ -78,20 +68,15 @@ fun CreateRoomRootView(
modifier = Modifier.padding(paddingValues),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
CreateRoomSearchBar(
SelectUsersView(
modifier = Modifier.fillMaxWidth(),
query = state.searchQuery,
placeHolderTitle = stringResource(StringR.string.search_for_someone),
results = state.searchResults,
active = state.isSearchActive,
onActiveChanged = { state.eventSink(CreateRoomRootEvents.OnSearchActiveChanged(it)) },
onTextChanged = { state.eventSink(CreateRoomRootEvents.UpdateSearchQuery(it)) },
onResultSelected = { state.eventSink(CreateRoomRootEvents.StartDM(it)) }
state = state.selectUsersState,
onUserSelected = { state.eventSink.invoke(CreateRoomRootEvents.StartDM(it)) },
)
if (!state.isSearchActive) {
if (!state.selectUsersState.isSearchActive) {
CreateRoomActionButtonsList(
onNewRoomClicked = { state.eventSink(CreateRoomRootEvents.CreateRoom) },
onNewRoomClicked = onNewRoomClicked,
onInvitePeopleClicked = { state.eventSink(CreateRoomRootEvents.InvitePeople) },
)
}
@@ -122,77 +107,6 @@ fun CreateRoomRootViewTopBar(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateRoomSearchBar(
query: String,
placeHolderTitle: String,
results: ImmutableList<MatrixUser>,
active: Boolean,
modifier: Modifier = Modifier,
onActiveChanged: (Boolean) -> Unit = {},
onTextChanged: (String) -> Unit = {},
onResultSelected: (MatrixUser) -> Unit = {},
) {
val focusManager = LocalFocusManager.current
if (!active) {
onTextChanged("")
focusManager.clearFocus()
}
SearchBar(
query = query,
onQueryChange = onTextChanged,
onSearch = { focusManager.clearFocus() },
active = active,
onActiveChange = onActiveChanged,
modifier = modifier
.padding(horizontal = if (!active) 16.dp else 0.dp),
placeholder = {
Text(
text = placeHolderTitle,
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
)
},
leadingIcon = if (active) {
{ BackButton(onClick = { onActiveChanged(false) }) }
} else {
null
},
trailingIcon = when {
active && query.isNotEmpty() -> {
{
IconButton(onClick = { onTextChanged("") }) {
Icon(Icons.Default.Close, stringResource(StringR.string.a11y_clear))
}
}
}
!active -> {
{
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(StringR.string.search),
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
)
}
}
else -> null
},
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
content = {
LazyColumn {
items(results) {
CreateRoomSearchResultItem(
matrixUser = it,
onClick = { onResultSelected(it) }
)
}
}
},
)
}
@Composable
fun CreateRoomActionButtonsList(
modifier: Modifier = Modifier,
@@ -213,20 +127,6 @@ fun CreateRoomActionButtonsList(
}
}
@Composable
fun CreateRoomSearchResultItem(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
MatrixUserRow(
modifier = modifier,
matrixUser = matrixUser,
avatarSize = AvatarSize.Custom(36.dp),
onClick = onClick,
)
}
@Composable
fun CreateRoomActionButton(
@DrawableRes iconRes: Int,

View File

@@ -0,0 +1,53 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.createroom.impl.addpeople
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.selectusers.impl.DefaultSelectUsersPresenter
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
class AddPeoplePresenterTests {
private lateinit var presenter: AddPeoplePresenter
@Before
fun setup() {
val selectUsersFactory = object : DefaultSelectUsersPresenter.DefaultSelectUsersFactory {
override fun create(args: SelectUsersPresenterArgs) = DefaultSelectUsersPresenter(args)
}
presenter = AddPeoplePresenter(selectUsersFactory)
}
@Test
fun `present - initial state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState)
}
}
}

View File

@@ -22,17 +22,29 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.selectusers.impl.DefaultSelectUsersPresenter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
class CreateRoomRootPresenterTests {
private lateinit var presenter: CreateRoomRootPresenter
@Before
fun setup() {
val selectUsersPresenter = object : DefaultSelectUsersPresenter.DefaultSelectUsersFactory {
override fun create(args: SelectUsersPresenterArgs) = DefaultSelectUsersPresenter(args)
}
presenter = CreateRoomRootPresenter(selectUsersPresenter)
}
@Test
fun `present - initial state`() = runTest {
val presenter = CreateRoomRootPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -43,45 +55,16 @@ class CreateRoomRootPresenterTests {
@Test
fun `present - trigger action buttons`() = runTest {
val presenter = CreateRoomRootPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(CreateRoomRootEvents.CreateRoom) // Not implemented yet
initialState.eventSink(CreateRoomRootEvents.InvitePeople) // Not implemented yet
}
}
@Test
fun `present - update search query`() = runTest {
val presenter = CreateRoomRootPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(CreateRoomRootEvents.OnSearchActiveChanged(true))
assertThat(awaitItem().isSearchActive).isTrue()
val matrixIdQuery = "@name:matrix.org"
initialState.eventSink(CreateRoomRootEvents.UpdateSearchQuery(matrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(matrixIdQuery)
assertThat(awaitItem().searchResults).containsExactly(MatrixUser(UserId(matrixIdQuery)))
val notMatrixIdQuery = "name"
initialState.eventSink(CreateRoomRootEvents.UpdateSearchQuery(notMatrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(notMatrixIdQuery)
assertThat(awaitItem().searchResults).isEmpty()
initialState.eventSink(CreateRoomRootEvents.OnSearchActiveChanged(false))
assertThat(awaitItem().isSearchActive).isFalse()
}
}
@Test
fun `present - trigger start DM action`() = runTest {
val presenter = CreateRoomRootPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.selectusers.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.selectusers.api
import io.element.android.libraries.matrix.ui.model.MatrixUser
sealed interface SelectUsersEvents {
data class UpdateSearchQuery(val query: String) : SelectUsersEvents
data class AddToSelection(val matrixUser: MatrixUser) : SelectUsersEvents
data class RemoveFromSelection(val matrixUser: MatrixUser) : SelectUsersEvents
data class OnSearchActiveChanged(val active: Boolean) : SelectUsersEvents
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.selectusers.api
import io.element.android.libraries.architecture.Presenter
interface SelectUsersPresenter : Presenter<SelectUsersState> {
interface Factory {
fun create(args: SelectUsersPresenterArgs): SelectUsersPresenter
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.selectusers.api
data class SelectUsersPresenterArgs(
val selectionMode: SelectionMode,
)
enum class SelectionMode {
Single,
Multiple,
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.selectusers.api
import androidx.compose.foundation.lazy.LazyListState
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class SelectUsersState(
val searchQuery: String,
val searchResults: ImmutableList<MatrixUser>,
val selectedUsers: ImmutableList<MatrixUser>,
val selectedUsersListState: LazyListState,
val isSearchActive: Boolean,
val selectionMode: SelectionMode,
val eventSink: (SelectUsersEvents) -> Unit,
) {
val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.selectusers.api
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.persistentListOf
open class SelectUsersStateProvider : PreviewParameterProvider<SelectUsersState> {
override val values: Sequence<SelectUsersState>
get() = sequenceOf(
aSelectUsersState(),
aSelectUsersState().copy(
isSearchActive = false,
selectedUsers = aListOfSelectedUsers(),
selectionMode = SelectionMode.Multiple,
),
aSelectUsersState().copy(isSearchActive = true),
aSelectUsersState().copy(isSearchActive = true, searchQuery = "someone"),
aSelectUsersState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple),
aSelectUsersState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectedUsers = aListOfSelectedUsers(),
searchResults = aListOfResults(),
),
aSelectUsersState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectionMode = SelectionMode.Multiple,
selectedUsers = aListOfSelectedUsers(),
searchResults = aListOfResults(),
)
)
}
fun aSelectUsersState() = SelectUsersState(
isSearchActive = false,
searchQuery = "",
searchResults = persistentListOf(),
selectedUsers = persistentListOf(),
selectedUsersListState = LazyListState(
firstVisibleItemIndex = 0,
firstVisibleItemScrollOffset = 0,
),
selectionMode = SelectionMode.Single,
eventSink = {}
)
fun aListOfSelectedUsers() = persistentListOf(
MatrixUser(id = UserId("@someone:matrix.org")),
MatrixUser(id = UserId("@other:matrix.org"), username = "other"),
)
fun aListOfResults() = persistentListOf(
MatrixUser(id = UserId("@someone:matrix.org")),
MatrixUser(id = UserId("@other:matrix.org"), username = "other"),
MatrixUser(
id = UserId("@someone_with_a_very_long_matrix_identifier:a_very_long_domain.org"),
username = "hey, I am someone with a very long display name"
),
MatrixUser(id = UserId("@someone_2:matrix.org"), username = "someone 2"),
MatrixUser(id = UserId("@someone_3:matrix.org"), username = "someone 3"),
MatrixUser(id = UserId("@someone_4:matrix.org"), username = "someone 4"),
MatrixUser(id = UserId("@someone_5:matrix.org"), username = "someone 5"),
MatrixUser(id = UserId("@someone_6:matrix.org"), username = "someone 6"),
MatrixUser(id = UserId("@someone_7:matrix.org"), username = "someone 7"),
MatrixUser(id = UserId("@someone_8:matrix.org"), username = "someone 8"),
MatrixUser(id = UserId("@someone_9:matrix.org"), username = "someone 9"),
MatrixUser(id = UserId("@someone_10:matrix.org"), username = "someone 10"),
)

View File

@@ -0,0 +1,311 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.selectusers.api
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.model.MatrixUser
import io.element.android.libraries.matrix.ui.model.getBestName
import kotlinx.collections.immutable.ImmutableList
import io.element.android.libraries.ui.strings.R as StringR
@Composable
fun SelectUsersView(
state: SelectUsersState,
modifier: Modifier = Modifier,
onUserSelected: (MatrixUser) -> Unit = {},
onUserDeselected: (MatrixUser) -> Unit = {},
) {
Column(
modifier = modifier,
) {
SearchUserBar(
modifier = Modifier.fillMaxWidth(),
query = state.searchQuery,
results = state.searchResults,
selectedUsers = state.selectedUsers,
selectedUsersListState = state.selectedUsersListState,
active = state.isSearchActive,
isMultiSelectionEnabled = state.isMultiSelectionEnabled,
onActiveChanged = { state.eventSink(SelectUsersEvents.OnSearchActiveChanged(it)) },
onTextChanged = { state.eventSink(SelectUsersEvents.UpdateSearchQuery(it)) },
onUserSelected = {
state.eventSink(SelectUsersEvents.AddToSelection(it))
onUserSelected(it)
},
onUserDeselected = {
state.eventSink(SelectUsersEvents.RemoveFromSelection(it))
onUserDeselected(it)
},
)
if (state.isMultiSelectionEnabled && !state.isSearchActive && state.selectedUsers.isNotEmpty()) {
SelectedUsersList(
listState = state.selectedUsersListState,
modifier = Modifier.padding(16.dp),
selectedUsers = state.selectedUsers,
onUserRemoved = {
state.eventSink(SelectUsersEvents.RemoveFromSelection(it))
onUserDeselected(it)
},
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchUserBar(
query: String,
results: ImmutableList<MatrixUser>,
selectedUsers: ImmutableList<MatrixUser>,
selectedUsersListState: LazyListState,
active: Boolean,
isMultiSelectionEnabled: Boolean,
modifier: Modifier = Modifier,
placeHolderTitle: String = stringResource(StringR.string.search_for_someone),
onActiveChanged: (Boolean) -> Unit = {},
onTextChanged: (String) -> Unit = {},
onUserSelected: (MatrixUser) -> Unit = {},
onUserDeselected: (MatrixUser) -> Unit = {},
) {
val focusManager = LocalFocusManager.current
if (!active) {
onTextChanged("")
focusManager.clearFocus()
}
SearchBar(
query = query,
onQueryChange = onTextChanged,
onSearch = { focusManager.clearFocus() },
active = active,
onActiveChange = onActiveChanged,
modifier = modifier
.padding(horizontal = if (!active) 16.dp else 0.dp),
placeholder = {
Text(
text = placeHolderTitle,
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
)
},
leadingIcon = if (active) {
{ BackButton(onClick = { onActiveChanged(false) }) }
} else {
null
},
trailingIcon = when {
active && query.isNotEmpty() -> {
{
IconButton(onClick = { onTextChanged("") }) {
Icon(Icons.Default.Close, stringResource(StringR.string.a11y_clear))
}
}
}
!active -> {
{
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(StringR.string.search),
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
)
}
}
else -> null
},
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
content = {
if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) {
SelectedUsersList(
listState = selectedUsersListState,
modifier = Modifier.padding(16.dp),
selectedUsers = selectedUsers,
onUserRemoved = onUserDeselected,
)
}
LazyColumn {
if (isMultiSelectionEnabled) {
items(results) { matrixUser ->
SearchMultipleUsersResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
isUserSelected = selectedUsers.find { it.id == matrixUser.id } != null,
onCheckedChange = { checked ->
if (checked) {
onUserSelected(matrixUser)
} else {
onUserDeselected(matrixUser)
}
}
)
}
} else {
items(results) { matrixUser ->
SearchSingleUserResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
onClick = { onUserSelected(matrixUser) }
)
}
}
}
},
)
}
@Composable
fun SearchMultipleUsersResultItem(
matrixUser: MatrixUser,
isUserSelected: Boolean,
modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit,
) {
CheckableMatrixUserRow(
checked = isUserSelected,
modifier = modifier,
matrixUser = matrixUser,
avatarSize = AvatarSize.Custom(36.dp),
onCheckedChange = onCheckedChange,
)
}
@Composable
fun SearchSingleUserResultItem(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
MatrixUserRow(
modifier = modifier.clickable(onClick = onClick),
matrixUser = matrixUser,
avatarSize = AvatarSize.Custom(36.dp),
)
}
@Composable
fun SelectedUsersList(
listState: LazyListState,
selectedUsers: ImmutableList<MatrixUser>,
modifier: Modifier = Modifier,
onUserRemoved: (MatrixUser) -> Unit = {},
) {
LazyRow(
state = listState,
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
items(selectedUsers.toList()) { matrixUser ->
SelectedUser(
matrixUser = matrixUser,
onUserRemoved = onUserRemoved,
)
}
}
}
@Composable
fun SelectedUser(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
onUserRemoved: (MatrixUser) -> Unit,
) {
Box(modifier = modifier.width(56.dp)) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Avatar(matrixUser.avatarData.copy(size = AvatarSize.Custom(56.dp)))
Text(
text = matrixUser.getBestName(),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyLarge,
)
}
IconButton(
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.size(20.dp)
.align(Alignment.TopEnd),
onClick = { onUserRemoved(matrixUser) }
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(id = StringR.string.action_remove),
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
@Preview
@Composable
internal fun SelectUsersViewLightPreview(@PreviewParameter(SelectUsersStateProvider::class) state: SelectUsersState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun SelectUsersViewDarkPreview(@PreviewParameter(SelectUsersStateProvider::class) state: SelectUsersState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: SelectUsersState) {
SelectUsersView(state = state)
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
}
android {
namespace = "io.element.android.features.selectusers.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
api(projects.features.selectusers.api)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.coroutines.core)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.mockk)
testImplementation(projects.libraries.matrix.test)
androidTestImplementation(libs.test.junitext)
}

View File

@@ -0,0 +1,123 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.selectusers.impl
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.selectusers.api.SelectUsersEvents
import io.element.android.features.selectusers.api.SelectUsersPresenter
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.selectusers.api.SelectUsersState
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class DefaultSelectUsersPresenter @AssistedInject constructor(
@Assisted val args: SelectUsersPresenterArgs,
) : SelectUsersPresenter {
@AssistedFactory
@ContributesBinding(SessionScope::class)
interface DefaultSelectUsersFactory : SelectUsersPresenter.Factory {
override fun create(args: SelectUsersPresenterArgs): DefaultSelectUsersPresenter
}
@Composable
override fun present(): SelectUsersState {
val localCoroutineScope = rememberCoroutineScope()
var isSearchActive by rememberSaveable { mutableStateOf(false) }
val selectedUsers: MutableState<ImmutableList<MatrixUser>> = remember {
mutableStateOf(persistentListOf())
}
val selectedUsersListState = rememberLazyListState()
var searchQuery by rememberSaveable { mutableStateOf("") }
val searchResults: MutableState<ImmutableList<MatrixUser>> = remember {
mutableStateOf(persistentListOf())
}
fun handleEvents(event: SelectUsersEvents) {
when (event) {
is SelectUsersEvents.OnSearchActiveChanged -> isSearchActive = event.active
is SelectUsersEvents.UpdateSearchQuery -> searchQuery = event.query
is SelectUsersEvents.AddToSelection -> {
if (event.matrixUser !in selectedUsers.value) {
selectedUsers.value = selectedUsers.value.plus(event.matrixUser).toImmutableList()
}
localCoroutineScope.scrollToFirstSelectedUser(selectedUsersListState)
}
is SelectUsersEvents.RemoveFromSelection -> selectedUsers.value = selectedUsers.value.minus(event.matrixUser).toImmutableList()
}
}
LaunchedEffect(searchQuery) {
// Clear the search results before performing the search, manually add a fake result with the matrixId, if any
searchResults.value = if (MatrixPatterns.isUserId(searchQuery)) {
persistentListOf(MatrixUser(UserId(searchQuery)))
} else {
persistentListOf()
}
// Perform the search asynchronously
if (searchQuery.isNotEmpty()) {
searchResults.value = performSearch(searchQuery)
}
}
return SelectUsersState(
searchQuery = searchQuery,
searchResults = searchResults.value,
selectedUsers = selectedUsers.value.reversed().toImmutableList(),
selectedUsersListState = selectedUsersListState,
isSearchActive = isSearchActive,
selectionMode = args.selectionMode,
eventSink = ::handleEvents,
)
}
private fun performSearch(query: String): ImmutableList<MatrixUser> {
val isMatrixId = MatrixPatterns.isUserId(query)
val results = mutableListOf<MatrixUser>()// TODO trigger /search request
if (isMatrixId && results.none { it.id.value == query }) {
val getProfileResult: MatrixUser? = null // TODO trigger /profile request
val profile = getProfileResult ?: MatrixUser(UserId(query))
results.add(0, profile)
}
return results.toImmutableList()
}
private fun CoroutineScope.scrollToFirstSelectedUser(listState: LazyListState) = launch {
listState.scrollToItem(index = 0)
}
}

View File

@@ -0,0 +1,131 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.selectusers.impl
import androidx.compose.foundation.lazy.LazyListState
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.selectusers.api.SelectUsersEvents
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.selectusers.api.SelectionMode
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.model.MatrixUser
import io.mockk.coJustRun
import io.mockk.mockkConstructor
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultSelectUsersPresenterTests {
@Test
fun `present - initial state for single selection`() = runTest {
val presenter = DefaultSelectUsersPresenter(SelectUsersPresenterArgs(SelectionMode.Single))
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.isMultiSelectionEnabled).isFalse()
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.selectedUsers).isEmpty()
assertThat(initialState.searchResults).isEmpty()
}
}
@Test
fun `present - initial state for multiple selection`() = runTest {
val presenter = DefaultSelectUsersPresenter(SelectUsersPresenterArgs(SelectionMode.Multiple))
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.isMultiSelectionEnabled).isTrue()
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.selectedUsers).isEmpty()
assertThat(initialState.searchResults).isEmpty()
}
}
@Test
fun `present - update search query`() = runTest {
val presenter = DefaultSelectUsersPresenter(SelectUsersPresenterArgs(SelectionMode.Single))
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SelectUsersEvents.OnSearchActiveChanged(true))
assertThat(awaitItem().isSearchActive).isTrue()
val matrixIdQuery = "@name:matrix.org"
initialState.eventSink(SelectUsersEvents.UpdateSearchQuery(matrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(matrixIdQuery)
assertThat(awaitItem().searchResults).containsExactly(MatrixUser(UserId(matrixIdQuery)))
val notMatrixIdQuery = "name"
initialState.eventSink(SelectUsersEvents.UpdateSearchQuery(notMatrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(notMatrixIdQuery)
assertThat(awaitItem().searchResults).isEmpty()
initialState.eventSink(SelectUsersEvents.OnSearchActiveChanged(false))
assertThat(awaitItem().isSearchActive).isFalse()
}
}
@Test
fun `present - select a user`() = runTest {
mockkConstructor(LazyListState::class)
coJustRun { anyConstructed<LazyListState>().scrollToItem(index = any()) }
val presenter = DefaultSelectUsersPresenter(SelectUsersPresenterArgs(SelectionMode.Single))
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val userA = aMatrixUser("userA", "A")
val userB = aMatrixUser("userB", "B")
val userABis = aMatrixUser("userA", "A")
val userC = aMatrixUser("userC", "C")
initialState.eventSink(SelectUsersEvents.AddToSelection(userA))
assertThat(awaitItem().selectedUsers).containsExactly(userA)
initialState.eventSink(SelectUsersEvents.AddToSelection(userB))
// the last added user should be presented first
assertThat(awaitItem().selectedUsers).containsExactly(userB, userA)
initialState.eventSink(SelectUsersEvents.AddToSelection(userABis))
initialState.eventSink(SelectUsersEvents.AddToSelection(userC))
// duplicated users should be ignored
assertThat(awaitItem().selectedUsers).containsExactly(userC, userB, userA)
initialState.eventSink(SelectUsersEvents.RemoveFromSelection(userB))
assertThat(awaitItem().selectedUsers).containsExactly(userC, userA)
initialState.eventSink(SelectUsersEvents.RemoveFromSelection(userA))
assertThat(awaitItem().selectedUsers).containsExactly(userC)
initialState.eventSink(SelectUsersEvents.RemoveFromSelection(userC))
assertThat(awaitItem().selectedUsers).isEmpty()
}
}
}

View File

@@ -0,0 +1,81 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.matrix.ui.model.MatrixUser
@Composable
fun CheckableMatrixUserRow(
checked: Boolean,
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
avatarSize: AvatarSize = matrixUser.avatarData.size,
onCheckedChange: (Boolean) -> Unit = {},
enabled: Boolean = true,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(role = Role.Checkbox) { onCheckedChange(!checked) },
verticalAlignment = Alignment.CenterVertically,
) {
MatrixUserRow(
modifier = Modifier.weight(1f),
matrixUser = matrixUser,
avatarSize = avatarSize,
)
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
)
}
}
@Preview
@Composable
internal fun CheckableMatrixUserRowLightPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) =
ElementPreviewLight { ContentToPreview(matrixUser) }
@Preview
@Composable
internal fun CheckableMatrixUserRowDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) =
ElementPreviewDark { ContentToPreview(matrixUser) }
@Composable
private fun ContentToPreview(matrixUser: MatrixUser) {
Column {
CheckableMatrixUserRow(checked = true, matrixUser)
CheckableMatrixUserRow(checked = false, matrixUser)
}
}

View File

@@ -32,9 +32,9 @@ open class MatrixUserProvider : PreviewParameterProvider<MatrixUser> {
)
}
fun aMatrixUser() = MatrixUser(
id = UserId("@id_of_alice:server.org"),
username = "Alice",
fun aMatrixUser(id: String = "@id_of_alice:server.org", userName: String = "Alice") = MatrixUser(
id = UserId(id),
username = userName,
avatarData = anAvatarData()
)

View File

@@ -16,7 +16,6 @@
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
@@ -46,11 +45,9 @@ fun MatrixUserRow(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
avatarSize: AvatarSize = matrixUser.avatarData.size,
onClick: () -> Unit = {},
) {
Row(
modifier = modifier
.clickable(onClick = onClick)
.fillMaxWidth()
.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp)
.height(IntrinsicSize.Min),

View File

@@ -80,6 +80,7 @@ fun DependencyHandlerScope.allFeaturesApi() {
implementation(project(":features:preferences:api"))
implementation(project(":features:createroom:api"))
implementation(project(":features:verifysession:api"))
implementation(project(":features:selectusers:api"))
}
fun DependencyHandlerScope.allFeaturesImpl() {
@@ -92,4 +93,5 @@ fun DependencyHandlerScope.allFeaturesImpl() {
implementation(project(":features:preferences:impl"))
implementation(project(":features:createroom:impl"))
implementation(project(":features:verifysession:impl"))
implementation(project(":features:selectusers:impl"))
}

View File

@@ -92,3 +92,5 @@ include(":features:createroom:api")
include(":features:createroom:impl")
include(":features:verifysession:api")
include(":features:verifysession:impl")
include(":features:selectusers:api")
include(":features:selectusers:impl")