mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 10:32:34 +00:00
Add status emoji badge to current user avatar in home top bar
This commit is contained in:
+126
-4
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
package io.element.android.features.home.impl.components
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.RowScope
|
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.WindowInsetsSides
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.only
|
import androidx.compose.foundation.layout.only
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.pager.VerticalPager
|
import androidx.compose.foundation.pager.VerticalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
@@ -25,8 +29,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
|
import androidx.compose.material3.ripple
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -37,10 +44,20 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.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.res.stringResource
|
||||||
import androidx.compose.ui.semantics.heading
|
import androidx.compose.ui.semantics.heading
|
||||||
import androidx.compose.ui.semantics.semantics
|
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 androidx.compose.ui.unit.dp
|
||||||
import io.element.android.appconfig.RoomListConfig
|
import io.element.android.appconfig.RoomListConfig
|
||||||
import io.element.android.compound.theme.ElementTheme
|
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.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
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.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.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.aMatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
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.getAvatarData
|
||||||
|
import io.element.android.libraries.matrix.ui.model.toEmojiText
|
||||||
import io.element.android.libraries.testtags.TestTags
|
import io.element.android.libraries.testtags.TestTags
|
||||||
import io.element.android.libraries.testtags.testTag
|
import io.element.android.libraries.testtags.testTag
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
@@ -151,7 +171,7 @@ fun HomeTopBar(
|
|||||||
},
|
},
|
||||||
// We want a 16dp left padding for the navigationIcon :
|
// We want a 16dp left padding for the navigationIcon :
|
||||||
// 4dp from default TopAppBarHorizontalPadding
|
// 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
|
// 4dp extra padding using left insets
|
||||||
windowInsets = WindowInsets(left = 4.dp),
|
windowInsets = WindowInsets(left = 4.dp),
|
||||||
)
|
)
|
||||||
@@ -316,19 +336,36 @@ private fun AccountIcon(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
|
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
|
||||||
IconButton(
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
modifier = modifier.then(testTag),
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.then(testTag)
|
||||||
|
.minimumInteractiveComponentSize()
|
||||||
|
.clickable(
|
||||||
|
interactionSource = interactionSource,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
indication = ripple(bounded = false),
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Box {
|
|
||||||
val avatarData by remember(matrixUser) {
|
val avatarData by remember(matrixUser) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
|
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(
|
Avatar(
|
||||||
avatarData = avatarData,
|
avatarData = avatarData,
|
||||||
avatarType = AvatarType.User,
|
avatarType = AvatarType.User,
|
||||||
|
modifier = avatarModifier,
|
||||||
contentDescription = if (isCurrentAccount) {
|
contentDescription = if (isCurrentAccount) {
|
||||||
if (showAvatarIndicator) {
|
if (showAvatarIndicator) {
|
||||||
stringResource(CommonStrings.a11y_settings_with_required_action)
|
stringResource(CommonStrings.a11y_settings_with_required_action)
|
||||||
@@ -339,6 +376,12 @@ private fun AccountIcon(
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if (statusEmoji != null) {
|
||||||
|
StatusEmojiBadge(
|
||||||
|
emoji = statusEmoji,
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd),
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showAvatarIndicator) {
|
if (showAvatarIndicator) {
|
||||||
RedIndicatorAtom(
|
RedIndicatorAtom(
|
||||||
modifier = Modifier.align(Alignment.TopEnd)
|
modifier = Modifier.align(Alignment.TopEnd)
|
||||||
@@ -346,6 +389,58 @@ private fun AccountIcon(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
Reference in New Issue
Block a user