Merge branch 'develop' into feature/fga/module_templates

This commit is contained in:
ganfra 2023-03-27 17:21:45 +02:00
commit 8a41f60585
108 changed files with 1590 additions and 383 deletions

View file

@ -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")
}
}

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

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

1
changelog.d/89.bugfix Normal file
View file

@ -0,0 +1 @@
Design fixes for the session verification flow and button components.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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)
}

View file

@ -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)

View file

@ -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
)
}
},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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)

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
)
}

View file

@ -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))
}
}

View file

@ -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()
}

View file

@ -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"

View file

@ -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,

View file

@ -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()
}

View file

@ -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?) {

View file

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

View file

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

View file

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

View file

@ -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)
}

View file

@ -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>
}

View file

@ -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

View file

@ -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>

View file

@ -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"))
}

View file

@ -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) {

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:55d774d5e61f8832162859ec9d22299efa5b0728cfc3308b13fb11df31c5130e
size 13790

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:489aa166bf6d0f283da497b2752e457f81620959d404b5b9c3d96c7e18e8b953
size 28100

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0a8969c9a82f41bbdbdfb013412d039f3be4fcf450a0dd55f3217c64d51c8489
size 23495

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2ed229820d7cbd6b550e98116ec3bf688c52957e2a57ab296bfc5abd212345b1
size 13784

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d5f29443193cd21eeb721a4c86f9063466439dd5ad4830708e6fa587f95e5abd
size 27456

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fa79c54d2431dc0c8399a2bcb8e03d73749e9d54f505ac893e2376fded0e7dfe
size 22599

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:89ea65099fb4981bbeb24e49afb7400b0e8da79e3b783e198519ba1e970404a8
size 8317

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9a8ba207cf61c56b64c6855d04c8a30def0b3daf325adf57edd45b40981ed745
size 7733

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b6703f2cba99aa60bdf3ecd380a46846f0e1b95a0b2718ed707348b6bc1713b1
size 107508

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b8de2f28cf9918a7eddcc1311c34ba0376242a4708724b57689df000b7480524
size 8197

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:687133e4729ea42382ac0b76af6ceaf8b20cbc65923412fb97b077d2475c70f7
size 7514

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d8e1ca8a115c3e46c7f24fe41dc6c9448ffecc65a05c6be1945b84f35d60a099
size 103642

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:47cc4075122ec2d0a1420e6c3c8765f6d28b521927034067a1ff1c877bf069fb
size 41214
oid sha256:2fac7314c1dee8d4570922b984b0749f24ec8984e5e602892e2e4ed925d143b6
size 41145

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b8d776746230925c8b35bb688ae51d3b3eca0ebee6beb9e716dabcbf4ae57361
size 44563
oid sha256:dff29249aabb7818151e55dbc9a1290bc09cf0e638e3ad2842791904b14368ca
size 44381

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c22ea865154e6c55cdc04acd4a96c3d0c87e1918c29604d72d37d1b29466ff46
size 43877
oid sha256:cb351458d7b5d70f2201f94e4466d5c7dbebb60bca164a986967d2d8006405da
size 43814

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:95a4f4ce5871c6c65dfd732bde01ea3a80c47fd97a83e306642b4b3821b23b65
size 48597
oid sha256:4363256d328a0ab2cc3966b42827c7a7319bee962710a0bac542f6494799ff71
size 48526

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:74483873056b7dab096936e09e07249b84ffebe42619f20a829e9994b3b01ced
size 43840
oid sha256:afa9ab67471727f4c396d1919fd1187ec28a40b8cb9b03c2b35400c5e32f4eda
size 43776

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1c2700fd98861f5bb34ee48b24c7ddceea57971fbe9e9b0aa0d5b24edc90cc6a
size 43809
oid sha256:6b724d1b93a04117fc3cc405920fd22530e004cc9ba9c39f88ca1a2a2a4229c4
size 43743

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:86c3bc2523fd194a87d062bb122a8d84d412db419c0a9e2f0c6e51ccd338119a
size 37809
oid sha256:28c23058fdd9b24a7079251a2ab1ae2f85dafe0c1d4f01be3ffeca91984e0805
size 37703

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9e0c3a87fb5fc5c3e8c31df3c071bf69b956e0dcfc35f5168452253c9e55b6fd
size 41535
oid sha256:7779d6bf28eca6e72f9f1bcbd8b27881d0df0e594041269367a1f909e941cec9
size 41368

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f841b8076376b61a21c517e93627992a179ef1042566133367229c4926322279
size 40436
oid sha256:f51052d3ece86fe7e07cc686a539b81d23f795c64bf66bd093b9d0e8e9fc7192
size 40335

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:091e106c67c1a503c98c36a0ed58a3cb08461840643cd2fe2ffc7e1f46ab3774
size 47711
oid sha256:27d2d879493e8ed1cc17dcdbb384504126fad63eb7e53a069f530457c35b94b3
size 47609

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9496dd89a284f77cb01295a69dcf60ccdb566224560139d13f926e38a0bd226c
size 40436
oid sha256:66e9060ad93cec0e1bf8e284ac959a713a8201a33ff931aeb61aaf29ab7d9640
size 40335

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:be6c0687f8ac84831db038a9846aefba81c4a6af08237d89b2cdba5676f31142
size 40419
oid sha256:7f7ecb805b73afe15db56853fec77d92f28fc9f8080dc7f1b2bb9f99cc262719
size 40317

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ae82ef6285127f343f0ebee35cedc6f28132dde37c3303bbc33aa881c471c48c
size 32466
oid sha256:a302148918d0f328b778c18de007538724879fd6f02babf667504ab049c02511
size 32428

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:50627ff6140b8fb9eed181e812740a8bf8cf4cdb23cb4cf2fa9cd0b37fb880bc
size 35224
oid sha256:1f67b9a62ee3628af0308b2a01883e09d4068c3c833a79c702a204942e2d2133
size 35180

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8f94523e18a1eb82324325f0e3f8439d4d457d6d4883cd75a7fa6169f90bbdf4
size 34229
oid sha256:5c0b9edebd50e38f07ae3663f0f760674e8d9194d41077c70f93d8af46a78f0a
size 34139

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fe3431804d6d84b8588b0fb475bd824159abe4d0a7e35f0b7967b08477b07778
size 33523
oid sha256:3d552e8ad5f20ce2c08c3f7a4b970d552743b473acdc7a75d5414520a9db7421
size 33475

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3d1c502239f0fd9cdd7fa286275d299b74597a2b0b5d0f081503a7db3cb39f52
size 30277
oid sha256:f548bca0c797f35eec698f28daf74d409210d076b91ef5e4db1501ed12cefb88
size 30197

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8f94523e18a1eb82324325f0e3f8439d4d457d6d4883cd75a7fa6169f90bbdf4
size 34229
oid sha256:5c0b9edebd50e38f07ae3663f0f760674e8d9194d41077c70f93d8af46a78f0a
size 34139

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e3e148e980f5096934ec4742a3f8b05442f0019dbcf6435fcdc1875c326a6ae
size 31513
oid sha256:7890d36ac726a7f0764319a72d0d3aca13d2c1d382616f0f1bdb5cc19926b168
size 31455

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:21e9c4c0ecf5a348361ff30195195743192cb784c9d6d248ca83010e3646c39f
size 34212
oid sha256:978d1b6b40b4109a1dd941574b08d39093c5a1eeaf3da18c24a677080bfb378a
size 34158

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:04f73ec1830baa28808881f040f6bfc2b1ce0de1ca2d4b3e16a3b57513814a7f
size 33173
oid sha256:785ad6fe0af439444b469ae0189ea627600fc0ba71f1dff1af470ce10ce81d4e
size 33105

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da84d9195ed0cac06efff562156ed8abc161a6dbd3272727c1cb787b83c62558
size 32253
oid sha256:ea3ff5e912f88f12bdbf65ce027a5bde87472bbafd0146ebf609f36027dda31a
size 32194

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ea2dce0fde5ff6563f283e643edadd5c95c29a4084c8f1d3902ec3c759cccea7
size 30070
oid sha256:09ff583713751988f4ccdb465b1b53d88b788d6b275512afde08ce4e5d3e4346
size 30022

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:04f73ec1830baa28808881f040f6bfc2b1ce0de1ca2d4b3e16a3b57513814a7f
size 33173
oid sha256:785ad6fe0af439444b469ae0189ea627600fc0ba71f1dff1af470ce10ce81d4e
size 33105

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:474c799a08ccba7c8b4d9ba3d120b103d0e8bb9654bb4da576d4eee579e5d517
size 28222
oid sha256:5a06f563a1b006bd007c123a03486bb26ec93029e65c3a3c71a6ead01f747ddf
size 28139

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c705a9c97a0bc5a2987cef4db6fd9b79de1cf1f5405035c6371615fb72fc5f36
size 27715
oid sha256:8796755d5118f24c6d3632cf1b0cee2b1d2b3743620d24e65f57ceb27bc7a48e
size 27627

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f653439749b16d9f683b63984855e2dad576e5d567eeed792f28e9484b12b67f
size 60599
oid sha256:d51bcda4d9b04e7569aff45bcf599c779bbcad52de8c4700036abe4084128707
size 60513

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d88db44fb66340043d83e256b1eb7655e66af5e11daa55bd1336b4ff6e57883d
size 59472
oid sha256:69f1674c3e4dc3afc194c0a7bd1064ae37722cc132ead43bf02a1183cc1e2ece
size 59392

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:07e72c29b3a928eaa86ec7c97cb680cf2eaacebf6512390eb9dda47bdc2a9c7e
size 29249
oid sha256:b964fd69198367cd702d8ff09d75a79a66bbee22798cbaa381fabde9bab5fda0
size 29646

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:76b4ac8395ac876c4ff979bd92279b9735fae581987326c6ab929e8578335c5a
size 26657
oid sha256:0162d20f04bfdd0fdd6eb6e00b18b5774f83f07a5c7a72d20d0f51988b05dc66
size 26899

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ca4957bb6995efbf398bf211da38cafb6b2c1693167f9c940d6a31343bfcf127
size 61454
oid sha256:2f38843f426b531b5ec9febae2d2d442bfdf29c9fe8dda52bf27a53c8226a9d2
size 60873

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7590a2604bb93de8a416e7e45b6c410c27fd83481cfb08f3f41e820d22fc5582
size 62046
oid sha256:c97fd08e6cf3df11f285c1e94d869e492b168be339dcbe2a3a841395b9549f12
size 61452

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4861b0c969a8af115ad2638b54f5893e813633a61bebb8bccafa01cfda320ea9
size 31937
oid sha256:15b907fd9177e6e0b7b3e0371f42899099913d40b439da2addb18afb912dc69d
size 32864

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:949bd00ccc555ef5e3218d37e418a45c303d7eab6af652f5d48d380b1a8e19ca
size 27302

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4ee6ee256e59932e2d6aa29c393ec30499da06c6759ed33e8298838618bbfb9f
size 28465
oid sha256:97a422ee19417033b00cbc829f4755992be068b42d2889597ad0bba68dd18fe4
size 29172

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:586954a4fae4405f47a02140ec0ea9c89b2f6becc1b9b852d0a8fa28e10bf089
size 25959
oid sha256:dd0c4a8485e00b917265b6061ee901a9f131883069621d55f3f5c9a42673046c
size 26182

Some files were not shown because too many files have changed in this diff Show more