commit e6b00e38d95a3db7b7356e644d6adab8c71a5db0
parent a53b0fc697af7360e5b4ae7c91a88c587583fca4
Author: Julie De Lorenzo <jdelorenzo@mozilla.com>
Date: Mon, 8 Dec 2025 22:55:30 +0000
Bug 1994783: Add a reusable badge component r=android-reviewers,007
Differential Revision: https://phabricator.services.mozilla.com/D270738
Diffstat:
5 files changed, 721 insertions(+), 33 deletions(-)
diff --git a/mobile/android/android-components/components/compose/base/build.gradle b/mobile/android/android-components/components/compose/base/build.gradle
@@ -36,6 +36,7 @@ dependencies {
implementation libs.androidx.navigation.compose
debugImplementation libs.androidx.compose.ui.tooling
+ testImplementation libs.androidx.compose.ui.test.manifest
testImplementation project(':components:support-test')
diff --git a/mobile/android/android-components/components/compose/base/src/main/java/mozilla/components/compose/base/badge/BadgedIcon.kt b/mobile/android/android-components/components/compose/base/src/main/java/mozilla/components/compose/base/badge/BadgedIcon.kt
@@ -0,0 +1,319 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.compose.base.badge
+
+import androidx.annotation.IntDef
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.material3.Badge
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import mozilla.components.compose.base.modifier.thenConditional
+import mozilla.components.compose.base.theme.AcornTheme
+import mozilla.components.compose.base.theme.acornPrivateColorScheme
+import mozilla.components.compose.base.theme.information
+import mozilla.components.compose.base.utils.toLocaleString
+import mozilla.components.ui.icons.R as iconsR
+
+/**
+ * Class representing a [Badge] size.
+ * BADGE_SIZE_SMALL is 8x8dp.
+ * BADGE_SIZE_LARGE is 16x16dp or up to 16x32dp.
+ */
+@Retention(AnnotationRetention.SOURCE)
+@IntDef(BADGE_SIZE_SMALL, BADGE_SIZE_LARGE)
+annotation class BadgeSize
+
+/**
+ * Small badge size, 8x8dp.
+ */
+const val BADGE_SIZE_SMALL = 0
+
+/**
+ * Large badge size, 16x16dp or up to 16x32dp.
+ */
+const val BADGE_SIZE_LARGE = 1
+
+/**
+ * Test tag to find the badge.
+ */
+const val BADGE_TEST_TAG = "badge"
+
+private const val MAX_BADGE_COUNT = 99
+private const val MAX_BADGE_COUNT_EXCEEDED = "\u221e"
+private val SYMBOL_VERTICAL_OFFSET = (-0.5).dp
+private val SMALL_BADGE_OFFSET = IntOffset(1, (-1))
+private val LARGE_BADGE_OFFSET = IntOffset(8, (-4))
+
+/**
+ * Badged icon.
+ * The badge may be small (8x8 Dp) or large (with up to two numeric digits (99)
+ *
+ * @param painter [Painter] representing the icon
+ * @param isHighlighted whether or not the button is highlighted. No badge will be shown
+ * if highlighted is false.
+ * @param modifier [Modifier]
+ * @param size [BadgeSize], defaults to BADGE_SIZE_SMALL
+ * @param notificationCount Int value representing count drawn inside badge. Defaults to 0
+ * @param contentDescription String content description
+ * @param containerColor [Color] of the badge's container color
+ * @param tint [Color] of the icon's tint
+ * @param badgeContentColor [Color] of the badge's content color (text color)
+ */
+@Composable
+fun BadgedIcon(
+ painter: Painter,
+ isHighlighted: Boolean,
+ modifier: Modifier = Modifier,
+ @BadgeSize size: Int = BADGE_SIZE_SMALL,
+ notificationCount: Int = 0,
+ contentDescription: String? = null,
+ containerColor: Color = MaterialTheme.colorScheme.information,
+ tint: Color = MaterialTheme.colorScheme.onSurface,
+ badgeContentColor: Color = MaterialTheme.colorScheme.onError,
+) {
+ BadgedBox(
+ isHighlighted = isHighlighted,
+ size = size,
+ modifier = modifier,
+ notificationCount = notificationCount,
+ containerColor = containerColor,
+ badgeContentColor = badgeContentColor,
+ ) {
+ Icon(
+ painter = painter,
+ contentDescription = contentDescription,
+ tint = tint,
+ )
+ }
+}
+
+@Composable
+@PreviewLightDark
+@PreviewNumericSystems
+private fun BadgedIconPreview(
+ @PreviewParameter(BadgeProvider::class) config: BadgeData,
+) {
+ AcornTheme {
+ Column(
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.background)
+ .padding(16.dp),
+ verticalArrangement = spacedBy(16.dp),
+ ) {
+ BadgedIcon(
+ isHighlighted = config.isHighlighted,
+ painter = painterResource(config.icon),
+ size = config.size,
+ notificationCount = config.notificationCount,
+ )
+ }
+ }
+}
+
+@Composable
+@Preview
+private fun BadgedIconPreviewPrivate(
+ @PreviewParameter(BadgeProvider::class) config: BadgeData,
+) {
+ AcornTheme(colorScheme = acornPrivateColorScheme()) {
+ Column(
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.background)
+ .padding(16.dp),
+ verticalArrangement = spacedBy(16.dp),
+ ) {
+ BadgedIcon(
+ isHighlighted = config.isHighlighted,
+ painter = painterResource(config.icon),
+ size = config.size,
+ notificationCount = config.notificationCount,
+ )
+ }
+ }
+}
+
+/**
+ * Converts a numeric count to a Locale-appropriate string, or ∞ if max count has
+ * been exceeded.
+ *
+ * Treatment aligns with [TabCounter] and [TabCounterButton] composables.
+ */
+@Composable
+private fun convertNotificationCountToLabel(notificationCount: Int): String {
+ return if (notificationCount <= MAX_BADGE_COUNT) {
+ notificationCount.toLocaleString()
+ } else {
+ MAX_BADGE_COUNT_EXCEEDED
+ }
+}
+
+/**
+ * Content to be shown inside the badge.
+ * Content is only rendered for the large [BadgeSize].
+ * @param size [BadgeSize]
+ * @param notificationCount Int representing count displayed in badge. Defaults to 0.
+ * @return string content, or null if no content is to be drawn.
+ */
+@Composable
+private fun badgeContent(
+ @BadgeSize size: Int,
+ notificationCount: Int = 0,
+): @Composable (RowScope.() -> Unit)? {
+ val notificationLabel = convertNotificationCountToLabel(notificationCount)
+ return when (size) {
+ BADGE_SIZE_LARGE -> {
+ {
+ Text(
+ // Adds a small offset to center the ∞ symbol
+ modifier = Modifier
+ .thenConditional(
+ Modifier.offset(y = SYMBOL_VERTICAL_OFFSET),
+ predicate = { notificationLabel == MAX_BADGE_COUNT_EXCEEDED },
+ ),
+ text = notificationLabel,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onError,
+ maxLines = 1,
+ )
+ }
+ }
+ else -> {
+ null
+ }
+ }
+}
+
+/**
+ * Box to house the badged icon. The [BadgedBox] provided by Material 3 does not work for our
+ * purposes because the Mozilla badge sizing is different.
+ *
+ * @param isHighlighted Boolean value representing whether or not the icon is highlighted. The badge
+ * is only drawn if the icon is highlighted.
+ * @param size [BadgeSize]
+ * @param modifier [Modifier]
+ * @param notificationCount Int representing the count shown in the badge content
+ * @param containerColor [Color] of the badge's container color
+ * @param badgeContentColor [Color] of the badge's content color (text color)
+ * @param content The content rendered inside the badge
+ */
+@Composable
+private fun BadgedBox(
+ isHighlighted: Boolean,
+ @BadgeSize size: Int,
+ modifier: Modifier,
+ notificationCount: Int = 0,
+ containerColor: Color = MaterialTheme.colorScheme.information,
+ badgeContentColor: Color = MaterialTheme.colorScheme.onError,
+ content: @Composable BoxScope.() -> Unit,
+) {
+ val offset: IntOffset by remember(size) {
+ derivedStateOf {
+ if (size == BADGE_SIZE_SMALL) {
+ SMALL_BADGE_OFFSET
+ } else {
+ LARGE_BADGE_OFFSET
+ }
+ }
+ }
+
+ Box(modifier = modifier) {
+ content()
+ if (isHighlighted) {
+ Badge(
+ modifier = Modifier
+ .thenConditional(Modifier.requiredSize(8.dp), { size == BADGE_SIZE_SMALL })
+ .thenConditional(Modifier.requiredHeight(16.dp), predicate = { size == BADGE_SIZE_LARGE })
+ .align(alignment = Alignment.TopEnd)
+ .offset { IntOffset(x = offset.x.dp.roundToPx(), y = offset.y.dp.roundToPx()) }
+ .testTag(BADGE_TEST_TAG),
+ containerColor = containerColor,
+ contentColor = badgeContentColor,
+ content = badgeContent(size = size, notificationCount = notificationCount),
+ )
+ }
+ }
+}
+
+private data class BadgeData(
+ val isHighlighted: Boolean = false,
+ val icon: Int = iconsR.drawable.mozac_ic_download_24,
+ val size: Int = BADGE_SIZE_SMALL,
+ val notificationCount: Int = 0,
+)
+private class BadgeProvider : PreviewParameterProvider<BadgeData> {
+ override val values = sequenceOf(
+ // small, not highlighted
+ BadgeData(
+ isHighlighted = false,
+ size = BADGE_SIZE_SMALL,
+ ),
+ // large, not highlighted
+ BadgeData(
+ isHighlighted = false,
+ size = BADGE_SIZE_LARGE,
+ ),
+ // small, highlighted
+ BadgeData(
+ isHighlighted = true,
+ size = BADGE_SIZE_SMALL,
+ ),
+ // large, highlighted single digit
+ BadgeData(
+ isHighlighted = true,
+ size = BADGE_SIZE_LARGE,
+ notificationCount = 3,
+ ),
+ // large, highlighted double digit
+ BadgeData(
+ isHighlighted = true,
+ size = BADGE_SIZE_LARGE,
+ notificationCount = 99,
+ ),
+ // large, highlighted triple digit
+ BadgeData(
+ isHighlighted = true,
+ size = BADGE_SIZE_LARGE,
+ notificationCount = 999,
+ ),
+ )
+}
+
+/**
+ * An annotation class representing Locales that represent different
+ * numeric systems. If this is useful in other contexts it may be
+ * extracted from the Badge class.
+ */
+@Preview(name = "Western Arabic", locale = "en")
+@Preview(name = "Eastern Arabic", locale = "ar")
+@Preview(name = "Bengali", locale = "bn")
+@Preview(name = "Burmese", locale = "mr")
+@Preview(name = "Nepali", locale = "ne")
+annotation class PreviewNumericSystems
diff --git a/mobile/android/android-components/components/compose/base/src/test/java/mozilla/components/compose/base/badge/BadgedIconTest.kt b/mobile/android/android-components/components/compose/base/src/test/java/mozilla/components/compose/base/badge/BadgedIconTest.kt
@@ -0,0 +1,326 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.compose.base.badge
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.test.isDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onChild
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.ui.icons.R
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class BadgedIconTest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun `WHEN badge size is small THEN icon size is 24x24`() {
+ val expectedSize = 24
+ composeTestRule.setContent {
+ BadgedIcon(
+ modifier = Modifier.Companion.testTag("badgedIcon"),
+ painter = painterResource(R.drawable.mozac_ic_download_24),
+ isHighlighted = true,
+ size = BADGE_SIZE_SMALL,
+ notificationCount = 0,
+ )
+ }
+ val badgedIcon = composeTestRule.onNodeWithTag("badgedIcon")
+ val layoutInfo = badgedIcon.fetchSemanticsNode().layoutInfo
+ assertEquals(expectedSize, layoutInfo.height)
+ assertEquals(expectedSize, layoutInfo.width)
+ }
+
+ @Test
+ fun `WHEN badge size is small THEN badge size is 8x8`() {
+ val expectedSize = 8
+ composeTestRule.setContent {
+ BadgedIcon(
+ modifier = Modifier.Companion.testTag("badgedIcon"),
+ painter = painterResource(R.drawable.mozac_ic_download_24),
+ isHighlighted = true,
+ size = BADGE_SIZE_SMALL,
+ notificationCount = 0,
+ )
+ }
+ val badgedIcon = composeTestRule.onNodeWithTag("badgedIcon")
+ val badge = badgedIcon.onChild()
+ val layoutInfo = badge.fetchSemanticsNode().layoutInfo
+ assertEquals(expectedSize, layoutInfo.width)
+ assertEquals(expectedSize, layoutInfo.height)
+ }
+
+ @Test
+ fun `WHEN badge size is large AND count is one digit THEN icon is 24x24`() {
+ val expectedWidth = 24
+ val expectedHeight = 24
+ composeTestRule.setContent {
+ BadgedIcon(
+ modifier = Modifier.Companion.testTag("badgedIcon"),
+ painter = painterResource(R.drawable.mozac_ic_download_24),
+ isHighlighted = true,
+ size = BADGE_SIZE_LARGE,
+ notificationCount = 1,
+ )
+ }
+ val badgedIcon = composeTestRule.onNodeWithTag("badgedIcon")
+ val layoutInfo = badgedIcon.fetchSemanticsNode().layoutInfo
+ assertEquals(expectedWidth, layoutInfo.width)
+ assertEquals(expectedHeight, layoutInfo.height)
+ }
+
+ @Test
+ fun `WHEN badge size is large AND count is one digit THEN badge size is 16x16`() {
+ val expectedWidth = 16
+ val expectedHeight = 16
+ composeTestRule.setContent {
+ BadgedIcon(
+ modifier = Modifier.Companion.testTag("badgedIcon"),
+ painter = painterResource(R.drawable.mozac_ic_download_24),
+ isHighlighted = true,
+ size = BADGE_SIZE_LARGE,
+ notificationCount = 1,
+ )
+ }
+ val badgedIcon = composeTestRule.onNodeWithTag("badgedIcon")
+ val badge = badgedIcon.onChild()
+ val layoutInfo = badge.fetchSemanticsNode().layoutInfo
+ assertEquals(expectedWidth, layoutInfo.width)
+ assertEquals(expectedHeight, layoutInfo.height)
+ }
+
+ @Test
+ fun `WHEN badge size is large AND count is two digits THEN icon size is 24x24`() {
+ val expectedWidth = 24
+ val expectedHeight = 24
+ composeTestRule.setContent {
+ BadgedIcon(
+ modifier = Modifier.Companion.testTag("badgedIcon"),
+ painter = painterResource(R.drawable.mozac_ic_download_24),
+ isHighlighted = true,
+ size = BADGE_SIZE_LARGE,
+ notificationCount = 10,
+ )
+ }
+ val badgedIcon = composeTestRule.onNodeWithTag("badgedIcon")
+ val layoutInfo = badgedIcon.fetchSemanticsNode().layoutInfo
+ assertEquals(expectedWidth, layoutInfo.width)
+ assertEquals(expectedHeight, layoutInfo.height)
+ }
+
+ @Test
+ fun `WHEN badge size is large AND count is two digits THEN badge size is 16x16dp`() {
+ val expectedWidth = 16
+ val expectedHeight = 16
+ composeTestRule.setContent {
+ BadgedIcon(
+ modifier = Modifier.Companion.testTag("badgedIcon"),
+ painter = painterResource(R.drawable.mozac_ic_download_24),
+ isHighlighted = true,
+ size = BADGE_SIZE_LARGE,
+ notificationCount = 10,
+ )
+ }
+ val badgedIcon = composeTestRule.onNodeWithTag("badgedIcon")
+ val badge = badgedIcon.onChild()
+ val layoutInfo = badge.fetchSemanticsNode().layoutInfo
+ assertEquals(expectedWidth, layoutInfo.width)
+ assertEquals(expectedHeight, layoutInfo.height)
+ }
+
+ @Test
+ fun `WHEN badge size is large AND count is three digits THEN icon size is 24x24`() {
+ val expectedWidth = 24
+ val expectedHeight = 24
+ composeTestRule.setContent {
+ BadgedIcon(
+ modifier = Modifier.Companion.testTag("badgedIcon"),
+ painter = painterResource(R.drawable.mozac_ic_download_24),
+ isHighlighted = true,
+ size = BADGE_SIZE_LARGE,
+ notificationCount = 999,
+ )
+ }
+ val badgedIcon = composeTestRule.onNodeWithTag("badgedIcon")
+ val layoutInfo = badgedIcon.fetchSemanticsNode().layoutInfo
+ assertEquals(expectedWidth, layoutInfo.width)
+ assertEquals(expectedHeight, layoutInfo.height)
+ }
+
+ @Test
+ fun `WHEN badge size is large AND count is max int THEN badge size is 16x16dp`() {
+ val expectedWidth = 16
+ val expectedHeight = 16
+ composeTestRule.setContent {
+ BadgedIcon(
+ modifier = Modifier.Companion.testTag("badgedIcon"),
+ painter = painterResource(R.drawable.mozac_ic_download_24),
+ isHighlighted = true,
+ size = BADGE_SIZE_LARGE,
+ notificationCount = Int.MAX_VALUE,
+ )
+ }
+ val badgedIcon = composeTestRule.onNodeWithTag("badgedIcon")
+ val badge = badgedIcon.onChild()
+ val layoutInfo = badge.fetchSemanticsNode().layoutInfo
+ assertEquals(expectedWidth, layoutInfo.width)
+ assertEquals(expectedHeight, layoutInfo.height)
+ }
+
+ @Test
+ fun `WHEN badge size is large AND count is three digits THEN badge size is 16x16dp`() {
+ val expectedWidth = 16
+ val expectedHeight = 16
+ composeTestRule.setContent {
+ BadgedIcon(
+ modifier = Modifier.Companion.testTag("badgedIcon"),
+ painter = painterResource(R.drawable.mozac_ic_download_24),
+ isHighlighted = true,
+ size = BADGE_SIZE_LARGE,
+ notificationCount = 999,
+ )
+ }
+ val badgedIcon = composeTestRule.onNodeWithTag("badgedIcon")
+ val badge = badgedIcon.onChild()
+ val layoutInfo = badge.fetchSemanticsNode().layoutInfo
+ assertEquals(expectedWidth, layoutInfo.width)
+ assertEquals(expectedHeight, layoutInfo.height)
+ }
+
+ @Test
+ fun `WHEN badge size is small THEN count is NOT displayed`() {
+ composeTestRule.setContent {
+ BadgedIcon(
+ modifier = Modifier.Companion.testTag("badgedIcon"),
+ painter = painterResource(R.drawable.mozac_ic_download_24),
+ isHighlighted = true,
+ size = BADGE_SIZE_SMALL,
+ notificationCount = 10,
+ )
+ }
+ val textField = composeTestRule.onNodeWithText("10")
+ assertFalse(textField.isDisplayed())
+ }
+
+ @Test
+ fun `WHEN badge size is large THEN count is displayed`() {
+ composeTestRule.setContent {
+ BadgedIcon(
+ modifier = Modifier.Companion.testTag("badgedIcon"),
+ painter = painterResource(R.drawable.mozac_ic_download_24),
+ isHighlighted = true,
+ size = BADGE_SIZE_LARGE,
+ notificationCount = 10,
+ )
+ }
+ val textField = composeTestRule.onNodeWithText("10")
+ assertTrue(textField.isDisplayed())
+ }
+
+ @Test
+ fun `WHEN badge size is large AND count is one digit THEN entire count is displayed`() {
+ composeTestRule.setContent {
+ BadgedIcon(
+ modifier = Modifier.Companion.testTag("badgedIcon"),
+ painter = painterResource(R.drawable.mozac_ic_download_24),
+ isHighlighted = true,
+ size = BADGE_SIZE_LARGE,
+ notificationCount = 1,
+ )
+ }
+ val textField = composeTestRule.onNodeWithText("1")
+ assertTrue(textField.isDisplayed())
+ }
+
+ @Test
+ fun `WHEN badge size is large AND count is two digits THEN entire count is displayed`() {
+ composeTestRule.setContent {
+ BadgedIcon(
+ modifier = Modifier.Companion.testTag("badgedIcon"),
+ painter = painterResource(R.drawable.mozac_ic_download_24),
+ isHighlighted = true,
+ size = BADGE_SIZE_LARGE,
+ notificationCount = 20,
+ )
+ }
+ val textField = composeTestRule.onNodeWithText("20")
+ assertTrue(textField.isDisplayed())
+ }
+
+ @Test
+ fun `WHEN badge size is large AND count is three digits THEN infinity symbol is displayed`() {
+ composeTestRule.setContent {
+ BadgedIcon(
+ modifier = Modifier.Companion.testTag("badgedIcon"),
+ painter = painterResource(R.drawable.mozac_ic_download_24),
+ isHighlighted = true,
+ size = BADGE_SIZE_LARGE,
+ notificationCount = 100,
+ )
+ }
+ val textField = composeTestRule.onNodeWithText("∞")
+ assertTrue(textField.isDisplayed())
+ }
+
+ @Test
+ fun `WHEN badge size is large AND count is int max THEN infinity symbol is displayed`() {
+ composeTestRule.setContent {
+ BadgedIcon(
+ modifier = Modifier.Companion.testTag("badgedIcon"),
+ painter = painterResource(R.drawable.mozac_ic_download_24),
+ isHighlighted = true,
+ size = BADGE_SIZE_LARGE,
+ notificationCount = Int.MAX_VALUE,
+ )
+ }
+ val textField = composeTestRule.onNodeWithText("∞")
+ assertTrue(textField.isDisplayed())
+ }
+
+ @Test
+ fun `WHEN badge size is large AND not highlighted THEN only the icon is displayed`() {
+ composeTestRule.setContent {
+ BadgedIcon(
+ modifier = Modifier.Companion.testTag("badgedIcon"),
+ painter = painterResource(R.drawable.mozac_ic_download_24),
+ isHighlighted = false,
+ size = BADGE_SIZE_LARGE,
+ notificationCount = 100,
+ )
+ }
+ val icon = composeTestRule.onNodeWithTag("badgedIcon")
+ assertTrue(icon.isDisplayed())
+ val badge = icon.onChild()
+ assertFalse(badge.isDisplayed())
+ }
+
+ @Test
+ fun `WHEN badge size is small AND not highlighted THEN only the icon is displayed`() {
+ composeTestRule.setContent {
+ BadgedIcon(
+ modifier = Modifier.Companion.testTag("badgedIcon"),
+ painter = painterResource(R.drawable.mozac_ic_download_24),
+ isHighlighted = false,
+ size = BADGE_SIZE_SMALL,
+ notificationCount = 100,
+ )
+ }
+ val icon = composeTestRule.onNodeWithTag("badgedIcon")
+ assertTrue(icon.isDisplayed())
+ val badge = icon.onChild()
+ assertFalse(badge.isDisplayed())
+ }
+}
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/menu/compose/LibraryMenuItem.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/menu/compose/LibraryMenuItem.kt
@@ -19,9 +19,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Badge
-import androidx.compose.material3.BadgedBox
-import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -43,6 +40,8 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
+import mozilla.components.compose.base.badge.BADGE_SIZE_SMALL
+import mozilla.components.compose.base.badge.BadgedIcon
import mozilla.components.compose.base.theme.information
import mozilla.components.compose.base.theme.surfaceDimVariant
import org.mozilla.fenix.R
@@ -98,19 +97,14 @@ fun LibraryMenuItem(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 12.dp),
) {
- BadgedBox(
- badge = {
- if (isHighlighted) {
- Badge(containerColor = MaterialTheme.colorScheme.information)
- }
- },
- ) {
- Icon(
- painter = painterResource(iconRes),
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurface,
- )
- }
+ BadgedIcon(
+ painter = painterResource(iconRes),
+ isHighlighted = isHighlighted,
+ size = BADGE_SIZE_SMALL,
+ contentDescription = null,
+ containerColor = MaterialTheme.colorScheme.information,
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
Spacer(Modifier.height(4.dp))
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/compose/list/ListItem.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/compose/list/ListItem.kt
@@ -24,8 +24,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Badge
-import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -65,7 +63,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import mozilla.components.compose.base.badge.BADGE_SIZE_SMALL
+import mozilla.components.compose.base.badge.BadgedIcon
import mozilla.components.compose.base.modifier.thenConditional
+import mozilla.components.compose.base.theme.information
import mozilla.components.compose.base.theme.surfaceDimVariant
import org.mozilla.fenix.compose.Favicon
import org.mozilla.fenix.compose.button.RadioButton
@@ -363,20 +364,15 @@ private fun IconListItemBeforeIcon(
description: String?,
tint: Color,
) {
- BadgedBox(
- badge = {
- if (isHighlighted) {
- Badge(containerColor = FirefoxTheme.colors.actionInformation)
- }
- },
- ) {
- Icon(
- painter = painter,
- contentDescription = description,
- tint = tint,
- modifier = Modifier.size(ICON_SIZE),
- )
- }
+ BadgedIcon(
+ painter = painter,
+ isHighlighted = isHighlighted,
+ tint = tint,
+ size = BADGE_SIZE_SMALL,
+ contentDescription = description,
+ containerColor = MaterialTheme.colorScheme.information,
+ modifier = Modifier.size(ICON_SIZE),
+ )
}
@Composable
@@ -1067,8 +1063,9 @@ private fun TextListItemWithIconPreview() {
}
}
+@Suppress("LongMethod")
@Composable
-@Preview(name = "IconListItem", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@PreviewLightDark
private fun IconListItemPreview() {
FirefoxTheme {
Column(Modifier.background(MaterialTheme.colorScheme.surface)) {
@@ -1080,6 +1077,14 @@ private fun IconListItemPreview() {
)
IconListItem(
+ label = "Left icon list item highlighted",
+ onClick = {},
+ beforeIconPainter = painterResource(iconsR.drawable.mozac_ic_folder_24),
+ beforeIconDescription = "click me",
+ isBeforeIconHighlighted = true,
+ )
+
+ IconListItem(
label = "Left icon list item",
colors = ListItemDefaults.colors(headlineColor = MaterialTheme.colorScheme.tertiary),
onClick = {},
@@ -1088,6 +1093,15 @@ private fun IconListItemPreview() {
)
IconListItem(
+ label = "Left icon list item highlighted",
+ colors = ListItemDefaults.colors(headlineColor = MaterialTheme.colorScheme.tertiary),
+ onClick = {},
+ beforeIconPainter = painterResource(iconsR.drawable.mozac_ic_folder_24),
+ beforeIconDescription = "click me",
+ isBeforeIconHighlighted = true,
+ )
+
+ IconListItem(
label = "Left icon list item + right icon",
onClick = {},
beforeIconPainter = painterResource(iconsR.drawable.mozac_ic_folder_24),
@@ -1098,6 +1112,17 @@ private fun IconListItemPreview() {
)
IconListItem(
+ label = "Left icon list item highlighted + right icon",
+ onClick = {},
+ beforeIconPainter = painterResource(iconsR.drawable.mozac_ic_folder_24),
+ beforeIconDescription = "click me",
+ showDivider = true,
+ afterIconPainter = painterResource(iconsR.drawable.mozac_ic_chevron_right_24),
+ afterIconDescription = null,
+ isBeforeIconHighlighted = true,
+ )
+
+ IconListItem(
label = "Left icon list item + right icon (disabled)",
enabled = false,
onClick = {},
@@ -1108,6 +1133,17 @@ private fun IconListItemPreview() {
)
IconListItem(
+ label = "Left icon list item highlighted + right icon (disabled)",
+ enabled = false,
+ onClick = {},
+ beforeIconPainter = painterResource(iconsR.drawable.mozac_ic_folder_24),
+ beforeIconDescription = "click me",
+ afterIconPainter = painterResource(iconsR.drawable.mozac_ic_chevron_right_24),
+ afterIconDescription = null,
+ isBeforeIconHighlighted = true,
+ )
+
+ IconListItem(
label = "Left icon list item + right icon (disabled)",
overline = "Overline",
enabled = false,
@@ -1128,6 +1164,18 @@ private fun IconListItemPreview() {
afterIconPainter = painterResource(iconsR.drawable.mozac_ic_chevron_right_24),
afterIconDescription = null,
)
+
+ IconListItem(
+ label = "Left icon list item highlighted + right icon (disabled)",
+ overline = "Overline",
+ enabled = false,
+ onClick = {},
+ beforeIconPainter = painterResource(iconsR.drawable.mozac_ic_folder_24),
+ beforeIconDescription = "click me",
+ afterIconPainter = painterResource(iconsR.drawable.mozac_ic_chevron_right_24),
+ afterIconDescription = null,
+ isBeforeIconHighlighted = true,
+ )
}
}
}