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
1 change: 1 addition & 0 deletions apps/flipcash/features/messenger/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<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))
}
) { show ->
if (show) {
Expand Down Expand Up @@ -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<Float>(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 ->
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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<Dp>(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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Any?>(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),
Expand All @@ -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<Float>(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)
Expand Down Expand Up @@ -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)
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<Float>(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<Float>(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,
)
}
}

Expand Down
Loading
Loading