diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt index 8163d29d92..63d28dca21 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt @@ -8,6 +8,8 @@ package io.element.android.features.home.impl.components +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.RowScope @@ -15,9 +17,11 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.pager.rememberPagerState @@ -25,8 +29,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -37,10 +44,20 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import io.element.android.appconfig.RoomListConfig import io.element.android.compound.theme.ElementTheme @@ -71,10 +88,13 @@ import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.matrix.ui.model.toEmojiText import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings @@ -151,7 +171,7 @@ fun HomeTopBar( }, // We want a 16dp left padding for the navigationIcon : // 4dp from default TopAppBarHorizontalPadding - // 8dp from AccountIcon default padding (because of IconButton) + // 8dp from AccountIcon default padding (48dp touch target around 32dp avatar) // 4dp extra padding using left insets windowInsets = WindowInsets(left = 4.dp), ) @@ -316,19 +336,36 @@ private fun AccountIcon( modifier: Modifier = Modifier, ) { val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier - IconButton( - modifier = modifier.then(testTag), - onClick = onClick, + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = modifier + .then(testTag) + .minimumInteractiveComponentSize() + .clickable( + interactionSource = interactionSource, + onClick = onClick, + indication = ripple(bounded = false), + ), + contentAlignment = Alignment.Center, ) { - Box { val avatarData by remember(matrixUser) { derivedStateOf { matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) } } + val statusEmoji = matrixUser.displayedStatus?.toEmojiText() + val avatarModifier = if (statusEmoji != null) { + Modifier.eraseStatusEmojiBackground( + parentSize = AvatarSize.CurrentUserTopBar.dp, + layoutDirection = LocalLayoutDirection.current, + ) + } else { + Modifier + } Avatar( avatarData = avatarData, avatarType = AvatarType.User, + modifier = avatarModifier, contentDescription = if (isCurrentAccount) { if (showAvatarIndicator) { stringResource(CommonStrings.a11y_settings_with_required_action) @@ -339,11 +376,69 @@ private fun AccountIcon( null }, ) + if (statusEmoji != null) { + StatusEmojiBadge( + emoji = statusEmoji, + modifier = Modifier.align(Alignment.BottomEnd), + ) + } if (showAvatarIndicator) { RedIndicatorAtom( modifier = Modifier.align(Alignment.TopEnd) ) } + } +} + +private val statusEmojiBadgeSize = 20.dp +private val statusEmojiBadgeRadius = statusEmojiBadgeSize / 2 +private val statusEmojiBadgeOffset = 8.dp + +private fun Modifier.eraseStatusEmojiBackground( + parentSize: Dp, + layoutDirection: LayoutDirection, +): Modifier = this + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + } + .drawWithContent { + drawContent() + drawCircle( + color = Color.Black, + center = Offset( + x = if (layoutDirection == LayoutDirection.Ltr) { + (parentSize - statusEmojiBadgeRadius + statusEmojiBadgeOffset).toPx() + } else { + (statusEmojiBadgeRadius - statusEmojiBadgeOffset).toPx() + }, + y = size.height - statusEmojiBadgeRadius.toPx(), + ), + radius = statusEmojiBadgeRadius.toPx(), + blendMode = BlendMode.Clear, + ) + } + +@Composable +private fun StatusEmojiBadge( + emoji: String, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + // Ensure the emoji size is not affected by the user's font scale settings + val fixedFontScaleDensity = remember(density.density) { + Density(density = density.density, fontScale = 1f) + } + Box( + modifier = modifier + .offset(x = statusEmojiBadgeOffset) + .size(statusEmojiBadgeSize), + contentAlignment = Alignment.Center, + ) { + CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) { + Text( + text = emoji, + style = ElementTheme.typography.fontBodyMdRegular, + ) } } } @@ -432,6 +527,33 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview { ) } +@OptIn(ExperimentalMaterial3Api::class) +@PreviewsDayNight +@Composable +internal fun HomeTopBarWithStatusPreview() = ElementPreview { + HomeTopBar( + selectedNavigationItem = HomeNavigationBarItem.Chats, + currentUserAndNeighbors = persistentListOf( + aMatrixUser( + id = "@id:domain", + displayName = USER_NAME_ALICE, + displayedStatus = DisplayedStatus.UserSet(UserStatus(emoji = "🌴", text = "Away")), + ) + ), + showAvatarIndicator = false, + areSearchResultsDisplayed = false, + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()), + onOpenSettings = {}, + onAccountSwitch = {}, + onToggleSearch = {}, + canReportBug = true, + displayFilters = true, + filtersState = aRoomListFiltersState(), + spaceFiltersState = anUnselectedSpaceFiltersState(), + onMenuActionClick = {}, + ) +} + @OptIn(ExperimentalMaterial3Api::class) @PreviewsDayNight @Composable