Iterate on unread counts again (#7869)

* Fix timestamp color to match the designs.

* Don't limit the call and unread row height to 16dp: the font size is not fixed and it might display components in a broken state

* Ensure the alignment works

* Remove `SemanticColors.unreadIndicator` and use `.iconAccentPrimary` and `.textActionAccent` instead

* Simplify and document `UnreadIndicatorAtom`

* Use `fontBodySmMedium` for the font size in the unread indicator

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-10-09 08:30:43 +02:00
committed by GitHub
parent 367cea0213
commit 1564de9b6e
43 changed files with 161 additions and 154 deletions
@@ -60,7 +60,6 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.roomListRoomMessage import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
import io.element.android.libraries.designsystem.theme.roomListRoomName import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.unreadIndicator
import io.element.android.libraries.matrix.api.notification.CallIntent import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.DisplayedStatus
@@ -244,7 +243,7 @@ private fun NameAndTimestampRow(
text = timestamp ?: "", text = timestamp ?: "",
style = ElementTheme.typography.fontBodySmMedium, style = ElementTheme.typography.fontBodySmMedium,
color = if (isHighlighted) { color = if (isHighlighted) {
ElementTheme.colors.unreadIndicator ElementTheme.colors.textActionAccent
} else { } else {
ElementTheme.colors.roomListRoomMessageDate ElementTheme.colors.roomListRoomMessageDate
}, },
@@ -347,13 +346,12 @@ private fun MessagePreviewAndIndicatorRow(
// Call and unread // Call and unread
Row( Row(
modifier = Modifier modifier = Modifier
.height(16.dp)
// Used to force this line to be read aloud earlier than the latest event when using Talkback // Used to force this line to be read aloud earlier than the latest event when using Talkback
.zIndex(-1f), .zIndex(-1f),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary val tint = if (room.isHighlighted) ElementTheme.colors.textActionAccent else ElementTheme.colors.iconQuaternary
if (room.hasRoomCall) { if (room.hasRoomCall) {
OnGoingCallIcon( OnGoingCallIcon(
color = tint, color = tint,
@@ -422,10 +420,7 @@ private fun InviteNameAndIndicatorRow(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
if (!isInviteSeen) { if (!isInviteSeen) {
UnreadIndicatorAtom( UnreadIndicatorAtom(count = 0)
color = ElementTheme.colors.unreadIndicator,
count = 0,
)
} }
} }
} }
@@ -459,7 +454,7 @@ private fun MentionIndicatorAtom() {
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
contentDescription = stringResource(CommonStrings.a11y_notifications_new_mentions), contentDescription = stringResource(CommonStrings.a11y_notifications_new_mentions),
imageVector = CompoundIcons.Mention(), imageVector = CompoundIcons.Mention(),
tint = ElementTheme.colors.unreadIndicator, tint = ElementTheme.colors.iconAccentPrimary,
) )
} }
@@ -264,11 +264,12 @@ private fun ThreadListItemRow(
) )
} }
UnreadIndicatorAtom( if (hasUnreadNotifications) {
size = 14.dp, UnreadIndicatorAtom(
isVisible = hasUnreadNotifications, dotIndicatorSize = 14.dp,
count = 0, count = 0,
) )
}
} }
} }
@@ -597,7 +597,6 @@ private fun JumpToPositionButton(
modifier = Modifier modifier = Modifier
.align(dotAlignment) .align(dotAlignment)
.offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) }, .offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) },
color = ElementTheme.colors.iconSuccessPrimary,
border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault), border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault),
count = 0, count = 0,
) )
@@ -8,26 +8,32 @@
package io.element.android.libraries.designsystem.atomic.atoms package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment 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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceAtLeast
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.SemanticColors import io.element.android.compound.tokens.generated.SemanticColors
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
private const val MAX_COUNT = 99 private const val MAX_COUNT = 99
@@ -54,7 +60,7 @@ fun CounterAtom(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
containerColor: Color? = null, containerColor: Color? = null,
contentColor: Color? = null, contentColor: Color? = null,
contentPadding: PaddingValues = PaddingValues.Zero, contentPadding: PaddingValues = PaddingValues(vertical = 0.5.dp),
textStyle: TextStyle = CounterAtomDefaults.textStyle, textStyle: TextStyle = CounterAtomDefaults.textStyle,
dotSize: Dp = 12.0.dp, dotSize: Dp = 12.0.dp,
isCritical: Boolean = false, isCritical: Boolean = false,
@@ -65,40 +71,50 @@ fun CounterAtom(
else -> MAX_COUNT_STRING else -> MAX_COUNT_STRING
} }
val minSingleDigitSize = if (countAsText?.length == 1) { val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer() val textMeasurer = rememberTextMeasurer()
// Measure the maximum count string size val minTextSize = remember(density.density, textStyle) {
val textLayoutResult = textMeasurer.measure( val result = textMeasurer.measure(
text = countAsText, text = MAX_COUNT_STRING,
style = textStyle style = textStyle
) )
val textSize = textLayoutResult.size with(density) {
maxOf(textSize.width, textSize.height) minOf(result.size.width, result.size.height).toDp()
}
}
val sizeModifier = if (countAsText?.length == 1) {
Modifier.size(minTextSize)
} else { } else {
0 Modifier.sizeIn(minHeight = minTextSize)
} }
androidx.compose.material3.Badge( val extraPadding = if (countAsText?.length == 1) {
modifier = modifier.then( contentPadding
if (countAsText == null) Modifier.sizeIn(minWidth = dotSize, minHeight = dotSize) else Modifier } else {
), contentPadding + PaddingValues(horizontal = 4.dp)
containerColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary, }
contentColor = contentColor ?: ElementTheme.colors.textOnSolidPrimary, val backgroundColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary
content = countAsText?.let {
@Composable { Box(
Text( modifier = modifier
modifier = Modifier.sizeIn( .clip(RoundedCornerShape(100))
minWidth = (minSingleDigitSize.toDp() - 8.dp).coerceAtLeast(0.dp), .background(backgroundColor)
minHeight = minSingleDigitSize.toDp() .then(
).padding(contentPadding), if (countAsText == null) Modifier.size(dotSize) else Modifier
text = countAsText, )
style = textStyle, ) {
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary, countAsText?.let {
textAlign = TextAlign.Center, Text(
) modifier = sizeModifier
} .align(Alignment.Center)
.padding(extraPadding),
text = countAsText,
style = textStyle,
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
textAlign = TextAlign.Center,
)
} }
) }
} }
object CounterAtomDefaults { object CounterAtomDefaults {
@@ -112,7 +128,7 @@ internal fun CounterAtomPreview() = ElementPreview {
Row(horizontalArrangement = spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
CounterAtom(count = 0) CounterAtom(count = 0)
CounterAtom(count = 4) CounterAtom(count = 4)
CounterAtom(count = 99) CounterAtom(count = 10)
CounterAtom(count = 100) CounterAtom(count = 100)
CounterAtom(count = 4, isCritical = true) CounterAtom(count = 4, isCritical = true)
} }
@@ -13,7 +13,6 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -27,35 +26,42 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.unreadIndicator
/**
* A small indicator to show the number of unread messages or notifications.
*
* @param count The number of unread messages or notifications. If null, the indicator will be hidden, if 0, a notification dot will be shown.
* @param modifier The modifier to be applied to the indicator.
* @param dotIndicatorSize The size of the dot indicator.
* @param color The background color of the indicator.
* @param contentDescription The content description for accessibility.
* @param border The border to be applied to the indicator.
* @param contentPadding The padding inside the indicator.
*/
@Composable @Composable
fun UnreadIndicatorAtom( fun UnreadIndicatorAtom(
count: Long?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
size: Dp = 12.dp, dotIndicatorSize: Dp = 12.dp,
count: Long? = null, color: Color = ElementTheme.colors.iconAccentPrimary,
color: Color = ElementTheme.colors.unreadIndicator,
isVisible: Boolean = true,
contentDescription: String? = null, contentDescription: String? = null,
border: BorderStroke? = null, border: BorderStroke? = null,
contentPadding: PaddingValues = PaddingValues.Zero, contentPadding: PaddingValues = PaddingValues.Zero,
) { ) {
when { count?.let {
!isVisible -> Spacer(modifier = modifier.size(size)) CounterAtom(
else -> count?.let { count = count.toInt(),
CounterAtom( modifier = modifier
count = count.toInt(), .semantics {
modifier = modifier contentDescription?.let { this.contentDescription = it }
.semantics { }
contentDescription?.let { this.contentDescription = it } .then(if (count == 0L) Modifier.size(dotIndicatorSize) else Modifier)
} .then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 100)) else Modifier),
.then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 50)) else Modifier), containerColor = color,
containerColor = color, contentColor = ElementTheme.colors.bgCanvasDefault,
contentColor = ElementTheme.colors.bgCanvasDefault, textStyle = ElementTheme.typography.fontBodySmMedium,
textStyle = ElementTheme.typography.fontBodySmMedium, contentPadding = contentPadding,
contentPadding = contentPadding, )
)
}
} }
} }
@@ -66,7 +72,7 @@ internal fun UnreadIndicatorAtomPreview() = ElementPreview {
UnreadIndicatorAtom(count = null) UnreadIndicatorAtom(count = null)
UnreadIndicatorAtom(count = 0) UnreadIndicatorAtom(count = 0)
UnreadIndicatorAtom(count = 1) UnreadIndicatorAtom(count = 1)
UnreadIndicatorAtom(count = 10) UnreadIndicatorAtom(count = 20)
UnreadIndicatorAtom(count = 99) UnreadIndicatorAtom(count = 99)
UnreadIndicatorAtom(count = 999) UnreadIndicatorAtom(count = 999)
} }
@@ -32,9 +32,6 @@ val SemanticColors.roomListRoomMessage
val SemanticColors.roomListRoomMessageDate val SemanticColors.roomListRoomMessageDate
get() = textSecondary get() = textSecondary
val SemanticColors.unreadIndicator
get() = iconAccentTertiary
val SemanticColors.placeholderBackground val SemanticColors.placeholderBackground
get() = bgSubtleSecondary get() = bgSubtleSecondary
@@ -83,7 +80,6 @@ internal fun ColorAliasesPreview() = ElementPreview {
"roomListRoomName" to ElementTheme.colors.roomListRoomName, "roomListRoomName" to ElementTheme.colors.roomListRoomName,
"roomListRoomMessage" to ElementTheme.colors.roomListRoomMessage, "roomListRoomMessage" to ElementTheme.colors.roomListRoomMessage,
"roomListRoomMessageDate" to ElementTheme.colors.roomListRoomMessageDate, "roomListRoomMessageDate" to ElementTheme.colors.roomListRoomMessageDate,
"unreadIndicator" to ElementTheme.colors.unreadIndicator,
"placeholderBackground" to ElementTheme.colors.placeholderBackground, "placeholderBackground" to ElementTheme.colors.placeholderBackground,
"messageFromMeBackground" to ElementTheme.colors.messageFromMeBackground, "messageFromMeBackground" to ElementTheme.colors.messageFromMeBackground,
"messageFromOtherBackground" to ElementTheme.colors.messageFromOtherBackground, "messageFromOtherBackground" to ElementTheme.colors.messageFromOtherBackground,
@@ -36,7 +36,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.atoms.CounterAtom import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.tooltip.PlainTooltip import io.element.android.libraries.designsystem.components.tooltip.PlainTooltip
import io.element.android.libraries.designsystem.components.tooltip.TooltipBox import io.element.android.libraries.designsystem.components.tooltip.TooltipBox
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -140,12 +140,10 @@ fun HorizontalFloatingToolbarItem(
BadgedBox( BadgedBox(
badge = { badge = {
counter?.let { counter?.let {
CounterAtom( UnreadIndicatorAtom(
count = it, count = it.toLong(),
// Tweak the offset so it's centered on the icon // Tweak the offset so it's centered on the icon
modifier = Modifier.offset(x = (-16).dp, y = 10.dp), modifier = Modifier.offset(x = (-16).dp, y = 10.dp),
contentPadding = PaddingValues(vertical = 0.5.dp),
textStyle = ElementTheme.typography.fontBodySmMedium,
) )
} }
} }
@@ -44,7 +44,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.unreadIndicator
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility
@@ -168,10 +167,7 @@ private fun NameAndIndicatorRow(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
if (showIndicator) { if (showIndicator) {
UnreadIndicatorAtom( UnreadIndicatorAtom(count = 0)
color = ElementTheme.colors.unreadIndicator,
count = 0,
)
} }
} }
} }