diff --git a/changelog.d/108.wip b/changelog.d/108.wip new file mode 100644 index 0000000000..e483e7f6ea --- /dev/null +++ b/changelog.d/108.wip @@ -0,0 +1 @@ +[Create and join rooms] Select members before creating a room (UI for selection) diff --git a/features/createroom/impl/build.gradle.kts b/features/createroom/impl/build.gradle.kts index dc7eaded75..366cc1e0bd 100644 --- a/features/createroom/impl/build.gradle.kts +++ b/features/createroom/impl/build.gradle.kts @@ -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) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt index 618eda0166..13c3ff52be 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt @@ -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(buildContext) + NavTarget.Root -> { + val callback = object : CreateRoomRootNode.Callback { + override fun onCreateNewRoom() { + backstack.push(NavTarget.NewRoom) + } + } + createNode(buildContext, plugins = listOf(callback)) + } + NavTarget.NewRoom -> createNode(buildContext) } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleEvents.kt new file mode 100644 index 0000000000..5d246be501 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleEvents.kt @@ -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 diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt new file mode 100644 index 0000000000..5393075d18 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt @@ -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, + 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 = { }, + ) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenter.kt new file mode 100644 index 0000000000..51b2928862 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenter.kt @@ -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 { + + 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, + ) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleState.kt new file mode 100644 index 0000000000..8212d02cc4 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleState.kt @@ -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, +) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleStateProvider.kt new file mode 100644 index 0000000000..6f1679e252 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleStateProvider.kt @@ -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 { + override val values: Sequence + 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 = {} +) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt new file mode 100644 index 0000000000..ba2abb498d --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt @@ -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) +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt index ddf2003e83..5d2f0f684c 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt @@ -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 } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt index 16a965695f..dadc16efc4 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt @@ -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().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, ) } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt index 828f531211..2f3f3ded4a 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt @@ -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 { +class CreateRoomRootPresenter @Inject constructor( + private val presenterFactory: SelectUsersPresenter.Factory, +) : Presenter { + + 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> = 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 { - val isMatrixId = MatrixPatterns.isUserId(query) - val results = mutableListOf()// 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 } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt index be9757f122..a57d6aaaf6 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt @@ -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, ) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt index 5f050ab1cc..678f02476c 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt @@ -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 { override val values: Sequence 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(), ) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt index bab42c6964..b0791c5331 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt @@ -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, - 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, diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt new file mode 100644 index 0000000000..d9f40c1dbf --- /dev/null +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt @@ -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) + } + } +} diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt index c13d59f3a6..5dc9ec00e9 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt @@ -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 { diff --git a/features/selectusers/api/build.gradle.kts b/features/selectusers/api/build.gradle.kts new file mode 100644 index 0000000000..d46ed2fbf1 --- /dev/null +++ b/features/selectusers/api/build.gradle.kts @@ -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) +} diff --git a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersEvents.kt b/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersEvents.kt new file mode 100644 index 0000000000..e0ee6ddf68 --- /dev/null +++ b/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersEvents.kt @@ -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 +} diff --git a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersPresenter.kt b/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersPresenter.kt new file mode 100644 index 0000000000..be85455d09 --- /dev/null +++ b/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersPresenter.kt @@ -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 { + + interface Factory { + fun create(args: SelectUsersPresenterArgs): SelectUsersPresenter + } +} diff --git a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersPresenterArgs.kt b/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersPresenterArgs.kt new file mode 100644 index 0000000000..543e73b77e --- /dev/null +++ b/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersPresenterArgs.kt @@ -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, +} diff --git a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersState.kt b/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersState.kt new file mode 100644 index 0000000000..2a1a2c48e3 --- /dev/null +++ b/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersState.kt @@ -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, + val selectedUsers: ImmutableList, + val selectedUsersListState: LazyListState, + val isSearchActive: Boolean, + val selectionMode: SelectionMode, + val eventSink: (SelectUsersEvents) -> Unit, +) { + val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple +} diff --git a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersStateProvider.kt b/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersStateProvider.kt new file mode 100644 index 0000000000..93209a632c --- /dev/null +++ b/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersStateProvider.kt @@ -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 { + override val values: Sequence + 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"), +) diff --git a/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersView.kt b/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersView.kt new file mode 100644 index 0000000000..60d7785b69 --- /dev/null +++ b/features/selectusers/api/src/main/kotlin/io/element/android/features/selectusers/api/SelectUsersView.kt @@ -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, + selectedUsers: ImmutableList, + 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, + 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) +} diff --git a/features/selectusers/impl/build.gradle.kts b/features/selectusers/impl/build.gradle.kts new file mode 100644 index 0000000000..baac7d2d2f --- /dev/null +++ b/features/selectusers/impl/build.gradle.kts @@ -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) +} diff --git a/features/selectusers/impl/src/main/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenter.kt b/features/selectusers/impl/src/main/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenter.kt new file mode 100644 index 0000000000..e1135cd1a2 --- /dev/null +++ b/features/selectusers/impl/src/main/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenter.kt @@ -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> = remember { + mutableStateOf(persistentListOf()) + } + val selectedUsersListState = rememberLazyListState() + var searchQuery by rememberSaveable { mutableStateOf("") } + val searchResults: MutableState> = 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 { + val isMatrixId = MatrixPatterns.isUserId(query) + val results = mutableListOf()// 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) + } +} diff --git a/features/selectusers/impl/src/test/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenterTests.kt b/features/selectusers/impl/src/test/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenterTests.kt new file mode 100644 index 0000000000..f5b0b43d0e --- /dev/null +++ b/features/selectusers/impl/src/test/kotlin/io/element/android/features/selectusers/impl/DefaultSelectUsersPresenterTests.kt @@ -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().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() + } + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableMatrixUserRow.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableMatrixUserRow.kt new file mode 100644 index 0000000000..e61f02ebf6 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableMatrixUserRow.kt @@ -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) + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt index b19424c28b..b95b93d348 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt @@ -32,9 +32,9 @@ open class MatrixUserProvider : PreviewParameterProvider { ) } -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() ) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserRow.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserRow.kt index 035f2d3824..e4d2386c1c 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserRow.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserRow.kt @@ -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), diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index 52063dd08e..7ae1a0bd15 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -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")) } diff --git a/settings.gradle.kts b/settings.gradle.kts index 0fd8bc2938..04636090ec 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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") diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..525854695a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:55d774d5e61f8832162859ec9d22299efa5b0728cfc3308b13fb11df31c5130e +size 13790 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ad94119f4c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:489aa166bf6d0f283da497b2752e457f81620959d404b5b9c3d96c7e18e8b953 +size 28100 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..85c608618b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a8969c9a82f41bbdbdfb013412d039f3be4fcf450a0dd55f3217c64d51c8489 +size 23495 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8ea3902e3a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ed229820d7cbd6b550e98116ec3bf688c52957e2a57ab296bfc5abd212345b1 +size 13784 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2074516963 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d5f29443193cd21eeb721a4c86f9063466439dd5ad4830708e6fa587f95e5abd +size 27456 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2139d8a6c5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa79c54d2431dc0c8399a2bcb8e03d73749e9d54f505ac893e2376fded0e7dfe +size 22599 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png deleted file mode 100644 index f13a65f8e2..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:89ea65099fb4981bbeb24e49afb7400b0e8da79e3b783e198519ba1e970404a8 -size 8317 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png deleted file mode 100644 index 74a7f599cd..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9a8ba207cf61c56b64c6855d04c8a30def0b3daf325adf57edd45b40981ed745 -size 7733 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png deleted file mode 100644 index 6d7f2c5d11..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b6703f2cba99aa60bdf3ecd380a46846f0e1b95a0b2718ed707348b6bc1713b1 -size 107508 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_1,NEXUS_5,1.0,en].png deleted file mode 100644 index eb23dcec96..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_1,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b8de2f28cf9918a7eddcc1311c34ba0376242a4708724b57689df000b7480524 -size 8197 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_2,NEXUS_5,1.0,en].png deleted file mode 100644 index e2e4a33e67..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_2,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:687133e4729ea42382ac0b76af6ceaf8b20cbc65923412fb97b077d2475c70f7 -size 7514 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_3,NEXUS_5,1.0,en].png deleted file mode 100644 index 2746320bc2..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_3,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d8e1ca8a115c3e46c7f24fe41dc6c9448ffecc65a05c6be1945b84f35d60a099 -size 103642 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..02c9c42691 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a1ec43b9fa3fd3906bc1d55145320a91a08f9222928ac55f7c264a7b5fa27ad8 +size 24303 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3f174ff793 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59478d13aa6150c097167e3b2d984f5f82d46a74326b7e69c74b9785cae9bd56 +size 23382 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1e8d75d01a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5c6225b33876b3a3878f0bfc230c081e798f6e0aae53286b93eb1ac76c17b35 +size 23116 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7ca77c5fff --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d13731b708adb16d1524d66a13daea13071cfd5ba646d21ef80fd682c28f6a4 +size 22450