From d3968b1aba11edd70e51190990da3134f8d05586 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 14 Jul 2026 12:00:23 -0400 Subject: [PATCH] feat(chat): improved text input/send money experience Make chat/text input a first class citizen in the messaging experience Signed-off-by: Brandon McAnsh --- .../core/src/main/res/values/strings.xml | 1 + .../app/messenger/internal/ChatViewModel.kt | 14 +- .../internal/screens/MessengerScreen.kt | 284 +----------------- .../screens/components/ChatBottomBar.kt | 179 +++++++++++ .../internal/screens/components/ChatTopBar.kt | 80 +++++ .../screens/components/SendCashButton.kt | 127 ++++++++ .../getcode/ui/components/chat/ChatInput.kt | 2 +- 7 files changed, 408 insertions(+), 279 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt 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 ) {