diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 58d0b1fe7f..226514a5f6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -199,6 +199,8 @@ internal class ChatViewModel @Inject constructor( val chatType: ChatType = ChatType.UNKNOWN, val chatInputState: TextFieldState = TextFieldState(), val typists: Set = emptySet(), + /** What the typing indicator draws ahead of its dots. See [typingAvatars]. */ + val typingAvatars: List = emptyList(), val resolveState: ResolveState = ResolveState.Pending, val sendProgress: LoadingSuccessState = LoadingSuccessState(), val isSelfTyping: Boolean = false, @@ -467,6 +469,7 @@ internal class ChatViewModel @Inject constructor( data object OnStopMessageInput: Event data object OnMessageInputConsumed: Event data class TypistsUpdated(val typists: Set) : Event + data class TypingAvatarsUpdated(val avatars: List) : Event data object ResolveCompleted : Event data object ResolveFailed : Event @@ -1445,6 +1448,27 @@ internal class ChatViewModel @Inject constructor( .onEach { typists -> dispatchEvent(Event.TypistsUpdated(typists)) } .launchIn(viewModelScope) + // Faces for the typing indicator, from the same profiles the transcript attributes + // messages with. A typist the roster doesn't cover is asked for, like an unknown sender, + // and draws the fallback until their profile lands. + combine( + stateFlow.map { it.typists }.distinctUntilChanged(), + stateFlow.map { it.chatType }.distinctUntilChanged(), + senderProfiles, + ::Triple, + ) + .onEach { (typists, _, profiles) -> + profiles ?: return@onEach + typists.filter { it.userId.hexEncodedString() !in profiles } + .forEach { chatCoordinator.requestSenderProfile(it.userId) } + } + .map { (typists, chatType, profiles) -> + typingAvatars(typists, chatType, profiles.orEmpty()) + } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.TypingAvatarsUpdated(it)) } + .launchIn(viewModelScope) + // A DM opens its composer once a payment has been exchanged. A group has no such // exchange to wait for: its own rules say who may post, and [GroupAccess] has already // applied them — a group that reaches the composer at all is [GroupAccess.Membered], and @@ -2225,6 +2249,7 @@ internal class ChatViewModel @Inject constructor( Event.OnStopMessageInput -> { state -> state } Event.OnMessageInputConsumed -> { state -> state.copy(messageInputRequested = false) } is Event.TypistsUpdated -> { state -> state.copy(typists = event.typists) } + is Event.TypingAvatarsUpdated -> { state -> state.copy(typingAvatars = event.avatars) } Event.ResolveCompleted -> { state -> state.copy(resolveState = ResolveState.Resolved) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/TypingAvatars.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/TypingAvatars.kt new file mode 100644 index 0000000000..5637ffbf07 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/TypingAvatars.kt @@ -0,0 +1,38 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatType +import com.flipcash.shared.chat.ActiveTypist +import com.getcode.opencode.model.core.ID +import com.getcode.utils.hexEncodedString + +/** + * One face in the typing indicator. [profile] is null until the member's profile resolves, and the + * avatar draws what the transcript draws for a sender with no picture and no name until then. + */ +internal data class TypingAvatar( + val userId: ID, + val profile: UserProfile?, +) { + /** Stable per user, and a String, so it can serve as the indicator's lazy item key. */ + val key: String get() = userId.hexEncodedString() +} + +/** + * What the typing indicator draws ahead of its dots: one entry per typist, oldest first, so the + * indicator's `takeLast` keeps the people who started most recently. + * + * Group chats only. A DM or tip chat already names its one counterparty in the title bar, so it + * keeps the dots on their own and this is empty. + */ +internal fun typingAvatars( + typists: Set, + chatType: ChatType, + profiles: Map, +): List { + if (chatType != ChatType.GROUP) return emptyList() + + return typists + .sortedWith(compareBy({ it.since }, { it.userId.hexEncodedString() })) + .map { typist -> TypingAvatar(typist.userId, profiles[typist.userId.hexEncodedString()]) } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index a976a9c66f..fe0b13faae 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal.screens.components import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition @@ -51,11 +52,13 @@ import com.flipcash.app.messenger.internal.requiresStaff import com.flipcash.shared.chat.models.ChatActionHandler import com.flipcash.app.messenger.internal.screens.ChatAnimations import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.features.messenger.R import com.getcode.theme.CodeTheme import com.flipcash.shared.chat.ui.ComposerReplyStrip import com.getcode.ui.components.chat.ChatInput import com.getcode.ui.components.chat.ChatInputSubmit +import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.ui.components.chat.TypingIndicator import com.getcode.ui.core.drawWithGradient import com.getcode.ui.core.measured @@ -125,14 +128,30 @@ internal fun UserControlBottomBar( ChatAnimations.typingIndicator, targetScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f) - ) + fadeOut(ChatAnimations.typingIndicator)) + ) + fadeOut(ChatAnimations.typingIndicator)) using + // The container shrinks to nothing as the indicator leaves; unclipped, the + // indicator fades out whole instead of being cut off by the shrinking bounds. + SizeTransform(clip = false) } ) { show -> if (show) { TypingIndicator( + typists = state.typingAvatars, + key = { it.key }, modifier = Modifier .hazeBlur(HazeInput.Sources(hazeState), material), - ) + ) { typist -> + // Drawn the way the transcript's sender gutter draws the same member, so the + // face beside the dots is the face beside their messages. A typist whose + // profile hasn't resolved goes through the same call with no picture and no + // name, rather than a separate unknown-person glyph. + val profile = typist.profile + ContactAvatar( + image = profile?.profilePicture, + displayName = profile?.displayName.orEmpty(), + access = BlobAccessContext.profile(typist.userId), + ) + } } } Box { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/TypingAvatarsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/TypingAvatarsTest.kt new file mode 100644 index 0000000000..a3d1274545 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/TypingAvatarsTest.kt @@ -0,0 +1,94 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatType +import com.flipcash.shared.chat.ActiveTypist +import com.getcode.opencode.model.core.ID +import com.getcode.utils.hexEncodedString +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The faces the typing indicator draws. Pinned: a group lists its typists oldest first so the + * indicator's `takeLast` keeps the newest, a DM or tip chat lists nobody, and a typist who stops + * drops out. + */ +class TypingAvatarsTest { + + private val ada: ID = List(16) { 1 } + private val grace: ID = List(16) { 2 } + private val linus: ID = List(16) { 3 } + + private val adaProfile = profile("Ada") + private val graceProfile = profile("Grace") + + private val profiles = mapOf( + ada.hexEncodedString() to adaProfile, + grace.hexEncodedString() to graceProfile, + ) + + private fun profile(name: String) = UserProfile( + displayName = name, + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + ) + + private fun typing(userId: ID, atMillis: Long) = + ActiveTypist(userId, Instant.fromEpochMilliseconds(atMillis)) + + @Test + fun `a group lists its typists oldest first`() { + val avatars = typingAvatars( + // Out of order on purpose: a set carries no order, so `since` has to supply it. + typists = setOf(typing(linus, 300), typing(ada, 100), typing(grace, 200)), + chatType = ChatType.GROUP, + profiles = profiles, + ) + + // Linus has no profile yet, and is still listed so the fallback can hold his place. + assertEquals( + listOf( + TypingAvatar(ada, adaProfile), + TypingAvatar(grace, graceProfile), + TypingAvatar(linus, null), + ), + avatars, + ) + } + + @Test + fun `a DM with a typist lists nobody`() { + val typists = setOf(typing(ada, 100)) + + assertTrue(typingAvatars(typists, ChatType.CONTACT_DM, profiles).isEmpty()) + assertTrue(typingAvatars(typists, ChatType.TIP_DM, profiles).isEmpty()) + } + + @Test + fun `a typist who stops typing drops out`() { + val both = setOf(typing(ada, 100), typing(grace, 200)) + assertEquals( + listOf(ada, grace), + typingAvatars(both, ChatType.GROUP, profiles).map { it.userId }, + ) + + val adaStopped = both.filterNot { it.userId == ada }.toSet() + assertEquals( + listOf(grace), + typingAvatars(adaStopped, ChatType.GROUP, profiles).map { it.userId }, + ) + } + + @Test + fun `a typist keeps their key when their profile resolves`() { + // The indicator keys its lazy items on this; a key that moved when the profile landed + // would animate the avatar out and back in mid-typing. + val before = TypingAvatar(ada, profile = null) + val after = TypingAvatar(ada, adaProfile) + + assertEquals(before.key, after.key) + } +} diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt index 2fee4b4565..287e342fb7 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt @@ -331,8 +331,9 @@ private fun BoxWithConstraintsScope.InitialsText(displayName: String) { .take(2) .mapNotNull { it.firstOrNull()?.uppercaseChar() } .joinToString("") - .ifEmpty { "?" } } + // A name with no letters leaves the gradient on its own, as iOS does, rather than a "?". + if (initials.isEmpty()) return val fontSize = with(LocalDensity.current) { (min(maxWidth, maxHeight) * 0.38f).toSp() } diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt index de46a11327..a311cb38d3 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt @@ -1,11 +1,13 @@ package com.getcode.ui.components.chat import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.animateContentSize +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.rememberInfiniteTransition @@ -14,31 +16,23 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Button -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Person +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -46,83 +40,180 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +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.ColorFilter import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp import androidx.compose.ui.zIndex import com.getcode.theme.CodeTheme import com.getcode.theme.DesignSystem -import com.getcode.ui.components.PriceWithFlagDefaults.Text -import com.getcode.ui.utils.IDPreviewParameterProvider import kotlinx.coroutines.delay -import kotlin.random.Random +import kotlinx.coroutines.launch +import kotlin.math.roundToInt +/** + * Dots in a bubble, led by an overlapping stack of the last [MaxAvatars] of [typists]. An empty + * [typists] draws the dots alone. + * + * The stack reads left to right: the oldest typist sits at the back on the left and the newest on + * top at the right. [avatar] draws one typist inside a circle the stack sizes, clips and animates; + * it is measured to fill that circle. [key] identifies a typist across updates, so an avatar slides + * to its new place as others join and leave rather than animating out and back in. + */ @Composable -fun TypingIndicator( +fun TypingIndicator( + typists: List, + key: (T) -> Any, modifier: Modifier = Modifier, - userImages: List = emptyList(), + avatar: @Composable (T) -> Unit, ) { Row( modifier = Modifier .padding(top = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (userImages.isNotEmpty()) { - AvatarRow( - modifier = Modifier.weight(1f, fill = false), - userImages = userImages, - ) - } + AvatarStack( + typists = typists, + key = key, + avatar = avatar, + ) TypingDots(modifier = modifier) } } +/** One avatar in the stack, kept after its typist leaves until it has animated out. */ +private class StackEntry( + val key: Any, + item: T, + slot: Float, + presence: Float, +) { + var item by mutableStateOf(item) + var leaving by mutableStateOf(false) + val slot = Animatable(slot) + val presence = Animatable(presence) +} + @Composable -private fun AvatarRow( - userImages: List, - modifier: Modifier = Modifier +private fun AvatarStack( + typists: List, + key: (T) -> Any, + avatar: @Composable (T) -> Unit, + modifier: Modifier = Modifier, ) { - val avatarSize = CodeTheme.dimens.staticGrid.x8 - val overlap = CodeTheme.dimens.staticGrid.x4 + val avatarSize = AvatarSize + val step = avatarSize - AvatarOverlap + val gap = CodeTheme.dimens.grid.x1 + val density = LocalDensity.current + val stepPx = with(density) { step.toPx() } + val ringPx = with(density) { AvatarRing.toPx() } + + val shown = typists.takeLast(MaxAvatars) + // Avatars on the first frame are part of the indicator's own entrance, so they start in place + // rather than growing out of it a second time. + val entries = remember { + mutableStateListOf>().apply { + shown.forEachIndexed { index, item -> add(StackEntry(key(item), item, index.toFloat(), 1f)) } + } + } + val shownKeys = shown.map(key) + val count by animateFloatAsState( + targetValue = shown.size.toFloat(), + animationSpec = AvatarMotion, + label = "avatarCount", + ) - LazyRow( + LaunchedEffect(shownKeys) { + shown.forEachIndexed { index, item -> + val itemKey = key(item) + val entry = entries.firstOrNull { it.key == itemKey && !it.leaving } + ?: StackEntry(itemKey, item, index.toFloat(), 0f).also { entries.add(it) } + entry.item = item + launch { entry.slot.animateTo(index.toFloat(), AvatarMotion) } + launch { entry.presence.animateTo(1f, AvatarMotion) } + } + entries.filter { !it.leaving && it.key !in shownKeys }.forEach { entry -> + entry.leaving = true + launch { + entry.presence.animateTo(0f, AvatarMotion) + entries.remove(entry) + } + } + } + + // Stacking order: later slots on top, and a leaving avatar beneath everything, so the ones + // that close the gap slide over it. + fun StackEntry.z(): Float = if (leaving) -1f else slot.value + + // Avatars grow from and shrink to their left edge. From AvatarEnterScale up, that keeps an + // arriving avatar's right edge inside the stack's animated width, clear of the bubble. + fun StackEntry.scale(): Float = lerp(AvatarEnterScale, 1f, presence.value) + + Box( modifier = modifier - .graphicsLayer { - compositingStrategy = CompositingStrategy.Offscreen - clip = false } - .padding(vertical = 4.dp) - .animateContentSize(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(-overlap, Alignment.Start), - contentPadding = PaddingValues(end = CodeTheme.dimens.grid.x2) + .layout { measurable, constraints -> + // n avatars are n steps plus one overlap wide; the gap to the bubble grows in with + // the first avatar, so the dots never jump. + val c = count + val width = (c * step.toPx() + (AvatarOverlap + gap).toPx() * c.coerceAtMost(1f)) + .roundToInt() + val placeable = measurable.measure(constraints.copy(minWidth = 0, maxWidth = Constraints.Infinity)) + layout(width, placeable.height) { placeable.place(0, 0) } + }, ) { - itemsIndexed( - items = userImages.takeLast(MaxAvatars), - key = { _, item -> item.hashCode() } - ) { index, image -> - UserAvatar( - modifier = Modifier - .size(avatarSize) - .zIndex(userImages.takeLast(MaxAvatars).count() - index.toFloat()) - .clip(CircleShape) - .animateItem( - fadeOutSpec = spring( - stiffness = Spring.StiffnessMediumLow, - visibilityThreshold = 0.5f - ) - ), - data = image - ) { - Image( - modifier = Modifier.padding(5.dp), - imageVector = Icons.Default.Person, - colorFilter = ColorFilter.tint(Color.White), - contentDescription = null - ) + entries.forEach { entry -> + key(entry.key) { + Box( + modifier = Modifier + .zIndex(entry.z()) + .offset { IntOffset((entry.slot.value * stepPx).roundToInt(), 0) } + .size(avatarSize) + .graphicsLayer { + val scale = entry.scale() + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0f, 0.5f) + alpha = entry.presence.value + // Its own buffer, so the rings cut below clear only this avatar. + compositingStrategy = CompositingStrategy.Offscreen + } + .drawWithContent { + drawContent() + // Cut a ring where each avatar above this one sits, so overlapping + // faces stay apart over any background. + // Worked in this layer's unscaled space, which is scaled about its left edge. + val myScale = entry.scale() + val myZ = entry.z() + val radius = size.minDimension / 2 + entries.forEach { other -> + if (other === entry || other.z() <= myZ) return@forEach + val otherScale = other.scale() + val otherCenterX = + (other.slot.value - entry.slot.value) * stepPx + radius * otherScale + drawCircle( + color = Color.Black.copy(alpha = other.presence.value), + radius = (radius * otherScale + ringPx) / myScale, + center = Offset(otherCenterX / myScale, center.y), + blendMode = BlendMode.DstOut, + ) + } + } + .clip(CircleShape), + propagateMinConstraints = true, + ) { + avatar(entry.item) + } } } } @@ -199,7 +290,16 @@ private fun TypingDots( } } -private const val MaxAvatars = 10 +private const val MaxAvatars = 3 +private const val AvatarEnterScale = 0.4f +// Matches the sender avatar beside a group's messages. +private val AvatarSize = 24.dp +private val AvatarOverlap = 8.dp +private val AvatarRing = 2.dp +private val AvatarMotion = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, +) private const val DotCount = 3 private val DotSize = 7.dp @@ -212,49 +312,54 @@ private const val DotFall = 300 // ms — time to fall back to base op private const val WaveBaseOpacity = 0.3f private const val WavePeakOpacity = 0.85f +/** + * Scripted joins and leaves: the first three typists arrive one by one, a fourth pushes the oldest + * off the back, one in the middle stops, then everyone stops. + */ @Composable @Preview fun PreviewTypingIndicator() { - val provider = IDPreviewParameterProvider(MaxAvatars + 2) - - var userCount by remember { mutableIntStateOf(0) } - - // Animation logic + val script = remember { + listOf( + listOf("A"), + listOf("A", "B"), + listOf("A", "B", "C"), + listOf("A", "B", "C", "D"), + listOf("B", "D"), + listOf("D"), + emptyList(), + ) + } + var step by remember { mutableIntStateOf(0) } LaunchedEffect(Unit) { while (true) { - // Ramp up from 0 to max - for (i in 0..MaxAvatars) { - userCount = i - delay(2000L) // 2-second delay - } - // Ramp down from max to 0 - for (i in MaxAvatars downTo 0) { - userCount = i - delay(2000L) // 2-second delay - } + delay(1600L) + step = (step + 1) % script.size } } + val users = script[step] - val users by remember(userCount) { derivedStateOf { provider.values.toList().take(userCount) } } DesignSystem { - Box(modifier = Modifier.size(400.dp), contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(width = 360.dp, height = 160.dp) + .background(CodeTheme.colors.background), + contentAlignment = Alignment.CenterStart, + ) { AnimatedContent( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), targetState = users.isNotEmpty(), transitionSpec = { - slideInVertically( - animationSpec = spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessLow - ) - ) { it } + scaleIn() + fadeIn() togetherWith fadeOut() + scaleOut() + slideOutVertically { it } - } + (scaleIn(initialScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f)) + fadeIn()) togetherWith + (scaleOut(targetScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f)) + fadeOut()) using + SizeTransform(clip = false) + }, ) { show -> if (show) { TypingIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.grid.x2), - userImages = users + typists = users, + key = { it }, + avatar = { PreviewAvatar(it) }, ) } } @@ -263,35 +368,12 @@ fun PreviewTypingIndicator() { } @Composable -@Preview -private fun TestAvatarRow() { - var imageList by remember { mutableStateOf(listOf("user1", "user2", "user3")) } - - DesignSystem { - Column { - AvatarRow( - userImages = imageList, - ) - Button(onClick = { - imageList = imageList + "user${imageList.size + 1}" - }) { - Text("Add Avatar") - } - - Button(onClick = { - imageList = imageList.drop(1) - }) { - Text("Remove Avatar") - } - } +private fun PreviewAvatar(name: String) { + val colors = listOf(Color(0xFFE0A458), Color(0xFF5B8DEF), Color(0xFF3FB68B), Color(0xFFD9637C)) + Box( + modifier = Modifier.background(colors[(name.first() - 'A') % colors.size]), + contentAlignment = Alignment.Center, + ) { + Text(text = name, color = Color.White, style = CodeTheme.typography.caption) } } - -private fun randomColor(): Color { - return Color( - red = Random.nextFloat(), - green = Random.nextFloat(), - blue = Random.nextFloat(), - alpha = 1f - ) -} \ No newline at end of file