From c3e3ac9674650c4f7bbb06965e2a5e6633503dd0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 15:23:23 -0400 Subject: [PATCH 01/23] feat(payments): split a group send's total between its recipients Splits in the currency's minor unit, rounding each part down and giving the leftover one unit each to the first recipients in selection order. GroupSendSplitVectorsTest runs the shared test-vectors/group-send-split.json. --- .../shared/payments/group/GroupSendSplit.kt | 69 +++++++ .../group/GroupSendSplitVectorsTest.kt | 72 +++++++ test-vectors/group-send-split.json | 181 ++++++++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendSplit.kt create mode 100644 apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/group/GroupSendSplitVectorsTest.kt create mode 100644 test-vectors/group-send-split.json diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendSplit.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendSplit.kt new file mode 100644 index 0000000000..bb556126a6 --- /dev/null +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendSplit.kt @@ -0,0 +1,69 @@ +package com.flipcash.shared.payments.group + +import com.getcode.opencode.internal.extensions.fractionDigits +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import java.math.BigDecimal +import java.math.RoundingMode + +/** What a group send should pay, from [GroupSendSplit.split]. */ +sealed interface GroupSendPlan { + /** + * Pay [parts], in the currency's minor unit and in selection order. One part is a single + * payment; two or more are one multi-payment. + */ + data class Pay(val parts: List) : GroupSendPlan { + val isEven: Boolean get() = parts.all { it == parts.first() } + + /** The part shown as "Each": every part when [isEven], the smallest otherwise. */ + val smallest: Long get() = parts.min() + } + + data object NoRecipients : GroupSendPlan + + /** More than [GroupSendSplit.MAX_RECIPIENTS] were selected. */ + data object TooManyRecipients : GroupSendPlan + + /** The total is under [GroupSendSplit.MINIMUM_PART_UNITS] per recipient. */ + data object PartBelowMinimum : GroupSendPlan +} + +/** + * Splits a group send's total between its recipients. A cross-platform parity hotspot: it must + * agree with iOS at every vector in `test-vectors/group-send-split.json`. + * + * The total is split in the currency's minor unit (a cent for USD) before anything is priced. Each + * part is the total divided by the recipient count, rounded down to that unit, and the leftover + * goes one unit each to the first recipients in selection order, so the parts add up to the total + * exactly. + */ +object GroupSendSplit { + const val MAX_RECIPIENTS = 10 + const val MINIMUM_PART_UNITS = 1L + + fun split(totalUnits: Long, recipients: Int): GroupSendPlan { + if (recipients <= 0) return GroupSendPlan.NoRecipients + if (recipients > MAX_RECIPIENTS) return GroupSendPlan.TooManyRecipients + val base = totalUnits / recipients + if (base < MINIMUM_PART_UNITS) return GroupSendPlan.PartBelowMinimum + val leftover = totalUnits % recipients + return GroupSendPlan.Pay(List(recipients) { i -> if (i < leftover) base + 1 else base }) + } + + /** [fiat] in its currency's minor unit, rounded half-up to that unit. */ + fun minorUnits(fiat: Fiat): Long = + BigDecimal.valueOf(fiat.quarks) + .movePointLeft(FIAT_QUARK_DIGITS) + .setScale(fiat.currencyCode.fractionDigits, RoundingMode.HALF_UP) + .unscaledValue() + .longValueExact() + + /** [units] minor units of [currency] as a [Fiat], without going through a double. */ + fun fiat(units: Long, currency: CurrencyCode): Fiat = Fiat( + stringAmount = BigDecimal.valueOf(units, currency.fractionDigits).toPlainString(), + currencyCode = currency, + ) + + /** [Fiat.quarks] are millionths of a unit. */ + private const val FIAT_QUARK_DIGITS = 6 +} diff --git a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/group/GroupSendSplitVectorsTest.kt b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/group/GroupSendSplitVectorsTest.kt new file mode 100644 index 0000000000..2f0c5404a4 --- /dev/null +++ b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/group/GroupSendSplitVectorsTest.kt @@ -0,0 +1,72 @@ +package com.flipcash.shared.payments.group + +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.math.BigDecimal +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** Runs every vector in the shared `test-vectors/group-send-split.json`. */ +@RunWith(RobolectricTestRunner::class) +class GroupSendSplitVectorsTest { + + private val fixture: JSONObject = JSONObject( + javaClass.classLoader!!.getResourceAsStream("group-send-split.json")!! + .bufferedReader().use { it.readText() }, + ) + + @Test + fun `constants match the fixture`() { + assertEquals(fixture.getInt("maxRecipients"), GroupSendSplit.MAX_RECIPIENTS) + assertEquals(cents(fixture.getString("minimumPart")), GroupSendSplit.MINIMUM_PART_UNITS) + } + + @Test + fun `every vector`() { + val vectors = fixture.getJSONArray("vectors") + assertEquals(14, vectors.length()) + for (i in 0 until vectors.length()) { + val v = vectors.getJSONObject(i) + val name = v.getString("name") + val plan = GroupSendSplit.split(cents(v.getString("total")), v.getInt("recipients")) + + if (v.has("expectedError")) { + val error = when (val rule = v.getString("expectedError")) { + "part-below-minimum" -> GroupSendPlan.PartBelowMinimum + "too-many-recipients" -> GroupSendPlan.TooManyRecipients + "no-recipients" -> GroupSendPlan.NoRecipients + else -> error("$name: unknown rule $rule") + } + assertEquals(error, plan, name) + continue + } + + val pay = assertIs(plan, name) + val intent = if (pay.parts.size > 1) "SendPublicMultiPayment" else "SendPublicPayment" + assertEquals(v.getString("intent"), intent, name) + + val parts = v.getJSONArray("parts") + assertEquals((0 until parts.length()).map { cents(parts.getString(it)) }, pay.parts, name) + + if (v.has("eachLabel")) { + val each = "$" + BigDecimal.valueOf(pay.smallest, 2).toPlainString() + " Each" + assertEquals(v.getString("eachLabel"), if (pay.isEven) each else "~$each", name) + } + } + } + + @Test + fun `minor units round trip through Fiat`() { + assertEquals(12345L, GroupSendSplit.minorUnits(Fiat("123.45", CurrencyCode.USD))) + assertEquals(1L, GroupSendSplit.minorUnits(Fiat("0.01", CurrencyCode.USD))) + assertEquals(500L, GroupSendSplit.minorUnits(Fiat("500", CurrencyCode.JPY))) + assertEquals(Fiat("0.34", CurrencyCode.USD), GroupSendSplit.fiat(34, CurrencyCode.USD)) + assertEquals(Fiat("7", CurrencyCode.JPY), GroupSendSplit.fiat(7, CurrencyCode.JPY)) + } + + private fun cents(usd: String): Long = BigDecimal(usd).movePointRight(2).longValueExact() +} diff --git a/test-vectors/group-send-split.json b/test-vectors/group-send-split.json new file mode 100644 index 0000000000..00685790ca --- /dev/null +++ b/test-vectors/group-send-split.json @@ -0,0 +1,181 @@ +{ + "algorithm": "group-send-split", + "source": "docs/specs/group-send.md (Splitting the total); flipcash2-client-protocol 0.21.0 CashContent.multi_receiver; ocp SendPublicMultiPaymentMetadata", + "note": "Amounts are USD decimal strings. parts are in selection order: the first recipients checked get the leftover cents. Quarks are omitted: each part is priced from its own fiat at one verified state. expectedError names the rule, not a platform error type.", + "maxRecipients": 10, + "minimumPart": "0.01", + "vectors": [ + { + "name": "one recipient", + "total": "5.00", + "recipients": 1, + "intent": "SendPublicPayment", + "parts": [ + "5.00" + ] + }, + { + "name": "two even", + "total": "2.00", + "recipients": 2, + "intent": "SendPublicMultiPayment", + "parts": [ + "1.00", + "1.00" + ], + "eachLabel": "$1.00 Each" + }, + { + "name": "three uneven", + "total": "5.00", + "recipients": 3, + "intent": "SendPublicMultiPayment", + "parts": [ + "1.67", + "1.67", + "1.66" + ], + "eachLabel": "~$1.66 Each" + }, + { + "name": "six uneven", + "total": "2.00", + "recipients": 6, + "intent": "SendPublicMultiPayment", + "parts": [ + "0.34", + "0.34", + "0.33", + "0.33", + "0.33", + "0.33" + ], + "eachLabel": "~$0.33 Each" + }, + { + "name": "six exact minimum", + "total": "0.06", + "recipients": 6, + "intent": "SendPublicMultiPayment", + "parts": [ + "0.01", + "0.01", + "0.01", + "0.01", + "0.01", + "0.01" + ], + "eachLabel": "$0.01 Each" + }, + { + "name": "seven two cents left", + "total": "1.00", + "recipients": 7, + "intent": "SendPublicMultiPayment", + "parts": [ + "0.15", + "0.15", + "0.14", + "0.14", + "0.14", + "0.14", + "0.14" + ], + "eachLabel": "~$0.14 Each" + }, + { + "name": "ten even", + "total": "10.00", + "recipients": 10, + "intent": "SendPublicMultiPayment", + "parts": [ + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00" + ], + "eachLabel": "$1.00 Each" + }, + { + "name": "ten uneven", + "total": "9.99", + "recipients": 10, + "intent": "SendPublicMultiPayment", + "parts": [ + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "0.99" + ], + "eachLabel": "~$0.99 Each" + }, + { + "name": "ten exact minimum", + "total": "0.10", + "recipients": 10, + "intent": "SendPublicMultiPayment", + "parts": [ + "0.01", + "0.01", + "0.01", + "0.01", + "0.01", + "0.01", + "0.01", + "0.01", + "0.01", + "0.01" + ], + "eachLabel": "$0.01 Each" + }, + { + "name": "large total", + "total": "123.45", + "recipients": 4, + "intent": "SendPublicMultiPayment", + "parts": [ + "30.87", + "30.86", + "30.86", + "30.86" + ], + "eachLabel": "~$30.86 Each" + }, + { + "name": "too small for six", + "total": "0.05", + "recipients": 6, + "expectedError": "part-below-minimum" + }, + { + "name": "too small for two", + "total": "0.01", + "recipients": 2, + "expectedError": "part-below-minimum" + }, + { + "name": "eleven recipients", + "total": "11.00", + "recipients": 11, + "expectedError": "too-many-recipients" + }, + { + "name": "no recipients", + "total": "1.00", + "recipients": 0, + "expectedError": "no-recipients" + } + ] +} From 4c501cc7ba84410161cbc2479b36bb33009a200f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 15:25:59 -0400 Subject: [PATCH 02/23] feat(payments): pay a group send's parts in one intent GroupSendPaymentDelegate prices each part from its own fiat amount at one verified state and pays them with GroupPayment metadata: one recipient as a single transfer, more as one multi-transfer in selection order. The pricing and paying move out of ReactionBoostPaymentDelegate into SharedStatePayment so both use the same path. --- .../shared/payments/SharedStatePayment.kt | 83 +++++++++++ .../boost/ReactionBoostPaymentDelegate.kt | 82 +++-------- .../group/GroupSendPaymentDelegate.kt | 67 +++++++++ .../group/GroupSendPaymentDelegateTest.kt | 139 ++++++++++++++++++ .../services/models/GroupPaymentMetadata.kt | 23 +++ 5 files changed, 331 insertions(+), 63 deletions(-) create mode 100644 apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/SharedStatePayment.kt create mode 100644 apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendPaymentDelegate.kt create mode 100644 apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/group/GroupSendPaymentDelegateTest.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/GroupPaymentMetadata.kt diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/SharedStatePayment.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/SharedStatePayment.kt new file mode 100644 index 0000000000..a22f2569fb --- /dev/null +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/SharedStatePayment.kt @@ -0,0 +1,83 @@ +package com.flipcash.shared.payments + +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.controllers.ResolverController +import com.getcode.opencode.controllers.TransactionController +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.Rate +import com.getcode.opencode.model.financial.Token + +/** + * Pays several people in one intent, each their own fiat amount. Shared by reaction boosts and + * group sends, which split a total before pricing it. + * + * Every part is priced to quarks from its own amount, all at one verified state: a multi-payment + * intent carries one rate and reserve for every payment. On a bonding curve the parts' quarks need + * not add up to what the whole would price to; the payer pays the sum of the parts. + */ +internal class SharedStatePayment( + private val resolverController: ResolverController, + private val transactionController: TransactionController, + private val tokenCoordinator: TokenCoordinator, + private val verifiedFiatCalculator: VerifiedFiatCalculator, +) { + + /** + * Each of [amounts] priced in [token] at [rate], all at one verified state. A refresh between + * two computes is retried once before giving up as stale. + */ + suspend fun price(amounts: List, token: Token, rate: Rate): Result> { + repeat(2) { + val priced = amounts.map { amount -> + verifiedFiatCalculator.compute( + amount = amount, + token = token, + balance = tokenCoordinator.balanceForToken(token), + rate = rate, + ).getOrElse { return Result.failure(it) } + } + val states = priced.map { it.verifiedState } + if (states.all { it != null && it == states.first() }) return Result.success(priced) + } + return Result.failure(ComputeVerifiedFiatError.StaleRate()) + } + + /** + * Pays each of [payees] its [priced] amount from [source]: one payee is a single transfer, more + * are one multi-transfer in the order given. Debits the local balance by every part. + */ + suspend fun pay( + payees: List, + priced: List, + token: Token, + source: AccountCluster, + appMetadata: ByteArray?, + ): Result = runCatching { + require(payees.size == priced.size) { "${payees.size} payees for ${priced.size} amounts" } + val destinations = payees.map { resolverController.resolve(it).getOrThrow() } + + if (priced.size == 1) { + transactionController.directTransfer( + amount = priced.single(), + token = token, + source = source, + destinationOwner = destinations.single(), + appMetadata = appMetadata, + ).getOrThrow() + } else { + transactionController.directMultiTransfer( + payments = destinations.zip(priced.map { it.localFiat }), + verifiedState = requireNotNull(priced.first().verifiedState), + token = token, + source = source, + appMetadata = appMetadata, + ).getOrThrow() + } + priced.forEach { tokenCoordinator.subtract(token, it.localFiat) } + } +} diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/boost/ReactionBoostPaymentDelegate.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/boost/ReactionBoostPaymentDelegate.kt index 40032570c7..358cd053e9 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/boost/ReactionBoostPaymentDelegate.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/boost/ReactionBoostPaymentDelegate.kt @@ -1,16 +1,15 @@ package com.flipcash.shared.payments.boost import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.shared.payments.SharedStatePayment import com.flipcash.services.controllers.ResolverController import com.flipcash.services.models.buildReactionBoostMetadata import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.Emoji 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.errors.ComputeVerifiedFiatError import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Token @@ -30,16 +29,20 @@ import javax.inject.Singleton */ @Singleton class ReactionBoostPaymentDelegate @Inject constructor( - private val resolverController: ResolverController, - private val transactionController: TransactionController, - private val tokenCoordinator: TokenCoordinator, - private val verifiedFiatCalculator: VerifiedFiatCalculator, + resolverController: ResolverController, + transactionController: TransactionController, + tokenCoordinator: TokenCoordinator, + verifiedFiatCalculator: VerifiedFiatCalculator, private val exchange: Exchange, ) { + private val payment = SharedStatePayment( + resolverController, transactionController, tokenCoordinator, verifiedFiatCalculator, + ) + /** * Boosts the viewer's existing [emoji] reaction on [messageId] in [chatId] with [plan], paid in - * [token] from [source]. Every share is priced before anything is sent. + * [token] from [source]. Every share is priced, at one verified state, before anything is sent. */ suspend fun boost( chatId: ChatId, @@ -49,63 +52,16 @@ class ReactionBoostPaymentDelegate @Inject constructor( token: Token, source: AccountCluster, ): Result { - val priced = price(plan, token).getOrElse { return Result.failure(it) } - return pay(chatId, messageId, emoji, priced, token, source) - } - - /** - * Each share's quarks from its own USD amount, all at one verified state: a multi-payment - * intent carries one rate and reserve for every share. A refresh between two computes is - * retried once before giving up as stale. - */ - private suspend fun price(plan: ReactionBoostPlan.Pay, token: Token): Result> { - repeat(2) { - val priced = plan.shares.map { share -> - verifiedFiatCalculator.compute( - amount = share.usd(), - token = token, - balance = tokenCoordinator.balanceForToken(token), - rate = exchange.rateForUsd(), - ).map { PricedShare(share, it) }.getOrElse { return Result.failure(it) } - } - val states = priced.map { it.amount.verifiedState } - if (states.all { it != null && it == states.first() }) return Result.success(priced) - } - return Result.failure(ComputeVerifiedFiatError.StaleRate()) - } - - private suspend fun pay( - chatId: ChatId, - messageId: Long, - emoji: String, - priced: List, - token: Token, - source: AccountCluster, - ): Result = runCatching { - val metadata = buildReactionBoostMetadata(chatId, messageId, Emoji(emoji)) - val destinations = priced.map { resolverController.resolve(it.share.payee).getOrThrow() } - - if (priced.size == 1) { - transactionController.directTransfer( - amount = priced.single().amount, - token = token, - source = source, - destinationOwner = destinations.single(), - appMetadata = metadata, - ).getOrThrow() - } else { - transactionController.directMultiTransfer( - payments = destinations.zip(priced.map { it.amount.localFiat }), - verifiedState = requireNotNull(priced.first().amount.verifiedState), - token = token, - source = source, - appMetadata = metadata, - ).getOrThrow() - } - priced.forEach { tokenCoordinator.subtract(token, it.amount.localFiat) } + val priced = payment.price(plan.shares.map { it.usd() }, token, exchange.rateForUsd()) + .getOrElse { return Result.failure(it) } + return payment.pay( + payees = plan.shares.map { it.payee }, + priced = priced, + token = token, + source = source, + appMetadata = buildReactionBoostMetadata(chatId, messageId, Emoji(emoji)), + ) } - - private class PricedShare(val share: BoostShare, val amount: VerifiedFiat) } /** This share as a USD amount, exact to the cent. */ diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendPaymentDelegate.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendPaymentDelegate.kt new file mode 100644 index 0000000000..bb6df9fa71 --- /dev/null +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendPaymentDelegate.kt @@ -0,0 +1,67 @@ +package com.flipcash.shared.payments.group + +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.controllers.ResolverController +import com.flipcash.services.models.buildGroupPaymentMetadata +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.payments.SharedStatePayment +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +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.CurrencyCode +import com.getcode.opencode.model.financial.Token +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Pays members of a group from one cash message, split by [GroupSendSplit]: one recipient is a + * single payment, 2 to 10 are one multi-payment in selection order. Both carry group-payment app + * metadata, so the server posts the message into the group. + * + * No UI. Balance and send-limit checks on the total are the caller's, before it calls [send]. + */ +@Singleton +class GroupSendPaymentDelegate @Inject constructor( + resolverController: ResolverController, + transactionController: TransactionController, + tokenCoordinator: TokenCoordinator, + verifiedFiatCalculator: VerifiedFiatCalculator, + private val exchange: Exchange, +) { + + private val payment = SharedStatePayment( + resolverController, transactionController, tokenCoordinator, verifiedFiatCalculator, + ) + + /** + * Pays each of [recipients] its part of [plan], in [currency], from [source] in [token]. + * [recipients] are in selection order, matching [GroupSendPlan.Pay.parts]. Every part is priced + * from its own amount, at one verified state, before anything is sent. + */ + suspend fun send( + chatId: ChatId, + recipients: List, + plan: GroupSendPlan.Pay, + currency: CurrencyCode, + token: Token, + source: AccountCluster, + ): Result { + require(recipients.size == plan.parts.size) { + "${recipients.size} recipients for ${plan.parts.size} parts" + } + val rate = exchange.rateFor(currency) + ?: return Result.failure(ComputeVerifiedFiatError.StaleRate()) + val priced = payment.price(plan.parts.map { GroupSendSplit.fiat(it, currency) }, token, rate) + .getOrElse { return Result.failure(it) } + return payment.pay( + payees = recipients, + priced = priced, + token = token, + source = source, + appMetadata = buildGroupPaymentMetadata(chatId), + ) + } +} diff --git a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/group/GroupSendPaymentDelegateTest.kt b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/group/GroupSendPaymentDelegateTest.kt new file mode 100644 index 0000000000..d01c589df2 --- /dev/null +++ b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/group/GroupSendPaymentDelegateTest.kt @@ -0,0 +1,139 @@ +package com.flipcash.shared.payments.group + +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.controllers.ResolverController +import com.flipcash.services.models.buildGroupPaymentMetadata +import com.flipcash.services.models.chat.ChatId +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.core.errors.ComputeVerifiedFiatError +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 io.mockk.coEvery +import io.mockk.coVerify +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.assertIs +import kotlin.test.assertTrue + +class GroupSendPaymentDelegateTest { + + 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 chatId = ChatId(ByteArray(32) { 9 }) + private val token = mockk(relaxed = true) + private val source = mockk(relaxed = true) + private val state = mockk() + private val cad = mockk() + + private val members: List = List(6) { i -> List(32) { (i + 1).toByte() } } + private val keys: Map = members.associateWith { mockk() } + + private val delegate = GroupSendPaymentDelegate( + resolverController, transactionController, tokenCoordinator, calculator, exchange, + ) + + private val requested = mutableListOf() + private val rates = mutableListOf() + + private fun pricesAt(vararg states: VerifiedState?) { + val queue = ArrayDeque(states.toList()) + val amount = slot() + val rate = slot() + coEvery { calculator.compute(capture(amount), any(), any(), capture(rate), any()) } answers { + requested += amount.captured + rates += rate.captured + Result.success(VerifiedFiat(mockk(relaxed = true), queue.removeFirstOrNull() ?: state)) + } + } + + private suspend fun send(recipients: List, totalCents: Long) = delegate.send( + chatId = chatId, + recipients = recipients, + plan = GroupSendSplit.split(totalCents, recipients.size) as GroupSendPlan.Pay, + currency = CurrencyCode.CAD, + token = token, + source = source, + ) + + init { + every { exchange.rateFor(CurrencyCode.CAD) } returns cad + members.forEach { coEvery { resolverController.resolve(userId = it) } returns Result.success(keys.getValue(it)) } + coEvery { transactionController.directMultiTransfer(any(), any(), any(), any(), any(), any()) } returns + Result.success(mockk()) + coEvery { transactionController.directTransfer(any(), any(), any(), any(), any(), any()) } returns + Result.success(mockk()) + } + + @Test + fun `several recipients go in one multi-transfer, in selection order, each priced from its own part`() = runTest { + pricesAt() + val payments = slot>>() + val metadata = slot() + coEvery { + transactionController.directMultiTransfer(capture(payments), state, token, source, capture(metadata), any()) + } returns Result.success(mockk()) + + assertTrue(send(members, 200).isSuccess) + + assertEquals( + listOf("0.34", "0.34", "0.33", "0.33", "0.33", "0.33").map { Fiat(it, CurrencyCode.CAD) }, + requested, + ) + assertTrue(rates.all { it === cad }) + assertEquals(members.map { keys.getValue(it) }, payments.captured.map { it.first }) + assertContentEquals(buildGroupPaymentMetadata(chatId), metadata.captured) + coVerify(exactly = 6) { tokenCoordinator.subtract(token, any()) } + } + + @Test + fun `one recipient is a single transfer with group metadata`() = runTest { + pricesAt() + val metadata = slot() + coEvery { + transactionController.directTransfer(any(), token, source, keys.getValue(members[0]), capture(metadata), any()) + } returns Result.success(mockk()) + + assertTrue(send(members.take(1), 500).isSuccess) + + assertEquals(listOf(Fiat("5.00", CurrencyCode.CAD)), requested) + assertContentEquals(buildGroupPaymentMetadata(chatId), metadata.captured) + coVerify(exactly = 0) { transactionController.directMultiTransfer(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `parts priced at different states twice fail as stale and send nothing`() = runTest { + val other = mockk() + pricesAt(state, other, state, other) + + assertIs(send(members.take(2), 200).exceptionOrNull()) + coVerify(exactly = 0) { transactionController.directMultiTransfer(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `no rate for the currency sends nothing`() = runTest { + pricesAt() + every { exchange.rateFor(CurrencyCode.CAD) } returns null + + assertTrue(send(members.take(2), 200).isFailure) + assertTrue(requested.isEmpty()) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/GroupPaymentMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/GroupPaymentMetadata.kt new file mode 100644 index 0000000000..9c36799fd0 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/GroupPaymentMetadata.kt @@ -0,0 +1,23 @@ +package com.flipcash.services.models + +import com.codeinc.flipcash.gen.common.v1.Common +import com.codeinc.flipcash.gen.intent.v1.Model as FlipcashIntentModel +import com.flipcash.services.models.chat.ChatId +import com.getcode.utils.toByteString + +/** + * Builds the serialized Flipcash `AppMetadata` proto bytes for a payment straight to members of the + * group [chatId]: one member by a single payment, 2 to 10 by one multi-payment. The server posts + * the cash message into the group as the sender. The action is always SEND: the server denies TIP + * on a group payment. + */ +fun buildGroupPaymentMetadata(chatId: ChatId): ByteArray = + FlipcashIntentModel.AppMetadata.newBuilder() + .setChat( + FlipcashIntentModel.ChatMetadata.newBuilder() + .setChatId(Common.ChatId.newBuilder().setValue(chatId.bytes.toByteString())) + .setGroupPayment( + FlipcashIntentModel.ChatMetadata.GroupPayment.newBuilder() + .setAction(FlipcashIntentModel.PaymentAction.SEND) + ) + ).build().toByteArray() From 56d722852ae9aedeb7cd0adf02144a21e0e6b716 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 15:26:50 -0400 Subject: [PATCH 03/23] docs(test-vectors): list group-send-split.json --- test-vectors/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/test-vectors/README.md b/test-vectors/README.md index 053d1c69fd..38ebe07b0e 100644 --- a/test-vectors/README.md +++ b/test-vectors/README.md @@ -40,6 +40,7 @@ impl must reproduce the fixtures before the native duplicates are deleted. | `kikcode.json` + `kikcode_golden.svg` | `:libs:codes:kikcode` → `testAndroidHostTest` (host JVM) — **green** | *same Kotlin source* → `iosSimulatorArm64Test` (Kotlin/Native) — **green** | | `reactions.json` | `:libs:emojis` + `:apps:flipcash:shared:chat` → `testDebugUnitTest` (host JVM); `drawability` via `:libs:emojis` androidTest → `connectedAndroidTest`. Read in place, no per-module copy. Canonical in the orchestrator (#17), not regenerated here | `FlipcashCoreVectors` → xcodebuild on iOS Simulator (copy of the orchestrator fixture) | | `reaction-boost-split.json` | `:apps:flipcash:shared:payments` → `testDebugUnitTest` (host JVM). Read in place, no per-module copy. Canonical in the orchestrator (`docs/reaction-boost-split`), not regenerated here | iOS copy of the orchestrator fixture | +| `group-send-split.json` | `:apps:flipcash:shared:payments` → `testDebugUnitTest` (host JVM), `GroupSendSplitVectorsTest`. Read in place, no per-module copy. Canonical in the orchestrator (`docs/specs/group-send.md`), not regenerated here | iOS copy of the orchestrator fixture | Why the iOS split: **ed25519** lives in the standalone `CodeCurves` C package → host `swift test`. Everything else (**base58**, and later derivation/bonding curve) lives in **FlipcashCore**, whose From 060e42d5b4265785e7d7c27e25adc289cd65ce41 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 15:35:25 -0400 Subject: [PATCH 04/23] feat(messenger): pay checked members from a private group's amount sheet A private group's amount sheet now leads to Select People and a confirm step instead of posting a cash link. Checked members are paid in the order they were checked, an 11th check shows the 10 People Max card, and a total under one minor unit per person shows an alert and stays on Select People. Public groups still post a cash link. --- .../core/src/main/res/values/strings.xml | 36 ++++ .../app/messenger/internal/ChatViewModel.kt | 165 +++++++++++++++++- .../internal/groupsend/GroupSendState.kt | 41 +++++ .../messenger/internal/ChatEventStallTest.kt | 1 + .../internal/ChatGroupAnalyticsTest.kt | 1 + .../internal/ChatGroupCashLinkTest.kt | 131 +++++++++++++- .../internal/ChatMentionPickerTest.kt | 1 + .../internal/ChatOpenTranscriptTest.kt | 1 + .../internal/ChatPreviewsReadyTest.kt | 1 + .../internal/ChatReturnToOriginTest.kt | 1 + .../internal/ChatSendFailureAnalyticsTest.kt | 1 + .../messenger/internal/ChatTypingGateTest.kt | 1 + 12 files changed, 376 insertions(+), 5 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendState.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index dd1c7fdfc4..c58d846923 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1650,4 +1650,40 @@ Get Notified We\'ll let you know when your transactions complete, even if you leave the app Turn On Notifications + + + Select People + Members + + Send to %1$d Person + Send to %1$d People + + 10 People Max + The maximum number of people you can send to at once is 10 people + + Amount Too Small + + This amount is too small to send to %1$d person. Enter a larger amount + This amount is too small to split between %1$d people. Pick fewer people or enter a larger amount + + + %1$s and %2$d Other + %1$s and %2$d Others + + to %1$s + %1$s Each + ~%1$s Each + %1$s sent + You received %1$s + Sent + From + To + + %1$s sent %2$s to %3$d person + %1$s sent %2$s to %3$d people + + + You sent %1$s to %2$d person + You sent %1$s to %2$d people + 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 b0b7c8a48b..aa102f00f9 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 @@ -47,6 +47,7 @@ import com.flipcash.app.core.tokens.brandedName import com.flipcash.app.core.tokens.isReserve import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.app.core.util.Linkify +import com.flipcash.app.messenger.internal.groupsend.GroupSendState import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.app.messenger.internal.link.CashCardTap import com.flipcash.app.messenger.internal.link.ClaimReplyTargets @@ -153,6 +154,8 @@ import com.flipcash.shared.chat.ui.detectUrls import com.flipcash.shared.chat.ui.linkableText import com.flipcash.shared.chat.withinWindows import com.flipcash.shared.payments.ContactPaymentDelegate +import com.flipcash.shared.payments.group.GroupSendPaymentDelegate +import com.flipcash.shared.payments.group.GroupSendPlan import com.flipcash.shared.payments.TipPaymentDelegate import com.getcode.libs.emojis.reactions.EmojiCatalogLoader import com.getcode.libs.emojis.reactions.EmojiDrawability @@ -285,6 +288,7 @@ internal class ChatViewModel @AssistedInject constructor( private val linkCardResolver: LinkCardResolver, private val cashLinkClaims: CashLinkClaims, private val chatCashLinks: ChatCashLinks, + private val groupSendPayments: GroupSendPaymentDelegate, private val chatDraftStore: ChatDraftStore, private val recentReactionsStore: RecentReactionsStore, private val emojiCatalogLoader: EmojiCatalogLoader, @@ -372,6 +376,11 @@ internal class ChatViewModel @AssistedInject constructor( val typingAvatars: List = emptyList(), val resolveState: ResolveState = ResolveState.Pending, val sendProgress: LoadingSuccessState = LoadingSuccessState(), + /** + * A send from a private group to its members, from the amount sheet's Next to the swipe. + * Null for every other send, a public group's cash link included. + */ + val groupSend: GroupSendState? = null, val isSelfTyping: Boolean = false, val typingConstraints: TypingConstraints = TypingConstraints(), val token: Token? = null, @@ -621,6 +630,17 @@ internal class ChatViewModel @AssistedInject constructor( val participant: ChatParticipant? get() = subject?.asParticipant() + /** + * Cash sent here pays members rather than posting a cash link. Private groups only: they + * are where the client holds the whole roster. + */ + val sendsToMembers: Boolean + get() = chatType == ChatType.GROUP && (subject as? ChatSubject.Group)?.isPrivate == true + + /** Who Select People lists: the roster, or what this device knows of it, less the viewer. */ + val groupSendCandidates: List + get() = groupSend?.candidates(members.ifEmpty { knownMembers }).orEmpty() + // Opening the participant's profile (the entry point to blocking) is the tip arm's answer // alone. Asking the subject rather than comparing chat types means a new arm has to say. val canViewProfile: Boolean @@ -1002,6 +1022,18 @@ internal class ChatViewModel @AssistedInject constructor( ) : Event data class SendComplete(val amount: Fiat) : Event + data class GroupSendUpdated(val groupSend: GroupSendState?) : Event + data class OnGroupSendMemberToggled(val member: ID) : Event + + /** Back from Select People to the amount, or from the swipe to Select People. */ + data object OnGroupSendBack : Event + + /** "Send to N People". */ + data object OnGroupSendPeopleConfirmed : Event + + /** The swipe on the group send's confirm sheet. */ + data object OnGroupSendRequested : Event + data object OnSelfTypingStarted : Event data object OnSelfTypingStill : Event data object OnSelfTypingStopped : Event @@ -1747,11 +1779,16 @@ internal class ChatViewModel @AssistedInject constructor( // Every payment from the conversation is a plain send. The one that opens a tip DM is paid from // the person's profile, which is where the recipient's fee applies. - private fun amountStyle() = AmountEntryStyle( + // + // A private group's send picks its people after the amount, so its keypad ends on Next and the + // swipe moves to the confirm sheet. + private fun amountStyle(toMembers: Boolean = false) = AmountEntryStyle( // One label for both kinds of payment. The chat above the keypad already says who this is // going to and why; the slider only has to say what the gesture does. - actionLabel = AmountEntryLabel.Plain(resources.getString(R.string.action_swipeToSend)), - actionStyle = ConfirmationStyle.Slide, + actionLabel = AmountEntryLabel.Plain( + resources.getString(if (toMembers) R.string.action_next else R.string.action_swipeToSend) + ), + actionStyle = if (toMembers) ConfirmationStyle.Button else ConfirmationStyle.Slide, infoHint = { resources.getString(R.string.subtitle_sendHint, it) }, overMaxHint = { resources.getString(R.string.subtitle_sendHintLimitExceeded, it) }, standingHint = AmountEntryStyle.StandingHint.Ceiling, @@ -3493,10 +3530,19 @@ internal class ChatViewModel @AssistedInject constructor( ) { return@onEach } + val toMembers = stateFlow.value.sendsToMembers + amountStyleFlow.value = amountStyle(toMembers) + dispatchEvent( + Event.GroupSendUpdated( + GroupSendState(viewerId = userManager.accountId).takeIf { toMembers } + ) + ) amountDelegate.reset() dispatchEvent(Event.NavigateToAmountEntry) }.launchIn(viewModelScope) + observeGroupSend() + eventFlow .filterIsInstance() .onEach { @@ -3690,6 +3736,104 @@ internal class ChatViewModel @AssistedInject constructor( } } + /** Select People and the confirm sheet of a private group's send. See [GroupSendState]. */ + private fun observeGroupSend() { + fun update(transform: (GroupSendState) -> GroupSendState?) { + val current = stateFlow.value.groupSend ?: return + dispatchEvent(Event.GroupSendUpdated(transform(current))) + } + + eventFlow.filterIsInstance() + .onEach { (member) -> + val groupSend = stateFlow.value.groupSend ?: return@onEach + val selected = groupSend.toggled(member) + if (selected == null) { + BottomBarManager.showInfo( + title = resources.getString(R.string.title_groupSendMaxPeople), + message = resources.getString(R.string.description_groupSendMaxPeople), + ) + return@onEach + } + update { it.copy(selected = selected) } + }.launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { + update { + when (it.stage) { + GroupSendState.Stage.Confirm -> it.copy(stage = GroupSendState.Stage.People) + else -> it.copy(stage = GroupSendState.Stage.Amount) + } + } + }.launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { + val groupSend = stateFlow.value.groupSend ?: return@onEach + when (groupSend.plan) { + is GroupSendPlan.Pay -> update { it.copy(stage = GroupSendState.Stage.Confirm) } + // Nothing is priced: a part under one cent can't be sent, whatever the rate. + // The user stays here to pick fewer people or go back and raise the amount. + GroupSendPlan.PartBelowMinimum -> BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_groupSendTooSmall), + message = resources.getQuantityString( + R.plurals.error_description_groupSendTooSmall, + groupSend.selected.size, + groupSend.selected.size, + ), + ) + GroupSendPlan.NoRecipients, GroupSendPlan.TooManyRecipients -> Unit + } + }.launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { sendToMembers() } + .launchIn(viewModelScope) + } + + private fun sendToMembers() = viewModelScope.launch { + val groupSend = stateFlow.value.groupSend ?: return@launch + val plan = groupSend.plan as? GroupSendPlan.Pay ?: return@launch + val total = groupSend.total ?: return@launch + val chatId = stateFlow.value.chatId ?: return@launch + val token = stateFlow.value.token ?: return@launch + val owner = userManager.accountCluster ?: return@launch + + dispatchEvent(Event.SendStateUpdated(loading = true)) + + val result = groupSendPayments.send( + chatId = chatId, + recipients = groupSend.selected, + plan = plan, + currency = total.currencyCode, + token = token, + source = owner.withTimelockForToken(token), + ) + + result.onSuccess { + dispatchEvent(Event.SendStateUpdated(success = true)) + delay(400.milliseconds) + analytics.track(TransferEvents.sentCash(AnalyticsState.SUCCESS, total.analytics, null)) + dispatchEvent(Event.GroupSendUpdated(null)) + dispatchEvent(Dispatchers.Main, Event.SendComplete(total)) + }.onFailure { cause -> + dispatchEvent(Event.SendStateUpdated()) + analytics.track(TransferEvents.sentCash(AnalyticsState.FAILURE, total.analytics, cause.analytics)) + val (title, message) = when (cause) { + is ComputeVerifiedFiatError.AmountBelowMinimum -> + R.string.error_title_amountTooSmall to R.string.error_description_amountTooSmall + is ComputeVerifiedFiatError -> + R.string.error_title_staleRates to R.string.error_description_staleRates + else -> + R.string.error_title_cashFailedToSend to R.string.error_description_cashFailedToSend + } + BottomBarManager.showError( + title = resources.getString(title), + message = resources.getString(message), + ) + } + } + private fun checkBalanceLimit(amount: Fiat): Boolean { val token = stateFlow.value.token ?: return false val rate = exchange.preferredRate @@ -3732,6 +3876,16 @@ internal class ChatViewModel @AssistedInject constructor( val token = stateFlow.value.token ?: return + val groupSend = stateFlow.value.groupSend + if (groupSend != null && fixedAmount == null) { + dispatchEvent( + Event.GroupSendUpdated( + groupSend.copy(total = amount, stage = GroupSendState.Stage.People) + ) + ) + return + } + if (stateFlow.value.resolveState is ResolveState.Resolved) { dispatchEvent(Event.OnSendRequested( amount = amount, @@ -3993,6 +4147,11 @@ internal class ChatViewModel @AssistedInject constructor( is Event.OpenMention -> { state -> state } is Event.OnConfirmRequested -> { state -> state } is Event.OnSendRequested -> { state -> state } + is Event.GroupSendUpdated -> { state -> state.copy(groupSend = event.groupSend) } + is Event.OnGroupSendMemberToggled -> { state -> state } + Event.OnGroupSendBack -> { state -> state } + Event.OnGroupSendPeopleConfirmed -> { state -> state } + Event.OnGroupSendRequested -> { state -> state } is Event.SendStateUpdated -> { state -> state.copy( sendProgress = LoadingSuccessState( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendState.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendState.kt new file mode 100644 index 0000000000..1785a6ce33 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendState.kt @@ -0,0 +1,41 @@ +package com.flipcash.app.messenger.internal.groupsend + +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.shared.payments.group.GroupSendPlan +import com.flipcash.shared.payments.group.GroupSendSplit +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Fiat + +/** + * A send from a private group to some of its members, from the amount entry sheet's "Next" to the + * swipe. Node 11293:6918. + * + * @param total what the sender entered, in the currency it was entered in. Null until then. + * @param selected who to pay, in the order they were checked: the order decides the split. + */ +internal data class GroupSendState( + val viewerId: ID?, + val stage: Stage = Stage.Amount, + val total: Fiat? = null, + val selected: List = emptyList(), +) { + enum class Stage { Amount, People, Confirm } + + /** The parts to pay, once [total] is known and someone is checked. */ + val plan: GroupSendPlan + get() = total?.let { GroupSendSplit.split(GroupSendSplit.minorUnits(it), selected.size) } + ?: GroupSendPlan.NoRecipients + + /** Who Select People lists: the roster without the sender, who never pays themselves. */ + fun candidates(roster: List): List = + roster.filter { it.userId != viewerId }.distinctBy { it.userId } + + /** + * [selected] with [member] checked or unchecked, or null when checking them would make an 11th. + */ + fun toggled(member: ID): List? = when { + member in selected -> selected.filterNot { it == member } + selected.size >= GroupSendSplit.MAX_RECIPIENTS -> null + else -> selected.plusElement(member) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt index b89a503d7d..e0d0a9e60e 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt @@ -150,6 +150,7 @@ class ChatEventStallTest { linkCardResolver = linkCardResolver, cashLinkClaims = cashLinkClaims, chatCashLinks = mockk(relaxed = true), + groupSendPayments = mockk(relaxed = true), chatDraftStore = chatDraftStore, recentReactionsStore = recentReactionsStore, toastController = mockk(relaxed = true), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index 2f231485a2..4e5bfd8091 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -159,6 +159,7 @@ class ChatGroupAnalyticsTest { linkCardResolver = linkCardResolver, cashLinkClaims = cashLinkClaims, chatCashLinks = mockk(relaxed = true), + groupSendPayments = mockk(relaxed = true), chatDraftStore = chatDraftStore, recentReactionsStore = recentReactionsStore, toastController = mockk(relaxed = true), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index 60c7185bbc..5be4f9a820 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -58,8 +58,17 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test +import com.getcode.opencode.model.financial.Currency +import com.getcode.opencode.model.financial.Limits +import com.getcode.opencode.model.financial.SendLimit +import com.flipcash.app.messenger.internal.groupsend.GroupSendState +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.shared.payments.group.GroupSendPaymentDelegate +import com.flipcash.shared.payments.group.GroupSendPlan +import com.getcode.opencode.model.financial.CurrencyCode import java.util.UUID import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -102,6 +111,9 @@ class ChatGroupCashLinkTest { every { claimInFlight } returns MutableStateFlow(null) } private val chatCashLinks = mockk(relaxed = true) + private val groupSendPayments = mockk(relaxed = true) + private val viewerId: ID = UUID.randomUUID().bytes + private val roster: List = List(12) { member(UUID.randomUUID().bytes) } private val chatDraftStore = mockk(relaxed = true) private val owner = mockk(relaxed = true) @@ -115,8 +127,19 @@ class ChatGroupCashLinkTest { BottomBarManager.clear() every { userManager.accountCluster } returns owner + every { userManager.accountId } returns viewerId + coEvery { + groupSendPayments.send(any(), any(), any(), any(), any(), any()) + } returns Result.success(Unit) every { exchange.preferredRate } returns Rate.oneToOne - every { transactionController.limits } returns MutableStateFlow(null) + every { transactionController.limits } returns MutableStateFlow( + Limits( + sinceDate = 0L, + fetchDate = 0L, + sendLimits = mapOf(CurrencyCode.USD to SendLimit(1000.0, 1000.0, 1000.0)), + amountUsdTransactedSinceConsumption = Fiat.Zero, + ) + ) every { tokenCoordinator.balanceForToken(any()) } returns Fiat(999.0) coEvery { tokenCoordinator.hasGiveableBalance(any()) } returns true coEvery { tokenCoordinator.getTokenMetadata(any()) } returns Result.failure(RuntimeException()) @@ -163,6 +186,7 @@ class ChatGroupCashLinkTest { linkCardResolver = linkCardResolver, cashLinkClaims = cashLinkClaims, chatCashLinks = chatCashLinks, + groupSendPayments = groupSendPayments, chatDraftStore = chatDraftStore, recentReactionsStore = mockk(relaxed = true), emojiCatalogLoader = mockk(relaxed = true), @@ -177,7 +201,9 @@ class ChatGroupCashLinkTest { identifier = null, ) - private fun ChatViewModel.openGroup() { + private fun member(id: ID) = ChatMember(id, mockk(relaxed = true), emptyList()) + + private fun ChatViewModel.openGroup(isPrivate: Boolean = false) { dispatchEvent(ChatViewModel.Event.ChatFound(chatId)) dispatchEvent( ChatViewModel.Event.OnGroupResolved( @@ -185,6 +211,7 @@ class ChatGroupCashLinkTest { metadata = mockk(relaxed = true) { every { this@mockk.chatId } returns this@ChatGroupCashLinkTest.chatId every { rules } returns null + every { this@mockk.isPrivate } returns isPrivate }, isMember = true, ) @@ -286,4 +313,104 @@ class ChatGroupCashLinkTest { coVerify(exactly = 0) { chatCashLinks.sendToChat(any(), any(), any(), any()) } assertTrue(analytics.events.none { it.name == "Send Cash Link" }) } + + /** A private group with [roster] and the viewer in it, on Select People with [total] entered. */ + private fun TestScope.selectingPeople(vm: ChatViewModel, total: Double) { + vm.openGroup(isPrivate = true) + vm.dispatchEvent(ChatViewModel.Event.OnMembersResolved(listOf(member(viewerId)) + roster)) + enterAmount(vm, total) + vm.dispatchEvent(ChatViewModel.Event.OnConfirmRequested) + advanceUntilIdle() + } + + /** Opens the amount sheet, after which the keypad holds [total] dollars. */ + private fun TestScope.enterAmount(vm: ChatViewModel, total: Double) { + vm.dispatchEvent(ChatViewModel.Event.TokenUpdated(token)) + vm.dispatchEvent(ChatViewModel.Event.OnSendCash) + advanceUntilIdle() + vm.amountDelegate.onCurrencyChanged(Currency(code = "USD", name = "US Dollar")) + vm.amountDelegate.setAmount(total) + } + + private fun ChatViewModel.check(count: Int) = roster.take(count).forEach { + dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(it.userId)) + } + + @Test + fun `a public group's amount goes straight to a cash link`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + vm.openGroup(isPrivate = false) + enterAmount(vm, 5.0) + vm.dispatchEvent(ChatViewModel.Event.OnConfirmRequested) + advanceUntilIdle() + + assertNull(vm.stateFlow.value.groupSend) + coVerify(exactly = 1) { chatCashLinks.sendToChat(chatId, any(), token, owner) } + coVerify(exactly = 0) { groupSendPayments.send(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `a private group's amount moves on to Select People without the viewer`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + selectingPeople(vm, total = 2.0) + advanceUntilIdle() + + val state = vm.stateFlow.value + assertEquals(GroupSendState.Stage.People, state.groupSend?.stage) + assertEquals(Fiat(2.0), state.groupSend?.total) + assertEquals(roster.map { it.userId }, state.groupSendCandidates.map { it.userId }) + coVerify(exactly = 0) { chatCashLinks.sendToChat(any(), any(), any(), any()) } + } + + @Test + fun `an 11th check shows the cap and keeps ten`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + selectingPeople(vm, total = 20.0) + vm.check(11) + advanceUntilIdle() + + assertEquals(roster.take(10).map { it.userId }, vm.stateFlow.value.groupSend?.selected) + assertEquals(1, BottomBarManager.messages.value.size) + } + + @Test + fun `a total under a cent each alerts and stays on Select People`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + selectingPeople(vm, total = 0.05) + vm.check(6) + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed) + advanceUntilIdle() + + assertEquals(GroupSendState.Stage.People, vm.stateFlow.value.groupSend?.stage) + assertEquals(1, BottomBarManager.messages.value.size) + } + + @Test + fun `the swipe pays the checked members in the order they were checked`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + val events = recordEvents(vm) + selectingPeople(vm, total = 2.0) + roster.take(6).reversed().forEach { + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(it.userId)) + } + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed) + assertEquals(GroupSendState.Stage.Confirm, vm.stateFlow.value.groupSend?.stage) + + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendRequested) + advanceUntilIdle() + + coVerify(exactly = 1) { + groupSendPayments.send( + chatId = chatId, + recipients = roster.take(6).reversed().map { it.userId }, + plan = GroupSendPlan.Pay(listOf(34, 34, 33, 33, 33, 33)), + currency = CurrencyCode.USD, + token = token, + source = any(), + ) + } + coVerify(exactly = 0) { chatCashLinks.sendToChat(any(), any(), any(), any()) } + assertTrue(events.any { it is ChatViewModel.Event.SendComplete }) + assertNull(vm.stateFlow.value.groupSend) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt index 0d58626af3..0dabae0b9a 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt @@ -107,6 +107,7 @@ class ChatMentionPickerTest { linkCardResolver = mockk(relaxed = true), cashLinkClaims = mockk(relaxed = true) { every { claimInFlight } returns MutableStateFlow(null) }, chatCashLinks = mockk(relaxed = true), + groupSendPayments = mockk(relaxed = true), chatDraftStore = mockk(relaxed = true), recentReactionsStore = mockk(relaxed = true), toastController = mockk(relaxed = true), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt index 66074f0e27..429aa1ce9d 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt @@ -117,6 +117,7 @@ class ChatOpenTranscriptTest { linkCardResolver = mockk(relaxed = true), cashLinkClaims = mockk(relaxed = true) { every { claimInFlight } returns MutableStateFlow(null) }, chatCashLinks = mockk(relaxed = true), + groupSendPayments = mockk(relaxed = true), chatDraftStore = mockk(relaxed = true), recentReactionsStore = mockk(relaxed = true), toastController = mockk(relaxed = true), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt index 64e81fc788..2c6a66deac 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt @@ -97,6 +97,7 @@ class ChatPreviewsReadyTest { linkCardResolver = mockk(relaxed = true), cashLinkClaims = mockk(relaxed = true) { every { claimInFlight } returns MutableStateFlow(null) }, chatCashLinks = mockk(relaxed = true), + groupSendPayments = mockk(relaxed = true), chatDraftStore = mockk(relaxed = true), recentReactionsStore = mockk(relaxed = true), toastController = mockk(relaxed = true), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReturnToOriginTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReturnToOriginTest.kt index 2372c19185..b3ca18df9b 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReturnToOriginTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReturnToOriginTest.kt @@ -116,6 +116,7 @@ class ChatReturnToOriginTest { linkCardResolver = mockk(relaxed = true), cashLinkClaims = mockk(relaxed = true) { every { claimInFlight } returns MutableStateFlow(null) }, chatCashLinks = mockk(relaxed = true), + groupSendPayments = mockk(relaxed = true), chatDraftStore = mockk(relaxed = true), recentReactionsStore = mockk(relaxed = true), toastController = mockk(relaxed = true), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index 5963249765..e640c60601 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -150,6 +150,7 @@ class ChatSendFailureAnalyticsTest { linkCardResolver = linkCardResolver, cashLinkClaims = cashLinkClaims, chatCashLinks = mockk(relaxed = true), + groupSendPayments = mockk(relaxed = true), chatDraftStore = chatDraftStore, recentReactionsStore = recentReactionsStore, toastController = mockk(relaxed = true), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt index 7af68648a8..a2150dde72 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt @@ -114,6 +114,7 @@ class ChatTypingGateTest { linkCardResolver = mockk(relaxed = true), cashLinkClaims = mockk(relaxed = true) { every { claimInFlight } returns MutableStateFlow(null) }, chatCashLinks = mockk(relaxed = true), + groupSendPayments = mockk(relaxed = true), chatDraftStore = mockk(relaxed = true), recentReactionsStore = mockk(relaxed = true), toastController = mockk(relaxed = true), From 3386b758b4a45d85f3c4d4bff04a98228ab471a0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 15:38:03 -0400 Subject: [PATCH 05/23] feat(messenger): add Select People and the group send confirm sheet --- .../flipcash/app/messenger/ChatFlowScreen.kt | 36 +++ .../app/messenger/internal/ChatViewModel.kt | 3 +- .../internal/screens/cash/GroupSendScreens.kt | 278 ++++++++++++++++++ .../internal/ChatGroupCashLinkTest.kt | 1 - 4 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 59ed7d937f..10c9703aab 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -3,6 +3,7 @@ package com.flipcash.app.messenger import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.material3.Text +import androidx.activity.compose.BackHandler import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -45,7 +46,10 @@ import com.flipcash.app.messenger.internal.screens.boost.ReactionBoostCurrencySh import com.flipcash.app.messenger.internal.screens.boost.ReactionBoostSheet import com.flipcash.app.messenger.internal.screens.boost.ReactionBoostViewModel import com.getcode.theme.CodeTheme +import com.flipcash.app.messenger.internal.groupsend.GroupSendState import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent +import com.flipcash.app.messenger.internal.screens.cash.GroupSelectPeopleContent +import com.flipcash.app.messenger.internal.screens.cash.GroupSendConfirmSheet import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel import com.flipcash.app.messenger.internal.screens.profile.GroupProfileScreen @@ -274,6 +278,38 @@ private fun FlowAmountEntryScreen() { // No re-shadow: ChatAmountEntryContent reads the inner LocalCodeNavigator, and its // navigator.push(AppRoute.Main.RegionSelection) / push(AppRoute.Sheets.TokenSelection) // bubble to the app navigator through the dispatcher. + // A private group's send carries on past the amount to Select People and its confirm sheet, + // in this same sheet. Back steps through them rather than dismissing. + val groupSend = state.groupSend + BackHandler(enabled = groupSend != null && groupSend.stage != GroupSendState.Stage.Amount) { + viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendBack) + } + if (groupSend != null && groupSend.stage != GroupSendState.Stage.Amount) { + val candidates = state.groupSendCandidates + GroupSelectPeopleContent( + candidates = candidates, + selected = groupSend.selected, + onBack = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendBack) }, + onToggle = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(it.userId)) }, + onSend = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed) }, + ) + if (groupSend.stage == GroupSendState.Stage.Confirm) { + GroupSendConfirmSheet( + groupSend = groupSend, + recipients = groupSend.selected.mapNotNull { id -> candidates.find { it.userId == id } }, + token = state.token, + sendProgress = state.sendProgress, + onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendRequested) }, + onDismiss = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendBack) }, + ) + } + LaunchedEffect(viewModel) { + viewModel.eventFlow.filterIsInstance() + .collect { resultBack.returnValue(ChatSendResult) } + } + return + } + ChatAmountEntryContent( amountDelegate = viewModel.amountDelegate, resolveState = state.resolveState, 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 aa102f00f9..1471116cfe 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 @@ -3814,7 +3814,8 @@ internal class ChatViewModel @AssistedInject constructor( dispatchEvent(Event.SendStateUpdated(success = true)) delay(400.milliseconds) analytics.track(TransferEvents.sentCash(AnalyticsState.SUCCESS, total.analytics, null)) - dispatchEvent(Event.GroupSendUpdated(null)) + // The send state stays until the sheet has gone, so Select People doesn't flash back to + // the keypad on the way out. The next "$" starts a fresh one. dispatchEvent(Dispatchers.Main, Event.SendComplete(total)) }.onFailure { cause -> dispatchEvent(Event.SendStateUpdated()) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt new file mode 100644 index 0000000000..3bbd94607b --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt @@ -0,0 +1,278 @@ +package com.flipcash.app.messenger.internal.screens.cash + +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.ui.TokenSelectionPill +import com.flipcash.app.messenger.internal.groupsend.GroupSendState +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.profile.chatterName +import com.flipcash.shared.payments.group.GroupSendPlan +import com.flipcash.shared.payments.group.GroupSendSplit +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Token +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.SlideToConfirm +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import com.getcode.view.LoadingSuccessState + +/** + * Select People, node 11293:6918: the group's members less the sender, checked one by one in the + * order that decides the split. There is no Select All. + */ +@Composable +internal fun GroupSelectPeopleContent( + candidates: List, + selected: List, + onBack: () -> Unit, + onToggle: (ChatMember) -> Unit, + onSend: () -> Unit, +) { + val selectedIds = selected.toSet() + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_selectPeople), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = onBack, + ) + }, + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x2), + text = pluralStringResource(R.plurals.action_sendToPeople, selected.size, selected.size), + enabled = selected.isNotEmpty(), + onClick = onSend, + ) + }, + ) { innerPadding -> + val unknownName = stringResource(R.string.title_unnamedUser) + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(bottom = CodeTheme.dimens.grid.x4), + ) { + item { + Text( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.grid.x1), + text = stringResource(R.string.header_groupSendMembers), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + } + items(candidates, key = { it.userId }) { member -> + MemberCheckRow( + member = member, + name = chatterName(member.userProfile.displayName, member.userProfile.username, unknownName), + selected = member.userId in selectedIds, + onClick = { onToggle(member) }, + ) + } + } + } +} + +/** One member, checked or not. The whole row takes the tap, like the invite sheet's chat rows. */ +@Composable +private fun MemberCheckRow( + member: ChatMember, + name: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable(value = selected, role = Role.Checkbox, onValueChange = { onClick() }) + .padding(horizontal = CodeTheme.dimens.inset), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + ContactAvatar( + userProfile = member.userProfile, + modifier = Modifier + .requiredSize(CodeTheme.dimens.staticGrid.x6) + .clip(CircleShape), + ) + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x4), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = name, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Image( + painter = painterResource(if (selected) R.drawable.ic_checked else R.drawable.ic_unchecked), + contentDescription = null, + ) + } + HorizontalDivider(color = CodeTheme.colors.divider) + } + } +} + +/** + * The confirm sheet over Select People, node 11293:6918: who is being paid, the total, what each + * gets, and the swipe. Dismissing it goes back to Select People; it can't be dismissed mid-send. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun GroupSendConfirmSheet( + groupSend: GroupSendState, + recipients: List, + token: Token?, + sendProgress: LoadingSuccessState, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val locked by rememberUpdatedState(!sendProgress.isIdle) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { !locked }, + ) + val total = groupSend.total ?: return + val parts = (groupSend.plan as? GroupSendPlan.Pay)?.parts ?: return + val unknownName = stringResource(R.string.title_unnamedUser) + + ModalBottomSheet( + onDismissRequest = { if (!locked) onDismiss() }, + sheetState = sheetState, + containerColor = CodeTheme.colors.background, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x4), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + RecipientAvatars(recipients) + + val first = recipients.firstOrNull()?.let { + chatterName(it.userProfile.displayName, it.userProfile.username, unknownName) + }.orEmpty() + val others = recipients.size - 1 + Text( + text = if (others == 0) first + else pluralStringResource(R.plurals.label_groupSendRecipients, others, first, others), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = total.formatted(), + style = CodeTheme.typography.displayMedium, + color = CodeTheme.colors.textMain, + ) + Text( + text = eachLabel(parts, total.currencyCode), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + + // Shown, not changed here: the token was picked on the amount screen. + TokenSelectionPill( + token = token, + textStyle = CodeTheme.typography.textMedium, + imageSize = CodeTheme.dimens.staticGrid.x3, + onClick = {}, + ) + + SlideToConfirm( + onConfirm = onConfirm, + modifier = Modifier + .fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x3), + enabled = sendProgress.isIdle, + isLoading = sendProgress.loading, + isSuccess = sendProgress.success, + label = stringResource(R.string.action_swipeToSend), + ) + } + } +} + +/** "$1.00 Each" when the parts are equal, else "~$0.33 Each" for the smallest. */ +@Composable +private fun eachLabel(parts: List, currency: CurrencyCode): String { + val smallest = GroupSendSplit.fiat(parts.min(), currency).formatted() + return if (parts.distinct().size == 1) stringResource(R.string.label_groupSendEach, smallest) + else stringResource(R.string.label_groupSendEachApprox, smallest) +} + +/** The first few recipients' faces, overlapping, as in the design's pair of avatars. */ +@Composable +private fun RecipientAvatars(recipients: List) { + val shown = recipients.take(3) + val size = CodeTheme.dimens.staticGrid.x10 + val overlap = size / 3 + Box(contentAlignment = Alignment.CenterStart) { + shown.forEachIndexed { index, member -> + ContactAvatar( + userProfile = member.userProfile, + modifier = Modifier + .offset(x = (size - overlap) * index) + .requiredSize(size) + .clip(CircleShape) + .border(2.dp, CodeTheme.colors.background, CircleShape), + ) + } + // Reserve the width the offsets take, so the row stays centred. + Box(Modifier.requiredSize(width = size + (size - overlap) * (shown.size - 1).coerceAtLeast(0), height = size)) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index 5be4f9a820..091ac1fd24 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -411,6 +411,5 @@ class ChatGroupCashLinkTest { } coVerify(exactly = 0) { chatCashLinks.sendToChat(any(), any(), any(), any()) } assertTrue(events.any { it is ChatViewModel.Event.SendComplete }) - assertNull(vm.stateFlow.value.groupSend) } } From d746fee2d52a13d58f715bcf230e2e2c33be2766 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 15:42:51 -0400 Subject: [PATCH 06/23] feat(chat): draw group sends as one bubble with a recipients sheet A cash message with multi_receiver shows who sent the total, the per-person amount ("~" on the smallest part when the split is uneven), how many people it went to, and the viewer's own part when they were paid. Tapping it opens a sheet listing the sender and recipients by name. --- .../com/flipcash/app/core/chat/ChatStep.kt | 13 ++ .../core/src/main/res/values/strings.xml | 5 + .../flipcash/app/messenger/ChatFlowScreen.kt | 13 ++ .../app/messenger/internal/ChatViewModel.kt | 17 +++ .../internal/screens/MessengerScreen.kt | 16 +++ .../screens/cash/GroupSendDetailsSheet.kt | 121 +++++++++++++++++ .../flipcash/shared/chat/models/ChatAction.kt | 3 + .../shared/chat/models/ChatListItem.kt | 5 + .../flipcash/shared/chat/ui/CashAmounts.kt | 11 ++ .../flipcash/shared/chat/ui/MessageBubble.kt | 124 +++++++++++++++++- 10 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendDetailsSheet.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index 4cdc7d4694..0c9d59bd3a 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -209,6 +209,19 @@ sealed interface ChatStep : FlowStep, Parcelable { * sheet height rather than wrapping its content. [HalfSheet] matches iOS's * `ReactorsSheet.presentationDetents([.medium, .large])`. */ + /** + * A group send's details (node 11293:6918, frame 10): the currency, who sent it and who it + * paid, by name and without amounts. Ids are hex, as the profile map keys them. + */ + @Parcelize + @Serializable + data class GroupSendDetails( + val tokenName: String, + val tokenImageUrl: String, + val senderIdHex: String?, + val recipientIdsHex: List, + ) : ChatStep, Sheet, WrapContentSheet + @Parcelize @Serializable data class Reactors(val chatId: ChatId, val messageId: Long) : ChatStep, Sheet, HalfSheet diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index c58d846923..66206369c1 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1671,6 +1671,10 @@ %1$s and %2$d Others to %1$s + + to %1$d person + to %1$d people + %1$s Each ~%1$s Each %1$s sent @@ -1678,6 +1682,7 @@ Sent From To + Close %1$s sent %2$s to %3$d person %1$s sent %2$s to %3$d people diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 10c9703aab..b742123852 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -49,6 +49,7 @@ import com.getcode.theme.CodeTheme import com.flipcash.app.messenger.internal.groupsend.GroupSendState import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent import com.flipcash.app.messenger.internal.screens.cash.GroupSelectPeopleContent +import com.flipcash.app.messenger.internal.screens.cash.GroupSendDetailsSheet import com.flipcash.app.messenger.internal.screens.cash.GroupSendConfirmSheet import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel @@ -187,6 +188,18 @@ private fun chatEntryProvider( FlowEditGroupDescriptionScreen() } + annotatedEntry { step -> + val viewModel = flowSharedViewModel() + val dismissSheet = LocalBottomSheetDismissDispatcher.current + GroupSendDetailsSheet( + tokenName = step.tokenName, + tokenImageUrl = step.tokenImageUrl, + sender = viewModel.groupSendPerson(step.senderIdHex), + recipients = step.recipientIdsHex.map(viewModel::groupSendPerson), + onClose = { dismissSheet() }, + ) + } + annotatedEntry { step -> FlowReactionPickerScreen(step.messageId) } 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 1471116cfe..e698e1d14b 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 @@ -48,6 +48,7 @@ import com.flipcash.app.core.tokens.isReserve import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.app.core.util.Linkify import com.flipcash.app.messenger.internal.groupsend.GroupSendState +import com.flipcash.app.messenger.internal.screens.cash.GroupSendPerson import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.app.messenger.internal.link.CashCardTap import com.flipcash.app.messenger.internal.link.ClaimReplyTargets @@ -1363,6 +1364,20 @@ internal class ChatViewModel @AssistedInject constructor( return true } + /** + * Someone on a group send's details sheet, by their hex id; null is the viewer. A member whose + * profile hasn't resolved reads as the generic name until it does. + */ + internal fun groupSendPerson(userIdHex: String?): GroupSendPerson { + if (userIdHex == null || userIdHex == userManager.accountId?.hexEncodedString()) { + return GroupSendPerson(resources.getString(R.string.title_you), profile = null) + } + val profile = senderProfiles.value?.get(userIdHex) + val name = profile?.let { nameOrHandle(it.displayName, it.handle) } + ?: resources.getString(R.string.title_unnamedUser) + return GroupSendPerson(name, profile) + } + fun memberParticipant(userId: ID): ChatParticipant.TipUser? = senderProfiles.value?.get(userId.hexEncodedString()) ?.let { ChatParticipant.TipUser(userId, it) } @@ -1603,6 +1618,8 @@ internal class ChatViewModel @AssistedInject constructor( ?: stateFlow.value.participant?.name ?: resources.getString(R.string.title_unnamedUser), ) ?: UndecryptableHint.UpdateApp, + receivedPart = (enriched as? MessageContent.Cash)?.multiReceiver + ?.firstOrNull { it.receiver == userManager.accountId }, ) // A carded link takes a row of its own, with the prose either side of it // on rows above and below. Reversed because the list is: this page runs 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 c4ce1979ee..0d9d7f0059 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 @@ -85,10 +85,12 @@ import com.getcode.navigation.results.navigateForResult import com.getcode.ui.theme.CodeScaffold import com.getcode.ui.theme.ScaffoldBarPlacement import com.getcode.ui.utils.rememberKeyboardController +import com.getcode.utils.hexEncodedString import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.flow.filterIsInstance import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.services.models.chat.MessageContent import com.flipcash.shared.common.ui.profile.PrefetchProfileCover @Composable @@ -206,6 +208,20 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } + is ChatAction.ViewGroupSend -> { + val cash = action.bubble.content as? MessageContent.Cash + if (cash != null) keyboard.hideIfVisible { + navigator.push( + ChatStep.GroupSendDetails( + tokenName = cash.tokenName, + tokenImageUrl = cash.tokenImageUrl, + senderIdHex = action.bubble.authorId?.hexEncodedString(), + recipientIdsHex = cash.multiReceiver.map { it.receiver.hexEncodedString() }, + ) + ) + } + } + is ChatAction.BuyGateToken -> { viewModel.dispatchEvent(ChatViewModel.Event.GateFundingTapped(GroupGateFunding.BUY_TOKEN)) keyboard.hideIfVisible { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendDetailsSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendDetailsSheet.kt new file mode 100644 index 0000000000..86ff916a7c --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendDetailsSheet.kt @@ -0,0 +1,121 @@ +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.ui.TokenIconWithName +import com.flipcash.features.messenger.R +import com.flipcash.services.models.UserProfile +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton + +/** One person on the details sheet: who they are, and their profile when it has resolved. */ +internal data class GroupSendPerson(val name: String, val profile: UserProfile?) + +/** + * A group send's details, node 11293:6918 frame 10: the currency it was sent in, who sent it and + * who it paid. Names only — each part's amount is in the bubble, and only for the viewer's own. + */ +@Composable +internal fun GroupSendDetailsSheet( + tokenName: String, + tokenImageUrl: String, + sender: GroupSendPerson, + recipients: List, + onClose: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x4, bottom = CodeTheme.dimens.grid.x3), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x4), + ) { + Column( + modifier = Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x4), + ) { + DetailRow(label = stringResource(R.string.label_cashDetails_sent)) { + TokenIconWithName( + tokenName = tokenName, + tokenImage = tokenImageUrl, + imageSize = CodeTheme.dimens.staticGrid.x6, + spacing = CodeTheme.dimens.grid.x3, + textStyle = CodeTheme.typography.textMedium, + ) + } + DetailRow(label = stringResource(R.string.label_cashDetails_from)) { + PersonCell(sender) + } + recipients.forEachIndexed { index, person -> + DetailRow(label = if (index == 0) stringResource(R.string.label_cashDetails_to) else "") { + PersonCell(person) + } + } + } + CodeButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.action_groupSendDetailsClose), + buttonState = ButtonState.Filled, + onClick = onClose, + ) + } +} + +@Composable +private fun DetailRow(label: String, content: @Composable () -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + modifier = Modifier.width(64.dp), + text = label, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + content() + } +} + +@Composable +private fun PersonCell(person: GroupSendPerson) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + val avatar = Modifier + .requiredSize(CodeTheme.dimens.staticGrid.x6) + .clip(CircleShape) + if (person.profile != null) { + ContactAvatar(userProfile = person.profile, modifier = avatar) + } else { + ContactAvatar(photoUri = null, displayName = person.name, modifier = avatar) + } + Text( + text = person.name, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index a1725e8a08..4c83d5d8a7 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -15,6 +15,9 @@ sealed interface ChatAction { /** Opens a currency's info screen. */ data class ViewToken(val mint: Mint) : ChatAction + /** Opens who a group send ([ChatListItem.ContentBubble] with a multi-receiver cash) paid. */ + data class ViewGroupSend(val bubble: ChatListItem.ContentBubble) : 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/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index 768ea37f37..3ef71d28d7 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -199,6 +199,11 @@ sealed interface ChatListItem { * selected or copied. */ val redacted: Boolean = false, + /** + * The viewer's own part of a group send ([MessageContent.Cash.multiReceiver]), which the + * bubble shows as "You received". Null when the viewer was not one of its recipients. + */ + val receivedPart: MessageContent.Cash.Payment? = 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/main/kotlin/com/flipcash/shared/chat/ui/CashAmounts.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/CashAmounts.kt index 36cc57ee56..daa1e39c04 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/CashAmounts.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/CashAmounts.kt @@ -37,3 +37,14 @@ fun cashInDisplayCurrency(amount: Fiat, settledUsd: Fiat?): Fiat = settledUsd ?.let { LocalFiat(usdf = it, nativeAmount = amount).inDisplayCurrency() } ?: amount.inDisplayCurrency() + +/** + * One recipient's [part] of a group send in the viewer's currency, read the way [cashInDisplayCurrency] + * reads the whole: from the USD it settled at when the payment was USDF. + */ +@Composable +fun MessageContent.Cash.partInDisplayCurrency(part: MessageContent.Cash.Payment): Fiat = + cashInDisplayCurrency( + amount = Fiat(part.nativeAmount, amount.currencyCode), + settledUsd = Fiat(part.tokenQuarks, CurrencyCode.USD).takeIf { mint == Mint.usdf && part.tokenQuarks > 0 }, + ) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 6bfb7d74a8..b835810834 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Placeholder @@ -247,7 +248,23 @@ fun ContentBubble( attention = attention, ) - is MessageContent.Cash -> CashBubble( + is MessageContent.Cash -> if (content.multiReceiver.isNotEmpty()) GroupSendBubble( + modifier = modifier, + content = content, + senderName = item.sender?.displayName, + isFromSelf = item.isFromSelf, + receivedPart = item.receivedPart, + position = position, + maxWidth = bubbleMaxWidth, + onClick = if (interactive) { + { actionHandler(ChatAction.ViewGroupSend(item)) } + } else { + null + }, + onLongClick = onLongClick?.takeIf { interactive }, + onDoubleClick = onDoubleClick?.takeIf { interactive }, + attention = attention, + ) else CashBubble( modifier = modifier, amount = content.amount, tokenName = content.tokenName, @@ -1074,6 +1091,111 @@ private fun CashBubble( } } +/** + * A cash message that paid several members at once (`CashContent.multi_receiver`), node 11293:6918 + * frames 08 and 09: who sent the total, what each person got, and how many people it went to. A + * recipient also sees their own part, which can differ from the per-person line by a cent. + */ +@Composable +private fun GroupSendBubble( + content: MessageContent.Cash, + senderName: String?, + isFromSelf: Boolean, + receivedPart: MessageContent.Cash.Payment?, + position: BubblePosition, + maxWidth: Dp, + onClick: (() -> Unit)?, + onLongClick: (() -> Unit)?, + onDoubleClick: (() -> Unit)?, + modifier: Modifier = Modifier, + attention: () -> Float = { 0f }, +) { + Bubble( + isFromSelf = isFromSelf, + position = position, + minWidth = maxWidth, + maxWidth = maxWidth, + onClick = onClick, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + modifier = modifier, + attention = attention, + ) { + val total = cashInDisplayCurrency(content.amount, content.settledUsd()) + val parts = content.multiReceiver + // Uneven parts differ by one minor unit, so the smallest one with a "~" is the honest + // per-person figure; the vectors put the larger parts first. + val smallest = parts.minBy { it.nativeAmount } + val even = parts.all { it.nativeAmount == smallest.nativeAmount } + val each = content.partInDisplayCurrency(smallest).formatted() + val secondary = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Medium) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = CodeTheme.dimens.grid.x5), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text( + text = when { + isFromSelf -> stringResource(R.string.subtitle_youSent) + else -> stringResource( + R.string.subtitle_senderSent, + senderName ?: stringResource(R.string.title_unnamedUser), + ) + }, + style = secondary, + color = CodeTheme.colors.textSecondary, + ) + Text( + text = total.formatted(), + style = CodeTheme.typography.displayMedium, + color = CodeTheme.colors.textMain, + autoSize = TextAutoSize.StepBased( + minFontSize = 20.sp, + maxFontSize = CodeTheme.typography.displayMedium.fontSize + ), + maxLines = 1, + ) + Text( + text = stringResource( + if (even) R.string.label_groupSendEach else R.string.label_groupSendEachApprox, + each, + ), + style = secondary, + color = CodeTheme.colors.textSecondary, + ) + if (content.tokenName.isNotBlank() && !LocalInspectionMode.current) { + TokenIconWithName( + tokenName = content.tokenName, + tokenImage = content.tokenImageUrl, + imageSize = CodeTheme.dimens.staticGrid.x2, + spacing = CodeTheme.dimens.grid.x1, + textStyle = CodeTheme.typography.caption, + textColor = CodeTheme.colors.textSecondary, + ) + } + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), + text = pluralStringResource(R.plurals.label_groupSendToPeople, parts.size, parts.size), + style = secondary, + color = CodeTheme.colors.textSecondary, + ) + if (receivedPart != null) { + Text( + text = stringResource( + R.string.label_groupSendYouReceived, + content.partInDisplayCurrency(receivedPart).formatted(), + ), + style = secondary, + color = CodeTheme.colors.textMain, + ) + } + } + } +} + /** * The bubble's own geometry. Named rather than inlined because the citation panel inside a reply * has to be laid out against it: a panel that picks its own corner radius and its own padding stops From 8125d918d88c2dc570d064f2d68c9b1252e6049c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 15:47:52 -0400 Subject: [PATCH 07/23] fix(chat): credit and label only the viewer's own part of a group send The eager balance update credited every non-sender with the full amount of any cash message, so a group send would have credited each member the total. It now credits the viewer's multi_receiver part, or the amount when they are the single receiver, and nothing when the message names others. The cash bubble said "You received" to every non-sender. A one-person group send names its recipient, so other members now read " sent". --- .../app/messenger/internal/ChatViewModel.kt | 4 +- .../shared/chat/models/ChatListItem.kt | 5 +- .../flipcash/shared/chat/ui/CashAmounts.kt | 13 ++++ .../flipcash/shared/chat/ui/MessageBubble.kt | 11 +++- .../shared/chat/ui/CashPartForTest.kt | 61 +++++++++++++++++++ .../internal/delegates/EventStreamDelegate.kt | 17 +++++- .../chat/ChatCoordinatorEagerBalanceTest.kt | 41 +++++++++++++ 7 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/CashPartForTest.kt 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 e698e1d14b..9e148b262b 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 @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal import com.flipcash.shared.chat.ui.settledUsd +import com.flipcash.shared.chat.ui.partFor import com.flipcash.shared.chat.media.ChatPhoto import com.flipcash.services.models.chat.MediaItemRendition import com.flipcash.services.models.chat.LinkPreview @@ -1618,8 +1619,7 @@ internal class ChatViewModel @AssistedInject constructor( ?: stateFlow.value.participant?.name ?: resources.getString(R.string.title_unnamedUser), ) ?: UndecryptableHint.UpdateApp, - receivedPart = (enriched as? MessageContent.Cash)?.multiReceiver - ?.firstOrNull { it.receiver == userManager.accountId }, + receivedPart = (enriched as? MessageContent.Cash)?.partFor(userManager.accountId), ) // A carded link takes a row of its own, with the prose either side of it // on rows above and below. Reversed because the list is: this page runs diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index 3ef71d28d7..d0496439ec 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -200,8 +200,9 @@ sealed interface ChatListItem { */ val redacted: Boolean = false, /** - * The viewer's own part of a group send ([MessageContent.Cash.multiReceiver]), which the - * bubble shows as "You received". Null when the viewer was not one of its recipients. + * The viewer's own part of a cash message that names its recipients + * ([MessageContent.Cash.multiReceiver] or [MessageContent.Cash.receiver]), which the bubble + * shows as "You received". Null when the message names others and not the viewer. */ val receivedPart: MessageContent.Cash.Payment? = null, ) : TranscriptMessage { diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/CashAmounts.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/CashAmounts.kt index daa1e39c04..f3dbc2952b 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/CashAmounts.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/CashAmounts.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import com.flipcash.app.core.ui.forViewer import com.flipcash.app.core.ui.inDisplayCurrency import com.flipcash.services.models.chat.MessageContent +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 @@ -48,3 +49,15 @@ fun MessageContent.Cash.partInDisplayCurrency(part: MessageContent.Cash.Payment) amount = Fiat(part.nativeAmount, amount.currencyCode), settledUsd = Fiat(part.tokenQuarks, CurrencyCode.USD).takeIf { mint == Mint.usdf && part.tokenQuarks > 0 }, ) + +/** + * [userId]'s part of this payment when the message names its recipients: their entry in + * [MessageContent.Cash.multiReceiver], or the whole amount when they are the single [receiver]. + * Null when the message names other people only, or names no one. + */ +fun MessageContent.Cash.partFor(userId: ID?): MessageContent.Cash.Payment? = when { + userId == null -> null + multiReceiver.isNotEmpty() -> multiReceiver.firstOrNull { it.receiver == userId } + receiver == userId -> MessageContent.Cash.Payment(userId, amount.decimalValue, tokenQuarks ?: 0L) + else -> null +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index b835810834..a8770141d3 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -272,6 +272,13 @@ fun ContentBubble( isFromSelf = item.isFromSelf, action = content.action, settledUsd = content.settledUsd(), + // A one-person group send names its recipient; everyone else in the group + // reads who sent it rather than "You received". + paidOtherBy = if (!item.isFromSelf && content.receiver != null && item.receivedPart == null) { + item.sender?.displayName ?: stringResource(R.string.title_unnamedUser) + } else { + null + }, position = position, maxWidth = bubbleMaxWidth, // Dropped rather than ignored while the transcript is behind a backdrop: with @@ -996,6 +1003,7 @@ private fun CashBubble( maxWidth: Dp, action: MessageContent.Cash.Action = MessageContent.Cash.Action.SENT, settledUsd: Fiat? = null, + paidOtherBy: String? = null, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null, @@ -1070,7 +1078,8 @@ private fun CashBubble( MessageContent.Cash.Action.SENT -> R.string.subtitle_youSent } Text( - text = stringResource(subtitleRes), + text = paidOtherBy?.let { stringResource(R.string.subtitle_senderSent, it) } + ?: stringResource(subtitleRes), style = CodeTheme.typography.caption.copy( fontWeight = FontWeight.Medium, ), diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/CashPartForTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/CashPartForTest.kt new file mode 100644 index 0000000000..c06c850fc7 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/CashPartForTest.kt @@ -0,0 +1,61 @@ +package com.flipcash.shared.chat.ui + +import com.flipcash.services.models.chat.MessageContent +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** Which part of a cash message the viewer reads as "You received". */ +class CashPartForTest { + + private val self = listOf(1) + private val other = listOf(2) + private val third = listOf(3) + + private fun cash( + receiver: List? = null, + multiReceiver: List = emptyList(), + ) = MessageContent.Cash( + intentId = listOf(0), + amount = Fiat(fiat = 1.0, currencyCode = CurrencyCode.CAD), + mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa"), + receiver = receiver, + tokenQuarks = 700_000L, + multiReceiver = multiReceiver, + ) + + @Test + fun `a group send gives the viewer their own part`() { + val mine = MessageContent.Cash.Payment(self, 0.34, 240_000L) + val theirs = MessageContent.Cash.Payment(other, 0.33, 230_000L) + assertEquals(mine, cash(multiReceiver = listOf(theirs, mine)).partFor(self)) + } + + @Test + fun `a group send that skips the viewer gives nothing`() { + val theirs = MessageContent.Cash.Payment(other, 0.5, 1L) + val others = MessageContent.Cash.Payment(third, 0.5, 1L) + assertNull(cash(multiReceiver = listOf(theirs, others)).partFor(self)) + } + + @Test + fun `a single receiver gets the whole amount`() { + assertEquals( + MessageContent.Cash.Payment(self, 1.0, 700_000L), + cash(receiver = self).partFor(self), + ) + } + + @Test + fun `a single receiver who is someone else gives nothing`() { + assertNull(cash(receiver = other).partFor(self)) + } + + @Test + fun `a message naming no one gives nothing`() { + assertNull(cash().partFor(self)) + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index 20aa6ffdc0..b93f82ddca 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -31,6 +31,7 @@ import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.internal.MentionPoolUpdates import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Fiat import com.getcode.utils.TraceType import com.getcode.utils.trace import kotlinx.coroutines.CoroutineScope @@ -507,7 +508,7 @@ class EventStreamDelegate @Inject constructor( if (msg.senderId == selfId) continue for (content in msg.content) { if (content is MessageContent.Cash) { - tokenCoordinator.add(content.mint, content.amount) + content.receivedBy(selfId)?.let { tokenCoordinator.add(content.mint, it) } } } @@ -581,3 +582,17 @@ class EventStreamDelegate @Inject constructor( // endregion } + +/** + * What [userId] was paid by this cash message, for the eager balance update. A group send pays only + * the members it lists, each their own part, so the rest of the group gets nothing and a recipient + * gets their part rather than the total. A message with no recipients on record is a DM payment, + * which pays the one other person in full. + */ +internal fun MessageContent.Cash.receivedBy(userId: ID?): Fiat? = when { + multiReceiver.isNotEmpty() -> multiReceiver + .firstOrNull { it.receiver == userId } + ?.let { Fiat(it.nativeAmount, amount.currencyCode) } + receiver != null -> amount.takeIf { receiver == userId } + else -> amount +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt index e738df966c..485c93f968 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt @@ -51,6 +51,8 @@ class ChatCoordinatorEagerBalanceTest { private val selfId = listOf(1, 2, 3) private val otherId = listOf(4, 5, 6) + private val thirdId = listOf(7, 8, 9) + private val fourthId = listOf(10, 11, 12) private val chatId = ChatId("aabbccdd") private val mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa") @@ -155,6 +157,45 @@ class ChatCoordinatorEagerBalanceTest { unreadSeq = 0, ) + private fun groupSendMessage(vararg parts: Pair, Double>) = ChatMessage( + messageId = 1L, + senderId = otherId, + content = listOf(MessageContent.Cash( + intentId = listOf(0), + amount = Fiat(fiat = parts.sumOf { it.second }, currencyCode = CurrencyCode.CAD), + mint = mint, + multiReceiver = parts.map { (receiver, native) -> + MessageContent.Cash.Payment(receiver = receiver, nativeAmount = native, tokenQuarks = 1L) + }, + )), + timestamp = Instant.fromEpochSeconds(1000), + unreadSeq = 0, + ) + + @Test + fun `group send credits only the viewer's own part`() = runTest(testDispatchers.dispatcher) { + tornDown { + triggerCollection() + chatUpdatesChannel.send(chatUpdate(groupSendMessage(selfId to 3.34, thirdId to 3.33))) + advanceTimeBy(1_000.milliseconds) + runCurrent() + + coVerify(exactly = 1) { tokenCoordinator.add(mint, Fiat(fiat = 3.34, currencyCode = CurrencyCode.CAD)) } + } + } + + @Test + fun `group send the viewer is not part of credits nothing`() = runTest(testDispatchers.dispatcher) { + tornDown { + triggerCollection() + chatUpdatesChannel.send(chatUpdate(groupSendMessage(thirdId to 2.0, fourthId to 2.0))) + advanceTimeBy(1_000.milliseconds) + runCurrent() + + coVerify(exactly = 0) { tokenCoordinator.add(any(), any()) } + } + } + private fun textMessage(senderId: List?) = ChatMessage( messageId = 2L, senderId = senderId, From e30268fab349ba5a084db1bdc64b1bbed72c32f3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 19:35:41 -0400 Subject: [PATCH 08/23] fix(messenger): let the group send confirm sheet change the token The token pill on the confirm sheet drew a chevron but ignored taps. It now opens the token picker, and the send re-checks the balance, since the token can change after the amount step checked it. --- .../main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt | 2 ++ .../com/flipcash/app/messenger/internal/ChatViewModel.kt | 2 ++ .../app/messenger/internal/screens/cash/GroupSendScreens.kt | 4 ++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index b742123852..58a338b3d6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -281,6 +281,7 @@ private fun FlowConversationScreen( private fun FlowAmountEntryScreen() { val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val navigator = LocalCodeNavigator.current // Every way out of this step goes through the sheet's own dismissal, which animates it down to // Hidden and pops the entry once it settles. Popping the entry directly — navigateBack, or the // pop ResultBackNavigator does by default — deletes the scene mid-frame, so the sheet vanishes @@ -313,6 +314,7 @@ private fun FlowAmountEntryScreen() { token = state.token, sendProgress = state.sendProgress, onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendRequested) }, + onPickToken = { navigator.push(AppRoute.Sheets.TokenSelection(TokenPurpose.Select)) }, onDismiss = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendBack) }, ) } 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 9e148b262b..933fffc98f 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 @@ -3815,6 +3815,8 @@ internal class ChatViewModel @AssistedInject constructor( val chatId = stateFlow.value.chatId ?: return@launch val token = stateFlow.value.token ?: return@launch val owner = userManager.accountCluster ?: return@launch + // The confirm sheet can change the token after the amount step checked the balance. + if (checkBalanceLimit(total)) return@launch dispatchEvent(Event.SendStateUpdated(loading = true)) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt index 3bbd94607b..db9c5efb21 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt @@ -173,6 +173,7 @@ internal fun GroupSendConfirmSheet( token: Token?, sendProgress: LoadingSuccessState, onConfirm: () -> Unit, + onPickToken: () -> Unit, onDismiss: () -> Unit, ) { val locked by rememberUpdatedState(!sendProgress.isIdle) @@ -225,12 +226,11 @@ internal fun GroupSendConfirmSheet( ) } - // Shown, not changed here: the token was picked on the amount screen. TokenSelectionPill( token = token, textStyle = CodeTheme.typography.textMedium, imageSize = CodeTheme.dimens.staticGrid.x3, - onClick = {}, + onClick = { if (!locked) onPickToken() }, ) SlideToConfirm( From 57bcc0a528a7c9295b55c24274357743bed7e48a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 21:13:36 -0400 Subject: [PATCH 09/23] fix(chat): loosen the group send bubble and details sheet The details sheet stacked its rows 16dp apart with 24dp avatars, against the design's 20dp rows and 32dp avatars, and had no top gap above Sent. The group bubble now ends on a "to Alex Morgan and 2 Others" line, as on iOS, and a group payment to one other member uses the same layout. --- .../app/messenger/internal/ChatViewModel.kt | 34 ++++++++++ .../screens/cash/GroupSendDetailsSheet.kt | 10 +-- .../shared/chat/models/ChatListItem.kt | 6 ++ .../flipcash/shared/chat/ui/MessageBubble.kt | 66 +++++++++++-------- 4 files changed, 82 insertions(+), 34 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 933fffc98f..072911bdd6 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 @@ -1436,6 +1436,37 @@ internal class ChatViewModel @AssistedInject constructor( chatCoordinator.observeMediaSendProgress() .stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap()) + /** + * The foot line of a group's cash bubble, matching iOS's `ChatItem.groupSend`: a split names its + * first recipient and counts the rest, falling back to a head count while that name is unknown, + * and a payment to one other member names them. Null for a payment to the viewer alone, or to + * one member nobody has a name for yet. + */ + private fun groupSendRecipients(cash: MessageContent.Cash, profiles: Map?): String? { + val self = userManager.accountId + fun nameOf(id: ID): String? { + if (id == self) return resources.getString(R.string.title_you) + val profile = profiles?.get(id.hexEncodedString()) + if (profile == null || profile.displayName.isBlank()) chatCoordinator.requestSenderProfile(id) + return profile?.let { nameOrHandle(it.displayName, it.handle) } + } + + val parts = cash.multiReceiver + if (parts.size < 2) { + val receiver = cash.receiver?.takeIf { it != self } ?: return null + return nameOf(receiver)?.let { resources.getString(R.string.label_groupSendTo, it) } + } + val others = parts.size - 1 + return nameOf(parts.first().receiver) + ?.let { first -> + resources.getString( + R.string.label_groupSendTo, + resources.getQuantityString(R.plurals.label_groupSendRecipients, others, first, others), + ) + } + ?: resources.getQuantityString(R.plurals.label_groupSendToPeople, parts.size, parts.size) + } + /** * The transcript's rows before separators. Cached so a change to the unread boundary or the * separator config re-runs only [messages]' separator pass, not the per-message lookups here. @@ -1620,6 +1651,9 @@ internal class ChatViewModel @AssistedInject constructor( ?: resources.getString(R.string.title_unnamedUser), ) ?: UndecryptableHint.UpdateApp, receivedPart = (enriched as? MessageContent.Cash)?.partFor(userManager.accountId), + groupSendRecipients = (enriched as? MessageContent.Cash) + ?.takeIf { chatType == ChatType.GROUP } + ?.let { groupSendRecipients(it, profiles) }, ) // A carded link takes a row of its own, with the prose either side of it // on rows above and below. Reversed because the list is: this page runs diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendDetailsSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendDetailsSheet.kt index 86ff916a7c..4c807406d5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendDetailsSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendDetailsSheet.kt @@ -47,20 +47,20 @@ internal fun GroupSendDetailsSheet( .fillMaxWidth() .navigationBarsPadding() .padding(horizontal = CodeTheme.dimens.inset) - .padding(top = CodeTheme.dimens.grid.x4, bottom = CodeTheme.dimens.grid.x3), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x4), + .padding(top = CodeTheme.dimens.grid.x6, bottom = CodeTheme.dimens.grid.x2), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), ) { Column( modifier = Modifier .weight(1f, fill = false) .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x4), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x5), ) { DetailRow(label = stringResource(R.string.label_cashDetails_sent)) { TokenIconWithName( tokenName = tokenName, tokenImage = tokenImageUrl, - imageSize = CodeTheme.dimens.staticGrid.x6, + imageSize = CodeTheme.dimens.staticGrid.x8, spacing = CodeTheme.dimens.grid.x3, textStyle = CodeTheme.typography.textMedium, ) @@ -103,7 +103,7 @@ private fun PersonCell(person: GroupSendPerson) { horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), ) { val avatar = Modifier - .requiredSize(CodeTheme.dimens.staticGrid.x6) + .requiredSize(CodeTheme.dimens.staticGrid.x8) .clip(CircleShape) if (person.profile != null) { ContactAvatar(userProfile = person.profile, modifier = avatar) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index d0496439ec..1f56e7e4a3 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -205,6 +205,12 @@ sealed interface ChatListItem { * shows as "You received". Null when the message names others and not the viewer. */ val receivedPart: MessageContent.Cash.Payment? = null, + /** + * Who a cash message in a group paid, as the bubble's foot reads it: "to Alex Morgan and + * 2 Others", or "to Alex Morgan" for one member. Null outside a group, and for a payment to + * the viewer alone, which keeps the plain "You received" card. + */ + val groupSendRecipients: String? = 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/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index a8770141d3..640177447c 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.Dp @@ -248,12 +249,13 @@ fun ContentBubble( attention = attention, ) - is MessageContent.Cash -> if (content.multiReceiver.isNotEmpty()) GroupSendBubble( + is MessageContent.Cash -> if (content.multiReceiver.isNotEmpty() || item.groupSendRecipients != null) GroupSendBubble( modifier = modifier, content = content, senderName = item.sender?.displayName, isFromSelf = item.isFromSelf, receivedPart = item.receivedPart, + recipients = item.groupSendRecipients, position = position, maxWidth = bubbleMaxWidth, onClick = if (interactive) { @@ -1101,9 +1103,10 @@ private fun CashBubble( } /** - * A cash message that paid several members at once (`CashContent.multi_receiver`), node 11293:6918 - * frames 08 and 09: who sent the total, what each person got, and how many people it went to. A - * recipient also sees their own part, which can differ from the per-person line by a cent. + * A cash message in a group that names who it paid, node 11293:6918 frames 08 and 09, laid out as + * iOS's `ChatCashCardCell` lays out a group card: who sent it, the total, what each person got, the + * viewer's own part when they were paid, the token, and who it went to at the foot. A payment to one + * other member ([MessageContent.Cash.receiver]) draws the same card with no per-person line. */ @Composable private fun GroupSendBubble( @@ -1111,6 +1114,7 @@ private fun GroupSendBubble( senderName: String?, isFromSelf: Boolean, receivedPart: MessageContent.Cash.Payment?, + recipients: String?, position: BubblePosition, maxWidth: Dp, onClick: (() -> Unit)?, @@ -1132,11 +1136,6 @@ private fun GroupSendBubble( ) { val total = cashInDisplayCurrency(content.amount, content.settledUsd()) val parts = content.multiReceiver - // Uneven parts differ by one minor unit, so the smallest one with a "~" is the honest - // per-person figure; the vectors put the larger parts first. - val smallest = parts.minBy { it.nativeAmount } - val even = parts.all { it.nativeAmount == smallest.nativeAmount } - val each = content.partInDisplayCurrency(smallest).formatted() val secondary = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Medium) Column( @@ -1167,14 +1166,30 @@ private fun GroupSendBubble( ), maxLines = 1, ) - Text( - text = stringResource( - if (even) R.string.label_groupSendEach else R.string.label_groupSendEachApprox, - each, - ), - style = secondary, - color = CodeTheme.colors.textSecondary, - ) + if (parts.size >= 2) { + // Uneven parts differ by one minor unit, so the smallest one with a "~" is the + // honest per-person figure; the vectors put the larger parts first. + val smallest = parts.minBy { it.nativeAmount } + val even = parts.all { it.nativeAmount == smallest.nativeAmount } + Text( + text = stringResource( + if (even) R.string.label_groupSendEach else R.string.label_groupSendEachApprox, + content.partInDisplayCurrency(smallest).formatted(), + ), + style = secondary, + color = CodeTheme.colors.textSecondary, + ) + } + if (receivedPart != null && parts.isNotEmpty()) { + Text( + text = stringResource( + R.string.label_groupSendYouReceived, + content.partInDisplayCurrency(receivedPart).formatted(), + ), + style = secondary, + color = CodeTheme.colors.textMain, + ) + } if (content.tokenName.isNotBlank() && !LocalInspectionMode.current) { TokenIconWithName( tokenName = content.tokenName, @@ -1186,21 +1201,14 @@ private fun GroupSendBubble( ) } Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), - text = pluralStringResource(R.plurals.label_groupSendToPeople, parts.size, parts.size), + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x4), + text = recipients + ?: pluralStringResource(R.plurals.label_groupSendToPeople, parts.size, parts.size), style = secondary, color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, ) - if (receivedPart != null) { - Text( - text = stringResource( - R.string.label_groupSendYouReceived, - content.partInDisplayCurrency(receivedPart).formatted(), - ), - style = secondary, - color = CodeTheme.colors.textMain, - ) - } } } } From 0022d6ad50e828d8668a89efc5b6a1bac5a08c16 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 21:44:43 -0400 Subject: [PATCH 10/23] fix(chat): show the group send token name at full opacity --- .../main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 640177447c..915464bc5d 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -1197,7 +1197,7 @@ private fun GroupSendBubble( imageSize = CodeTheme.dimens.staticGrid.x2, spacing = CodeTheme.dimens.grid.x1, textStyle = CodeTheme.typography.caption, - textColor = CodeTheme.colors.textSecondary, + textColor = CodeTheme.colors.textMain, ) } Text( From ee564a7b51e3b7a92aca5f2e975a472c734a4ebd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 21:44:44 -0400 Subject: [PATCH 11/23] fix(messenger): match the confirm sheet token pill to other cash sheets Use the White10 pill background and padding from the reaction boost sheet, and drop the Each line when only one person is paid. --- .../internal/screens/cash/GroupSendScreens.kt | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt index db9c5efb21..2ae13e9fe5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt @@ -47,6 +47,7 @@ import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Token import com.getcode.theme.CodeTheme +import com.getcode.theme.White10 import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.SlideToConfirm import com.getcode.ui.theme.CodeButton @@ -219,17 +220,24 @@ internal fun GroupSendConfirmSheet( style = CodeTheme.typography.displayMedium, color = CodeTheme.colors.textMain, ) - Text( - text = eachLabel(parts, total.currencyCode), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) + if (parts.size >= 2) { + Text( + text = eachLabel(parts, total.currencyCode), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } } TokenSelectionPill( token = token, + background = White10, textStyle = CodeTheme.typography.textMedium, imageSize = CodeTheme.dimens.staticGrid.x3, + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.grid.x1 + 1.dp, + vertical = CodeTheme.dimens.grid.x1 - 1.dp, + ), onClick = { if (!locked) onPickToken() }, ) From 06985a54dc1522960256d05e46b6f1fad091c071 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 21:51:20 -0400 Subject: [PATCH 12/23] fix(chat): size the group send token row like the single send bubble Use the single send bubble's token icon size and lowercase "each" in the per-person line. --- apps/flipcash/core/src/main/res/values/strings.xml | 4 ++-- .../main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 66206369c1..ba9f8bfcbd 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1675,8 +1675,8 @@ to %1$d person to %1$d people - %1$s Each - ~%1$s Each + %1$s each + ~%1$s each %1$s sent You received %1$s Sent diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 915464bc5d..6b47a14088 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -1194,7 +1194,7 @@ private fun GroupSendBubble( TokenIconWithName( tokenName = content.tokenName, tokenImage = content.tokenImageUrl, - imageSize = CodeTheme.dimens.staticGrid.x2, + imageSize = CodeTheme.dimens.staticGrid.x4, spacing = CodeTheme.dimens.grid.x1, textStyle = CodeTheme.typography.caption, textColor = CodeTheme.colors.textMain, From edeef2a93ca9e0ba48ad97fbc81eaf45f773fa64 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 21:53:49 -0400 Subject: [PATCH 13/23] fix(chat): drop the group send "You received" line, name the viewer first The per-person line already gives the viewer's share. When the viewer was paid, the footer reads "to You and N Others", matching iOS. --- apps/flipcash/core/src/main/res/values/strings.xml | 1 - .../flipcash/app/messenger/internal/ChatViewModel.kt | 5 +++-- .../com/flipcash/shared/chat/ui/MessageBubble.kt | 12 ------------ 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index ba9f8bfcbd..6b1403246d 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1678,7 +1678,6 @@ %1$s each ~%1$s each %1$s sent - You received %1$s Sent From To 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 072911bdd6..15ceb45248 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 @@ -1438,7 +1438,7 @@ internal class ChatViewModel @AssistedInject constructor( /** * The foot line of a group's cash bubble, matching iOS's `ChatItem.groupSend`: a split names its - * first recipient and counts the rest, falling back to a head count while that name is unknown, + * first recipient (or "You" when the viewer was paid) and counts the rest, falling back to a head count while that name is unknown, * and a payment to one other member names them. Null for a payment to the viewer alone, or to * one member nobody has a name for yet. */ @@ -1457,7 +1457,8 @@ internal class ChatViewModel @AssistedInject constructor( return nameOf(receiver)?.let { resources.getString(R.string.label_groupSendTo, it) } } val others = parts.size - 1 - return nameOf(parts.first().receiver) + val lead = parts.firstOrNull { it.receiver == self } ?: parts.first() + return nameOf(lead.receiver) ?.let { first -> resources.getString( R.string.label_groupSendTo, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 6b47a14088..d174345a14 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -254,7 +254,6 @@ fun ContentBubble( content = content, senderName = item.sender?.displayName, isFromSelf = item.isFromSelf, - receivedPart = item.receivedPart, recipients = item.groupSendRecipients, position = position, maxWidth = bubbleMaxWidth, @@ -1113,7 +1112,6 @@ private fun GroupSendBubble( content: MessageContent.Cash, senderName: String?, isFromSelf: Boolean, - receivedPart: MessageContent.Cash.Payment?, recipients: String?, position: BubblePosition, maxWidth: Dp, @@ -1180,16 +1178,6 @@ private fun GroupSendBubble( color = CodeTheme.colors.textSecondary, ) } - if (receivedPart != null && parts.isNotEmpty()) { - Text( - text = stringResource( - R.string.label_groupSendYouReceived, - content.partInDisplayCurrency(receivedPart).formatted(), - ), - style = secondary, - color = CodeTheme.colors.textMain, - ) - } if (content.tokenName.isNotBlank() && !LocalInspectionMode.current) { TokenIconWithName( tokenName = content.tokenName, From 99881094db3fd5f0833d5ec0641a06b4da3e0a46 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 21:55:42 -0400 Subject: [PATCH 14/23] fix(chat): trim the group send bubble's vertical padding --- .../main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index d174345a14..c5c364eccc 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -1139,7 +1139,7 @@ private fun GroupSendBubble( Column( modifier = Modifier .fillMaxWidth() - .padding(vertical = CodeTheme.dimens.grid.x5), + .padding(vertical = CodeTheme.dimens.grid.x2), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), ) { From 5aa78c084e16f289bb198872f0e80d3ae8378b61 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 21:58:29 -0400 Subject: [PATCH 15/23] fix(chat): show the viewer's avatar on the group send details sheet --- .../kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 15ceb45248..c5ed25a323 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 @@ -1371,7 +1371,7 @@ internal class ChatViewModel @AssistedInject constructor( */ internal fun groupSendPerson(userIdHex: String?): GroupSendPerson { if (userIdHex == null || userIdHex == userManager.accountId?.hexEncodedString()) { - return GroupSendPerson(resources.getString(R.string.title_you), profile = null) + return GroupSendPerson(resources.getString(R.string.title_you), userManager.profile) } val profile = senderProfiles.value?.get(userIdHex) val name = profile?.let { nameOrHandle(it.displayName, it.handle) } From d842b00b779e98e3cdde2b73421da00d4a5ca82c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 22:04:48 -0400 Subject: [PATCH 16/23] feat(messenger): open a recipient's profile from the group send details Each To row now shows a chevron and opens that member's profile. The push waits for the sheet's hide animation, since the sheet pops whatever is on top once it settles. --- .../flipcash/app/messenger/ChatFlowScreen.kt | 9 ++++ .../app/messenger/internal/ChatViewModel.kt | 7 ++- .../screens/cash/GroupSendDetailsSheet.kt | 47 ++++++++++++++++--- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 58a338b3d6..d2bbec8b7c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -191,11 +191,20 @@ private fun chatEntryProvider( annotatedEntry { step -> val viewModel = flowSharedViewModel() val dismissSheet = LocalBottomSheetDismissDispatcher.current + val navigator = LocalCodeNavigator.current GroupSendDetailsSheet( tokenName = step.tokenName, tokenImageUrl = step.tokenImageUrl, sender = viewModel.groupSendPerson(step.senderIdHex), recipients = step.recipientIdsHex.map(viewModel::groupSendPerson), + onOpenProfile = { participant -> + // The sheet pops whatever is on top once its hide animation ends, so a push made + // alongside dismissSheet() lands first and is the entry that gets popped. Push from + // the scene's after-dismiss hook instead, once the sheet itself is gone. + navigator.pendingSheetDismiss = { + navigator.push(ChatStep.Profile(contact = participant, origin = ProfileOrigin.GroupMember)) + } + }, onClose = { dismissSheet() }, ) } 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 c5ed25a323..9c4e7dcaab 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 @@ -1369,6 +1369,7 @@ internal class ChatViewModel @AssistedInject constructor( * Someone on a group send's details sheet, by their hex id; null is the viewer. A member whose * profile hasn't resolved reads as the generic name until it does. */ + @OptIn(ExperimentalStdlibApi::class) internal fun groupSendPerson(userIdHex: String?): GroupSendPerson { if (userIdHex == null || userIdHex == userManager.accountId?.hexEncodedString()) { return GroupSendPerson(resources.getString(R.string.title_you), userManager.profile) @@ -1376,7 +1377,11 @@ internal class ChatViewModel @AssistedInject constructor( val profile = senderProfiles.value?.get(userIdHex) val name = profile?.let { nameOrHandle(it.displayName, it.handle) } ?: resources.getString(R.string.title_unnamedUser) - return GroupSendPerson(name, profile) + val participant = profile?.let { resolved -> + runCatching { userIdHex.hexToByteArray().toList() }.getOrNull() + ?.let { ChatParticipant.TipUser(it, resolved) } + } + return GroupSendPerson(name, profile, participant) } fun memberParticipant(userId: ID): ChatParticipant.TipUser? = diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendDetailsSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendDetailsSheet.kt index 4c807406d5..a6721c09ec 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendDetailsSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendDetailsSheet.kt @@ -1,8 +1,10 @@ package com.flipcash.app.messenger.internal.screens.cash +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding @@ -11,14 +13,17 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.ui.TokenIconWithName import com.flipcash.features.messenger.R import com.flipcash.services.models.UserProfile @@ -27,8 +32,15 @@ import com.getcode.theme.CodeTheme import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton -/** One person on the details sheet: who they are, and their profile when it has resolved. */ -internal data class GroupSendPerson(val name: String, val profile: UserProfile?) +/** + * One person on the details sheet: who they are, their profile when it has resolved, and who to + * open when their row is tapped. [participant] is null for the viewer and for anyone unresolved. + */ +internal data class GroupSendPerson( + val name: String, + val profile: UserProfile?, + val participant: ChatParticipant.TipUser? = null, +) /** * A group send's details, node 11293:6918 frame 10: the currency it was sent in, who sent it and @@ -40,6 +52,7 @@ internal fun GroupSendDetailsSheet( tokenImageUrl: String, sender: GroupSendPerson, recipients: List, + onOpenProfile: (ChatParticipant.TipUser) -> Unit, onClose: () -> Unit, ) { Column( @@ -52,6 +65,7 @@ internal fun GroupSendDetailsSheet( ) { Column( modifier = Modifier + .fillMaxWidth() .weight(1f, fill = false) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x5), @@ -69,7 +83,11 @@ internal fun GroupSendDetailsSheet( PersonCell(sender) } recipients.forEachIndexed { index, person -> - DetailRow(label = if (index == 0) stringResource(R.string.label_cashDetails_to) else "") { + val participant = person.participant + DetailRow( + label = if (index == 0) stringResource(R.string.label_cashDetails_to) else "", + onClick = participant?.let { { onOpenProfile(it) } }, + ) { PersonCell(person) } } @@ -84,8 +102,17 @@ internal fun GroupSendDetailsSheet( } @Composable -private fun DetailRow(label: String, content: @Composable () -> Unit) { - Row(verticalAlignment = Alignment.CenterVertically) { +private fun DetailRow( + label: String, + onClick: (() -> Unit)? = null, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), + verticalAlignment = Alignment.CenterVertically, + ) { Text( modifier = Modifier.width(64.dp), text = label, @@ -93,12 +120,20 @@ private fun DetailRow(label: String, content: @Composable () -> Unit) { color = CodeTheme.colors.textSecondary, ) content() + if (onClick != null) { + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } } } @Composable -private fun PersonCell(person: GroupSendPerson) { +private fun RowScope.PersonCell(person: GroupSendPerson) { Row( + modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), ) { From 276194613958a99193fe60f8a09ea446da5b80f8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 22:06:31 -0400 Subject: [PATCH 17/23] fix(messenger): show a group payment to you alone as the group card --- .../com/flipcash/app/messenger/internal/ChatViewModel.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 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 9c4e7dcaab..bf4e8b843f 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 @@ -1444,8 +1444,8 @@ internal class ChatViewModel @AssistedInject constructor( /** * The foot line of a group's cash bubble, matching iOS's `ChatItem.groupSend`: a split names its * first recipient (or "You" when the viewer was paid) and counts the rest, falling back to a head count while that name is unknown, - * and a payment to one other member names them. Null for a payment to the viewer alone, or to - * one member nobody has a name for yet. + * and a payment to one member names them ("to You" when that is the viewer). Null for a payment + * with no receiver, or to one member nobody has a name for yet. */ private fun groupSendRecipients(cash: MessageContent.Cash, profiles: Map?): String? { val self = userManager.accountId @@ -1458,7 +1458,7 @@ internal class ChatViewModel @AssistedInject constructor( val parts = cash.multiReceiver if (parts.size < 2) { - val receiver = cash.receiver?.takeIf { it != self } ?: return null + val receiver = cash.receiver ?: return null return nameOf(receiver)?.let { resources.getString(R.string.label_groupSendTo, it) } } val others = parts.size - 1 From 83a71103e8c5b237c8b03325c9a53fd7ee45bd97 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 22:17:10 -0400 Subject: [PATCH 18/23] fix(messenger): list the payee of a one-recipient group payment in its details --- .../app/messenger/internal/screens/MessengerScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 0d9d7f0059..f47e2ad400 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 @@ -216,7 +216,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { tokenName = cash.tokenName, tokenImageUrl = cash.tokenImageUrl, senderIdHex = action.bubble.authorId?.hexEncodedString(), - recipientIdsHex = cash.multiReceiver.map { it.receiver.hexEncodedString() }, + // A one-recipient group payment names its payee in `receiver`, not `multiReceiver`. + recipientIdsHex = cash.multiReceiver.map { it.receiver } + .ifEmpty { listOfNotNull(cash.receiver) } + .map { it.hexEncodedString() }, ) ) } From dbd63b837f78ea42d7b79ae35d304d3f7d5f9534 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 22:23:27 -0400 Subject: [PATCH 19/23] feat(messenger): pick the send currency from a half sheet in the chat send flow Both the amount step's token pill and the group send confirm pill open ChatStep.SendCurrency, which reuses the paid-reaction currency list, so choosing a currency returns to the same step instead of leaving the flow. --- .../com/flipcash/app/core/chat/ChatStep.kt | 8 ++++++++ .../flipcash/app/messenger/ChatFlowScreen.kt | 11 +++++----- .../boost/ReactionBoostCurrencySheet.kt | 14 +++++++++++-- .../screens/cash/ChatAmountEntryScreen.kt | 9 ++++----- .../internal/screens/cash/GroupSendScreens.kt | 20 ++++++++++++++++--- 5 files changed, 47 insertions(+), 15 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index 0c9d59bd3a..5e08f61361 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -192,6 +192,14 @@ sealed interface ChatStep : FlowStep, Parcelable { val messageId: Long, ) : ChatStep, Sheet, HalfSheet + /** + * The currency picker behind the send sheet's token pill on the amount step. Opens over the + * send sheet, which stays up beneath it, and closes on a pick. + */ + @Parcelize + @Serializable + data object SendCurrency : ChatStep, Sheet, HalfSheet + /** * The keypad behind [ReactionBoost]'s "…" chip, for an amount the presets don't cover. Returns * the amount in whole US cents. Opens over the Paid Reactions sheet, which stays up beneath it. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index d2bbec8b7c..ec28eb965b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -29,7 +29,6 @@ import com.flipcash.app.core.chat.GroupInviteResult import com.flipcash.app.core.chat.ProfileOrigin import com.flipcash.app.core.chat.ReactionBoostAmountResult import com.flipcash.app.core.extensions.openAsSheet -import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.core.util.Linkify import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel @@ -218,6 +217,9 @@ private fun chatEntryProvider( annotatedEntry { ReactionBoostCurrencySheet() } + annotatedEntry { + ReactionBoostCurrencySheet() + } annotatedEntry { step -> FlowReactionBoostAmountScreen(step) } @@ -298,9 +300,9 @@ private fun FlowAmountEntryScreen() { val dismissSheet = LocalBottomSheetDismissDispatcher.current val resultBack = resultBackNavigator(exit = dismissSheet) - // No re-shadow: ChatAmountEntryContent reads the inner LocalCodeNavigator, and its - // navigator.push(AppRoute.Main.RegionSelection) / push(AppRoute.Sheets.TokenSelection) - // bubble to the app navigator through the dispatcher. + // No re-shadow: ChatAmountEntryContent reads the inner LocalCodeNavigator. Its + // push(ChatStep.SendCurrency) stays in this flow, as a half sheet over this one; its + // push(AppRoute.Main.RegionSelection) bubbles to the app navigator through the dispatcher. // A private group's send carries on past the amount to Select People and its confirm sheet, // in this same sheet. Back steps through them rather than dismissing. val groupSend = state.groupSend @@ -323,7 +325,6 @@ private fun FlowAmountEntryScreen() { token = state.token, sendProgress = state.sendProgress, onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendRequested) }, - onPickToken = { navigator.push(AppRoute.Sheets.TokenSelection(TokenPurpose.Select)) }, onDismiss = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendBack) }, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/boost/ReactionBoostCurrencySheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/boost/ReactionBoostCurrencySheet.kt index f6e9e49145..1761f1a0df 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/boost/ReactionBoostCurrencySheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/boost/ReactionBoostCurrencySheet.kt @@ -32,12 +32,22 @@ import kotlinx.coroutines.flow.onEach * The "Pay with" currency picker, drawn as a sheet over the Paid Reactions sheet so the amount and * emoji the viewer chose are still there when it closes. Picking a currency moves the wallet's * selected token, which is what the Paid Reactions sheet reads its "Pay with" pill from, then - * closes this sheet only. + * closes this sheet only. The chat's send sheet opens it the same way, for its amount pill. */ @Composable internal fun ReactionBoostCurrencySheet() { // Exit through the sheet so it animates down rather than having its scene deleted mid-frame. val dismissSheet = LocalBottomSheetDismissDispatcher.current + CurrencyPickerContent(onPicked = { dismissSheet() }) +} + +/** + * The compact "Select Currency" list. Picking moves the wallet's selected token, then calls + * [onPicked] once the new selection has landed, so whatever reads the selected token already + * shows it. + */ +@Composable +internal fun CurrencyPickerContent(onPicked: () -> Unit) { val viewModel = hiltViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -93,7 +103,7 @@ internal fun ReactionBoostCurrencySheet() { LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() - .onEach { dismissSheet() } + .onEach { onPicked() } .launchIn(this) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt index 4f51d0d880..b560d12636 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt @@ -6,8 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.tokens.TokenPurpose +import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.ui.TokenSelectionPill import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.features.messenger.R @@ -74,9 +73,9 @@ internal fun ChatAmountEntryContent( .wrapContentHeight(), token = token ) { - navigator.push( - AppRoute.Sheets.TokenSelection(TokenPurpose.Select) - ) + // A half sheet over this one, not the app's full-screen picker, so the + // amount stays in view underneath. + navigator.push(ChatStep.SendCurrency) } }, // A close X, not an up arrow: this is a sheet over the conversation, and dismissing diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt index 2ae13e9fe5..5c31826cd2 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt @@ -23,7 +23,12 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.activity.compose.BackHandler import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.flipcash.app.messenger.internal.screens.boost.CurrencyPickerContent import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -165,6 +170,9 @@ private fun MemberCheckRow( /** * The confirm sheet over Select People, node 11293:6918: who is being paid, the total, what each * gets, and the swipe. Dismissing it goes back to Select People; it can't be dismissed mid-send. + * + * The token pill swaps this sheet's content for the currency list and back, rather than opening a + * sheet of its own: this one is a separate window, so a navigation sheet would draw beneath it. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -174,13 +182,14 @@ internal fun GroupSendConfirmSheet( token: Token?, sendProgress: LoadingSuccessState, onConfirm: () -> Unit, - onPickToken: () -> Unit, onDismiss: () -> Unit, ) { val locked by rememberUpdatedState(!sendProgress.isIdle) + var pickingCurrency by rememberSaveable { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, - confirmValueChange = { !locked }, + // Back or a swipe on the currency list returns to the confirm content, not Select People. + confirmValueChange = { !locked && !pickingCurrency }, ) val total = groupSend.total ?: return val parts = (groupSend.plan as? GroupSendPlan.Pay)?.parts ?: return @@ -191,6 +200,11 @@ internal fun GroupSendConfirmSheet( sheetState = sheetState, containerColor = CodeTheme.colors.background, ) { + BackHandler(enabled = pickingCurrency) { pickingCurrency = false } + if (pickingCurrency) { + CurrencyPickerContent(onPicked = { pickingCurrency = false }) + return@ModalBottomSheet + } Column( modifier = Modifier .fillMaxWidth() @@ -238,7 +252,7 @@ internal fun GroupSendConfirmSheet( horizontal = CodeTheme.dimens.grid.x1 + 1.dp, vertical = CodeTheme.dimens.grid.x1 - 1.dp, ), - onClick = { if (!locked) onPickToken() }, + onClick = { if (!locked) pickingCurrency = true }, ) SlideToConfirm( From a9702f9ca4698acf880812a3e60ba1c592773168 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 22:32:41 -0400 Subject: [PATCH 20/23] fix(messenger): open the sender and token from group send details The From row and the Sent token row had no tap target, so an incoming payment gave no way to reach the sender's profile. Both rows now show a chevron: From opens the sender's profile, Sent opens the token info. --- .../main/kotlin/com/flipcash/app/core/chat/ChatStep.kt | 2 ++ .../kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt | 6 ++++++ .../app/messenger/internal/screens/MessengerScreen.kt | 1 + .../internal/screens/cash/GroupSendDetailsSheet.kt | 9 +++++++-- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index 5e08f61361..19cb96474f 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -2,6 +2,7 @@ package com.flipcash.app.core.chat import android.os.Parcelable import com.flipcash.services.models.chat.ChatId +import com.getcode.solana.keys.Mint import com.getcode.navigation.HalfSheet import com.getcode.navigation.Sheet import com.getcode.navigation.WrapContentSheet @@ -224,6 +225,7 @@ sealed interface ChatStep : FlowStep, Parcelable { @Parcelize @Serializable data class GroupSendDetails( + val mint: Mint, val tokenName: String, val tokenImageUrl: String, val senderIdHex: String?, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index ec28eb965b..c33e59cd0c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -21,6 +21,7 @@ import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.scene.SinglePaneSceneStrategy import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.chat.ChatSendResult @@ -204,6 +205,11 @@ private fun chatEntryProvider( navigator.push(ChatStep.Profile(contact = participant, origin = ProfileOrigin.GroupMember)) } }, + onOpenToken = { + navigator.pendingSheetDismiss = { + navigator.push(AppRoute.Token.Info(step.mint, TokenInfoEntry.Chat)) + } + }, onClose = { dismissSheet() }, ) } 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 f47e2ad400..daefc9d7ac 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 @@ -213,6 +213,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { if (cash != null) keyboard.hideIfVisible { navigator.push( ChatStep.GroupSendDetails( + mint = cash.mint, tokenName = cash.tokenName, tokenImageUrl = cash.tokenImageUrl, senderIdHex = action.bubble.authorId?.hexEncodedString(), diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendDetailsSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendDetailsSheet.kt index a6721c09ec..8695f2e79d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendDetailsSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendDetailsSheet.kt @@ -53,6 +53,7 @@ internal fun GroupSendDetailsSheet( sender: GroupSendPerson, recipients: List, onOpenProfile: (ChatParticipant.TipUser) -> Unit, + onOpenToken: () -> Unit, onClose: () -> Unit, ) { Column( @@ -70,8 +71,9 @@ internal fun GroupSendDetailsSheet( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x5), ) { - DetailRow(label = stringResource(R.string.label_cashDetails_sent)) { + DetailRow(label = stringResource(R.string.label_cashDetails_sent), onClick = onOpenToken) { TokenIconWithName( + modifier = Modifier.weight(1f), tokenName = tokenName, tokenImage = tokenImageUrl, imageSize = CodeTheme.dimens.staticGrid.x8, @@ -79,7 +81,10 @@ internal fun GroupSendDetailsSheet( textStyle = CodeTheme.typography.textMedium, ) } - DetailRow(label = stringResource(R.string.label_cashDetails_from)) { + DetailRow( + label = stringResource(R.string.label_cashDetails_from), + onClick = sender.participant?.let { { onOpenProfile(it) } }, + ) { PersonCell(sender) } recipients.forEachIndexed { index, person -> From dc3692f8e29418fd99d33b09ce1e19914527b94b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 25 Oct 2026 13:52:18 -0400 Subject: [PATCH 21/23] fix(messenger): slide Select People in on the amount sheet's own nav host The amount sheet swapped its content for Select People in place, so the screen appeared without the forward slide. The sheet now hosts its own FlowHost with Amount and People steps, driven by the view model's group send stage, so Next and back animate like any other push and pop. --- .../flipcash/app/messenger/ChatFlowScreen.kt | 120 +++++++++++------- .../internal/groupsend/GroupSendStep.kt | 20 +++ 2 files changed, 95 insertions(+), 45 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendStep.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index c33e59cd0c..a4b940895a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -4,6 +4,7 @@ import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.material3.Text import androidx.activity.compose.BackHandler +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -47,6 +48,7 @@ import com.flipcash.app.messenger.internal.screens.boost.ReactionBoostSheet import com.flipcash.app.messenger.internal.screens.boost.ReactionBoostViewModel import com.getcode.theme.CodeTheme import com.flipcash.app.messenger.internal.groupsend.GroupSendState +import com.flipcash.app.messenger.internal.groupsend.GroupSendStep import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent import com.flipcash.app.messenger.internal.screens.cash.GroupSelectPeopleContent import com.flipcash.app.messenger.internal.screens.cash.GroupSendDetailsSheet @@ -129,7 +131,7 @@ fun ChatFlowScreen( // onRootReached, and so does system back — so this is the one place that has to do it. // Popping with the IME still up drags the screen behind it out from under the keyboard. onExit = { _, _ -> keyboard.hideIfVisible { navigator.pop() } }, - entryProvider = chatEntryProvider(route.openKeyboard), + entryProvider = chatEntryProvider(route.openKeyboard, resultStateRegistry), // ChatStep.AmountEntry and ChatStep.InviteToGroup are Sheets, so the // flow needs the sheet strategy to draw them as such; without it the step would fall // through to SinglePane and cover the thread. Amount entry @@ -146,12 +148,13 @@ fun ChatFlowScreen( @Composable private fun chatEntryProvider( openKeyboard: Boolean, + resultStateRegistry: NavResultStateRegistry, ): (NavKey) -> NavEntry = entryProvider { annotatedEntry { FlowConversationScreen(openKeyboard) } annotatedEntry { - FlowAmountEntryScreen() + FlowAmountEntryScreen(resultStateRegistry) } annotatedEntry { FlowGroupInviteSheet() @@ -295,10 +298,11 @@ private fun FlowConversationScreen( } @Composable -private fun FlowAmountEntryScreen() { +private fun FlowAmountEntryScreen(resultStateRegistry: NavResultStateRegistry) { val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val navigator = LocalCodeNavigator.current + // The chat flow's navigator, read before the sheet's own FlowHost below shadows it. + val chatNavigator = LocalCodeNavigator.current // Every way out of this step goes through the sheet's own dismissal, which animates it down to // Hidden and pops the entry once it settles. Popping the entry directly — navigateBack, or the // pop ResultBackNavigator does by default — deletes the scene mid-frame, so the sheet vanishes @@ -306,52 +310,78 @@ private fun FlowAmountEntryScreen() { val dismissSheet = LocalBottomSheetDismissDispatcher.current val resultBack = resultBackNavigator(exit = dismissSheet) - // No re-shadow: ChatAmountEntryContent reads the inner LocalCodeNavigator. Its - // push(ChatStep.SendCurrency) stays in this flow, as a half sheet over this one; its - // push(AppRoute.Main.RegionSelection) bubbles to the app navigator through the dispatcher. // A private group's send carries on past the amount to Select People and its confirm sheet, - // in this same sheet. Back steps through them rather than dismissing. - val groupSend = state.groupSend - BackHandler(enabled = groupSend != null && groupSend.stage != GroupSendState.Stage.Amount) { - viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendBack) - } - if (groupSend != null && groupSend.stage != GroupSendState.Stage.Amount) { - val candidates = state.groupSendCandidates - GroupSelectPeopleContent( - candidates = candidates, - selected = groupSend.selected, - onBack = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendBack) }, - onToggle = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(it.userId)) }, - onSend = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed) }, - ) - if (groupSend.stage == GroupSendState.Stage.Confirm) { - GroupSendConfirmSheet( - groupSend = groupSend, - recipients = groupSend.selected.mapNotNull { id -> candidates.find { it.userId == id } }, - token = state.token, - sendProgress = state.sendProgress, - onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendRequested) }, - onDismiss = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendBack) }, - ) - } - LaunchedEffect(viewModel) { - viewModel.eventFlow.filterIsInstance() - .collect { resultBack.returnValue(ChatSendResult) } - } - return - } + // in this same sheet. The sheet has its own nav host so Select People slides in over the + // amount; the view model's stage decides which step is on top. + val inPeople = state.groupSend?.let { it.stage != GroupSendState.Stage.Amount } == true - ChatAmountEntryContent( - amountDelegate = viewModel.amountDelegate, - resolveState = state.resolveState, - token = state.token, - eventFlow = viewModel.eventFlow, - onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnConfirmRequested) }, - onSendComplete = { resultBack.returnValue(ChatSendResult) }, // intra-flow result -> Conversation - onExit = dismissSheet, + FlowHost( + initialStack = listOf(GroupSendStep.Amount), + resultStateRegistry = resultStateRegistry, + onExit = { _, _ -> dismissSheet() }, + entryProvider = entryProvider { + annotatedEntry { + val flowNavigator = rememberFlowNavigator() + LaunchedEffect(inPeople) { + if (inPeople) flowNavigator.navigateTo(GroupSendStep.People) + } + // ChatAmountEntryContent's push(ChatStep.SendCurrency) belongs to the chat flow, + // as a half sheet over this one; on this sheet's navigator it would land here. + // Its push(AppRoute.Main.RegionSelection) bubbles to the app navigator either way. + CompositionLocalProvider(LocalCodeNavigator provides chatNavigator) { + ChatAmountEntryContent( + amountDelegate = viewModel.amountDelegate, + resolveState = state.resolveState, + token = state.token, + eventFlow = viewModel.eventFlow, + onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnConfirmRequested) }, + onSendComplete = { resultBack.returnValue(ChatSendResult) }, // intra-flow result -> Conversation + onExit = dismissSheet, + ) + } + } + annotatedEntry { + val flowNavigator = rememberFlowNavigator() + LaunchedEffect(inPeople) { + if (!inPeople) flowNavigator.back() + } + // Back goes through the view model, whose stage change pops this step above; + // the nav host popping it alone would leave the stage behind. + BackHandler { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendBack) } + FlowGroupSelectPeople(viewModel) { resultBack.returnValue(ChatSendResult) } + } + }, ) } +@Composable +private fun FlowGroupSelectPeople(viewModel: ChatViewModel, onSendComplete: () -> Unit) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val groupSend = state.groupSend ?: return + val candidates = state.groupSendCandidates + GroupSelectPeopleContent( + candidates = candidates, + selected = groupSend.selected, + onBack = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendBack) }, + onToggle = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(it.userId)) }, + onSend = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed) }, + ) + if (groupSend.stage == GroupSendState.Stage.Confirm) { + GroupSendConfirmSheet( + groupSend = groupSend, + recipients = groupSend.selected.mapNotNull { id -> candidates.find { it.userId == id } }, + token = state.token, + sendProgress = state.sendProgress, + onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendRequested) }, + onDismiss = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendBack) }, + ) + } + LaunchedEffect(viewModel) { + viewModel.eventFlow.filterIsInstance() + .collect { onSendComplete() } + } +} + /** * The invite sheet, on the conversation's own view model. * diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendStep.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendStep.kt new file mode 100644 index 0000000000..1a979d7a04 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendStep.kt @@ -0,0 +1,20 @@ +package com.flipcash.app.messenger.internal.groupsend + +import android.os.Parcelable +import com.getcode.navigation.flow.FlowStep +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * The screens inside the amount entry sheet, on the sheet's own nav host so Select People slides + * in over the amount rather than replacing it. The confirm sheet is drawn over [People], not a step. + */ +internal sealed interface GroupSendStep : FlowStep, Parcelable { + @Parcelize + @Serializable + data object Amount : GroupSendStep + + @Parcelize + @Serializable + data object People : GroupSendStep +} From 7da35d241476713321e90496197a77920801ba3c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 25 Oct 2026 14:25:51 -0400 Subject: [PATCH 22/23] fix(messenger): present group send like the share sheet Group send opens as GroupSendAmountEntry, a FullscreenSheet like ShareMessage, and its steps are NonDraggableRoute, so the amount and Select People screens close by their X or back arrow only. The confirm sheet gets an X, no drag handle, and turns off drag and tap-outside dismissal. --- .../com/flipcash/app/core/chat/ChatStep.kt | 14 +++++++++++ .../flipcash/app/messenger/ChatFlowScreen.kt | 8 ++++-- .../app/messenger/internal/ChatViewModel.kt | 7 +++--- .../internal/groupsend/GroupSendStep.kt | 6 ++++- .../internal/screens/cash/GroupSendScreens.kt | 25 ++++++++++++++++++- .../internal/ChatGroupCashLinkTest.kt | 2 +- 6 files changed, 54 insertions(+), 8 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index 19cb96474f..2ed3e1d666 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -50,6 +50,20 @@ sealed interface ChatStep : FlowStep, Parcelable { @Serializable data object AmountEntry : ChatStep, NavigationRetVal, Sheet + /** + * [AmountEntry] for a send to a private group's members, which carries on past the amount to + * Select People and the confirm. Presented as the share sheet ([ShareMessage]) is: full screen, + * closed by its X. Its steps can't be dragged away (see `GroupSendStep`), so a half-picked send + * isn't lost to a stray swipe. + */ + @Parcelize + @Serializable + data object GroupSendAmountEntry : + ChatStep, + NavigationRetVal, + Sheet, + com.getcode.navigation.FullscreenSheet + /** * Nodes 10329:12104 and 10330:19549 — share or copy a group's invite link, or send it to * recent 1:1 chats. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index a4b940895a..91c2db89a2 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -156,6 +156,9 @@ private fun chatEntryProvider( annotatedEntry { FlowAmountEntryScreen(resultStateRegistry) } + annotatedEntry { + FlowAmountEntryScreen(resultStateRegistry) + } annotatedEntry { FlowGroupInviteSheet() } @@ -263,10 +266,11 @@ private fun FlowConversationScreen( LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() - .collect { + .collect { event -> // ChatStep.AmountEntry is a FlowStep -> the dispatcher keeps this push on the inner // flow stack and registers the result callback on the inner store (intra-flow). - navigator.navigateForResult(ChatStep.AmountEntry) { result -> + val step = if (event.toMembers) ChatStep.GroupSendAmountEntry else ChatStep.AmountEntry + navigator.navigateForResult(step) { result -> if (result is NavResultOrCanceled.ReturnValue) { viewModel.dispatchEvent(ChatViewModel.Event.OnStartMessageInput) } 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 bf4e8b843f..6a10eba653 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 @@ -999,7 +999,8 @@ internal class ChatViewModel @AssistedInject constructor( data class PhotosRestored(val photos: List) : Event data class RetryMessage(val pendingId: String?, val content: MessageContent) : Event - data object NavigateToAmountEntry : Event + /** @param toMembers whether this is a group send, which opens [ChatStep.GroupSendAmountEntry]. */ + data class NavigateToAmountEntry(val toMembers: Boolean) : Event data object PresentDepositOptions : Event data class OpenScreen(val route: AppRoute, val asSheet: Boolean = false): Event @@ -3595,7 +3596,7 @@ internal class ChatViewModel @AssistedInject constructor( ) ) amountDelegate.reset() - dispatchEvent(Event.NavigateToAmountEntry) + dispatchEvent(Event.NavigateToAmountEntry(toMembers)) }.launchIn(viewModelScope) observeGroupSend() @@ -4200,7 +4201,7 @@ internal class ChatViewModel @AssistedInject constructor( is Event.PhotosRestored -> { state -> state.copy(stagedPhotos = (event.photos + state.stagedPhotos).take(MAX_STAGED_PHOTOS)) } - Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) } + is Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) } is Event.PresentDepositOptions -> { state -> state } is Event.OpenScreen -> { state -> state } is Event.MentionTapped -> { state -> state } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendStep.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendStep.kt index 1a979d7a04..1c5ef3c669 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendStep.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendStep.kt @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal.groupsend import android.os.Parcelable +import com.getcode.navigation.NonDraggableRoute import com.getcode.navigation.flow.FlowStep import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @@ -8,8 +9,11 @@ import kotlinx.serialization.Serializable /** * The screens inside the amount entry sheet, on the sheet's own nav host so Select People slides * in over the amount rather than replacing it. The confirm sheet is drawn over [People], not a step. + * + * [NonDraggableRoute] at the interface level, as `ReportStep` does: the sheet closes by its X, as + * the share sheet does, never by a drag. */ -internal sealed interface GroupSendStep : FlowStep, Parcelable { +internal sealed interface GroupSendStep : FlowStep, Parcelable, NonDraggableRoute { @Parcelize @Serializable data object Amount : GroupSendStep diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt index 5c31826cd2..62593653dc 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt @@ -20,11 +20,13 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.activity.compose.BackHandler import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -53,11 +55,13 @@ import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Token import com.getcode.theme.CodeTheme import com.getcode.theme.White10 +import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.SlideToConfirm import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold import com.getcode.view.LoadingSuccessState +import kotlinx.coroutines.launch /** * Select People, node 11293:6918: the group's members less the sender, checked one by one in the @@ -169,7 +173,8 @@ private fun MemberCheckRow( /** * The confirm sheet over Select People, node 11293:6918: who is being paid, the total, what each - * gets, and the swipe. Dismissing it goes back to Select People; it can't be dismissed mid-send. + * gets, and the swipe. Closing it goes back to Select People; it can't be closed mid-send. Closed + * by its X or back, as the share sheet is, never by a drag or a tap outside it. * * The token pill swaps this sheet's content for the currency list and back, rather than opening a * sheet of its own: this one is a separate window, so a navigation sheet would draw beneath it. @@ -194,13 +199,31 @@ internal fun GroupSendConfirmSheet( val total = groupSend.total ?: return val parts = (groupSend.plan as? GroupSendPlan.Pay)?.parts ?: return val unknownName = stringResource(R.string.title_unnamedUser) + val scope = rememberCoroutineScope() ModalBottomSheet( onDismissRequest = { if (!locked) onDismiss() }, sheetState = sheetState, + sheetGesturesEnabled = false, + dragHandle = null, + properties = ModalBottomSheetProperties(shouldDismissOnClickOutside = false), containerColor = CodeTheme.colors.background, ) { BackHandler(enabled = pickingCurrency) { pickingCurrency = false } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x3, end = CodeTheme.dimens.inset), + contentAlignment = Alignment.CenterEnd, + ) { + AppBarDefaults.Close { + when { + locked -> Unit + pickingCurrency -> pickingCurrency = false + else -> scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() } + } + } + } if (pickingCurrency) { CurrencyPickerContent(onPicked = { pickingCurrency = false }) return@ModalBottomSheet diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index 091ac1fd24..015900c114 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -236,7 +236,7 @@ class ChatGroupCashLinkTest { vm.dispatchEvent(ChatViewModel.Event.OnSendCash) advanceUntilIdle() - assertTrue(ChatViewModel.Event.NavigateToAmountEntry in events) + assertTrue(events.any { it is ChatViewModel.Event.NavigateToAmountEntry }) } @Test From 65bdc4145150b04dec9fa7a38af7f37881140c63 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 25 Oct 2026 17:14:33 -0400 Subject: [PATCH 23/23] fix(messenger): drop the confirm sheet's X to match iOS The group send confirm closes by system back, which returns to Select People, as iOS's back gesture does. Drag and tap-outside dismissal stay off for the whole flow, and the amount step keeps its X. The X row gave the avatar its top spacing, so the content now pads its own top. --- .../internal/screens/cash/GroupSendScreens.kt | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt index 62593653dc..c36ebc2906 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt @@ -26,7 +26,6 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.activity.compose.BackHandler import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -55,13 +54,11 @@ import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Token import com.getcode.theme.CodeTheme import com.getcode.theme.White10 -import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.SlideToConfirm import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold import com.getcode.view.LoadingSuccessState -import kotlinx.coroutines.launch /** * Select People, node 11293:6918: the group's members less the sender, checked one by one in the @@ -174,7 +171,7 @@ private fun MemberCheckRow( /** * The confirm sheet over Select People, node 11293:6918: who is being paid, the total, what each * gets, and the swipe. Closing it goes back to Select People; it can't be closed mid-send. Closed - * by its X or back, as the share sheet is, never by a drag or a tap outside it. + * by back, as iOS's confirm is, never by a drag or a tap outside it. * * The token pill swaps this sheet's content for the currency list and back, rather than opening a * sheet of its own: this one is a separate window, so a navigation sheet would draw beneath it. @@ -199,7 +196,6 @@ internal fun GroupSendConfirmSheet( val total = groupSend.total ?: return val parts = (groupSend.plan as? GroupSendPlan.Pay)?.parts ?: return val unknownName = stringResource(R.string.title_unnamedUser) - val scope = rememberCoroutineScope() ModalBottomSheet( onDismissRequest = { if (!locked) onDismiss() }, @@ -210,20 +206,6 @@ internal fun GroupSendConfirmSheet( containerColor = CodeTheme.colors.background, ) { BackHandler(enabled = pickingCurrency) { pickingCurrency = false } - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = CodeTheme.dimens.grid.x3, end = CodeTheme.dimens.inset), - contentAlignment = Alignment.CenterEnd, - ) { - AppBarDefaults.Close { - when { - locked -> Unit - pickingCurrency -> pickingCurrency = false - else -> scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() } - } - } - } if (pickingCurrency) { CurrencyPickerContent(onPicked = { pickingCurrency = false }) return@ModalBottomSheet @@ -233,7 +215,7 @@ internal fun GroupSendConfirmSheet( .fillMaxWidth() .navigationBarsPadding() .padding(horizontal = CodeTheme.dimens.inset) - .padding(bottom = CodeTheme.dimens.grid.x4), + .padding(top = CodeTheme.dimens.grid.x6, bottom = CodeTheme.dimens.grid.x4), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), ) {