Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.flipcash.app.messenger.internal.screens

import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.ui.unit.IntSize

// All chat animation spring specs in one place.
internal object ChatAnimations {
// Message bubble insertion — scale from 0.95 + opacity.
// Matches iOS insertionSpring: .spring(duration: 0.23, bounce: 0.27).
val insertion: SpringSpec<Float> = spring(dampingRatio = 0.73f, stiffness = 746f)

// Typing indicator entry/exit — scale from 0.95 + opacity.
val typingIndicator: SpringSpec<Float> = spring(dampingRatio = 0.73f, stiffness = Spring.StiffnessHigh)

// Action bar <-> composer swap — scale from 0.95 + opacity.
val composerSwap: SpringSpec<Float> = spring(dampingRatio = 0.69f, stiffness = Spring.StiffnessHigh)

// Delivered label appearance — scale from 0.95 + opacity.
// Matches iOS deliveredSpring: .spring(duration: 0.4, bounce: 0.12).
val delivered: SpringSpec<Float> = spring(dampingRatio = 0.88f, stiffness = 250f)

// Delivered -> Read label swap — scale + opacity.
val readSwap: SpringSpec<Float> = spring(dampingRatio = 0.74f, stiffness = Spring.StiffnessHigh)

// Receipt label exit when a new message is sent — fade out + collapse.
private val deliveredIntSize: SpringSpec<IntSize> = spring(dampingRatio = 0.88f, stiffness = 250f)
val receiptExit: ExitTransition = shrinkVertically(deliveredIntSize) + fadeOut(delivered)
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package com.flipcash.app.messenger.internal.screens

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
Expand Down Expand Up @@ -49,7 +47,6 @@ import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.app.messenger.internal.screens.components.MessageList
import com.flipcash.features.messenger.R
import com.flipcash.shared.chat.ui.SeparatorConfig
import com.getcode.navigation.core.CodeNavigator
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.theme.CodeTheme
Expand Down Expand Up @@ -191,11 +188,10 @@ private fun UserControlBottomBar(
modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset),
targetState = state.typists.isNotEmpty(),
transitionSpec = {
val insertSpec = spring<Float>(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))
(scaleIn(ChatAnimations.typingIndicator, initialScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f))
+ fadeIn(ChatAnimations.typingIndicator)) togetherWith
(scaleOut(ChatAnimations.typingIndicator, targetScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f))
+ fadeOut(ChatAnimations.typingIndicator))
}
) { show ->
if (show) {
Expand Down Expand Up @@ -228,7 +224,7 @@ private fun UserControlBottomBar(
transitionSpec = {
// Action bar <-> composer swap
// Buttons: scale from 0.95 + opacity; Composer: opacity only
val swapSpec = spring<Float>(dampingRatio = 0.69f, stiffness = Spring.StiffnessHigh)
val swapSpec = ChatAnimations.composerSwap
when (targetState) {
ChatViewModel.UserState.Typing ->
// Composer fades in; buttons scale+fade out
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.flipcash.app.messenger.internal.screens.components

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import com.flipcash.app.messenger.internal.screens.ChatAnimations
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
Expand Down Expand Up @@ -139,22 +141,19 @@ internal fun MessageList(
val isOutgoing = (item as? ChatListItem.ContentBubble)?.isFromSelf ?: false

// Message insertion animation — scale from 0.95 + opacity with edge anchor.
// Tuned to match observed iOS timing (~500ms gradual fade-in).
// Only animate genuinely new messages (index 0 after initial load).
val shouldAnimate = index == 0 && hasLoaded && item.itemKey !in animatedKeys
if (shouldAnimate) animatedKeys.add(item.itemKey)
var appeared by remember(item.itemKey) { mutableStateOf(!shouldAnimate) }
LaunchedEffect(Unit) { if (!appeared) appeared = true }
val insertionAlphaSpec = spring<Float>(dampingRatio = 0.86f, stiffness = 80f)
val insertionScaleSpec = spring<Float>(dampingRatio = 0.73f, stiffness = 300f)
val insertionAlpha by animateFloatAsState(
targetValue = if (appeared) 1f else 0f,
animationSpec = insertionAlphaSpec,
animationSpec = ChatAnimations.insertion,
label = "insertAlpha",
)
val insertionScale by animateFloatAsState(
targetValue = if (appeared) 1f else 0.95f,
animationSpec = insertionScaleSpec,
animationSpec = ChatAnimations.insertion,
label = "insertScale",
)

Expand Down Expand Up @@ -206,12 +205,18 @@ internal fun MessageList(
}
val showReceipt =
shouldShowReceiptLabel(index, item, messages, otherReadPointer)
if (showReceipt && effectiveStatus != null) {
ReceiptLabel(
status = effectiveStatus,
readPointer = otherReadPointer,
animateEntrance = wasSending,
)
AnimatedVisibility(
visible = showReceipt && effectiveStatus != null,
enter = EnterTransition.None,
exit = ChatAnimations.receiptExit,
) {
if (effectiveStatus != null) {
ReceiptLabel(
status = effectiveStatus,
readPointer = otherReadPointer,
animateEntrance = wasSending,
)
}
}
}
}
Expand Down Expand Up @@ -286,20 +291,17 @@ 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
// Opt out of the list maintaining scroll position when adding
// elements before the first item. Only needed during initial load
// (to prevent starting at the ContactInfoContainer) and when
// scrolled back (to prevent shifting when pagination prepends).
// When at index 0, we do NOT call requestScrollToItem — doing so
// forces an instant reposition that causes a single-frame jitter
// when new messages are inserted. Instead, animateScrollToItem in
// the LaunchedEffect above handles smooth scrolling to new items.
Snapshot.withoutReadObservation {
if (!hasLoaded) {
// During initial load, always pin to index 0 (newest message)
// to prevent the list from starting at the ContactInfoContainer
listState.requestScrollToItem(0, 0)
} else if (listState.firstVisibleItemIndex == 0) {
listState.requestScrollToItem(
index = listState.firstVisibleItemIndex,
scrollOffset = listState.firstVisibleItemScrollOffset
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ 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 com.flipcash.app.messenger.internal.screens.ChatAnimations
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
Expand All @@ -25,7 +24,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
Expand Down Expand Up @@ -74,8 +72,7 @@ internal fun ReceiptLabel(
}
}

// Matches iOS deliveredSpring: .spring(duration: 0.4, bounce: 0.12)
val deliveredSpec = spring<Float>(dampingRatio = 0.88f, stiffness = 250f)
val deliveredSpec = ChatAnimations.delivered

Box(
modifier = modifier.padding(
Expand All @@ -94,7 +91,7 @@ internal fun ReceiptLabel(
exit = shrinkVertically(snap()) + fadeOut(snap()),
) {
// Delivered -> Read directional swap with scale
val readSwapSpec = spring<Float>(dampingRatio = 0.74f, stiffness = Spring.StiffnessHigh)
val readSwapSpec = ChatAnimations.readSwap
AnimatedContent(
targetState = status,
transitionSpec = {
Expand Down
Loading