UX cleanup: room details (#2816)

* UX cleanup: room details screen

Add new CTA buttons for Invite and Call actions

* Update screenshots

* Fix maestro

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa 2024-05-08 11:42:33 +02:00 committed by GitHub
parent d86b7d24db
commit 46b22d7db7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 149 additions and 94 deletions

View file

@ -16,7 +16,7 @@ appId: ${MAESTRO_APP_ID}
- tapOn: "Create"
- takeScreenshot: build/maestro/320-createAndDeleteRoom
- tapOn: "aRoomName"
- tapOn: "Invite people"
- tapOn: "Invite"
# assert there's 1 member and 1 invitee
- tapOn: "Search for someone"
- inputText: ${MAESTRO_INVITEE2_MXID}

1
changelog.d/2814.misc Normal file
View file

@ -0,0 +1 @@
UX cleanup: room details screen, add new CTA buttons for Invite and Call actions.

View file

@ -86,6 +86,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.room.canCall
import io.element.android.libraries.matrix.ui.room.canRedactOtherAsState
import io.element.android.libraries.matrix.ui.room.canRedactOwnAsState
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
@ -158,9 +159,7 @@ class MessagesPresenter @AssistedInject constructor(
mutableStateOf(false)
}
var canJoinCall by rememberSaveable {
mutableStateOf(false)
}
val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
LaunchedEffect(Unit) {
// Remove the unread flag on entering but don't send read receipts
@ -170,12 +169,6 @@ class MessagesPresenter @AssistedInject constructor(
}
}
LaunchedEffect(syncUpdateFlow.value) {
withContext(dispatchers.io) {
canJoinCall = room.canUserJoinCall(room.sessionId).getOrDefault(false)
}
}
val inviteProgress = remember { mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized) }
var showReinvitePrompt by remember { mutableStateOf(false) }
LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow.value) {

View file

@ -288,7 +288,7 @@ class MessagesPresenterTest {
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent()))
val finalState = awaitItem()
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@ -298,10 +298,9 @@ class MessagesPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(2)
val initialState = awaitItem()
val initialState = awaitFirstItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null)))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(initialState.actionListState.target).isEqualTo(ActionListState.Target.None)
// Otherwise we would have some extra items here
ensureAllEventsConsumed()
}
@ -335,7 +334,7 @@ class MessagesPresenterTest {
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@ -368,7 +367,7 @@ class MessagesPresenterTest {
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@ -394,7 +393,7 @@ class MessagesPresenterTest {
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@ -408,7 +407,7 @@ class MessagesPresenterTest {
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent()))
val finalState = awaitItem()
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Edit::class.java)
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@ -732,7 +731,7 @@ class MessagesPresenterTest {
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
assertThat(replyMode.attachmentThumbnailInfo?.textContent)
.isEqualTo("What type of food should we have at the party?")
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(finalState.actionListState.target).isEqualTo(ActionListState.Target.None)
}
}

View file

@ -56,8 +56,9 @@ dependencies {
api(projects.libraries.usersearch.api)
api(projects.services.apperror.api)
implementation(libs.coil.compose)
implementation(projects.features.leaveroom.api)
implementation(projects.features.call)
implementation(projects.features.createroom.api)
implementation(projects.features.leaveroom.api)
implementation(projects.features.userprofile.shared)
implementation(projects.services.analytics.api)
implementation(projects.features.poll.api)

View file

@ -16,6 +16,7 @@
package io.element.android.features.roomdetails.impl
import android.content.Context
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -28,6 +29,8 @@ 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.call.CallType
import io.element.android.features.call.ui.ElementCallActivity
import io.element.android.features.poll.api.history.PollHistoryEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode
@ -42,10 +45,12 @@ import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
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.media.MediaSource
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
import kotlinx.parcelize.Parcelize
@ -54,7 +59,9 @@ import kotlinx.parcelize.Parcelize
class RoomDetailsFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@ApplicationContext private val context: Context,
private val pollHistoryEntryPoint: PollHistoryEntryPoint,
private val room: MatrixRoom,
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
@ -129,6 +136,14 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun openAdminSettings() {
backstack.push(NavTarget.AdminSettings)
}
override fun onJoinCall() {
val inputs = CallType.RoomCall(
sessionId = room.sessionId,
roomId = room.roomId,
)
ElementCallActivity.start(context, inputs)
}
}
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
}

