diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index 503869268..16d352c07 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -800,6 +800,7 @@
Swipe to Send
Send Cash
+ Send %1$s
Send Message
This user unlinked their phone number and this conversation is no longer available
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 56d69793e..c7db5504e 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
@@ -105,16 +105,10 @@ internal class ChatViewModel @Inject constructor(
data object Failed : ResolveState
}
- sealed interface UserState {
- data object Reading: UserState
- data object Typing: UserState
- }
-
data class State(
val separatorConfig: SeparatorConfig= SeparatorConfig.Continuous(),
val chatId: ChatId? = null,
val chattingWith: DeviceContact? = null,
- val userState: UserState = UserState.Reading,
val chatInputState: TextFieldState = TextFieldState(),
val typists: Set = emptySet(),
val resolveState: ResolveState = ResolveState.Pending,
@@ -124,11 +118,13 @@ internal class ChatViewModel @Inject constructor(
val token: Token? = null,
val limits: Limits? = null,
val isAnonymous: Boolean = false,
+ val cashSymbol: String = "$",
)
sealed interface Event {
data class OnChatOpened(val identifier: ChatIdentifier) : Event
data class OnContactFound(val contact: DeviceContact): Event
+ data class OnCurrencySymbolUpdated(val symbol: String): Event
data object RefreshContact : Event
data class ChatFound(val chatId: ChatId) : Event
data object OnSendCash: Event
@@ -381,6 +377,7 @@ internal class ChatViewModel @Inject constructor(
val currency = exchange.getCurrency(rate.currency.name)
if (currency != null) {
amountDelegate.onCurrencyChanged(currency)
+ dispatchEvent(Event.OnCurrencySymbolUpdated(currency.symbol))
}
}.launchIn(viewModelScope)
@@ -755,11 +752,12 @@ internal class ChatViewModel @Inject constructor(
chattingWith = event.contact
)
}
+ is Event.OnCurrencySymbolUpdated -> { state -> state.copy(cashSymbol = event.symbol) }
is Event.RefreshContact -> { state -> state }
is Event.ChatFound -> { state -> state.copy(chatId = event.chatId) }
Event.OnSendCash -> { state -> state }
- Event.OnStartMessageInput -> { state -> state.copy(userState = UserState.Typing) }
- Event.OnStopMessageInput -> { state -> state.copy(userState = UserState.Reading) }
+ Event.OnStartMessageInput -> { state -> state }
+ Event.OnStopMessageInput -> { state -> state }
is Event.TypistsUpdated -> { state -> state.copy(typists = event.typists) }
is Event.ResolveCompleted -> { state ->
state.copy(resolveState = ResolveState.Resolved(event.authority))
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 b33aa5755..34324673a 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
@@ -1,70 +1,23 @@
package com.flipcash.app.messenger.internal.screens
-import androidx.compose.animation.AnimatedContent
-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.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.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
-import androidx.compose.foundation.layout.navigationBarsPadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.requiredSize
-import androidx.compose.foundation.shape.CircleShape
-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.Alignment
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.Color
-import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.collectAsLazyPagingItems
-import com.flipcash.app.contacts.ui.ContactAvatar
import com.flipcash.app.core.AppRoute
-import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.app.messenger.internal.ChatViewModel
+import com.flipcash.app.messenger.internal.screens.components.ChatTopBar
import com.flipcash.app.messenger.internal.screens.components.MessageList
+import com.flipcash.app.messenger.internal.screens.components.UserControlBottomBar
import com.flipcash.shared.chat.ui.ChatAction
-import com.flipcash.features.messenger.R
-import com.getcode.navigation.core.CodeNavigator
import com.getcode.navigation.core.LocalCodeNavigator
-import com.getcode.theme.CodeTheme
-import com.getcode.ui.components.AppBarDefaults
-import com.getcode.ui.components.AppBarWithTitle
-import com.getcode.ui.components.chat.ChatInput
-import com.getcode.ui.components.chat.TypingIndicator
-import com.getcode.ui.core.drawWithGradient
-import com.getcode.ui.core.measured
-import com.getcode.ui.theme.ButtonState
-import com.getcode.ui.theme.CodeButton
import com.getcode.ui.utils.rememberKeyboardController
-import dev.chrisbanes.haze.HazeState
-import dev.chrisbanes.haze.blur.blurEffect
-import dev.chrisbanes.haze.blur.materials.HazeMaterials
-import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
@@ -81,15 +34,11 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
ChatInputScaffold(
topBar = { ChatTopBar(navigator, state.chattingWith) },
bottomBar = {
- if (state.isAnonymous) {
- DeactivatedChatBottomBar()
- } else {
- UserControlBottomBar(
- state = state,
- hazeState = hazeState,
- dispatch = viewModel::dispatchEvent,
- )
- }
+ UserControlBottomBar(
+ state = state,
+ hazeState = hazeState,
+ dispatch = viewModel::dispatchEvent,
+ )
},
) { overlapPadding ->
MessageList(
@@ -107,16 +56,22 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
is ChatAction.AdvanceReadPointer -> {
viewModel.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(action.messageId))
}
+
ChatAction.RefreshContact -> {
viewModel.dispatchEvent(ChatViewModel.Event.RefreshContact)
}
+
is ChatAction.RetryMessage -> {
keyboard.hideIfVisible {
viewModel.dispatchEvent(
- ChatViewModel.Event.RetryMessage(action.bubble.pendingClientIdHex, action.bubble.content)
+ ChatViewModel.Event.RetryMessage(
+ action.bubble.pendingClientIdHex,
+ action.bubble.content
+ )
)
}
}
+
is ChatAction.ViewToken -> {
keyboard.hideIfVisible {
viewModel.dispatchEvent(
@@ -130,217 +85,6 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
}
}
-@Composable
-private fun ChatTopBar(
- navigator: CodeNavigator,
- chattingWith: DeviceContact?,
-) {
- var titleHeight by remember { mutableStateOf(0.dp) }
- val bgColor = CodeTheme.colors.background
- Box {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(titleHeight + 24.dp)
- .background(
- Brush.verticalGradient(
- colorStops = arrayOf(
- 0f to bgColor,
- 1f to Color.Transparent,
- )
- )
- )
- )
- AppBarWithTitle(
- modifier = Modifier.measured { titleHeight = it.height },
- leftIcon = {
- AppBarDefaults.UpNavigation { navigator.pop() }
- },
- title = {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
- ) {
- ContactAvatar(
- contact = chattingWith,
- modifier = Modifier
- .requiredSize(CodeTheme.dimens.staticGrid.x8)
- .clip(CircleShape),
- )
-
- Text(
- modifier = Modifier.weight(1f),
- text = chattingWith?.displayName.orEmpty(),
- style = CodeTheme.typography.textMedium,
- color = CodeTheme.colors.textMain,
- )
- }
- }
- )
- }
-}
-
-@Composable
-private fun UserControlBottomBar(
- state: ChatViewModel.State,
- hazeState: HazeState,
- dispatch: (ChatViewModel.Event) -> Unit,
-) {
- val keyboard = rememberKeyboardController()
- val focusRequester = remember { FocusRequester() }
- var buttonHeight by remember { mutableStateOf(0.dp) }
- val material = HazeMaterials.ultraThin(
- containerColor = CodeTheme.colors.background
- )
-
- LaunchedEffect(keyboard.visible) {
- if (!keyboard.visible) {
- dispatch(ChatViewModel.Event.OnStopMessageInput)
- }
- }
-
- Column(
- 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 = {
- (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) {
- TypingIndicator(
- modifier = Modifier
- .hazeEffect(hazeState) {
- blurEffect { style = material }
- },
- )
- }
- }
- Box {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(buttonHeight)
- .align(Alignment.BottomCenter)
- .drawWithGradient(
- color = CodeTheme.colors.background,
- startY = { 0f },
- ),
- )
- AnimatedContent(
- modifier = Modifier
- .measured { buttonHeight = it.height }
- .padding(horizontal = CodeTheme.dimens.inset)
- .padding(vertical = CodeTheme.dimens.grid.x3)
- .navigationBarsPadding(),
- targetState = state.userState,
- transitionSpec = {
- // Action bar <-> composer swap
- // Buttons: scale from 0.95 + opacity; Composer: opacity only
- val swapSpec = ChatAnimations.composerSwap
- when (targetState) {
- ChatViewModel.UserState.Typing ->
- // Composer fades in; buttons scale+fade out
- fadeIn(swapSpec) togetherWith
- (scaleOut(swapSpec, targetScale = 0.95f) + fadeOut(swapSpec))
-
- ChatViewModel.UserState.Reading ->
- // Buttons scale+fade in; composer fades out
- (scaleIn(swapSpec, initialScale = 0.95f) + fadeIn(swapSpec)) togetherWith
- fadeOut(swapSpec)
- }
- },
- ) { s ->
- when (s) {
- ChatViewModel.UserState.Reading -> {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
- ) {
- CodeButton(
- modifier = Modifier.weight(1f),
- buttonState = ButtonState.Filled,
- text = stringResource(R.string.action_sendCash),
- ) { dispatch(ChatViewModel.Event.OnSendCash) }
- if (state.typingConstraints.enabled) {
- CodeButton(
- modifier = Modifier
- .weight(1f)
- .testTag("chat_send_message_button")
- .hazeEffect(hazeState) {
- blurEffect {
- style = material
- }
- },
- buttonState = ButtonState.Filled10,
- text = stringResource(R.string.action_sendMessage),
- ) { dispatch(ChatViewModel.Event.OnStartMessageInput) }
- }
- }
- }
-
- ChatViewModel.UserState.Typing -> {
- ChatInput(
- modifier = Modifier
- .testTag("chat_message_input")
- .fillMaxWidth()
- .border(
- CodeTheme.dimens.border,
- CodeTheme.colors.divider,
- CodeTheme.shapes.medium,
- )
- .hazeEffect(hazeState) {
- blurEffect {
- style = material
- }
- },
- focusRequester = focusRequester,
- hint = "Message",
- state = state.chatInputState,
- onSendMessage = {
- dispatch(ChatViewModel.Event.SendMessage)
- keyboard.restartInput()
- },
- )
-
- LaunchedEffect(Unit) {
- focusRequester.requestFocus()
- }
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun DeactivatedChatBottomBar() {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .navigationBarsPadding()
- .padding(horizontal = CodeTheme.dimens.inset)
- .padding(vertical = CodeTheme.dimens.grid.x3),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- text = stringResource(R.string.subtitle_chatNoLongerAvailable),
- style = CodeTheme.typography.textSmall,
- color = CodeTheme.colors.textSecondary,
- textAlign = TextAlign.Center,
- )
- }
-}
-
@Composable
private fun ChatInputScaffold(
topBar: @Composable () -> Unit = {},
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
new file mode 100644
index 000000000..b645d721b
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt
@@ -0,0 +1,179 @@
+package com.flipcash.app.messenger.internal.screens.components
+
+import androidx.compose.animation.AnimatedContent
+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.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.flipcash.app.messenger.internal.ChatViewModel
+import com.flipcash.app.messenger.internal.screens.ChatAnimations
+import com.flipcash.features.messenger.R
+import com.getcode.theme.CodeTheme
+import com.getcode.ui.components.chat.ChatInput
+import com.getcode.ui.components.chat.TypingIndicator
+import com.getcode.ui.core.drawWithGradient
+import com.getcode.ui.core.measured
+import com.getcode.ui.utils.rememberKeyboardController
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.blur.blurEffect
+import dev.chrisbanes.haze.blur.materials.HazeMaterials
+import dev.chrisbanes.haze.hazeEffect
+
+@Composable
+internal fun UserControlBottomBar(
+ state: ChatViewModel.State,
+ hazeState: HazeState,
+ dispatch: (ChatViewModel.Event) -> Unit,
+) {
+ if (state.isAnonymous) {
+ DeactivatedChatBottomBar()
+ return
+ }
+
+ val keyboard = rememberKeyboardController()
+ val focusRequester = remember { FocusRequester() }
+ var buttonHeight by remember { mutableStateOf(0.dp) }
+ val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background)
+
+ LaunchedEffect(keyboard.visible) {
+ if (!keyboard.visible) {
+ dispatch(ChatViewModel.Event.OnStopMessageInput)
+ }
+ }
+
+ Column(
+ 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 = {
+ (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) {
+ TypingIndicator(
+ modifier = Modifier
+ .hazeEffect(hazeState) {
+ blurEffect { style = material }
+ },
+ )
+ }
+ }
+ Box {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(buttonHeight)
+ .align(Alignment.BottomCenter)
+ .drawWithGradient(
+ color = CodeTheme.colors.background,
+ startY = { 0f },
+ ),
+ )
+ AnimatedContent(
+ modifier = Modifier
+ .measured { buttonHeight = it.height }
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(vertical = CodeTheme.dimens.grid.x3)
+ .navigationBarsPadding(),
+ targetState = state.typingConstraints.enabled,
+ ) { canType ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ SendCashButton(
+ state = state,
+ hazeState = hazeState,
+ hazeMaterial = material,
+ onClick = { dispatch(ChatViewModel.Event.OnSendCash) }
+ )
+
+ if (canType) {
+ ChatInput(
+ modifier = Modifier
+ .testTag("chat_message_input")
+ .weight(1f)
+ .border(
+ CodeTheme.dimens.border,
+ CodeTheme.colors.divider,
+ CodeTheme.shapes.medium,
+ )
+ .hazeEffect(hazeState) {
+ blurEffect {
+ style = material
+ }
+ },
+ focusRequester = focusRequester,
+ hint = "Message",
+ state = state.chatInputState,
+ onSendMessage = {
+ dispatch(ChatViewModel.Event.SendMessage)
+ keyboard.restartInput()
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DeactivatedChatBottomBar() {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(vertical = CodeTheme.dimens.grid.x3),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = stringResource(R.string.subtitle_chatNoLongerAvailable),
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textSecondary,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
\ No newline at end of file
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt
new file mode 100644
index 000000000..c95919bbb
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt
@@ -0,0 +1,80 @@
+package com.flipcash.app.messenger.internal.screens.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+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.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.flipcash.app.contacts.ui.ContactAvatar
+import com.flipcash.app.core.contacts.DeviceContact
+import com.getcode.navigation.core.CodeNavigator
+import com.getcode.theme.CodeTheme
+import com.getcode.ui.components.AppBarDefaults
+import com.getcode.ui.components.AppBarWithTitle
+import com.getcode.ui.core.measured
+
+@Composable
+internal fun ChatTopBar(
+ navigator: CodeNavigator,
+ chattingWith: DeviceContact?,
+) {
+ var titleHeight by remember { mutableStateOf(0.dp) }
+ val bgColor = CodeTheme.colors.background
+ Box {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(titleHeight + 24.dp)
+ .background(
+ Brush.verticalGradient(
+ colorStops = arrayOf(
+ 0f to bgColor,
+ 1f to Color.Transparent,
+ )
+ )
+ )
+ )
+ AppBarWithTitle(
+ modifier = Modifier.measured { titleHeight = it.height },
+ leftIcon = {
+ AppBarDefaults.UpNavigation { navigator.pop() }
+ },
+ title = {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
+ ) {
+ ContactAvatar(
+ contact = chattingWith,
+ modifier = Modifier
+ .requiredSize(CodeTheme.dimens.staticGrid.x8)
+ .clip(CircleShape),
+ )
+
+ Text(
+ modifier = Modifier.weight(1f),
+ text = chattingWith?.displayName.orEmpty(),
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textMain,
+ )
+ }
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt
new file mode 100644
index 000000000..095c30755
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt
@@ -0,0 +1,127 @@
+package com.flipcash.app.messenger.internal.screens.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandHorizontally
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkHorizontally
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import com.flipcash.app.messenger.internal.ChatViewModel
+import com.flipcash.features.messenger.R
+import com.getcode.theme.CodeTheme
+import com.getcode.ui.core.addIf
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.blur.HazeBlurStyle
+import dev.chrisbanes.haze.blur.blurEffect
+import dev.chrisbanes.haze.hazeEffect
+
+@Composable
+internal fun RowScope.SendCashButton(
+ state: ChatViewModel.State,
+ hazeState: HazeState,
+ hazeMaterial: HazeBlurStyle,
+ onClick: () -> Unit,
+) {
+ val isTyping = state.chatInputState.text.isNotEmpty()
+ val canType = state.typingConstraints.enabled
+
+ // Colors ease slowly and independently of the width/label so the fill change reads as one
+ // calm transition instead of snapping with the resize.
+ val colorSpec = tween(durationMillis = 350)
+ val backgroundColor by animateColorAsState(
+ targetValue = if (isTyping) Color.Transparent else Color.White,
+ animationSpec = colorSpec,
+ label = "send button background",
+ )
+ val contentColor by animateColorAsState(
+ targetValue = if (isTyping) CodeTheme.colors.textMain else Color.Black,
+ animationSpec = colorSpec,
+ label = "send button content",
+ )
+ val borderColor by animateColorAsState(
+ targetValue = if (isTyping) CodeTheme.colors.divider else Color.Transparent,
+ animationSpec = colorSpec,
+ label = "send button border",
+ )
+
+ // One spring drives the pill's width; the input's weight(1f) fills whatever the pill gives
+ // up, so the whole row resizes as a single coordinated motion.
+ val widthSpec = spring(
+ dampingRatio = Spring.DampingRatioNoBouncy,
+ stiffness = Spring.StiffnessMediumLow,
+ )
+
+ val shape = if (canType) CodeTheme.shapes.medium else CodeTheme.shapes.small
+
+ Row(
+ modifier = Modifier
+ .addIf(!canType) { Modifier.weight(1f) }
+ // Match the chat input's single-line height (~54dp; driven by its trailing send-icon
+ // stack). Equal min width keeps the collapsed "$" state a square with even padding
+ // instead of a short rectangle; the expanded "Send $" content grows past it.
+ .defaultMinSize(minWidth = 54.dp, minHeight = 54.dp)
+ .clip(shape)
+ // Haze sits under the fill — hidden while the background is opaque white, revealed
+ // naturally as the background eases to transparent when typing begins.
+ .hazeEffect(hazeState) { blurEffect { style = hazeMaterial } }
+ .background(backgroundColor, shape)
+ .border(CodeTheme.dimens.border, borderColor, shape)
+ .clickable(onClick = onClick)
+ .padding(
+ horizontal = CodeTheme.dimens.grid.x3,
+ vertical = CodeTheme.dimens.grid.x2,
+ ),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // "action_sendCashViaSymbol" is "Send %1$s" — literally "Send " + the currency symbol.
+ // Keep the symbol mounted at all times and only collapse the "Send " prefix, so the
+ // symbol never crossfades against a wider label (which garbled into "$nd $").
+ val fullText = stringResource(R.string.action_sendCashViaSymbol, state.cashSymbol)
+ val prefix = remember(fullText, state.cashSymbol) {
+ fullText.removeSuffix(state.cashSymbol)
+ }
+ AnimatedVisibility(
+ visible = !isTyping,
+ enter = fadeIn() + expandHorizontally(widthSpec, expandFrom = Alignment.Start),
+ exit = fadeOut() + shrinkHorizontally(widthSpec, shrinkTowards = Alignment.Start),
+ ) {
+ Text(
+ text = prefix,
+ color = contentColor,
+ style = CodeTheme.typography.textMedium,
+ maxLines = 1,
+ softWrap = false,
+ )
+ }
+ Text(
+ text = state.cashSymbol,
+ color = contentColor,
+ style = CodeTheme.typography.textMedium,
+ maxLines = 1,
+ softWrap = false,
+ )
+ }
+}
\ No newline at end of file
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 e69733a28..f53b1b02c 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
@@ -67,7 +67,7 @@ fun ChatInput(
.clip(shape)
.then(modifier)
.fillMaxWidth()
- .animateContentSize(),
+ .animateContentSize(alignment = Alignment.BottomEnd),
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
verticalAlignment = Alignment.Bottom
) {