From c4dc19ef3618f0f545d570ff3a5db172f40a5f46 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 16 Jun 2026 12:51:56 -0400 Subject: [PATCH] chore(chat): align messenger animations with animation spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A1: Message bubble insertion — scale from 0.95 + opacity with edge anchor - A2: Corner radius morph — animated 12↔6dp transitions on grouping changes - A5: Action bar ↔ composer swap — scale+opacity instead of vertical slide - A6: Send button — scale from 0.6 with fast bouncy spring - A7: Delivered receipt — 700ms delayed pop with scale+fade, instant exit - A8: Delivered→Read swap — scale 0.9 + fade transition - A9: Typing dots — keyframe wave (1.3s period, staggered opacity, no scale) - A10: Typing indicator entry — scale from 0.95 anchored leading, no slide - A12: Haptic feedback on incoming messages - A3: Animated scroll-to-bottom on new messages Signed-off-by: Brandon McAnsh --- .../features/messenger/build.gradle.kts | 1 + .../internal/screens/MessengerScreen.kt | 29 ++++--- .../screens/components/MessageBubble.kt | 46 +++++------ .../screens/components/MessageList.kt | 80 +++++++++++++++--- .../screens/components/ReceiptLabel.kt | 72 ++++++++++++---- .../getcode/ui/components/chat/ChatInput.kt | 8 +- .../ui/components/chat/TypingIndicator.kt | 82 ++++++++++--------- 7 files changed, 214 insertions(+), 104 deletions(-) diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 278004a4c..c1839c4e8 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:tokens")) + implementation(project(":libs:vibrator:bindings")) implementation(project(":libs:messaging")) implementation(project(":services:flipcash")) implementation(project(":services:opencode")) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index bd8a7660a..b8b665a07 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -8,9 +8,8 @@ import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.animation.shrinkHorizontally -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -43,6 +42,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource @@ -179,17 +179,17 @@ private fun UserControlBottomBar( modifier = Modifier .fillMaxWidth(), ) { + // Typing indicator entry/exit — scale from 0.95 anchored leading + opacity + // (same as message bubble insertion, no vertical slide) AnimatedContent( modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), targetState = state.typists.isNotEmpty(), transitionSpec = { - slideInVertically( - animationSpec = spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessLow - ) - ) { it } + scaleIn() + fadeIn() togetherWith - fadeOut() + slideOutVertically { it } + val insertSpec = spring(dampingRatio = 0.73f, stiffness = Spring.StiffnessHigh) + (scaleIn(insertSpec, initialScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f)) + + fadeIn(insertSpec)) togetherWith + (scaleOut(insertSpec, targetScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f)) + + fadeOut(insertSpec)) } ) { show -> if (show) { @@ -220,12 +220,19 @@ private fun UserControlBottomBar( .navigationBarsPadding(), targetState = state.userState, transitionSpec = { + // Action bar <-> composer swap + // Buttons: scale from 0.95 + opacity; Composer: opacity only + val swapSpec = spring(dampingRatio = 0.69f, stiffness = Spring.StiffnessHigh) when (targetState) { ChatViewModel.UserState.Typing -> - slideInVertically { it } + fadeIn() togetherWith fadeOut() + // Composer fades in; buttons scale+fade out + fadeIn(swapSpec) togetherWith + (scaleOut(swapSpec, targetScale = 0.95f) + fadeOut(swapSpec)) ChatViewModel.UserState.Reading -> - fadeIn() togetherWith slideOutVertically { it } + fadeOut() + // Buttons scale+fade in; composer fades out + (scaleIn(swapSpec, initialScale = 0.95f) + fadeIn(swapSpec)) togetherWith + fadeOut(swapSpec) } }, ) { s -> diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageBubble.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageBubble.kt index 624cfd672..89790f08c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageBubble.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageBubble.kt @@ -1,5 +1,7 @@ package com.flipcash.app.messenger.internal.screens.components +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -16,6 +18,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -35,7 +38,6 @@ import com.getcode.opencode.compose.ExchangeStub import com.getcode.opencode.compose.LocalExchange import com.getcode.opencode.model.financial.Fiat import com.getcode.theme.CodeTheme -import com.getcode.theme.extraSmall import com.getcode.ui.components.PriceWithFlag import com.getcode.ui.core.addIf @@ -185,33 +187,31 @@ private fun Bubble( @Composable private fun bubbleShape(position: BubblePosition, isFromSelf: Boolean): Shape { - val l = CodeTheme.shapes.medium.topStart - val s = CodeTheme.shapes.extraSmall.topStart - // RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart) - return when (position) { - BubblePosition.Solo -> RoundedCornerShape(l) - BubblePosition.First -> if (isFromSelf) { - // Top of group, outgoing: bottom-end connects to next below - RoundedCornerShape(topStart = l, topEnd = l, bottomEnd = s, bottomStart = l) - } else { - RoundedCornerShape(topStart = l, topEnd = l, bottomEnd = l, bottomStart = s) - } + // CodeTheme.shapes.medium = 12dp, extraSmall = 6dp + val l = 12.dp + val s = 6.dp - BubblePosition.Middle -> if (isFromSelf) { - RoundedCornerShape(topStart = l, topEnd = s, bottomEnd = s, bottomStart = l) - } else { - RoundedCornerShape(topStart = s, topEnd = l, bottomEnd = l, bottomStart = s) - } + // Corner radius morph — animate each corner with spring matching prototype + val cornerSpec = spring(dampingRatio = 0.68f, stiffness = 500f) - BubblePosition.Last -> if (isFromSelf) { - // Bottom of group, outgoing: top-end connects to item above - RoundedCornerShape(topStart = l, topEnd = s, bottomEnd = l, bottomStart = l) - } else { - RoundedCornerShape(topStart = s, topEnd = l, bottomEnd = l, bottomStart = l) - } + // Target corners: (topStart, topEnd, bottomEnd, bottomStart) + val targets = when (position) { + BubblePosition.Solo -> BubbleCorners(l, l, l, l) + BubblePosition.First -> if (isFromSelf) BubbleCorners(l, l, s, l) else BubbleCorners(l, l, l, s) + BubblePosition.Middle -> if (isFromSelf) BubbleCorners(l, s, s, l) else BubbleCorners(s, l, l, s) + BubblePosition.Last -> if (isFromSelf) BubbleCorners(l, s, l, l) else BubbleCorners(s, l, l, l) } + + val topStart by animateDpAsState(targets.topStart, cornerSpec, label = "cTS") + val topEnd by animateDpAsState(targets.topEnd, cornerSpec, label = "cTE") + val bottomEnd by animateDpAsState(targets.bottomEnd, cornerSpec, label = "cBE") + val bottomStart by animateDpAsState(targets.bottomStart, cornerSpec, label = "cBS") + + return RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart) } +private data class BubbleCorners(val topStart: Dp, val topEnd: Dp, val bottomEnd: Dp, val bottomStart: Dp) + internal fun bubblePositionOf( index: Int, item: ChatListItem.ContentBubble, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 317f0fa01..47fb6bc06 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -1,6 +1,8 @@ package com.flipcash.app.messenger.internal.screens.components +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -18,14 +20,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.paging.compose.LazyPagingItems @@ -37,6 +41,7 @@ import com.flipcash.services.models.chat.MessagePointer import com.getcode.theme.CodeTheme import com.getcode.ui.utils.sheetResignmentBehavior import com.getcode.util.toLocalDate +import com.getcode.util.vibration.LocalVibrator import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull @@ -102,11 +107,31 @@ internal fun MessageList( onAdvanceReadPointer: ((Long) -> Unit)? = null, ) { val listState = rememberLazyListState() + val vibrator = LocalVibrator.current if (onAdvanceReadPointer != null) { HandleMessageReads(listState, messages, onAdvanceReadPointer) } + // Haptic feedback when a new incoming message arrives + // Track the newest message key — only fire when it changes to an incoming message + var lastNewestKey by remember { mutableStateOf(null) } + LaunchedEffect(messages) { + snapshotFlow { + if (messages.itemCount == 0) null else messages.peek(0) + } + .filterNotNull() + .mapNotNull { it as? ChatListItem.ContentBubble } + .distinctUntilChanged { old, new -> old.itemKey == new.itemKey } + .collectLatest { newest -> + val prevKey = lastNewestKey + lastNewestKey = newest.itemKey + if (prevKey != null && !newest.isFromSelf) { + vibrator.tick() + } + } + } + LazyColumn( modifier = modifier .sheetResignmentBehavior(listState), @@ -127,10 +152,36 @@ internal fun MessageList( val item = messages[index] ?: return@items val bottomSpacing = bottomSpacingFor(index, item, messages, separatorConfig) + // Message insertion animation — scale from 0.95 + opacity with edge anchor + var appeared by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { appeared = true } + val insertionSpec = spring(dampingRatio = 0.73f, stiffness = Spring.StiffnessHigh) + val insertionAlpha by animateFloatAsState( + targetValue = if (appeared) 1f else 0f, + animationSpec = insertionSpec, + label = "insertAlpha", + ) + val insertionScale by animateFloatAsState( + targetValue = if (appeared) 1f else 0.95f, + animationSpec = insertionSpec, + label = "insertScale", + ) + val isOutgoing = (item as? ChatListItem.ContentBubble)?.isFromSelf ?: false + Box( modifier = Modifier .padding(bottom = bottomSpacing) - .animateItem(placementSpec = null), + .animateItem(placementSpec = null) + .graphicsLayer { + alpha = insertionAlpha + scaleX = insertionScale + scaleY = insertionScale + transformOrigin = if (isOutgoing) { + TransformOrigin(1f, 0.5f) // anchor trailing + } else { + TransformOrigin(0f, 0.5f) // anchor leading + } + }, ) { when (item) { is ChatListItem.DateSeparator -> DateSeparatorRow(item.timestamp) @@ -169,17 +220,22 @@ internal fun MessageList( } } - // opts out of the list maintaining - // scroll position when adding elements before the first item - // we are checking first visible item index to ensure - // the list doesn't shift when viewing scroll back - Snapshot.withoutReadObservation { - if (listState.firstVisibleItemIndex == 0) { - listState.requestScrollToItem( - index = listState.firstVisibleItemIndex, - scrollOffset = listState.firstVisibleItemScrollOffset - ) + // Scroll to bottom when the newest message changes (sent or received) + LaunchedEffect(listState, messages) { + snapshotFlow { + if (messages.itemCount == 0) null + else (messages.peek(0) as? ChatListItem.ContentBubble)?.itemKey } + .filterNotNull() + .distinctUntilChanged() + .collectLatest { + // Always scroll for own messages; only near-bottom for incoming + val nearBottom = listState.firstVisibleItemIndex <= 3 + val newest = messages.peek(0) as? ChatListItem.ContentBubble + if (newest?.isFromSelf == true || nearBottom) { + listState.animateScrollToItem(0) + } + } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt index 5e3a1298c..69a83d186 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt @@ -2,18 +2,30 @@ package com.flipcash.app.messenger.internal.screens.components import android.text.format.DateFormat import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper +import kotlinx.coroutines.delay import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.MessagePointer @@ -24,6 +36,7 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Instant @Composable @@ -32,25 +45,52 @@ internal fun ReceiptLabel( readPointer: MessagePointer?, modifier: Modifier = Modifier, ) { - AnimatedContent( - targetState = status, + // Delayed pop for "Delivered" — wait 700ms before showing, instant removal + var visible by remember { mutableStateOf(false) } + LaunchedEffect(status) { + if (status == ReceiptStatus.SENT) { + visible = false + delay(700.milliseconds) + visible = true + } else { + visible = true + } + } + + val deliveredSpec = spring(dampingRatio = 0.88f, stiffness = 600f) + + AnimatedVisibility( + visible = visible, modifier = modifier.padding(top = CodeTheme.dimens.grid.x1), - transitionSpec = { fadeIn() togetherWith fadeOut() }, - label = "receiptStatus", - ) { animatedStatus -> - val text = when (animatedStatus) { - ReceiptStatus.SENT -> stringResource(R.string.label_chatReceipt_delivered) - ReceiptStatus.READ -> { - val readAtFormatted = readPointer?.timestamp?.let { formatReadTimestamp(it) } ?: "" - stringResource(R.string.label_chatReceipt_read, readAtFormatted) + enter = scaleIn(deliveredSpec, initialScale = 0.95f) + fadeIn(deliveredSpec), + exit = fadeOut(snap()), + ) { + // Delivered -> Read directional swap with scale + val readSwapSpec = spring(dampingRatio = 0.74f, stiffness = Spring.StiffnessHigh) + AnimatedContent( + targetState = status, + transitionSpec = { + (scaleIn(readSwapSpec, initialScale = 0.9f) + fadeIn(readSwapSpec)) togetherWith + (scaleOut(readSwapSpec, targetScale = 0.9f) + fadeOut(readSwapSpec)) + }, + label = "receiptStatus", + ) { animatedStatus -> + val text = when (animatedStatus) { + ReceiptStatus.SENT -> stringResource(R.string.label_chatReceipt_delivered) + ReceiptStatus.READ -> { + val readAtFormatted = + readPointer?.timestamp?.let { formatReadTimestamp(it) } ?: "" + stringResource(R.string.label_chatReceipt_read, readAtFormatted) + } + + else -> return@AnimatedContent } - else -> return@AnimatedContent + Text( + text = text, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) } - Text( - text = text, - style = CodeTheme.typography.caption, - color = CodeTheme.colors.textSecondary, - ) } } diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt index fcc54b9b0..2ef5ad43a 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt @@ -4,6 +4,8 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.spring import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -21,7 +23,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.material.Icon +import androidx.compose.material3.Icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowUpward import androidx.compose.runtime.Composable @@ -93,7 +95,9 @@ fun ChatInput( targetState = state.text.isNotEmpty(), label = "show/hide send button", transitionSpec = { - fadeIn(spring()) togetherWith fadeOut(spring()) + val spec = spring(dampingRatio = 0.66f, stiffness = 4000f) + (fadeIn(spec) + scaleIn(spec, initialScale = 0.6f)) togetherWith + (fadeOut(spec) + scaleOut(spec, targetScale = 0.6f)) } ) { show -> if (show) { 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 ffa076652..de46a1132 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 @@ -2,10 +2,14 @@ package com.getcode.ui.components.chat import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn @@ -154,52 +158,41 @@ private fun TypingDots( verticalAlignment = Alignment.CenterVertically ) { val baseColor = CodeTheme.colors.chat.typingIndicator.dots - var currentIndex by remember { mutableIntStateOf(0) } - val animatedAlphas = List(DotCount) { index -> - val targetAlpha = if (index == currentIndex) 1f else 0.4f - animateFloatAsState( - targetValue = targetAlpha, - animationSpec = tween(durationMillis = StepDuration), - label = "dotAlpha_$index" - ) - } + val easeIn = CubicBezierEasing(0.42f, 0f, 1f, 1f) + val easeOut = CubicBezierEasing(0f, 0f, 0.58f, 1f) - val animatedScales = List(DotCount) { index -> - val targetScale = if (index == currentIndex) 1f else 0.6f - animateFloatAsState( - targetValue = targetScale, - animationSpec = tween(durationMillis = StepDuration), - label = "dotScale_$index" - ) - } + val infiniteTransition = rememberInfiniteTransition(label = "typingWave") - LaunchedEffect(Unit) { - while (true) { - delay(StepDuration.toLong()) - currentIndex = when (currentIndex) { - DotCount -> 0 - DotCount - 1 -> DotCount - else -> currentIndex + 1 - } + val dotOpacities = List(DotCount) { index -> + val delayMs = WaveLeadIn + index * WaveStagger + val riseEnd = delayMs + DotRise + val fallEnd = riseEnd + DotFall - // Add extra delay when resetting - if (currentIndex == DotCount) { - delay(StepDuration.toLong()) - } - } + infiniteTransition.animateFloat( + initialValue = WaveBaseOpacity, + targetValue = WaveBaseOpacity, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = WavePeriod + WaveBaseOpacity at 0 using easeOut + WaveBaseOpacity at delayMs using easeIn + WavePeakOpacity at riseEnd using easeOut + WaveBaseOpacity at fallEnd using easeOut + WaveBaseOpacity at WavePeriod using easeOut + }, + repeatMode = RepeatMode.Restart, + ), + label = "dotOpacity_$index" + ) } - animatedAlphas.zip(animatedScales).forEach { (alpha, scale) -> + dotOpacities.forEach { opacity -> Box( modifier = Modifier - .size(CodeTheme.dimens.grid.x2) - .graphicsLayer { - scaleX = scale.value - scaleY = scale.value - } + .size(DotSize) .drawBehind { - drawCircle(color = baseColor.copy(alpha = alpha.value)) + drawCircle(color = baseColor.copy(alpha = opacity.value)) } ) } @@ -208,7 +201,16 @@ private fun TypingDots( private const val MaxAvatars = 10 private const val DotCount = 3 -private const val StepDuration = 500 +private val DotSize = 7.dp + +// Wave animation parameters +private const val WavePeriod = 1300 // ms — full cycle length +private const val WaveLeadIn = 200 // ms — silence before first dot rises +private const val WaveStagger = 160 // ms — delay between successive dots +private const val DotRise = 200 // ms — time to rise to peak opacity +private const val DotFall = 300 // ms — time to fall back to base opacity +private const val WaveBaseOpacity = 0.3f +private const val WavePeakOpacity = 0.85f @Composable @Preview