View file

@ -58,6 +58,7 @@ class RoomDetailsNode @AssistedInject constructor(
fun openAvatarPreview(name: String, url: String)
fun openPollHistory()
fun openAdminSettings()
fun onJoinCall()
}
private val callbacks = plugins<Callback>()
@ -86,6 +87,10 @@ class RoomDetailsNode @AssistedInject constructor(
callbacks.forEach { it.openPollHistory() }
}
private fun onJoinCall() {
callbacks.forEach { it.onJoinCall() }
}
private fun CoroutineScope.onShareRoom(context: Context) = launch {
room.getPermalink()
.onSuccess { permalink ->
@ -162,6 +167,7 @@ class RoomDetailsNode @AssistedInject constructor(
openAvatarPreview = ::openAvatarPreview,
openPollHistory = ::openPollHistory,
openAdminSettings = this::openAdminSettings,
onJoinCallClicked = ::onJoinCall,
)
}
}

View file

@ -44,6 +44,7 @@ import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.ui.room.canCall
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
import io.element.android.services.analytics.api.AnalyticsService
@ -86,11 +87,14 @@ class RoomDetailsPresenter @Inject constructor(
}
}
val syncUpdateTimestamp by room.syncUpdateFlow.collectAsState()
val membersState by room.membersStateFlow.collectAsState()
val canInvite by getCanInvite(membersState)
val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME)
val canEditAvatar by getCanSendState(membersState, StateEventType.ROOM_AVATAR)
val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC)
val canJoinCall by room.canCall(updateKey = syncUpdateTimestamp)
val dmMember by room.getDirectRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
val roomType by getRoomType(dmMember)
@ -138,6 +142,7 @@ class RoomDetailsPresenter @Inject constructor(
canInvite = canInvite,
canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room,
canShowNotificationSettings = canShowNotificationSettings.value,
canCall = canJoinCall,
roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState,

View file

@ -36,6 +36,7 @@ data class RoomDetailsState(
val canEdit: Boolean,
val canInvite: Boolean,
val canShowNotificationSettings: Boolean,
val canCall: Boolean,
val leaveRoomState: LeaveRoomState,
val roomNotificationSettings: RoomNotificationSettings?,
val isFavorite: Boolean,

View file

@ -46,6 +46,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
// Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed
roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true)
),
aRoomDetailsState(canCall = false, canInvite = false),
// Add other state here
)
}
@ -89,6 +90,7 @@ fun aRoomDetailsState(
canInvite: Boolean = false,
canEdit: Boolean = false,
canShowNotificationSettings: Boolean = true,
canCall: Boolean = true,
roomType: RoomDetailsType = RoomDetailsType.Room,
roomMemberDetailsState: UserProfileState? = null,
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
@ -107,6 +109,7 @@ fun aRoomDetailsState(
canInvite = canInvite,
canEdit = canEdit,
canShowNotificationSettings = canShowNotificationSettings,
canCall = canCall,
roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState,

View file

@ -27,7 +27,6 @@ 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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@ -100,6 +99,7 @@ fun RoomDetailsView(
openAvatarPreview: (name: String, url: String) -> Unit,
openPollHistory: () -> Unit,
openAdminSettings: () -> Unit,
onJoinCallClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
fun onShareMember() {
@ -137,7 +137,9 @@ fun RoomDetailsView(
)
MainActionsSection(
state = state,
onShareRoom = onShareRoom
onShareRoom = onShareRoom,
onInvitePeople = invitePeople,
onCall = onJoinCallClicked,
)
}
@ -188,21 +190,13 @@ fun RoomDetailsView(
}
val displayMemberListItem = state.roomType is RoomDetailsType.Room
val displayInviteMembersItem = state.canInvite
if (displayMemberListItem || displayInviteMembersItem) {
PreferenceCategory {
if (displayMemberListItem) {
PreferenceCategory {
MembersItem(
memberCount = state.memberCount,
openRoomMemberList = openRoomMemberList,
)
}
if (displayInviteMembersItem) {
InviteItem(
invitePeople = invitePeople
)
}
}
}
PollsSection(
@ -267,10 +261,12 @@ private fun RoomDetailsTopBar(
private fun MainActionsSection(
state: RoomDetailsState,
onShareRoom: () -> Unit,
onInvitePeople: () -> Unit,
onCall: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
val roomNotificationSettings = state.roomNotificationSettings
if (state.canShowNotificationSettings && roomNotificationSettings != null) {
@ -292,9 +288,22 @@ private fun MainActionsSection(
)
}
}
Spacer(modifier = Modifier.width(20.dp))
if (state.canCall) {
MainActionButton(
title = stringResource(R.string.screen_room_details_share_room_title),
title = stringResource(CommonStrings.action_call),
imageVector = CompoundIcons.VideoCall(),
onClick = onCall,
)
}
if (state.roomType is RoomDetailsType.Room && state.canInvite) {
MainActionButton(
title = stringResource(CommonStrings.action_invite),
imageVector = CompoundIcons.UserAdd(),
onClick = onInvitePeople,
)
}
MainActionButton(
title = stringResource(CommonStrings.action_share),
imageVector = CompoundIcons.ShareAndroid(),
onClick = onShareRoom
)
@ -410,17 +419,6 @@ private fun MembersItem(
)
}
@Composable
private fun InviteItem(
invitePeople: () -> Unit,
) {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_invite_people_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())),
onClick = invitePeople,
)
}
@Composable
private fun PollsSection(
openPollHistory: () -> Unit,
@ -491,5 +489,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
openAvatarPreview = { _, _ -> },
openPollHistory = {},
openAdminSettings = {},
onJoinCallClicked = {},
)
}

View file

@ -62,7 +62,7 @@ class RoomDetailsViewTest {
rule.setRoomDetailView(
onShareRoom = callback,
)
rule.clickOn(R.string.screen_room_details_share_room_title)
rule.clickOn(CommonStrings.action_share)
}
}
@ -112,9 +112,8 @@ class RoomDetailsViewTest {
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `click on invite people invokes expected callback`() {
fun `click on invite invokes expected callback`() {
ensureCalledOnce { callback ->
rule.setRoomDetailView(
state = aRoomDetailsState(
@ -123,7 +122,21 @@ class RoomDetailsViewTest {
),
invitePeople = callback,
)
rule.clickOn(R.string.screen_room_details_invite_people_title)
rule.clickOn(CommonStrings.action_invite)
}
}
@Test
fun `click on call invokes expected callback`() {
ensureCalledOnce { callback ->
rule.setRoomDetailView(
state = aRoomDetailsState(
eventSink = EventsRecorder(expectEvents = false),
canInvite = true,
),
onJoinCallClicked = callback,
)
rule.clickOn(CommonStrings.action_call)
}
}
@ -258,6 +271,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
openAvatarPreview: (name: String, url: String) -> Unit = EnsureNeverCalledWithTwoParams(),
openPollHistory: () -> Unit = EnsureNeverCalled(),
openAdminSettings: () -> Unit = EnsureNeverCalled(),
onJoinCallClicked: () -> Unit = EnsureNeverCalled(),
) {
setContent {
RoomDetailsView(
@ -272,6 +286,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
openAvatarPreview = openAvatarPreview,
openPollHistory = openPollHistory,
openAdminSettings = openAdminSettings,
onJoinCallClicked = onJoinCallClicked,
)
}
}

View file

@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
@ -53,12 +54,14 @@ fun MainActionButton(
val ripple = rememberRipple(bounded = false)
val interactionSource = remember { MutableInteractionSource() }
Column(
modifier.clickable(
modifier
.clickable(
enabled = enabled,
interactionSource = interactionSource,
onClick = onClick,
indication = ripple
),
)
.widthIn(min = 76.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
val tintColor = if (enabled) LocalContentColor.current else MaterialTheme.colorScheme.secondary

View file

@ -49,6 +49,13 @@ fun MatrixRoom.canRedactOtherAsState(updateKey: Long): State<Boolean> {
}
}
@Composable
fun MatrixRoom.canCall(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canUserJoinCall(sessionId).getOrElse { false }
}
}
@Composable
fun MatrixRoom.isOwnUserAdmin(): Boolean {
val roomInfo by roomInfoFlow.collectAsState(initial = null)

View file

@ -34,6 +34,7 @@
<string name="action_accept">"Accept"</string>
<string name="action_add_to_timeline">"Add to timeline"</string>
<string name="action_back">"Back"</string>
<string name="action_call">"Call"</string>
<string name="action_cancel">"Cancel"</string>
<string name="action_choose_photo">"Choose photo"</string>
<string name="action_clear">"Clear"</string>

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fe5642aa6804a99d9b3505bb63fe0c328015592f35e095415c910d9a5abfa052
size 47238
oid sha256:ba1133eb6569d4821b12fe56c3347a472bd95ad5b269546fed57f249e365d432
size 47379

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7465cc5ca6b9f49ab4ac1a529fc2ee21547afb85a231d74491df6b2a2781bd18
size 33075
oid sha256:d0f46ff6cfe976d3d96ecbf323fcf2ed2ed57c326c3bed3c450c30e851442567
size 33239

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:93d4eb6dce460237fe6650e965b901c28b020057907867ac9f73665032a85f88
size 35427
oid sha256:84dcef17832aad71a9bf0505346731b27f882795d9bf75b194b647dfad40eca2
size 35595

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c8a655fb4e64fa300c6169d332fa894b6bb215a9acd09c0fbe876c7430f3ae1d
size 34786
oid sha256:72b731453adaad784d3c037c06defd7b24939435ab54ee47a693f6aabcc0dce7
size 34928

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4329c322042bc8489177886e37f6d3a876cbfee8a8a73e3aae6414a17e92ad67
size 42330
oid sha256:c675a4d9422ba2d73e860420e25f10a21c899b33e780a4f61a41f71921f3078a
size 42471

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8359c8e69df44350b30b5aaf9ad99ace52fe378ba6db2cfd86a4deb35aa3abd1
size 46347
oid sha256:78a182e17a2ef55a4d7dbcb4ad952d5f06f12044da4dc3578a5cd3cb7b7d34a1
size 45723

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8659b897a22fd88915a8df3de3f0ba8d6775057f51b1c5cd1db83b0e58497576
size 44394
oid sha256:ccf59aaaba1ebbf35518840ba069f2ea587c5f5590ed1f0be5904922d10321e0
size 44535

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7a195980dff4ad4343eec99438a35ab655abd124346f5e4d66011c3c1736938e
size 44589
oid sha256:972414b2900a7b2fddc7c45640f0718bbed3ff799067a0dde4f9e3f2fb70266a
size 44574

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:29abf008345cd7c7150a64b17c6bd1c0af06c8d960c5e91a2cf0e4b1c0365954
size 48791
oid sha256:f89ab41056584b086cf6fbeabff68cee7ee7dde4d52fcef9849dcdc296c69a1e
size 48983

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e6228ac8af567a9d4fc402d2b14ff65315f6cfca2d02407664b0db00df5fa68e
size 34363
oid sha256:d3dead46255af9a750e9afe09f2e5201a4f12adea8f2e1a3d0806062775e3ea0
size 34587

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3987ef26780da018bedbe05b1af43c3a9c3f31963aff1eb17833a284118d29ec
size 36951
oid sha256:82107faba76f5c6cc992ec47d6e4d765761743094fcae65c5ed948fce3cf1f05
size 37183

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c74c580b51aaa480a1ca15f2f33e3e6acb9e2a5581d5501ff67b6c4b56d92099
size 35582
oid sha256:73a016fc3d40517042f8d231ab682d71e477843195fbe4e080cd62fc12e8b954
size 35778

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d81c82c5d5f07bb57911488e18e2cf0867009233ad678557a8ead13e6827ba2b
size 43640
oid sha256:b9250d7927307d397b969e0803ed9965a7ae301347446fef264004d690d064f6
size 43826

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bdfe3fb1c1594335fda27a62f2c1b4740815778248ba76923a2184937db02144
size 47743
oid sha256:fd42eba5e0daa676298cfb2813d595083e8abd1c5abf8dfb416910fbbc2ea02c
size 47128

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d23cfed590306aec6eb92664c616e38f18e54274bb381e2e044e22916e8b1129
size 45723
oid sha256:58cdd2ba7d10769a29ad59fa2cff7c972767d01352d656a06bfcb922522980b3
size 45918

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:33e9d17227fda64a35cd885d029b70bab0088d5748cc5440c32d1cfa0a3bbf52
size 45908
oid sha256:43d176f6e953006f72b09ba374f95c28388f10ce600c9d495761c4c11ccc109b
size 45874

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3a4cd869d7be5ebd07dc72947fe36f6d601fa55494bb4f7c75b828d1569d983a
size 15105
oid sha256:9c5d638b74379f27b9774309848b4cb068514faa856f5882f8f0f8614ecaad12
size 15712