diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 531284166..98dcd43e0 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -2,6 +2,7 @@ package com.flipcash.app.core import android.os.Parcelable import androidx.navigation3.runtime.NavKey +import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.deposit.DepositResult import com.flipcash.app.core.deposit.DepositStep import com.flipcash.app.core.tokens.CurrencyCreatorResult @@ -19,7 +20,6 @@ import com.flipcash.app.core.onboarding.OnboardingStep import com.getcode.navigation.flow.FlowRoute import com.getcode.navigation.flow.FlowRouteWithResult import com.getcode.opencode.model.financial.Fiat -import com.flipcash.services.models.chat.ChatId import com.getcode.solana.keys.Mint import com.getcode.ui.core.RestrictionType import kotlinx.parcelize.Parcelize @@ -238,24 +238,6 @@ sealed interface AppRoute : NavKey, Parcelable { data object NavBarSettings : Menu, com.getcode.navigation.Sheet, com.getcode.navigation.WrapContentSheet } - @Serializable - @Parcelize - sealed interface ChatIdentifier : Parcelable { - val key: String - - @Serializable - @Parcelize - data class ByChatId(val chatId: ChatId) : ChatIdentifier { - override val key: String get() = chatId.toString() - } - - @Serializable - @Parcelize - data class ByContact(val e164: String, val displayName: String, val chatId: ChatId? = null) : ChatIdentifier { - override val key: String get() = e164 - } - } - @Serializable @Parcelize sealed interface Messaging : AppRoute { diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatIdentifier.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatIdentifier.kt new file mode 100644 index 000000000..8927235e2 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatIdentifier.kt @@ -0,0 +1,24 @@ +package com.flipcash.app.core.chat + +import android.os.Parcelable +import com.flipcash.services.models.chat.ChatId +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Serializable +@Parcelize +sealed interface ChatIdentifier : Parcelable { + val key: String + + @Serializable + @Parcelize + data class ByChatId(val chatId: ChatId) : ChatIdentifier { + override val key: String get() = chatId.toString() + } + + @Serializable + @Parcelize + data class ByContact(val e164: String, val displayName: String, val chatId: ChatId? = null) : ChatIdentifier { + override val key: String get() = e164 + } +} \ No newline at end of file diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt index 7fd918c14..87989f21e 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt @@ -9,6 +9,7 @@ import com.flipcash.app.contacts.ContactCoordinator.ContactState import com.flipcash.app.contacts.device.DeviceContact import com.flipcash.app.contacts.device.PickedContactData import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.send.SendStep import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController @@ -43,7 +44,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.take -import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -89,7 +89,7 @@ internal class SendFlowViewModel @Inject constructor( data class ContactRemoved(val e164: String) : Event data class SendInvite(val contact: DeviceContact) : Event - data class NavigateToChat(val identifier: AppRoute.ChatIdentifier) : Event + data class NavigateToChat(val identifier: ChatIdentifier) : Event data class NavigateToDirectSend(val contact: DeviceContact) : Event data object PresentDepositOptions : Event data class NavigateToUsdfDepositOption(val route: AppRoute): Event @@ -182,9 +182,9 @@ internal class SendFlowViewModel @Inject constructor( if (isOnFlipcash) { if (featureFlags.get(FeatureFlag.Messenger)) { val identifier = if (contact.e164.isNotEmpty()) { - AppRoute.ChatIdentifier.ByContact(contact.e164, contact.displayName, row.chatId) + ChatIdentifier.ByContact(contact.e164, contact.displayName, row.chatId) } else { - AppRoute.ChatIdentifier.ByChatId(row.chatId!!) + ChatIdentifier.ByChatId(row.chatId!!) } dispatchEvent(Event.NavigateToChat(identifier)) } else { diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt index 87c521421..db4cc7801 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -24,34 +23,23 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.GroupAdd import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.rounded.PersonRemove import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon 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.saveable.rememberSaveable -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.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.dp -import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.crossfade import com.flipcash.app.contacts.device.DeviceContact import com.flipcash.app.core.AppRoute import com.flipcash.app.core.send.SendResult @@ -71,15 +59,11 @@ import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.CircularIconButton import com.getcode.ui.components.SearchInput -import com.getcode.ui.components.SwipeAction -import com.getcode.ui.components.SwipeActionRow import androidx.compose.foundation.clickable import com.flipcash.app.contacts.ui.ContactAvatar +import com.flipcash.app.core.chat.ChatIdentifier import com.getcode.ui.core.verticalScrollStateGradient -import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.theme.CodeScaffold -import com.getcode.view.LoadingSuccessState -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map @@ -117,7 +101,7 @@ internal fun ContactListScreen() { .collect { contact -> flowNavigator.navigate( AppRoute.Messaging.AmountEntry( - identifier = AppRoute.ChatIdentifier.ByContact( + identifier = ChatIdentifier.ByContact( e164 = contact.e164, displayName = contact.displayName, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatAmountEntryScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatAmountEntryScreen.kt index 6b5a4303e..7daf061e4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatAmountEntryScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatAmountEntryScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.getValue import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.core.ui.TokenSelectionPill import com.flipcash.app.messenger.internal.ChatViewModel @@ -29,7 +30,7 @@ import kotlinx.coroutines.flow.onEach * (messenger disabled). Creates its own [ChatViewModel] scoped to this nav entry. */ @Composable -fun ChatAmountEntryScreen(identifier: AppRoute.ChatIdentifier) { +fun ChatAmountEntryScreen(identifier: ChatIdentifier) { val viewModel = hiltViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index e47bb7cc4..c35acc1c6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -9,6 +9,7 @@ import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.MessengerScreen @@ -40,7 +41,7 @@ fun ChatFlowScreen( @Composable private fun chatEntryProvider( - identifier: AppRoute.ChatIdentifier, + identifier: ChatIdentifier, ): (NavKey) -> NavEntry = entryProvider { annotatedEntry { FlowConversationScreen(identifier) @@ -51,7 +52,7 @@ private fun chatEntryProvider( } @Composable -private fun FlowConversationScreen(identifier: AppRoute.ChatIdentifier) { +private fun FlowConversationScreen(identifier: ChatIdentifier) { val viewModel = flowSharedViewModel() val flowNavigator = rememberFlowNavigator() 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 b14257351..ff63a4393 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 @@ -9,8 +9,9 @@ import androidx.paging.flatMap import androidx.paging.insertSeparators import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.contacts.device.DeviceContact +import androidx.compose.runtime.snapshotFlow import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.AppRoute.ChatIdentifier +import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.app.messenger.internal.screens.components.ChatListItem @@ -23,6 +24,7 @@ import com.flipcash.services.models.buildDmPaymentMetadata import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.DeliveryStatus import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.TypingState import com.flipcash.services.user.UserManager import com.flipcash.shared.amountentry.AmountEntryDelegate import com.flipcash.shared.amountentry.AmountEntryStyle @@ -52,6 +54,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest @@ -60,11 +63,20 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.math.min +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant +data class TypingConstraints( + val enabled: Boolean = false, + val interval: Duration = 3.seconds, + val timeout: Duration = 5.seconds, +) + @HiltViewModel internal class ChatViewModel @Inject constructor( private val chatCoordinator: ChatCoordinator, @@ -100,9 +112,10 @@ internal class ChatViewModel @Inject constructor( val typists: Set = emptySet(), val resolveState: ResolveState = ResolveState.Pending, val sendProgress: LoadingSuccessState = LoadingSuccessState(), + val isSelfTyping: Boolean = false, + val typingConstraints: TypingConstraints = TypingConstraints(), val token: Token? = null, val limits: Limits? = null, - val hasPayment: Boolean = false, ) sealed interface Event { @@ -134,9 +147,13 @@ internal class ChatViewModel @Inject constructor( ) : Event data class SendComplete(val amount: Fiat) : Event + data object OnSelfTypingStarted : Event + data object OnSelfTypingStill : Event + data object OnSelfTypingStopped : Event + data class TypingEnabled(val enabled: Boolean) : Event + data class TokenUpdated(val token: Token) : Event data class LimitsChanged(val limits: Limits?) : Event - data class HasPaymentBeenMade(val hasPayment: Boolean) : Event data class AdvanceReadPointer(val messageId: Long) : Event } @@ -258,6 +275,7 @@ internal class ChatViewModel @Inject constructor( if (chatId != null) { dispatchEvent(Event.ChatFound(chatId)) + chatCoordinator.setActiveChatId(chatId) chatCoordinator.dismissNotifications(chatId) } @@ -317,6 +335,67 @@ internal class ChatViewModel @Inject constructor( } .launchIn(viewModelScope) + // Dispatch typing notifications based on text changes. + // transformLatest auto-cancels the previous block on each new emission, + // replacing manual Job tracking for idle timeout and heartbeats. + @OptIn(ExperimentalCoroutinesApi::class) + snapshotFlow { stateFlow.value.chatInputState.text.toString() } + .drop(1) + .distinctUntilChanged() + .transformLatest { text -> + if (!stateFlow.value.typingConstraints.enabled) return@transformLatest + + if (text.isEmpty()) { + if (stateFlow.value.isSelfTyping) { + emit(Event.OnSelfTypingStopped) + } + return@transformLatest + } + + if (!stateFlow.value.isSelfTyping) { + emit(Event.OnSelfTypingStarted) + } + + val constraints = stateFlow.value.typingConstraints + var elapsed = Duration.ZERO + while (elapsed < constraints.timeout) { + val wait = minOf(constraints.interval, constraints.timeout - elapsed) + delay(wait) + elapsed += wait + if (elapsed < constraints.timeout) { + emit(Event.OnSelfTypingStill) + } + } + emit(Event.OnSelfTypingStopped) + } + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + + // Send STOPPED_TYPING when keyboard is dismissed + eventFlow.filterIsInstance() + .onEach { + if (stateFlow.value.isSelfTyping) { + dispatchEvent(Event.OnSelfTypingStopped) + } + } + .launchIn(viewModelScope) + + // Notify server of typing state changes + eventFlow.filterIsInstance() + .mapNotNull { stateFlow.value.chatId } + .onEach { chatCoordinator.notifyTyping(it, TypingState.STARTED_TYPING) } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .mapNotNull { stateFlow.value.chatId } + .onEach { chatCoordinator.notifyTyping(it, TypingState.STILL_TYPING) } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .mapNotNull { stateFlow.value.chatId } + .onEach { chatCoordinator.notifyTyping(it, TypingState.STOPPED_TYPING) } + .launchIn(viewModelScope) + // Observe typing indicators once chatId is known stateFlow.map { it.chatId } .filterNotNull() @@ -324,7 +403,7 @@ internal class ChatViewModel @Inject constructor( .onEach { typists -> dispatchEvent(Event.TypistsUpdated(typists)) } .launchIn(viewModelScope) - // Track whether any payment has been exchanged in this chat + // Enable typing notifications once a payment has been exchanged stateFlow.map { it.chatId } .filterNotNull() .distinctUntilChanged() @@ -335,7 +414,7 @@ internal class ChatViewModel @Inject constructor( } .distinctUntilChanged() } - .onEach { dispatchEvent(Event.HasPaymentBeenMade(it)) } + .onEach { dispatchEvent(Event.TypingEnabled(it)) } .launchIn(viewModelScope) // Send text message @@ -462,6 +541,11 @@ internal class ChatViewModel @Inject constructor( }.launchIn(viewModelScope) } + override fun onCleared() { + super.onCleared() + chatCoordinator.setActiveChatId(null) + } + private fun checkBalanceLimit(): Boolean { val amount = amountDelegate.state.value.enteredAmount val token = stateFlow.value.token ?: return false @@ -553,9 +637,16 @@ internal class ChatViewModel @Inject constructor( ) } is Event.SendComplete -> { state -> state } + Event.OnSelfTypingStarted -> { state -> state.copy(isSelfTyping = true) } + Event.OnSelfTypingStill -> { state -> state } + Event.OnSelfTypingStopped -> { state -> state.copy(isSelfTyping = false) } + is Event.TypingEnabled -> { state -> + state.copy( + typingConstraints = state.typingConstraints.copy(enabled = event.enabled) + ) + } is Event.TokenUpdated -> { state -> state.copy(token = event.token) } is Event.LimitsChanged -> { state -> state.copy(limits = event.limits) } - is Event.HasPaymentBeenMade -> { state -> state.copy(hasPayment = event.hasPayment) } is Event.AdvanceReadPointer -> { state -> state } } } 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 6052d81a8..4abece695 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 @@ -2,18 +2,20 @@ package com.flipcash.app.messenger.internal.screens import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn 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 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 @@ -33,7 +35,6 @@ 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.BlurredEdgeTreatment import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.layout.onSizeChanged @@ -50,20 +51,17 @@ import com.flipcash.app.messenger.internal.screens.components.SeparatorConfig import com.flipcash.features.messenger.R import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.results.key import com.getcode.theme.CodeTheme -import com.getcode.theme.White10 import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.chat.ChatInput -import com.getcode.ui.core.debugBounds +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.HazeBlurStyle import dev.chrisbanes.haze.blur.blurEffect import dev.chrisbanes.haze.blur.materials.HazeMaterials import dev.chrisbanes.haze.hazeEffect @@ -170,6 +168,9 @@ private fun UserControlBottomBar( 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) { @@ -177,27 +178,50 @@ private fun UserControlBottomBar( } } - Box( + Column( modifier = Modifier .fillMaxWidth(), ) { - 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, + 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 } + } + ) { 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 = { when (targetState) { ChatViewModel.UserState.Typing -> @@ -220,14 +244,11 @@ private fun UserControlBottomBar( text = stringResource(R.string.action_sendCash), ) { dispatch(ChatViewModel.Event.OnSendCash) } AnimatedVisibility( - visible = state.hasPayment, + visible = state.typingConstraints.enabled, modifier = Modifier.weight(1f), enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn(), exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut(), ) { - val material = HazeMaterials.ultraThin( - containerColor = CodeTheme.colors.background - ) CodeButton( modifier = Modifier .hazeEffect(hazeState) { @@ -250,7 +271,11 @@ private fun UserControlBottomBar( CodeTheme.dimens.border, CodeTheme.colors.divider, CodeTheme.shapes.medium, - ), + ).hazeEffect(hazeState) { + blurEffect { + style = material + } + }, focusRequester = focusRequester, hint = "Message", state = state.chatInputState, @@ -263,6 +288,7 @@ private fun UserControlBottomBar( } } } + } } } @@ -276,7 +302,7 @@ private fun ChatInputScaffold( var topBarHeight by remember { mutableStateOf(0.dp) } var bottomBarHeight by remember { mutableStateOf(0.dp) } - Box { + Box(modifier = Modifier.imePadding()) { content( PaddingValues( top = topBarHeight, @@ -294,7 +320,6 @@ private fun ChatInputScaffold( modifier = Modifier .align(Alignment.BottomCenter) .onSizeChanged { bottomBarHeight = with(density) { it.height.toDp() } } - .imePadding() ) { bottomBar() } 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 3dc1404bc..f5af4f89b 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 @@ -132,7 +132,7 @@ internal fun MessageList( Box( modifier = Modifier .padding(bottom = bottomSpacing) - .animateItem(), + .animateItem(placementSpec = null), ) { when (item) { is ChatListItem.DateSeparator -> DateSeparatorRow(item.label) diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index 64bf778ca..fbc952da6 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -2,6 +2,7 @@ package com.flipcash.app.router.internal import androidx.core.net.toUri import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.services.models.chat.ChatId @@ -59,7 +60,7 @@ internal class AppRouter( is DeeplinkType.EmailVerification -> resolveEmailVerification(type) is DeeplinkType.Chat -> DeeplinkAction.Navigate( - listOf(AppRoute.Sheets.Send(), AppRoute.Messaging.Chat(AppRoute.ChatIdentifier.ByChatId(type.chatId))) + listOf(AppRoute.Sheets.Send(), AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(type.chatId))) ) } } diff --git a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt index d8e158ff1..f8aa8fde3 100644 --- a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt +++ b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt @@ -19,6 +19,7 @@ import com.getcode.theme.Error import com.getcode.theme.GradientSpec import com.getcode.theme.Gray50 import com.getcode.theme.TextError +import com.getcode.theme.TypingIndicator import com.getcode.theme.Warning import com.getcode.theme.White import com.getcode.theme.White05 @@ -72,6 +73,11 @@ object Flipcash2ColorSpec { outgoingBubble = Bubble( background = Color.White.copy(alpha = 0.08f), border = Color.White.copy(alpha = 0.03f), + ), + typingIndicator = TypingIndicator( + background = Color.White.copy(alpha = 0.02f), + border = Color.White.copy(alpha = 0.03f), + dots = Color.White.copy(alpha = 0.30f), ) ) } diff --git a/ui/components/build.gradle.kts b/ui/components/build.gradle.kts index bfbdf02dd..936547819 100644 --- a/ui/components/build.gradle.kts +++ b/ui/components/build.gradle.kts @@ -43,4 +43,5 @@ dependencies { implementation(libs.compose.accompanist) implementation(libs.compose.paging) api(libs.vico.compose) + implementation(libs.bundles.haze) } 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 e52fbf3d0..3d99ee096 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 @@ -36,6 +36,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview @@ -61,8 +63,12 @@ fun ChatInput( focusRequester: FocusRequester = remember { FocusRequester() }, onSendMessage: () -> Unit, ) { + val shape = CodeTheme.shapes.medium Row( - modifier = modifier + modifier = Modifier + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .clip(shape) + .then(modifier) .fillMaxWidth() .height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), @@ -71,7 +77,7 @@ fun ChatInput( TextInput( modifier = Modifier .weight(1f) - .background(CodeTheme.colors.background, shape = CodeTheme.shapes.medium) + .background(Color.Transparent, shape = CodeTheme.shapes.medium) .focusRequester(focusRequester), minHeight = 40.dp, enabled = enabled, 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 d877e6737..ffa076652 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 @@ -15,6 +15,7 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image 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 @@ -26,7 +27,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.CornerSize import androidx.compose.material.Button import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Person @@ -62,22 +62,18 @@ fun TypingIndicator( userImages: List = emptyList(), ) { Row( - modifier = modifier - .padding(top = 4.dp) - .graphicsLayer { - compositingStrategy = CompositingStrategy.Offscreen - clip = false - }, + modifier = Modifier + .padding(top = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Avatar Row - AvatarRow( - modifier = Modifier.weight(1f, fill = false), - userImages = userImages, - ) + if (userImages.isNotEmpty()) { + AvatarRow( + modifier = Modifier.weight(1f, fill = false), + userImages = userImages, + ) + } - // Typing Dots - TypingDots() + TypingDots(modifier = modifier) } } @@ -130,19 +126,26 @@ private fun AvatarRow( @Composable private fun TypingDots( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { + val shape = CodeTheme.shapes.medium Row( - modifier = modifier + modifier = Modifier + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .clip(shape) + .then(modifier) + .border( + color = CodeTheme.colors.chat.typingIndicator.border, + width = CodeTheme.dimens.border, + shape = CodeTheme.shapes.medium, + ) .background( - color = CodeTheme.colors.brandDark, - shape = CodeTheme.shapes.small.copy( - topStart = CornerSize(3.dp) - ) + color = CodeTheme.colors.chat.typingIndicator.background, + shape = CodeTheme.shapes.medium, ) .padding( horizontal = CodeTheme.dimens.grid.x2, - vertical = CodeTheme.dimens.grid.x3 + vertical = CodeTheme.dimens.grid.x3, ), horizontalArrangement = Arrangement.spacedBy( space = CodeTheme.dimens.grid.x1, @@ -150,7 +153,7 @@ private fun TypingDots( ), verticalAlignment = Alignment.CenterVertically ) { - val baseColor = CodeTheme.colors.onSurface + val baseColor = CodeTheme.colors.chat.typingIndicator.dots var currentIndex by remember { mutableIntStateOf(0) } val animatedAlphas = List(DotCount) { index -> diff --git a/ui/theme/src/main/kotlin/com/getcode/theme/Color.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Color.kt index 2f4a57f04..89cdfa643 100644 --- a/ui/theme/src/main/kotlin/com/getcode/theme/Color.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Color.kt @@ -102,6 +102,13 @@ data class GradientSpec( data class ChatColors( val incomingBubble: Bubble, val outgoingBubble: Bubble, + val typingIndicator: TypingIndicator, +) + +data class TypingIndicator( + val background: Color, + val border: Color, + val dots: Color, ) data class Bubble( diff --git a/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt index fee2540e9..66935063e 100644 --- a/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt @@ -62,6 +62,11 @@ internal val CodeDefaultColorScheme = ColorScheme( chat = ChatColors( incomingBubble = Bubble(background = White10, border = Color.Transparent), outgoingBubble = Bubble(background = BrandDark, border = Color.Transparent), + typingIndicator = TypingIndicator( + background = White05, + border = White05, + dots = White20 + ) ), )