Merge branch 'develop' into feature/fga/room_directory
This commit is contained in:
commit
3eae08b3e9
264 changed files with 944 additions and 201 deletions
1
changelog.d/2521.feature
Normal file
1
changelog.d/2521.feature
Normal file
|
|
@ -0,0 +1 @@
|
|||
Implement MSC2530 (Body field as media caption)
|
||||
1
changelog.d/2574.misc
Normal file
1
changelog.d/2574.misc
Normal file
|
|
@ -0,0 +1 @@
|
|||
Compound: add `BigIcon`, `BigCheckmark` and `PageTitle` components.
|
||||
|
|
@ -615,9 +615,9 @@ private fun MessageEventBubbleContent(
|
|||
}
|
||||
|
||||
val timestampPosition = when (event.content) {
|
||||
is TimelineItemImageContent,
|
||||
is TimelineItemImageContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemVideoContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemStickerContent,
|
||||
is TimelineItemVideoContent,
|
||||
is TimelineItemLocationContent -> TimestampPosition.Overlay
|
||||
is TimelineItemPollContent -> TimestampPosition.Below
|
||||
else -> TimestampPosition.Default
|
||||
|
|
@ -723,10 +723,10 @@ private fun ReplyToContentText(metadata: InReplyToMetadata?) {
|
|||
@Composable
|
||||
internal fun TimelineItemEventRowPreview() = ElementPreview {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach {
|
||||
sequenceOf(false, true).forEach { isMine ->
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = it,
|
||||
isMine = isMine,
|
||||
content = aTimelineItemTextContent().copy(
|
||||
body = "A long text which will be displayed on several lines and" +
|
||||
" hopefully can be manually adjusted to test different behaviors."
|
||||
|
|
@ -736,7 +736,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
|
|||
)
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = it,
|
||||
isMine = isMine,
|
||||
content = aTimelineItemImageContent().copy(
|
||||
aspectRatio = 2.5f
|
||||
),
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
|
|||
),
|
||||
aMessageContent(
|
||||
body = "Video",
|
||||
type = VideoMessageType("Video", MediaSource("url"), null),
|
||||
type = VideoMessageType("Video", null, null, MediaSource("url"), null),
|
||||
),
|
||||
aMessageContent(
|
||||
body = "Audio",
|
||||
|
|
@ -113,7 +113,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
|
|||
),
|
||||
aMessageContent(
|
||||
body = "Image",
|
||||
type = ImageMessageType("Image", MediaSource("url"), null),
|
||||
type = ImageMessageType("Image", null, null, MediaSource("url"), null),
|
||||
),
|
||||
aMessageContent(
|
||||
body = "Sticker",
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private const val MIN_HEIGHT_IN_DP = 100
|
||||
private const val MAX_HEIGHT_IN_DP = 360
|
||||
const val MIN_HEIGHT_IN_DP = 100
|
||||
const val MAX_HEIGHT_IN_DP = 360
|
||||
private const val DEFAULT_ASPECT_RATIO = 1.33f
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ fun TimelineItemEventContentView(
|
|||
)
|
||||
is TimelineItemImageContent -> TimelineItemImageView(
|
||||
content = content,
|
||||
onContentLayoutChanged = onContentLayoutChanged,
|
||||
modifier = modifier,
|
||||
)
|
||||
is TimelineItemStickerContent -> TimelineItemStickerView(
|
||||
|
|
@ -85,6 +86,7 @@ fun TimelineItemEventContentView(
|
|||
)
|
||||
is TimelineItemVideoContent -> TimelineItemVideoView(
|
||||
content = content,
|
||||
onContentLayoutChanged = onContentLayoutChanged,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemFileContent -> TimelineItemFileView(
|
||||
|
|
|
|||
|
|
@ -16,39 +16,134 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannedString
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
|
||||
import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
|
||||
@Composable
|
||||
fun TimelineItemImageView(
|
||||
content: TimelineItemImageContent,
|
||||
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val description = stringResource(CommonStrings.common_image)
|
||||
TimelineItemAspectRatioBox(
|
||||
aspectRatio = content.aspectRatio,
|
||||
Column(
|
||||
modifier = modifier.semantics { contentDescription = description },
|
||||
) {
|
||||
BlurHashAsyncImage(
|
||||
model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
|
||||
blurHash = content.blurhash,
|
||||
)
|
||||
val containerModifier = if (content.showCaption) {
|
||||
Modifier
|
||||
.padding(top = 6.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
TimelineItemAspectRatioBox(
|
||||
modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f),
|
||||
aspectRatio = content.aspectRatio,
|
||||
) {
|
||||
var isLoaded by remember { mutableStateOf(false) }
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
|
||||
model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.Center,
|
||||
contentDescription = description,
|
||||
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
||||
)
|
||||
}
|
||||
|
||||
if (content.showCaption) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val caption = if (LocalInspectionMode.current) {
|
||||
SpannedString(content.caption)
|
||||
} else {
|
||||
content.formatted?.body?.takeIf { content.formatted.format == MessageFormat.HTML } ?: SpannedString(content.caption)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
|
||||
) {
|
||||
EditorStyledText(
|
||||
modifier = Modifier
|
||||
.widthIn(min = MIN_HEIGHT_IN_DP.dp * content.aspectRatio!!, max = MAX_HEIGHT_IN_DP.dp * content.aspectRatio),
|
||||
text = caption,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
releaseOnDetach = false,
|
||||
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChanged = onContentLayoutChanged),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageContentProvider::class) content: TimelineItemImageContent) = ElementPreview {
|
||||
TimelineItemImageView(content)
|
||||
TimelineItemImageView(content, {})
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineImageWithCaptionRowPreview() = ElementPreview {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach { isMine ->
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = isMine,
|
||||
content = aTimelineItemImageContent().copy(
|
||||
filename = "image.jpg",
|
||||
body = "A long caption that may wrap into several lines",
|
||||
aspectRatio = 2.5f,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
|||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider
|
||||
import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
|
||||
import io.element.android.libraries.designsystem.components.blurhash.BlurHashAsyncImage
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
|
|
|
|||
|
|
@ -16,54 +16,124 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannedString
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
|
||||
import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
|
||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||
import io.element.android.libraries.designsystem.modifiers.roundedBackground
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
|
||||
@Composable
|
||||
fun TimelineItemVideoView(
|
||||
content: TimelineItemVideoContent,
|
||||
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val description = stringResource(CommonStrings.common_image)
|
||||
TimelineItemAspectRatioBox(
|
||||
aspectRatio = content.aspectRatio,
|
||||
modifier = modifier.semantics { contentDescription = description },
|
||||
contentAlignment = Alignment.Center,
|
||||
Column(
|
||||
modifier = modifier.semantics { contentDescription = description }
|
||||
) {
|
||||
BlurHashAsyncImage(
|
||||
model = MediaRequestData(content.thumbnailSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
|
||||
blurHash = content.blurHash,
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.roundedBackground(),
|
||||
val containerModifier = if (content.showCaption) {
|
||||
Modifier.padding(top = 6.dp).clip(RoundedCornerShape(6.dp))
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
TimelineItemAspectRatioBox(
|
||||
modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f),
|
||||
aspectRatio = content.aspectRatio,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_play),
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
var isLoaded by remember { mutableStateOf(false) }
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
|
||||
model = MediaRequestData(content.thumbnailSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.Center,
|
||||
contentDescription = description,
|
||||
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.roundedBackground(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_play),
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (content.showCaption) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val caption = if (LocalInspectionMode.current) {
|
||||
SpannedString(content.caption)
|
||||
} else {
|
||||
content.formatted?.body?.takeIf { content.formatted.format == MessageFormat.HTML } ?: SpannedString(content.caption)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular,
|
||||
) {
|
||||
EditorStyledText(
|
||||
modifier = Modifier
|
||||
.widthIn(min = MIN_HEIGHT_IN_DP.dp * content.aspectRatio!!, max = MAX_HEIGHT_IN_DP.dp * content.aspectRatio),
|
||||
text = caption,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
releaseOnDetach = false,
|
||||
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChanged = onContentLayoutChanged),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -71,5 +141,25 @@ fun TimelineItemVideoView(
|
|||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoContentProvider::class) content: TimelineItemVideoContent) = ElementPreview {
|
||||
TimelineItemVideoView(content)
|
||||
TimelineItemVideoView(content, {})
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineVideoWithCaptionRowPreview() = ElementPreview {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach { isMine ->
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = isMine,
|
||||
content = aTimelineItemVideoContent().copy(
|
||||
filename = "video.mp4",
|
||||
body = "A long caption that may wrap into several lines",
|
||||
aspectRatio = 2.5f,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
|
||||
TimelineItemImageContent(
|
||||
body = messageType.body.trimEnd(),
|
||||
formatted = messageType.formatted,
|
||||
filename = messageType.filename,
|
||||
mediaSource = messageType.source,
|
||||
thumbnailSource = messageType.info?.thumbnailSource,
|
||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
|
|
@ -91,7 +93,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
height = messageType.info?.height?.toInt(),
|
||||
aspectRatio = aspectRatio,
|
||||
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
||||
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
|
||||
fileExtension = messageType.filename?.let { fileExtensionExtractor.extractFromName(it) }.orEmpty()
|
||||
)
|
||||
}
|
||||
is StickerMessageType -> {
|
||||
|
|
@ -132,6 +134,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
|
||||
TimelineItemVideoContent(
|
||||
body = messageType.body.trimEnd(),
|
||||
formatted = messageType.formatted,
|
||||
filename = messageType.filename,
|
||||
thumbnailSource = messageType.info?.thumbnailSource,
|
||||
videoSource = messageType.source,
|
||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
|
|
@ -141,7 +145,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
blurHash = messageType.info?.blurhash,
|
||||
aspectRatio = aspectRatio,
|
||||
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
||||
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
|
||||
fileExtension = messageType.filename?.let { fileExtensionExtractor.extractFromName(it) }.orEmpty(),
|
||||
)
|
||||
}
|
||||
is AudioMessageType -> {
|
||||
|
|
|
|||
|
|
@ -18,9 +18,12 @@ package io.element.android.features.messages.impl.timeline.model.event
|
|||
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
||||
|
||||
data class TimelineItemImageContent(
|
||||
val body: String,
|
||||
val formatted: FormattedBody?,
|
||||
val filename: String?,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val formattedFileSize: String,
|
||||
|
|
@ -33,6 +36,9 @@ data class TimelineItemImageContent(
|
|||
) : TimelineItemEventContent {
|
||||
override val type: String = "TimelineItemImageContent"
|
||||
|
||||
val showCaption = filename != null && filename != body
|
||||
val caption = if (showCaption) body else ""
|
||||
|
||||
val preferredMediaSource = if (mimeType == MimeTypes.Gif) {
|
||||
mediaSource
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineI
|
|||
|
||||
fun aTimelineItemImageContent() = TimelineItemImageContent(
|
||||
body = "a body",
|
||||
formatted = null,
|
||||
filename = null,
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
mimeType = MimeTypes.IMAGE_JPEG,
|
||||
|
|
|
|||
|
|
@ -17,10 +17,13 @@
|
|||
package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class TimelineItemVideoContent(
|
||||
val body: String,
|
||||
val formatted: FormattedBody?,
|
||||
val filename: String?,
|
||||
val duration: Duration,
|
||||
val videoSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
|
|
@ -33,4 +36,7 @@ data class TimelineItemVideoContent(
|
|||
val fileExtension: String,
|
||||
) : TimelineItemEventContent {
|
||||
override val type: String = "TimelineItemImageContent"
|
||||
|
||||
val showCaption = filename != null && filename != body
|
||||
val caption = if (showCaption) body else ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
|
|||
|
||||
fun aTimelineItemVideoContent() = TimelineItemVideoContent(
|
||||
body = "Video.mp4",
|
||||
formatted = null,
|
||||
filename = null,
|
||||
thumbnailSource = null,
|
||||
blurHash = A_BLUR_HASH,
|
||||
aspectRatio = 0.5f,
|
||||
|
|
|
|||
|
|
@ -270,6 +270,8 @@ class MessagesPresenterTest {
|
|||
val mediaMessage = aMessageEvent(
|
||||
content = TimelineItemImageContent(
|
||||
body = "image.jpg",
|
||||
formatted = null,
|
||||
filename = null,
|
||||
mediaSource = MediaSource(AN_AVATAR_URL),
|
||||
thumbnailSource = null,
|
||||
mimeType = MimeTypes.Jpeg,
|
||||
|
|
@ -300,6 +302,8 @@ class MessagesPresenterTest {
|
|||
val mediaMessage = aMessageEvent(
|
||||
content = TimelineItemVideoContent(
|
||||
body = "video.mp4",
|
||||
formatted = null,
|
||||
filename = null,
|
||||
duration = 10.milliseconds,
|
||||
videoSource = MediaSource(AN_AVATAR_URL),
|
||||
thumbnailSource = MediaSource(AN_AVATAR_URL),
|
||||
|
|
|
|||
|
|
@ -227,12 +227,14 @@ class TimelineItemContentMessageFactoryTest {
|
|||
fun `test create VideoMessageType`() = runTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = VideoMessageType("body", MediaSource("url"), null)),
|
||||
content = createMessageContent(type = VideoMessageType("body", null, null, MediaSource("url"), null)),
|
||||
senderDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemVideoContent(
|
||||
body = "body",
|
||||
formatted = null,
|
||||
filename = null,
|
||||
duration = Duration.ZERO,
|
||||
videoSource = MediaSource(url = "url", json = null),
|
||||
thumbnailSource = null,
|
||||
|
|
@ -253,7 +255,9 @@ class TimelineItemContentMessageFactoryTest {
|
|||
val result = sut.create(
|
||||
content = createMessageContent(
|
||||
type = VideoMessageType(
|
||||
body = "body.mp4",
|
||||
body = "body.mp4 caption",
|
||||
formatted = FormattedBody(MessageFormat.HTML, "formatted"),
|
||||
filename = "body.mp4",
|
||||
source = MediaSource("url"),
|
||||
info = VideoInfo(
|
||||
duration = 1.minutes,
|
||||
|
|
@ -276,7 +280,9 @@ class TimelineItemContentMessageFactoryTest {
|
|||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemVideoContent(
|
||||
body = "body.mp4",
|
||||
body = "body.mp4 caption",
|
||||
formatted = FormattedBody(MessageFormat.HTML, "formatted"),
|
||||
filename = "body.mp4",
|
||||
duration = 1.minutes,
|
||||
videoSource = MediaSource(url = "url", json = null),
|
||||
thumbnailSource = MediaSource("url_thumbnail"),
|
||||
|
|
@ -420,12 +426,14 @@ class TimelineItemContentMessageFactoryTest {
|
|||
fun `test create ImageMessageType`() = runTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = ImageMessageType("body", MediaSource("url"), null)),
|
||||
content = createMessageContent(type = ImageMessageType("body", null, null, MediaSource("url"), null)),
|
||||
senderDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemImageContent(
|
||||
body = "body",
|
||||
formatted = null,
|
||||
filename = null,
|
||||
mediaSource = MediaSource(url = "url", json = null),
|
||||
thumbnailSource = null,
|
||||
formattedFileSize = "0 Bytes",
|
||||
|
|
@ -470,7 +478,9 @@ class TimelineItemContentMessageFactoryTest {
|
|||
val result = sut.create(
|
||||
content = createMessageContent(
|
||||
type = ImageMessageType(
|
||||
body = "body.jpg",
|
||||
body = "body.jpg caption",
|
||||
formatted = FormattedBody(MessageFormat.HTML, "formatted"),
|
||||
filename = "body.jpg",
|
||||
source = MediaSource("url"),
|
||||
info = ImageInfo(
|
||||
height = 10L,
|
||||
|
|
@ -492,7 +502,9 @@ class TimelineItemContentMessageFactoryTest {
|
|||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemImageContent(
|
||||
body = "body.jpg",
|
||||
body = "body.jpg caption",
|
||||
formatted = FormattedBody(MessageFormat.HTML, "formatted"),
|
||||
filename = "body.jpg",
|
||||
mediaSource = MediaSource(url = "url", json = null),
|
||||
thumbnailSource = MediaSource("url_thumbnail"),
|
||||
formattedFileSize = "888 Bytes",
|
||||
|
|
|
|||
|
|
@ -83,6 +83,8 @@ class InReplyToMetadataKtTest {
|
|||
eventContent = aMessageContent(
|
||||
messageType = ImageMessageType(
|
||||
body = "body",
|
||||
formatted = null,
|
||||
filename = null,
|
||||
source = aMediaSource(),
|
||||
info = anImageInfo(),
|
||||
)
|
||||
|
|
@ -137,6 +139,8 @@ class InReplyToMetadataKtTest {
|
|||
eventContent = aMessageContent(
|
||||
messageType = VideoMessageType(
|
||||
body = "body",
|
||||
formatted = null,
|
||||
filename = null,
|
||||
source = aMediaSource(),
|
||||
info = aVideoInfo(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ class PollHistoryStateProvider : PreviewParameterProvider<PollHistoryState> {
|
|||
activeFilter = PollHistoryFilter.PAST,
|
||||
currentItems = emptyList(),
|
||||
),
|
||||
aPollHistoryState(
|
||||
activeFilter = PollHistoryFilter.PAST,
|
||||
currentItems = emptyList(),
|
||||
hasMoreToLoad = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ private fun PollHistoryList(
|
|||
Column(
|
||||
modifier = Modifier.fillParentMaxSize().padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
val emptyStringResource = if (filter == PollHistoryFilter.PAST) {
|
||||
stringResource(R.string.screen_polls_history_empty_past)
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ signing.element.nightly.keyPassword=Secret
|
|||
|
||||
# Customise the Lint version to use a more recent version than the one bundled with AGP
|
||||
# https://googlesamples.github.io/android-custom-lint-rules/usage/newer-lint.md.html
|
||||
android.experimental.lint.version=8.3.0-alpha12
|
||||
android.experimental.lint.version=8.4.0-alpha13
|
||||
|
||||
# Enable test fixture for all modules by default
|
||||
android.experimental.enableTestFixtures=true
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
|
||||
[versions]
|
||||
# Project
|
||||
android_gradle_plugin = "8.2.2"
|
||||
kotlin = "1.9.22"
|
||||
ksp = "1.9.22-1.0.17"
|
||||
android_gradle_plugin = "8.3.1"
|
||||
kotlin = "1.9.23"
|
||||
ksp = "1.9.23-1.0.19"
|
||||
firebaseAppDistribution = "4.2.0"
|
||||
|
||||
# AndroidX
|
||||
|
|
@ -18,8 +18,8 @@ activity = "1.8.2"
|
|||
media3 = "1.3.0"
|
||||
|
||||
# Compose
|
||||
compose_bom = "2024.02.02"
|
||||
composecompiler = "1.5.10"
|
||||
compose_bom = "2024.03.00"
|
||||
composecompiler = "1.5.11"
|
||||
|
||||
# Coroutines
|
||||
coroutines = "1.8.0"
|
||||
|
|
@ -38,7 +38,7 @@ serialization_json = "1.6.3"
|
|||
showkase = "1.0.2"
|
||||
appyx = "1.4.0"
|
||||
sqldelight = "2.0.1"
|
||||
wysiwyg = "2.33.0"
|
||||
wysiwyg = "2.34.0"
|
||||
telephoto = "0.8.0"
|
||||
|
||||
# DI
|
||||
|
|
@ -153,7 +153,7 @@ jsoup = "org.jsoup:jsoup:1.17.2"
|
|||
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
|
||||
molecule-runtime = "app.cash.molecule:molecule-runtime:1.4.1"
|
||||
timber = "com.jakewharton.timber:timber:5.0.1"
|
||||
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.10"
|
||||
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.11"
|
||||
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
|
||||
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
|
||||
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* 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.libraries.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.bigCheckmarkBorderColor
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
* Compound component that displays a big checkmark centered in a rounded square.
|
||||
*
|
||||
* @param modifier the modifier to apply to this layout
|
||||
*/
|
||||
@Composable
|
||||
fun BigCheckmark(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.size(120.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = ElementTheme.colors.bgCanvasDefault,
|
||||
border = BorderStroke(1.dp, ElementTheme.colors.bigCheckmarkBorderColor),
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
modifier = Modifier.size(72.dp),
|
||||
tint = ElementTheme.colors.iconSuccessPrimary,
|
||||
imageVector = CompoundIcons.CheckCircleSolid(),
|
||||
contentDescription = stringResource(CommonStrings.common_success)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun BigCheckmarkPreview() {
|
||||
ElementPreview {
|
||||
Box(
|
||||
modifier = Modifier.padding(10.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
BigCheckmark()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
* 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.libraries.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CatchingPokemon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.bigIconDefaultBackgroundColor
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
* Compound component that display a big icon centered in a rounded square.
|
||||
*/
|
||||
object BigIcon {
|
||||
/**
|
||||
* The style of the [BigIcon].
|
||||
*/
|
||||
@Immutable
|
||||
sealed interface Style {
|
||||
/**
|
||||
* The default style.
|
||||
*
|
||||
* @param vectorIcon the [ImageVector] to display
|
||||
* @param contentDescription the content description of the icon, if any. It defaults to `null`
|
||||
*/
|
||||
data class Default(val vectorIcon: ImageVector, val contentDescription: String? = null) : Style
|
||||
|
||||
/**
|
||||
* An alert style with a transparent background.
|
||||
*/
|
||||
data object Alert : Style
|
||||
|
||||
/**
|
||||
* An alert style with a tinted background.
|
||||
*/
|
||||
data object AlertSolid : Style
|
||||
|
||||
/**
|
||||
* A success style with a transparent background.
|
||||
*/
|
||||
data object Success : Style
|
||||
|
||||
/**
|
||||
* A success style with a tinted background.
|
||||
*/
|
||||
data object SuccessSolid : Style
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a [BigIcon].
|
||||
*
|
||||
* @param style the style of the icon
|
||||
* @param modifier the modifier to apply to this layout
|
||||
*/
|
||||
@Composable
|
||||
operator fun invoke(
|
||||
style: Style,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val backgroundColor = when (style) {
|
||||
is Style.Default -> ElementTheme.colors.bigIconDefaultBackgroundColor
|
||||
Style.Alert, Style.Success -> Color.Transparent
|
||||
Style.AlertSolid -> ElementTheme.colors.bgCriticalSubtle
|
||||
Style.SuccessSolid -> ElementTheme.colors.bgSuccessSubtle
|
||||
}
|
||||
val icon = when (style) {
|
||||
is Style.Default -> style.vectorIcon
|
||||
Style.Alert, Style.AlertSolid -> CompoundIcons.Error()
|
||||
Style.Success, Style.SuccessSolid -> CompoundIcons.CheckCircleSolid()
|
||||
}
|
||||
val contentDescription = when (style) {
|
||||
is Style.Default -> style.contentDescription
|
||||
Style.Alert, Style.AlertSolid -> stringResource(CommonStrings.common_error)
|
||||
Style.Success, Style.SuccessSolid -> stringResource(CommonStrings.common_success)
|
||||
}
|
||||
val iconTint = when (style) {
|
||||
is Style.Default -> ElementTheme.colors.iconSecondaryAlpha
|
||||
Style.Alert, Style.AlertSolid -> ElementTheme.colors.iconCriticalPrimary
|
||||
Style.Success, Style.SuccessSolid -> ElementTheme.colors.iconSuccessPrimary
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(backgroundColor),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = iconTint,
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun BigIconPreview() {
|
||||
ElementPreview {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(10.dp)) {
|
||||
val provider = BigIconStylePreviewProvider()
|
||||
for (style in provider.values) {
|
||||
BigIcon(style = style)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class BigIconStylePreviewProvider : PreviewParameterProvider<BigIcon.Style> {
|
||||
override val values: Sequence<BigIcon.Style>
|
||||
get() = sequenceOf(
|
||||
BigIcon.Style.Default(Icons.Filled.CatchingPokemon),
|
||||
BigIcon.Style.Alert,
|
||||
BigIcon.Style.AlertSolid,
|
||||
BigIcon.Style.Success,
|
||||
BigIcon.Style.SuccessSolid
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
* 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.libraries.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
|
||||
/**
|
||||
* Compound component that displays a big icon, a title, an optional subtitle and an optional call to action component.
|
||||
*
|
||||
* @param title the title to display
|
||||
* @param iconStyle the style of the [BigIcon] to display
|
||||
* @param modifier the modifier to apply to this layout
|
||||
* @param subtitle the optional subtitle to display. It defaults to `null`
|
||||
* @param callToAction the optional call to action component to display. It defaults to `null`
|
||||
*/
|
||||
@Composable
|
||||
fun PageTitle(
|
||||
title: AnnotatedString,
|
||||
iconStyle: BigIcon.Style,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: AnnotatedString? = null,
|
||||
callToAction: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 40.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
BigIcon(style = iconStyle)
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = title,
|
||||
style = ElementTheme.typography.fontHeadingMdBold,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
subtitle?.let {
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = it,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
callToAction?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compound component that displays a big icon, a title, an optional subtitle and an optional call to action component.
|
||||
*
|
||||
* @param title the title to display
|
||||
* @param iconStyle the style of the [BigIcon] to display
|
||||
* @param modifier the modifier to apply to this layout
|
||||
* @param subtitle the optional subtitle to display. It defaults to `null`
|
||||
* @param callToAction the optional call to action component to display. It defaults to `null`
|
||||
*/
|
||||
@Composable
|
||||
fun PageTitle(
|
||||
title: String,
|
||||
iconStyle: BigIcon.Style,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
callToAction: @Composable (() -> Unit)? = null,
|
||||
) = PageTitle(
|
||||
title = AnnotatedString(title),
|
||||
iconStyle = iconStyle,
|
||||
modifier = modifier,
|
||||
subtitle = subtitle?.let { AnnotatedString(it) },
|
||||
callToAction = callToAction
|
||||
)
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TitleWithIconFullPreview(@PreviewParameter(BigIconStylePreviewProvider::class) style: BigIcon.Style) {
|
||||
ElementPreview {
|
||||
PageTitle(
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
title = AnnotatedString("Headline"),
|
||||
subtitle = AnnotatedString("Description goes here"),
|
||||
iconStyle = style,
|
||||
callToAction = {
|
||||
TextButton(text = "Learn more", onClick = {})
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TitleWithIconMinimalPreview() {
|
||||
ElementPreview {
|
||||
PageTitle(
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
title = "Headline",
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.CheckCircleSolid()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* 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.
|
||||
|
|
@ -14,27 +14,22 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.components
|
||||
package io.element.android.libraries.designsystem.components.blurhash
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil.compose.AsyncImage
|
||||
import com.vanniktech.blurhash.BlurHash
|
||||
|
||||
@Composable
|
||||
fun BlurHashAsyncImage(
|
||||
|
|
@ -69,31 +64,3 @@ fun BlurHashAsyncImage(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlurHashImage(
|
||||
blurHash: String?,
|
||||
contentDescription: String? = null,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
) {
|
||||
if (blurHash == null) return
|
||||
val bitmapState = remember(blurHash) {
|
||||
mutableStateOf(
|
||||
// Build a small blurhash image so that it's fast
|
||||
BlurHash.decode(blurHash, 10, 10)
|
||||
)
|
||||
}
|
||||
DisposableEffect(blurHash) {
|
||||
onDispose {
|
||||
bitmapState.value?.recycle()
|
||||
}
|
||||
}
|
||||
bitmapState.value?.let { bitmap ->
|
||||
Image(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentScale = contentScale,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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.libraries.designsystem.components.blurhash
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
|
||||
fun Modifier.blurHashBackground(blurHash: String?, alpha: Float = 1f) = this.composed {
|
||||
val blurHashBitmap = rememberBlurHashImage(blurHash)
|
||||
if (blurHashBitmap != null) {
|
||||
Modifier.drawBehind {
|
||||
drawImage(blurHashBitmap, dstSize = IntSize(size.width.toInt(), size.height.toInt()), alpha = alpha)
|
||||
}
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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.libraries.designsystem.components.blurhash
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import com.vanniktech.blurhash.BlurHash
|
||||
|
||||
@Suppress("ModifierMissing")
|
||||
@Composable
|
||||
fun BlurHashImage(
|
||||
blurHash: String?,
|
||||
contentDescription: String? = null,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
) {
|
||||
if (blurHash == null) return
|
||||
val blurHashImage = rememberBlurHashImage(blurHash)
|
||||
blurHashImage?.let { bitmap ->
|
||||
Image(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bitmap = bitmap,
|
||||
contentScale = contentScale,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberBlurHashImage(blurHash: String?): ImageBitmap? {
|
||||
return if (LocalInspectionMode.current) {
|
||||
blurHash?.let { BlurHash.decode(it, 10, 10)?.asImageBitmap() }
|
||||
} else {
|
||||
produceState<ImageBitmap?>(initialValue = null, blurHash) {
|
||||
blurHash?.let { value = BlurHash.decode(it, 10, 10)?.asImageBitmap() }
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
|
@ -19,9 +19,12 @@ package io.element.android.libraries.designsystem.theme
|
|||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import io.element.android.compound.annotations.CoreColorToken
|
||||
import io.element.android.compound.previews.ColorListPreview
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.SemanticColors
|
||||
import io.element.android.compound.tokens.generated.internal.DarkColorTokens
|
||||
import io.element.android.compound.tokens.generated.internal.LightColorTokens
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
|
|
@ -138,6 +141,14 @@ val SemanticColors.mentionPillBackground
|
|||
Color(0x26f4f7fa)
|
||||
}
|
||||
|
||||
@OptIn(CoreColorToken::class)
|
||||
val SemanticColors.bigIconDefaultBackgroundColor
|
||||
get() = if (isLight) LightColorTokens.colorAlphaGray300 else DarkColorTokens.colorAlphaGray300
|
||||
|
||||
@OptIn(CoreColorToken::class)
|
||||
val SemanticColors.bigCheckmarkBorderColor
|
||||
get() = if (isLight) LightColorTokens.colorGray400 else DarkColorTokens.colorGray400
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ColorAliasesPreview() = ElementPreview {
|
||||
|
|
@ -155,6 +166,7 @@ internal fun ColorAliasesPreview() = ElementPreview {
|
|||
"progressIndicatorTrackColor" to ElementTheme.colors.progressIndicatorTrackColor,
|
||||
"temporaryColorBgSpecial" to ElementTheme.colors.temporaryColorBgSpecial,
|
||||
"iconSuccessPrimaryBackground" to ElementTheme.colors.iconSuccessPrimaryBackground,
|
||||
"bigIconBackgroundColor" to ElementTheme.colors.bigIconDefaultBackgroundColor,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,10 +161,10 @@ class DefaultRoomLastMessageFormatterTest {
|
|||
|
||||
val sharedContentMessagesTypes = arrayOf(
|
||||
TextMessageType(body, null),
|
||||
VideoMessageType(body, MediaSource("url"), null),
|
||||
VideoMessageType(body, null, null, MediaSource("url"), null),
|
||||
AudioMessageType(body, MediaSource("url"), null),
|
||||
VoiceMessageType(body, MediaSource("url"), null, null),
|
||||
ImageMessageType(body, MediaSource("url"), null),
|
||||
ImageMessageType(body, null, null, MediaSource("url"), null),
|
||||
StickerMessageType(body, MediaSource("url"), null),
|
||||
FileMessageType(body, MediaSource("url"), null),
|
||||
LocationMessageType(body, "geo:1,2", null),
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ data class EmoteMessageType(
|
|||
|
||||
data class ImageMessageType(
|
||||
val body: String,
|
||||
val formatted: FormattedBody?,
|
||||
val filename: String?,
|
||||
val source: MediaSource,
|
||||
val info: ImageInfo?
|
||||
) : MessageType
|
||||
|
|
@ -65,6 +67,8 @@ data class VoiceMessageType(
|
|||
|
||||
data class VideoMessageType(
|
||||
val body: String,
|
||||
val formatted: FormattedBody?,
|
||||
val filename: String?,
|
||||
val source: MediaSource,
|
||||
val info: VideoInfo?
|
||||
) : MessageType
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ class EventMessageMapper {
|
|||
FileMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
|
||||
}
|
||||
is RustMessageType.Image -> {
|
||||
ImageMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
|
||||
ImageMessageType(type.content.body, type.content.formatted?.map(), type.content.filename, type.content.source.map(), type.content.info?.map())
|
||||
}
|
||||
is RustMessageType.Notice -> {
|
||||
NoticeMessageType(type.content.body, type.content.formatted?.map())
|
||||
|
|
@ -110,7 +110,7 @@ class EventMessageMapper {
|
|||
EmoteMessageType(type.content.body, type.content.formatted?.map())
|
||||
}
|
||||
is RustMessageType.Video -> {
|
||||
VideoMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
|
||||
VideoMessageType(type.content.body, type.content.formatted?.map(), type.content.filename, type.content.source.map(), type.content.info?.map())
|
||||
}
|
||||
is RustMessageType.Location -> {
|
||||
LocationMessageType(type.content.body, type.content.geoUri, type.content.description)
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ import androidx.compose.ui.layout.ContentScale
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
|
||||
import io.element.android.libraries.designsystem.components.PinIcon
|
||||
import io.element.android.libraries.designsystem.components.blurhash.BlurHashAsyncImage
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ class NotifiableEventResolverTest {
|
|||
createNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = VideoMessageType(body = "Video", MediaSource("url"), null)
|
||||
messageType = VideoMessageType(body = "Video", null, null, MediaSource("url"), null)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -224,7 +224,7 @@ class NotifiableEventResolverTest {
|
|||
createNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = ImageMessageType("Image", MediaSource("url"), null),
|
||||
messageType = ImageMessageType("Image", null, null, MediaSource("url"), null),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import org.gradle.api.JavaVersion
|
|||
import org.gradle.api.Project
|
||||
import java.io.File
|
||||
|
||||
fun CommonExtension<*, *, *, *, *>.androidConfig(project: Project) {
|
||||
fun CommonExtension<*, *, *, *, *, *>.androidConfig(project: Project) {
|
||||
defaultConfig {
|
||||
compileSdk = Versions.compileSdk
|
||||
minSdk = Versions.minSdk
|
||||
|
|
@ -53,7 +53,7 @@ fun CommonExtension<*, *, *, *, *>.androidConfig(project: Project) {
|
|||
}
|
||||
}
|
||||
|
||||
fun CommonExtension<*, *, *, *, *>.composeConfig(libs: LibrariesForLibs) {
|
||||
fun CommonExtension<*, *, *, *, *, *>.composeConfig(libs: LibrariesForLibs) {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9da6e0e90c73d447145dd71a7dd1cd2f6c60cc1a85bb57427d522fb793263ad1
|
||||
size 137016
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9878ddbb3e3ac9a9e997002487272edd73efc8beb329cd9ad6e4f12f388fcb5a
|
||||
size 135425
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:301b6cc63cc5e075aa70e0d4d67da5a3fd96049f834f5d04665e905de6860d89
|
||||
size 123525
|
||||
oid sha256:77cdd18c2c42c406ba338ad56d133a9f220f6de00553b61be6edee1fb933dd5e
|
||||
size 118753
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d7c47c713c74766c39d3b349d9e421ea588261e043105a5475cd8e68af782806
|
||||
size 185325
|
||||
oid sha256:cd0025b120e0d90a9770524cf2c4ce7b7966338083a8b63b6db9285dd8949c8d
|
||||
size 180290
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5d5479f3a6ea138b00c8f6a376cc4a67984385a087fb32bcdb764b2996e89402
|
||||
size 137137
|
||||
oid sha256:f9660b5e2034920207c53c2b02aea5f85f9278393208e5bd21b69ac6060a486c
|
||||
size 130411
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d03c9055f17ecba10787da59bc7d598b9b397c52f70f930a4603c58bb26b2d60
|
||||
size 123995
|
||||
oid sha256:ed3e7817151d518a353834abad6469ecbd14376988712282a3f5f04396de6118
|
||||
size 119553
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:df1bcc21fd0d3aea40bf70f8f6bb29b90fac461e5038ed06e1f52486874e0d68
|
||||
size 185273
|
||||
oid sha256:63ee2e5cf1f537bf99bd3b2145c3a8e0586257638772b99738d8ba39e39f00a5
|
||||
size 179578
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:486097573705148658e334d7543bd78df6430a742dfb984b5578ecd6cf81d5ff
|
||||
size 136445
|
||||
oid sha256:adeb027966ee73299589716934ddf1cf39c158c06c1ba5998aff06979c355d63
|
||||
size 130106
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f4fe06708cd1f1e49254779bce07f6751792a94fa9065882385efad1ac8663c2
|
||||
size 124159
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:55d2d11ba729a68b1b62e9a69c3308594839fdb3612ff3db59850f0b346c28da
|
||||
size 186118
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:62c57a45c2cb1aac23fa0c3af44f90a5d7b9a1e1626ec49eb94a2c126f53f96d
|
||||
size 137535
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e6f5154be0e92e6c8f0d2972eb32c9b80690936d4b1c7f011ad31e94da2c3f90
|
||||
size 119153
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:117e853cd3c5a02de954320e89a930c2b805d90f88e31e3fde63a42fc2b95e15
|
||||
size 180804
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:83280e9f22fcae0f4777eb9cf5e77cf0e929761001665161aacc52b2f0387259
|
||||
size 130882
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b1c09f5b8c68f493a04541a7ce77ca33d98c41e56c020e355dfc4419406592ae
|
||||
size 124700
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e99f07b952b52536d0f7b564e36a98b27ecfb44c3419cf0f41a397cfc48bbfdf
|
||||
size 186016
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ae50c359db9d0e22de60b49fadb2a6b2ea514414a0d27c42e5050e2b88454e0e
|
||||
size 136838
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5e82405b9a2ce4605d0aa2cbb263dde7e8d4d92d2c8bdab43c045cc179b8377b
|
||||
size 119796
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d3ce35907e27728ba0f476a4b61409ca47276b34987bd5131eca383268531950
|
||||
size 180168
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ee7cd47dfc99e054e5c837ee09055cae1dc6b70210abd30ecdfc3fc781ee16fc
|
||||
size 130576
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue