change (room avatar) : use TextAvatar from InitialLetterAvatar

This commit is contained in:
ganfra 2025-06-13 18:34:55 +02:00
parent 08bbd7219c
commit f6dc8da6ad
4 changed files with 24 additions and 44 deletions

View file

@ -370,7 +370,7 @@ fun Modifier.avatarBloom(
val initialsBitmap = initialsBitmap( val initialsBitmap = initialsBitmap(
width = BloomDefaults.ENCODE_SIZE_PX.toDp(), width = BloomDefaults.ENCODE_SIZE_PX.toDp(),
height = BloomDefaults.ENCODE_SIZE_PX.toDp(), height = BloomDefaults.ENCODE_SIZE_PX.toDp(),
text = avatarData.initial, text = avatarData.initialLetter,
textColor = avatarColors.foreground, textColor = avatarColors.foreground,
backgroundColor = avatarColors.background, backgroundColor = avatarColors.background,
) )

View file

@ -7,11 +7,9 @@
package io.element.android.libraries.designsystem.components.avatar package io.element.android.libraries.designsystem.components.avatar
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
@ -21,21 +19,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImagePainter import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent import coil3.compose.SubcomposeAsyncImageContent
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import timber.log.Timber import timber.log.Timber
@ -50,21 +43,18 @@ fun Avatar(
// If true, will show initials even if avatarData.url is not null // If true, will show initials even if avatarData.url is not null
hideImage: Boolean = false, hideImage: Boolean = false,
) { ) {
val commonModifier = modifier
.size(forcedAvatarSize ?: avatarData.size.dp)
.clip(CircleShape)
if (avatarData.url.isNullOrBlank() || hideImage) { if (avatarData.url.isNullOrBlank() || hideImage) {
InitialsAvatar( InitialLetterAvatar(
avatarData = avatarData, avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize, forcedAvatarSize = forcedAvatarSize,
modifier = commonModifier, modifier = modifier,
contentDescription = contentDescription, contentDescription = contentDescription,
) )
} else { } else {
ImageAvatar( ImageAvatar(
avatarData = avatarData, avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize, forcedAvatarSize = forcedAvatarSize,
modifier = commonModifier, modifier = modifier,
contentDescription = contentDescription, contentDescription = contentDescription,
) )
} }
@ -77,11 +67,14 @@ private fun ImageAvatar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentDescription: String? = null, contentDescription: String? = null,
) { ) {
val size = forcedAvatarSize ?: avatarData.size.dp
SubcomposeAsyncImage( SubcomposeAsyncImage(
model = avatarData, model = avatarData,
contentDescription = contentDescription, contentDescription = contentDescription,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = modifier modifier = modifier
.requiredSize(size)
.clip(CircleShape)
) { ) {
val collectedState by painter.state.collectAsState() val collectedState by painter.state.collectAsState()
when (val state = collectedState) { when (val state = collectedState) {
@ -90,13 +83,13 @@ private fun ImageAvatar(
SideEffect { SideEffect {
Timber.e(state.result.throwable, "Error loading avatar $state\n${state.result}") Timber.e(state.result.throwable, "Error loading avatar $state\n${state.result}")
} }
InitialsAvatar( InitialLetterAvatar(
avatarData = avatarData, avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize, forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription, contentDescription = contentDescription,
) )
} }
else -> InitialsAvatar( else -> InitialLetterAvatar(
avatarData = avatarData, avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize, forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription, contentDescription = contentDescription,
@ -106,33 +99,20 @@ private fun ImageAvatar(
} }
@Composable @Composable
private fun InitialsAvatar( private fun InitialLetterAvatar(
avatarData: AvatarData, avatarData: AvatarData,
forcedAvatarSize: Dp?, forcedAvatarSize: Dp?,
contentDescription: String?, contentDescription: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val avatarColors = AvatarColorsProvider.provide(avatarData.id) val avatarColors = AvatarColorsProvider.provide(avatarData.id)
Box( TextAvatar(
modifier.background(color = avatarColors.background) text = avatarData.initialLetter,
) { size = forcedAvatarSize ?: avatarData.size.dp,
val fontSize = (forcedAvatarSize ?: avatarData.size.dp).toSp() / 2 colors = avatarColors,
val originalFont = ElementTheme.typography.fontHeadingMdBold contentDescription = contentDescription,
val ratio = fontSize.value / originalFont.fontSize.value modifier = modifier
val lineHeight = originalFont.lineHeight * ratio )
Text(
modifier = Modifier
.clearAndSetSemantics {
contentDescription?.let {
this.contentDescription = it
}
}
.align(Alignment.Center),
text = avatarData.initial,
style = originalFont.copy(fontSize = fontSize, lineHeight = lineHeight, letterSpacing = 0.sp),
color = avatarColors.foreground,
)
}
} }
@Preview(group = PreviewGroup.Avatars) @Preview(group = PreviewGroup.Avatars)

View file

@ -18,7 +18,7 @@ data class AvatarData(
val url: String? = null, val url: String? = null,
val size: AvatarSize, val size: AvatarSize,
) { ) {
val initial by lazy { val initialLetter by lazy {
// For roomIds, use "#" as initial // For roomIds, use "#" as initial
(name?.takeIf { it.isNotBlank() } ?: id.takeIf { !it.startsWith("!") } ?: "#") (name?.takeIf { it.isNotBlank() } ?: id.takeIf { !it.startsWith("!") } ?: "#")
.let { dn -> .let { dn ->

View file

@ -14,30 +14,30 @@ class AvatarDataTest {
@Test @Test
fun `initial with text should get the first char, uppercased`() { fun `initial with text should get the first char, uppercased`() {
val data = AvatarData("id", "test", null, AvatarSize.InviteSender) val data = AvatarData("id", "test", null, AvatarSize.InviteSender)
assertThat(data.initial).isEqualTo("T") assertThat(data.initialLetter).isEqualTo("T")
} }
@Test @Test
fun `initial with leading whitespace should get the first non-whitespace char, uppercased`() { fun `initial with leading whitespace should get the first non-whitespace char, uppercased`() {
val data = AvatarData("id", " test", null, AvatarSize.InviteSender) val data = AvatarData("id", " test", null, AvatarSize.InviteSender)
assertThat(data.initial).isEqualTo("T") assertThat(data.initialLetter).isEqualTo("T")
} }
@Test @Test
fun `initial with long emoji should get the full emoji`() { fun `initial with long emoji should get the full emoji`() {
val data = AvatarData("id", "\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08 Test", null, AvatarSize.InviteSender) val data = AvatarData("id", "\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08 Test", null, AvatarSize.InviteSender)
assertThat(data.initial).isEqualTo("\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08") assertThat(data.initialLetter).isEqualTo("\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08")
} }
@Test @Test
fun `initial with short emoji should get the emoji`() { fun `initial with short emoji should get the emoji`() {
val data = AvatarData("id", "✂ Test", null, AvatarSize.InviteSender) val data = AvatarData("id", "✂ Test", null, AvatarSize.InviteSender)
assertThat(data.initial).isEqualTo("") assertThat(data.initialLetter).isEqualTo("")
} }
@Test @Test
fun `initial with a single letter should take that letter`() { fun `initial with a single letter should take that letter`() {
val data = AvatarData("id", "T", null, AvatarSize.InviteSender) val data = AvatarData("id", "T", null, AvatarSize.InviteSender)
assertThat(data.initial).isEqualTo("T") assertThat(data.initialLetter).isEqualTo("T")
} }
} }