Merge pull request #4071 from element-hq/feature/bma/galleryUiTweak

Media gallery UI update
This commit is contained in:
Benoit Marty 2024-12-20 09:12:44 +01:00 committed by GitHub
commit 26ffb1eb0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 443 additions and 298 deletions

View file

@ -251,8 +251,6 @@ class MessagesFlowNode @AssistedInject constructor(
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
canShowInfo = true,
canDownload = true,
canShare = true,
)
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {

View file

@ -158,7 +158,7 @@ enum class FeatureFlags(
key = "feature.media_gallery",
title = "Allow user to open the media gallery",
description = null,
defaultValue = { buildMeta -> buildMeta.buildType != BuildType.RELEASE },
defaultValue = { true },
isFinished = false,
),
EventCache(

View file

@ -36,7 +36,5 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val canShowInfo: Boolean,
val canDownload: Boolean,
val canShare: Boolean,
) : NodeInputs
}

View file

@ -59,8 +59,6 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null,
canShowInfo = false,
canDownload = false,
canShare = false,
)
)
}

View file

@ -48,6 +48,8 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun MediaDetailsBottomSheet(
state: MediaBottomSheetState.MediaDetailsBottomSheetState,
onViewInTimeline: (EventId) -> Unit,
onShare: (EventId) -> Unit,
onDownload: (EventId) -> Unit,
onDelete: (EventId) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
@ -92,6 +94,22 @@ fun MediaDetailsBottomSheet(
onViewInTimeline(state.eventId)
}
)
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ShareAndroid())),
headlineContent = { Text(stringResource(CommonStrings.action_share)) },
style = ListItemStyle.Primary,
onClick = {
onShare(state.eventId)
}
)
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Download())),
headlineContent = { Text(stringResource(CommonStrings.action_save)) },
style = ListItemStyle.Primary,
onClick = {
onDownload(state.eventId)
}
)
if (state.canDelete) {
HorizontalDivider()
ListItem(
@ -196,6 +214,8 @@ internal fun MediaDetailsBottomSheetPreview() = ElementPreview {
MediaDetailsBottomSheet(
state = aMediaDetailsBottomSheetState(),
onViewInTimeline = {},
onShare = {},
onDownload = {},
onDelete = {},
onDismiss = {},
)

View file

@ -12,10 +12,11 @@ import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
fun aMediaDetailsBottomSheetState(
dateSentFull: String = "December 6, 2024 at 12:59",
canDelete: Boolean = true,
): MediaBottomSheetState.MediaDetailsBottomSheetState {
return MediaBottomSheetState.MediaDetailsBottomSheetState(
eventId = EventId("\$eventId"),
canDelete = true,
canDelete = canDelete,
mediaInfo = anImageMediaInfo(
senderName = "Alice",
dateSentFull = dateSentFull,

View file

@ -15,8 +15,8 @@ import io.element.android.libraries.mediaviewer.api.MediaInfo
sealed interface MediaGalleryEvents {
data class ChangeMode(val mode: MediaGalleryMode) : MediaGalleryEvents
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaGalleryEvents
data class Share(val mediaItem: MediaItem.Event) : MediaGalleryEvents
data class SaveOnDisk(val mediaItem: MediaItem.Event) : MediaGalleryEvents
data class Share(val eventId: EventId?) : MediaGalleryEvents
data class SaveOnDisk(val eventId: EventId?) : MediaGalleryEvents
data class OpenInfo(val mediaItem: MediaItem.Event) : MediaGalleryEvents
data class ViewInTimeline(val eventId: EventId) : MediaGalleryEvents

View file

@ -117,8 +117,16 @@ class MediaGalleryPresenter @AssistedInject constructor(
timeline.dataOrNull()?.paginate(event.direction)
}
is MediaGalleryEvents.Delete -> coroutineScope.delete(timeline, event.eventId)
is MediaGalleryEvents.SaveOnDisk -> coroutineScope.saveOnDisk(event.mediaItem)
is MediaGalleryEvents.Share -> coroutineScope.share(event.mediaItem)
is MediaGalleryEvents.SaveOnDisk -> coroutineScope.launch {
mediaItems.dataOrNull().find(event.eventId)?.let {
saveOnDisk(it)
}
}
is MediaGalleryEvents.Share -> coroutineScope.launch {
mediaItems.dataOrNull().find(event.eventId)?.let {
share(it)
}
}
is MediaGalleryEvents.ViewInTimeline -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onViewInTimelineClick(event.eventId)
@ -221,7 +229,7 @@ class MediaGalleryPresenter @AssistedInject constructor(
}
}
private fun CoroutineScope.saveOnDisk(mediaItem: MediaItem.Event) = launch {
private suspend fun saveOnDisk(mediaItem: MediaItem.Event) {
downloadMedia(mediaItem)
.mapCatching { localMedia ->
localMediaActions.saveOnDisk(localMedia)
@ -236,7 +244,7 @@ class MediaGalleryPresenter @AssistedInject constructor(
}
}
private fun CoroutineScope.share(mediaItem: MediaItem.Event) = launch {
private suspend fun share(mediaItem: MediaItem.Event) {
downloadMedia(mediaItem)
.mapCatching { localMedia ->
localMediaActions.share(localMedia)
@ -255,3 +263,11 @@ class MediaGalleryPresenter @AssistedInject constructor(
}
}
}
private fun List<MediaItem>?.find(eventId: EventId?): MediaItem.Event? {
if (this == null || eventId == null) {
return null
}
return filterIsInstance<MediaItem.Event>()
.firstOrNull { it.eventId() == eventId }
}

View file

@ -41,6 +41,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.components.async.AsyncFailure
@ -106,7 +107,8 @@ fun MediaGalleryView(
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.fillMaxSize()
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
SingleChoiceSegmentedButtonRow(
modifier = Modifier
@ -151,6 +153,12 @@ fun MediaGalleryView(
onViewInTimeline = { eventId ->
state.eventSink(MediaGalleryEvents.ViewInTimeline(eventId))
},
onShare = { eventId ->
state.eventSink(MediaGalleryEvents.Share(eventId))
},
onDownload = { eventId ->
state.eventSink(MediaGalleryEvents.SaveOnDisk(eventId))
},
onDelete = { eventId ->
state.eventSink(
MediaGalleryEvents.ConfirmDelete(
@ -274,11 +282,17 @@ private fun MediaGalleryFilesList(
modifier = Modifier.animateItem(),
file = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
},
)
is MediaItem.Audio -> AudioItemView(
modifier = Modifier.animateItem(),
audio = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
},
)
is MediaItem.Voice -> {
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(item)
@ -286,9 +300,9 @@ private fun MediaGalleryFilesList(
modifier = Modifier.animateItem(),
state = presenter.present(),
voice = item,
onShareClick = { eventSink(MediaGalleryEvents.Share(item)) },
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
},
)
}
is MediaItem.DateSeparator -> DateItemView(
@ -426,6 +440,7 @@ private fun EmptyContent(
Box(
modifier = Modifier.fillMaxSize(),
) {
OnboardingBackground()
PageTitle(
modifier = Modifier
.fillMaxWidth()

View file

@ -122,8 +122,6 @@ class MediaGalleryRootNode @AssistedInject constructor(
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
canShowInfo = true,
canDownload = true,
canShare = true,
)
)
.callback(callback)

View file

@ -7,8 +7,9 @@
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -41,6 +42,7 @@ import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
fun AudioItemView(
audio: MediaItem.Audio,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@ -52,6 +54,7 @@ fun AudioItemView(
FilenameRow(
audio = audio,
onClick = onClick,
onLongClick = onLongClick,
)
val caption = audio.mediaInfo.caption
if (caption != null) {
@ -63,10 +66,12 @@ fun AudioItemView(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FilenameRow(
audio: MediaItem.Audio,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier = Modifier
@ -75,7 +80,7 @@ private fun FilenameRow(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(12.dp),
)
.clickable { onClick() }
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
@ -119,5 +124,6 @@ internal fun AudioItemViewPreview(
AudioItemView(
audio = audio,
onClick = {},
onLongClick = {},
)
}

View file

@ -7,8 +7,9 @@
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -40,6 +41,7 @@ import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
fun FileItemView(
file: MediaItem.File,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@ -51,6 +53,7 @@ fun FileItemView(
FilenameRow(
file = file,
onClick = onClick,
onLongClick = onLongClick,
)
val caption = file.mediaInfo.caption
if (caption != null) {
@ -62,10 +65,12 @@ fun FileItemView(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FilenameRow(
file: MediaItem.File,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier = Modifier
@ -74,7 +79,7 @@ private fun FilenameRow(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(12.dp),
)
.clickable { onClick() }
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
@ -118,5 +123,6 @@ internal fun FileItemViewPreview(
FileItemView(
file = file,
onClick = {},
onLongClick = {},
)
}

View file

@ -24,6 +24,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
@ -84,6 +85,14 @@ private fun VideoInfoRow(
Row(
modifier = modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
ElementTheme.colors.bgCanvasDefault.copy(alpha = 0f),
ElementTheme.colors.bgCanvasDefault,
)
)
)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {

View file

@ -7,9 +7,10 @@
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -58,9 +59,7 @@ import kotlinx.coroutines.delay
fun VoiceItemView(
state: VoiceMessageState,
voice: MediaItem.Voice,
onShareClick: () -> Unit,
onDownloadClick: () -> Unit,
onInfoClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@ -72,6 +71,7 @@ fun VoiceItemView(
VoiceInfoRow(
state = state,
voice = voice,
onLongClick = onLongClick,
)
val caption = voice.mediaInfo.caption
if (caption != null) {
@ -79,19 +79,16 @@ fun VoiceItemView(
} else {
Spacer(modifier = Modifier.height(16.dp))
}
ActionIconsRow(
onShareClick = onShareClick,
onDownloadClick = onDownloadClick,
onInfoClick = onInfoClick,
)
HorizontalDivider()
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun VoiceInfoRow(
state: VoiceMessageState,
voice: MediaItem.Voice,
onLongClick: () -> Unit,
) {
fun playPause() {
state.eventSink(VoiceMessageEvents.PlayPause)
@ -104,6 +101,7 @@ private fun VoiceInfoRow(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(12.dp),
)
.combinedClickable(onClick = {}, onLongClick = onLongClick)
.fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
@ -257,43 +255,6 @@ private fun CustomIconButton(
)
}
@Composable
private fun ActionIconsRow(
onShareClick: () -> Unit,
onDownloadClick: () -> Unit,
onInfoClick: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
IconButton(
onClick = onShareClick,
) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = null,
)
}
IconButton(
onClick = onDownloadClick,
) {
Icon(
imageVector = CompoundIcons.Download(),
contentDescription = null,
)
}
IconButton(
onClick = onInfoClick,
) {
Icon(
imageVector = CompoundIcons.Info(),
contentDescription = null,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun VoiceItemViewPreview(
@ -302,9 +263,7 @@ internal fun VoiceItemViewPreview(
VoiceItemView(
state = aVoiceMessageState(),
voice = voice,
onShareClick = {},
onDownloadClick = {},
onInfoClick = {},
onLongClick = {},
)
}
@ -316,8 +275,6 @@ internal fun VoiceItemViewPlayPreview(
VoiceItemView(
state = state,
voice = aMediaItemVoice(),
onShareClick = {},
onDownloadClick = {},
onInfoClick = {},
onLongClick = {},
)
}

View file

@ -83,9 +83,18 @@ class MediaViewerPresenter @AssistedInject constructor(
when (mediaViewerEvents) {
MediaViewerEvents.RetryLoading -> loadMediaTrigger++
MediaViewerEvents.ClearLoadingError -> localMedia.value = AsyncData.Uninitialized
MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value)
MediaViewerEvents.Share -> coroutineScope.share(localMedia.value)
MediaViewerEvents.OpenWith -> coroutineScope.open(localMedia.value)
MediaViewerEvents.SaveOnDisk -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.saveOnDisk(localMedia.value)
}
MediaViewerEvents.Share -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.share(localMedia.value)
}
MediaViewerEvents.OpenWith -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.open(localMedia.value)
}
is MediaViewerEvents.Delete -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.delete(mediaViewerEvents.eventId)
@ -126,8 +135,6 @@ class MediaViewerPresenter @AssistedInject constructor(
downloadedMedia = localMedia.value,
snackbarMessage = snackbarMessage,
canShowInfo = inputs.canShowInfo,
canDownload = inputs.canDownload,
canShare = inputs.canShare,
mediaBottomSheetState = mediaBottomSheetState,
eventSink = ::handleEvents
)

View file

@ -22,8 +22,6 @@ data class MediaViewerState(
val downloadedMedia: AsyncData<LocalMedia>,
val snackbarMessage: SnackbarMessage?,
val canShowInfo: Boolean,
val canDownload: Boolean,
val canShare: Boolean,
val mediaBottomSheetState: MediaBottomSheetState,
val eventSink: (MediaViewerEvents) -> Unit,
)

View file

@ -91,8 +91,6 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
),
mediaInfo = it,
canShowInfo = false,
canDownload = false,
canShare = false,
)
},
aMediaViewerState(
@ -118,8 +116,6 @@ fun aMediaViewerState(
downloadedMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized,
mediaInfo: MediaInfo = anImageMediaInfo(),
canShowInfo: Boolean = true,
canDownload: Boolean = true,
canShare: Boolean = true,
mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden,
eventSink: (MediaViewerEvents) -> Unit = {},
) = MediaViewerState(
@ -129,8 +125,6 @@ fun aMediaViewerState(
downloadedMedia = downloadedMedia,
snackbarMessage = null,
canShowInfo = canShowInfo,
canDownload = canDownload,
canShare = canShare,
mediaBottomSheetState = mediaBottomSheetState,
eventSink = eventSink,
)

View file

@ -119,8 +119,6 @@ fun MediaViewerView(
) {
MediaViewerTopBar(
actionsEnabled = state.downloadedMedia is AsyncData.Success,
canDownload = state.canDownload,
canShare = state.canShare,
mimeType = state.mediaInfo.mimeType,
senderName = state.mediaInfo.senderName,
dateSent = state.mediaInfo.dateSent,
@ -148,6 +146,12 @@ fun MediaViewerView(
onViewInTimeline = {
state.eventSink(MediaViewerEvents.ViewInTimeline(it))
},
onShare = {
state.eventSink(MediaViewerEvents.Share)
},
onDownload = {
state.eventSink(MediaViewerEvents.SaveOnDisk)
},
onDelete = { eventId ->
state.eventSink(MediaViewerEvents.ConfirmDelete(eventId))
},
@ -313,8 +317,6 @@ private fun rememberShowProgress(downloadedMedia: AsyncData<LocalMedia>): Boolea
@Composable
private fun MediaViewerTopBar(
actionsEnabled: Boolean,
canDownload: Boolean,
canShare: Boolean,
mimeType: String,
senderName: String?,
dateSent: String?,
@ -348,19 +350,6 @@ private fun MediaViewerTopBar(
),
navigationIcon = { BackButton(onClick = onBackClick) },
actions = {
if (canShare) {
IconButton(
enabled = actionsEnabled,
onClick = {
eventSink(MediaViewerEvents.Share)
},
) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = stringResource(id = CommonStrings.action_share)
)
}
}
IconButton(
enabled = actionsEnabled,
onClick = {
@ -378,19 +367,6 @@ private fun MediaViewerTopBar(
)
}
}
if (canDownload) {
IconButton(
enabled = actionsEnabled,
onClick = {
eventSink(MediaViewerEvents.SaveOnDisk)
},
) {
Icon(
imageVector = CompoundIcons.Download(),
contentDescription = stringResource(id = CommonStrings.action_save),
)
}
}
if (canShowInfo) {
IconButton(
onClick = onInfoClick,

View file

@ -0,0 +1,70 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.details
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.api.core.EventId
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.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MediaDeleteConfirmationBottomSheetTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on Cancel invokes expected callback`() {
val state = aMediaDeleteConfirmationState()
ensureCalledOnce { callback ->
rule.setMediaDeleteConfirmationBottomSheet(
state = state,
onDismiss = callback,
)
rule.clickOn(CommonStrings.action_cancel)
}
}
@Test
fun `clicking on Remove invokes expected callback`() {
val state = aMediaDeleteConfirmationState()
ensureCalledOnceWithParam(state.eventId) { callback ->
rule.setMediaDeleteConfirmationBottomSheet(
state = state,
onDelete = callback,
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_remove)).assertExists()
rule.clickOn(CommonStrings.action_remove)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMediaDeleteConfirmationBottomSheet(
state: MediaBottomSheetState.MediaDeleteConfirmationState,
onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onDismiss: () -> Unit = EnsureNeverCalled(),
) {
setContent {
MediaDeleteConfirmationBottomSheet(
state = state,
onDelete = onDelete,
onDismiss = onDismiss,
)
}
}

View file

@ -0,0 +1,113 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.details
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.api.core.EventId
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.clickOn
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class MediaDetailsBottomSheetTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on View in timeline invokes expected callback`() {
val state = aMediaDetailsBottomSheetState()
ensureCalledOnceWithParam(state.eventId) { callback ->
rule.setMediaDetailsBottomSheet(
state = state,
onViewInTimeline = callback,
)
rule.clickOn(CommonStrings.action_view_in_timeline)
}
}
@Test
fun `clicking on Share invokes expected callback`() {
val state = aMediaDetailsBottomSheetState()
ensureCalledOnceWithParam(state.eventId) { callback ->
rule.setMediaDetailsBottomSheet(
state = state,
onShare = callback,
)
rule.clickOn(CommonStrings.action_share)
}
}
@Test
fun `clicking on Save invokes expected callback`() {
val state = aMediaDetailsBottomSheetState()
ensureCalledOnceWithParam(state.eventId) { callback ->
rule.setMediaDetailsBottomSheet(
state = state,
onDownload = callback,
)
rule.clickOn(CommonStrings.action_save)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on Remove invokes expected callback`() {
val state = aMediaDetailsBottomSheetState()
ensureCalledOnceWithParam(state.eventId) { callback ->
rule.setMediaDetailsBottomSheet(
state = state,
onDelete = callback,
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_remove)).assertExists()
rule.clickOn(CommonStrings.action_remove)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `Remove is not present if canDelete is false`() {
val state = aMediaDetailsBottomSheetState(
canDelete = false,
)
rule.setMediaDetailsBottomSheet(
state = state,
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_remove)).assertDoesNotExist()
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMediaDetailsBottomSheet(
state: MediaBottomSheetState.MediaDetailsBottomSheetState,
onViewInTimeline: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onShare: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onDownload: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onDismiss: () -> Unit = EnsureNeverCalled(),
) {
setContent {
MediaDetailsBottomSheet(
state = state,
onViewInTimeline = onViewInTimeline,
onShare = onShare,
onDownload = onDownload,
onDelete = onDelete,
onDismiss = onDismiss,
)
}
}

View file

@ -66,8 +66,6 @@ class MediaViewerPresenterTest {
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isTrue()
assertThat(initialState.canDownload).isTrue()
assertThat(initialState.canShare).isTrue()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
}
}
@ -86,48 +84,6 @@ class MediaViewerPresenterTest {
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isFalse()
assertThat(initialState.canDownload).isTrue()
assertThat(initialState.canShare).isTrue()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
}
}
@Test
fun `present - initial state cannot share`() = runTest {
val presenter = createMediaViewerPresenter(
canShare = false,
room = FakeMatrixRoom(
canRedactOwnResult = { Result.success(true) },
)
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isTrue()
assertThat(initialState.canDownload).isTrue()
assertThat(initialState.canShare).isFalse()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
}
}
@Test
fun `present - initial state cannot download`() = runTest {
val presenter = createMediaViewerPresenter(
canDownload = false,
room = FakeMatrixRoom(
canRedactOwnResult = { Result.success(true) },
)
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isTrue()
assertThat(initialState.canDownload).isFalse()
assertThat(initialState.canShare).isTrue()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
}
}
@ -146,8 +102,6 @@ class MediaViewerPresenterTest {
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isTrue()
assertThat(initialState.canDownload).isTrue()
assertThat(initialState.canShare).isTrue()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
}
}
@ -167,8 +121,6 @@ class MediaViewerPresenterTest {
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isTrue()
assertThat(initialState.canDownload).isTrue()
assertThat(initialState.canShare).isTrue()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
}
}
@ -350,8 +302,6 @@ class MediaViewerPresenterTest {
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
canShowInfo: Boolean = true,
canShare: Boolean = true,
canDownload: Boolean = true,
mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(),
room: MatrixRoom = FakeMatrixRoom(
liveTimeline = FakeTimeline(),
@ -364,8 +314,6 @@ class MediaViewerPresenterTest {
mediaSource = aMediaSource(),
thumbnailSource = null,
canShowInfo = canShowInfo,
canShare = canShare,
canDownload = canDownload,
),
localMediaFactory = localMediaFactory,
mediaLoader = matrixMediaLoader,

View file

@ -20,6 +20,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
@ -54,16 +55,6 @@ class MediaViewerViewTest {
testMenuAction(CommonStrings.action_open_with, MediaViewerEvents.OpenWith)
}
@Test
fun `clicking on save emit expected Event`() {
testMenuAction(CommonStrings.action_save, MediaViewerEvents.SaveOnDisk)
}
@Test
fun `clicking on share emit expected Event`() {
testMenuAction(CommonStrings.action_share, MediaViewerEvents.Share)
}
private fun testMenuAction(contentDescriptionRes: Int, expectedEvent: MediaViewerEvents) {
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
rule.setMediaViewerView(
@ -80,6 +71,32 @@ class MediaViewerViewTest {
eventsRecorder.assertSingle(expectedEvent)
}
@Test
fun `clicking on save emit expected Event`() {
testBottomSheetAction(CommonStrings.action_save, MediaViewerEvents.SaveOnDisk)
}
@Test
fun `clicking on share emit expected Event`() {
testBottomSheetAction(CommonStrings.action_share, MediaViewerEvents.Share)
}
private fun testBottomSheetAction(contentDescriptionRes: Int, expectedEvent: MediaViewerEvents) {
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
rule.setMediaViewerView(
aMediaViewerState(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, anImageMediaInfo())
),
mediaInfo = anImageMediaInfo(),
mediaBottomSheetState = aMediaDetailsBottomSheetState(),
eventSink = eventsRecorder
),
)
rule.clickOn(contentDescriptionRes)
eventsRecorder.assertSingle(expectedEvent)
}
@Test
fun `clicking on image hides the overlay`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>(expectEvents = false)

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2c7ba307cf21056623bf35c8558809fdbc6deaacf4e9365a99ffcf829e8d9188
size 34477
oid sha256:4cded4f64be360fbd6ba607f9303e17154da24220712cf2e8da2d495b50bda26
size 38103

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:724bff130c547e7f0065ccb4c1b3319162ded2e6c1c1db666f4e08e01289a5a0
size 32776
oid sha256:de4cec2f60dda00375c6583fb2926cc0fdfa02d4673bd3d99bbe0ca3a2193952
size 36454

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:70f93330adb987d6f98d654670ab0898957d765ad3d92a47c9a1c781b24f9059
size 5317
oid sha256:00159ed8d968d53970e4a3b7f82ab542fb5eabfc4513dd68d0eef05f0615373e
size 7290

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ffac5911f928411fa0ac94e9ac59f6b8bb8bce1016e06f348d946f9d10053e5
size 4539
oid sha256:efb12b63fde67256b255503d00848d64480e142c54619329b75aeed451a3dd17
size 6375

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e0ee87589ec0e4f7cf67775dfa69cd289aeb27f22087bd91d54102923a28557d
size 4775
oid sha256:0f6835bd79d18d202c1d21b00a1afa4fa2c7cbfaf8f586a1dd1f48afdd5f69e5
size 7644

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6504e09eb09a9e28bc70594e669ec87abd290b4fe20e2ee9b3588c2116a049cd
size 3994
oid sha256:452afc2e04191eb82de772597ee97987eda5667ff56ecb684bb3b9e0bef90435
size 6737

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9d0b3f44a9a0ed9ab16192b23ccf95b7d34abccd025bb4a7ccd8ebb7a9379965
size 10824
oid sha256:4444ea352a367bb8617e9be4c86368b0a2292916a20a0c166933b219617e6055
size 8502

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:60f281d970321442e39b350f4f697c7ecfc9bc32000cd19676ea7ed6468ee63a
size 11411
oid sha256:eb103b365f83667834ccf8e6a181ab59d1c9dcbecf6fd4eb7f16bb236444b4e0
size 9087

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:25386b28846ae826701c9e53530cdd1e5fda4d0899673394bf6d81dac1ca751f
size 11057
oid sha256:0ce431cefbd22d8232af06626336e4c3baccbf9ad32e88f662efab66c7640b0d
size 8737

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:740923d3e740dc98a5d6890f8f8dfb5810606d99e81a4e6597078566194f076d
size 11290
oid sha256:d2a9292c525e7fe4d72362f2fe04a9f1184c7dff951f3b517e55f6f369214324
size 8992

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e18741850e92314b40da89c9e1cafd1795397960151f7bb4e221ae3866d25f9d
size 11497
oid sha256:d10b446f168e8a1295c811c57a08897db6636935646cd6c314eb7b6b7e310d5c
size 9184

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:73fecb12c33892a9bea41e5a4bbbf4db43990e44b2a36b96d03d7a81046c8f92
size 10076
oid sha256:44b37f78992762569431bed9330140037b5fe9e49bb0043e2bb94cdfc2b53844
size 7989

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:19c209ec0dca3c3b722af63dcd33444d1074749afd47a8364da4a7a071fce8aa
size 10680
oid sha256:54f3371f7003d7fe40dea4968037fbd4e148449e431356e693c7951a2814dab6
size 8592

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dabd54db78ae93b72cf9ee31fc4b153fe2ccf64869d861fd16995e16494f4d67
size 10423
oid sha256:bbe8efde3b6f6f13558eb323b7774640a269c66a5fe4b0c44e5b60fc6bccf4c6
size 8329

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5ea12dad2ab5c70aa30bc506343f0fce05e08a2940460ec595216d07a244fc84
size 10583
oid sha256:e3935b49178db454d1cd20270a1641d380f5f3e576548d74e194fe7619bff4c8
size 8492

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:470f7d0a1ceedcc0f9ade603c590101da8231b24745283976b3528a84e72d721
size 10743
oid sha256:0796d9962b93631f27338f9e93b9f7812964cd830cc1d62903829fd8012865cf
size 8653

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8f09ea644568c7ab55f0cfc3b8148d8e7821313852cc3fcd78dca2b473a07555
size 10871
oid sha256:b98131ca183aaa3fd550f2b78317d73fb63ad03122c298d56d451d4023fd61d3
size 8548

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a45e80573b7db0c47cbe9048c1bb95d66e4b8437dd4816cf6cb3548305549715
size 13017
oid sha256:e0fb23e74fded45ea3d56cd69c2675efa2c0c0190b17bc8b4cc3ffbb2715650e
size 10772

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:339a6ae86094fb3d0b99d6ea3b23bed14adf7bbe90bc55613d6ece3356f7be16
size 38566
oid sha256:a52ddb20b4f07ecdfc23f5d19a26832d8d61cbc25159ac2868e9ff56d844e755
size 36271

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:19b6feb0228df79d7f6ec505a8b073852e4400366027d93feb61c09186494ac6
size 9228
oid sha256:7a52b93594d74e66ac4c5145254feb4a5b82941783defd0e8820c6acc21cac97
size 6900

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b7fdc491e73aa3fe1afb52ac6a22b3ced09c73573dc175c415d116293c04c95d
size 10129
oid sha256:3d11986925680f2b7a9827858396e85ad13792b84412d01f5589a32f1a48c631
size 8038

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f816378db978ff00cd722fd44b8219b6181d9cc6fbf4d3b31fea33430e37580e
size 12309
oid sha256:d440f08f846bec01f07d10ab2347dd5e863ae594109038dc9e6e4e40fe9528d0
size 10239

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a0b97a65622307f289695d3a19aa0957fe739d35070b1f0cd972f7ff51987a7d
size 36824
oid sha256:ee9ef033060816da98e228abfeea3d09c2afc8822b884f1d656646dc3d15ce02
size 34685

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8b71ca3fcc1f94d89264f3d5c5751f46681014b662bcf7d910263dfb23178e0f
size 8623
oid sha256:1957dbe8c580b048fc32877639d42e3761b10a298dc80804634481ef391dcf76
size 6544

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:29d55f260237060e5ac280d6c87f5eefbdaa9dc6710572cab38fbd41dea77090
size 15465
oid sha256:1853de49049bcd1448cc4e7c4a38ed8ab3cf11c3d11b1ddf296f2b6a37e985b4
size 15428

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:925df53e0666d800b0f047f45abda50f1744ef1571594be50efd6008ed988b72
size 14549
oid sha256:d90abb10774208a842a3284346c3fa1d3c8e34b53daf6ea0f14f61bf4be5bb87
size 14551

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:29d55f260237060e5ac280d6c87f5eefbdaa9dc6710572cab38fbd41dea77090
size 15465
oid sha256:1853de49049bcd1448cc4e7c4a38ed8ab3cf11c3d11b1ddf296f2b6a37e985b4
size 15428

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:08b47d8b631032d7cb33cdd150eab67e4dc9e6498813e0f5107007c30143d249
size 26058
oid sha256:e74c3ab733e969059c4d8bb72903ab53aeb855f49857656bfe61f4dee574dbe4
size 70317

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:873e124ea0560fe577b93b5e55e959baf443eedcb07d6b229f0370621ee0dbf3
size 20621
oid sha256:4ee8d9e829efc0723d62411e2069a8dac90b18069d1d8cd5dc5ec6a5b9899a14
size 21572

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:88c68143c2bc7b098664d881bb6c0f2c35ac10ec690c0c7eb7ae6d9366144e83
size 15136
oid sha256:7d1cdeba30c6efca096c3625613ce5ad33b6f09e08eb09b2758d9a039e4206fb
size 15106

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:88c68143c2bc7b098664d881bb6c0f2c35ac10ec690c0c7eb7ae6d9366144e83
size 15136
oid sha256:7d1cdeba30c6efca096c3625613ce5ad33b6f09e08eb09b2758d9a039e4206fb
size 15106

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d2d9ef7383d17436738abf01c450ad189ebffebcfbee12de253fdc92f6feda93
size 28593
oid sha256:cbcf086763463eaa1dbf9cb52620c430f7a7982f01d3abcd039ebd307544f8e7
size 72986

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c8b23c6c8f47f7e49cdb56098408873aa9d3f02af22cae01add580f0e623678d
size 34814
oid sha256:ff96b22724d7f82b3003a73a560da0a34c9c196757b4336706b5823bbfa32589
size 32398

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:25ebb7460550b31bf2cebfca7bdab32e5f89e327b68f6687bf490e5d14cb9220
size 40950
oid sha256:492b5ae698dc52672d8d0a4599c9cd9a5b6f414e8a0a6f42c91e765e5a5b221d
size 40921

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fff7687206e1b1c03ecc4da231e8d030ec730573070550f1d6a7c355ba0c90d2
oid sha256:782fa9e5501e399d4840c0aab6ee317aa4fa8137eab93ee85924ec512b071be1
size 14525

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e379008ac3a5346b727bd9355a4a59b75a1a2eca8855ad4ef5b6d7c239da129b
size 15076
oid sha256:739e2618bac9233b0ff7335d734d7fb594e3ee8860f9e61ef80d2dc4d7736a27
size 15026

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d3fe3f3e7668a3d529132172366622db970391dcc8718f87a7fc90ec67b93ed1
size 13992
oid sha256:49e6a6bda914fc5e77bd0a864900f4fd7f654f4017a331be6008825b2150340d
size 14013

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e379008ac3a5346b727bd9355a4a59b75a1a2eca8855ad4ef5b6d7c239da129b
size 15076
oid sha256:739e2618bac9233b0ff7335d734d7fb594e3ee8860f9e61ef80d2dc4d7736a27
size 15026

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a99c2ae96a72551e0da2e6e3cce08a76fffc2f99763c81d5c3fe65a9604bbdc7
size 25564
oid sha256:9833ad112df471f8be9587999f444ad371ae1eebadfc351cea83c6db5685c9ad
size 62028

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f7c98ab469dd76beea196cfceff2ea9f72c9225f15b701ab7965b54cc9064dd8
size 19505
oid sha256:fcb5bc041286ba863ae982b2ad03873a76e48ed6ebd5d35c82dea269d86363a7
size 21189

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d414cb2e4268815943ce6d78fe8d85f576e055bd8ff4f652b7e15a664e3d8cb3
size 14597
oid sha256:8be4c40bcec9cb84bd087dc395be694b614f88ff5b44a33dafc3ad87576d23c6
size 14578

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d414cb2e4268815943ce6d78fe8d85f576e055bd8ff4f652b7e15a664e3d8cb3
size 14597
oid sha256:8be4c40bcec9cb84bd087dc395be694b614f88ff5b44a33dafc3ad87576d23c6
size 14578

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bfe8f753f87d3b67ad3ffde246dba0438120bb2a28efe033cbfd2204664fee95
size 27633
oid sha256:267cc528f2fdaba66bfad4f8c8622087b76c2e3409f5fda8ce25009039278a22
size 64356

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e361247dd137a828f1170fc1497ace365e04bb551edcd4984c26b15c26fe65f1
size 33011
oid sha256:7f5831741183467f1d05517097f2617aee405a9d6752cdf8a8e193e5851376a3
size 30904

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:39ecc9a50285df492417f5f22adcc391be2dcad0cc388efb756274c83aba077d
size 39030
oid sha256:de98370531bc9342539bbf98b6f3534b72e327a94e34b1c6d827e2330291340c
size 39235

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bd4903a8a383c5fe3c8c5bfeaf26ab16d3c785fccaab5b66de8b31f3380e9272
size 14104
oid sha256:c08080c2814f8e8273949b39359ad105f0305ee6c7b91ddf9b437ce925489b40
size 14125

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:043da4a779363f5162b1fbb6b1159ab3ae3f6a1635473146a5b73242525b5e53
size 390373
oid sha256:66437179fb0b851d4d4d647d00cab94cc7422d625f559839c675b378dbf1af38
size 389408

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2d5f183f53f9e8d0dbbae473f2f853f4372dbff15b1d6ea17e78b4770781fa34
size 35468
oid sha256:8165bcb4b0d52a227aad4e1f3951fc3628ef647947ef2584ed50d8ede8a6a344
size 38248

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:514f67acd325e01d010466786eb85e7db8071c36e2f21454be8f30c4a6a57425
size 32356
oid sha256:c9efb4ed1ee82bba30351bf213f0873637e8194140a3bbea669321bf76bc6483
size 31449

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8a6a599e68f0955d84ea737603f0db83be412433691f7b7b3729a01999808830
size 25827
oid sha256:5d2882d79b9f66726c6d16c8c6fc84cb9f65a5674222fe85794229dc5ba12a6b
size 24679

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a2c00187eb25b297f7debb7424969e5535e48366d139b7f073b6a7628f155d60
size 390395
oid sha256:da172fdf40dc8702bc6dcb89bcc75e93bd279f6bbb9454f5283febe4da25d399
size 389440

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:94520145bde5de6a0d7820dd44d7f0243d9fb06eca062b19b72cb2457abdfb7d
size 95438
oid sha256:bb79e754f9b4caeb40508bdc067d68a4e115e8a50467fc006be6f5db0684ea5b
size 94672

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:82710704b6daff1c1e12a4a3c782f68744c0d6b3ee30b3fa1e38739176c6eaef
size 397724
oid sha256:7ad6e45382dec9bb27593b6e2ed92ed633204479040ccebaf4d362bb2f41fec7
size 396403

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1547883dfc7ae3d742d2da2f6e5c6c2d6182d6039b3ce6075dbbe1d24f4ba341
size 23370
oid sha256:77913c010877d13d82196182d32e19168e77102d2f245ab321c2224a7108768a
size 21874

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:66add7cb3b696075b7e931b06d1d8472cfddc2fc1902081864c3d88754f7404a
size 6702
oid sha256:69b5d7572ae6e4ff084867fac1bae41a55c75c9a5236cb6ccb4c31b89ef77898
size 5442

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a86ec4a40a63f646e62b1c6a3e481f0b68be442923e39b6750836ae4e5ac3045
size 15577
oid sha256:f8d3e3f8733424870b254be90599ed1ff6ba784089600bcb200fbef62c81537c
size 14562

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0abf1fdae34ea898f817f9667a02adb551d5e3bb528f73b4c2c01826f4ca375a
size 15881
oid sha256:d1785d90957316791969f047e66bd779da62d675004914099f2af2b69bebe405
size 14700

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c7642e9e20a551e59f4529c52fa7fbe5b3f4dfcb8c26caeb716ccb2bdfc63dde
size 27176
oid sha256:1ddcd8e9e20de4171a3d9f8175806a268723e47a14dca431849c2c29edaf5d0b
size 26267

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1fd53c24dd38a12b4ceefa54e1d6d096d8f443d1cdd1d1f1cc71f92c1d603a51
size 27419
oid sha256:035ef0079af6e9825a52b86e2eab50667404a66d70dd2756596b60cc1cea376a
size 26404