UX cleanup: DM details screen (#2820)
* UX cleanup: user profile. - Move send DM to a CTA button. - Add 'Call' CTA button too when there is a DM with that user and a call is possible. - Add missing tests. * Update screenshots * Add tests for clicking on the avatar --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
17f5b0fe77
commit
2e93d842c7
38 changed files with 396 additions and 56 deletions
1
changelog.d/2818.misc
Normal file
1
changelog.d/2818.misc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
UX cleanup: user profile. Move send DM to a call to action button, add 'Call' CTA too.
|
||||||
|
|
@ -188,6 +188,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||||
override fun onStartDM(roomId: RoomId) {
|
override fun onStartDM(roomId: RoomId) {
|
||||||
plugins<RoomDetailsEntryPoint.Callback>().forEach { it.onOpenRoom(roomId) }
|
plugins<RoomDetailsEntryPoint.Callback>().forEach { it.onOpenRoom(roomId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onStartCall(roomId: RoomId) {
|
||||||
|
ElementCallActivity.start(context, CallType.RoomCall(roomId = roomId, sessionId = room.sessionId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId), callback)
|
val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId), callback)
|
||||||
createNode<RoomMemberDetailsNode>(buildContext, plugins)
|
createNode<RoomMemberDetailsNode>(buildContext, plugins)
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,10 @@ class RoomMemberDetailsNode @AssistedInject constructor(
|
||||||
callback.onStartDM(roomId)
|
callback.onStartDM(roomId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onStartCall(roomId: RoomId) {
|
||||||
|
callback.onStartCall(roomId)
|
||||||
|
}
|
||||||
|
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
|
|
||||||
LaunchedEffect(state.startDmActionState) {
|
LaunchedEffect(state.startDmActionState) {
|
||||||
|
|
@ -89,7 +93,8 @@ class RoomMemberDetailsNode @AssistedInject constructor(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
goBack = this::navigateUp,
|
goBack = this::navigateUp,
|
||||||
onShareUser = ::onShareUser,
|
onShareUser = ::onShareUser,
|
||||||
onDMStarted = ::onStartDM,
|
onDmStarted = ::onStartDM,
|
||||||
|
onStartCall = ::onStartCall,
|
||||||
openAvatarPreview = callback::openAvatarPreview,
|
openAvatarPreview = callback::openAvatarPreview,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,9 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
||||||
var userProfile by remember { mutableStateOf<MatrixUser?>(null) }
|
var userProfile by remember { mutableStateOf<MatrixUser?>(null) }
|
||||||
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
|
val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
|
||||||
|
val isCurrentUser = remember { client.isMe(roomMemberId) }
|
||||||
|
val dmRoomId by userProfilePresenterHelper.getDmRoomId()
|
||||||
|
val canCall by userProfilePresenterHelper.getCanCall(dmRoomId)
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
client.ignoredUsersFlow
|
client.ignoredUsersFlow
|
||||||
.map { ignoredUsers -> roomMemberId in ignoredUsers }
|
.map { ignoredUsers -> roomMemberId in ignoredUsers }
|
||||||
|
|
@ -158,7 +161,9 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
||||||
isBlocked = isBlocked.value,
|
isBlocked = isBlocked.value,
|
||||||
startDmActionState = startDmActionState.value,
|
startDmActionState = startDmActionState.value,
|
||||||
displayConfirmationDialog = confirmationDialog,
|
displayConfirmationDialog = confirmationDialog,
|
||||||
isCurrentUser = client.isMe(roomMemberId),
|
isCurrentUser = isCurrentUser,
|
||||||
|
dmRoomId = dmRoomId,
|
||||||
|
canCall = canCall,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,8 @@ class RoomMemberDetailsPresenterTests {
|
||||||
assertThat(initialState.userName).isEqualTo(roomMember.displayName)
|
assertThat(initialState.userName).isEqualTo(roomMember.displayName)
|
||||||
assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl)
|
assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl)
|
||||||
assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(roomMember.isIgnored))
|
assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(roomMember.isIgnored))
|
||||||
|
assertThat(initialState.dmRoomId).isEqualTo(A_ROOM_ID)
|
||||||
|
assertThat(initialState.canCall).isFalse()
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val loadedState = awaitItem()
|
val loadedState = awaitItem()
|
||||||
assertThat(loadedState.userName).isEqualTo("A custom name")
|
assertThat(loadedState.userName).isEqualTo("A custom name")
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ dependencies {
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.mediaviewer.api)
|
implementation(projects.libraries.mediaviewer.api)
|
||||||
|
implementation(projects.features.call)
|
||||||
api(projects.features.userprofile.api)
|
api(projects.features.userprofile.api)
|
||||||
api(projects.features.userprofile.shared)
|
api(projects.features.userprofile.shared)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.userprofile.impl
|
package io.element.android.features.userprofile.impl
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -28,6 +29,8 @@ import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.features.call.CallType
|
||||||
|
import io.element.android.features.call.ui.ElementCallActivity
|
||||||
import io.element.android.features.userprofile.api.UserProfileEntryPoint
|
import io.element.android.features.userprofile.api.UserProfileEntryPoint
|
||||||
import io.element.android.features.userprofile.impl.root.UserProfileNode
|
import io.element.android.features.userprofile.impl.root.UserProfileNode
|
||||||
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
|
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
|
||||||
|
|
@ -37,9 +40,11 @@ import io.element.android.libraries.architecture.BaseFlowNode
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.architecture.inputs
|
import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
|
||||||
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
|
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
@ -48,6 +53,8 @@ import kotlinx.parcelize.Parcelize
|
||||||
class UserProfileFlowNode @AssistedInject constructor(
|
class UserProfileFlowNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val sessionIdHolder: CurrentSessionIdHolder,
|
||||||
) : BaseFlowNode<UserProfileFlowNode.NavTarget>(
|
) : BaseFlowNode<UserProfileFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.Root,
|
initialElement = NavTarget.Root,
|
||||||
|
|
@ -75,6 +82,10 @@ class UserProfileFlowNode @AssistedInject constructor(
|
||||||
override fun onStartDM(roomId: RoomId) {
|
override fun onStartDM(roomId: RoomId) {
|
||||||
plugins<UserProfileEntryPoint.Callback>().forEach { it.onOpenRoom(roomId) }
|
plugins<UserProfileEntryPoint.Callback>().forEach { it.onOpenRoom(roomId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onStartCall(roomId: RoomId) {
|
||||||
|
ElementCallActivity.start(context, CallType.RoomCall(sessionId = sessionIdHolder.current, roomId = roomId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val params = UserProfileNode.UserProfileInputs(userId = inputs<UserProfileEntryPoint.Params>().userId)
|
val params = UserProfileNode.UserProfileInputs(userId = inputs<UserProfileEntryPoint.Params>().userId)
|
||||||
createNode<UserProfileNode>(buildContext, listOf(callback, params))
|
createNode<UserProfileNode>(buildContext, listOf(callback, params))
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,8 @@ class UserProfileNode @AssistedInject constructor(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
goBack = this::navigateUp,
|
goBack = this::navigateUp,
|
||||||
onShareUser = ::onShareUser,
|
onShareUser = ::onShareUser,
|
||||||
onDMStarted = ::onStartDM,
|
onDmStarted = ::onStartDM,
|
||||||
|
onStartCall = callback::onStartCall,
|
||||||
openAvatarPreview = callback::openAvatarPreview,
|
openAvatarPreview = callback::openAvatarPreview,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,8 @@ class UserProfilePresenter @AssistedInject constructor(
|
||||||
var userProfile by remember { mutableStateOf<MatrixUser?>(null) }
|
var userProfile by remember { mutableStateOf<MatrixUser?>(null) }
|
||||||
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
|
val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
|
||||||
|
val dmRoomId by userProfilePresenterHelper.getDmRoomId()
|
||||||
|
val canCall by userProfilePresenterHelper.getCanCall(dmRoomId)
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
client.ignoredUsersFlow
|
client.ignoredUsersFlow
|
||||||
.map { ignoredUsers -> userId in ignoredUsers }
|
.map { ignoredUsers -> userId in ignoredUsers }
|
||||||
|
|
@ -118,6 +120,8 @@ class UserProfilePresenter @AssistedInject constructor(
|
||||||
startDmActionState = startDmActionState.value,
|
startDmActionState = startDmActionState.value,
|
||||||
displayConfirmationDialog = confirmationDialog,
|
displayConfirmationDialog = confirmationDialog,
|
||||||
isCurrentUser = client.isMe(userId),
|
isCurrentUser = client.isMe(userId),
|
||||||
|
dmRoomId = dmRoomId,
|
||||||
|
canCall = canCall,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,8 @@ class UserProfilePresenterTests {
|
||||||
assertThat(initialState.userName).isEqualTo(matrixUser.displayName)
|
assertThat(initialState.userName).isEqualTo(matrixUser.displayName)
|
||||||
assertThat(initialState.avatarUrl).isEqualTo(matrixUser.avatarUrl)
|
assertThat(initialState.avatarUrl).isEqualTo(matrixUser.avatarUrl)
|
||||||
assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(false))
|
assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(false))
|
||||||
|
assertThat(initialState.dmRoomId).isEqualTo(A_ROOM_ID)
|
||||||
|
assertThat(initialState.canCall).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,12 @@ fun UserProfileHeaderSection(
|
||||||
openAvatarPreview: (url: String) -> Unit,
|
openAvatarPreview: (url: String) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
Box(modifier = Modifier.size(70.dp)) {
|
Box(modifier = Modifier.size(70.dp)) {
|
||||||
Avatar(
|
Avatar(
|
||||||
avatarData = AvatarData(userId.value, userName, avatarUrl, AvatarSize.UserHeader),
|
avatarData = AvatarData(userId.value, userName, avatarUrl, AvatarSize.UserHeader),
|
||||||
|
|
@ -65,6 +70,7 @@ fun UserProfileHeaderSection(
|
||||||
modifier = Modifier.clipToBounds(),
|
modifier = Modifier.clipToBounds(),
|
||||||
text = userName,
|
text = userName,
|
||||||
style = ElementTheme.typography.fontHeadingLgBold,
|
style = ElementTheme.typography.fontHeadingLgBold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,32 @@ import io.element.android.libraries.designsystem.components.button.MainActionBut
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UserProfileMainActionsSection(onShareUser: () -> Unit, modifier: Modifier = Modifier) {
|
fun UserProfileMainActionsSection(
|
||||||
|
isCurrentUser: Boolean,
|
||||||
|
canCall: Boolean,
|
||||||
|
onShareUser: () -> Unit,
|
||||||
|
onStartDM: () -> Unit,
|
||||||
|
onCall: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
) {
|
) {
|
||||||
|
if (!isCurrentUser) {
|
||||||
|
MainActionButton(
|
||||||
|
title = stringResource(CommonStrings.action_message),
|
||||||
|
imageVector = CompoundIcons.Chat(),
|
||||||
|
onClick = onStartDM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (canCall) {
|
||||||
|
MainActionButton(
|
||||||
|
title = stringResource(CommonStrings.action_call),
|
||||||
|
imageVector = CompoundIcons.VideoCall(),
|
||||||
|
onClick = onCall,
|
||||||
|
)
|
||||||
|
}
|
||||||
MainActionButton(
|
MainActionButton(
|
||||||
title = stringResource(CommonStrings.action_share),
|
title = stringResource(CommonStrings.action_share),
|
||||||
imageVector = CompoundIcons.ShareAndroid(),
|
imageVector = CompoundIcons.ShareAndroid(),
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ class UserProfileNodeHelper(
|
||||||
interface Callback : NodeInputs {
|
interface Callback : NodeInputs {
|
||||||
fun openAvatarPreview(username: String, avatarUrl: String)
|
fun openAvatarPreview(username: String, avatarUrl: String)
|
||||||
fun onStartDM(roomId: RoomId)
|
fun onStartDM(roomId: RoomId)
|
||||||
|
fun onStartCall(roomId: RoomId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onShareUser(
|
fun onShareUser(
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,14 @@
|
||||||
|
|
||||||
package io.element.android.features.userprofile.shared
|
package io.element.android.features.userprofile.shared
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -27,6 +32,24 @@ class UserProfilePresenterHelper(
|
||||||
private val userId: UserId,
|
private val userId: UserId,
|
||||||
private val client: MatrixClient,
|
private val client: MatrixClient,
|
||||||
) {
|
) {
|
||||||
|
@Composable
|
||||||
|
fun getDmRoomId(): State<RoomId?> {
|
||||||
|
return produceState<RoomId?>(initialValue = null) {
|
||||||
|
value = client.findDM(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun getCanCall(roomId: RoomId?): State<Boolean> {
|
||||||
|
return produceState(initialValue = false, roomId) {
|
||||||
|
value = if (client.isMe(userId)) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
roomId?.let { client.getRoom(it)?.canUserJoinCall(client.sessionId)?.getOrNull() == true }.orFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun blockUser(
|
fun blockUser(
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
isBlockedState: MutableState<AsyncData<Boolean>>,
|
isBlockedState: MutableState<AsyncData<Boolean>>,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ data class UserProfileState(
|
||||||
val startDmActionState: AsyncAction<RoomId>,
|
val startDmActionState: AsyncAction<RoomId>,
|
||||||
val displayConfirmationDialog: ConfirmationDialog?,
|
val displayConfirmationDialog: ConfirmationDialog?,
|
||||||
val isCurrentUser: Boolean,
|
val isCurrentUser: Boolean,
|
||||||
|
val dmRoomId: RoomId?,
|
||||||
|
val canCall: Boolean,
|
||||||
val eventSink: (UserProfileEvents) -> Unit
|
val eventSink: (UserProfileEvents) -> Unit
|
||||||
) {
|
) {
|
||||||
enum class ConfirmationDialog {
|
enum class ConfirmationDialog {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ open class UserProfileStateProvider : PreviewParameterProvider<UserProfileState>
|
||||||
aUserProfileState(displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock),
|
aUserProfileState(displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock),
|
||||||
aUserProfileState(isBlocked = AsyncData.Loading(true)),
|
aUserProfileState(isBlocked = AsyncData.Loading(true)),
|
||||||
aUserProfileState(startDmActionState = AsyncAction.Loading),
|
aUserProfileState(startDmActionState = AsyncAction.Loading),
|
||||||
|
aUserProfileState(canCall = true),
|
||||||
|
aUserProfileState(dmRoomId = null),
|
||||||
// Add other states here
|
// Add other states here
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -44,6 +46,8 @@ fun aUserProfileState(
|
||||||
startDmActionState: AsyncAction<RoomId> = AsyncAction.Uninitialized,
|
startDmActionState: AsyncAction<RoomId> = AsyncAction.Uninitialized,
|
||||||
displayConfirmationDialog: UserProfileState.ConfirmationDialog? = null,
|
displayConfirmationDialog: UserProfileState.ConfirmationDialog? = null,
|
||||||
isCurrentUser: Boolean = false,
|
isCurrentUser: Boolean = false,
|
||||||
|
dmRoomId: RoomId? = null,
|
||||||
|
canCall: Boolean = false,
|
||||||
eventSink: (UserProfileEvents) -> Unit = {},
|
eventSink: (UserProfileEvents) -> Unit = {},
|
||||||
) = UserProfileState(
|
) = UserProfileState(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
|
|
@ -53,5 +57,7 @@ fun aUserProfileState(
|
||||||
startDmActionState = startDmActionState,
|
startDmActionState = startDmActionState,
|
||||||
displayConfirmationDialog = displayConfirmationDialog,
|
displayConfirmationDialog = displayConfirmationDialog,
|
||||||
isCurrentUser = isCurrentUser,
|
isCurrentUser = isCurrentUser,
|
||||||
|
dmRoomId = dmRoomId,
|
||||||
|
canCall = canCall,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.userprofile.shared
|
package io.element.android.features.userprofile.shared
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
|
@ -29,20 +30,14 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
|
||||||
import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
|
import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
|
||||||
import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
|
import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
|
||||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||||
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
|
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
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.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
@ -52,11 +47,13 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
fun UserProfileView(
|
fun UserProfileView(
|
||||||
state: UserProfileState,
|
state: UserProfileState,
|
||||||
onShareUser: () -> Unit,
|
onShareUser: () -> Unit,
|
||||||
onDMStarted: (RoomId) -> Unit,
|
onDmStarted: (RoomId) -> Unit,
|
||||||
|
onStartCall: (RoomId) -> Unit,
|
||||||
goBack: () -> Unit,
|
goBack: () -> Unit,
|
||||||
openAvatarPreview: (username: String, url: String) -> Unit,
|
openAvatarPreview: (username: String, url: String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
BackHandler { goBack() }
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
|
|
@ -78,12 +75,17 @@ fun UserProfileView(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
UserProfileMainActionsSection(onShareUser = onShareUser)
|
UserProfileMainActionsSection(
|
||||||
|
isCurrentUser = state.isCurrentUser,
|
||||||
|
canCall = state.canCall,
|
||||||
|
onShareUser = onShareUser,
|
||||||
|
onStartDM = { state.eventSink(UserProfileEvents.StartDM) },
|
||||||
|
onCall = { state.dmRoomId?.let { onStartCall(it) } }
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(26.dp))
|
Spacer(modifier = Modifier.height(26.dp))
|
||||||
|
|
||||||
if (!state.isCurrentUser) {
|
if (!state.isCurrentUser) {
|
||||||
StartDMSection(onStartDMClicked = { state.eventSink(UserProfileEvents.StartDM) })
|
|
||||||
BlockUserSection(state)
|
BlockUserSection(state)
|
||||||
BlockUserDialogs(state)
|
BlockUserDialogs(state)
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +96,7 @@ fun UserProfileView(
|
||||||
progressText = stringResource(CommonStrings.common_starting_chat),
|
progressText = stringResource(CommonStrings.common_starting_chat),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onSuccess = onDMStarted,
|
onSuccess = onDmStarted,
|
||||||
errorMessage = { stringResource(R.string.screen_start_chat_error_starting_chat) },
|
errorMessage = { stringResource(R.string.screen_start_chat_error_starting_chat) },
|
||||||
onRetry = { state.eventSink(UserProfileEvents.StartDM) },
|
onRetry = { state.eventSink(UserProfileEvents.StartDM) },
|
||||||
onErrorDismiss = { state.eventSink(UserProfileEvents.ClearStartDMState) },
|
onErrorDismiss = { state.eventSink(UserProfileEvents.ClearStartDMState) },
|
||||||
|
|
@ -103,18 +105,6 @@ fun UserProfileView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun StartDMSection(
|
|
||||||
onStartDMClicked: () -> Unit,
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text(stringResource(CommonStrings.common_direct_chat)) },
|
|
||||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Chat())),
|
|
||||||
style = ListItemStyle.Primary,
|
|
||||||
onClick = onStartDMClicked,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun UserProfileViewPreview(
|
internal fun UserProfileViewPreview(
|
||||||
|
|
@ -124,7 +114,8 @@ internal fun UserProfileViewPreview(
|
||||||
state = state,
|
state = state,
|
||||||
onShareUser = {},
|
onShareUser = {},
|
||||||
goBack = {},
|
goBack = {},
|
||||||
onDMStarted = {},
|
onDmStarted = {},
|
||||||
|
onStartCall = {},
|
||||||
openAvatarPreview = { _, _ -> }
|
openAvatarPreview = { _, _ -> }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,235 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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.userprofile
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.hasTestTag
|
||||||
|
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import io.element.android.features.userprofile.shared.R
|
||||||
|
import io.element.android.features.userprofile.shared.UserProfileEvents
|
||||||
|
import io.element.android.features.userprofile.shared.UserProfileState
|
||||||
|
import io.element.android.features.userprofile.shared.UserProfileView
|
||||||
|
import io.element.android.features.userprofile.shared.aUserProfileState
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
|
import io.element.android.libraries.testtags.TestTags
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||||
|
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||||
|
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
|
||||||
|
import io.element.android.tests.testutils.EventsRecorder
|
||||||
|
import io.element.android.tests.testutils.clickOn
|
||||||
|
import io.element.android.tests.testutils.ensureCalledOnce
|
||||||
|
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||||
|
import io.element.android.tests.testutils.ensureCalledOnceWithTwoParams
|
||||||
|
import io.element.android.tests.testutils.pressBack
|
||||||
|
import io.element.android.tests.testutils.pressBackKey
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class UserProfileViewTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on back key press - the expected callback is called`() = runTest {
|
||||||
|
ensureCalledOnce { callback ->
|
||||||
|
rule.setUserProfileView(
|
||||||
|
goBack = callback,
|
||||||
|
)
|
||||||
|
rule.pressBackKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on back button click - the expected callback is called`() = runTest {
|
||||||
|
ensureCalledOnce { callback ->
|
||||||
|
rule.setUserProfileView(
|
||||||
|
goBack = callback,
|
||||||
|
)
|
||||||
|
rule.pressBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on avatar clicked - the expected callback is called`() = runTest {
|
||||||
|
ensureCalledOnceWithTwoParams(A_USER_NAME, AN_AVATAR_URL) { callback ->
|
||||||
|
rule.setUserProfileView(
|
||||||
|
state = aUserProfileState(userName = A_USER_NAME, avatarUrl = AN_AVATAR_URL),
|
||||||
|
openAvatarPreview = callback,
|
||||||
|
)
|
||||||
|
rule.onNode(hasTestTag(TestTags.memberDetailAvatar.value)).performClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on avatar clicked with no avatar - nothing happens`() = runTest {
|
||||||
|
val callback = EnsureNeverCalledWithTwoParams<String, String>()
|
||||||
|
rule.setUserProfileView(
|
||||||
|
state = aUserProfileState(userName = A_USER_NAME, avatarUrl = null),
|
||||||
|
openAvatarPreview = callback,
|
||||||
|
)
|
||||||
|
rule.onNode(hasTestTag(TestTags.memberDetailAvatar.value)).performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on Share clicked - the expected callback is called`() = runTest {
|
||||||
|
ensureCalledOnce { callback ->
|
||||||
|
rule.setUserProfileView(
|
||||||
|
onShareUser = callback,
|
||||||
|
)
|
||||||
|
rule.clickOn(CommonStrings.action_share)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on Message clicked - the StartDm event is emitted`() = runTest {
|
||||||
|
val eventsRecorder = EventsRecorder<UserProfileEvents>()
|
||||||
|
rule.setUserProfileView(
|
||||||
|
state = aUserProfileState(
|
||||||
|
dmRoomId = A_ROOM_ID,
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rule.clickOn(CommonStrings.action_message)
|
||||||
|
eventsRecorder.assertSingle(UserProfileEvents.StartDM)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on Call clicked - the expected callback is called`() = runTest {
|
||||||
|
ensureCalledOnceWithParam(A_ROOM_ID) { callback ->
|
||||||
|
rule.setUserProfileView(
|
||||||
|
state = aUserProfileState(
|
||||||
|
dmRoomId = A_ROOM_ID,
|
||||||
|
canCall = true,
|
||||||
|
),
|
||||||
|
onStartCall = callback,
|
||||||
|
)
|
||||||
|
rule.clickOn(CommonStrings.action_call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on Block user clicked - a BlockUser event is emitted with needsConfirmation`() = runTest {
|
||||||
|
val eventsRecorder = EventsRecorder<UserProfileEvents>()
|
||||||
|
rule.setUserProfileView(
|
||||||
|
state = aUserProfileState(
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rule.clickOn(R.string.screen_dm_details_block_user)
|
||||||
|
eventsRecorder.assertSingle(UserProfileEvents.BlockUser(needsConfirmation = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on confirming block user - a BlockUser event is emitted without needsConfirmation`() = runTest {
|
||||||
|
val eventsRecorder = EventsRecorder<UserProfileEvents>()
|
||||||
|
rule.setUserProfileView(
|
||||||
|
state = aUserProfileState(
|
||||||
|
displayConfirmationDialog = UserProfileState.ConfirmationDialog.Block,
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rule.clickOn(R.string.screen_dm_details_block_alert_action)
|
||||||
|
eventsRecorder.assertSingle(UserProfileEvents.BlockUser(needsConfirmation = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on canceling blocking a user - a ClearConfirmationDialog event is emitted`() = runTest {
|
||||||
|
val eventsRecorder = EventsRecorder<UserProfileEvents>()
|
||||||
|
rule.setUserProfileView(
|
||||||
|
state = aUserProfileState(
|
||||||
|
displayConfirmationDialog = UserProfileState.ConfirmationDialog.Block,
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rule.clickOn(CommonStrings.action_cancel)
|
||||||
|
eventsRecorder.assertSingle(UserProfileEvents.ClearConfirmationDialog)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on Unblock user clicked - an UnblockUser event is emitted with needsConfirmation`() = runTest {
|
||||||
|
val eventsRecorder = EventsRecorder<UserProfileEvents>()
|
||||||
|
rule.setUserProfileView(
|
||||||
|
state = aUserProfileState(
|
||||||
|
isBlocked = AsyncData.Success(true),
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rule.clickOn(R.string.screen_dm_details_unblock_user)
|
||||||
|
eventsRecorder.assertSingle(UserProfileEvents.UnblockUser(needsConfirmation = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on confirming Unblock user - an UnblockUser event is emitted without needsConfirmation`() = runTest {
|
||||||
|
val eventsRecorder = EventsRecorder<UserProfileEvents>()
|
||||||
|
rule.setUserProfileView(
|
||||||
|
state = aUserProfileState(
|
||||||
|
isBlocked = AsyncData.Success(true),
|
||||||
|
displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock,
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rule.clickOn(R.string.screen_dm_details_unblock_alert_action)
|
||||||
|
eventsRecorder.assertSingle(UserProfileEvents.UnblockUser(needsConfirmation = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on canceling unblocking a user - a ClearConfirmationDialog event is emitted`() = runTest {
|
||||||
|
val eventsRecorder = EventsRecorder<UserProfileEvents>()
|
||||||
|
rule.setUserProfileView(
|
||||||
|
state = aUserProfileState(
|
||||||
|
isBlocked = AsyncData.Success(true),
|
||||||
|
displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock,
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rule.clickOn(CommonStrings.action_cancel)
|
||||||
|
eventsRecorder.assertSingle(UserProfileEvents.ClearConfirmationDialog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setUserProfileView(
|
||||||
|
state: UserProfileState = aUserProfileState(
|
||||||
|
eventSink = EventsRecorder(expectEvents = false),
|
||||||
|
),
|
||||||
|
onShareUser: () -> Unit = EnsureNeverCalled(),
|
||||||
|
onDmStarted: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
|
onStartCall: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
|
goBack: () -> Unit = EnsureNeverCalled(),
|
||||||
|
openAvatarPreview: (String, String) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||||
|
) {
|
||||||
|
setContent {
|
||||||
|
UserProfileView(
|
||||||
|
state = state,
|
||||||
|
onShareUser = onShareUser,
|
||||||
|
onDmStarted = onDmStarted,
|
||||||
|
onStartCall = onStartCall,
|
||||||
|
goBack = goBack,
|
||||||
|
openAvatarPreview = openAvatarPreview,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -73,6 +73,7 @@
|
||||||
<string name="action_load_more">"Load more"</string>
|
<string name="action_load_more">"Load more"</string>
|
||||||
<string name="action_manage_account">"Manage account"</string>
|
<string name="action_manage_account">"Manage account"</string>
|
||||||
<string name="action_manage_devices">"Manage devices"</string>
|
<string name="action_manage_devices">"Manage devices"</string>
|
||||||
|
<string name="action_message">"Message"</string>
|
||||||
<string name="action_next">"Next"</string>
|
<string name="action_next">"Next"</string>
|
||||||
<string name="action_no">"No"</string>
|
<string name="action_no">"No"</string>
|
||||||
<string name="action_not_now">"Not now"</string>
|
<string name="action_not_now">"Not now"</string>
|
||||||
|
|
|
||||||
|
|
@ -105,3 +105,9 @@ fun <T, R> ensureCalledOnceWithParam(param: T, block: (callback: EnsureCalledOnc
|
||||||
block(callback)
|
block(callback)
|
||||||
callback.assertSuccess()
|
callback.assertSuccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun <P1, P2> ensureCalledOnceWithTwoParams(param1: P1, param2: P2, block: (callback: EnsureCalledOnceWithTwoParams<P1, P2>) -> Unit) {
|
||||||
|
val callback = EnsureCalledOnceWithTwoParams(param1, param2)
|
||||||
|
block(callback)
|
||||||
|
callback.assertSuccess()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8fc6369578a3684c1a88e904848bcb16a7e4cde46c887938db909563c6c54d2d
|
oid sha256:96414480d9e361319abf7543324927300f841633f628da68901d6291b8cfd675
|
||||||
size 23031
|
size 22667
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:59e3eccfa962e9d667aaa326ab3258214334db558983bc251543835585d78453
|
oid sha256:7388e54cc1fccc87a3257b099aa832f851aabace145cc09f965431653fc0cf35
|
||||||
size 20930
|
size 20661
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:07418c91e8efee98f7b9e7d069bf3aef2b674164dad0795719f97ad6b214225b
|
oid sha256:a5ca4c3f9c7376656357f07a6b1777bca92808eeb972f9f7d03bdac89a6c5867
|
||||||
size 23550
|
size 22987
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:90fe61eb831db5e1f1eb213fcdd690f06f2e547b8ad864680288d529d379d30e
|
oid sha256:2348853735daae624c99658d832382fd834a770b40fde0004089f6378ecb954c
|
||||||
size 43651
|
size 41498
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a6b0328e4f5a06b59ac5b73f4230bd950bed6847af275b9e6851ffdb9403742b
|
oid sha256:364172de34c530ce2cb4799e0b5ac5aa810068794bab10c9f04f72f709ebd8fa
|
||||||
size 35523
|
size 32482
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:7536568491a41526b48354b60db41e47d40b6677e5b38f464c57715499514171
|
oid sha256:1a956800e49a48da34a49f7ec4c2647641a1c64af70e4ccbcdbdac0fb0cc5616
|
||||||
size 24193
|
size 23513
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:902bd8ddad85ee2ea52898a910baf0c710880d94c36f56e22916bf4d15f4469c
|
oid sha256:01308cc0d4361e2685a16ce02526a9efdc4f81b86fdb982b016e090bcafa9bbb
|
||||||
size 25864
|
size 23486
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ab8949d70455a9b3ef5d6414b788cb1cddec95fc238df8277dfd41c405a4060c
|
||||||
|
size 23581
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:96414480d9e361319abf7543324927300f841633f628da68901d6291b8cfd675
|
||||||
|
size 22667
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1744d73991a0987c53d214041a26eaf7a3ce8a2ff4c90739f65f8641c4af921d
|
oid sha256:4a6807e02706f29b5b748cfbbb13f9e1437e38438da1c81fa568709e5f88d7a4
|
||||||
size 22351
|
size 22041
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4598d748f84146e66b547a5d9310c7e67477e1eb66d1c58bfb23f28807b7f54d
|
oid sha256:b9b5cf8ae7992a8ef83056daa2fb8ebff0c60310ec675efa0e45faa58c0e58ad
|
||||||
size 20263
|
size 20049
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3d448afa778c03771454d5493f15748dce3eeb7a7b35cd33cb38bb5a06646f5d
|
oid sha256:e256df9ae5a854a906a21868ecdf21a5d2ce37577a69a1da557949562d1da45d
|
||||||
size 22550
|
size 22193
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e000e964df93279e8325219a8a9ae74d1f66c533d418fabb88a208bc1165dbed
|
oid sha256:aefcccd96088200097cd12a934b27df29819044ab315f3e0a3e1df9228336669
|
||||||
size 38724
|
size 36801
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:bf1d5e3367a9684cd4fd8ffebd4c38475fad0a6a6fa122df19281c1a3bf7349f
|
oid sha256:561230a37864cced2286168b6bfe7c9e461177c056386dc08e538643d58b15a6
|
||||||
size 30814
|
size 28162
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:15c3812a12a76e5701bc1f487703ee32d6920f3c2d790f5b1d1a4b468da28000
|
oid sha256:125d38504d4500506fcb9d85eb9ce057620e683674efb2e6b33356764a14a1af
|
||||||
size 23047
|
size 22677
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a3fefee021f7c3bc6a8b950a129fd7549b05416ff72b32b2b24a4317555f43d8
|
oid sha256:5600ca86ffac9b33c320432dfb75b6242d7726ef39de0bc0e227b166565fa91f
|
||||||
size 22916
|
size 20622
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8cc9c9f6cdf542c4817dfcf54f43ebd3e8c7fc47d36a50e60be28642dfbdf8ae
|
||||||
|
size 22868
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4a6807e02706f29b5b748cfbbb13f9e1437e38438da1c81fa568709e5f88d7a4
|
||||||
|
size 22041
|
||||||
Loading…
Add table
Add a link
Reference in a new issue