Allow displaying only unread notifications (#7803)

* Allow displaying only unread notifications

Add `FeatureFlags.ShowAllActivityInRoomList` (enabled by default, to match the current behavior) that, when disabled, will display the unread messages of a room instead of unread notifications if no unread notifications are found

* Restore UI changes to the badges, `RoomSummaryRow` and `HorizontalFloatingToolbar`

* Fix `NavigationBarIcon`: use `BadgeBox` component.

* Adjust padding in `RoomSummaryRow` to 6.dp

* Fix other indicators not displaying a dot anymore

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-10-01 19:46:01 +02:00
committed by GitHub
parent a3ff7e1486
commit 2dd0a47eb4
197 changed files with 825 additions and 414 deletions
@@ -8,39 +8,43 @@
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.Column
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.runtime.Composable
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.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
private const val MAX_COUNT_STRING = "+$MAX_COUNT"
private const val MAX_COUNT_STRING = "$MAX_COUNT+"
/**
* A counter atom that displays a number in a circle.
* Figma link : https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2805-2649&m=dev
*
* @param count The number to display. If the number is greater than [MAX_COUNT], the counter will display [MAX_COUNT_STRING].
* If the number is less than 1, the counter will not be displayed.
* If the number is less than 1, just a dot will be displayed instead.
* @param modifier The modifier to apply to this layout.
* @param containerColor The background color of the counter. When null, uses [isCritical] to pick a default.
* @param contentColor The text color inside the counter. When null, uses [textOnSolidPrimary].
* @param contentColor The text color inside the counter. When null, uses [SemanticColors.textOnSolidPrimary].
* @param contentPadding The padding to apply to the text inside the counter.
* @param textStyle The style to apply to the text inside the counter.
* @param dotSize The size of the dot when [count] is less than 1. It defaults to `12.0.dp`.
* @param isCritical If true, the counter will use a critical color scheme, otherwise it will use an accent color scheme.
* Only used when [containerColor] is null.
*/
@@ -50,41 +54,51 @@ fun CounterAtom(
modifier: Modifier = Modifier,
containerColor: Color? = null,
contentColor: Color? = null,
contentPadding: PaddingValues = PaddingValues.Zero,
textStyle: TextStyle = CounterAtomDefaults.textStyle,
dotSize: Dp = 12.0.dp,
isCritical: Boolean = false,
) {
if (count < 1) return
val countAsText = when (count) {
in 0..MAX_COUNT -> count.toString()
in Int.MIN_VALUE..0 -> null
in 1..MAX_COUNT -> count.toString()
else -> MAX_COUNT_STRING
}
val textMeasurer = rememberTextMeasurer()
// Measure the maximum count string size
val textLayoutResult = textMeasurer.measure(
text = MAX_COUNT_STRING,
style = textStyle
)
val textSize = textLayoutResult.size
val squareSize = maxOf(textSize.width, textSize.height)
Box(
modifier = modifier
.size(squareSize.toDp() + 1.dp)
.clip(CircleShape)
.background(
containerColor ?: if (isCritical) {
ElementTheme.colors.iconCriticalPrimary
} else {
ElementTheme.colors.iconAccentPrimary
}
)
) {
Text(
modifier = Modifier.align(Alignment.Center),
val minSingleDigitSize = if (countAsText?.length == 1) {
val textMeasurer = rememberTextMeasurer()
// Measure the maximum count string size
val textLayoutResult = textMeasurer.measure(
text = countAsText,
style = textStyle,
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
style = textStyle
)
val textSize = textLayoutResult.size
maxOf(textSize.width, textSize.height)
} else {
0
}
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 {
Text(
modifier = Modifier.sizeIn(
minWidth = (minSingleDigitSize.toDp() - 8.dp).coerceAtLeast(0.dp),
minHeight = minSingleDigitSize.toDp()
).padding(contentPadding),
text = countAsText,
style = textStyle,
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
textAlign = TextAlign.Center,
)
}
}
)
}
object CounterAtomDefaults {
@@ -95,7 +109,7 @@ object CounterAtomDefaults {
@PreviewsDayNight
@Composable
internal fun CounterAtomPreview() = ElementPreview {
Column(verticalArrangement = spacedBy(2.dp)) {
Row(horizontalArrangement = spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
CounterAtom(count = 0)
CounterAtom(count = 4)
CounterAtom(count = 99)
@@ -9,15 +9,16 @@
package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
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.CircleShape
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -37,35 +38,36 @@ fun UnreadIndicatorAtom(
isVisible: Boolean = true,
contentDescription: String? = null,
border: BorderStroke? = null,
contentPadding: PaddingValues = PaddingValues.Zero,
) {
when {
!isVisible -> Spacer(modifier = modifier.size(size))
count != null && count >= 1 -> CounterAtom(
count = count.toInt(),
modifier = modifier
.semantics {
contentDescription?.let { this.contentDescription = it }
}
.then(if (border != null) Modifier.border(border, CircleShape) else Modifier),
containerColor = color,
contentColor = ElementTheme.colors.bgCanvasDefault,
textStyle = ElementTheme.typography.fontBodySmMedium,
)
else -> Box(
modifier = modifier
.semantics {
contentDescription?.let { this.contentDescription = it }
}
.size(size)
.clip(CircleShape)
.background(color)
.then(if (border != null) Modifier.border(border, CircleShape) else Modifier),
)
else -> 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),
containerColor = color,
contentColor = ElementTheme.colors.bgCanvasDefault,
textStyle = ElementTheme.typography.fontBodySmMedium,
contentPadding = contentPadding,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun UnreadIndicatorAtomPreview() = ElementPreview {
UnreadIndicatorAtom()
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
UnreadIndicatorAtom(count = null)
UnreadIndicatorAtom(count = 0)
UnreadIndicatorAtom(count = 1)
UnreadIndicatorAtom(count = 10)
UnreadIndicatorAtom(count = 99)
UnreadIndicatorAtom(count = 999)
}
}
@@ -8,16 +8,17 @@
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FloatingToolbarColors
import androidx.compose.material3.FloatingToolbarDefaults
import androidx.compose.material3.FloatingToolbarHorizontalFabPosition
@@ -27,7 +28,6 @@ import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
@@ -137,8 +137,20 @@ fun HorizontalFloatingToolbarItem(
contentColor = ElementTheme.colors.iconSecondary,
)
}
Box {
FilledIconButton(
BadgedBox(
badge = {
counter?.let {
CounterAtom(
count = it,
// 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,
)
}
}
) {
IconButton(
modifier = Modifier.widthIn(min = 56.dp),
colors = colors,
onClick = onClick,
@@ -149,15 +161,6 @@ fun HorizontalFloatingToolbarItem(
contentDescription = tooltipLabel,
)
}
if (counter != null) {
CounterAtom(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 6.dp, end = 3.dp),
count = counter,
textStyle = ElementTheme.typography.fontBodyXsMedium,
)
}
}
}
}
@@ -170,18 +173,47 @@ fun HorizontalFloatingToolbarSeparator(modifier: Modifier = Modifier) {
@PreviewsDayNight
@Composable
internal fun HorizontalFloatingToolbarPreview() = ElementPreview {
ContentToPreview(
floatingActionButton = {
FloatingActionButton(
onClick = {},
) {
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = null,
)
Column {
ContentToPreview(
count = 1,
floatingActionButton = {
FloatingActionButton(
onClick = {},
) {
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = null,
)
}
}
}
)
)
ContentToPreview(
count = 99,
floatingActionButton = {
FloatingActionButton(
onClick = {},
) {
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = null,
)
}
}
)
ContentToPreview(
count = 999,
floatingActionButton = {
FloatingActionButton(
onClick = {},
) {
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = null,
)
}
}
)
}
}
@PreviewsDayNight
@@ -194,10 +226,11 @@ internal fun HorizontalFloatingToolbarNoFabPreview() = ElementPreview {
@Composable
private fun ContentToPreview(
count: Int? = 6,
floatingActionButton: (@Composable () -> Unit)?,
) {
HorizontalFloatingToolbar(
modifier = Modifier.padding(28.dp),
modifier = Modifier.padding(horizontal = 28.dp, vertical = 16.dp),
floatingActionButton = floatingActionButton,
) {
listOf(
@@ -211,7 +244,7 @@ private fun ContentToPreview(
icon = icon,
tooltipLabel = "Label",
isSelected = index == 0,
counter = if (index == 0) 6 else null,
counter = if (index == 0) count else null,
forceRenderingTooltip = true,
onClick = { },
)
@@ -8,12 +8,10 @@
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.material3.BadgedBox
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.atoms.CounterAtom
@@ -24,16 +22,19 @@ fun NavigationBarIcon(
count: Int = 0,
isCritical: Boolean = false,
) {
Box(modifier) {
BadgedBox(
modifier = modifier,
badge = {
CounterAtom(
textStyle = ElementTheme.typography.fontBodyXsMedium,
count = count,
isCritical = isCritical,
)
}
) {
Icon(
imageVector = imageVector,
contentDescription = null,
)
CounterAtom(
modifier = Modifier.offset(11.dp, (-11).dp),
textStyle = ElementTheme.typography.fontBodyXsMedium,
count = count,
isCritical = isCritical,
)
}
}