diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index d9aa5d24c..a04eef56e 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -393,7 +393,7 @@ Solflare Backpack - Advanced Features + Advanced Wallet Wallet @@ -776,4 +776,7 @@ Yesterday Today + Send Money To Your Friends + Send money to friends as easily as a text. Connect your phone number to get started + \ No newline at end of file diff --git a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt index 521d41674..dbb6f1b25 100644 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt +++ b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt @@ -16,9 +16,9 @@ import kotlinx.coroutines.flow.onEach import javax.inject.Inject private val FullMenuList = buildList { - add(BillCustomizer) - add(DeviceLogs) add(BetaFlags) + add(DeviceLogs) +// add(BillCustomizer) } @HiltViewModel diff --git a/apps/flipcash/features/direct-send/build.gradle.kts b/apps/flipcash/features/direct-send/build.gradle.kts index 3401bdbc7..3d9dab527 100644 --- a/apps/flipcash/features/direct-send/build.gradle.kts +++ b/apps/flipcash/features/direct-send/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:permissions")) implementation(project(":apps:flipcash:shared:chat")) + implementation(project(":apps:flipcash:shared:chat-ui")) implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:phone")) implementation(project(":apps:flipcash:shared:tokens")) diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/SendFlowScreen.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/SendFlowScreen.kt index e69ca5482..d2c3ae5b2 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/SendFlowScreen.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/SendFlowScreen.kt @@ -15,7 +15,6 @@ import com.flipcash.app.directsend.internal.screens.ContactListScreen import com.flipcash.app.directsend.internal.screens.ContactsPermissionGateScreen import com.flipcash.app.directsend.internal.screens.PhoneGateLandingScreen import com.getcode.navigation.annotatedEntry -import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.FlowExitReason import com.getcode.navigation.flow.FlowHost import com.getcode.navigation.flow.flowSharedViewModel diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/PhoneGateLandingScreen.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/PhoneGateLandingScreen.kt index ebb7a6fc0..446b0784b 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/PhoneGateLandingScreen.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/PhoneGateLandingScreen.kt @@ -2,24 +2,31 @@ package com.flipcash.app.directsend.internal.screens import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize 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.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper import com.flipcash.app.core.AppRoute import com.flipcash.app.core.send.SendResult import com.flipcash.app.core.send.SendStep +import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.features.directsend.R +import com.flipcash.shared.chat.ui.AnimatedConversationPreview +import com.getcode.navigation.flow.LocalFlowNavigator +import com.getcode.navigation.flow.PreviewFlowNavigator import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.opencode.compose.ExchangeStub +import com.getcode.opencode.compose.LocalExchange import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle @@ -49,7 +56,7 @@ internal fun PhoneGateLandingScreen() { .padding(bottom = CodeTheme.dimens.grid.x3) .navigationBarsPadding(), buttonState = ButtonState.Filled, - text = stringResource(R.string.action_connectPhoneNumber), + text = stringResource(R.string.action_next), onClick = { flowNavigator.navigate( AppRoute.Verification( @@ -65,25 +72,28 @@ internal fun PhoneGateLandingScreen() { } ) { innerPadding -> Box( - Modifier + modifier = Modifier .fillMaxSize() - .padding(innerPadding), + .padding(innerPadding) ) { Column( modifier = Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally, ) { + AnimatedConversationPreview(animate = false) + Text( - text = stringResource(R.string.title_connectPhoneToSend), + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x8), + text = stringResource(R.string.title_sendFeatureIntro), style = CodeTheme.typography.displaySmall, color = CodeTheme.colors.textMain, ) - Spacer(Modifier.height(8.dp)) Text( modifier = Modifier .fillMaxWidth(0.8f) + .padding(top = CodeTheme.dimens.grid.x2) .padding(horizontal = CodeTheme.dimens.inset), - text = stringResource(R.string.subtitle_connectPhoneToSend), + text = stringResource(R.string.subtitle_sendFeatureIntro), style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, textAlign = TextAlign.Center, @@ -91,4 +101,16 @@ internal fun PhoneGateLandingScreen() { } } } +} + +@Composable +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +private fun PreviewPhoneGateScreen() { + CompositionLocalProvider( + LocalFlowNavigator provides PreviewFlowNavigator(), + LocalExchange provides ExchangeStub(context = LocalContext.current), + ) { + PhoneGateLandingScreen() + } } \ No newline at end of file diff --git a/apps/flipcash/features/direct-send/src/test/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModelStateTest.kt b/apps/flipcash/features/direct-send/src/test/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModelStateTest.kt index a01424506..9b7e76470 100644 --- a/apps/flipcash/features/direct-send/src/test/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModelStateTest.kt +++ b/apps/flipcash/features/direct-send/src/test/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModelStateTest.kt @@ -25,9 +25,9 @@ class SendFlowViewModelStateTest { @Test fun `OnStepChanged to PhoneGate updates currentStep`() { val updated = reduce( - SendFlowViewModel.Event.OnStepChanged(SendStep.PhoneGate) + SendFlowViewModel.Event.OnStepChanged(SendStep.Intro) )(SendFlowViewModel.State()) - assertEquals(SendStep.PhoneGate, updated.currentStep) + assertEquals(SendStep.Intro, updated.currentStep) } @Test @@ -40,7 +40,7 @@ class SendFlowViewModelStateTest { @Test fun `OnStepChanged replaces previous step`() { - val state = SendFlowViewModel.State(currentStep = SendStep.PhoneGate) + val state = SendFlowViewModel.State(currentStep = SendStep.Intro) val updated = reduce( SendFlowViewModel.Event.OnStepChanged(SendStep.ContactList) )(state) @@ -50,7 +50,7 @@ class SendFlowViewModelStateTest { @Test fun `OnStepChanged does not affect other state`() { val state = SendFlowViewModel.State( - steps = listOf(SendStep.PhoneGate, SendStep.ContactList), + steps = listOf(SendStep.Intro, SendStep.ContactList), isPickerMode = true, ) val updated = reduce( diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index c1839c4e8..dc1297ca7 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -8,6 +8,7 @@ android { dependencies { implementation(project(":apps:flipcash:shared:chat")) + implementation(project(":apps:flipcash:shared:chat-ui")) implementation(project(":apps:flipcash:shared:amount-entry")) implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:featureflags")) 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 0d49031b4..f021ac341 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 @@ -43,7 +43,6 @@ fun ChatAmountEntryScreen(identifier: ChatIdentifier) { ChatAmountEntryContent( amountDelegate = viewModel.amountDelegate, resolveState = state.resolveState, - chattingWithName = state.chattingWith?.displayName, token = state.token, eventFlow = viewModel.eventFlow, onExit = { navigator.pop() }, @@ -55,7 +54,6 @@ fun ChatAmountEntryScreen(identifier: ChatIdentifier) { internal fun ChatAmountEntryContent( amountDelegate: AmountEntryDelegate, resolveState: ChatViewModel.ResolveState, - chattingWithName: String?, token: Token?, eventFlow: Flow, onExit: () -> Unit, @@ -79,15 +77,7 @@ internal fun ChatAmountEntryContent( eventFlow .filterIsInstance() .onEach { event -> - BottomBarManager.showInfo( - title = resources.getString(R.string.prompt_title_fundsSentToContact), - message = resources.getString( - R.string.prompt_description_fundsSentToContact, - event.amount.formatted(rule = Fiat.FormattingRule.Truncated), - chattingWithName ?: resources.getString(R.string.subtitle_yourSelectedRecipient), - ), - onDismiss = { onSendComplete?.invoke() ?: navigator.pop() } - ) + onSendComplete?.invoke() ?: navigator.pop() }.launchIn(this) } 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 bd2ff6e76..8c8005d8a 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 @@ -89,7 +89,6 @@ private fun FlowAmountEntryScreen() { ChatAmountEntryContent( amountDelegate = viewModel.amountDelegate, resolveState = state.resolveState, - chattingWithName = state.chattingWith?.displayName, token = state.token, eventFlow = viewModel.eventFlow, onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnConfirmRequested) }, 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 da35256f4..f07750d25 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 @@ -16,9 +16,9 @@ import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController -import com.flipcash.app.messenger.internal.screens.components.ChatListItem -import com.flipcash.app.messenger.internal.screens.components.ReceiptStatus -import com.flipcash.app.messenger.internal.screens.components.SeparatorConfig +import com.flipcash.shared.chat.ui.ChatListItem +import com.flipcash.shared.chat.ui.ReceiptStatus +import com.flipcash.shared.chat.ui.SeparatorConfig import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.features.messenger.R @@ -546,7 +546,14 @@ internal class ChatViewModel @Inject constructor( onFailure = { Result.failure(it) } ).onSuccess { amount -> dispatchEvent(Event.SendStateUpdated(success = true)) - stateFlow.value.chatId?.let { chatCoordinator.loadMessages(it) } + val chatId = stateFlow.value.chatId + if (chatId != null) { + chatCoordinator.loadMessages(chatId) + } else { + // New conversation — server just created the DM chat. + // Sync the feed so it appears in the contact list. + chatCoordinator.refreshFeed() + } delay(400.milliseconds) dispatchEvent( Dispatchers.Main, 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 3389406fa..28f978417 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 @@ -54,7 +54,7 @@ import com.flipcash.app.contacts.ui.ContactAvatar import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.components.MessageList -import com.flipcash.app.messenger.internal.screens.components.SeparatorConfig +import com.flipcash.shared.chat.ui.SeparatorConfig import com.flipcash.features.messenger.R import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.LocalCodeNavigator 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 43fbe4434..0d28a1227 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 @@ -31,73 +31,22 @@ import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey import com.flipcash.app.messenger.internal.ChatViewModel -import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.shared.chat.ui.ChatListItem +import com.flipcash.shared.chat.ui.ContentBubble +import com.flipcash.shared.chat.ui.ReceiptStatus +import com.flipcash.shared.chat.ui.SeparatorConfig +import com.flipcash.shared.chat.ui.bubblePositionOf import com.getcode.theme.CodeTheme import com.getcode.ui.utils.rememberKeyboardController 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 import kotlinx.coroutines.flow.mapNotNull -import kotlin.time.Duration -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant -sealed interface SeparatorConfig { - val groupingWindow: Duration - - fun shouldSeparate(before: Instant, after: Instant): Boolean - fun isGrouped(a: Instant, b: Instant): Boolean = - (a - b).absoluteValue <= groupingWindow - - data object DayOnly : SeparatorConfig { - override val groupingWindow: Duration = 60.seconds - override fun shouldSeparate(before: Instant, after: Instant): Boolean = - before.toLocalDate() != after.toLocalDate() - } - - data class TimeGap( - val gap: Duration = 3.hours, - override val groupingWindow: Duration = 60.seconds, - ) : SeparatorConfig { - override fun shouldSeparate(before: Instant, after: Instant): Boolean = - before.toLocalDate() != after.toLocalDate() - || (before - after).absoluteValue > gap - } -} - -internal enum class ReceiptStatus { SENDING, SENT, READ, FAILED } - -internal sealed interface ChatListItem { - val itemKey: Any - val itemContentType: Any - - data class DateSeparator(val timestamp: Instant) : ChatListItem { - override val itemKey: Any = "sep-${timestamp.epochSeconds}" - override val itemContentType: Any = "date-separator" - } - - data class ContentBubble( - val messageId: Long, - val contentIndex: Int, - val content: MessageContent, - val isFromSelf: Boolean, - val timestamp: Instant, - val receiptStatus: ReceiptStatus? = null, - val pendingClientIdHex: String? = null, - ) : ChatListItem { - override val itemKey: Any = pendingClientIdHex ?: "$messageId-$contentIndex" - override val itemContentType: Any = when (content) { - is MessageContent.Text -> "text-bubble" - is MessageContent.Cash -> "cash-bubble" - } - } -} - @Composable internal fun MessageList( @@ -213,6 +162,15 @@ internal fun MessageList( } is ChatListItem.ContentBubble -> { val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer) + // Track whether this item was ever seen as SENDING so we + // can animate the receipt label entrance on the + // SENDING→SENT transition. This remember persists across + // recompositions of the same item (keyed by LazyColumn), + // surviving the status change that gates the label. + var wasSending by remember { mutableStateOf(false) } + if (item.receiptStatus == ReceiptStatus.SENDING) { + wasSending = true + } Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, @@ -220,7 +178,12 @@ internal fun MessageList( Box(insertionModifier) { ContentBubble( item = item, - position = bubblePositionOf(index, item, messages, separatorConfig), + position = bubblePositionOf( + index, + item, + messages, + separatorConfig + ), ) } val showReceipt = @@ -228,7 +191,8 @@ internal fun MessageList( if (showReceipt && effectiveStatus != null) { ReceiptLabel( status = effectiveStatus, - readPointer = otherReadPointer + readPointer = otherReadPointer, + animateEntrance = wasSending, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt index 43f8dacf4..046113608 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.shared.chat.ui.ReceiptStatus import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.PointerType @@ -42,22 +43,29 @@ import kotlin.time.Clock import kotlin.time.Duration.Companion.days import kotlin.time.Instant import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds -private const val DELIVERED_DELAY_MS = 700L +private val DELIVERED_DELAY = 700.milliseconds @Composable internal fun ReceiptLabel( status: ReceiptStatus, readPointer: MessagePointer?, modifier: Modifier = Modifier, + animateEntrance: Boolean = false, ) { // iOS: "Delivered" hides instantly on send, then appears after 700ms with // scale(0.95)+opacity spring (duration: 0.4, bounce: 0.12). // "Read" swaps in immediately (no delay). - var deliveredVisible by remember { mutableStateOf(status != ReceiptStatus.SENT) } + // + // animateEntrance: true only when the message is still SENDING at composition + // time, so the label animates in on the SENDING→SENT transition. When opening + // a chat or scrolling an already-delivered/read message into view, we skip + // the enter animation entirely. + var deliveredVisible by remember { mutableStateOf(!animateEntrance) } LaunchedEffect(status) { - if (status == ReceiptStatus.SENT) { - delay(DELIVERED_DELAY_MS) + if (animateEntrance && status == ReceiptStatus.SENT) { + delay(DELIVERED_DELAY) deliveredVisible = true } else { deliveredVisible = true @@ -75,8 +83,12 @@ internal fun ReceiptLabel( ) { AnimatedVisibility( visible = deliveredVisible, - enter = expandVertically() + - scaleIn(deliveredSpec, initialScale = 0.95f) + fadeIn(deliveredSpec), + enter = if (animateEntrance) { + expandVertically() + + scaleIn(deliveredSpec, initialScale = 0.95f) + fadeIn(deliveredSpec) + } else { + expandVertically(snap()) + fadeIn(snap()) + }, exit = shrinkVertically(snap()) + fadeOut(snap()), ) { // Delivered -> Read directional swap with scale diff --git a/apps/flipcash/shared/chat-ui/build.gradle.kts b/apps/flipcash/shared/chat-ui/build.gradle.kts new file mode 100644 index 000000000..312c40105 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + alias(libs.plugins.flipcash.android.library.compose) +} + +android { + namespace = "${Gradle.flipcashNamespace}.shared.chat.ui" +} + +dependencies { + implementation(project(":apps:flipcash:core")) + implementation(project(":ui:core")) + implementation(project(":ui:components")) + implementation(project(":ui:theme")) + implementation(project(":ui:resources")) + implementation(project(":services:flipcash")) + implementation(project(":services:opencode-compose")) + implementation(project(":libs:datetime")) + implementation(libs.androidx.paging.runtime) + implementation(libs.compose.paging) + implementation(project(":apps:flipcash:shared:theme")) +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListItem.kt new file mode 100644 index 000000000..7d0ce3079 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListItem.kt @@ -0,0 +1,32 @@ +package com.flipcash.shared.chat.ui + +import com.flipcash.services.models.chat.MessageContent +import kotlin.time.Instant + +enum class ReceiptStatus { SENDING, SENT, READ, FAILED } + +sealed interface ChatListItem { + val itemKey: Any + val itemContentType: Any + + data class DateSeparator(val timestamp: Instant) : ChatListItem { + override val itemKey: Any = "sep-${timestamp.epochSeconds}" + override val itemContentType: Any = "date-separator" + } + + data class ContentBubble( + val messageId: Long, + val contentIndex: Int, + val content: MessageContent, + val isFromSelf: Boolean, + val timestamp: Instant, + val receiptStatus: ReceiptStatus? = null, + val pendingClientIdHex: String? = null, + ) : ChatListItem { + override val itemKey: Any = pendingClientIdHex ?: "$messageId-$contentIndex" + override val itemContentType: Any = when (content) { + is MessageContent.Text -> "text-bubble" + is MessageContent.Cash -> "cash-bubble" + } + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationPreview.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationPreview.kt new file mode 100644 index 000000000..806d1f20c --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationPreview.kt @@ -0,0 +1,191 @@ +package com.flipcash.shared.chat.ui + +import android.graphics.BlurMaskFilter +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.ui.ScreenFrame +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.services.models.chat.MessageContent +import com.getcode.opencode.compose.ExchangeStub +import com.getcode.opencode.compose.LocalExchange +import com.getcode.opencode.model.core.RandomId +import com.getcode.opencode.model.financial.toFiat +import com.getcode.solana.keys.Mint +import com.getcode.theme.CodeTheme +import com.getcode.theme.LocalCodeTypography +import com.getcode.theme.codeTypography +import kotlinx.coroutines.delay +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes + +private val bottomFadeBrush = Brush.verticalGradient( + 0f to Color.Black, + 0.8f to Color.Black, + 0.95f to Color.Transparent, +) + +private val messages = listOf( + ChatListItem.ContentBubble( + messageId = 1, + contentIndex = 0, + content = MessageContent.Cash( + intentId = RandomId, + amount = 60.toFiat(), + mint = Mint.usdf, + ), + isFromSelf = true, + timestamp = Clock.System.now().minus(2.minutes), + ), + ChatListItem.ContentBubble( + messageId = 2, + contentIndex = 1, + content = MessageContent.Text( + text = "Thanks for dinner!" + ), + isFromSelf = true, + timestamp = Clock.System.now().minus(2.minutes), + ), + ChatListItem.ContentBubble( + messageId = 3, + contentIndex = 0, + content = MessageContent.Text( + text = "That's very kind of you. I had a great time last night" + ), + isFromSelf = false, + timestamp = Clock.System.now(), + ), +) + +@Composable +fun AnimatedConversationPreview(animate: Boolean = true) { + val alpha = remember { Animatable(if (animate) 0f else 1f) } + + LaunchedEffect(Unit) { + if (animate) { + delay(300.milliseconds) + alpha.animateTo(1f, tween(600)) + } + } + + // cash bubble prices are rendered at displaySmall sizes in this preview render + val previewTypography = codeTypography.copy( + displayMedium = codeTypography.displaySmall, + ) + + CompositionLocalProvider(LocalCodeTypography provides previewTypography) { + Box( + modifier = Modifier + .height(340.dp) + .graphicsLayer { + this.alpha = alpha.value + compositingStrategy = CompositingStrategy.Offscreen + } + .drawWithContent { + drawContent() + drawRect(brush = bottomFadeBrush, blendMode = BlendMode.DstIn) + }, + ) { + ScreenFrame( + modifier = Modifier.wrapContentSize(unbounded = true, align = Alignment.TopCenter), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = CodeTheme.dimens.inset) + .padding(horizontal = CodeTheme.dimens.grid.x2), + ) { + val reversed = messages.asReversed() + messages.forEachIndexed { index, item -> + if (index > 0) { + val sameSender = item.isFromSelf == messages[index - 1].isFromSelf + Spacer( + Modifier.height( + if (sameSender) CodeTheme.dimens.grid.x2 + else CodeTheme.dimens.grid.x3 + ) + ) + } + val position = bubblePositionOf( + index = messages.lastIndex - index, + item = item, + messages = reversed, + config = SeparatorConfig.TimeGap() + ) + val shape = bubbleShape(position, item.isFromSelf) + CompositionLocalProvider(LocalInspectionMode provides true) { + ContentBubble( + item = item, + modifier = Modifier.bubbleShadow(shape), + position = position, + ) + } + } + } + } + } + } +} + +private fun Modifier.bubbleShadow( + shape: Shape, + color: Color = Color.Black.copy(alpha = 0.2f), + blurRadius: Dp = 12.dp, + offsetY: Dp = 4.dp, +): Modifier = drawBehind { + val outline = shape.createOutline(size, layoutDirection, this) + val paint = Paint().apply { + this.color = color + asFrameworkPaint().maskFilter = + BlurMaskFilter(blurRadius.toPx(), BlurMaskFilter.Blur.NORMAL) + } + drawIntoCanvas { canvas -> + translate(top = offsetY.toPx()) { + canvas.drawOutline(outline, paint) + } + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun PreviewConversationPreview() { + CompositionLocalProvider( + LocalExchange provides ExchangeStub(context = LocalContext.current) + ) { + Box(modifier = Modifier) { + AnimatedConversationPreview(false) + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt similarity index 78% rename from apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageBubble.kt rename to apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 01f6099d4..cb63a1930 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.messenger.internal.screens.components +package com.flipcash.shared.chat.ui import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.spring @@ -11,9 +11,14 @@ import androidx.compose.foundation.layout.BoxWithConstraints 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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -22,38 +27,40 @@ import androidx.compose.runtime.getValue 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.graphics.Shape import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.paging.compose.LazyPagingItems import com.flipcash.app.core.ui.TokenIconWithName import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.services.models.chat.MessageContent -import com.flipcash.shared.flags.R 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.ui.components.PriceWithFlag import com.getcode.ui.core.addIf +import com.getcode.util.resources.R -internal enum class BubblePosition { Solo, First, Middle, Last } +enum class BubblePosition { Solo, First, Middle, Last } private const val BUBBLE_MAX_WIDTH_FRACTION = 0.78f private const val CASH_BUBBLE_MAX_WIDTH_FRACTION = 0.64f @Composable -internal fun ContentBubble( +fun ContentBubble( item: ChatListItem.ContentBubble, position: BubblePosition, modifier: Modifier = Modifier, ) { - BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val bubbleMaxWidth = when (item.content) { is MessageContent.Text -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION is MessageContent.Cash -> maxWidth * CASH_BUBBLE_MAX_WIDTH_FRACTION @@ -65,6 +72,7 @@ internal fun ContentBubble( ) { when (val content = item.content) { is MessageContent.Text -> TextBubble( + modifier = modifier, text = content.text, isFromSelf = item.isFromSelf, position = position, @@ -72,6 +80,7 @@ internal fun ContentBubble( ) is MessageContent.Cash -> CashBubble( + modifier = modifier, amount = content.amount, tokenName = content.tokenName, tokenImageUrl = content.tokenImageUrl, @@ -90,8 +99,9 @@ private fun TextBubble( isFromSelf: Boolean, position: BubblePosition, maxWidth: Dp, + modifier: Modifier = Modifier, ) { - Bubble(isFromSelf, position, maxWidth) { + Bubble(isFromSelf, position, maxWidth, modifier) { SelectionContainer { Text( text = text, @@ -110,8 +120,15 @@ private fun CashBubble( isFromSelf: Boolean, position: BubblePosition, maxWidth: Dp, + modifier: Modifier = Modifier, ) { - Bubble(isFromSelf = isFromSelf, position = position, minWidth = maxWidth, maxWidth = maxWidth) { + Bubble( + isFromSelf = isFromSelf, + position = position, + minWidth = maxWidth, + maxWidth = maxWidth, + modifier = modifier + ) { val exchange = LocalExchange.current Column( @@ -119,21 +136,42 @@ private fun CashBubble( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - if (tokenName.isNotBlank()) { - TokenIconWithName( - modifier = Modifier.align(Alignment.Start), - tokenName = tokenName, - tokenImage = tokenImageUrl, - imageSize = CodeTheme.dimens.staticGrid.x4, - spacing = CodeTheme.dimens.grid.x1, - textStyle = CodeTheme.typography.caption, - textColor = CodeTheme.colors.textSecondary, - ) + if (LocalInspectionMode.current) { + Row( + modifier = Modifier + .align(Alignment.Start), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x2) + .background(Color(0xFF3F3F3F), CircleShape) + ) + Box( + modifier = Modifier + .width(CodeTheme.dimens.staticGrid.x5) + .height(CodeTheme.dimens.staticGrid.x1) + .background(Color(0xFF3F3F3F), CircleShape) + ) + } + } else { + if (tokenName.isNotBlank()) { + TokenIconWithName( + modifier = Modifier.align(Alignment.Start), + tokenName = tokenName, + tokenImage = tokenImageUrl, + imageSize = CodeTheme.dimens.staticGrid.x4, + spacing = CodeTheme.dimens.grid.x1, + textStyle = CodeTheme.typography.caption, + textColor = CodeTheme.colors.textSecondary, + ) + } } Column( modifier = Modifier - .padding(vertical = CodeTheme.dimens.grid.x5), + .padding(top = CodeTheme.dimens.grid.x5, bottom = CodeTheme.dimens.grid.x8), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( @@ -155,6 +193,8 @@ private fun CashBubble( text = text, style = CodeTheme.typography.displayMedium, color = CodeTheme.colors.textMain, + autoSize = TextAutoSize.StepBased(minFontSize = 20.sp), + maxLines = 1, ) } ) @@ -168,6 +208,7 @@ private fun Bubble( isFromSelf: Boolean, position: BubblePosition, maxWidth: Dp, + modifier: Modifier = Modifier, minWidth: Dp = 0.dp, content: @Composable BoxScope.() -> Unit, ) { @@ -178,7 +219,7 @@ private fun Bubble( } val shape = bubbleShape(position, isFromSelf) Box( - modifier = Modifier + modifier = modifier .widthIn(min = minWidth, max = maxWidth) .clip(shape) .addIf(bubble.hasBorder) { @@ -192,20 +233,34 @@ private fun Bubble( } @Composable -private fun bubbleShape(position: BubblePosition, isFromSelf: Boolean): Shape { - // CodeTheme.shapes.medium = 12dp, tiny = 4dp +fun bubbleShape(position: BubblePosition, isFromSelf: Boolean): Shape { val l = 12.dp val s = 4.dp - // Corner radius morph — animate each corner with spring matching prototype val cornerSpec = spring(dampingRatio = 0.68f, stiffness = 500f) - // 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) + 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") @@ -216,15 +271,19 @@ private fun bubbleShape(position: BubblePosition, isFromSelf: Boolean): Shape { return RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart) } -private data class BubbleCorners(val topStart: Dp, val topEnd: Dp, val bottomEnd: Dp, val bottomStart: Dp) +private data class BubbleCorners( + val topStart: Dp, + val topEnd: Dp, + val bottomEnd: Dp, + val bottomStart: Dp +) -internal fun bubblePositionOf( +fun bubblePositionOf( index: Int, item: ChatListItem.ContentBubble, messages: LazyPagingItems, config: SeparatorConfig, ): BubblePosition { - // index+1 = visually above (older), index-1 = visually below (newer) val above = if (index + 1 < messages.itemCount) { messages.peek(index + 1) as? ChatListItem.ContentBubble } else null @@ -242,19 +301,18 @@ internal fun bubblePositionOf( return when { groupedAbove && groupedBelow -> BubblePosition.Middle - groupedAbove -> BubblePosition.Last // bottom of visual group - groupedBelow -> BubblePosition.First // top of visual group + groupedAbove -> BubblePosition.Last + groupedBelow -> BubblePosition.First else -> BubblePosition.Solo } } -internal fun bubblePositionOf( +fun bubblePositionOf( index: Int, item: ChatListItem.ContentBubble, messages: List, config: SeparatorConfig, ): BubblePosition { - // index+1 = visually above (older), index-1 = visually below (newer) val above = if (index + 1 < messages.count()) { messages[index + 1] as? ChatListItem.ContentBubble } else null @@ -272,8 +330,8 @@ internal fun bubblePositionOf( return when { groupedAbove && groupedBelow -> BubblePosition.Middle - groupedAbove -> BubblePosition.Last // bottom of visual group - groupedBelow -> BubblePosition.First // top of visual group + groupedAbove -> BubblePosition.Last + groupedBelow -> BubblePosition.First else -> BubblePosition.Solo } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/SeparatorConfig.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/SeparatorConfig.kt new file mode 100644 index 000000000..7406b0c18 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/SeparatorConfig.kt @@ -0,0 +1,30 @@ +package com.flipcash.shared.chat.ui + +import com.getcode.util.toLocalDate +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +sealed interface SeparatorConfig { + val groupingWindow: Duration + + fun shouldSeparate(before: Instant, after: Instant): Boolean + fun isGrouped(a: Instant, b: Instant): Boolean = + (a - b).absoluteValue <= groupingWindow + + data object DayOnly : SeparatorConfig { + override val groupingWindow: Duration = 60.seconds + override fun shouldSeparate(before: Instant, after: Instant): Boolean = + before.toLocalDate() != after.toLocalDate() + } + + data class TimeGap( + val gap: Duration = 3.hours, + override val groupingWindow: Duration = 60.seconds, + ) : SeparatorConfig { + override fun shouldSeparate(before: Instant, after: Instant): Boolean = + before.toLocalDate() != after.toLocalDate() + || (before - after).absoluteValue > gap + } +} diff --git a/apps/flipcash/shared/chat/build.gradle.kts b/apps/flipcash/shared/chat/build.gradle.kts index 3bcab1cb2..517c1b879 100644 --- a/apps/flipcash/shared/chat/build.gradle.kts +++ b/apps/flipcash/shared/chat/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(project(":apps:flipcash:shared:persistence:sources")) implementation(project(":apps:flipcash:shared:persistence:db")) implementation(project(":apps:flipcash:shared:contacts")) + implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":services:flipcash")) implementation(project(":libs:network:connectivity:public")) implementation(libs.androidx.lifecycle.process) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index a7ad72126..5c4d8c74e 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -12,6 +12,8 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.map import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.mediator.ChatMessageRemoteMediator import com.flipcash.app.persistence.sources.ChatMessageDataSource @@ -44,6 +46,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce @@ -75,6 +78,7 @@ class ChatCoordinator @Inject constructor( private val networkObserver: NetworkConnectivityListener, private val notificationManager: NotificationManagerCompat, private val userManager: UserManager, + private val featureFlags: FeatureFlagController, ) : SessionListener, DefaultLifecycleObserver { companion object { @@ -88,6 +92,7 @@ class ChatCoordinator @Inject constructor( private val cluster = MutableStateFlow(null) private val _state = MutableStateFlow(ChatState()) private var syncJob: Job? = null + private var flagObserverJob: Job? = null private var eventStreamCollectJob: Job? = null private var eventStreamRetryJob: Job? = null private var heartbeatJob: Job? = null @@ -121,6 +126,12 @@ class ChatCoordinator @Inject constructor( trace(tag = TAG, message = "User logged in, hydrating chat", type = TraceType.User) this.cluster.value = cluster hydrateFromPersistence() + if (isChatEnabled()) { + syncFeed() + openEventStream() + startHeartbeat() + } + observeFeatureFlag() } // endregion @@ -136,6 +147,7 @@ class ChatCoordinator @Inject constructor( .filter { it.connected } .debounce(1.seconds) .onEach { + if (!isChatEnabled()) return@onEach trace(tag = TAG, message = "Network connected, re-syncing chat feed", type = TraceType.Process) syncFeed() openEventStream() @@ -148,11 +160,13 @@ class ChatCoordinator @Inject constructor( setActiveChatId(it) backgroundedActiveChat = null } - if (cluster.value != null) { - trace(tag = TAG, message = "Lifecycle resumed, syncing chat feed", type = TraceType.Process) - syncFeed() - openEventStream() - startHeartbeat() + scope.launch { + if (cluster.value != null && isChatEnabled()) { + trace(tag = TAG, message = "Lifecycle resumed, syncing chat feed", type = TraceType.Process) + syncFeed() + openEventStream() + startHeartbeat() + } } } @@ -288,6 +302,20 @@ class ChatCoordinator @Inject constructor( return _state.value.activeChat == chatId } + suspend fun getOtherMemberE164(chatId: ChatId): String? { + val selfId = userManager.accountId + val localMembers = memberDataSource.getMembersForChat(chatId) + val otherMember = localMembers.firstOrNull { it.userId != selfId } + if (otherMember != null) return otherMember.userProfile.verifiedPhoneNumber + + // Chat not persisted locally yet — fetch from server + val metadata = chatController.getChat(chatId).getOrNull() ?: return null + memberDataSource.upsert(chatId, metadata.members) + return metadata.members + .firstOrNull { it.userId != selfId } + ?.userProfile?.verifiedPhoneNumber + } + fun dismissNotifications(chatId: ChatId) { notificationManager.cancel(chatId.hashCode()) } @@ -306,10 +334,15 @@ class ChatCoordinator @Inject constructor( return messagingController.notifyIsTyping(chatId, typingState) } + fun refreshFeed() { + syncFeed() + } + suspend fun reset() { stopHeartbeat() closeEventStream() syncJob?.cancel() + flagObserverJob?.cancel() _state.value = ChatState() cluster.value = null metadataDataSource.clear() @@ -322,6 +355,31 @@ class ChatCoordinator @Inject constructor( // region Internal + private suspend fun isChatEnabled(): Boolean { + val featureFlag = featureFlags.get(FeatureFlag.PhoneNumberSend) + val serverFlag = userManager.state.value.flags?.enablePhoneNumberSend == true + return featureFlag || serverFlag + } + + private fun observeFeatureFlag() { + flagObserverJob?.cancel() + flagObserverJob = combine( + featureFlags.observe(FeatureFlag.PhoneNumberSend), + userManager.state.map { it.flags?.enablePhoneNumberSend == true }, + ) { featureFlag, serverFlag -> featureFlag || serverFlag } + .distinctUntilChanged() + .filter { it } + .onEach { + if (cluster.value != null) { + trace(tag = TAG, message = "Chat feature enabled, syncing feed", type = TraceType.Process) + syncFeed() + openEventStream() + startHeartbeat() + } + } + .launchIn(scope) + } + private suspend fun hydrateFromPersistence() { val entities = metadataDataSource.observeAll().firstOrNull() ?: return if (entities.isEmpty()) return diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index edc5863ae..b068206f9 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -189,15 +189,13 @@ class NotificationService : FirebaseMessagingService(), body: String?, ): Int { val notificationId = chatId.hashCode() - val groupKeyE164 = groupKey?.takeIf { it.startsWith("+") } - val lookupContact = if (groupKeyE164 == null) { - contactCoordinator.lookupContactByDmChatId(chatId.toString()) - } else null - val e164 = groupKeyE164 ?: lookupContact?.e164 + val lookupContact = contactCoordinator.lookupContactByDmChatId(chatId.toString()) + val e164 = lookupContact?.e164 + ?: chatCoordinator.getOtherMemberE164(chatId) trace( tag = "NotificationService", - message = "applyContactChatStyle: chatId=$chatId, groupKey=$groupKey, groupKeyE164=$groupKeyE164, lookupE164=${lookupContact?.e164}, e164=$e164, authenticated=${userManager.accountCluster != null}", + message = "applyContactChatStyle: chatId=$chatId, groupKey=$groupKey, lookupE164=${lookupContact?.e164}, e164=$e164, authenticated=${userManager.accountCluster != null}", type = TraceType.Log, ) diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt index ccc4aabf7..01503a9ab 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt @@ -13,12 +13,14 @@ import androidx.compose.ui.Modifier 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 com.flipcash.app.analytics.StubFlipcashAnalytics import com.flipcash.app.permissions.ContactAccessHandle import com.flipcash.app.permissions.asContactAccessHandle import com.flipcash.app.permissions.internal.contacts.components.AnimatedContactListPreview import com.flipcash.app.permissions.internal.contacts.components.ContactPermissionBottomBar import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.shared.permissions.R import com.getcode.libs.analytics.LocalAnalytics import com.getcode.theme.CodeTheme @@ -76,13 +78,12 @@ fun ContactScreenContent( @Composable @Preview +@PreviewWrapper(FlipcashThemeWrapper::class) private fun PreviewContactPermissionScreen() { - FlipcashPreview(showBackground = true) { - CompositionLocalProvider(LocalAnalytics provides StubFlipcashAnalytics()) { - ProvideTestPermissions(granted = emptySet()) { - val state = rememberContactPermission() - ContactScreenContent(state.asContactAccessHandle(), onSkip = { }) - } + CompositionLocalProvider(LocalAnalytics provides StubFlipcashAnalytics()) { + ProvideTestPermissions(granted = emptySet()) { + val state = rememberContactPermission() + ContactScreenContent(state.asContactAccessHandle(), onSkip = { }) } } } \ No newline at end of file diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt index 184d288fc..a5c123dcf 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt @@ -140,7 +140,7 @@ data class Fiat( ) fun convertingToUsdIfNeeded(rate: Rate): Fiat { - return if (rate.currency != CurrencyCode.USD) { + return if (currencyCode != CurrencyCode.USD) { convertingTo(Rate(1 / rate.fx, CurrencyCode.USD)) } else { this diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/FiatTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/FiatTest.kt index 0165a754b..8df1c3f6a 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/FiatTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/FiatTest.kt @@ -286,6 +286,15 @@ class FiatTest { assertTrue(result === usd) // identity } + @Test + fun convertingToUsdIfNeededAlreadyUsdWithNonUsdRate() { + // Bug regression: USD balance + non-USD rate should NOT convert + val usd = Fiat(27.54, CurrencyCode.USD) + val arsRate = Rate(36.31, CurrencyCode.ARS) + val result = usd.convertingToUsdIfNeeded(arsRate) + assertTrue(result === usd) // already USD — must return self unchanged + } + // --- Constants --- @Test diff --git a/settings.gradle.kts b/settings.gradle.kts index dca2393cd..0fd255631 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -52,6 +52,7 @@ include( ":apps:flipcash:shared:bills", ":apps:flipcash:shared:bill-customization", ":apps:flipcash:shared:chat", + ":apps:flipcash:shared:chat-ui", ":apps:flipcash:shared:contacts", ":apps:flipcash:shared:currency-creator", ":apps:flipcash:shared:onramp:coinbase", diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/TextInput.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/TextInput.kt index 9c59e056c..bcffe61fa 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/TextInput.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/TextInput.kt @@ -199,7 +199,7 @@ private fun DecoratorBox( color = borderColor, shape = shape, ), - verticalAlignment = contentAlignment, + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x2) ) { leadingIcon?.invoke() @@ -225,7 +225,11 @@ private fun DecoratorBox( ) } } - trailingIcon?.invoke() + if (trailingIcon != null) { + Box(modifier = Modifier.align(contentAlignment)) { + trailingIcon() + } + } } } @@ -238,7 +242,9 @@ private fun TextInputEmptyPreview() { TextInput( state = remember { TextFieldState() }, placeholder = "Enter text", - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), ) } } @@ -250,7 +256,9 @@ private fun TextInputWithTextPreview() { TextInput( state = remember { TextFieldState("Hello, world!") }, placeholder = "Enter text", - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), ) } } @@ -263,7 +271,9 @@ private fun TextInputSingleLinePreview() { state = remember { TextFieldState() }, placeholder = "Single line", maxLines = 1, - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), ) } } @@ -276,7 +286,9 @@ private fun TextInputMultiLinePreview() { state = remember { TextFieldState("Line one\nLine two\nLine three") }, placeholder = "Multi line", maxLines = 4, - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), ) } } @@ -290,7 +302,9 @@ private fun TextInputUnboundedEmptyPreview() { placeholder = "Unbounded lines", maxLines = Int.MAX_VALUE, minHeight = 0.dp, - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), ) } } @@ -304,7 +318,9 @@ private fun TextInputUnboundedWithTextPreview() { placeholder = "Unbounded lines", maxLines = Int.MAX_VALUE, minHeight = 0.dp, - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), ) } } @@ -317,7 +333,9 @@ private fun TextInputDisabledPreview() { state = remember { TextFieldState("Disabled input") }, placeholder = "Disabled", enabled = false, - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), ) } } @@ -330,7 +348,9 @@ private fun TextInputErrorPreview() { state = remember { TextFieldState("Invalid value") }, placeholder = "Error state", isError = true, - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), ) } } @@ -346,7 +366,9 @@ private fun TextInputUnboundedLoremIpsumPreview() { placeholder = "Unbounded lines", maxLines = Int.MAX_VALUE, minHeight = 0.dp, - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), ) } } @@ -358,7 +380,9 @@ private fun TextInputWithIconsPreview() { TextInput( state = remember { TextFieldState() }, placeholder = "Search...", - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), leadingIcon = { Icon( imageVector = Icons.Default.Search, 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 d985f420c..6f9f0c03d 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 @@ -103,8 +103,8 @@ fun ChatInput( scaleX = sendScale scaleY = sendScale } - .padding(vertical = CodeTheme.dimens.grid.x1) - .padding(end = CodeTheme.dimens.staticGrid.x1) + .padding(vertical = CodeTheme.dimens.grid.x2) + .padding(end = CodeTheme.dimens.staticGrid.x2) .background( Color.White, shape = CodeTheme.shapes.extraSmall diff --git a/ui/resources/src/main/res/values/strings-localized.xml b/ui/resources/src/main/res/values/strings-localized.xml index 3cb8d66ec..171eafcce 100644 --- a/ui/resources/src/main/res/values/strings-localized.xml +++ b/ui/resources/src/main/res/values/strings-localized.xml @@ -318,7 +318,7 @@ App Settings Auto Start Camera Balance - Beta Flags + Beta Features Bonus Buy & Sell Kin Cash Payments