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