From f0ce4afda34d64ba181eb09171a13b2533d2d3e2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 26 Jun 2025 09:28:21 +0200 Subject: [PATCH] Add NavigationBar component --- .../designsystem/atomic/atoms/CounterAtom.kt | 25 ++- .../theme/components/NavigationBar.kt | 186 ++++++++++++++++++ .../tests/konsist/KonsistComposableTest.kt | 2 + 3 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBar.kt diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt index 32b96b6260..4af1e3f6b5 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt @@ -17,7 +17,7 @@ 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.unit.dp import io.element.android.compound.theme.ElementTheme @@ -41,13 +41,14 @@ private const val MAX_COUNT_STRING = "+$MAX_COUNT" fun CounterAtom( count: Int, modifier: Modifier = Modifier, + textStyle: TextStyle = CounterAtomDefaults.textStyle, + isCritical: Boolean = false, ) { if (count < 1) return val countAsText = when (count) { in 0..MAX_COUNT -> count.toString() else -> MAX_COUNT_STRING } - val textStyle = ElementTheme.typography.fontBodyMdMedium val textMeasurer = rememberTextMeasurer() // Measure the maximum count string size val textLayoutResult = textMeasurer.measure( @@ -58,19 +59,30 @@ fun CounterAtom( val squareSize = maxOf(textSize.width, textSize.height) Box( modifier = modifier - .size(squareSize.toDp() + 1.dp) - .clip(CircleShape) - .background(ElementTheme.colors.iconSuccessPrimary) + .size(squareSize.toDp() + 1.dp) + .clip(CircleShape) + .background( + if (isCritical) { + ElementTheme.colors.iconCriticalPrimary + } else { + ElementTheme.colors.iconAccentPrimary + } + ) ) { Text( modifier = Modifier.align(Alignment.Center), text = countAsText, style = textStyle, - color = Color.White, + color = ElementTheme.colors.textOnSolidPrimary, ) } } +object CounterAtomDefaults { + val textStyle: TextStyle + @Composable get() = ElementTheme.typography.fontBodyMdMedium +} + @PreviewsDayNight @Composable internal fun CounterAtomPreview() = ElementPreview { @@ -79,5 +91,6 @@ internal fun CounterAtomPreview() = ElementPreview { CounterAtom(count = 4) CounterAtom(count = 99) CounterAtom(count = 100) + CounterAtom(count = 4, isCritical = true) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBar.kt new file mode 100644 index 0000000000..6cab9a8464 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBar.kt @@ -0,0 +1,186 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.offset +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.NavigationBarItemColors +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +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.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import androidx.compose.material3.NavigationBarItem as MaterialNavigationBarItem + +@Composable +fun NavigationBar( + modifier: Modifier = Modifier, + containerColor: Color = ElementNavigationBarDefaults.containerColor, + contentColor: Color = MaterialTheme.colorScheme.contentColorFor(containerColor), + tonalElevation: Dp = ElementNavigationBarDefaults.tonalElevation, + windowInsets: WindowInsets = ElementNavigationBarDefaults.windowInsets, + content: @Composable RowScope.() -> Unit +) { + androidx.compose.material3.NavigationBar( + modifier = modifier, + containerColor = containerColor, + contentColor = contentColor, + tonalElevation = tonalElevation, + windowInsets = windowInsets, + content = content + ) +} + +object ElementNavigationBarDefaults { + val containerColor: Color + @Composable get() = if (ElementTheme.isLightTheme) { + ElementTheme.colors.bgSubtlePrimary + } else { + ElementTheme.colors.textOnSolidPrimary + } + + val tonalElevation: Dp = NavigationBarDefaults.Elevation + + val windowInsets: WindowInsets + @Composable get() = NavigationBarDefaults.windowInsets +} + +@Composable +fun RowScope.NavigationBarItem( + selected: Boolean, + onClick: () -> Unit, + icon: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + label: @Composable (() -> Unit)? = null, + alwaysShowLabel: Boolean = true, + colors: NavigationBarItemColors = ElementNavigationBarItemDefaultsDefaults.colors(), + interactionSource: MutableInteractionSource? = null +) { + MaterialNavigationBarItem( + selected = selected, + onClick = onClick, + icon = icon, + modifier = modifier, + enabled = enabled, + label = label, + alwaysShowLabel = alwaysShowLabel, + colors = colors, + interactionSource = interactionSource, + ) +} + +object ElementNavigationBarItemDefaultsDefaults { + @Composable + fun colors() = NavigationBarItemDefaults.colors().copy( + selectedIconColor = ElementTheme.colors.iconPrimary, + selectedTextColor = ElementTheme.colors.textPrimary, + unselectedIconColor = ElementTheme.colors.iconTertiary, + unselectedTextColor = ElementTheme.colors.textDisabled, + selectedIndicatorColor = Color.Transparent, + ) +} + +@Composable +fun NavigationBarIcon( + imageVector: ImageVector, + count: Int, + isCritical: Boolean, +) { + Box { + Icon( + imageVector = imageVector, + contentDescription = null, + ) + CounterAtom( + modifier = Modifier.offset(11.dp, (-11).dp), + textStyle = ElementTheme.typography.fontBodyXsMedium, + count = count, + isCritical = isCritical, + ) + } +} + +@Composable +fun NavigationBarText( + text: String, +) { + Text( + text = text, + style = ElementTheme.typography.fontBodySmMedium, + ) +} + +@Preview(group = PreviewGroup.AppBars) +@Composable +internal fun NavigationBarPreview() = ElementThemedPreview { + NavigationBar { + NavigationBarItem( + icon = { + NavigationBarIcon( + imageVector = CompoundIcons.ChatSolid(), + count = 5, + isCritical = false, + ) + }, + label = { + NavigationBarText( + text = "Chats" + ) + }, + selected = true, + onClick = {}, + ) + NavigationBarItem( + icon = { + NavigationBarIcon( + imageVector = CompoundIcons.ChatSolid(), + count = 5, + isCritical = true, + ) + }, + label = { + NavigationBarText( + text = "Teams" + ) + }, + selected = false, + onClick = {}, + ) + NavigationBarItem( + icon = { + NavigationBarIcon( + imageVector = CompoundIcons.ChatSolid(), + count = 0, + isCritical = false, + ) + }, + label = { + NavigationBarText( + text = "Other" + ) + }, + selected = false, + onClick = {}, + ) + } +} diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistComposableTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistComposableTest.kt index d1435e409d..88a5bd738c 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistComposableTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistComposableTest.kt @@ -34,6 +34,8 @@ class KonsistComposableTest { // Add some exceptions... "InvisibleButton", "OutlinedButton", + "NavigationBarIcon", + "NavigationBarText", "SimpleAlertDialogContent", "TextButton", )