From 7a22b3df716221745c6fa50a09143b691d0c26da Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Jun 2024 12:51:16 -0400 Subject: [PATCH] Realigning reactions --- .../amethyst/ui/actions/CrossfadeIfEnabled.kt | 86 ++++++- .../amethyst/ui/components/ClickableBox.kt | 26 +- .../amethyst/ui/note/ReactionsRow.kt | 236 ++++++++++-------- .../vitorpamplona/amethyst/ui/theme/Theme.kt | 44 ---- 4 files changed, 241 insertions(+), 151 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt index c0f20510c..232b31a20 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt @@ -20,12 +20,22 @@ */ package com.vitorpamplona.amethyst.ui.actions -import androidx.compose.animation.Crossfade +import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.util.fastForEach import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -33,16 +43,86 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel fun CrossfadeIfEnabled( targetState: T, modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, animationSpec: FiniteAnimationSpec = tween(), label: String = "Crossfade", accountViewModel: AccountViewModel, content: @Composable (T) -> Unit, ) { if (accountViewModel.settings.featureSet == FeatureSetType.PERFORMANCE) { - Box(modifier) { + Box(modifier, contentAlignment) { content(targetState) } } else { - Crossfade(targetState, modifier, animationSpec, label, content) + MyCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content) + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun MyCrossfade( + targetState: T, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + animationSpec: FiniteAnimationSpec = tween(), + label: String = "Crossfade", + content: @Composable (T) -> Unit, +) { + val transition = updateTransition(targetState, label) + transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content) +} + +@ExperimentalAnimationApi +@Composable +fun Transition.MyCrossfade( + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + animationSpec: FiniteAnimationSpec = tween(), + contentKey: (targetState: T) -> Any? = { it }, + content: @Composable (targetState: T) -> Unit, +) { + val currentlyVisible = remember { mutableStateListOf().apply { add(currentState) } } + val contentMap = + remember { + mutableMapOf Unit>() + } + if (currentState == targetState) { + // If not animating, just display the current state + if (currentlyVisible.size != 1 || currentlyVisible[0] != targetState) { + // Remove all the intermediate items from the list once the animation is finished. + currentlyVisible.removeAll { it != targetState } + contentMap.clear() + } + } + if (!contentMap.contains(targetState)) { + // Replace target with the same key if any + val replacementId = + currentlyVisible.indexOfFirst { + contentKey(it) == contentKey(targetState) + } + if (replacementId == -1) { + currentlyVisible.add(targetState) + } else { + currentlyVisible[replacementId] = targetState + } + contentMap.clear() + currentlyVisible.fastForEach { stateForContent -> + contentMap[stateForContent] = { + val alpha by animateFloat( + transitionSpec = { animationSpec }, + ) { if (it == stateForContent) 1f else 0f } + Box(Modifier.graphicsLayer { this.alpha = alpha }, contentAlignment) { + content(stateForContent) + } + } + } + } + + Box(modifier, contentAlignment) { + currentlyVisible.fastForEach { + key(contentKey(it)) { + contentMap[it]?.invoke() + } + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt index 57410903e..b8e522911 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.components +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.material.ripple.rememberRipple @@ -33,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size24dp @Composable fun ClickableBox( - modifier: Modifier, + modifier: Modifier = Modifier, onClick: () -> Unit, content: @Composable () -> Unit, ) { @@ -49,3 +51,25 @@ fun ClickableBox( content() } } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ClickableBox( + modifier: Modifier = Modifier, + onClick: () -> Unit, + onLongClick: () -> Unit, + content: @Composable () -> Unit, +) { + Box( + modifier.combinedClickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = Size24dp), + onClick = onClick, + onLongClick = onLongClick, + ), + contentAlignment = Alignment.Center, + ) { + content() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index e75103ce7..91f2d25de 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -35,7 +35,6 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -49,13 +48,15 @@ import androidx.compose.foundation.layout.fillMaxWidth 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.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults @@ -85,6 +86,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.TextUnit @@ -112,6 +114,7 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DarkerGreen import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.HalfPadding import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders import com.vitorpamplona.amethyst.ui.theme.ReactionRowExpandButton @@ -127,6 +130,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.Size24dp +import com.vitorpamplona.amethyst.ui.theme.SmallBorder +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.TinyBorders import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -135,6 +140,7 @@ import com.vitorpamplona.quartz.events.BaseTextNoteEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers @@ -831,29 +837,21 @@ fun LikeReaction( var wantsToChangeReactionSymbol by remember { mutableStateOf(false) } var wantsToReact by remember { mutableStateOf(false) } - Box( - contentAlignment = Center, - modifier = - Modifier - .combinedClickable( - role = Role.Button, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = Size24dp), - onClick = { - likeClick( - accountViewModel, - baseNote, - onMultipleChoices = { wantsToReact = true }, - onWantsToSignReaction = { accountViewModel.reactToOrDelete(baseNote) }, - ) - }, - onLongClick = { wantsToChangeReactionSymbol = true }, - ), + ClickableBox( + onClick = { + likeClick( + accountViewModel, + baseNote, + onMultipleChoices = { wantsToReact = true }, + onWantsToSignReaction = { accountViewModel.reactToOrDelete(baseNote) }, + ) + }, + onLongClick = { wantsToChangeReactionSymbol = true }, ) { ObserveLikeIcon(baseNote, accountViewModel) { reactionType -> - CrossfadeIfEnabled(targetState = reactionType, label = "LikeIcon", accountViewModel = accountViewModel) { - if (it != null) { - RenderReactionType(it, heartSizeModifier, iconFontSize) + CrossfadeIfEnabled(targetState = reactionType, contentAlignment = Center, label = "LikeIcon", accountViewModel = accountViewModel) { + if (reactionType != null) { + RenderReactionType(reactionType, heartSizeModifier, iconFontSize) } else { LikeIcon(heartSizeModifier, grayTint) } @@ -1320,6 +1318,7 @@ fun ReactionChoicePopup( val account = accountState?.account ?: return val toRemove = remember { baseNote.reactedBy(account.userProfile()).toImmutableSet() } + val reactions = remember { account.reactionChoices.toImmutableList() } val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } @@ -1328,108 +1327,139 @@ fun ReactionChoicePopup( offset = IntOffset(0, iconSizePx), onDismissRequest = { onDismiss() }, ) { + ReactionChoicePopupContent( + reactions, + toRemove = toRemove, + onClick = { reactionType -> + accountViewModel.reactToOrDelete( + baseNote, + reactionType, + ) + onDismiss() + }, + onChangeAmount, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ReactionChoicePopupContent( + listOfReactions: ImmutableList, + toRemove: ImmutableSet, + onClick: (reactionType: String) -> Unit, + onChangeAmount: () -> Unit, +) { + Box(HalfPadding, contentAlignment = Center) { ElevatedCard( - Modifier - .border(width = 1.dp, color = MaterialTheme.colorScheme.outline, shape = RoundedCornerShape(5.dp)), + shape = SmallBorder, elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { - Box(modifier = Modifier.padding(5.dp)) { - FlowRow(horizontalArrangement = Arrangement.Center) { - account.reactionChoices.forEach { reactionType -> - ActionableReactionButton( - baseNote, - reactionType, - accountViewModel, - onDismiss, - onChangeAmount, - toRemove, - ) - } + FlowRow( + modifier = HalfPadding, + horizontalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Center, + ) { + listOfReactions.forEach { reactionType -> + ActionableReactionButton( + reactionType = reactionType, + onClick = { onClick(reactionType) }, + onChangeAmount = onChangeAmount, + toRemove = toRemove, + ) } } } } } +@Preview() +@Composable +fun ReactionChoicePopupPeeview() { + ThemeComparisonColumn { + ReactionChoicePopupContent( + persistentListOf( + "\uD83D\uDE80", + "\uD83E\uDEC2", + "\uD83D\uDC40", + "\uD83D\uDE02", + "\uD83C\uDF89", + "\uD83E\uDD14", + "\uD83D\uDE31", + "\uD83E\uDD14", + "\uD83D\uDE31", + "+", + "-", + ), + onClick = {}, + onChangeAmount = {}, + toRemove = persistentSetOf(), + ) + } +} + @Composable @OptIn(ExperimentalFoundationApi::class) private fun ActionableReactionButton( - baseNote: Note, reactionType: String, - accountViewModel: AccountViewModel, - onDismiss: () -> Unit, + onClick: () -> Unit, onChangeAmount: () -> Unit, toRemove: ImmutableSet, ) { val thisModifier = - remember(reactionType) { - Modifier - .padding(horizontal = 3.dp) - .combinedClickable( - onClick = { - accountViewModel.reactToOrDelete( - baseNote, - reactionType, - ) - onDismiss() - }, - onLongClick = { onChangeAmount() }, - ) - .padding(5.dp) - } - - val removeSymbol = - remember(reactionType) { - if (reactionType in toRemove) { - " ✖" - } else { - "" - } - } - - if (reactionType.startsWith(":")) { - val noStartColon = reactionType.removePrefix(":") - val url = noStartColon.substringAfter(":") - - val renderable = - persistentListOf( - Nip30CustomEmoji.ImageUrlType(url), - Nip30CustomEmoji.TextType(removeSymbol), + Modifier + .padding(horizontal = 8.dp, vertical = 5.dp) + .height(Size20dp) + .combinedClickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = Size24dp), + onClick = onClick, + onLongClick = onChangeAmount, ) - InLineIconRenderer( - renderable, - style = SpanStyle(color = Color.White), - maxLines = 1, - modifier = thisModifier, - ) - } else { - when (reactionType) { - "+" -> { - LikedIcon(modifier = thisModifier.size(16.dp), tint = Color.White) + Row(thisModifier, verticalAlignment = Alignment.CenterVertically) { + if (reactionType.startsWith(":")) { + val noStartColon = reactionType.removePrefix(":") + val url = noStartColon.substringAfter(":") - Text( - text = removeSymbol, - color = Color.White, - textAlign = TextAlign.Center, - modifier = thisModifier, - ) + InLineIconRenderer( + persistentListOf( + Nip30CustomEmoji.ImageUrlType(url), + ), + style = SpanStyle(color = MaterialTheme.colorScheme.onBackground), + maxLines = 1, + ) + } else { + when (reactionType) { + "+" -> { + LikedIcon(modifier = Modifier.size(20.dp)) + } + + "-" -> { + Text( + text = "\uD83D\uDC4E", + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + ) + } + else -> { + Text( + "$reactionType", + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + ) + } } - "-" -> - Text( - text = "\uD83D\uDC4E$removeSymbol", - color = Color.White, - textAlign = TextAlign.Center, - modifier = thisModifier, - ) - else -> - Text( - "$reactionType$removeSymbol", - color = Color.White, - textAlign = TextAlign.Center, - modifier = thisModifier, - ) + } + + if (reactionType in toRemove) { + Icon( + imageVector = Icons.Default.Cancel, + contentDescription = null, + modifier = Modifier.padding(start = 2.dp).size(14.dp), + ) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 440a16c1e..14ae768ab 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -94,18 +94,12 @@ private val LightLessImportantLink = LightColorPalette.primary.copy(alpha = 0.52 private val DarkMediumImportantLink = DarkColorPalette.primary.copy(alpha = 0.32f) private val LightMediumImportantLink = LightColorPalette.primary.copy(alpha = 0.32f) -private val DarkVeryImportantLink = DarkColorPalette.primary.copy(alpha = 0.12f) -private val LightVeryImportantLink = LightColorPalette.primary.copy(alpha = 0.12f) - private val DarkGrayText = DarkColorPalette.onSurface.copy(alpha = 0.52f) private val LightGrayText = LightColorPalette.onSurface.copy(alpha = 0.52f) private val DarkPlaceholderText = DarkColorPalette.onSurface.copy(alpha = 0.32f) private val LightPlaceholderText = LightColorPalette.onSurface.copy(alpha = 0.32f) -private val DarkPlaceholderTextColorFilter = ColorFilter.tint(DarkPlaceholderText) -private val LightPlaceholderTextColorFilter = ColorFilter.tint(LightPlaceholderText) - private val DarkOnBackgroundColorFilter = ColorFilter.tint(DarkColorPalette.onBackground) private val LightOnBackgroundColorFilter = ColorFilter.tint(LightColorPalette.onBackground) @@ -115,20 +109,9 @@ private val LightSubtleButton = LightColorPalette.onSurface.copy(alpha = 0.22f) private val DarkSubtleBorder = DarkColorPalette.onSurface.copy(alpha = 0.12f) private val LightSubtleBorder = LightColorPalette.onSurface.copy(alpha = 0.12f) -private val DarkReplyItemBackground = DarkColorPalette.onSurface.copy(alpha = 0.05f) -private val LightReplyItemBackground = LightColorPalette.onSurface.copy(alpha = 0.05f) - -private val DarkZapraiserBackground = - BitcoinOrange.copy(0.52f).compositeOver(DarkColorPalette.background) -private val LightZapraiserBackground = - BitcoinOrange.copy(0.52f).compositeOver(LightColorPalette.background) - private val DarkOverPictureBackground = DarkColorPalette.background.copy(0.62f) private val LightOverPictureBackground = LightColorPalette.background.copy(0.62f) -val RepostPictureBorderDark = Modifier.border(2.dp, DarkColorPalette.background, CircleShape) -val RepostPictureBorderLight = Modifier.border(2.dp, LightColorPalette.background, CircleShape) - val DarkImageModifier = Modifier .fillMaxWidth() @@ -225,16 +208,6 @@ val DarkLargeRelayIconModifier = .size(Size55dp) .clip(shape = CircleShape) -val LightBottomIconModifier = - Modifier - .size(Size10dp) - .clip(shape = CircleShape) - -val DarkBottomIconModifier = - Modifier - .size(Size10dp) - .clip(shape = CircleShape) - val RichTextDefaults = RichTextStyle().resolveDefaults() val MarkDownStyleOnDark = @@ -319,9 +292,6 @@ val ColorScheme.isLight: Boolean val ColorScheme.newItemBackgroundColor: Color get() = if (isLight) LightNewItemBackground else DarkNewItemBackground -val ColorScheme.replyBackground: Color - get() = if (isLight) LightReplyItemBackground else DarkReplyItemBackground - val ColorScheme.selectedNote: Color get() = if (isLight) LightSelectedNote else DarkSelectedNote @@ -331,13 +301,8 @@ val ColorScheme.secondaryButtonBackground: Color val ColorScheme.lessImportantLink: Color get() = if (isLight) LightLessImportantLink else DarkLessImportantLink -val ColorScheme.zapraiserBackground: Color - get() = if (isLight) LightZapraiserBackground else DarkZapraiserBackground - val ColorScheme.mediumImportanceLink: Color get() = if (isLight) LightMediumImportantLink else DarkMediumImportantLink -val ColorScheme.veryImportantLink: Color - get() = if (isLight) LightVeryImportantLink else DarkVeryImportantLink val ColorScheme.placeholderText: Color get() = if (isLight) LightPlaceholderText else DarkPlaceholderText @@ -345,9 +310,6 @@ val ColorScheme.placeholderText: Color val ColorScheme.nip05: Color get() = if (isLight) Nip05EmailColorLight else Nip05EmailColorDark -val ColorScheme.placeholderTextColorFilter: ColorFilter - get() = if (isLight) LightPlaceholderTextColorFilter else DarkPlaceholderTextColorFilter - val ColorScheme.onBackgroundColorFilter: ColorFilter get() = if (isLight) LightOnBackgroundColorFilter else DarkOnBackgroundColorFilter @@ -375,9 +337,6 @@ val ColorScheme.allGoodColor: Color val ColorScheme.markdownStyle: RichTextStyle get() = if (isLight) MarkDownStyleOnLight else MarkDownStyleOnDark -val ColorScheme.repostProfileBorder: Modifier - get() = if (isLight) RepostPictureBorderLight else RepostPictureBorderDark - val ColorScheme.imageModifier: Modifier get() = if (isLight) LightImageModifier else DarkImageModifier @@ -402,9 +361,6 @@ val ColorScheme.relayIconModifier: Modifier val ColorScheme.largeRelayIconModifier: Modifier get() = if (isLight) LightLargeRelayIconModifier else DarkLargeRelayIconModifier -val ColorScheme.bottomIconModifier: Modifier - get() = if (isLight) LightBottomIconModifier else DarkBottomIconModifier - val ColorScheme.chartStyle: ChartStyle get() { val defaultColors = if (isLight) DefaultColors.Light else DefaultColors.Dark