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.roomListRoomMessageDate
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.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus
@@ -244,7 +243,7 @@ private fun NameAndTimestampRow(
text = timestamp ?: "",
style = ElementTheme.typography.fontBodySmMedium,
color = if (isHighlighted) {
ElementTheme.colors.unreadIndicator
ElementTheme.colors.textActionAccent
} else {
ElementTheme.colors.roomListRoomMessageDate
},
@@ -347,13 +346,12 @@ private fun MessagePreviewAndIndicatorRow(
// Call and unread
Row(
modifier = Modifier
.height(16.dp)
// Used to force this line to be read aloud earlier than the latest event when using Talkback
.zIndex(-1f),
horizontalArrangement = Arrangement.spacedBy(6.dp),
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) {
OnGoingCallIcon(
color = tint,
@@ -422,10 +420,7 @@ private fun InviteNameAndIndicatorRow(
overflow = TextOverflow.Ellipsis
)
if (!isInviteSeen) {
UnreadIndicatorAtom(
color = ElementTheme.colors.unreadIndicator,
count = 0,
)
UnreadIndicatorAtom(count = 0)
}
}
}
@@ -459,7 +454,7 @@ private fun MentionIndicatorAtom() {
modifier = Modifier.size(16.dp),
contentDescription = stringResource(CommonStrings.a11y_notifications_new_mentions),
imageVector = CompoundIcons.Mention(),
tint = ElementTheme.colors.unreadIndicator,
tint = ElementTheme.colors.iconAccentPrimary,
)
}
@@ -264,13 +264,14 @@ private fun ThreadListItemRow(
)
}
if (hasUnreadNotifications) {
UnreadIndicatorAtom(
size = 14.dp,
isVisible = hasUnreadNotifications,
dotIndicatorSize = 14.dp,
count = 0,
)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -597,7 +597,6 @@ private fun JumpToPositionButton(
modifier = Modifier
.align(dotAlignment)
.offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) },
color = ElementTheme.colors.iconSuccessPrimary,
border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault),
count = 0,
)
@@ -8,26 +8,32 @@
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.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceAtLeast
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.SemanticColors
import io.element.android.libraries.designsystem.preview.ElementPreview
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
private const val MAX_COUNT = 99
@@ -54,7 +60,7 @@ fun CounterAtom(
modifier: Modifier = Modifier,
containerColor: Color? = null,
contentColor: Color? = null,
contentPadding: PaddingValues = PaddingValues.Zero,
contentPadding: PaddingValues = PaddingValues(vertical = 0.5.dp),
textStyle: TextStyle = CounterAtomDefaults.textStyle,
dotSize: Dp = 12.0.dp,
isCritical: Boolean = false,
@@ -65,32 +71,43 @@ fun CounterAtom(
else -> MAX_COUNT_STRING
}
val minSingleDigitSize = if (countAsText?.length == 1) {
val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer()
// Measure the maximum count string size
val textLayoutResult = textMeasurer.measure(
text = countAsText,
val minTextSize = remember(density.density, textStyle) {
val result = textMeasurer.measure(
text = MAX_COUNT_STRING,
style = textStyle
)
val textSize = textLayoutResult.size
maxOf(textSize.width, textSize.height)
with(density) {
minOf(result.size.width, result.size.height).toDp()
}
}
val sizeModifier = if (countAsText?.length == 1) {
Modifier.size(minTextSize)
} else {
0
Modifier.sizeIn(minHeight = minTextSize)
}
androidx.compose.material3.Badge(
modifier = modifier.then(
if (countAsText == null) Modifier.sizeIn(minWidth = dotSize, minHeight = dotSize) else Modifier
),
containerColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary,
contentColor = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
content = countAsText?.let {
@Composable {
val extraPadding = if (countAsText?.length == 1) {
contentPadding
} else {
contentPadding + PaddingValues(horizontal = 4.dp)
}
val backgroundColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary
Box(
modifier = modifier
.clip(RoundedCornerShape(100))
.background(backgroundColor)
.then(
if (countAsText == null) Modifier.size(dotSize) else Modifier
)
) {
countAsText?.let {
Text(
modifier = Modifier.sizeIn(
minWidth = (minSingleDigitSize.toDp() - 8.dp).coerceAtLeast(0.dp),
minHeight = minSingleDigitSize.toDp()
).padding(contentPadding),
modifier = sizeModifier
.align(Alignment.Center)
.padding(extraPadding),
text = countAsText,
style = textStyle,
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
@@ -98,7 +115,6 @@ fun CounterAtom(
)
}
}
)
}
object CounterAtomDefaults {
@@ -112,7 +128,7 @@ internal fun CounterAtomPreview() = ElementPreview {
Row(horizontalArrangement = spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
CounterAtom(count = 0)
CounterAtom(count = 4)
CounterAtom(count = 99)
CounterAtom(count = 10)
CounterAtom(count = 100)
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@@ -27,36 +26,43 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
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
fun UnreadIndicatorAtom(
count: Long?,
modifier: Modifier = Modifier,
size: Dp = 12.dp,
count: Long? = null,
color: Color = ElementTheme.colors.unreadIndicator,
isVisible: Boolean = true,
dotIndicatorSize: Dp = 12.dp,
color: Color = ElementTheme.colors.iconAccentPrimary,
contentDescription: String? = null,
border: BorderStroke? = null,
contentPadding: PaddingValues = PaddingValues.Zero,
) {
when {
!isVisible -> Spacer(modifier = modifier.size(size))
else -> count?.let {
count?.let {
CounterAtom(
count = count.toInt(),
modifier = modifier
.semantics {
contentDescription?.let { this.contentDescription = it }
}
.then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 50)) else Modifier),
.then(if (count == 0L) Modifier.size(dotIndicatorSize) else Modifier)
.then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 100)) else Modifier),
containerColor = color,
contentColor = ElementTheme.colors.bgCanvasDefault,
textStyle = ElementTheme.typography.fontBodySmMedium,
contentPadding = contentPadding,
)
}
}
}
@PreviewsDayNight
@@ -66,7 +72,7 @@ internal fun UnreadIndicatorAtomPreview() = ElementPreview {
UnreadIndicatorAtom(count = null)
UnreadIndicatorAtom(count = 0)
UnreadIndicatorAtom(count = 1)
UnreadIndicatorAtom(count = 10)
UnreadIndicatorAtom(count = 20)
UnreadIndicatorAtom(count = 99)
UnreadIndicatorAtom(count = 999)
}
@@ -32,9 +32,6 @@ val SemanticColors.roomListRoomMessage
val SemanticColors.roomListRoomMessageDate
get() = textSecondary
val SemanticColors.unreadIndicator
get() = iconAccentTertiary
val SemanticColors.placeholderBackground
get() = bgSubtleSecondary
@@ -83,7 +80,6 @@ internal fun ColorAliasesPreview() = ElementPreview {
"roomListRoomName" to ElementTheme.colors.roomListRoomName,
"roomListRoomMessage" to ElementTheme.colors.roomListRoomMessage,
"roomListRoomMessageDate" to ElementTheme.colors.roomListRoomMessageDate,
"unreadIndicator" to ElementTheme.colors.unreadIndicator,
"placeholderBackground" to ElementTheme.colors.placeholderBackground,
"messageFromMeBackground" to ElementTheme.colors.messageFromMeBackground,
"messageFromOtherBackground" to ElementTheme.colors.messageFromOtherBackground,
@@ -36,7 +36,7 @@ import androidx.compose.ui.unit.Dp
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.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.TooltipBox
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -140,12 +140,10 @@ fun HorizontalFloatingToolbarItem(
BadgedBox(
badge = {
counter?.let {
CounterAtom(
count = it,
UnreadIndicatorAtom(
count = it.toLong(),
// Tweak the offset so it's centered on the icon
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.theme.components.Icon
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.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility
@@ -168,10 +167,7 @@ private fun NameAndIndicatorRow(
overflow = TextOverflow.Ellipsis
)
if (showIndicator) {
UnreadIndicatorAtom(
color = ElementTheme.colors.unreadIndicator,
count = 0,
)
UnreadIndicatorAtom(count = 0)
}
}
}