diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizer.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizer.kt index e4b239de56..2b2a216528 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizer.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.annotation.ColorInt import androidx.core.content.ContextCompat import org.signal.core.models.ServiceId +import org.signal.core.util.orNull import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId @@ -16,7 +17,7 @@ import org.signal.core.ui.R as CoreUiR * - Gives easy access to different bubble colors * - Watches and responds to RecyclerView scroll and layout changes to update a ColorizerView */ -class Colorizer { +class Colorizer @JvmOverloads constructor(groupMemberIds: List = emptyList()) { private var colorsHaveBeenSet = false @@ -25,6 +26,10 @@ class Colorizer { private val groupMembers: LinkedHashSet = linkedSetOf() + init { + onGroupMembershipChanged(groupMemberIds) + } + @ColorInt fun getOutgoingBodyTextColor(context: Context): Int { return ContextCompat.getColor(context, R.color.conversation_outgoing_body_color) @@ -67,29 +72,31 @@ class Colorizer { } } - @Suppress("DEPRECATION") @ColorInt fun getIncomingGroupSenderColor(context: Context, recipient: Recipient): Int { - return if (groupMembers.isEmpty()) { - groupSenderColors[recipient.id]?.getColor(context) ?: getDefaultColor(context, recipient) - } else if (recipient.hasServiceId) { - val memberPosition = groupMembers.indexOf(recipient.requireServiceId()) - - if (memberPosition >= 0) { - val colorPosition = memberPosition % ChatColorsPalette.Names.all.size - ChatColorsPalette.Names.all[colorPosition].getColor(context) - } else { - getDefaultColor(context, recipient) - } - } else { - getDefaultColor(context, recipient) - } + return getNameColor(context, recipient).getColor(context) } fun onGroupMembershipChanged(serviceIds: List) { groupMembers.addAll(serviceIds.sortedBy { it.toString() }) } + @Suppress("DEPRECATION") + fun getNameColor(context: Context, recipient: Recipient): NameColor { + if (groupMembers.isEmpty()) { + return groupSenderColors[recipient.id] ?: getDefaultColor(context, recipient) + } + + val serviceId = recipient.serviceId.orNull() + if (serviceId != null) { + val position = groupMembers.indexOf(serviceId) + if (position >= 0) { + return ChatColorsPalette.Names.all[position % ChatColorsPalette.Names.all.size] + } + } + return getDefaultColor(context, recipient) + } + @Suppress("DEPRECATION") @Deprecated("Not needed for CFv2", ReplaceWith("onGroupMembershipChanged")) fun onNameColorsChanged(nameColorMap: Map) { @@ -99,14 +106,13 @@ class Colorizer { } @Suppress("DEPRECATION") - @ColorInt - private fun getDefaultColor(context: Context, recipient: Recipient): Int { + private fun getDefaultColor(context: Context, recipient: Recipient): NameColor { return if (colorsHaveBeenSet) { - val color = ChatColorsPalette.Names.all[groupSenderColors.size % ChatColorsPalette.Names.all.size] - groupSenderColors[recipient.id] = color - return color.getColor(context) + ChatColorsPalette.Names.all[groupSenderColors.size % ChatColorsPalette.Names.all.size] + .also { groupSenderColors[recipient.id] = it } } else { - getIncomingBodyTextColor(context, recipient.hasWallpaper) + val colorInt = getIncomingBodyTextColor(context, recipient.hasWallpaper) + NameColor(lightColor = colorInt, darkColor = colorInt) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelFragment.kt index a8b5d8b344..e0c75a5abc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelFragment.kt @@ -6,13 +6,18 @@ package org.thoughtcrime.securesms.groups.memberlabel import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -46,6 +51,7 @@ import org.signal.core.util.isNotNullOrBlank import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelUiState.SaveState +import org.thoughtcrime.securesms.profiles.ProfileName import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.viewModel @@ -126,39 +132,60 @@ private fun MemberLabelScreenUi( keyboardController?.show() } - Column( + Box( modifier = Modifier .padding(paddingValues) + .consumeWindowInsets(paddingValues) .fillMaxSize() + .imePadding() ) { - Text( - text = stringResource(R.string.GroupMemberLabel__description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 24.dp) - ) - - LabelTextField( - labelEmoji = state.labelEmoji, - labelText = state.labelText, - remainingCharacters = state.remainingCharacters, - onLabelTextChange = callbacks::onLabelTextChanged, - onEmojiChange = callbacks::onSetEmojiClicked, - onClear = callbacks::onClearLabelClicked, - onSave = callbacks::onSaveClicked, + Column( modifier = Modifier .padding(horizontal = 24.dp) - .focusRequester(focusRequester) - ) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Text( + text = stringResource(R.string.GroupMemberLabel__description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp, bottom = 24.dp) + ) - Spacer(modifier = Modifier.weight(1f)) + LabelTextField( + labelEmoji = state.labelEmoji, + labelText = state.labelText, + remainingCharacters = state.remainingCharacters, + onLabelTextChange = callbacks::onLabelTextChanged, + onEmojiChange = callbacks::onSetEmojiClicked, + onClear = callbacks::onClearLabelClicked, + onSave = callbacks::onSaveClicked, + modifier = Modifier.focusRequester(focusRequester) + ) + + Text( + text = stringResource(R.string.GroupMemberLabel__preview_section_header), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(top = 24.dp, bottom = 12.dp) + ) + + if (state.recipient != null) { + MessageBubblePreview( + sender = state.recipient, + senderNameColor = state.senderNameColor, + labelEmoji = state.labelEmoji, + labelText = state.labelText, + messageText = stringResource(R.string.GroupMemberLabel__preview_sample_message) + ) + } + } SaveButton( enabled = state.isSaveEnabled, onClick = callbacks::onSaveClicked, modifier = Modifier - .align(Alignment.End) - .padding(24.dp) + .align(Alignment.BottomEnd) + .padding(end = 24.dp, bottom = 16.dp) ) } } @@ -269,6 +296,9 @@ private fun MemberLabelScreenPreview() { Previews.Preview { MemberLabelScreenUi( state = MemberLabelUiState( + recipient = Recipient( + profileName = ProfileName.fromParts("Kahless", "The Unforgettable") + ), labelEmoji = "⛑️", labelText = "Vet Coordinator" ), diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelPill.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelPill.kt index 3e22219d10..18c29b01f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelPill.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelPill.kt @@ -91,7 +91,7 @@ fun MemberLabelPill( Text( text = annotatedText, inlineContent = inlineContent, - modifier = Modifier.padding(end = 5.dp) + modifier = if (text.isNotEmpty()) Modifier.padding(end = 5.dp) else Modifier ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt index aaed9249c3..fa9e2c5087 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt @@ -12,6 +12,8 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.signal.core.models.ServiceId import org.signal.core.util.orNull +import org.thoughtcrime.securesms.conversation.colors.Colorizer +import org.thoughtcrime.securesms.conversation.colors.NameColor import org.thoughtcrime.securesms.database.GroupTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -33,11 +35,15 @@ class MemberLabelRepository private constructor( val instance: MemberLabelRepository by lazy { MemberLabelRepository() } } + suspend fun getRecipient(recipientId: RecipientId): Recipient = withContext(Dispatchers.IO) { + Recipient.resolved(recipientId) + } + /** * Gets the member label for a specific recipient in the group. */ - suspend fun getLabel(groupId: GroupId.V2, recipientId: RecipientId): MemberLabel? = withContext(Dispatchers.IO) { - getLabel(groupId, Recipient.resolved(recipientId)) + suspend fun getLabel(groupId: GroupId.V2, recipientId: RecipientId): MemberLabel? { + return getLabel(groupId, getRecipient(recipientId)) } /** @@ -89,6 +95,19 @@ class MemberLabelRepository private constructor( groupRecord.attributesAccessControl.allows(groupRecord.memberLevel(recipient)) } + /** + * Computes the sender [NameColor] for a recipient as seen by other group members. + */ + suspend fun getSenderNameColor(groupId: GroupId.V2, recipientId: RecipientId): NameColor = withContext(Dispatchers.IO) { + val recipient = getRecipient(recipientId) + + val groupMemberIds = groupsTable + .getGroupMembers(groupId, GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF) + .mapNotNull { it.serviceId.orNull() } + + Colorizer(groupMemberIds).getNameColor(context, recipient) + } + /** * Sets the group member label for the current user. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModel.kt index 901df2e25a..9c24e2f047 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModel.kt @@ -13,8 +13,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.signal.core.util.concurrent.SignalDispatchers +import org.thoughtcrime.securesms.conversation.colors.NameColor import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelUiState.SaveState +import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId private const val MIN_LABEL_TEXT_LENGTH = 1 @@ -33,10 +35,10 @@ class MemberLabelViewModel( val uiState: StateFlow = internalUiState.asStateFlow() init { - loadExistingLabel() + loadInitialState() } - private fun loadExistingLabel() { + private fun loadInitialState() { viewModelScope.launch(SignalDispatchers.IO) { val memberLabel = memberLabelRepo.getLabel(groupId, recipientId) originalLabelEmoji = memberLabel?.emoji.orEmpty() @@ -44,8 +46,10 @@ class MemberLabelViewModel( internalUiState.update { it.copy( + recipient = memberLabelRepo.getRecipient(recipientId), labelEmoji = originalLabelEmoji, - labelText = originalLabelText + labelText = originalLabelText, + senderNameColor = memberLabelRepo.getSenderNameColor(groupId, recipientId) ) } } @@ -119,6 +123,8 @@ class MemberLabelViewModel( data class MemberLabelUiState( val labelEmoji: String = "", val labelText: String = "", + val recipient: Recipient? = null, + val senderNameColor: NameColor? = null, val hasChanges: Boolean = false, val saveState: SaveState? = null ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MessageBubblePreview.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MessageBubblePreview.kt new file mode 100644 index 0000000000..0c2047bb5b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MessageBubblePreview.kt @@ -0,0 +1,180 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.groups.memberlabel + +import android.content.Context +import android.graphics.drawable.GradientDrawable +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import androidx.annotation.ColorInt +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.core.util.DimensionUnit +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.components.emoji.EmojiTextView +import org.thoughtcrime.securesms.conversation.colors.NameColor +import org.thoughtcrime.securesms.conversation.v2.items.SenderNameWithLabelView +import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.visible +import org.signal.core.ui.R as CoreUiR + +@Composable +fun MessageBubblePreview( + sender: Recipient, + senderNameColor: NameColor?, + labelEmoji: String?, + labelText: String?, + messageText: String, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val senderNameColorInt = senderNameColor?.getColor(context) ?: MaterialTheme.colorScheme.onSurface.toArgb() + + Box( + modifier = modifier + .widthIn(max = 600.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(27.dp)) + .background(SignalTheme.colors.colorSurface2) + .padding(start = 4.dp, end = 16.dp, top = 20.dp, bottom = 20.dp) + ) { + if (LocalInspectionMode.current) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .height(96.dp) + ) { + Text( + text = "", + fontStyle = FontStyle.Italic + ) + } + } else { + AndroidView( + factory = ::MessageBubblePreviewView, + update = { view -> + view.setData( + context = view.context, + sender = sender, + senderNameColor = senderNameColorInt, + labelEmoji = labelEmoji, + labelText = labelText, + messageText = messageText + ) + }, + modifier = modifier + ) + } + } +} + +private class MessageBubblePreviewView(context: Context) : FrameLayout(context) { + private val avatarView: AvatarImageView + private val groupSenderView: SenderNameWithLabelView + private val bodyBubble: View + private val bodyText: EmojiTextView + + init { + LayoutInflater.from(context).inflate(R.layout.v2_conversation_item_text_only_incoming, this, true) + + avatarView = findViewById(R.id.contact_photo) + groupSenderView = findViewById(R.id.group_sender_name_with_label) + bodyBubble = findViewById(R.id.conversation_item_body_wrapper) + bodyText = findViewById(R.id.conversation_item_body) + + bodyBubble.background = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = DimensionUnit.DP.toPixels(18f) + setColor(ContextCompat.getColor(context, CoreUiR.color.signal_colorSurface)) + } + + bodyBubble.minimumWidth = DimensionUnit.DP.toPixels(180f).toInt() + + // match the appearance of V2ConversationItemTextOnlyViewHolder when a member label is shown: + (bodyBubble.layoutParams as? MarginLayoutParams)?.marginEnd = 0 + (bodyText.layoutParams as? MarginLayoutParams)?.topMargin = 0 + + findViewById(R.id.conversation_item_reply)?.visible = false + findViewById(R.id.conversation_item_reactions)?.visible = false + } + + fun setData( + context: Context, + sender: Recipient, + @ColorInt senderNameColor: Int, + labelEmoji: String?, + labelText: String?, + messageText: String + ) { + avatarView.visible = true + avatarView.setAvatarUsingProfile(sender) + + groupSenderView.visible = true + groupSenderView.setSender(sender.getDisplayName(context), senderNameColor) + + val memberLabel = if (labelEmoji.isNullOrBlank() && labelText.isNullOrBlank()) { + null + } else { + MemberLabel(emoji = labelEmoji, text = labelText.orEmpty()) + } + groupSenderView.setLabel(memberLabel) + + bodyText.text = messageText + bodyText.setTextColor(ContextCompat.getColor(context, CoreUiR.color.signal_colorOnSurface)) + bodyText.visible = true + } +} + +@DayNightPreviews +@Composable +private fun MessageBubblePreviewPreview() { + Previews.Preview { + Box( + modifier = Modifier + .background(Color.Black) + .padding(20.dp) + ) { + MessageBubblePreview( + sender = Recipient( + profileName = ProfileName.fromParts("Kahless", "The Unforgettable") + ), + senderNameColor = NameColor( + lightColor = MaterialTheme.colorScheme.onSurface.toArgb(), + darkColor = MaterialTheme.colorScheme.onSurface.toArgb() + ), + labelEmoji = "⚔️️", + labelText = "Legendary Warrior", + messageText = "Questions are the beginning of wisdom, the mark of a true warrior." + ) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 44ee789624..be9f0e9a9f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9352,6 +9352,8 @@ Add your label Preview + + Hello! Save diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModelTest.kt index 672011de95..33b74480ef 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModelTest.kt @@ -13,8 +13,10 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Rule import org.junit.Test +import org.thoughtcrime.securesms.conversation.colors.NameColor import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule @@ -30,6 +32,12 @@ class MemberLabelViewModelTest { private val groupId = mockk() private val recipientId = RecipientId.from(1L) + @Before + fun setUp() { + coEvery { memberLabelRepo.getRecipient(any()) } returns mockk(relaxed = true) + coEvery { memberLabelRepo.getSenderNameColor(any(), any()) } returns NameColor(0, 0) + } + @Test fun `isSaveEnabled returns true when label text is different from the original value`() { coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/ClearableTextField.kt b/core/ui/src/main/java/org/signal/core/ui/compose/ClearableTextField.kt index 3dac355650..f4ebfb9eb9 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/ClearableTextField.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/ClearableTextField.kt @@ -6,6 +6,7 @@ package org.signal.core.ui.compose import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Column @@ -30,6 +31,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged @@ -151,18 +153,17 @@ fun ClearableTextField( trailingIcon = if (clearable) clearButton else null ) - AnimatedVisibility( - visible = displayCountdown, - modifier = Modifier.align(Alignment.End) - ) { - val errorThresholdExceeded = countdownConfig != null && charactersRemainingBeforeLimit <= countdownConfig.warnThreshold - Text( - text = "$charactersRemainingBeforeLimit", - style = MaterialTheme.typography.bodySmall, - color = if (errorThresholdExceeded) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline, - modifier = Modifier.padding(top = 4.dp, end = 16.dp) - ) - } + val countdownAlpha by animateFloatAsState(targetValue = if (displayCountdown) 1f else 0f, label = "countdownAlpha") + val errorThresholdExceeded = countdownConfig != null && charactersRemainingBeforeLimit <= countdownConfig.warnThreshold + Text( + text = "$charactersRemainingBeforeLimit", + style = MaterialTheme.typography.bodySmall, + color = if (errorThresholdExceeded) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline, + modifier = Modifier + .align(Alignment.End) + .alpha(countdownAlpha) + .padding(top = 4.dp, end = 16.dp) + ) } }