Realigning reactions

This commit is contained in:
Vitor Pamplona 2024-06-21 12:51:16 -04:00
parent 97671c570c
commit 7a22b3df71
4 changed files with 241 additions and 151 deletions

View File

@ -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 <T> CrossfadeIfEnabled(
targetState: T,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
animationSpec: FiniteAnimationSpec<Float> = 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 <T> MyCrossfade(
targetState: T,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
animationSpec: FiniteAnimationSpec<Float> = tween(),
label: String = "Crossfade",
content: @Composable (T) -> Unit,
) {
val transition = updateTransition(targetState, label)
transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content)
}
@ExperimentalAnimationApi
@Composable
fun <T> Transition<T>.MyCrossfade(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
animationSpec: FiniteAnimationSpec<Float> = tween(),
contentKey: (targetState: T) -> Any? = { it },
content: @Composable (targetState: T) -> Unit,
) {
val currentlyVisible = remember { mutableStateListOf<T>().apply { add(currentState) } }
val contentMap =
remember {
mutableMapOf<T, @Composable () -> 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()
}
}
}
}

View File

@ -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()
}
}

View File

@ -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<String>,
toRemove: ImmutableSet<String>,
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<String>,
) {
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),
)
}
}
}

View File

@ -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