Merge branch 'develop' into feature/fga/module_templates
This commit is contained in:
commit
1cf44f13c7
|
@ -3,7 +3,7 @@ import org.jetbrains.kotlin.cli.common.toBooleanLenient
|
|||
|
||||
buildscript {
|
||||
dependencies {
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
[Create and join rooms] Select members before creating a room (UI for selection)
|
|
@ -0,0 +1 @@
|
|||
Design fixes for the session verification flow and button components.
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -24,9 +24,11 @@ import com.bumble.appyx.core.modality.BuildContext
|
|||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
|
||||
import io.element.android.features.createroom.impl.root.CreateRoomRootNode
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
|
@ -50,11 +52,22 @@ class CreateRoomFlowNode @AssistedInject constructor(
|
|||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object Root : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object NewRoom : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Root -> createNode<CreateRoomRootNode>(buildContext)
|
||||
NavTarget.Root -> {
|
||||
val callback = object : CreateRoomRootNode.Callback {
|
||||
override fun onCreateNewRoom() {
|
||||
backstack.push(NavTarget.NewRoom)
|
||||
}
|
||||
}
|
||||
createNode<CreateRoomRootNode>(buildContext, plugins = listOf(callback))
|
||||
}
|
||||
NavTarget.NewRoom -> createNode<AddPeopleNode>(buildContext)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl.addpeople
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class AddPeopleNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: AddPeoplePresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
AddPeopleView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onBackPressed = { navigateUp() },
|
||||
onNextPressed = { },
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl.addpeople
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.element.android.features.selectusers.api.SelectUsersPresenter
|
||||
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
|
||||
import io.element.android.features.selectusers.api.SelectionMode
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import javax.inject.Inject
|
||||
|
||||
class AddPeoplePresenter @Inject constructor(
|
||||
private val selectUsersPresenterFactory: SelectUsersPresenter.Factory,
|
||||
) : Presenter<AddPeopleState> {
|
||||
|
||||
private val selectUsersPresenter by lazy {
|
||||
selectUsersPresenterFactory.create(SelectUsersPresenterArgs(SelectionMode.Multiple))
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): AddPeopleState {
|
||||
val selectUsersState = selectUsersPresenter.present()
|
||||
|
||||
fun handleEvents(event: AddPeopleEvents) {
|
||||
// do nothing for now
|
||||
}
|
||||
|
||||
return AddPeopleState(
|
||||
selectUsersState = selectUsersState,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl.addpeople
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.selectusers.api.SelectionMode
|
||||
import io.element.android.features.selectusers.api.aListOfSelectedUsers
|
||||
import io.element.android.features.selectusers.api.aSelectUsersState
|
||||
|
||||
open class AddPeopleStateProvider : PreviewParameterProvider<AddPeopleState> {
|
||||
override val values: Sequence<AddPeopleState>
|
||||
get() = sequenceOf(
|
||||
aAddPeopleState(),
|
||||
aAddPeopleState().copy(
|
||||
selectUsersState = aSelectUsersState().copy(
|
||||
selectedUsers = aListOfSelectedUsers(),
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
)
|
||||
),
|
||||
aAddPeopleState().copy(
|
||||
selectUsersState = aSelectUsersState().copy(
|
||||
selectedUsers = aListOfSelectedUsers(),
|
||||
isSearchActive = true,
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun aAddPeopleState() = AddPeopleState(
|
||||
selectUsersState = aSelectUsersState(),
|
||||
eventSink = {}
|
||||
)
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
|
|||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
|
@ -35,6 +36,14 @@ class CreateRoomRootNode @AssistedInject constructor(
|
|||
private val presenter: CreateRoomRootPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onCreateNewRoom()
|
||||
}
|
||||
|
||||
private fun onCreateNewRoom() {
|
||||
plugins<Callback>().forEach { it.onCreateNewRoom() }
|
||||
}
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object Root : NavTarget
|
||||
|
@ -47,6 +56,7 @@ class CreateRoomRootNode @AssistedInject constructor(
|
|||
state = state,
|
||||
modifier = modifier,
|
||||
onClosePressed = this::navigateUp,
|
||||
onNewRoomClicked = this::onCreateNewRoom,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,75 +17,39 @@
|
|||
package io.element.android.features.createroom.impl.root
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.features.selectusers.api.SelectUsersPresenter
|
||||
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
|
||||
import io.element.android.features.selectusers.api.SelectionMode
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.core.MatrixPatterns
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
class CreateRoomRootPresenter @Inject constructor() : Presenter<CreateRoomRootState> {
|
||||
class CreateRoomRootPresenter @Inject constructor(
|
||||
private val presenterFactory: SelectUsersPresenter.Factory,
|
||||
) : Presenter<CreateRoomRootState> {
|
||||
|
||||
private val presenter by lazy {
|
||||
presenterFactory.create(SelectUsersPresenterArgs(SelectionMode.Single))
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): CreateRoomRootState {
|
||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
val searchResults: MutableState<ImmutableList<MatrixUser>> = remember {
|
||||
mutableStateOf(persistentListOf())
|
||||
}
|
||||
val selectUsersState = presenter.present()
|
||||
|
||||
fun handleEvents(event: CreateRoomRootEvents) {
|
||||
when (event) {
|
||||
is CreateRoomRootEvents.OnSearchActiveChanged -> isSearchActive = event.active
|
||||
is CreateRoomRootEvents.UpdateSearchQuery -> searchQuery = event.query
|
||||
is CreateRoomRootEvents.StartDM -> handleStartDM(event.matrixUser)
|
||||
CreateRoomRootEvents.CreateRoom -> Unit // Todo Handle create room action
|
||||
CreateRoomRootEvents.InvitePeople -> Unit // Todo Handle invite people action
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(searchQuery) {
|
||||
// Clear the search results before performing the search, manually add a fake result with the matrixId, if any
|
||||
searchResults.value = if (MatrixPatterns.isUserId(searchQuery)) {
|
||||
persistentListOf(MatrixUser(UserId(searchQuery)))
|
||||
} else {
|
||||
persistentListOf()
|
||||
}
|
||||
// Perform the search asynchronously
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
searchResults.value = performSearch(searchQuery)
|
||||
}
|
||||
}
|
||||
|
||||
return CreateRoomRootState(
|
||||
selectUsersState = selectUsersState,
|
||||
eventSink = ::handleEvents,
|
||||
isSearchActive = isSearchActive,
|
||||
searchQuery = searchQuery,
|
||||
searchResults = searchResults.value,
|
||||
)
|
||||
}
|
||||
|
||||
private fun performSearch(query: String): ImmutableList<MatrixUser> {
|
||||
val isMatrixId = MatrixPatterns.isUserId(query)
|
||||
val results = mutableListOf<MatrixUser>()// TODO trigger /search request
|
||||
if (isMatrixId && results.none { it.id.value == query }) {
|
||||
val getProfileResult: MatrixUser? = null // TODO trigger /profile request
|
||||
val profile = getProfileResult ?: MatrixUser(UserId(query))
|
||||
results.add(0, profile)
|
||||
}
|
||||
return results.toImmutableList()
|
||||
}
|
||||
|
||||
private fun handleStartDM(matrixUser: MatrixUser) {
|
||||
Timber.d("handleStartDM: $matrixUser") // Todo handle start DM action
|
||||
}
|
||||
|
|
|
@ -16,13 +16,9 @@
|
|||
|
||||
package io.element.android.features.createroom.impl.root
|
||||
|
||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import io.element.android.features.selectusers.api.SelectUsersState
|
||||
|
||||
// Do not use default value, so no member get forgotten in the presenters.
|
||||
data class CreateRoomRootState(
|
||||
val selectUsersState: SelectUsersState,
|
||||
val eventSink: (CreateRoomRootEvents) -> Unit,
|
||||
val isSearchActive: Boolean,
|
||||
val searchQuery: String,
|
||||
val searchResults: ImmutableList<MatrixUser>,
|
||||
)
|
||||
|
|
|
@ -17,43 +17,16 @@
|
|||
package io.element.android.features.createroom.impl.root
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import io.element.android.features.selectusers.api.aSelectUsersState
|
||||
|
||||
open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRootState> {
|
||||
override val values: Sequence<CreateRoomRootState>
|
||||
get() = sequenceOf(
|
||||
aCreateRoomRootState(),
|
||||
aCreateRoomRootState().copy(isSearchActive = true),
|
||||
aCreateRoomRootState().copy(isSearchActive = true, searchQuery = "someone"),
|
||||
aCreateRoomRootState().copy(
|
||||
isSearchActive = true,
|
||||
searchQuery = "@someone:matrix.org",
|
||||
searchResults = persistentListOf(
|
||||
MatrixUser(id = UserId("@someone:matrix.org")),
|
||||
MatrixUser(id = UserId("@someone:matrix.org"), username = "someone"),
|
||||
MatrixUser(
|
||||
id = UserId("@someone_with_a_very_long_matrix_identifier:a_very_long_domain.org"),
|
||||
username = "hey, I am someone with a very long display name"
|
||||
),
|
||||
MatrixUser(id = UserId("@someone_2:matrix.org"), username = "someone 2"),
|
||||
MatrixUser(id = UserId("@someone_3:matrix.org"), username = "someone 3"),
|
||||
MatrixUser(id = UserId("@someone_4:matrix.org"), username = "someone 4"),
|
||||
MatrixUser(id = UserId("@someone_5:matrix.org"), username = "someone 5"),
|
||||
MatrixUser(id = UserId("@someone_6:matrix.org"), username = "someone 6"),
|
||||
MatrixUser(id = UserId("@someone_7:matrix.org"), username = "someone 7"),
|
||||
MatrixUser(id = UserId("@someone_8:matrix.org"), username = "someone 8"),
|
||||
MatrixUser(id = UserId("@someone_9:matrix.org"), username = "someone 9"),
|
||||
MatrixUser(id = UserId("@someone_10:matrix.org"), username = "someone 10"),
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aCreateRoomRootState() = CreateRoomRootState(
|
||||
eventSink = {},
|
||||
isSearchActive = false,
|
||||
searchQuery = "",
|
||||
searchResults = persistentListOf(),
|
||||
selectUsersState = aSelectUsersState(),
|
||||
)
|
||||
|
|
|
@ -24,38 +24,27 @@ import androidx.compose.foundation.layout.Row
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.features.selectusers.api.SelectUsersView
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import io.element.android.libraries.designsystem.R as DrawableR
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
|
@ -65,11 +54,12 @@ fun CreateRoomRootView(
|
|||
state: CreateRoomRootState,
|
||||
modifier: Modifier = Modifier,
|
||||
onClosePressed: () -> Unit = {},
|
||||
onNewRoomClicked: () -> Unit = {},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
topBar = {
|
||||
if (!state.isSearchActive) {
|
||||
if (!state.selectUsersState.isSearchActive) {
|
||||
CreateRoomRootViewTopBar(onClosePressed = onClosePressed)
|
||||
}
|
||||
}
|
||||
|
@ -78,20 +68,15 @@ fun CreateRoomRootView(
|
|||
modifier = Modifier.padding(paddingValues),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CreateRoomSearchBar(
|
||||
SelectUsersView(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
query = state.searchQuery,
|
||||
placeHolderTitle = stringResource(StringR.string.search_for_someone),
|
||||
results = state.searchResults,
|
||||
active = state.isSearchActive,
|
||||
onActiveChanged = { state.eventSink(CreateRoomRootEvents.OnSearchActiveChanged(it)) },
|
||||
onTextChanged = { state.eventSink(CreateRoomRootEvents.UpdateSearchQuery(it)) },
|
||||
onResultSelected = { state.eventSink(CreateRoomRootEvents.StartDM(it)) }
|
||||
state = state.selectUsersState,
|
||||
onUserSelected = { state.eventSink.invoke(CreateRoomRootEvents.StartDM(it)) },
|
||||
)
|
||||
|
||||
if (!state.isSearchActive) {
|
||||
if (!state.selectUsersState.isSearchActive) {
|
||||
CreateRoomActionButtonsList(
|
||||
onNewRoomClicked = { state.eventSink(CreateRoomRootEvents.CreateRoom) },
|
||||
onNewRoomClicked = onNewRoomClicked,
|
||||
onInvitePeopleClicked = { state.eventSink(CreateRoomRootEvents.InvitePeople) },
|
||||
)
|
||||
}
|
||||
|
@ -122,77 +107,6 @@ fun CreateRoomRootViewTopBar(
|
|||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CreateRoomSearchBar(
|
||||
query: String,
|
||||
placeHolderTitle: String,
|
||||
results: ImmutableList<MatrixUser>,
|
||||
active: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onActiveChanged: (Boolean) -> Unit = {},
|
||||
onTextChanged: (String) -> Unit = {},
|
||||
onResultSelected: (MatrixUser) -> Unit = {},
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
if (!active) {
|
||||
onTextChanged("")
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
|
||||
SearchBar(
|
||||
query = query,
|
||||
onQueryChange = onTextChanged,
|
||||
onSearch = { focusManager.clearFocus() },
|
||||
active = active,
|
||||
onActiveChange = onActiveChanged,
|
||||
modifier = modifier
|
||||
.padding(horizontal = if (!active) 16.dp else 0.dp),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = placeHolderTitle,
|
||||
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
|
||||
)
|
||||
},
|
||||
leadingIcon = if (active) {
|
||||
{ BackButton(onClick = { onActiveChanged(false) }) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
trailingIcon = when {
|
||||
active && query.isNotEmpty() -> {
|
||||
{
|
||||
IconButton(onClick = { onTextChanged("") }) {
|
||||
Icon(Icons.Default.Close, stringResource(StringR.string.a11y_clear))
|
||||
}
|
||||
}
|
||||
}
|
||||
!active -> {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(StringR.string.search),
|
||||
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
},
|
||||
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
|
||||
content = {
|
||||
LazyColumn {
|
||||
items(results) {
|
||||
CreateRoomSearchResultItem(
|
||||
matrixUser = it,
|
||||
onClick = { onResultSelected(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateRoomActionButtonsList(
|
||||
modifier: Modifier = Modifier,
|
||||
|
@ -213,20 +127,6 @@ fun CreateRoomActionButtonsList(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateRoomSearchResultItem(
|
||||
matrixUser: MatrixUser,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
MatrixUserRow(
|
||||
modifier = modifier,
|
||||
matrixUser = matrixUser,
|
||||
avatarSize = AvatarSize.Custom(36.dp),
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateRoomActionButton(
|
||||
@DrawableRes iconRes: Int,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -69,7 +69,7 @@ class ChangeServerPresenter @Inject constructor(private val authenticationServic
|
|||
private fun CoroutineScope.submit(homeserverUrl: MutableState<String>, changeServerAction: MutableState<Async<Unit>>) = launch {
|
||||
suspend {
|
||||
val domain = tryOrNull { URL(homeserverUrl.value) }?.host ?: homeserverUrl.value
|
||||
authenticationService.setHomeserver(domain)
|
||||
authenticationService.setHomeserver(domain).getOrThrow()
|
||||
homeserverUrl.value = domain
|
||||
}.execute(changeServerAction, errorMapping = ChangeServerError::from)
|
||||
}
|
||||
|
|
|
@ -134,6 +134,7 @@ class ChangeServerPresenterTest {
|
|||
val initialState = awaitItem()
|
||||
authServer.givenChangeServerError(Throwable())
|
||||
initialState.eventSink.invoke(ChangeServerEvents.Submit)
|
||||
skipItems(1) // Loading
|
||||
val failureState = awaitItem()
|
||||
assertThat(failureState.submitEnabled).isFalse()
|
||||
assertThat(failureState.changeServerAction).isInstanceOf(Async.Failure::class.java)
|
||||
|
@ -155,6 +156,8 @@ class ChangeServerPresenterTest {
|
|||
authenticationService.givenChangeServerError(A_THROWABLE)
|
||||
initialState.eventSink(ChangeServerEvents.Submit)
|
||||
|
||||
skipItems(1) // Loading
|
||||
|
||||
// Check an error was returned
|
||||
val submittedState = awaitItem()
|
||||
assertThat(submittedState.changeServerAction).isInstanceOf(Async.Failure::class.java)
|
||||
|
|
|
@ -135,7 +135,7 @@ fun RoomListContent(
|
|||
if (state.presentVerificationSuccessfulMessage) {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = verificationCompleteMessage,
|
||||
duration = SnackbarDuration.Short
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
state.eventSink(RoomListEvents.ClearSuccessfulVerificationMessage)
|
||||
}
|
||||
|
@ -194,8 +194,6 @@ fun RoomListContent(
|
|||
SnackbarHost (snackbarHostState) { data ->
|
||||
Snackbar(
|
||||
snackbarData = data,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.selectusers.api
|
||||
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
|
||||
interface SelectUsersPresenter : Presenter<SelectUsersState> {
|
||||
|
||||
interface Factory {
|
||||
fun create(args: SelectUsersPresenterArgs): SelectUsersPresenter
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.selectusers.api
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class SelectUsersState(
|
||||
val searchQuery: String,
|
||||
val searchResults: ImmutableList<MatrixUser>,
|
||||
val selectedUsers: ImmutableList<MatrixUser>,
|
||||
val selectedUsersListState: LazyListState,
|
||||
val isSearchActive: Boolean,
|
||||
val selectionMode: SelectionMode,
|
||||
val eventSink: (SelectUsersEvents) -> Unit,
|
||||
) {
|
||||
val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.selectusers.api
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
open class SelectUsersStateProvider : PreviewParameterProvider<SelectUsersState> {
|
||||
override val values: Sequence<SelectUsersState>
|
||||
get() = sequenceOf(
|
||||
aSelectUsersState(),
|
||||
aSelectUsersState().copy(
|
||||
isSearchActive = false,
|
||||
selectedUsers = aListOfSelectedUsers(),
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
),
|
||||
aSelectUsersState().copy(isSearchActive = true),
|
||||
aSelectUsersState().copy(isSearchActive = true, searchQuery = "someone"),
|
||||
aSelectUsersState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple),
|
||||
aSelectUsersState().copy(
|
||||
isSearchActive = true,
|
||||
searchQuery = "@someone:matrix.org",
|
||||
selectedUsers = aListOfSelectedUsers(),
|
||||
searchResults = aListOfResults(),
|
||||
),
|
||||
aSelectUsersState().copy(
|
||||
isSearchActive = true,
|
||||
searchQuery = "@someone:matrix.org",
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
selectedUsers = aListOfSelectedUsers(),
|
||||
searchResults = aListOfResults(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun aSelectUsersState() = SelectUsersState(
|
||||
isSearchActive = false,
|
||||
searchQuery = "",
|
||||
searchResults = persistentListOf(),
|
||||
selectedUsers = persistentListOf(),
|
||||
selectedUsersListState = LazyListState(
|
||||
firstVisibleItemIndex = 0,
|
||||
firstVisibleItemScrollOffset = 0,
|
||||
),
|
||||
selectionMode = SelectionMode.Single,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
fun aListOfSelectedUsers() = persistentListOf(
|
||||
MatrixUser(id = UserId("@someone:matrix.org")),
|
||||
MatrixUser(id = UserId("@other:matrix.org"), username = "other"),
|
||||
)
|
||||
|
||||
fun aListOfResults() = persistentListOf(
|
||||
MatrixUser(id = UserId("@someone:matrix.org")),
|
||||
MatrixUser(id = UserId("@other:matrix.org"), username = "other"),
|
||||
MatrixUser(
|
||||
id = UserId("@someone_with_a_very_long_matrix_identifier:a_very_long_domain.org"),
|
||||
username = "hey, I am someone with a very long display name"
|
||||
),
|
||||
MatrixUser(id = UserId("@someone_2:matrix.org"), username = "someone 2"),
|
||||
MatrixUser(id = UserId("@someone_3:matrix.org"), username = "someone 3"),
|
||||
MatrixUser(id = UserId("@someone_4:matrix.org"), username = "someone 4"),
|
||||
MatrixUser(id = UserId("@someone_5:matrix.org"), username = "someone 5"),
|
||||
MatrixUser(id = UserId("@someone_6:matrix.org"), username = "someone 6"),
|
||||
MatrixUser(id = UserId("@someone_7:matrix.org"), username = "someone 7"),
|
||||
MatrixUser(id = UserId("@someone_8:matrix.org"), username = "someone 8"),
|
||||
MatrixUser(id = UserId("@someone_9:matrix.org"), username = "someone 9"),
|
||||
MatrixUser(id = UserId("@someone_10:matrix.org"), username = "someone 10"),
|
||||
)
|
|
@ -0,0 +1,311 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.selectusers.api
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow
|
||||
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.model.getBestName
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
@Composable
|
||||
fun SelectUsersView(
|
||||
state: SelectUsersState,
|
||||
modifier: Modifier = Modifier,
|
||||
onUserSelected: (MatrixUser) -> Unit = {},
|
||||
onUserDeselected: (MatrixUser) -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
SearchUserBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
query = state.searchQuery,
|
||||
results = state.searchResults,
|
||||
selectedUsers = state.selectedUsers,
|
||||
selectedUsersListState = state.selectedUsersListState,
|
||||
active = state.isSearchActive,
|
||||
isMultiSelectionEnabled = state.isMultiSelectionEnabled,
|
||||
onActiveChanged = { state.eventSink(SelectUsersEvents.OnSearchActiveChanged(it)) },
|
||||
onTextChanged = { state.eventSink(SelectUsersEvents.UpdateSearchQuery(it)) },
|
||||
onUserSelected = {
|
||||
state.eventSink(SelectUsersEvents.AddToSelection(it))
|
||||
onUserSelected(it)
|
||||
},
|
||||
onUserDeselected = {
|
||||
state.eventSink(SelectUsersEvents.RemoveFromSelection(it))
|
||||
onUserDeselected(it)
|
||||
},
|
||||
)
|
||||
|
||||
if (state.isMultiSelectionEnabled && !state.isSearchActive && state.selectedUsers.isNotEmpty()) {
|
||||
SelectedUsersList(
|
||||
listState = state.selectedUsersListState,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
selectedUsers = state.selectedUsers,
|
||||
onUserRemoved = {
|
||||
state.eventSink(SelectUsersEvents.RemoveFromSelection(it))
|
||||
onUserDeselected(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SearchUserBar(
|
||||
query: String,
|
||||
results: ImmutableList<MatrixUser>,
|
||||
selectedUsers: ImmutableList<MatrixUser>,
|
||||
selectedUsersListState: LazyListState,
|
||||
active: Boolean,
|
||||
isMultiSelectionEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
placeHolderTitle: String = stringResource(StringR.string.search_for_someone),
|
||||
onActiveChanged: (Boolean) -> Unit = {},
|
||||
onTextChanged: (String) -> Unit = {},
|
||||
onUserSelected: (MatrixUser) -> Unit = {},
|
||||
onUserDeselected: (MatrixUser) -> Unit = {},
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
if (!active) {
|
||||
onTextChanged("")
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
|
||||
SearchBar(
|
||||
query = query,
|
||||
onQueryChange = onTextChanged,
|
||||
onSearch = { focusManager.clearFocus() },
|
||||
active = active,
|
||||
onActiveChange = onActiveChanged,
|
||||
modifier = modifier
|
||||
.padding(horizontal = if (!active) 16.dp else 0.dp),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = placeHolderTitle,
|
||||
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
|
||||
)
|
||||
},
|
||||
leadingIcon = if (active) {
|
||||
{ BackButton(onClick = { onActiveChanged(false) }) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
trailingIcon = when {
|
||||
active && query.isNotEmpty() -> {
|
||||
{
|
||||
IconButton(onClick = { onTextChanged("") }) {
|
||||
Icon(Icons.Default.Close, stringResource(StringR.string.a11y_clear))
|
||||
}
|
||||
}
|
||||
}
|
||||
!active -> {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(StringR.string.search),
|
||||
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
},
|
||||
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
|
||||
content = {
|
||||
if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) {
|
||||
SelectedUsersList(
|
||||
listState = selectedUsersListState,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
selectedUsers = selectedUsers,
|
||||
onUserRemoved = onUserDeselected,
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
if (isMultiSelectionEnabled) {
|
||||
items(results) { matrixUser ->
|
||||
SearchMultipleUsersResultItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
matrixUser = matrixUser,
|
||||
isUserSelected = selectedUsers.find { it.id == matrixUser.id } != null,
|
||||
onCheckedChange = { checked ->
|
||||
if (checked) {
|
||||
onUserSelected(matrixUser)
|
||||
} else {
|
||||
onUserDeselected(matrixUser)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(results) { matrixUser ->
|
||||
SearchSingleUserResultItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
matrixUser = matrixUser,
|
||||
onClick = { onUserSelected(matrixUser) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchMultipleUsersResultItem(
|
||||
matrixUser: MatrixUser,
|
||||
isUserSelected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
CheckableMatrixUserRow(
|
||||
checked = isUserSelected,
|
||||
modifier = modifier,
|
||||
matrixUser = matrixUser,
|
||||
avatarSize = AvatarSize.Custom(36.dp),
|
||||
onCheckedChange = onCheckedChange,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchSingleUserResultItem(
|
||||
matrixUser: MatrixUser,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
MatrixUserRow(
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
matrixUser = matrixUser,
|
||||
avatarSize = AvatarSize.Custom(36.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SelectedUsersList(
|
||||
listState: LazyListState,
|
||||
selectedUsers: ImmutableList<MatrixUser>,
|
||||
modifier: Modifier = Modifier,
|
||||
onUserRemoved: (MatrixUser) -> Unit = {},
|
||||
) {
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
items(selectedUsers.toList()) { matrixUser ->
|
||||
SelectedUser(
|
||||
matrixUser = matrixUser,
|
||||
onUserRemoved = onUserRemoved,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SelectedUser(
|
||||
matrixUser: MatrixUser,
|
||||
modifier: Modifier = Modifier,
|
||||
onUserRemoved: (MatrixUser) -> Unit,
|
||||
) {
|
||||
Box(modifier = modifier.width(56.dp)) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Avatar(matrixUser.avatarData.copy(size = AvatarSize.Custom(56.dp)))
|
||||
Text(
|
||||
text = matrixUser.getBestName(),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.size(20.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
onClick = { onUserRemoved(matrixUser) }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(id = StringR.string.action_remove),
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun SelectUsersViewLightPreview(@PreviewParameter(SelectUsersStateProvider::class) state: SelectUsersState) =
|
||||
ElementPreviewLight { ContentToPreview(state) }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun SelectUsersViewDarkPreview(@PreviewParameter(SelectUsersStateProvider::class) state: SelectUsersState) =
|
||||
ElementPreviewDark { ContentToPreview(state) }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview(state: SelectUsersState) {
|
||||
SelectUsersView(state = state)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.selectusers.impl
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.selectusers.api.SelectUsersEvents
|
||||
import io.element.android.features.selectusers.api.SelectUsersPresenter
|
||||
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
|
||||
import io.element.android.features.selectusers.api.SelectUsersState
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.MatrixPatterns
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class DefaultSelectUsersPresenter @AssistedInject constructor(
|
||||
@Assisted val args: SelectUsersPresenterArgs,
|
||||
) : SelectUsersPresenter {
|
||||
|
||||
@AssistedFactory
|
||||
@ContributesBinding(SessionScope::class)
|
||||
interface DefaultSelectUsersFactory : SelectUsersPresenter.Factory {
|
||||
override fun create(args: SelectUsersPresenterArgs): DefaultSelectUsersPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): SelectUsersState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
||||
val selectedUsers: MutableState<ImmutableList<MatrixUser>> = remember {
|
||||
mutableStateOf(persistentListOf())
|
||||
}
|
||||
val selectedUsersListState = rememberLazyListState()
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
val searchResults: MutableState<ImmutableList<MatrixUser>> = remember {
|
||||
mutableStateOf(persistentListOf())
|
||||
}
|
||||
|
||||
fun handleEvents(event: SelectUsersEvents) {
|
||||
when (event) {
|
||||
is SelectUsersEvents.OnSearchActiveChanged -> isSearchActive = event.active
|
||||
is SelectUsersEvents.UpdateSearchQuery -> searchQuery = event.query
|
||||
is SelectUsersEvents.AddToSelection -> {
|
||||
if (event.matrixUser !in selectedUsers.value) {
|
||||
selectedUsers.value = selectedUsers.value.plus(event.matrixUser).toImmutableList()
|
||||
}
|
||||
localCoroutineScope.scrollToFirstSelectedUser(selectedUsersListState)
|
||||
}
|
||||
is SelectUsersEvents.RemoveFromSelection -> selectedUsers.value = selectedUsers.value.minus(event.matrixUser).toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(searchQuery) {
|
||||
// Clear the search results before performing the search, manually add a fake result with the matrixId, if any
|
||||
searchResults.value = if (MatrixPatterns.isUserId(searchQuery)) {
|
||||
persistentListOf(MatrixUser(UserId(searchQuery)))
|
||||
} else {
|
||||
persistentListOf()
|
||||
}
|
||||
// Perform the search asynchronously
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
searchResults.value = performSearch(searchQuery)
|
||||
}
|
||||
}
|
||||
|
||||
return SelectUsersState(
|
||||
searchQuery = searchQuery,
|
||||
searchResults = searchResults.value,
|
||||
selectedUsers = selectedUsers.value.reversed().toImmutableList(),
|
||||
selectedUsersListState = selectedUsersListState,
|
||||
isSearchActive = isSearchActive,
|
||||
selectionMode = args.selectionMode,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
|
||||
private fun performSearch(query: String): ImmutableList<MatrixUser> {
|
||||
val isMatrixId = MatrixPatterns.isUserId(query)
|
||||
val results = mutableListOf<MatrixUser>()// TODO trigger /search request
|
||||
if (isMatrixId && results.none { it.id.value == query }) {
|
||||
val getProfileResult: MatrixUser? = null // TODO trigger /profile request
|
||||
val profile = getProfileResult ?: MatrixUser(UserId(query))
|
||||
results.add(0, profile)
|
||||
}
|
||||
return results.toImmutableList()
|
||||
}
|
||||
|
||||
private fun CoroutineScope.scrollToFirstSelectedUser(listState: LazyListState) = launch {
|
||||
listState.scrollToItem(index = 0)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.selectusers.impl
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.selectusers.api.SelectUsersEvents
|
||||
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
|
||||
import io.element.android.features.selectusers.api.SelectionMode
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||
import io.mockk.coJustRun
|
||||
import io.mockk.mockkConstructor
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DefaultSelectUsersPresenterTests {
|
||||
|
||||
@Test
|
||||
fun `present - initial state for single selection`() = runTest {
|
||||
val presenter = DefaultSelectUsersPresenter(SelectUsersPresenterArgs(SelectionMode.Single))
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.searchQuery).isEmpty()
|
||||
assertThat(initialState.isMultiSelectionEnabled).isFalse()
|
||||
assertThat(initialState.isSearchActive).isFalse()
|
||||
assertThat(initialState.selectedUsers).isEmpty()
|
||||
assertThat(initialState.searchResults).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state for multiple selection`() = runTest {
|
||||
val presenter = DefaultSelectUsersPresenter(SelectUsersPresenterArgs(SelectionMode.Multiple))
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.searchQuery).isEmpty()
|
||||
assertThat(initialState.isMultiSelectionEnabled).isTrue()
|
||||
assertThat(initialState.isSearchActive).isFalse()
|
||||
assertThat(initialState.selectedUsers).isEmpty()
|
||||
assertThat(initialState.searchResults).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - update search query`() = runTest {
|
||||
val presenter = DefaultSelectUsersPresenter(SelectUsersPresenterArgs(SelectionMode.Single))
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
initialState.eventSink(SelectUsersEvents.OnSearchActiveChanged(true))
|
||||
assertThat(awaitItem().isSearchActive).isTrue()
|
||||
|
||||
val matrixIdQuery = "@name:matrix.org"
|
||||
initialState.eventSink(SelectUsersEvents.UpdateSearchQuery(matrixIdQuery))
|
||||
assertThat(awaitItem().searchQuery).isEqualTo(matrixIdQuery)
|
||||
assertThat(awaitItem().searchResults).containsExactly(MatrixUser(UserId(matrixIdQuery)))
|
||||
|
||||
val notMatrixIdQuery = "name"
|
||||
initialState.eventSink(SelectUsersEvents.UpdateSearchQuery(notMatrixIdQuery))
|
||||
assertThat(awaitItem().searchQuery).isEqualTo(notMatrixIdQuery)
|
||||
assertThat(awaitItem().searchResults).isEmpty()
|
||||
|
||||
initialState.eventSink(SelectUsersEvents.OnSearchActiveChanged(false))
|
||||
assertThat(awaitItem().isSearchActive).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - select a user`() = runTest {
|
||||
mockkConstructor(LazyListState::class)
|
||||
coJustRun { anyConstructed<LazyListState>().scrollToItem(index = any()) }
|
||||
|
||||
val presenter = DefaultSelectUsersPresenter(SelectUsersPresenterArgs(SelectionMode.Single))
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
val userA = aMatrixUser("userA", "A")
|
||||
val userB = aMatrixUser("userB", "B")
|
||||
val userABis = aMatrixUser("userA", "A")
|
||||
val userC = aMatrixUser("userC", "C")
|
||||
|
||||
initialState.eventSink(SelectUsersEvents.AddToSelection(userA))
|
||||
assertThat(awaitItem().selectedUsers).containsExactly(userA)
|
||||
|
||||
initialState.eventSink(SelectUsersEvents.AddToSelection(userB))
|
||||
// the last added user should be presented first
|
||||
assertThat(awaitItem().selectedUsers).containsExactly(userB, userA)
|
||||
|
||||
initialState.eventSink(SelectUsersEvents.AddToSelection(userABis))
|
||||
initialState.eventSink(SelectUsersEvents.AddToSelection(userC))
|
||||
// duplicated users should be ignored
|
||||
assertThat(awaitItem().selectedUsers).containsExactly(userC, userB, userA)
|
||||
|
||||
initialState.eventSink(SelectUsersEvents.RemoveFromSelection(userB))
|
||||
assertThat(awaitItem().selectedUsers).containsExactly(userC, userA)
|
||||
initialState.eventSink(SelectUsersEvents.RemoveFromSelection(userA))
|
||||
assertThat(awaitItem().selectedUsers).containsExactly(userC)
|
||||
initialState.eventSink(SelectUsersEvents.RemoveFromSelection(userC))
|
||||
assertThat(awaitItem().selectedUsers).isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -42,6 +42,7 @@ dependencies {
|
|||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.elementresources)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.statemachine)
|
||||
implementation(libs.accompanist.flowlayout)
|
||||
api(projects.features.verifysession.api)
|
||||
|
||||
|
|
|
@ -85,11 +85,14 @@ class VerifySelfSessionPresenter @Inject constructor(
|
|||
StateMachineState.RequestingVerification,
|
||||
StateMachineState.StartingSasVerification,
|
||||
StateMachineState.SasVerificationStarted,
|
||||
StateMachineState.VerificationRequestAccepted,
|
||||
StateMachineState.Canceling -> {
|
||||
VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse
|
||||
}
|
||||
|
||||
StateMachineState.VerificationRequestAccepted -> {
|
||||
VerifySelfSessionState.VerificationStep.Ready
|
||||
}
|
||||
|
||||
StateMachineState.Canceled -> {
|
||||
VerifySelfSessionState.VerificationStep.Canceled
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ data class VerifySelfSessionState(
|
|||
object Initial : VerificationStep
|
||||
object Canceled : VerificationStep
|
||||
object AwaitingOtherDeviceResponse : VerificationStep
|
||||
object Ready : VerificationStep
|
||||
data class Verifying(val emojiList: List<VerificationEmoji>, val state: Async<Unit>) : VerificationStep
|
||||
object Completed : VerificationStep
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
@file:Suppress("WildcardImport")
|
||||
package io.element.android.features.verifysession.impl
|
||||
|
||||
import io.element.android.libraries.core.statemachine.createStateMachine
|
||||
import io.element.android.libraries.statemachine.createStateMachine
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
|
@ -81,6 +81,7 @@ class VerifySelfSessionStateMachine(
|
|||
// Observe the verification service state, translate it to state machine input events
|
||||
sessionVerificationService.verificationFlowState.onEach { verificationAttemptState ->
|
||||
when (verificationAttemptState) {
|
||||
VerificationFlowState.Initial -> stateMachine.restart()
|
||||
VerificationFlowState.AcceptedVerificationRequest -> {
|
||||
stateMachine.process(Event.DidAcceptVerificationRequest)
|
||||
}
|
||||
|
@ -102,7 +103,6 @@ class VerifySelfSessionStateMachine(
|
|||
VerificationFlowState.Failed -> {
|
||||
stateMachine.process(Event.DidFail)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}.launchIn(coroutineScope)
|
||||
}
|
||||
|
|
|
@ -36,6 +36,9 @@ open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfS
|
|||
aVerifySelfSessionState().copy(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled
|
||||
),
|
||||
aVerifySelfSessionState().copy(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready
|
||||
),
|
||||
// Add other state here
|
||||
)
|
||||
}
|
||||
|
|
|
@ -23,11 +23,11 @@ import androidx.compose.foundation.layout.Box
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
|
@ -39,10 +39,13 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
|
@ -82,16 +85,15 @@ fun VerifySelfSessionView(
|
|||
derivedStateOf { verificationFlowStep != FlowStep.AwaitingOtherDeviceResponse && verificationFlowStep != FlowStep.Completed }
|
||||
}
|
||||
Surface {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Column(modifier = modifier.systemBarsPadding()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp)
|
||||
) {
|
||||
HeaderContent(verificationFlowStep = verificationFlowStep)
|
||||
Content(flowState = verificationFlowStep)
|
||||
Content(modifier = Modifier.weight(1f), flowState = verificationFlowStep)
|
||||
}
|
||||
if (buttonsVisible) {
|
||||
BottomMenu(screenState = state, goBack = ::goBackAndCancelIfNeeded)
|
||||
|
@ -106,22 +108,23 @@ internal fun HeaderContent(verificationFlowStep: FlowStep, modifier: Modifier =
|
|||
FlowStep.Initial -> R.drawable.ic_verification_devices
|
||||
FlowStep.Canceled -> R.drawable.ic_verification_warning
|
||||
FlowStep.AwaitingOtherDeviceResponse -> R.drawable.ic_verification_waiting
|
||||
is FlowStep.Verifying, FlowStep.Completed -> R.drawable.ic_verification_emoji
|
||||
FlowStep.Ready, is FlowStep.Verifying, FlowStep.Completed -> R.drawable.ic_verification_emoji
|
||||
}
|
||||
val titleTextId = when (verificationFlowStep) {
|
||||
FlowStep.Initial -> StringR.string.verification_title_initial
|
||||
FlowStep.Canceled -> StringR.string.verification_title_canceled
|
||||
FlowStep.AwaitingOtherDeviceResponse -> StringR.string.verification_title_waiting
|
||||
is FlowStep.Verifying, FlowStep.Completed -> StringR.string.verification_title_verifying
|
||||
FlowStep.Ready, is FlowStep.Verifying, FlowStep.Completed -> StringR.string.verification_title_verifying
|
||||
}
|
||||
val subtitleTextId = when (verificationFlowStep) {
|
||||
FlowStep.Initial -> StringR.string.verification_subtitle_initial
|
||||
FlowStep.Canceled -> StringR.string.verification_subtitle_canceled
|
||||
FlowStep.AwaitingOtherDeviceResponse -> StringR.string.verification_subtitle_waiting
|
||||
is FlowStep.Verifying, FlowStep.Completed -> StringR.string.verification_subtitle_verifying
|
||||
FlowStep.Ready, is FlowStep.Verifying, FlowStep.Completed -> StringR.string.verification_subtitle_verifying
|
||||
}
|
||||
|
||||
Column(modifier) {
|
||||
Spacer(Modifier.height(68.dp))
|
||||
Spacer(Modifier.height(80.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 70.dp, height = 70.dp)
|
||||
|
@ -164,14 +167,14 @@ internal fun HeaderContent(verificationFlowStep: FlowStep, modifier: Modifier =
|
|||
|
||||
@Composable
|
||||
internal fun Content(flowState: FlowStep, modifier: Modifier = Modifier) {
|
||||
Column(modifier) {
|
||||
Spacer(Modifier.height(56.dp))
|
||||
Column(modifier, verticalArrangement = Arrangement.Center) {
|
||||
Spacer(Modifier.shrinkableHeight(min = 20.dp, max = 56.dp))
|
||||
when (flowState) {
|
||||
FlowStep.Initial, FlowStep.Canceled, FlowStep.Completed -> Unit
|
||||
FlowStep.Initial, FlowStep.Ready, FlowStep.Canceled, FlowStep.Completed -> Unit
|
||||
FlowStep.AwaitingOtherDeviceResponse -> ContentWaiting()
|
||||
is FlowStep.Verifying -> ContentVerifying(flowState)
|
||||
}
|
||||
Spacer(Modifier.height(56.dp))
|
||||
Spacer(Modifier.shrinkableHeight(min = 20.dp, max = 56.dp))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -185,19 +188,22 @@ internal fun ContentWaiting(modifier: Modifier = Modifier) {
|
|||
@Composable
|
||||
internal fun ContentVerifying(verificationFlowStep: FlowStep.Verifying, modifier: Modifier = Modifier) {
|
||||
FlowRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
mainAxisAlignment = MainAxisAlignment.Center,
|
||||
mainAxisSpacing = 32.dp,
|
||||
crossAxisSpacing = 40.dp
|
||||
) {
|
||||
for (entry in verificationFlowStep.emojiList) {
|
||||
Column(
|
||||
modifier = Modifier.defaultMinSize(minWidth = 56.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(entry.code, fontSize = 34.sp)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(entry.name, style = ElementTextStyles.Regular.body)
|
||||
Text(
|
||||
entry.name,
|
||||
style = ElementTextStyles.Regular.bodyMD,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -219,6 +225,7 @@ internal fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit)
|
|||
StringR.string.verification_positive_button_verifying_start
|
||||
}
|
||||
}
|
||||
FlowStep.Ready -> StringR.string.verification_positive_button_ready
|
||||
else -> null
|
||||
}
|
||||
val negativeButtonTitle = when (verificationViewState) {
|
||||
|
@ -231,6 +238,7 @@ internal fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit)
|
|||
|
||||
val positiveButtonEvent = when (verificationViewState) {
|
||||
FlowStep.Initial -> VerifySelfSessionViewEvents.RequestVerification
|
||||
FlowStep.Ready -> VerifySelfSessionViewEvents.StartSasVerification
|
||||
is FlowStep.Verifying -> if (!isVerifying) VerifySelfSessionViewEvents.ConfirmVerification else null
|
||||
FlowStep.Canceled -> VerifySelfSessionViewEvents.Restart
|
||||
else -> null
|
||||
|
@ -254,23 +262,25 @@ internal fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit)
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { positiveButtonEvent?.let { eventSink(it) } }
|
||||
) {
|
||||
positiveButtonTitle?.let { Text(stringResource(it)) }
|
||||
positiveButtonTitle?.let { Text(stringResource(it), style = ElementTextStyles.Button) }
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { positiveButtonEvent?.let { eventSink(it) } }
|
||||
) {
|
||||
positiveButtonTitle?.let { Text(stringResource(it)) }
|
||||
positiveButtonTitle?.let { Text(stringResource(it), style = ElementTextStyles.Button) }
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = negativeButtonCallback,
|
||||
enabled = negativeButtonEnabled,
|
||||
) {
|
||||
negativeButtonTitle?.let { Text(stringResource(it)) }
|
||||
if (negativeButtonTitle != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = negativeButtonCallback,
|
||||
enabled = negativeButtonEnabled,
|
||||
) {
|
||||
Text(stringResource(negativeButtonTitle), fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(40.dp))
|
||||
}
|
||||
|
@ -293,3 +303,15 @@ private fun ContentToPreview(state: VerifySelfSessionState) {
|
|||
goBack = {},
|
||||
)
|
||||
}
|
||||
|
||||
private fun Modifier.shrinkableHeight(
|
||||
min: Dp,
|
||||
max: Dp,
|
||||
minScreenHeight: Int = 720
|
||||
): Modifier = composed {
|
||||
if (LocalConfiguration.current.screenHeightDp >= minScreenHeight) {
|
||||
then(Modifier.height(max))
|
||||
} else {
|
||||
then(Modifier.height(min))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,6 +56,10 @@ class VerifySelfSessionPresenterTests {
|
|||
eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
||||
// Await for other device response:
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
|
||||
// Await for the state to be Ready
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Ready)
|
||||
// Await for other device response (again):
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
|
||||
// Finally, ChallengeReceived:
|
||||
val verifyingState = awaitItem()
|
||||
assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
|
||||
|
@ -233,8 +237,8 @@ class VerifySelfSessionPresenterTests {
|
|||
}
|
||||
|
||||
private suspend fun ReceiveTurbine<VerifySelfSessionState>.awaitChallengeReceivedState(): VerifySelfSessionState {
|
||||
// Skip 'waiting for response' state
|
||||
skipItems(1)
|
||||
// Skip 'waiting for response', 'ready' and 'starting verification' state
|
||||
skipItems(3)
|
||||
// Received challenge
|
||||
return awaitItem()
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ serialization_json = "1.5.0"
|
|||
showkase = "1.0.0-beta17"
|
||||
jsoup = "1.15.4"
|
||||
appyx = "1.0.5"
|
||||
dependencycheck = "8.2.0"
|
||||
dependencycheck = "8.2.1"
|
||||
stem = "2.3.0"
|
||||
sqldelight = "1.5.5"
|
||||
|
||||
|
|
|
@ -26,7 +26,7 @@ import androidx.compose.ui.unit.sp
|
|||
object ElementTextStyles {
|
||||
|
||||
val Button = TextStyle(
|
||||
fontSize = 17.sp,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 22.sp,
|
||||
fontStyle = FontStyle.Normal,
|
||||
|
|
|
@ -38,6 +38,7 @@ class RustSessionVerificationService @Inject constructor() : SessionVerification
|
|||
_isReady.value = value != null
|
||||
// If status was 'Unknown', move it to either 'Verified' or 'NotVerified'
|
||||
if (value != null) {
|
||||
value.setDelegate(this)
|
||||
updateVerificationStatus(value.isVerified())
|
||||
}
|
||||
}
|
||||
|
@ -52,7 +53,6 @@ class RustSessionVerificationService @Inject constructor() : SessionVerification
|
|||
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus.asStateFlow()
|
||||
|
||||
override fun requestVerification() = tryOrFail {
|
||||
verificationController?.setDelegate(this)
|
||||
verificationController?.requestVerification()
|
||||
}
|
||||
|
||||
|
@ -63,7 +63,6 @@ class RustSessionVerificationService @Inject constructor() : SessionVerification
|
|||
override fun declineVerification() = tryOrFail { verificationController?.declineVerification() }
|
||||
|
||||
override fun startVerification() = tryOrFail {
|
||||
verificationController?.setDelegate(this)
|
||||
verificationController?.startSasVerification()
|
||||
}
|
||||
|
||||
|
|
|
@ -53,20 +53,13 @@ class FakeAuthenticationService : MatrixAuthenticationService {
|
|||
}
|
||||
|
||||
override suspend fun setHomeserver(homeserver: String): Result<Unit> {
|
||||
changeServerError?.let { throw it }
|
||||
delay(100)
|
||||
return Result.success(Unit)
|
||||
return changeServerError?.let { Result.failure(it) } ?: Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun login(username: String, password: String): Result<SessionId> {
|
||||
delay(100)
|
||||
return loginError.let { loginError ->
|
||||
if (loginError == null) {
|
||||
Result.success(A_USER_ID)
|
||||
} else {
|
||||
Result.failure(loginError)
|
||||
}
|
||||
}
|
||||
return loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID)
|
||||
}
|
||||
|
||||
fun givenLoginError(throwable: Throwable?) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -32,9 +32,9 @@ open class MatrixUserProvider : PreviewParameterProvider<MatrixUser> {
|
|||
)
|
||||
}
|
||||
|
||||
fun aMatrixUser() = MatrixUser(
|
||||
id = UserId("@id_of_alice:server.org"),
|
||||
username = "Alice",
|
||||
fun aMatrixUser(id: String = "@id_of_alice:server.org", userName: String = "Alice") = MatrixUser(
|
||||
id = UserId(id),
|
||||
username = userName,
|
||||
avatarData = anAvatarData()
|
||||
)
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* 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("java-library")
|
||||
id("com.android.lint")
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.coroutines.core)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.truth)
|
||||
}
|
|
@ -14,9 +14,8 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.core.statemachine
|
||||
package io.element.android.libraries.statemachine
|
||||
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
|
@ -64,7 +63,7 @@ class StateMachine<Event : Any, State : Any>(
|
|||
private fun <E : Event> findMatchingRoute(event: E): StateMachineRoute<E, State, State>? {
|
||||
val routesForEvent = routes.filter { it.eventType.isInstance(event) }
|
||||
|
||||
return (routesForEvent.firstOrNull { it.fromState?.isInstance(currentState).orFalse() }
|
||||
return (routesForEvent.firstOrNull { it.fromState?.isInstance(currentState) == true }
|
||||
?: routesForEvent.firstOrNull { it.fromState == null }) as? StateMachineRoute<E, State, State>
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.core.statemachine
|
||||
package io.element.android.libraries.statemachine
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Assert.fail
|
|
@ -47,6 +47,7 @@
|
|||
|
||||
<string name="verification_positive_button_initial">I am ready</string>
|
||||
<string name="verification_positive_button_canceled">Retry verification</string>
|
||||
<string name="verification_positive_button_ready">Start</string>
|
||||
<string name="verification_positive_button_verifying_start">They match</string>
|
||||
<string name="verification_positive_button_verifying_ongoing">Waiting to match</string>
|
||||
|
||||
|
|
|
@ -77,6 +77,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
|
|||
implementation(project(":libraries:dateformatter:impl"))
|
||||
implementation(project(":libraries:di"))
|
||||
implementation(project(":libraries:session-storage:impl"))
|
||||
implementation(project(":libraries:statemachine"))
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -64,6 +64,7 @@ include(":libraries:encrypted-db")
|
|||
include(":libraries:session-storage:api")
|
||||
include(":libraries:session-storage:impl")
|
||||
include(":libraries:session-storage:impl-memory")
|
||||
include(":libraries:statemachine")
|
||||
|
||||
include(":services:analytics:api")
|
||||
include(":services:analytics:noop")
|
||||
|
@ -72,7 +73,6 @@ include(":services:appnavstate:impl")
|
|||
include(":services:toolbox:api")
|
||||
include(":services:toolbox:impl")
|
||||
|
||||
|
||||
fun includeProjects(directory: File, path: String) {
|
||||
directory.listFiles().orEmpty().forEach { file ->
|
||||
if (file.isDirectory) {
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue