From 13d2d107ed9de63aa58dd72159773b903ffe00f5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 15:07:22 -0500 Subject: [PATCH 01/10] feat(chat): send back a DM's opening payment The receiver of an offered cash message gets a Send Back row under the bubble. One GetCashMessageReturnStatus call per DM open decides whether it shows; a return in the transcript marks the original Sent Back for both people without a call. Tapping checks the token-quark balance at today's rate, then opens a confirm sheet or the existing insufficient-balance modal. The swipe pays the original's fiat amount back under the server's payment_intent_id with ReturnPayment metadata. The id stays in memory, is held in LiveSecrets, and is only logged masked. --- .../src/androidMain/res/values/strings.xml | 17 + .../app/messenger/internal/ChatViewModel.kt | 68 +++- .../internal/screens/MessengerScreen.kt | 11 + .../internal/screens/cash/SendBackSheet.kt | 128 ++++++++ .../internal/sendback/SendBackFlow.kt | 299 ++++++++++++++++++ .../shared/chat/ui/CashBubbleLinesTest.kt | 24 ++ .../flipcash/shared/chat/ui/MessageBubble.kt | 100 ++++++ .../flipcash/shared/chat/models/ChatAction.kt | 3 + .../shared/chat/models/ChatListItem.kt | 2 + .../shared/chat/models/SendBackRow.kt | 19 ++ .../shared/chat/ui/CashBubbleLines.kt | 27 ++ .../sendback/SendBackPaymentDelegate.kt | 99 ++++++ 12 files changed, 796 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/SendBackSheet.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlow.kt create mode 100644 apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/SendBackRow.kt create mode 100644 apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegate.kt diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index fbc6599a7c..c364144e86 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -2064,6 +2064,23 @@ You and %1$d others received from %1$s + + You sent back + + %1$s sent back + + to you + + Send Back + + Sent Back + + Send back to %1$s + Swipe to Send Back + Couldn\'t Send Back + Something went wrong. Please try again. + + Sent back to %1$s Someone Sent 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 74c700e487..c14a4fc351 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 @@ -178,6 +178,9 @@ import com.flipcash.shared.payments.ContactPaymentDelegate import com.flipcash.shared.payments.group.GroupSendInsufficientBalance import com.flipcash.shared.payments.group.GroupSendPaymentDelegate import com.flipcash.shared.payments.TipPaymentDelegate +import com.flipcash.shared.payments.sendback.SendBackPaymentDelegate +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.app.messenger.internal.sendback.SendBackFlow import com.getcode.libs.emojis.reactions.EmojiCatalogLoader import com.getcode.libs.emojis.reactions.EmojiDrawability import com.getcode.libs.emojis.reactions.RecentReactions @@ -327,6 +330,9 @@ internal class ChatViewModel @AssistedInject constructor( // Last and defaulted so the transcript tests, which hold no saved previews, need not supply one. linkCardMemory: LinkCardMemory? = null, private val composerLinkPreview: ComposerLinkPreview? = null, + // Defaulted for the same reason: only the Send Back tests supply them. + sendBackPayments: SendBackPaymentDelegate? = null, + chatMessagingController: ChatMessagingController? = null, ) : BaseViewModel( initialState = State( openingAtMessage = openAtMessageId != null, @@ -1400,6 +1406,47 @@ internal class ChatViewModel @AssistedInject constructor( .flatMapLatest { chatCoordinator.observeChatReactions(it) } .stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap()) + /** + * Send Back: returning a DM's opening payment. The transcript notes each cash message to it as + * it maps them, and its rows join the built pages as an overlay, like [reactionOverlay]. Null + * where its collaborators are not supplied (tests that don't exercise it). + */ + private val sendBack: SendBackFlow? = + if (sendBackPayments != null && chatMessagingController != null) { + SendBackFlow( + scope = viewModelScope, + payments = sendBackPayments, + returnStatus = chatMessagingController::getCashMessageReturnStatus, + payer = startChattingPayer, + tokenCoordinator = tokenCoordinator, + exchange = exchange, + userManager = userManager, + resources = resources, + analytics = analytics, + chatId = { stateFlow.value.chatId }, + peerName = { stateFlow.value.participant?.name }, + openScreen = { route -> dispatchEvent(Event.OpenScreen(route)) }, + ) + } else { + null + } + + /** The Send Back confirm sheet, or null when it is not up. */ + val sendBackSheet: StateFlow = + sendBack?.sheet ?: MutableStateFlow(null) + + fun onSendBack(messageId: Long) { + sendBack?.tap(messageId) + } + + fun confirmSendBack() { + sendBack?.confirm() + } + + fun dismissSendBack() { + sendBack?.dismiss() + } + /** * Null for a DM, a map for a group — the distinction the transcript needs, because a DM's * counterparty is already named in the title bar and attributing each of their bubbles would be @@ -1708,6 +1755,10 @@ internal class ChatViewModel @AssistedInject constructor( } } + (enriched as? MessageContent.Cash)?.let { cash -> + sendBack?.note(message.messageId, cash, message.senderId, message.isFromSelf) + } + ChatListItem.ContentBubble( messageId = message.messageId, contentIndex = index, @@ -1804,7 +1855,22 @@ internal class ChatViewModel @AssistedInject constructor( ) } } - }.cachedIn(pagingScope) + } + // Send Back's rows, the same way: an overlay on the built pages keyed by message id, so a + // status answer or a tap re-maps one field without re-running the pass above. + .let { pages -> + val rows = sendBack?.rows ?: return@let pages + combine(pages, rows) { data, overlay -> + if (overlay.isEmpty()) return@combine data + data.map { item -> + if (item !is ChatListItem.ContentBubble || item.content !is MessageContent.Cash) { + return@map item + } + overlay[item.messageId]?.let { item.copy(sendBack = it) } ?: item + } + } + } + .cachedIn(pagingScope) /** * The transcript as the list draws it: [mappedMessages] with date separators and the unread 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 a1d6b477d7..cd2e4a0a76 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 @@ -71,6 +71,7 @@ import com.flipcash.app.core.links.LocalTrustedWebsites import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.screens.cash.SendBackSheetHost import com.flipcash.app.messenger.internal.MemberModerationViewModel import com.flipcash.app.core.chat.ProfileOrigin import com.flipcash.app.messenger.internal.screens.profile.memberTapStep @@ -464,6 +465,9 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { is ChatAction.RefreshReactionIds -> { viewModel.dispatchEvent(ChatViewModel.Event.RefreshReactionIds(action.messageIds)) } + + // Keyboard first, so the confirm sheet or the balance modal rises over a settled chat. + is ChatAction.SendBack -> keyboard.hideIfVisible { viewModel.onSendBack(action.messageId) } } Unit @@ -796,6 +800,13 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } + val sendBackSheet by viewModel.sendBackSheet.collectAsStateWithLifecycle() + SendBackSheetHost( + sheet = sendBackSheet, + onConfirm = viewModel::confirmSendBack, + onDismiss = viewModel::dismissSendBack, + ) + // In-window, over the conversation and the deck, so the bottom bar draws above it. RequestPaySheet( pay = requestPayState, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/SendBackSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/SendBackSheet.kt new file mode 100644 index 0000000000..1ba7c32af3 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/SendBackSheet.kt @@ -0,0 +1,128 @@ +package com.flipcash.app.messenger.internal.screens.cash + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.flipcash.app.core.ui.TokenSelectionPill +import com.flipcash.app.messenger.internal.sendback.SendBackFlow +import com.flipcash.features.messenger.R +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import com.getcode.theme.CodeTheme +import com.getcode.theme.White10 +import com.getcode.ui.components.SlideToConfirm + +/** + * The Send Back confirm sheet over the chat, held in-screen like the fee sheet. The sheet stays + * composed until it has slid away, so a dismissal from the flow (the swipe, or a balance that + * dropped meanwhile) animates out like a drag does. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SendBackSheetHost( + sheet: SendBackFlow.Sheet?, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var shown by remember { mutableStateOf(null) } + LaunchedEffect(sheet) { + if (sheet != null) { + shown = sheet + } else if (shown != null) { + sheetState.hide() + shown = null + } + } + val current = shown ?: return + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = CodeTheme.colors.background, + ) { + SendBackSheet( + amount = current.amount, + token = current.token, + recipientName = current.recipientName, + onConfirm = onConfirm, + ) + } +} + +/** + * Returning a DM's opening payment: the original's amount, its currency read-only (the contract + * fixes currency and mint), who it goes back to, and the swipe. [ChatInitPaymentSheet]'s layout + * with the picker taken out. + */ +@Composable +internal fun SendBackSheet( + amount: Fiat, + token: Token?, + recipientName: String?, + onConfirm: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x6, bottom = CodeTheme.dimens.grid.x2), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Text( + text = amount.formatted(), + style = CodeTheme.typography.displayLarge, + color = CodeTheme.colors.textMain, + ) + + TokenSelectionPill( + token = token, + background = White10, + textStyle = CodeTheme.typography.textMedium, + imageSize = CodeTheme.dimens.staticGrid.x3, + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.grid.x2, + vertical = CodeTheme.dimens.grid.x1, + ), + showsPicker = false, + onClick = {}, + ) + + Text( + text = stringResource( + R.string.label_sendBackTo, + recipientName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.title_unnamedUser), + ), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + } + + SlideToConfirm( + onConfirm = onConfirm, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.action_swipeToSendBack), + ) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlow.kt new file mode 100644 index 0000000000..e239aab775 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlow.kt @@ -0,0 +1,299 @@ +package com.flipcash.app.messenger.internal.sendback + +import com.flipcash.analytics.SendContext +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics +import com.flipcash.app.core.AppRoute +import com.flipcash.app.messenger.internal.payment.StartChattingPayer +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.CashMessageReturnStatus +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.paymentrequest.PaymentIntentId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.models.SendBackRow +import com.flipcash.shared.payments.sendback.SendBackPaymentDelegate +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.base58 +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.LiveSecrets +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Send Back for one open DM: returning its opening payment to the person who paid it. + * + * The transcript [note]s each cash message as it maps it. From that, [rows] says what each bubble + * draws under its lines: a return in the transcript marks the message it names Sent Back, for both + * people; an offered message the viewer received gets one status check per DM open, and its row + * follows that answer and the viewer's taps. + * + * The server's `payment_intent_id` lives in [intentIds] only, for as long as this DM is open. It is + * never put in [rows] or [sheet], so it can't reach a log through a state's toString, and it is + * held in [LiveSecrets] from the moment it arrives. + */ +internal class SendBackFlow( + private val scope: CoroutineScope, + private val payments: SendBackPaymentDelegate, + private val returnStatus: suspend (ChatId, Long) -> Result, + private val payer: StartChattingPayer, + private val tokenCoordinator: TokenCoordinator, + private val exchange: Exchange, + private val userManager: UserManager, + private val resources: ResourceHelper, + private val analytics: FlipcashAnalytics, + /** The open DM, or null before it resolves. */ + private val chatId: () -> ChatId?, + /** The DM peer's name as the chat title shows it. */ + private val peerName: () -> String?, + /** Where "Add Money" goes. */ + private val openScreen: (AppRoute) -> Unit, +) { + /** What the confirm sheet draws. No intent id: see the class comment. */ + data class Sheet( + val messageId: Long, + val amount: Fiat, + val token: Token, + val recipientName: String?, + ) + + /** An offered payment the viewer received, as much of it as a return needs. */ + private data class Offer(val messageId: Long, val amount: Fiat, val mint: Mint, val payee: ID) + + private val lock = Any() + private val offers = mutableMapOf() + private val checked = mutableSetOf() + private val intentIds = mutableMapOf() + + /** Ids of messages a return in the transcript names. */ + private val returned = MutableStateFlow>(emptySet()) + + /** The row each offered message's status and the viewer's taps have put it in. */ + private val local = MutableStateFlow>(emptyMap()) + + val rows: StateFlow> = + combine(local, returned) { byState, byTranscript -> + byState + byTranscript.associateWith { SendBackRow.SentBack } + }.stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + private val _sheet = MutableStateFlow(null) + val sheet: StateFlow = _sheet.asStateFlow() + + /** A tap or swipe is between its balance check and its outcome. */ + private var busy = false + + /** + * Called for each cash message as the transcript maps it. Pages load newest first, and a return + * is always later in the DM than what it returns, so a loaded return is noted before its + * original: the status check below is skipped for an original whose return is already here. + */ + fun note(messageId: Long, cash: MessageContent.Cash, senderId: ID?, isFromSelf: Boolean) { + cash.paymentReturn?.returnOf?.let { of -> + if (of.messageId !in returned.value) returned.update { it + of.messageId } + } + if (cash.paymentReturn?.offer == null || isFromSelf || senderId == null) return + val self = userManager.accountId ?: return + // The offer is on the sender's copy too; only its single receiver may send it back. + if (cash.receiver != self) return + val chat = chatId() ?: return + synchronized(lock) { + if (!checked.add(messageId)) return + offers[messageId] = Offer(messageId, cash.amount, cash.mint, senderId) + } + scope.launch { checkStatus(chat, messageId) } + } + + private suspend fun checkStatus(chat: ChatId, messageId: Long) { + if (messageId in returned.value) return + // A failure leaves the row hidden, with no error; the next DM open asks again. + val status = returnStatus(chat, messageId).getOrNull() ?: return + when (status.status) { + CashMessageReturnStatus.Status.AVAILABLE -> { + status.paymentIntentId?.let { keep(messageId, it) } + setRowIfUnset(messageId, SendBackRow.Available) + } + CashMessageReturnStatus.Status.RETURNED -> setRow(messageId, SendBackRow.SentBack) + CashMessageReturnStatus.Status.UNKNOWN -> Unit + } + } + + /** The receiver tapped Send Back under [messageId]. */ + fun tap(messageId: Long) { + if (rows.value[messageId] != SendBackRow.Available || _sheet.value != null) return + val offer = synchronized(lock) { offers[messageId] } ?: return + if (!claim()) return + scope.launch { + try { + val token = tokenFor(offer.mint) + val hasBalance = token?.let { hasBalanceFor(offer, it) } + if (token == null || hasBalance == null) { + presentFailed() + return@launch + } + analytics.track(TransferEvents.sendBackTapped(hasBalance = hasBalance)) + if (hasBalance) { + _sheet.value = Sheet(messageId, offer.amount, token, peerName()) + } else { + presentInsufficientBalance() + } + } finally { + busy = false + } + } + } + + /** The swipe. The balance can change while the sheet is up, so it is checked again. */ + fun confirm() { + val open = _sheet.value ?: return + val offer = synchronized(lock) { offers[open.messageId] } ?: return + if (!claim()) return + scope.launch { + val hasBalance = hasBalanceFor(offer, open.token) + _sheet.value = null + if (hasBalance != true) { + busy = false + if (hasBalance == false) presentInsufficientBalance() else presentFailed() + return@launch + } + setRow(offer.messageId, SendBackRow.Sending) + try { + submit(offer, open.token) + } finally { + busy = false + } + } + } + + fun dismiss() { + _sheet.value = null + } + + private suspend fun submit(offer: Offer, token: Token) { + val id = offer.messageId + val chat = chatId() + val owner = userManager.accountCluster + if (chat == null || owner == null) { + failed(offer, IllegalStateException("no chat or account")) + return + } + val intentId = synchronized(lock) { intentIds[id] } ?: run { + val status = returnStatus(chat, id).getOrNull() + if (status?.status == CashMessageReturnStatus.Status.RETURNED) { + alreadyReturned(offer) + return + } + status?.paymentIntentId?.takeIf { status.status == CashMessageReturnStatus.Status.AVAILABLE } + ?.also { keep(id, it) } + } + if (intentId == null) { + failed(offer, IllegalStateException("no payment intent id")) + return + } + + payments.pay( + chatId = chat, + messageId = id, + payee = offer.payee, + amount = offer.amount, + token = token, + source = owner.withTimelockForToken(token), + intentId = intentId, + ).onSuccess { + setRow(id, SendBackRow.SentBack) + analytics.track(sentCash(AnalyticsState.SUCCESS, offer, null)) + }.onFailure { cause -> + // The outcome is unknown: a second tap or another device may have got there first. + val after = returnStatus(chat, id).getOrNull() + if (after?.status == CashMessageReturnStatus.Status.RETURNED) { + alreadyReturned(offer) + } else { + failed(offer, cause) + } + } + } + + private fun alreadyReturned(offer: Offer) { + setRow(offer.messageId, SendBackRow.SentBack) + analytics.track(sentCash(AnalyticsState.FAILURE, offer, ALREADY_RETURNED)) + } + + private fun failed(offer: Offer, cause: Throwable) { + setRow(offer.messageId, SendBackRow.Available) + analytics.track(sentCash(AnalyticsState.FAILURE, offer, cause.analytics)) + presentFailed() + } + + private fun sentCash(state: AnalyticsState, offer: Offer, error: String?) = + TransferEvents.sentCash(state, offer.amount.analytics, error, SendContext.SEND_BACK) + + /** + * Whether the viewer holds the token quarks [offer] needs at today's rate, or null when it + * can't be priced. While only a USD value of the holding is known (restored from Room, before + * the first balance fetch), the value is compared instead. + */ + private suspend fun hasBalanceFor(offer: Offer, token: Token): Boolean? { + val priced = payments.price(offer.amount, token).getOrNull() ?: return null + val needed = priced.localFiat.underlyingTokenAmount.quarks + val held = tokenCoordinator.heldQuarksForToken(offer.mint).first() + if (held != null) return held >= needed + val rate = exchange.rateFor(offer.amount.currencyCode) ?: return null + return !offer.amount.valueGreaterThan(tokenCoordinator.balanceForToken(token).convertingTo(rate)) + } + + private suspend fun tokenFor(mint: Mint): Token? = + tokenCoordinator.cachedToken(mint) ?: tokenCoordinator.getTokenMetadata(mint).getOrNull()?.token + + private fun keep(messageId: Long, intentId: PaymentIntentId) { + LiveSecrets.hold(PublicKey(intentId.bytes.toList()).base58()) + synchronized(lock) { intentIds[messageId] = intentId } + } + + private fun claim(): Boolean = synchronized(lock) { + if (busy) false else true.also { busy = true } + } + + private fun setRow(messageId: Long, row: SendBackRow) { + local.update { it + (messageId to row) } + } + + private fun setRowIfUnset(messageId: Long, row: SendBackRow) { + local.update { if (messageId in it) it else it + (messageId to row) } + } + + private fun presentInsufficientBalance() { + payer.presentInsufficientBalance(onAddMoney = ::presentDeposit) + } + + private fun presentDeposit() { + scope.launch { payer.depositRoute()?.let(openScreen) } + } + + private fun presentFailed() { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_couldntSendBack), + message = resources.getString(R.string.error_description_couldntSendBack), + ) + } + + private companion object { + /** `Sent Cash`'s error when the payment had already been returned. */ + const val ALREADY_RETURNED = "Already Returned" + } +} diff --git a/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/CashBubbleLinesTest.kt b/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/CashBubbleLinesTest.kt index d50ea78f90..616c53b1e3 100644 --- a/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/CashBubbleLinesTest.kt +++ b/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/CashBubbleLinesTest.kt @@ -226,6 +226,30 @@ class CashBubbleLinesTest { ) } + // Send Back: a return of a DM's opening payment. + + private fun returnOf() = cash().copy( + paymentReturn = MessageContent.Cash.PaymentReturn(returnOf = MessageContent.Cash.PaymentReturn.ReturnOf(7)), + ) + + @Test + fun `8 - the sender of a return reads that they sent it back`() { + assertEquals("You sent back" to "to Noah Parker", returnOf().read(isFromSelf = true, people = dm)) + } + + @Test + fun `9 - the receiver of a return reads who sent it back, to them`() { + assertEquals("Noah Parker sent back" to "to you", returnOf().read(isFromSelf = false, people = dm)) + } + + @Test + fun `an offered payment that is not a return keeps its DM lines`() { + val offered = cash().copy( + paymentReturn = MessageContent.Cash.PaymentReturn(offer = MessageContent.Cash.PaymentReturn.Offer), + ) + assertEquals("You received" to "from Noah Parker", offered.read(isFromSelf = false, people = dm)) + } + // Tips. @Test diff --git a/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 0f49a3a3df..ca513811bb 100644 --- a/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -57,6 +57,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import com.flipcash.core.resources.Res +import com.flipcash.core.resources.action_sendBack +import com.flipcash.core.resources.label_sentBack import com.flipcash.core.resources.label_edited import com.flipcash.core.resources.label_groupSendTotal import com.flipcash.core.resources.label_messageDeleted @@ -79,6 +81,9 @@ import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.LinkCard import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SendBackRow +import com.getcode.ui.theme.CodeCircularProgressIndicator +import androidx.compose.foundation.clickable import com.flipcash.shared.chat.models.LocalChatActionHandler import com.flipcash.shared.chat.ui.media.MediaMessageBubble import com.flipcash.shared.chat.ui.media.photoBody @@ -259,6 +264,12 @@ fun ContentBubble( onLongClick = onLongClick?.takeIf { interactive }, onDoubleClick = onDoubleClick?.takeIf { interactive }, attention = attention, + sendBack = item.sendBack, + onSendBack = if (interactive) { + { actionHandler(ChatAction.SendBack(item.messageId)) } + } else { + null + }, ) // A reply is a text bubble with a citation above the body. Routing it through @@ -929,7 +940,14 @@ private fun CashBubble( onDoubleClick: (() -> Unit)?, modifier: Modifier = Modifier, attention: () -> Float = { 0f }, + sendBack: SendBackRow? = null, + onSendBack: (() -> Unit)? = null, ) { + // A filled Send Back row spans the bubble edge to edge along its bottom, so the bubble drops its + // own inset and the lines above the row put it back. + val filledRow = sendBack == SendBackRow.Available || sendBack == SendBackRow.Sending + val insetH = BubbleDefaults.paddingHorizontal + val insetV = BubbleDefaults.paddingVertical Bubble( isFromSelf = isFromSelf, position = position, @@ -940,6 +958,8 @@ private fun CashBubble( onDoubleClick = onDoubleClick, modifier = modifier, attention = attention, + horizontalPadding = if (filledRow) Dp.Hairline else insetH, + verticalPadding = if (filledRow) Dp.Hairline else insetV, ) { // The same number the bill's "You received" sheet reads: a USDF payment converts from the // USD it settled at, anything else from today's rates. @@ -948,11 +968,13 @@ private fun CashBubble( val secondary = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Medium) val amountStyle = CodeTheme.typography.displayMedium + Column(modifier = Modifier.fillMaxWidth()) { // The gaps use the static grid, like the bubble's own padding, so each spec value maps // down to the same 5dp step at every screen width. Column( modifier = Modifier .fillMaxWidth() + .then(if (filledRow) Modifier.padding(horizontal = insetH, vertical = insetV) else Modifier) .padding(vertical = CodeTheme.dimens.staticGrid.x1), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -1064,6 +1086,59 @@ private fun CashBubble( overflow = TextOverflow.MiddleEllipsis, ) } + + if (sendBack == SendBackRow.SentBack) { + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x4), + text = stringResource(Res.string.label_sentBack), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + ) + } + } + + if (filledRow) { + SendBackActionRow( + sending = sendBack == SendBackRow.Sending, + onClick = onSendBack, + ) + } + } + } +} + +/** + * The white row along a cash bubble's bottom edge that offers Send Back, or holds a spinner while + * the return is submitted. Not clickable while sending, or when there is nothing to call. + */ +@Composable +private fun SendBackActionRow( + sending: Boolean, + onClick: (() -> Unit)?, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(CodeTheme.dimens.grid.x12) + .background(CodeTheme.colors.textMain) + .then( + if (!sending && onClick != null) Modifier.clickable(onClick = onClick) else Modifier + ), + contentAlignment = Alignment.Center, + ) { + if (sending) { + CodeCircularProgressIndicator( + modifier = Modifier.size(CodeTheme.dimens.grid.x4), + color = CodeTheme.colors.background, + strokeWidth = CodeTheme.dimens.thickBorder, + ) + } else { + Text( + text = stringResource(Res.string.action_sendBack), + style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.SemiBold), + color = CodeTheme.colors.background, + ) } } } @@ -1353,6 +1428,7 @@ private fun PreviewCashBubble( isFromSelf: Boolean, viewerPaid: Boolean = false, people: CashBubblePeople?, + sendBack: SendBackRow? = null, ) { CompositionLocalProvider( LocalExchange provides ExchangeStub() @@ -1367,10 +1443,34 @@ private fun PreviewCashBubble( onClick = null, onLongClick = null, onDoubleClick = null, + sendBack = sendBack, + onSendBack = {}, ) } } +/** Send Back frames S1, S2, S5 and S6, and the return bubble both ways. */ +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_CashBubble_SendBack() { + val offered = previewCash(5.0).copy( + paymentReturn = MessageContent.Cash.PaymentReturn(offer = MessageContent.Cash.PaymentReturn.Offer), + ) + val returned = previewCash(5.0).copy( + paymentReturn = MessageContent.Cash.PaymentReturn(returnOf = MessageContent.Cash.PaymentReturn.ReturnOf(1)), + ) + val fred = CashBubblePeople.Direct("Fred Wilson") + Column(verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2)) { + PreviewCashBubble(offered, isFromSelf = false, people = fred, sendBack = SendBackRow.Available) + PreviewCashBubble(offered, isFromSelf = false, people = fred, sendBack = SendBackRow.Sending) + PreviewCashBubble(offered, isFromSelf = false, people = fred, sendBack = SendBackRow.SentBack) + PreviewCashBubble(offered, isFromSelf = true, people = fred, sendBack = SendBackRow.SentBack) + PreviewCashBubble(returned, isFromSelf = true, people = fred) + PreviewCashBubble(returned, isFromSelf = false, people = fred) + } +} + /** Frame C. */ @Preview @PreviewWrapper(FlipcashThemeWrapper::class) diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index bb085882de..9fe9f196ff 100644 --- a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -18,6 +18,9 @@ sealed interface ChatAction { /** Opens who a group send ([ChatListItem.ContentBubble] with a multi-receiver cash) paid. */ data class ViewGroupSend(val bubble: ChatListItem.ContentBubble) : ChatAction + /** The receiver tapped Send Back under the opening payment in [messageId]. */ + data class SendBack(val messageId: Long) : ChatAction + /** * The group gate's Buy: opens the buy screen for [mint] straight away, so the reader comes back * to the gate once the buy lands. The gate sends [AddCash] instead when nothing they hold can diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index 145504d784..f5a79506df 100644 --- a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -214,6 +214,8 @@ sealed interface ChatListItem { * resolved them, which leaves the bubble its caption alone. */ val cashPeople: CashBubblePeople? = null, + /** The Send Back row under a cash bubble, or null for none. */ + val sendBack: SendBackRow? = null, ) : TranscriptMessage { /** * Who this bubble is attributed to, for grouping. [senderId] is the answer whenever the diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/SendBackRow.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/SendBackRow.kt new file mode 100644 index 0000000000..dcb7c40527 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/SendBackRow.kt @@ -0,0 +1,19 @@ +package com.flipcash.shared.chat.models + +/** + * What a cash bubble shows under its lines for Send Back: returning a DM's opening payment to its + * sender. Absent (null on the bubble) draws the bubble as before. + * + * Only the payment's receiver ever sees [Available] or [Sending]; its sender sees [SentBack] once + * the return is in the transcript. + */ +enum class SendBackRow { + /** The receiver can send the payment back. A tappable "Send Back" row. */ + Available, + + /** The return is being submitted. The row holds a spinner and takes no taps. */ + Sending, + + /** The payment has been returned. A "Sent Back" footer. */ + SentBack, +} diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/CashBubbleLines.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/CashBubbleLines.kt index 2dcd195663..454dc2afce 100644 --- a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/CashBubbleLines.kt +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/CashBubbleLines.kt @@ -5,15 +5,18 @@ import androidx.compose.runtime.Immutable import com.flipcash.core.resources.Res import com.flipcash.core.resources.label_cashFrom import com.flipcash.core.resources.label_cashSomeone +import com.flipcash.core.resources.label_cashToYou import com.flipcash.core.resources.label_groupSendEach import com.flipcash.core.resources.label_groupSendEachApprox import com.flipcash.core.resources.label_groupSendRecipients import com.flipcash.core.resources.label_groupSendTo import com.flipcash.core.resources.label_groupSendToPeople import com.flipcash.core.resources.subtitle_senderSent +import com.flipcash.core.resources.subtitle_senderSentBack import com.flipcash.core.resources.subtitle_youAndOthersReceived import com.flipcash.core.resources.subtitle_youReceived import com.flipcash.core.resources.subtitle_youSent +import com.flipcash.core.resources.subtitle_youSentBack import com.flipcash.core.resources.subtitle_youTipped import org.jetbrains.compose.resources.PluralStringResource import org.jetbrains.compose.resources.StringResource @@ -55,6 +58,12 @@ sealed interface CashCaption { /** A group member who was not paid reads who sent it. */ data class SenderSent(val sender: String?) : CashCaption + + /** The viewer sent this payment back: it returns an opening payment they were paid. */ + data object YouSentBack : CashCaption + + /** [sender] sent this payment back to the viewer. */ + data class SenderSentBack(val sender: String?) : CashCaption } /** The line at the foot of a cash bubble: who it went to, or who it came from. */ @@ -69,6 +78,9 @@ sealed interface CashCounterpart { data class ToPeople(val count: Int) : CashCounterpart data class From(val name: String?) : CashCounterpart + + /** A returned payment, read by the person it was returned to. */ + data object ToYou : CashCounterpart } @Immutable @@ -97,6 +109,17 @@ fun MessageContent.Cash.bubbleLines( MessageContent.Cash.Action.TIPPED -> CashCaption.YouTipped MessageContent.Cash.Action.SENT -> CashCaption.YouSent } + // A return names itself on both sides, and reads "to you" rather than "from" for its receiver: + // the money is going back, not arriving (Send Back spec, "The return message"). + if (paymentReturn?.returnOf != null && people !is CashBubblePeople.Group) { + val other = (people as? CashBubblePeople.Direct)?.other + return if (isFromSelf) { + CashBubbleLines(CashCaption.YouSentBack, people?.let { CashCounterpart.To(other) }) + } else { + CashBubbleLines(CashCaption.SenderSentBack(other), people?.let { CashCounterpart.ToYou }) + } + } + // The verb splits only on the sending side: a received tip and a received payment are the same // money arriving. val captionOnly = CashBubbleLines(if (isFromSelf) own else CashCaption.YouReceived, counterpart = null) @@ -212,6 +235,9 @@ internal inline fun CashCaption.resolve( is CashCaption.YouAndOthersReceived -> plural(Res.plurals.subtitle_youAndOthersReceived, others, arrayOf(others)) is CashCaption.SenderSent -> string(Res.string.subtitle_senderSent, arrayOf(sender.orSomeone(string))) + CashCaption.YouSentBack -> string(Res.string.subtitle_youSentBack, emptyArray()) + is CashCaption.SenderSentBack -> + string(Res.string.subtitle_senderSentBack, arrayOf(sender.orSomeone(string))) } internal inline fun CashCounterpart.resolve( @@ -225,6 +251,7 @@ internal inline fun CashCounterpart.resolve( ) is CashCounterpart.ToPeople -> plural(Res.plurals.label_groupSendToPeople, count, arrayOf(count)) is CashCounterpart.From -> string(Res.string.label_cashFrom, arrayOf(name.orSomeone(string))) + CashCounterpart.ToYou -> string(Res.string.label_cashToYou, emptyArray()) } private inline fun String?.orSomeone(string: (StringResource, Array) -> String): String = diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegate.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegate.kt new file mode 100644 index 0000000000..a63db553f7 --- /dev/null +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegate.kt @@ -0,0 +1,99 @@ +package com.flipcash.shared.payments.sendback + +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.controllers.ResolverController +import com.flipcash.services.models.buildReturnPaymentMetadata +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaymentIntentId +import com.flipcash.shared.payments.SharedStatePayment +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.exchange.VerifiedFiatCalculator +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.base58 +import com.getcode.utils.LiveSecrets +import com.getcode.utils.TraceType +import com.getcode.utils.maskedSuffix +import com.getcode.utils.trace +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Sends back a DM's opening payment: one public payment of the original's fiat amount, in its + * currency and mint, to the person who paid it, carrying `ChatMetadata.ReturnPayment` and submitted + * under the intent id `GetCashMessageReturnStatus` issued for that message. + * + * The amount is priced at today's rate, so the token count may differ from the original's. + * + * No UI, no status calls, and no balance check: those are the caller's. + */ +@Singleton +class SendBackPaymentDelegate @Inject constructor( + resolverController: ResolverController, + transactionController: TransactionController, + tokenCoordinator: TokenCoordinator, + private val verifiedFiatCalculator: VerifiedFiatCalculator, + private val exchange: Exchange, +) { + + private val payment = SharedStatePayment( + resolverController, transactionController, tokenCoordinator, verifiedFiatCalculator, + ) + + /** Where the pay outcome is logged; tests swap it to read the line. */ + @androidx.annotation.VisibleForTesting + internal var log: (message: String, type: TraceType) -> Unit = { message, type -> + trace(tag = "SendBack", message = message, type = type) + } + + /** + * [amount] in [token] at today's rate, uncapped by any balance, so its token quarks + * (`localFiat.underlyingTokenAmount.quarks`) are what the return needs. + */ + suspend fun price(amount: Fiat, token: Token): Result { + val rate = exchange.rateFor(amount.currencyCode) + ?: return Result.failure(ComputeVerifiedFiatError.StaleRate()) + return verifiedFiatCalculator.compute(amount = amount, token = token, balance = null, rate = rate) + } + + /** + * Pays [amount] in [token] from [source] to [payee], the original payment's sender, as the + * return of [messageId] in the DM [chatId], under [intentId]. + * + * The id is only ever logged masked, on either outcome: the spec keeps it out of every log. + */ + suspend fun pay( + chatId: ChatId, + messageId: Long, + payee: ID, + amount: Fiat, + token: Token, + source: AccountCluster, + intentId: PaymentIntentId, + ): Result { + val rate = exchange.rateFor(amount.currencyCode) + ?: return Result.failure(ComputeVerifiedFiatError.StaleRate()) + val priced = payment.price(listOf(amount), token, rate) + .getOrElse { return Result.failure(it) } + val key = PublicKey(intentId.bytes.toList()) + val masked = key.base58().also(LiveSecrets::hold).maskedSuffix() + return payment.pay( + payees = listOf(payee), + priced = priced, + token = token, + source = source, + appMetadata = buildReturnPaymentMetadata(chatId, messageId), + intentId = key, + ).onSuccess { + log("sent back under intent $masked", TraceType.Log) + }.onFailure { + log("send back failed under intent $masked", TraceType.Error) + } + } +} From a5fda220d0b9b539a057fcbb20e476bfa7c89057 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 15:13:08 -0500 Subject: [PATCH 02/10] feat(activity): title send-back rows and open their DM A returned payment reads "Sent back to " or " sent back" and opens the DM it was paid in. The DM is checked first, so a chat the server no longer has opens the row's details instead of an empty chat. --- .../app/inject/TransactionRouterModule.kt | 26 +++++++ .../balance/internal/HiltWalletViewModel.kt | 3 + .../balance/internal/WalletScreenContent.kt | 4 +- .../app/balance/internal/WalletViewModel.kt | 9 +++ .../TransactionDetailsMapperTest.kt | 15 ++++ .../TransactionItemMapperTest.kt | 69 +++++++++++++++++++ .../ActivityHistoryScreen.kt | 5 +- .../ActivityHistoryViewModel.kt | 4 ++ .../src/androidMain/res/values/strings.xml | 2 + .../transactionhistory/TransactionListItem.kt | 3 + .../transactionhistory/TransactionRouter.kt | 34 +++++++++ .../internal/TransactionDetailsMapper.kt | 5 +- .../internal/TransactionItemMapper.kt | 29 ++++++++ 13 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 apps/flipcash/app/src/main/kotlin/com/flipcash/app/inject/TransactionRouterModule.kt create mode 100644 apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/TransactionRouter.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/inject/TransactionRouterModule.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/inject/TransactionRouterModule.kt new file mode 100644 index 0000000000..5ede525f5c --- /dev/null +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/inject/TransactionRouterModule.kt @@ -0,0 +1,26 @@ +package com.flipcash.app.inject + +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatHydration +import com.flipcash.shared.transactionhistory.TransactionRouter +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object TransactionRouterModule { + /** + * A send-back's activity row opens its DM only when the chat is stored or the server still has + * it. An absent or unreachable chat opens the details instead. + */ + @Provides + fun providesTransactionRouter(chats: ChatCoordinator): TransactionRouter = + TransactionRouter.forChats { chatId -> + when (chats.hydrateChat(chatId)) { + ChatHydration.Stored, is ChatHydration.Fetched -> true + else -> false + } + } +} diff --git a/apps/flipcash/features/balance/src/androidMain/kotlin/com/flipcash/app/balance/internal/HiltWalletViewModel.kt b/apps/flipcash/features/balance/src/androidMain/kotlin/com/flipcash/app/balance/internal/HiltWalletViewModel.kt index 6fc32c8c25..5209fb7fe7 100644 --- a/apps/flipcash/features/balance/src/androidMain/kotlin/com/flipcash/app/balance/internal/HiltWalletViewModel.kt +++ b/apps/flipcash/features/balance/src/androidMain/kotlin/com/flipcash/app/balance/internal/HiltWalletViewModel.kt @@ -8,6 +8,7 @@ import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.user.UserManager import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator +import com.flipcash.shared.transactionhistory.TransactionRouter import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -22,6 +23,7 @@ internal class HiltWalletViewModel @Inject constructor( feedCoordinator: ActivityFeedCoordinator, tokenCoordinator: TokenCoordinator, walletReveal: WalletRevealCoordinator, + transactionRouter: TransactionRouter, ) : WalletViewModel( userManager = userManager, userFlags = userFlags, @@ -31,4 +33,5 @@ internal class HiltWalletViewModel @Inject constructor( feedCoordinator = feedCoordinator, tokenCoordinator = tokenCoordinator, walletReveal = walletReveal, + transactionRouter = transactionRouter, ) diff --git a/apps/flipcash/features/balance/src/commonMain/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt b/apps/flipcash/features/balance/src/commonMain/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt index 9d01f8ba60..b6b6a1a6f4 100644 --- a/apps/flipcash/features/balance/src/commonMain/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt +++ b/apps/flipcash/features/balance/src/commonMain/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt @@ -288,9 +288,7 @@ internal fun WalletScreenContent( } .padding(top = grid.x4, bottom = grid.x1), onItemClick = { item -> - dispatchEvent( - WalletViewModel.Event.OpenScreen(AppRoute.Sheets.TransactionDetails(item.messageId)) - ) + dispatchEvent(WalletViewModel.Event.OpenTransaction(item)) }, ) } diff --git a/apps/flipcash/features/balance/src/commonMain/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt b/apps/flipcash/features/balance/src/commonMain/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt index b93c8c3132..674da52a01 100644 --- a/apps/flipcash/features/balance/src/commonMain/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt +++ b/apps/flipcash/features/balance/src/commonMain/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt @@ -8,6 +8,7 @@ import com.flipcash.app.core.AppRoute import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator import com.flipcash.shared.transactionhistory.FeedSyncState import com.flipcash.shared.transactionhistory.TransactionListItem +import com.flipcash.shared.transactionhistory.TransactionRouter import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.tokens.WalletReveal @@ -40,6 +41,7 @@ open class WalletViewModel @Inject constructor( feedCoordinator: ActivityFeedCoordinator, tokenCoordinator: TokenCoordinator, walletReveal: WalletRevealCoordinator, + transactionRouter: TransactionRouter = TransactionRouter.DetailsOnly, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -117,6 +119,7 @@ open class WalletViewModel @Inject constructor( data object OpenCurrencySelection : Event data class OpenScreen(val screen: AppRoute) : Event + data class OpenTransaction(val item: TransactionListItem) : Event data object PresentDepositOptions: Event } @@ -151,6 +154,11 @@ open class WalletViewModel @Inject constructor( .onEach { provider -> dispatchEvent(Event.OnPreferredOnRampProviderChanged(provider)) } .launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { dispatchEvent(Event.OpenScreen(transactionRouter.routeFor(it.item))) } + .launchIn(viewModelScope) + eventFlow .filterIsInstance() .mapNotNull { @@ -211,6 +219,7 @@ open class WalletViewModel @Inject constructor( } Event.PresentDepositOptions -> { state -> state } is Event.OpenScreen -> { state -> state } + is Event.OpenTransaction -> { state -> state } } } } diff --git a/apps/flipcash/shared/transaction-history/src/androidHostTest/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt b/apps/flipcash/shared/transaction-history/src/androidHostTest/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt index 75f1e6952a..fc4f036355 100644 --- a/apps/flipcash/shared/transaction-history/src/androidHostTest/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt +++ b/apps/flipcash/shared/transaction-history/src/androidHostTest/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt @@ -11,6 +11,7 @@ import com.flipcash.app.core.feed.SwapState import com.flipcash.app.core.feed.SwappedCryptoMetadata import com.flipcash.services.models.BoostedReaction import com.flipcash.services.models.RequestedPayment +import com.flipcash.services.models.ReturnedPayment import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.Emoji @@ -122,6 +123,20 @@ class TransactionDetailsMapperTest { holderMetrics = HolderMetrics.None, ) + @Test + fun `a send-back is a send or receive whatever the verb says`() { + val returned = ReturnedPayment(chatId = ChatId(ByteArray(32) { 4 }), messageId = 11L) + + assertEquals( + TransactionKind.Sent, + map(MessageMetadata.DirectlySentCrypto(userId = knownUserId, returnedPayment = returned), text = "Tipped").kind, + ) + assertEquals( + TransactionKind.Received, + map(MessageMetadata.ReceivedCrypto(userId = knownUserId, returnedPayment = returned), text = "Tip").kind, + ) + } + @Test fun `the verb separates a tip from a plain send`() { val meta = MessageMetadata.DirectlySentCrypto(userId = knownUserId) diff --git a/apps/flipcash/shared/transaction-history/src/androidHostTest/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt b/apps/flipcash/shared/transaction-history/src/androidHostTest/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt index 91131e1b30..9a54b4d037 100644 --- a/apps/flipcash/shared/transaction-history/src/androidHostTest/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt +++ b/apps/flipcash/shared/transaction-history/src/androidHostTest/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt @@ -13,6 +13,10 @@ import com.flipcash.app.core.feed.MessageSubstitution import com.flipcash.app.core.feed.SwapState import com.flipcash.app.core.feed.SwappedCryptoMetadata import com.flipcash.services.models.BoostedReaction +import com.flipcash.services.models.ReturnedPayment +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier +import kotlinx.coroutines.test.runTest import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.Emoji @@ -39,6 +43,8 @@ class TransactionItemMapperTest { .stub(R.string.title_activity_tipFrom, "Tip from %1\$s") .stub(R.string.title_activity_sentTo, "Sent to %1\$s") .stub(R.string.title_activity_receivedFrom, "Received from %1\$s") + .stub(R.string.title_activity_sentBackTo, "Sent back to %1\$s") + .stub(R.string.title_activity_sentBack, "%1\$s sent back") .stub(R.string.title_activity_convert, "%1\$s → %2\$s") .stub(R.string.title_activity_boostedFor, "Boosted %1\$s for %2\$s") .stub(R.string.title_activity_boostFrom, "Boost %1\$s from %2\$s") @@ -555,4 +561,67 @@ class TransactionItemMapperTest { assertEquals(TransactionAvatar.TokenIcon(usdfToken()), item.avatar) assertEquals(msg.amount, item.amount) } + + // --- Send back (spec test cases 16 and 17) --- + + private val dmId = ChatId(ByteArray(32) { 4 }) + private val returned = ReturnedPayment(chatId = dmId, messageId = 11L) + + private fun returnItem(metadata: MessageMetadata, profiles: Map = cached) = + mapper.map( + ActivityFeedMessageWithToken(feedMessage(metadata).copy(text = "Sent"), token = null) to profiles + ) + + @Test + fun `16 a sent return reads Sent back to the peer and opens the DM`() = runTest { + val item = returnItem(MessageMetadata.DirectlySentCrypto(userId = knownUserId, returnedPayment = returned)) + + assertEquals("Sent back to Sally The Streamer", item.title) + assertEquals(dmId, item.returnChatId) + assertEquals( + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(dmId)), + TransactionRouter.forChats { true }.routeFor(item), + ) + } + + @Test + fun `17 a received return reads the peer sent back and opens the DM`() = runTest { + val item = returnItem(MessageMetadata.ReceivedCrypto(userId = knownUserId, returnedPayment = returned)) + + assertEquals("Sally The Streamer sent back", item.title) + assertEquals( + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(dmId)), + TransactionRouter.forChats { true }.routeFor(item), + ) + } + + @Test + fun `a return with an unresolved peer keeps the server title`() { + val item = returnItem( + MessageMetadata.ReceivedCrypto(userId = knownUserId, returnedPayment = returned), + profiles = emptyMap(), + ) + + assertEquals("Sent", item.title) + } + + @Test + fun `a return whose DM is gone opens its details`() = runTest { + val item = returnItem(MessageMetadata.DirectlySentCrypto(userId = knownUserId, returnedPayment = returned)) + + assertEquals( + AppRoute.Sheets.TransactionDetails(item.messageId), + TransactionRouter.forChats { false }.routeFor(item), + ) + } + + @Test + fun `a row with no return opens its details without asking for the chat`() = runTest { + val item = returnItem(MessageMetadata.DirectlySentCrypto(userId = knownUserId)) + + assertEquals( + AppRoute.Sheets.TransactionDetails(item.messageId), + TransactionRouter.forChats { error("no chat lookup expected") }.routeFor(item), + ) + } } diff --git a/apps/flipcash/shared/transaction-history/src/androidMain/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryScreen.kt b/apps/flipcash/shared/transaction-history/src/androidMain/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryScreen.kt index d9724f4b7b..76baf06910 100644 --- a/apps/flipcash/shared/transaction-history/src/androidMain/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryScreen.kt +++ b/apps/flipcash/shared/transaction-history/src/androidMain/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryScreen.kt @@ -1,5 +1,7 @@ package com.flipcash.shared.transactionhistory +import kotlinx.coroutines.launch +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -35,6 +37,7 @@ import dev.chrisbanes.haze.rememberHazeState fun ActivityHistoryScreen() { val navigator = LocalCodeNavigator.current val viewModel = hiltViewModel() + val scope = rememberCoroutineScope() val items = viewModel.transactions.collectAsLazyPagingItems() val listState = rememberLazyListState() @@ -73,7 +76,7 @@ fun ActivityHistoryScreen() { ActivityFeedRow( item = item, modifier = Modifier.fillMaxWidth(), - onClick = { navigator.push(AppRoute.Sheets.TransactionDetails(item.messageId)) }, + onClick = { scope.launch { navigator.push(viewModel.routeFor(item)) } }, ) } diff --git a/apps/flipcash/shared/transaction-history/src/androidMain/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryViewModel.kt b/apps/flipcash/shared/transaction-history/src/androidMain/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryViewModel.kt index 70f364f601..0756e4c194 100644 --- a/apps/flipcash/shared/transaction-history/src/androidMain/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryViewModel.kt +++ b/apps/flipcash/shared/transaction-history/src/androidMain/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryViewModel.kt @@ -1,5 +1,6 @@ package com.flipcash.shared.transactionhistory +import com.flipcash.app.core.AppRoute import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData @@ -16,7 +17,10 @@ import javax.inject.Inject @HiltViewModel class ActivityHistoryViewModel @Inject constructor( feedCoordinator: ActivityFeedCoordinator, + private val router: TransactionRouter, ) : ViewModel() { + suspend fun routeFor(item: TransactionListItem): AppRoute = router.routeFor(item) + val transactions: Flow> = feedCoordinator.recentTransactions().cachedIn(viewModelScope) } diff --git a/apps/flipcash/shared/transaction-history/src/androidMain/res/values/strings.xml b/apps/flipcash/shared/transaction-history/src/androidMain/res/values/strings.xml index a786d72cd5..c390eb4bae 100644 --- a/apps/flipcash/shared/transaction-history/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/shared/transaction-history/src/androidMain/res/values/strings.xml @@ -6,6 +6,8 @@ Sent to %1$s Received from %1$s + Sent back to %1$s + %1$s sent back Boosted %1$s for %2$s Boost %1$s from %2$s diff --git a/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt b/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt index d58bde92fb..4d47e0f942 100644 --- a/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt +++ b/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt @@ -1,5 +1,6 @@ package com.flipcash.shared.transactionhistory +import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.UserProfile import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat @@ -59,4 +60,6 @@ data class TransactionListItem( val amount: LocalFiat?, // message.amount, or null if non-financial val fee: Fiat?, // converts only: what the swap cost, shown under the amount val canCancel: Boolean, + // The DM a send-back was paid in, so tapping the row opens that chat (see [TransactionRouter]). + val returnChatId: ChatId? = null, ) diff --git a/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/TransactionRouter.kt b/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/TransactionRouter.kt new file mode 100644 index 0000000000..9c2afdf9aa --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/TransactionRouter.kt @@ -0,0 +1,34 @@ +package com.flipcash.shared.transactionhistory + +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.services.models.chat.ChatId + +/** + * Where tapping an activity row leads. + * + * Most rows open their details. A send-back opens the DM it was paid in, falling back to the + * details when that DM can't be opened: the server no longer has it, or it can't be reached right + * now. Without that check a missing DM would open as an empty chat. + * + * The chat lookup is passed in rather than read here because the chat module already depends on + * this one; the app binds it (see `TransactionRouterModule`). + */ +fun interface TransactionRouter { + suspend fun routeFor(item: TransactionListItem): AppRoute + + companion object { + /** Every row opens its details. For callers with no chat to open. */ + val DetailsOnly = TransactionRouter { item -> AppRoute.Sheets.TransactionDetails(item.messageId) } + + /** Opens a send-back's DM when [canOpen] says it exists, else the row's details. */ + fun forChats(canOpen: suspend (ChatId) -> Boolean) = TransactionRouter { item -> + val chatId = item.returnChatId + if (chatId != null && canOpen(chatId)) { + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId)) + } else { + AppRoute.Sheets.TransactionDetails(item.messageId) + } + } + } +} diff --git a/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt b/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt index ff8557b56f..8cd9332743 100644 --- a/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt +++ b/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt @@ -162,13 +162,16 @@ internal class TransactionDetailsMapper @Inject constructor( */ private fun kindOf(meta: MessageMetadata?, text: String): TransactionKind = when (meta) { is MessageMetadata.DirectlySentCrypto -> when { + // A send-back always names a DM peer; the verb heuristics below must not reclassify it. + meta.returnedPayment != null -> TransactionKind.Sent meta.boostedReaction != null -> TransactionKind.Boosted isUnidentifiedBill(meta) -> TransactionKind.GaveCash text.isTipVerb() -> TransactionKind.Tipped else -> TransactionKind.Sent } is MessageMetadata.ReceivedCrypto -> - if (meta.boostedReaction != null) TransactionKind.BoostReceived + if (meta.returnedPayment != null) TransactionKind.Received + else if (meta.boostedReaction != null) TransactionKind.BoostReceived else if (isUnidentifiedBill(meta)) TransactionKind.ReceivedCash else TransactionKind.Received is MessageMetadata.IndirectlySentCrypto -> TransactionKind.SentCashLink is MessageMetadata.WithdrewCrypto -> TransactionKind.Withdraw diff --git a/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt b/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt index 10a0ee7b3e..79dbc9d4ae 100644 --- a/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt +++ b/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt @@ -4,12 +4,15 @@ import com.flipcash.shared.transactionhistory.generated.resources.Res import com.flipcash.shared.transactionhistory.generated.resources.title_activity_convert import com.flipcash.shared.transactionhistory.generated.resources.title_activity_receivedFrom import com.flipcash.shared.transactionhistory.generated.resources.title_activity_sentTo +import com.flipcash.shared.transactionhistory.generated.resources.title_activity_sentBack +import com.flipcash.shared.transactionhistory.generated.resources.title_activity_sentBackTo import com.flipcash.shared.transactionhistory.generated.resources.title_activity_tipFrom import com.flipcash.app.core.feed.ActivityFeedMessageWithToken import com.flipcash.app.core.feed.MessageMetadata import com.flipcash.app.core.feed.MessageSubstitution import com.flipcash.services.models.BoostedReaction import com.flipcash.services.models.RequestedPayment +import com.flipcash.services.models.ReturnedPayment import com.flipcash.services.models.UserProfile import com.flipcash.shared.transactionhistory.TransactionAvatar import com.flipcash.shared.transactionhistory.TransactionListItem @@ -69,6 +72,7 @@ internal class TransactionItemMapper @Inject constructor( messageId = msg.id, title = convertTitle(resources, source) ?: boostTitle(resources, meta, counterparty, source.boostChatName) + ?: returnTitle(resources, meta, counterparty) ?: resolveTitle(resources, meta, msg.text, msg.textSubstitutions, counterparty, profiles), timestamp = msg.timestamp, avatar = avatar, @@ -76,6 +80,7 @@ internal class TransactionItemMapper @Inject constructor( amount = msg.amount, fee = convert?.fee, canCancel = (meta as? MessageMetadata.IndirectlySentCrypto)?.canCancel == true, + returnChatId = returnedPaymentOf(meta)?.chatId, ) } } @@ -134,6 +139,30 @@ internal fun boostedReactionOf(meta: MessageMetadata?): BoostedReaction? = when else -> null } +/** + * The title of a payment that sent back a DM's opening payment, or null when [meta] carries no + * return or the counterparty's name is not known yet, so the row falls back to the server's title. + */ +private fun returnTitle( + resources: ResourceHelper, + meta: MessageMetadata?, + counterparty: UserProfile?, +): String? { + if (returnedPaymentOf(meta) == null) return null + val name = counterparty?.displayName?.takeIf { it.isNotBlank() } ?: return null + return when (meta) { + is MessageMetadata.DirectlySentCrypto -> resources.getString(Res.string.title_activity_sentBackTo, name) + is MessageMetadata.ReceivedCrypto -> resources.getString(Res.string.title_activity_sentBack, name) + else -> null + } +} + +internal fun returnedPaymentOf(meta: MessageMetadata?): ReturnedPayment? = when (meta) { + is MessageMetadata.DirectlySentCrypto -> meta.returnedPayment + is MessageMetadata.ReceivedCrypto -> meta.returnedPayment + else -> null +} + internal fun requestedPaymentOf(meta: MessageMetadata?): RequestedPayment? = when (meta) { is MessageMetadata.DirectlySentCrypto -> meta.requestedPayment is MessageMetadata.ReceivedCrypto -> meta.requestedPayment From af50c5aa3dfb2fe21c0b905c016f665ff5aa43a8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 15:17:06 -0500 Subject: [PATCH 03/10] test(chat): cover Send Back rows, taps, submit and the intent id --- .../internal/sendback/SendBackFlowTest.kt | 374 ++++++++++++++++++ .../sendback/SendBackPaymentDelegateTest.kt | 136 +++++++ 2 files changed, 510 insertions(+) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlowTest.kt create mode 100644 apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegateTest.kt diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlowTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlowTest.kt new file mode 100644 index 0000000000..bf19eddf56 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlowTest.kt @@ -0,0 +1,374 @@ +package com.flipcash.app.messenger.internal.sendback + +import com.flipcash.analytics.SendContext +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.analytics.analytics +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.messenger.internal.payment.StartChattingPayer +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.models.chat.CashMessageReturnStatus +import com.flipcash.services.models.chat.CashMessageReturnStatus.Status +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.MessageContent.Cash.PaymentReturn +import com.flipcash.services.models.paymentrequest.PaymentIntentId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.models.SendBackRow +import com.flipcash.shared.payments.sendback.SendBackPaymentDelegate +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.Mint +import com.getcode.util.resources.ResourceHelper +import com.getcode.vendor.Base58 +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** Spec test cases 1–7, 10, 11, 13–15, 18 and 19. Case 12 is SendBackPaymentDelegateTest's. */ +@OptIn(ExperimentalCoroutinesApi::class) +class SendBackFlowTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val payments = mockk(relaxed = true) + private val payer = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val exchange = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val token = mockk(relaxed = true) + private val owner = mockk(relaxed = true) + private val mint = mockk(relaxed = true) + private val scope = CoroutineScope(Dispatchers.Unconfined) + private val analytics = RecordingAnalytics() + + private val me: ID = listOf(1) + private val peer: ID = listOf(2) + private val chat = ChatId(ByteArray(32) { 3 }) + private val offeredId = 10L + private val amount = Fiat("5.00", CurrencyCode.USD) + private val intentBytes = ByteArray(32) { 7 } + private val intentId = PaymentIntentId(intentBytes) + + /** Quarks the return needs at today's rate, and quarks the viewer holds. */ + private val needed = 500L + private var held: Long? = 1_000L + + private var statusCalls = 0 + private var statuses = ArrayDeque>() + + @Before + fun setUp() { + BottomBarManager.clear() + every { userManager.accountId } returns me + every { userManager.accountCluster } returns owner + every { owner.withTimelockForToken(any()) } returns owner + every { tokenCoordinator.cachedToken(any()) } returns token + every { tokenCoordinator.heldQuarksForToken(any()) } answers { flowOf(held) } + val priced = mockk(relaxed = true) { + every { localFiat.underlyingTokenAmount.quarks } returns needed + } + coEvery { payments.price(any(), any()) } returns Result.success(priced) + coEvery { payments.pay(any(), any(), any(), any(), any(), any(), any()) } returns Result.success(Unit) + } + + @After + fun tearDown() { + scope.cancel() + BottomBarManager.clear() + } + + private fun status(s: Status, id: PaymentIntentId? = intentId) = Result.success(CashMessageReturnStatus(s, id)) + + private fun answers(first: Result, then: Result? = null) { + statuses = ArrayDeque(listOfNotNull(first, then)) + } + + private fun flow() = SendBackFlow( + scope = scope, + payments = payments, + returnStatus = { _, _ -> + statusCalls++ + statuses.removeFirstOrNull() ?: Result.failure(IllegalStateException("no status")) + }, + payer = payer, + tokenCoordinator = tokenCoordinator, + exchange = exchange, + userManager = userManager, + resources = resources, + analytics = analytics, + chatId = { chat }, + peerName = { "Sally" }, + openScreen = {}, + ) + + private fun cash( + receiver: ID? = me, + offer: Boolean = true, + returnOf: Long? = null, + ) = MessageContent.Cash( + intentId = listOf(9), + amount = amount, + mint = mint, + receiver = receiver, + paymentReturn = PaymentReturn( + offer = if (offer) PaymentReturn.Offer else null, + returnOf = returnOf?.let { PaymentReturn.ReturnOf(it) }, + ), + ) + + /** The peer's offered payment to me, as the transcript notes it. */ + private fun SendBackFlow.noteOffer() = note(offeredId, cash(), senderId = peer, isFromSelf = false) + + private fun SendBackFlow.available(): SendBackFlow = apply { + answers(status(Status.AVAILABLE)) + noteOffer() + } + + private fun sentCash(state: AnalyticsState, error: String?) = + TransferEvents.sentCash(state, amount.analytics, error, SendContext.SEND_BACK) + + // --- Rows (1–7) --- + + @Test + fun `1 the receiver of an available offer sees Send Back`() { + val f = flow().available() + + assertEquals(SendBackRow.Available, f.rows.value[offeredId]) + assertEquals(1, statusCalls) + } + + @Test + fun `2 the sender of an offer sees no row and makes no status call`() { + val f = flow() + answers(status(Status.AVAILABLE)) + + f.note(offeredId, cash(receiver = peer), senderId = me, isFromSelf = true) + + assertNull(f.rows.value[offeredId]) + assertEquals(0, statusCalls) + } + + @Test + fun `2 a viewer who is not the single receiver makes no status call`() { + val f = flow() + + f.note(offeredId, cash(receiver = listOf(5)), senderId = peer, isFromSelf = false) + + assertNull(f.rows.value[offeredId]) + assertEquals(0, statusCalls) + } + + @Test + fun `3 a cash message with no offer gets no row and no status call`() { + val f = flow() + + f.note(offeredId, cash(offer = false), senderId = peer, isFromSelf = false) + + assertNull(f.rows.value[offeredId]) + assertEquals(0, statusCalls) + } + + @Test + fun `4 a return already in the transcript marks the offer Sent Back without a status call`() { + val f = flow() + answers(status(Status.AVAILABLE)) + + // Newest first: the return is noted before the original it names. + f.note(20L, cash(receiver = peer, offer = false, returnOf = offeredId), senderId = me, isFromSelf = true) + f.noteOffer() + + assertEquals(SendBackRow.SentBack, f.rows.value[offeredId]) + assertEquals(0, statusCalls) + } + + @Test + fun `5 status RETURNED marks the offer Sent Back`() { + val f = flow() + answers(status(Status.RETURNED, id = null)) + + f.noteOffer() + + assertEquals(SendBackRow.SentBack, f.rows.value[offeredId]) + } + + @Test + fun `6 the original sender sees Sent Back once a return names their message`() { + val f = flow() + + f.note(offeredId, cash(receiver = peer), senderId = me, isFromSelf = true) + f.note(20L, cash(receiver = me, offer = false, returnOf = offeredId), senderId = peer, isFromSelf = false) + + assertEquals(SendBackRow.SentBack, f.rows.value[offeredId]) + assertEquals(0, statusCalls) + } + + @Test + fun `7 a failed status call leaves no row and shows no error`() { + val f = flow() + answers(Result.failure(IllegalStateException("offline"))) + + f.noteOffer() + + assertNull(f.rows.value[offeredId]) + assertEquals(0, BottomBarManager.messages.value.size) + } + + @Test + fun `the status check runs once per DM open however often the message is mapped`() { + val f = flow().available() + + f.noteOffer() + f.noteOffer() + + assertEquals(1, statusCalls) + } + + // --- Tap (10, 11) --- + + @Test + fun `10 a tap with enough balance opens the sheet naming the peer`() { + val f = flow().available() + + f.tap(offeredId) + + val sheet = assertNotNull(f.sheet.value) + assertEquals("Sally", sheet.recipientName) + assertEquals(amount, sheet.amount) + assertEquals(listOf(TransferEvents.sendBackTapped(hasBalance = true)), analytics.events) + verify(exactly = 0) { payer.presentInsufficientBalance(any(), any()) } + } + + @Test + fun `11 a tap with too little balance shows the modal instead of the sheet`() { + held = needed - 1 + val f = flow().available() + + f.tap(offeredId) + + assertNull(f.sheet.value) + verify(exactly = 1) { payer.presentInsufficientBalance(any(), any()) } + assertEquals(listOf(TransferEvents.sendBackTapped(hasBalance = false)), analytics.events) + } + + // --- Submit (13–15, 19) --- + + private fun SendBackFlow.swipe(): SendBackFlow = apply { + tap(offeredId) + analytics.events.clear() + confirm() + } + + @Test + fun `13 a successful return marks the row Sent Back and sends Sent Cash Success`() { + val f = flow().available().swipe() + + assertNull(f.sheet.value) + assertEquals(SendBackRow.SentBack, f.rows.value[offeredId]) + assertEquals(listOf(sentCash(AnalyticsState.SUCCESS, null)), analytics.events) + coVerify(exactly = 1) { + payments.pay(chat, offeredId, peer, amount, token, owner, intentId) + } + // The id from the DM-open check is reused; no second status call. + assertEquals(1, statusCalls) + } + + @Test + fun `14 a failed return the server reports RETURNED is Sent Back with no error`() { + coEvery { payments.pay(any(), any(), any(), any(), any(), any(), any()) } returns + Result.failure(IllegalStateException("boom")) + val f = flow().available() + answers(status(Status.RETURNED, id = null)) + + f.swipe() + + assertEquals(SendBackRow.SentBack, f.rows.value[offeredId]) + assertEquals(0, BottomBarManager.messages.value.size) + assertEquals(listOf(sentCash(AnalyticsState.FAILURE, "Already Returned")), analytics.events) + } + + @Test + fun `15 a failed return still available goes back to Available with the error`() { + coEvery { payments.pay(any(), any(), any(), any(), any(), any(), any()) } returns + Result.failure(IllegalStateException("boom")) + val f = flow().available() + answers(status(Status.AVAILABLE)) + + f.swipe() + + assertEquals(SendBackRow.Available, f.rows.value[offeredId]) + assertEquals(1, BottomBarManager.messages.value.size) + assertEquals(listOf(sentCash(AnalyticsState.FAILURE, "boom")), analytics.events) + } + + @Test + fun `status already RETURNED at submit makes no payment and counts as Already Returned`() { + val f = flow() + // Available with no id: the submit has to ask again, and learns it was returned. + answers(status(Status.AVAILABLE, id = null), status(Status.RETURNED, id = null)) + f.noteOffer() + + f.swipe() + + assertEquals(SendBackRow.SentBack, f.rows.value[offeredId]) + coVerify(exactly = 0) { payments.pay(any(), any(), any(), any(), any(), any(), any()) } + assertEquals(listOf(sentCash(AnalyticsState.FAILURE, "Already Returned")), analytics.events) + } + + @Test + fun `19 a balance that drops while the sheet is open stops the swipe`() { + val f = flow().available() + f.tap(offeredId) + held = needed - 1 + + f.confirm() + + assertNull(f.sheet.value) + assertEquals(SendBackRow.Available, f.rows.value[offeredId]) + verify(exactly = 1) { payer.presentInsufficientBalance(any(), any()) } + coVerify(exactly = 0) { payments.pay(any(), any(), any(), any(), any(), any(), any()) } + } + + // --- 18: no intent id outside the payment --- + + @Test + fun `18 no event and no exposed state carries the intent id`() { + coEvery { payments.pay(any(), any(), any(), any(), any(), any(), any()) } returns + Result.failure(IllegalStateException("boom")) + val f = flow().available() + f.tap(offeredId) + val sheet = f.sheet.value.toString() + answers(status(Status.AVAILABLE)) + f.confirm() + + val base58 = Base58.encode(intentBytes) + val forms = listOf(base58, intentBytes.toList().toString()) + val exposed = analytics.events.map { it.toString() } + sheet + f.rows.value.toString() + for (text in exposed) for (form in forms) assertFalse(form in text, "intent id in: $text") + } +} diff --git a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegateTest.kt b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegateTest.kt new file mode 100644 index 0000000000..2bee7725e2 --- /dev/null +++ b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegateTest.kt @@ -0,0 +1,136 @@ +package com.flipcash.shared.payments.sendback + +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.controllers.ResolverController +import com.flipcash.services.models.buildReturnPaymentMetadata +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaymentIntentId +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.exchange.VerifiedFiatCalculator +import com.getcode.opencode.internal.manager.VerifiedState +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.PublicKey +import com.getcode.utils.LiveSecrets +import com.getcode.vendor.Base58 +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class SendBackPaymentDelegateTest { + + private val resolverController = mockk() + private val transactionController = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val calculator = mockk() + private val exchange = mockk(relaxed = true) + + private val delegate = SendBackPaymentDelegate( + resolverController, transactionController, tokenCoordinator, calculator, exchange, + ) + + private val chatId = ChatId(ByteArray(32) { 9 }) + private val messageId = 41L + private val originalSender: ID = List(16) { 1 } + private val senderOwner = mockk() + private val intentBytes = ByteArray(32) { 7 } + private val token = mockk(relaxed = true) + private val source = mockk(relaxed = true) + private val state = mockk() + private val eur = mockk() + private val original = Fiat("5.00", CurrencyCode.EUR) + + init { + every { exchange.rateFor(CurrencyCode.EUR) } returns eur + coEvery { resolverController.resolve(userId = originalSender) } returns Result.success(senderOwner) + coEvery { calculator.compute(any(), any(), any(), any(), any()) } returns + Result.success(VerifiedFiat(mockk(relaxed = true), state)) + } + + private suspend fun pay() = delegate.pay( + chatId = chatId, + messageId = messageId, + payee = originalSender, + amount = original, + token = token, + source = source, + intentId = PaymentIntentId(intentBytes), + ) + + @Test + fun `12 the return pays the original's amount to its sender under the server's intent id`() = runTest { + val amount = slot() + val rate = slot() + coEvery { calculator.compute(capture(amount), any(), any(), capture(rate), any()) } returns + Result.success(VerifiedFiat(mockk(relaxed = true), state)) + val metadata = slot() + val intentId = slot() + coEvery { + transactionController.directTransfer(any(), token, source, senderOwner, capture(metadata), any(), capture(intentId)) + } returns Result.success(mockk()) + + assertTrue(pay().isSuccess) + + // Native amount and currency are the original's; the token count comes from today's rate. + assertEquals(original, amount.captured) + assertSame(eur, rate.captured) + assertContentEquals(intentBytes, intentId.captured.byteArray) + assertContentEquals(buildReturnPaymentMetadata(chatId, messageId), metadata.captured) + } + + @Test + fun `price is not capped by any balance, so its quarks are what the return needs`() = runTest { + val balance = slot() + coEvery { calculator.compute(any(), any(), captureNullable(balance), any(), any()) } returns + Result.success(VerifiedFiat(mockk(relaxed = true), state)) + + assertTrue(delegate.price(original, token).isSuccess) + assertEquals(null, balance.captured) + } + + private val intentBase58 = Base58.encode(intentBytes) + + private suspend fun logLinesFor(outcome: Result): List { + coEvery { + transactionController.directTransfer(any(), token, source, senderOwner, any(), any(), any()) + } returns outcome.map { mockk() } + val lines = mutableListOf() + delegate.log = { message, _ -> lines += message } + pay() + return lines + } + + @Test + fun `18 a successful return logs the intent id masked only`() = runTest { + val line = logLinesFor(Result.success(Unit)).single() + + assertFalse(intentBase58 in line, line) + assertTrue(line.endsWith("…${intentBase58.takeLast(6)}"), line) + assertTrue(LiveSecrets.isLive(intentBase58)) + LiveSecrets.release(intentBase58) + } + + @Test + fun `18 a failed return logs the intent id masked only`() = runTest { + val line = logLinesFor(Result.failure(IllegalStateException("boom"))).single() + + assertFalse(intentBase58 in line, line) + assertTrue(LiveSecrets.isLive(intentBase58)) + LiveSecrets.release(intentBase58) + } +} From a09d235326c1e3c8f29c9ceef825274b0cf92085 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 16:02:57 -0500 Subject: [PATCH 04/10] fix(chat): pay the Send Back amount the balance check priced SendBackPaymentDelegate.pay read the rate again and re-priced the return, so the token amount it sent could differ from the one the swipe's balance check approved. It also priced through SharedStatePayment.price, which caps at the balance, while the check priced uncapped. pay now takes the VerifiedFiat from the swipe's check and sends it as is. --- .../internal/sendback/SendBackFlow.kt | 36 +++++++++------- .../internal/sendback/SendBackFlowTest.kt | 24 +++++++++-- .../sendback/SendBackPaymentDelegate.kt | 13 +++--- .../sendback/SendBackPaymentDelegateTest.kt | 41 +++++++++++++++---- 4 files changed, 80 insertions(+), 34 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlow.kt index e239aab775..01bdbeec2f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlow.kt @@ -18,6 +18,7 @@ import com.flipcash.shared.chat.models.SendBackRow import com.flipcash.shared.payments.sendback.SendBackPaymentDelegate import com.getcode.manager.BottomBarManager import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiat import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Token @@ -142,7 +143,7 @@ internal class SendBackFlow( scope.launch { try { val token = tokenFor(offer.mint) - val hasBalance = token?.let { hasBalanceFor(offer, it) } + val hasBalance = token?.let { checkBalance(offer, it) }?.hasBalance if (token == null || hasBalance == null) { presentFailed() return@launch @@ -159,22 +160,25 @@ internal class SendBackFlow( } } - /** The swipe. The balance can change while the sheet is up, so it is checked again. */ + /** + * The swipe. The balance can change while the sheet is up, so it is checked again, and the + * amount that check priced is the one paid. + */ fun confirm() { val open = _sheet.value ?: return val offer = synchronized(lock) { offers[open.messageId] } ?: return if (!claim()) return scope.launch { - val hasBalance = hasBalanceFor(offer, open.token) + val check = checkBalance(offer, open.token) _sheet.value = null - if (hasBalance != true) { + if (check?.hasBalance != true) { busy = false - if (hasBalance == false) presentInsufficientBalance() else presentFailed() + if (check != null) presentInsufficientBalance() else presentFailed() return@launch } setRow(offer.messageId, SendBackRow.Sending) try { - submit(offer, open.token) + submit(offer, open.token, check.priced) } finally { busy = false } @@ -185,7 +189,7 @@ internal class SendBackFlow( _sheet.value = null } - private suspend fun submit(offer: Offer, token: Token) { + private suspend fun submit(offer: Offer, token: Token, priced: VerifiedFiat) { val id = offer.messageId val chat = chatId() val owner = userManager.accountCluster @@ -211,7 +215,7 @@ internal class SendBackFlow( chatId = chat, messageId = id, payee = offer.payee, - amount = offer.amount, + priced = priced, token = token, source = owner.withTimelockForToken(token), intentId = intentId, @@ -243,18 +247,22 @@ internal class SendBackFlow( private fun sentCash(state: AnalyticsState, offer: Offer, error: String?) = TransferEvents.sentCash(state, offer.amount.analytics, error, SendContext.SEND_BACK) + /** [offer] priced once at today's rate, and whether the viewer holds that much. */ + private class BalanceCheck(val priced: VerifiedFiat, val hasBalance: Boolean) + /** - * Whether the viewer holds the token quarks [offer] needs at today's rate, or null when it - * can't be priced. While only a USD value of the holding is known (restored from Room, before - * the first balance fetch), the value is compared instead. + * Prices [offer] and checks the viewer holds the token quarks it needs, or null when it can't + * be priced. While only a USD value of the holding is known (restored from Room, before the + * first balance fetch), the value is compared instead. */ - private suspend fun hasBalanceFor(offer: Offer, token: Token): Boolean? { + private suspend fun checkBalance(offer: Offer, token: Token): BalanceCheck? { val priced = payments.price(offer.amount, token).getOrNull() ?: return null val needed = priced.localFiat.underlyingTokenAmount.quarks val held = tokenCoordinator.heldQuarksForToken(offer.mint).first() - if (held != null) return held >= needed + if (held != null) return BalanceCheck(priced, held >= needed) val rate = exchange.rateFor(offer.amount.currencyCode) ?: return null - return !offer.amount.valueGreaterThan(tokenCoordinator.balanceForToken(token).convertingTo(rate)) + val hasBalance = !offer.amount.valueGreaterThan(tokenCoordinator.balanceForToken(token).convertingTo(rate)) + return BalanceCheck(priced, hasBalance) } private suspend fun tokenFor(mint: Mint): Token? = diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlowTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlowTest.kt index bf19eddf56..1096d945c0 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlowTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlowTest.kt @@ -79,6 +79,12 @@ class SendBackFlowTest { private val needed = 500L private var held: Long? = 1_000L + private fun pricedAt(quarks: Long) = mockk(relaxed = true) { + every { localFiat.underlyingTokenAmount.quarks } returns quarks + } + + private val priced = pricedAt(needed) + private var statusCalls = 0 private var statuses = ArrayDeque>() @@ -90,9 +96,6 @@ class SendBackFlowTest { every { owner.withTimelockForToken(any()) } returns owner every { tokenCoordinator.cachedToken(any()) } returns token every { tokenCoordinator.heldQuarksForToken(any()) } answers { flowOf(held) } - val priced = mockk(relaxed = true) { - every { localFiat.underlyingTokenAmount.quarks } returns needed - } coEvery { payments.price(any(), any()) } returns Result.success(priced) coEvery { payments.pay(any(), any(), any(), any(), any(), any(), any()) } returns Result.success(Unit) } @@ -292,12 +295,25 @@ class SendBackFlowTest { assertEquals(SendBackRow.SentBack, f.rows.value[offeredId]) assertEquals(listOf(sentCash(AnalyticsState.SUCCESS, null)), analytics.events) coVerify(exactly = 1) { - payments.pay(chat, offeredId, peer, amount, token, owner, intentId) + payments.pay(chat, offeredId, peer, priced, token, owner, intentId) } // The id from the DM-open check is reused; no second status call. assertEquals(1, statusCalls) } + @Test + fun `the swipe pays the amount its balance check priced, without pricing again`() { + val atTap = pricedAt(needed) + val atSwipe = pricedAt(needed + 1) + coEvery { payments.price(any(), any()) } returnsMany listOf(Result.success(atTap), Result.success(atSwipe)) + + flow().available().swipe() + + coVerify(exactly = 1) { payments.pay(chat, offeredId, peer, atSwipe, token, owner, intentId) } + // One price for the tap's check and one for the swipe's; none between the check and pay. + coVerify(exactly = 2) { payments.price(amount, token) } + } + @Test fun `14 a failed return the server reports RETURNED is Sent Back with no error`() { coEvery { payments.pay(any(), any(), any(), any(), any(), any(), any()) } returns diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegate.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegate.kt index a63db553f7..74d6144d34 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegate.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegate.kt @@ -63,29 +63,28 @@ class SendBackPaymentDelegate @Inject constructor( } /** - * Pays [amount] in [token] from [source] to [payee], the original payment's sender, as the + * Pays [priced] in [token] from [source] to [payee], the original payment's sender, as the * return of [messageId] in the DM [chatId], under [intentId]. * + * [priced] is [price]'s answer for the balance check just made, sent as is: pricing again here + * could read a newer rate and pay a different token count from the one checked. + * * The id is only ever logged masked, on either outcome: the spec keeps it out of every log. */ suspend fun pay( chatId: ChatId, messageId: Long, payee: ID, - amount: Fiat, + priced: VerifiedFiat, token: Token, source: AccountCluster, intentId: PaymentIntentId, ): Result { - val rate = exchange.rateFor(amount.currencyCode) - ?: return Result.failure(ComputeVerifiedFiatError.StaleRate()) - val priced = payment.price(listOf(amount), token, rate) - .getOrElse { return Result.failure(it) } val key = PublicKey(intentId.bytes.toList()) val masked = key.base58().also(LiveSecrets::hold).maskedSuffix() return payment.pay( payees = listOf(payee), - priced = priced, + priced = listOf(priced), token = token, source = source, appMetadata = buildReturnPaymentMetadata(chatId, messageId), diff --git a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegateTest.kt b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegateTest.kt index 2bee7725e2..1db0079a7e 100644 --- a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegateTest.kt +++ b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegateTest.kt @@ -21,9 +21,11 @@ import com.getcode.solana.keys.PublicKey import com.getcode.utils.LiveSecrets import com.getcode.vendor.Base58 import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.slot +import io.mockk.verify import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertContentEquals @@ -54,6 +56,7 @@ class SendBackPaymentDelegateTest { private val state = mockk() private val eur = mockk() private val original = Fiat("5.00", CurrencyCode.EUR) + private val priced = VerifiedFiat(mockk(relaxed = true), state) init { every { exchange.rateFor(CurrencyCode.EUR) } returns eur @@ -66,7 +69,7 @@ class SendBackPaymentDelegateTest { chatId = chatId, messageId = messageId, payee = originalSender, - amount = original, + priced = priced, token = token, source = source, intentId = PaymentIntentId(intentBytes), @@ -74,25 +77,45 @@ class SendBackPaymentDelegateTest { @Test fun `12 the return pays the original's amount to its sender under the server's intent id`() = runTest { - val amount = slot() - val rate = slot() - coEvery { calculator.compute(capture(amount), any(), any(), capture(rate), any()) } returns - Result.success(VerifiedFiat(mockk(relaxed = true), state)) + val sent = slot() val metadata = slot() val intentId = slot() coEvery { - transactionController.directTransfer(any(), token, source, senderOwner, capture(metadata), any(), capture(intentId)) + transactionController.directTransfer(capture(sent), token, source, senderOwner, capture(metadata), any(), capture(intentId)) } returns Result.success(mockk()) assertTrue(pay().isSuccess) - // Native amount and currency are the original's; the token count comes from today's rate. - assertEquals(original, amount.captured) - assertSame(eur, rate.captured) + assertSame(priced, sent.captured) assertContentEquals(intentBytes, intentId.captured.byteArray) assertContentEquals(buildReturnPaymentMetadata(chatId, messageId), metadata.captured) } + @Test + fun `12 the return is priced in the original's amount and currency at today's rate`() = runTest { + val amount = slot() + val rate = slot() + coEvery { calculator.compute(capture(amount), any(), any(), capture(rate), any()) } returns + Result.success(VerifiedFiat(mockk(relaxed = true), state)) + + assertTrue(delegate.price(original, token).isSuccess) + + assertEquals(original, amount.captured) + assertSame(eur, rate.captured) + } + + @Test + fun `pay sends the amount it is given and does not price again`() = runTest { + coEvery { + transactionController.directTransfer(any(), token, source, senderOwner, any(), any(), any()) + } returns Result.success(mockk()) + + pay() + + coVerify(exactly = 0) { calculator.compute(any(), any(), any(), any(), any()) } + verify(exactly = 0) { exchange.rateFor(any()) } + } + @Test fun `price is not capped by any balance, so its quarks are what the return needs`() = runTest { val balance = slot() From 4aab906c2be5e8b619c7340d900b439b35143dce Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 16:16:12 -0500 Subject: [PATCH 05/10] fix(chat): size the Send Back row 44 and the Sent Back footer 36 Per the Send Back spec's sizes: the Available/Sending row drops from grid.x12 to grid.x11. The Sent Back footer is a grid.x9 band centred along the bubble's bottom edge, in place of the bubble's bottom padding, instead of a caption 16 below the last line inside it. --- .../flipcash/shared/chat/ui/MessageBubble.kt | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index ca513811bb..69b66f9a31 100644 --- a/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -944,8 +944,10 @@ private fun CashBubble( onSendBack: (() -> Unit)? = null, ) { // A filled Send Back row spans the bubble edge to edge along its bottom, so the bubble drops its - // own inset and the lines above the row put it back. + // own inset and the lines above the row put it back. The Sent Back footer takes the place of the + // bottom padding, so the lines above it put back every side but the bottom. val filledRow = sendBack == SendBackRow.Available || sendBack == SendBackRow.Sending + val sentBack = sendBack == SendBackRow.SentBack val insetH = BubbleDefaults.paddingHorizontal val insetV = BubbleDefaults.paddingVertical Bubble( @@ -958,8 +960,8 @@ private fun CashBubble( onDoubleClick = onDoubleClick, modifier = modifier, attention = attention, - horizontalPadding = if (filledRow) Dp.Hairline else insetH, - verticalPadding = if (filledRow) Dp.Hairline else insetV, + horizontalPadding = if (filledRow || sentBack) Dp.Hairline else insetH, + verticalPadding = if (filledRow || sentBack) Dp.Hairline else insetV, ) { // The same number the bill's "You received" sheet reads: a USDF payment converts from the // USD it settled at, anything else from today's rates. @@ -974,8 +976,17 @@ private fun CashBubble( Column( modifier = Modifier .fillMaxWidth() - .then(if (filledRow) Modifier.padding(horizontal = insetH, vertical = insetV) else Modifier) - .padding(vertical = CodeTheme.dimens.staticGrid.x1), + .then( + when { + filledRow -> Modifier + .padding(horizontal = insetH, vertical = insetV) + .padding(vertical = CodeTheme.dimens.staticGrid.x1) + sentBack -> Modifier + .padding(start = insetH, end = insetH, top = insetV) + .padding(top = CodeTheme.dimens.staticGrid.x1) + else -> Modifier.padding(vertical = CodeTheme.dimens.staticGrid.x1) + } + ), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( @@ -1087,9 +1098,16 @@ private fun CashBubble( ) } - if (sendBack == SendBackRow.SentBack) { + } + + if (sentBack) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(CodeTheme.dimens.grid.x9), + contentAlignment = Alignment.Center, + ) { Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x4), text = stringResource(Res.string.label_sentBack), style = CodeTheme.typography.caption, color = CodeTheme.colors.textSecondary, @@ -1120,7 +1138,7 @@ private fun SendBackActionRow( Box( modifier = Modifier .fillMaxWidth() - .height(CodeTheme.dimens.grid.x12) + .height(CodeTheme.dimens.grid.x11) .background(CodeTheme.colors.textMain) .then( if (!sending && onClick != null) Modifier.clickable(onClick = onClick) else Modifier From 73a7251856bb9f90883aa213735e7c73d7896619 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 18:43:31 -0500 Subject: [PATCH 06/10] fix(chat): show Rate Unavailable when a Send Back can't be priced A tap or swipe with no fresh rate, or before the quark balance has loaded, now shows "Rate Unavailable" instead of "Couldn't Send Back", which stays for a failed submit. These taps reach neither the sheet nor the insufficient-balance modal, so they no longer send Send Back: Tapped. The balance check compares quarks only. The fiat comparison it fell back to before the balance loaded could pass a balance just short of the return, so that case now counts as undetermined. --- .../app/messenger/internal/ChatViewModel.kt | 1 - .../internal/sendback/SendBackFlow.kt | 28 +++++---- .../internal/sendback/SendBackFlowTest.kt | 59 +++++++++++++++++-- 3 files changed, 71 insertions(+), 17 deletions(-) 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 c14a4fc351..c1b6dd04f5 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 @@ -1419,7 +1419,6 @@ internal class ChatViewModel @AssistedInject constructor( returnStatus = chatMessagingController::getCashMessageReturnStatus, payer = startChattingPayer, tokenCoordinator = tokenCoordinator, - exchange = exchange, userManager = userManager, resources = resources, analytics = analytics, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlow.kt index 01bdbeec2f..ba1add2c0f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlow.kt @@ -17,7 +17,6 @@ import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.models.SendBackRow import com.flipcash.shared.payments.sendback.SendBackPaymentDelegate import com.getcode.manager.BottomBarManager -import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.exchange.VerifiedFiat import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat @@ -56,7 +55,6 @@ internal class SendBackFlow( private val returnStatus: suspend (ChatId, Long) -> Result, private val payer: StartChattingPayer, private val tokenCoordinator: TokenCoordinator, - private val exchange: Exchange, private val userManager: UserManager, private val resources: ResourceHelper, private val analytics: FlipcashAnalytics, @@ -145,7 +143,8 @@ internal class SendBackFlow( val token = tokenFor(offer.mint) val hasBalance = token?.let { checkBalance(offer, it) }?.hasBalance if (token == null || hasBalance == null) { - presentFailed() + // Not a tap the funnel counts: it reached neither the sheet nor the modal. + presentRateUnavailable() return@launch } analytics.track(TransferEvents.sendBackTapped(hasBalance = hasBalance)) @@ -173,7 +172,7 @@ internal class SendBackFlow( _sheet.value = null if (check?.hasBalance != true) { busy = false - if (check != null) presentInsufficientBalance() else presentFailed() + if (check != null) presentInsufficientBalance() else presentRateUnavailable() return@launch } setRow(offer.messageId, SendBackRow.Sending) @@ -251,18 +250,15 @@ internal class SendBackFlow( private class BalanceCheck(val priced: VerifiedFiat, val hasBalance: Boolean) /** - * Prices [offer] and checks the viewer holds the token quarks it needs, or null when it can't - * be priced. While only a USD value of the holding is known (restored from Room, before the - * first balance fetch), the value is compared instead. + * Prices [offer] and compares the token quarks it needs with the quarks the viewer holds, + * exactly. Null when that can't be decided: no fresh rate to price it, or no quark balance + * loaded yet. A fiat comparison is no stand-in, since rounding passes a balance just short. */ private suspend fun checkBalance(offer: Offer, token: Token): BalanceCheck? { val priced = payments.price(offer.amount, token).getOrNull() ?: return null val needed = priced.localFiat.underlyingTokenAmount.quarks - val held = tokenCoordinator.heldQuarksForToken(offer.mint).first() - if (held != null) return BalanceCheck(priced, held >= needed) - val rate = exchange.rateFor(offer.amount.currencyCode) ?: return null - val hasBalance = !offer.amount.valueGreaterThan(tokenCoordinator.balanceForToken(token).convertingTo(rate)) - return BalanceCheck(priced, hasBalance) + val held = tokenCoordinator.heldQuarksForToken(offer.mint).first() ?: return null + return BalanceCheck(priced, held >= needed) } private suspend fun tokenFor(mint: Mint): Token? = @@ -293,6 +289,14 @@ internal class SendBackFlow( scope.launch { payer.depositRoute()?.let(openScreen) } } + private fun presentRateUnavailable() { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_staleRates), + message = resources.getString(R.string.error_description_staleRates), + ) + } + + /** A submit that failed, the return still not made (test case 15). */ private fun presentFailed() { BottomBarManager.showError( title = resources.getString(R.string.error_title_couldntSendBack), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlowTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlowTest.kt index 1096d945c0..06e02c1c3e 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlowTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlowTest.kt @@ -8,6 +8,7 @@ import com.flipcash.app.analytics.analytics import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.CashMessageReturnStatus import com.flipcash.services.models.chat.CashMessageReturnStatus.Status import com.flipcash.services.models.chat.ChatId @@ -18,7 +19,6 @@ import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.models.SendBackRow import com.flipcash.shared.payments.sendback.SendBackPaymentDelegate import com.getcode.manager.BottomBarManager -import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.exchange.VerifiedFiat import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.core.ID @@ -58,7 +58,6 @@ class SendBackFlowTest { private val payments = mockk(relaxed = true) private val payer = mockk(relaxed = true) private val tokenCoordinator = mockk(relaxed = true) - private val exchange = mockk(relaxed = true) private val userManager = mockk(relaxed = true) private val resources = mockk(relaxed = true) private val token = mockk(relaxed = true) @@ -97,6 +96,8 @@ class SendBackFlowTest { every { tokenCoordinator.cachedToken(any()) } returns token every { tokenCoordinator.heldQuarksForToken(any()) } answers { flowOf(held) } coEvery { payments.price(any(), any()) } returns Result.success(priced) + every { resources.getString(R.string.error_title_staleRates) } returns RATE_UNAVAILABLE + every { resources.getString(R.string.error_title_couldntSendBack) } returns COULDNT_SEND_BACK coEvery { payments.pay(any(), any(), any(), any(), any(), any(), any()) } returns Result.success(Unit) } @@ -121,7 +122,6 @@ class SendBackFlowTest { }, payer = payer, tokenCoordinator = tokenCoordinator, - exchange = exchange, userManager = userManager, resources = resources, analytics = analytics, @@ -279,6 +279,52 @@ class SendBackFlowTest { assertEquals(listOf(TransferEvents.sendBackTapped(hasBalance = false)), analytics.events) } + // --- Balance that can't be determined --- + + private fun noFreshRate() { + coEvery { payments.price(any(), any()) } returns Result.failure(IllegalStateException("stale")) + } + + @Test + fun `a tap with no fresh rate shows Rate Unavailable and sends no Tapped event`() { + noFreshRate() + val f = flow().available() + + f.tap(offeredId) + + assertNull(f.sheet.value) + assertEquals(listOf(RATE_UNAVAILABLE), BottomBarManager.messages.value.map { it.title }) + verify(exactly = 0) { payer.presentInsufficientBalance(any(), any()) } + assertEquals(emptyList(), analytics.events) + } + + @Test + fun `a tap before the balance has loaded shows Rate Unavailable and sends no Tapped event`() { + held = null + val f = flow().available() + + f.tap(offeredId) + + assertNull(f.sheet.value) + assertEquals(listOf(RATE_UNAVAILABLE), BottomBarManager.messages.value.map { it.title }) + verify(exactly = 0) { payer.presentInsufficientBalance(any(), any()) } + assertEquals(emptyList(), analytics.events) + } + + @Test + fun `a swipe with no fresh rate closes the sheet with Rate Unavailable and pays nothing`() { + val f = flow().available() + f.tap(offeredId) + noFreshRate() + + f.confirm() + + assertNull(f.sheet.value) + assertEquals(SendBackRow.Available, f.rows.value[offeredId]) + assertEquals(listOf(RATE_UNAVAILABLE), BottomBarManager.messages.value.map { it.title }) + coVerify(exactly = 0) { payments.pay(any(), any(), any(), any(), any(), any(), any()) } + } + // --- Submit (13–15, 19) --- private fun SendBackFlow.swipe(): SendBackFlow = apply { @@ -338,7 +384,7 @@ class SendBackFlowTest { f.swipe() assertEquals(SendBackRow.Available, f.rows.value[offeredId]) - assertEquals(1, BottomBarManager.messages.value.size) + assertEquals(listOf(COULDNT_SEND_BACK), BottomBarManager.messages.value.map { it.title }) assertEquals(listOf(sentCash(AnalyticsState.FAILURE, "boom")), analytics.events) } @@ -387,4 +433,9 @@ class SendBackFlowTest { val exposed = analytics.events.map { it.toString() } + sheet + f.rows.value.toString() for (text in exposed) for (form in forms) assertFalse(form in text, "intent id in: $text") } + + private companion object { + const val RATE_UNAVAILABLE = "Rate Unavailable" + const val COULDNT_SEND_BACK = "Couldn't Send Back" + } } From 8d7c3e02d5bd2aed8d46abcd4a9954b577c8b0ec Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 4 Nov 2026 14:10:42 -0600 Subject: [PATCH 07/10] fix(chat): divide the Sent Back footer and capitalize "to You" The Sent Back footer now has a full-width hairline along its top edge, per the spec change in flipcash-client-orchestrator #115. It is the app's standard section divider, HorizontalDivider in CodeTheme.colors.divider at CodeTheme.dimens.border, as in GroupInviteSheet and CurrencySendScreen. A return bubble's receiver now reads "to You". label_cashToYou ("to you") came in with this branch and only return bubbles used it, so it is replaced by label_cashReturnToYou rather than left unused. SendBackBubbleScreenshotTest checks that only the two Sent Back frames draw the divider and that the return's receiver reads "to You", and renders the Send Back frames to build/screenshots/send_back_bubbles.png. --- .../src/androidMain/res/values/strings.xml | 4 +- .../shared/chat/ui/CashBubbleLinesTest.kt | 2 +- .../chat/ui/SendBackBubbleScreenshotTest.kt | 127 ++++++++++++++++++ .../flipcash/shared/chat/ui/MessageBubble.kt | 11 ++ .../shared/chat/ui/CashBubbleLines.kt | 6 +- 5 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index c364144e86..e8d46f48ef 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -2068,8 +2068,8 @@ You sent back %1$s sent back - - to you + + to You Send Back diff --git a/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/CashBubbleLinesTest.kt b/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/CashBubbleLinesTest.kt index 616c53b1e3..da8d9a4494 100644 --- a/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/CashBubbleLinesTest.kt +++ b/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/CashBubbleLinesTest.kt @@ -239,7 +239,7 @@ class CashBubbleLinesTest { @Test fun `9 - the receiver of a return reads who sent it back, to them`() { - assertEquals("Noah Parker sent back" to "to you", returnOf().read(isFromSelf = false, people = dm)) + assertEquals("Noah Parker sent back" to "to You", returnOf().read(isFromSelf = false, people = dm)) } @Test diff --git a/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt b/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt new file mode 100644 index 0000000000..b6ea1be21d --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt @@ -0,0 +1,127 @@ +package com.flipcash.shared.chat.ui + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashTheme +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SendBackRow +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint +import com.getcode.theme.CodeTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File +import kotlin.time.Instant + +/** + * The Send Back frames of a DM's opening payment and its return (`docs/specs/send-back.md` in the + * orchestrator): the offered payment with each row, and the return as each side reads it. Checks the + * Sent Back footer's divider, and writes every frame to `build/screenshots/send_back_bubbles.png`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w402dp-h2000dp-xhdpi") +class SendBackBubbleScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun cash(paymentReturn: MessageContent.Cash.PaymentReturn) = MessageContent.Cash( + intentId = listOf(0), + amount = Fiat(fiat = 5.0, currencyCode = CurrencyCode.USD), + mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa"), + tokenName = "Dollars", + paymentReturn = paymentReturn, + ) + + private val offered = cash(MessageContent.Cash.PaymentReturn(offer = MessageContent.Cash.PaymentReturn.Offer)) + private val returned = cash( + MessageContent.Cash.PaymentReturn(returnOf = MessageContent.Cash.PaymentReturn.ReturnOf(1)), + ) + private val dm = CashBubblePeople.Direct(other = "Alex Morgan") + + private fun bubble(content: MessageContent.Cash, isFromSelf: Boolean, sendBack: SendBackRow? = null) = + ChatListItem.ContentBubble( + messageId = 42, + contentIndex = 0, + content = content, + isFromSelf = isFromSelf, + timestamp = Instant.fromEpochSeconds(1_000), + cashPeople = dm, + sendBack = sendBack, + ) + + private val frames = listOf( + "S1 Available, receiver" to bubble(offered, isFromSelf = false, sendBack = SendBackRow.Available), + "S5 Sent Back, sender" to bubble(offered, isFromSelf = true, sendBack = SendBackRow.SentBack), + "S6 Sent Back, receiver" to bubble(offered, isFromSelf = false, sendBack = SendBackRow.SentBack), + "8 Return, its sender" to bubble(returned, isFromSelf = true), + "9 Return, its receiver" to bubble(returned, isFromSelf = false), + ) + + private fun show(items: List>) { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .background(CodeTheme.colors.background) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items.forEach { (name, item) -> + Text(text = name, style = CodeTheme.typography.caption, color = CodeTheme.colors.textSecondary) + ContentBubble(item = item, position = BubblePosition.Solo, interactive = false) + } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + } + + @Test + fun `only a Sent Back footer draws the divider`() { + show(frames) + // S5 and S6; not the Send Back row and not either return bubble. + composeRule.onAllNodesWithTag(SENT_BACK_DIVIDER_TAG, useUnmergedTree = true).assertCountEquals(2) + } + + @Test + fun `the return's receiver reads to You`() { + show(listOf(frames.last())) + composeRule.onNodeWithText("to You", useUnmergedTree = true).assertExists() + } + + @Test + fun rendersEveryFrame() { + show(frames) + val root: View = composeRule.activity.findViewById(android.R.id.content) + val bitmap = Bitmap.createBitmap(root.width, root.height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, "send_back_bubbles.png") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${bitmap.width}x${bitmap.height})") + } +} diff --git a/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 69b66f9a31..f9784447bd 100644 --- a/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.foundation.text.InlineTextContent import androidx.compose.runtime.Composable @@ -447,6 +448,9 @@ private const val EDITED_MARKER_SLOT = "edited-marker" internal const val REPLY_QUOTE_TAG = "bubble_reply_quote" +/** The hairline across the top of a cash bubble's Sent Back footer. */ +internal const val SENT_BACK_DIVIDER_TAG = "bubble_sent_back_divider" + /** * The citation over the body, with the narrower of the two stretched to the width of the wider. * @@ -1101,6 +1105,13 @@ private fun CashBubble( } if (sentBack) { + // The divider every sheet and card in the app draws between sections, edge to edge: + // the bubble drops its horizontal inset for this footer. + HorizontalDivider( + modifier = Modifier.testTag(SENT_BACK_DIVIDER_TAG), + thickness = CodeTheme.dimens.border, + color = CodeTheme.colors.divider, + ) Box( modifier = Modifier .fillMaxWidth() diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/CashBubbleLines.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/CashBubbleLines.kt index 454dc2afce..9e2ee143e0 100644 --- a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/CashBubbleLines.kt +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/CashBubbleLines.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.Immutable import com.flipcash.core.resources.Res import com.flipcash.core.resources.label_cashFrom import com.flipcash.core.resources.label_cashSomeone -import com.flipcash.core.resources.label_cashToYou +import com.flipcash.core.resources.label_cashReturnToYou import com.flipcash.core.resources.label_groupSendEach import com.flipcash.core.resources.label_groupSendEachApprox import com.flipcash.core.resources.label_groupSendRecipients @@ -109,7 +109,7 @@ fun MessageContent.Cash.bubbleLines( MessageContent.Cash.Action.TIPPED -> CashCaption.YouTipped MessageContent.Cash.Action.SENT -> CashCaption.YouSent } - // A return names itself on both sides, and reads "to you" rather than "from" for its receiver: + // A return names itself on both sides, and reads "to You" rather than "from" for its receiver: // the money is going back, not arriving (Send Back spec, "The return message"). if (paymentReturn?.returnOf != null && people !is CashBubblePeople.Group) { val other = (people as? CashBubblePeople.Direct)?.other @@ -251,7 +251,7 @@ internal inline fun CashCounterpart.resolve( ) is CashCounterpart.ToPeople -> plural(Res.plurals.label_groupSendToPeople, count, arrayOf(count)) is CashCounterpart.From -> string(Res.string.label_cashFrom, arrayOf(name.orSomeone(string))) - CashCounterpart.ToYou -> string(Res.string.label_cashToYou, emptyArray()) + CashCounterpart.ToYou -> string(Res.string.label_cashReturnToYou, emptyArray()) } private inline fun String?.orSomeone(string: (StringResource, Array) -> String): String = From d12326693733ce57aeaa867f22d7d8c82c90dc88 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 4 Nov 2026 14:37:20 -0600 Subject: [PATCH 08/10] fix(chat): put the Sent Back footer below the bubble's bottom padding The footer replaced the bottom padding, so the divider sat 1-2dp under the last line. It now sits below the padding like the Send Back row, with the same modifier the Available and Sending states use (insetH/insetV plus staticGrid.x1), per the spec change in flipcash-client-orchestrator #115. SendBackBubbleScreenshotTest checks that the gap above the divider matches the gap above the Send Back row, that the band is grid.x9, and that a Sent Back bubble is a bare bubble plus the hairline and band. --- .../chat/ui/SendBackBubbleScreenshotTest.kt | 52 ++++++++++++++++++- .../flipcash/shared/chat/ui/MessageBubble.kt | 19 ++++--- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt b/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt index b6ea1be21d..8a9d9a64e0 100644 --- a/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt +++ b/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt @@ -11,7 +11,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.Dp +import org.junit.Assert.assertEquals import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithText @@ -36,7 +43,7 @@ import kotlin.time.Instant /** * The Send Back frames of a DM's opening payment and its return (`docs/specs/send-back.md` in the * orchestrator): the offered payment with each row, and the return as each side reads it. Checks the - * Sent Back footer's divider, and writes every frame to `build/screenshots/send_back_bubbles.png`. + * Sent Back footer's divider and spacing, and writes every frame to `build/screenshots/send_back_bubbles.png`. */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -79,10 +86,17 @@ class SendBackBubbleScreenshotTest { "9 Return, its receiver" to bubble(returned, isFromSelf = false), ) + private var footerHeight = Dp.Unspecified + private var rowHeight = Dp.Unspecified + private var hairline = Dp.Unspecified + private fun show(items: List>) { composeRule.mainClock.autoAdvance = false composeRule.setContent { FlipcashTheme { + footerHeight = CodeTheme.dimens.grid.x9 + rowHeight = CodeTheme.dimens.grid.x11 + hairline = CodeTheme.dimens.border Column( modifier = Modifier .fillMaxWidth() @@ -92,7 +106,9 @@ class SendBackBubbleScreenshotTest { ) { items.forEach { (name, item) -> Text(text = name, style = CodeTheme.typography.caption, color = CodeTheme.colors.textSecondary) - ContentBubble(item = item, position = BubblePosition.Solo, interactive = false) + Box(modifier = Modifier.testTag(name)) { + ContentBubble(item = item, position = BubblePosition.Solo, interactive = false) + } } } } @@ -107,6 +123,38 @@ class SendBackBubbleScreenshotTest { composeRule.onAllNodesWithTag(SENT_BACK_DIVIDER_TAG, useUnmergedTree = true).assertCountEquals(2) } + private fun height(tag: String) = composeRule.onNodeWithTag(tag, useUnmergedTree = true) + .getUnclippedBoundsInRoot().let { it.bottom - it.top } + + private fun assertDp(expected: Dp, actual: Dp, what: String) = + assertEquals(what, expected.value, actual.value, 0.5f) + + @Test + fun `the last line has the same gap above the Sent Back divider as above the Send Back row`() { + show(frames.filter { it.first.startsWith("S1") || it.first.startsWith("S6") }) + val lines = composeRule.onAllNodesWithText("from Alex Morgan", useUnmergedTree = true) + val aboveRow = composeRule.onNodeWithTag(SEND_BACK_ROW_TAG, useUnmergedTree = true) + .getUnclippedBoundsInRoot().top - lines[0].getUnclippedBoundsInRoot().bottom + val aboveDivider = composeRule.onNodeWithTag(SENT_BACK_DIVIDER_TAG, useUnmergedTree = true) + .getUnclippedBoundsInRoot().top - lines[1].getUnclippedBoundsInRoot().bottom + assertDp(aboveRow, aboveDivider, "gap above the divider") + } + + @Test + fun `the Sent Back footer sits below the bottom padding`() { + val bare = "offered, no row" to bubble(offered, isFromSelf = false) + show(listOf(bare) + frames.filter { it.first.startsWith("S1") || it.first.startsWith("S6") }) + assertDp(footerHeight, height(SENT_BACK_FOOTER_TAG), "footer band") + // The bare bubble already ends in its bottom padding; the footer adds its hairline and band. + assertDp(height(bare.first) + hairline + footerHeight, height("S6 Sent Back, receiver"), "S6 over bare") + // Same body as S1, with the band in place of the white row. + assertDp( + height("S1 Available, receiver") - rowHeight + hairline + footerHeight, + height("S6 Sent Back, receiver"), + "S6 against S1", + ) + } + @Test fun `the return's receiver reads to You`() { show(listOf(frames.last())) diff --git a/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index f9784447bd..b8814cf276 100644 --- a/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -451,6 +451,12 @@ internal const val REPLY_QUOTE_TAG = "bubble_reply_quote" /** The hairline across the top of a cash bubble's Sent Back footer. */ internal const val SENT_BACK_DIVIDER_TAG = "bubble_sent_back_divider" +/** The Sent Back footer's band, under its divider. */ +internal const val SENT_BACK_FOOTER_TAG = "bubble_sent_back_footer" + +/** The white Send Back row along a cash bubble's bottom edge. */ +internal const val SEND_BACK_ROW_TAG = "bubble_send_back_row" + /** * The citation over the body, with the narrower of the two stretched to the width of the wider. * @@ -947,9 +953,9 @@ private fun CashBubble( sendBack: SendBackRow? = null, onSendBack: (() -> Unit)? = null, ) { - // A filled Send Back row spans the bubble edge to edge along its bottom, so the bubble drops its - // own inset and the lines above the row put it back. The Sent Back footer takes the place of the - // bottom padding, so the lines above it put back every side but the bottom. + // A Send Back row or Sent Back footer spans the bubble edge to edge along its bottom, so the + // bubble drops its own inset and the lines above put it back on every side. Both sit below the + // bottom padding, so the last line has the same gap above either. val filledRow = sendBack == SendBackRow.Available || sendBack == SendBackRow.Sending val sentBack = sendBack == SendBackRow.SentBack val insetH = BubbleDefaults.paddingHorizontal @@ -982,12 +988,9 @@ private fun CashBubble( .fillMaxWidth() .then( when { - filledRow -> Modifier + filledRow || sentBack -> Modifier .padding(horizontal = insetH, vertical = insetV) .padding(vertical = CodeTheme.dimens.staticGrid.x1) - sentBack -> Modifier - .padding(start = insetH, end = insetH, top = insetV) - .padding(top = CodeTheme.dimens.staticGrid.x1) else -> Modifier.padding(vertical = CodeTheme.dimens.staticGrid.x1) } ), @@ -1114,6 +1117,7 @@ private fun CashBubble( ) Box( modifier = Modifier + .testTag(SENT_BACK_FOOTER_TAG) .fillMaxWidth() .height(CodeTheme.dimens.grid.x9), contentAlignment = Alignment.Center, @@ -1148,6 +1152,7 @@ private fun SendBackActionRow( ) { Box( modifier = Modifier + .testTag(SEND_BACK_ROW_TAG) .fillMaxWidth() .height(CodeTheme.dimens.grid.x11) .background(CodeTheme.colors.textMain) From fc28385cab09b1171e841396b2b65cc3372f9db7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 4 Nov 2026 14:53:27 -0600 Subject: [PATCH 09/10] fix(chat): grow the Send Back footer in when it arrives after the bubble The return state can load after the transcript draws. The bubble then drew bare and jumped taller in one frame when the Send Back row or Sent Back footer landed (on a Pixel 7a, 345px to 466px; the width stayed 623px). The footer now sits in an AnimatedVisibility on ChatAnimations.receiptEnter, the transcript's reflow spring. A footer known when the bubble first composes starts visible and draws with no animation. SendBackBubbleScreenshotTest adds a width check for the row and footer, a mid-animation height check for each late arrival, and a first-frame check for a footer known up front. --- .../chat/ui/SendBackBubbleScreenshotTest.kt | 75 +++++++++++++++ .../flipcash/shared/chat/ui/MessageBubble.kt | 91 +++++++++++++------ 2 files changed, 138 insertions(+), 28 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt b/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt index 8a9d9a64e0..91a5c69c66 100644 --- a/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt +++ b/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt @@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.foundation.layout.Box import androidx.compose.ui.platform.testTag @@ -19,6 +21,7 @@ import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.unit.Dp import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithText @@ -161,6 +164,78 @@ class SendBackBubbleScreenshotTest { composeRule.onNodeWithText("to You", useUnmergedTree = true).assertExists() } + private fun width(tag: String) = composeRule.onNodeWithTag(tag, useUnmergedTree = true) + .getUnclippedBoundsInRoot().let { it.right - it.left } + + @Test + fun `the Send Back row and the Sent Back footer never change the bubble's width`() { + val bare = "offered, no row" to bubble(offered, isFromSelf = false) + show(listOf(bare) + frames.filter { it.first.startsWith("S1") || it.first.startsWith("S6") }) + assertDp(width(bare.first), width("S1 Available, receiver"), "S1 width") + assertDp(width(bare.first), width("S6 Sent Back, receiver"), "S6 width") + } + + /** + * A bare offered bubble tagged `bare` beside one tagged `live` whose Send Back state the test + * changes after it has drawn, the way the transcript gets it when the return state loads late. + */ + private fun showLive(initial: SendBackRow?): MutableState { + val state = mutableStateOf(initial) + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashTheme { + footerHeight = CodeTheme.dimens.grid.x9 + rowHeight = CodeTheme.dimens.grid.x11 + hairline = CodeTheme.dimens.border + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Box(modifier = Modifier.testTag("bare")) { + ContentBubble(item = bubble(offered, isFromSelf = false), position = BubblePosition.Solo, interactive = false) + } + Box(modifier = Modifier.testTag("live")) { + ContentBubble( + item = bubble(offered, isFromSelf = false, sendBack = state.value), + position = BubblePosition.Solo, + interactive = false, + ) + } + } + } + } + composeRule.mainClock.advanceTimeByFrame() + return state + } + + private fun assertGrowsIn(arriving: SendBackRow, added: () -> Dp) { + val state = showLive(initial = null) + repeat(5) { composeRule.mainClock.advanceTimeByFrame() } + val bare = height("bare") + assertDp(bare, height("live"), "before it arrives") + + composeRule.runOnUiThread { state.value = arriving } + // Applies the write so the next frames recompose with it; the clock does not move. + composeRule.waitForIdle() + repeat(3) { composeRule.mainClock.advanceTimeByFrame() } + val mid = height("live") + assertTrue("mid-animation height $mid should be between $bare and ${bare + added()}", mid.value > bare.value + 0.5f && mid.value < (bare + added()).value - 0.5f) + assertDp(width("bare"), width("live"), "width while it grows") + + composeRule.mainClock.advanceTimeBy(3_000) + assertDp(bare + added(), height("live"), "settled") + assertDp(width("bare"), width("live"), "width once settled") + } + + @Test + fun `a Sent Back footer that arrives late grows in`() = assertGrowsIn(SendBackRow.SentBack) { hairline + footerHeight } + + @Test + fun `a Send Back row that arrives late grows in`() = assertGrowsIn(SendBackRow.Available) { rowHeight } + + @Test + fun `a footer known when the bubble first draws is there at full height`() { + showLive(initial = SendBackRow.SentBack) + assertDp(height("bare") + hairline + footerHeight, height("live"), "first frame") + } + @Test fun rendersEveryFrame() { show(frames) diff --git a/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index b8814cf276..e5dbd4c64a 100644 --- a/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -21,6 +21,9 @@ import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf @@ -1107,40 +1110,72 @@ private fun CashBubble( } - if (sentBack) { - // The divider every sheet and card in the app draws between sections, edge to edge: - // the bubble drops its horizontal inset for this footer. - HorizontalDivider( - modifier = Modifier.testTag(SENT_BACK_DIVIDER_TAG), - thickness = CodeTheme.dimens.border, - color = CodeTheme.colors.divider, - ) - Box( - modifier = Modifier - .testTag(SENT_BACK_FOOTER_TAG) - .fillMaxWidth() - .height(CodeTheme.dimens.grid.x9), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(Res.string.label_sentBack), - style = CodeTheme.typography.caption, - color = CodeTheme.colors.textSecondary, - maxLines = 1, - ) - } + SendBackFooter(sendBack = sendBack, onSendBack = onSendBack) } + } +} - if (filledRow) { - SendBackActionRow( - sending = sendBack == SendBackRow.Sending, - onClick = onSendBack, - ) - } +/** + * The Send Back row or Sent Back footer along a cash bubble's bottom edge, or nothing. + * + * Present when the bubble first draws, it is drawn in place. Arriving after the bubble has drawn + * (the return state loads after the transcript does), it grows in on [ChatAnimations.receiptEnter], + * the transcript's reflow spring, rather than popping the bubble taller in one frame. + */ +@Composable +private fun ColumnScope.SendBackFooter( + sendBack: SendBackRow?, + onSendBack: (() -> Unit)?, +) { + val visible = remember { MutableTransitionState(sendBack != null) } + visible.targetState = sendBack != null + // What it last showed, so it still has something to draw while it leaves. + val shown = remember { SendBackHolder(sendBack) } + if (sendBack != null) shown.row = sendBack + + AnimatedVisibility( + visibleState = visible, + enter = ChatAnimations.receiptEnter, + exit = ChatAnimations.receiptExit, + ) { + Column { + when (shown.row) { + SendBackRow.SentBack -> { + // The divider every sheet and card in the app draws between sections, edge to + // edge: the bubble drops its horizontal inset for this footer. + HorizontalDivider( + modifier = Modifier.testTag(SENT_BACK_DIVIDER_TAG), + thickness = CodeTheme.dimens.border, + color = CodeTheme.colors.divider, + ) + Box( + modifier = Modifier + .testTag(SENT_BACK_FOOTER_TAG) + .fillMaxWidth() + .height(CodeTheme.dimens.grid.x9), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.label_sentBack), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + ) + } + } + SendBackRow.Available, SendBackRow.Sending -> SendBackActionRow( + sending = shown.row == SendBackRow.Sending, + onClick = onSendBack, + ) + null -> Unit + } } } } +/** Not state: [SendBackFooter] writes it while composing, only when its Send Back state changed and it is recomposing anyway. */ +private class SendBackHolder(var row: SendBackRow?) + /** * The white row along a cash bubble's bottom edge that offers Send Back, or holds a spinner while * the return is submitted. Not clickable while sending, or when there is nothing to call. From b41238f1d353cebeef3a4c801b67ee4ade6861ed Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 4 Nov 2026 15:50:23 -0600 Subject: [PATCH 10/10] fix(chat): animate every late height change on a cash bubble A cash bubble that had already drawn jumped when its counterpart line and token icon arrived (345 to 403px in one frame on a Pixel 7a), and when a Send Back row gave way to Sent Back after a return. The content column now runs animateContentSize on ChatAnimations' reflow spring, so any late line moves the height. The first measure is taken as is, so state known at first draw is there at full height. The footer no longer expands itself, which would have animated its height twice; it fades in on the reflow spring when it arrives or changes late. The bubble's inset moves inside the animated column in every state, so a row arriving adds only its own height instead of dipping the bubble first. New tests in SendBackBubbleScreenshotTest for a late counterpart line and for the row-to-Sent Back swap failed before this change: the height was already at its final value (153.5dp and 199.5dp) mid-animation. --- .../chat/ui/SendBackBubbleScreenshotTest.kt | 68 +++++++++++++---- .../flipcash/shared/chat/ui/MessageBubble.kt | 73 +++++++++---------- .../flipcash/shared/chat/ui/ChatAnimations.kt | 4 +- 3 files changed, 91 insertions(+), 54 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt b/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt index 91a5c69c66..6b06853f48 100644 --- a/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt +++ b/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt @@ -176,11 +176,15 @@ class SendBackBubbleScreenshotTest { } /** - * A bare offered bubble tagged `bare` beside one tagged `live` whose Send Back state the test - * changes after it has drawn, the way the transcript gets it when the return state loads late. + * A bare offered bubble tagged `bare` beside one tagged `live` whose Send Back state and names + * the test changes after it has drawn, the way the transcript gets them when they load late. */ - private fun showLive(initial: SendBackRow?): MutableState { + private fun showLive( + initial: SendBackRow?, + people: CashBubblePeople? = dm, + ): Pair, MutableState> { val state = mutableStateOf(initial) + val names = mutableStateOf(people) composeRule.mainClock.autoAdvance = false composeRule.setContent { FlipcashTheme { @@ -193,7 +197,8 @@ class SendBackBubbleScreenshotTest { } Box(modifier = Modifier.testTag("live")) { ContentBubble( - item = bubble(offered, isFromSelf = false, sendBack = state.value), + item = bubble(offered, isFromSelf = false, sendBack = state.value) + .copy(cashPeople = names.value), position = BubblePosition.Solo, interactive = false, ) @@ -202,34 +207,69 @@ class SendBackBubbleScreenshotTest { } } composeRule.mainClock.advanceTimeByFrame() - return state + return state to names } - private fun assertGrowsIn(arriving: SendBackRow, added: () -> Dp) { - val state = showLive(initial = null) + /** + * Makes [change] once the live bubble has drawn and settled, and checks its height eases from + * where it stood to [settled] on a spring, never jumping there in a frame, at the bare width. + */ + private fun assertEases(change: () -> Unit, settled: () -> Dp) { repeat(5) { composeRule.mainClock.advanceTimeByFrame() } - val bare = height("bare") - assertDp(bare, height("live"), "before it arrives") + val before = height("live") - composeRule.runOnUiThread { state.value = arriving } + composeRule.runOnUiThread(change) // Applies the write so the next frames recompose with it; the clock does not move. composeRule.waitForIdle() - repeat(3) { composeRule.mainClock.advanceTimeByFrame() } + // Long enough for the spring to have moved a few dp even on the smallest change, the row + // giving way to Sent Back, and short of it settling. + repeat(6) { composeRule.mainClock.advanceTimeByFrame() } val mid = height("live") - assertTrue("mid-animation height $mid should be between $bare and ${bare + added()}", mid.value > bare.value + 0.5f && mid.value < (bare + added()).value - 0.5f) - assertDp(width("bare"), width("live"), "width while it grows") + val low = minOf(before.value, settled().value) + val high = maxOf(before.value, settled().value) + assertTrue( + "mid-animation height $mid should be between $before and ${settled()}", + mid.value > low + 0.5f && mid.value < high - 0.5f, + ) + assertDp(width("bare"), width("live"), "width while it moves") composeRule.mainClock.advanceTimeBy(3_000) - assertDp(bare + added(), height("live"), "settled") + assertDp(settled(), height("live"), "settled") assertDp(width("bare"), width("live"), "width once settled") } + private fun assertGrowsIn(arriving: SendBackRow, added: () -> Dp) { + val (state, _) = showLive(initial = null) + repeat(5) { composeRule.mainClock.advanceTimeByFrame() } + assertDp(height("bare"), height("live"), "before it arrives") + assertEases(change = { state.value = arriving }, settled = { height("bare") + added() }) + } + @Test fun `a Sent Back footer that arrives late grows in`() = assertGrowsIn(SendBackRow.SentBack) { hairline + footerHeight } @Test fun `a Send Back row that arrives late grows in`() = assertGrowsIn(SendBackRow.Available) { rowHeight } + @Test + fun `a counterpart line that arrives late grows in`() { + val (_, names) = showLive(initial = null, people = null) + repeat(5) { composeRule.mainClock.advanceTimeByFrame() } + assertTrue("no counterpart yet, so shorter than the named bubble", height("live") < height("bare")) + assertEases(change = { names.value = dm }, settled = { height("bare") }) + } + + @Test + fun `the Send Back row giving way to Sent Back eases`() { + val (state, _) = showLive(initial = SendBackRow.Available) + repeat(5) { composeRule.mainClock.advanceTimeByFrame() } + assertDp(height("bare") + rowHeight, height("live"), "row drawn") + assertEases( + change = { state.value = SendBackRow.SentBack }, + settled = { height("bare") + hairline + footerHeight }, + ) + } + @Test fun `a footer known when the bubble first draws is there at full height`() { showLive(initial = SendBackRow.SentBack) diff --git a/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index e5dbd4c64a..909becf647 100644 --- a/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -21,9 +21,11 @@ import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.runtime.key +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.animateContentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf @@ -957,10 +959,9 @@ private fun CashBubble( onSendBack: (() -> Unit)? = null, ) { // A Send Back row or Sent Back footer spans the bubble edge to edge along its bottom, so the - // bubble drops its own inset and the lines above put it back on every side. Both sit below the - // bottom padding, so the last line has the same gap above either. - val filledRow = sendBack == SendBackRow.Available || sendBack == SendBackRow.Sending - val sentBack = sendBack == SendBackRow.SentBack + // bubble draws no inset of its own and the lines above carry it on every side, row or no row. + // Both sit below the bottom padding, so the last line has the same gap above either. Keeping the + // inset inside the animated column means a row arriving only adds its own height. val insetH = BubbleDefaults.paddingHorizontal val insetV = BubbleDefaults.paddingVertical Bubble( @@ -973,8 +974,8 @@ private fun CashBubble( onDoubleClick = onDoubleClick, modifier = modifier, attention = attention, - horizontalPadding = if (filledRow || sentBack) Dp.Hairline else insetH, - verticalPadding = if (filledRow || sentBack) Dp.Hairline else insetV, + horizontalPadding = Dp.Hairline, + verticalPadding = Dp.Hairline, ) { // The same number the bill's "You received" sheet reads: a USDF payment converts from the // USD it settled at, anything else from today's rates. @@ -983,20 +984,19 @@ private fun CashBubble( val secondary = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Medium) val amountStyle = CodeTheme.typography.displayMedium - Column(modifier = Modifier.fillMaxWidth()) { + // Any line that lands after the bubble has drawn (the counterpart once names resolve, the + // token, the Send Back row or footer, the row giving way to Sent Back) moves the bubble's + // height on the transcript's reflow spring. The first measure is taken as is, so what is + // known when the bubble first draws is there at full height. The bubble's width is fixed, + // so only the height moves. + Column(modifier = Modifier.fillMaxWidth().animateContentSize(ChatAnimations.reflowIntSize)) { // The gaps use the static grid, like the bubble's own padding, so each spec value maps // down to the same 5dp step at every screen width. Column( modifier = Modifier .fillMaxWidth() - .then( - when { - filledRow || sentBack -> Modifier - .padding(horizontal = insetH, vertical = insetV) - .padding(vertical = CodeTheme.dimens.staticGrid.x1) - else -> Modifier.padding(vertical = CodeTheme.dimens.staticGrid.x1) - } - ), + .padding(horizontal = insetH, vertical = insetV) + .padding(vertical = CodeTheme.dimens.staticGrid.x1), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( @@ -1118,28 +1118,23 @@ private fun CashBubble( /** * The Send Back row or Sent Back footer along a cash bubble's bottom edge, or nothing. * - * Present when the bubble first draws, it is drawn in place. Arriving after the bubble has drawn - * (the return state loads after the transcript does), it grows in on [ChatAnimations.receiptEnter], - * the transcript's reflow spring, rather than popping the bubble taller in one frame. + * Present when the bubble first draws, it is drawn as is. Arriving or changing after that (the + * return state loads after the transcript does, or a return lands), it fades in on the reflow + * spring while the bubble's [animateContentSize] grows or shrinks the room for it, so the size is + * animated once, by the bubble. */ @Composable -private fun ColumnScope.SendBackFooter( +private fun SendBackFooter( sendBack: SendBackRow?, onSendBack: (() -> Unit)?, ) { - val visible = remember { MutableTransitionState(sendBack != null) } - visible.targetState = sendBack != null - // What it last showed, so it still has something to draw while it leaves. - val shown = remember { SendBackHolder(sendBack) } - if (sendBack != null) shown.row = sendBack - - AnimatedVisibility( - visibleState = visible, - enter = ChatAnimations.receiptEnter, - exit = ChatAnimations.receiptExit, - ) { - Column { - when (shown.row) { + val drawn = remember { SendBackDrawn(first = sendBack) } + if (sendBack != drawn.first) drawn.changed = true + key(sendBack) { + val alpha = remember { Animatable(if (drawn.changed) 0f else 1f) } + LaunchedEffect(Unit) { alpha.animateTo(1f, ChatAnimations.reflowFloat) } + Column(modifier = Modifier.graphicsLayer { this.alpha = alpha.value }) { + when (sendBack) { SendBackRow.SentBack -> { // The divider every sheet and card in the app draws between sections, edge to // edge: the bubble drops its horizontal inset for this footer. @@ -1164,7 +1159,7 @@ private fun ColumnScope.SendBackFooter( } } SendBackRow.Available, SendBackRow.Sending -> SendBackActionRow( - sending = shown.row == SendBackRow.Sending, + sending = sendBack == SendBackRow.Sending, onClick = onSendBack, ) null -> Unit @@ -1173,8 +1168,10 @@ private fun ColumnScope.SendBackFooter( } } -/** Not state: [SendBackFooter] writes it while composing, only when its Send Back state changed and it is recomposing anyway. */ -private class SendBackHolder(var row: SendBackRow?) +/** Not state: [SendBackFooter] writes [changed] while composing, only when its Send Back state moved off [first]. */ +private class SendBackDrawn(val first: SendBackRow?) { + var changed = false +} /** * The white row along a cash bubble's bottom edge that offers Send Back, or holds a spinner while diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt index 7d0c5c43fe..c8404ccb28 100644 --- a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt @@ -36,9 +36,9 @@ object ChatAnimations { // Rows resizing in place (a receipt moving on, a reaction): glides to the new layout and never // bounces. Matches iOS reflow: .spring(duration: 0.35, bounce: 0). private const val ReflowStiffness = 322f - private val reflowIntSize: SpringSpec = + internal val reflowIntSize: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = ReflowStiffness) - private val reflowFloat: SpringSpec = + internal val reflowFloat: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = ReflowStiffness) val reflowDp: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = ReflowStiffness)