Skip to content
Merged
5 changes: 4 additions & 1 deletion apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@
<string name="label_solflare">Solflare</string>
<string name="label_backpack">Backpack</string>

<string name="title_advancedFeatures">Advanced Features</string>
<string name="title_advancedFeatures">Advanced</string>
<string name="title_wallet">Wallet</string>
<string name="action_wallet">Wallet</string>

Expand Down Expand Up @@ -776,4 +776,7 @@
<string name="label_chatReceipt_yesterday">Yesterday</string>
<string name="label_chatSeparator_today">Today</string>

<string name="title_sendFeatureIntro">Send Money To Your Friends</string>
<string name="subtitle_sendFeatureIntro">Send money to friends as easily as a text. Connect your phone number to get started</string>

</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/flipcash/features/direct-send/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -65,30 +72,45 @@ 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,
)
}
}
}
}

@Composable
@Preview
@PreviewWrapper(FlipcashThemeWrapper::class)
private fun PreviewPhoneGateScreen() {
CompositionLocalProvider(
LocalFlowNavigator provides PreviewFlowNavigator<SendStep, SendResult>(),
LocalExchange provides ExchangeStub(context = LocalContext.current),
) {
PhoneGateLandingScreen()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions apps/flipcash/features/messenger/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand All @@ -55,7 +54,6 @@ fun ChatAmountEntryScreen(identifier: ChatIdentifier) {
internal fun ChatAmountEntryContent(
amountDelegate: AmountEntryDelegate,
resolveState: ChatViewModel.ResolveState,
chattingWithName: String?,
token: Token?,
eventFlow: Flow<ChatViewModel.Event>,
onExit: () -> Unit,
Expand All @@ -79,15 +77,7 @@ internal fun ChatAmountEntryContent(
eventFlow
.filterIsInstance<ChatViewModel.Event.SendComplete>()
.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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -213,22 +162,37 @@ 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,
) {
Box(insertionModifier) {
ContentBubble(
item = item,
position = bubblePositionOf(index, item, messages, separatorConfig),
position = bubblePositionOf(
index,
item,
messages,
separatorConfig
),
)
}
val showReceipt =
shouldShowReceiptLabel(index, item, messages, otherReadPointer)
if (showReceipt && effectiveStatus != null) {
ReceiptLabel(
status = effectiveStatus,
readPointer = otherReadPointer
readPointer = otherReadPointer,
animateEntrance = wasSending,
)
}
}
Expand Down
Loading
Loading