From 85ce8c409a93ce682b78a7eda04942b03b6458b8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 16:41:56 -0400 Subject: [PATCH 1/3] feat(group-send): let the sender include themselves in the split Select People lists the sender first as "You", unchecked. Checking it splits the entered total one more way and the sender keeps their part, which is always the last share so it never takes a leftover cent. You + 4 on $5.00 pays $1.00 to each of the 4. GroupSendSplit.split takes includesSender, and GroupSendPlan.Pay carries the kept part. The 10 cap, the "Each" label and its "~", and the too-small check count the sender's share; "Send to N People" counts only the members paid, and Confirm still shows the entered total. test-vectors/group-send-split.json is the orchestrator's copy with the includesSender vectors. --- .../flipcash/app/messenger/ChatFlowScreen.kt | 7 +- .../app/messenger/internal/ChatViewModel.kt | 12 +- .../internal/groupsend/GroupSendState.kt | 34 +++-- .../internal/screens/cash/GroupSendScreens.kt | 23 ++-- .../internal/ChatGroupCashLinkTest.kt | 53 +++++++- .../shared/payments/group/GroupSendSplit.kt | 41 ++++-- .../group/GroupSendSplitVectorsTest.kt | 27 +++- test-vectors/group-send-split.json | 119 +++++++++++++++++- 8 files changed, 272 insertions(+), 44 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 4680aa9b02..7660dff508 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 @@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.compose.ui.text.style.TextAlign @@ -490,10 +491,14 @@ private fun FlowGroupSelectPeople(viewModel: ChatViewModel, onSendComplete: () - val candidates = state.groupSendCandidates GroupSelectPeopleContent( candidates = candidates, - selected = groupSend.selected, + selected = groupSend.checked, onBack = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendBack) }, onToggle = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(it.userId)) }, onSend = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed) }, + // "Send to N People" counts who is paid: You is never one of them. + buttonText = pluralStringResource(R.plurals.action_sendToPeople, groupSend.selected.size, groupSend.selected.size), + buttonEnabled = groupSend.selected.isNotEmpty(), + viewerId = groupSend.viewerId, ) if (groupSend.stage == GroupSendState.Stage.Confirm) { GroupSendConfirmSheet( 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 67cd3d42ca..36b28734f7 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 @@ -682,7 +682,7 @@ internal class ChatViewModel @AssistedInject constructor( 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. */ + /** Who Select People lists: the roster, or what this device knows of it, the viewer first. */ val groupSendCandidates: List get() = groupSend?.candidates(members.ifEmpty { knownMembers }).orEmpty() @@ -4056,15 +4056,15 @@ internal class ChatViewModel @AssistedInject constructor( eventFlow.filterIsInstance() .onEach { (member) -> val groupSend = stateFlow.value.groupSend ?: return@onEach - val selected = groupSend.toggled(member) - if (selected == null) { + val toggled = groupSend.toggled(member) + if (toggled == null) { BottomBarManager.showInfo( title = resources.getString(R.string.title_groupSendMaxPeople), message = resources.getString(R.string.description_groupSendMaxPeople), ) return@onEach } - update { it.copy(selected = selected) } + update { toggled } }.launchIn(viewModelScope) eventFlow.filterIsInstance() @@ -4088,8 +4088,8 @@ internal class ChatViewModel @AssistedInject constructor( title = resources.getString(R.string.error_title_groupSendTooSmall), message = resources.getQuantityString( R.plurals.error_description_groupSendTooSmall, - groupSend.selected.size, - groupSend.selected.size, + groupSend.shares, + groupSend.shares, ), ) GroupSendPlan.NoRecipients, GroupSendPlan.TooManyRecipients -> Unit 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 index 1785a6ce33..ec8970950a 100644 --- 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 @@ -11,31 +11,47 @@ import com.getcode.opencode.model.financial.Fiat * 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. + * @param selected who to pay, in the order they were checked: the order decides the split. Never + * the sender, who is [includesSender] instead. + * @param includesSender whether the sender checked "You", splitting [total] with themselves and + * keeping their part. */ internal data class GroupSendState( val viewerId: ID?, val stage: Stage = Stage.Amount, val total: Fiat? = null, val selected: List = emptyList(), + val includesSender: Boolean = false, ) { 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) } + get() = total?.let { GroupSendSplit.split(GroupSendSplit.minorUnits(it), selected.size, includesSender) } ?: GroupSendPlan.NoRecipients - /** Who Select People lists: the roster without the sender, who never pays themselves. */ + /** How many ways [total] splits: [selected], and the sender when [includesSender]. */ + val shares: Int get() = selected.size + if (includesSender) 1 else 0 + + /** The rows Select People shows checked: [selected], and the sender's when [includesSender]. */ + val checked: List get() = if (includesSender) selected + listOfNotNull(viewerId) else selected + + /** Who Select People lists: the sender first, as "You", then the rest of the roster. */ fun candidates(roster: List): List = - roster.filter { it.userId != viewerId }.distinctBy { it.userId } + roster.distinctBy { it.userId }.sortedByDescending { it.userId == viewerId } /** - * [selected] with [member] checked or unchecked, or null when checking them would make an 11th. + * This state with [member] checked or unchecked, the sender included, or null when checking + * them would make an 11th share. */ - fun toggled(member: ID): List? = when { - member in selected -> selected.filterNot { it == member } - selected.size >= GroupSendSplit.MAX_RECIPIENTS -> null - else -> selected.plusElement(member) + fun toggled(member: ID): GroupSendState? = when { + member == viewerId -> when { + includesSender -> copy(includesSender = false) + shares >= GroupSendSplit.MAX_RECIPIENTS -> null + else -> copy(includesSender = true) + } + member in selected -> copy(selected = selected.filterNot { it == member }) + shares >= GroupSendSplit.MAX_RECIPIENTS -> null + else -> copy(selected = selected.plusElement(member)) } } 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 9b42cf5fc6..4491cd8098 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 @@ -65,8 +65,9 @@ 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. + * Select People, node 11293:6918: the group's members, checked one by one in the order that decides + * the split. There is no Select All; the sender's "You" row takes its place, as the first of + * [candidates] when [viewerId] is set. */ @Composable internal fun GroupSelectPeopleContent( @@ -196,8 +197,9 @@ internal fun GroupSendConfirmSheet( swipePay: SwipePayUiState, actions: SwipePayActions, ) { + // The total entered, not what is sent: with You checked, the sender's part stays behind. val total = groupSend.total ?: return - val parts = (groupSend.plan as? GroupSendPlan.Pay)?.parts ?: return + val plan = groupSend.plan as? GroupSendPlan.Pay ?: return val unknownName = stringResource(R.string.title_unnamedUser) SwipePaySheet( @@ -226,9 +228,9 @@ internal fun GroupSendConfirmSheet( style = CodeTheme.typography.displayMedium, color = CodeTheme.colors.textMain, ) - if (parts.size >= 2) { + if (plan.shares.size >= 2) { Text( - text = eachLabel(parts, total.currencyCode), + text = eachLabel(plan, total.currencyCode), style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, ) @@ -238,11 +240,14 @@ internal fun GroupSendConfirmSheet( ) } -/** "$1.00 Each" when the parts are equal, else "~$0.33 Each" for the smallest. */ +/** + * "$1.00 Each" when the shares are equal, else "~$0.33 Each" for the smallest. The sender's kept + * share counts: You + 1 on $5.00 is "$2.50 Each". + */ @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) +private fun eachLabel(plan: GroupSendPlan.Pay, currency: CurrencyCode): String { + val smallest = GroupSendSplit.fiat(plan.smallest, currency).formatted() + return if (plan.isEven) stringResource(R.string.label_groupSendEach, smallest) else stringResource(R.string.label_groupSendEachApprox, smallest) } 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 0fa10d4bf2..e10572c8c7 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 @@ -382,7 +382,7 @@ class ChatGroupCashLinkTest { } @Test - fun `a private group's amount moves on to Select People without the viewer`() = runTest(mainCoroutineRule.dispatcher) { + fun `a private group's amount moves on to Select People with the viewer first`() = runTest(mainCoroutineRule.dispatcher) { val vm = createViewModel() selectingPeople(vm, total = 2.0) advanceUntilIdle() @@ -390,7 +390,8 @@ class ChatGroupCashLinkTest { 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 }) + assertEquals(listOf(viewerId) + roster.map { it.userId }, state.groupSendCandidates.map { it.userId }) + assertEquals(false, state.groupSend?.includesSender) coVerify(exactly = 0) { chatCashLinks.sendToChat(any(), any(), any(), any()) } } @@ -444,4 +445,52 @@ class ChatGroupCashLinkTest { coVerify(exactly = 0) { chatCashLinks.sendToChat(any(), any(), any(), any()) } assertTrue(events.any { it is ChatViewModel.Event.SendComplete }) } + + @Test + fun `checking You splits with the sender, who keeps the last share`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + selectingPeople(vm, total = 5.0) + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(viewerId)) + vm.check(4) + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed) + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendRequested) + advanceUntilIdle() + + coVerify(exactly = 1) { + groupSendPayments.send( + chatId = chatId, + recipients = roster.take(4).map { it.userId }, + plan = GroupSendPlan.Pay(listOf(100, 100, 100, 100), kept = 100), + currency = CurrencyCode.USD, + token = token, + source = any(), + ) + } + } + + @Test + fun `You counts toward the ten`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + selectingPeople(vm, total = 20.0) + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(viewerId)) + vm.check(10) + advanceUntilIdle() + + val groupSend = vm.stateFlow.value.groupSend + assertEquals(roster.take(9).map { it.userId }, groupSend?.selected) + assertEquals(true, groupSend?.includesSender) + assertEquals(1, BottomBarManager.messages.value.size) + } + + @Test + fun `You alone sends nothing`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + selectingPeople(vm, total = 5.0) + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(viewerId)) + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed) + advanceUntilIdle() + + assertEquals(GroupSendState.Stage.People, vm.stateFlow.value.groupSend?.stage) + assertEquals(emptyList(), vm.stateFlow.value.groupSend?.selected) + } } 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 index bb556126a6..29190c2d10 100644 --- 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 @@ -11,20 +11,29 @@ 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. + * + * @param kept the sender's own part when they split the total with themselves: the last share, + * never sent. Null when they didn't. */ - data class Pay(val parts: List) : GroupSendPlan { - val isEven: Boolean get() = parts.all { it == parts.first() } + data class Pay(val parts: List, val kept: Long? = null) : GroupSendPlan { + /** Every part of the total, the sender's included: what "Each" and its "~" describe. */ + val shares: List get() = parts + listOfNotNull(kept) + + /** What the intent pays: the total less [kept]. */ + val sent: Long get() = parts.sum() + + val isEven: Boolean get() = shares.all { it == shares.first() } /** The part shown as "Each": every part when [isEven], the smallest otherwise. */ - val smallest: Long get() = parts.min() + val smallest: Long get() = shares.min() } data object NoRecipients : GroupSendPlan - /** More than [GroupSendSplit.MAX_RECIPIENTS] were selected. */ + /** More than [GroupSendSplit.MAX_RECIPIENTS] shares, the sender's included, were selected. */ data object TooManyRecipients : GroupSendPlan - /** The total is under [GroupSendSplit.MINIMUM_PART_UNITS] per recipient. */ + /** The total is under [GroupSendSplit.MINIMUM_PART_UNITS] per share, the sender's included. */ data object PartBelowMinimum : GroupSendPlan } @@ -36,18 +45,30 @@ sealed interface GroupSendPlan { * 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. + * + * A sender who includes themselves takes the last share, so they never take a leftover unit, and + * keep it: the parts paid are the shares before it. The cap and the minimum count their share. */ object GroupSendSplit { const val MAX_RECIPIENTS = 10 const val MINIMUM_PART_UNITS = 1L - fun split(totalUnits: Long, recipients: Int): GroupSendPlan { + /** + * @param recipients the members paid, not counting the sender. + * @param includesSender whether the sender takes a share of [totalUnits] and keeps it. + */ + fun split(totalUnits: Long, recipients: Int, includesSender: Boolean = false): GroupSendPlan { if (recipients <= 0) return GroupSendPlan.NoRecipients - if (recipients > MAX_RECIPIENTS) return GroupSendPlan.TooManyRecipients - val base = totalUnits / recipients + val shares = if (includesSender) recipients + 1 else recipients + if (shares > MAX_RECIPIENTS) return GroupSendPlan.TooManyRecipients + val base = totalUnits / shares 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 }) + val leftover = totalUnits % shares + return GroupSendPlan.Pay( + parts = List(recipients) { i -> if (i < leftover) base + 1 else base }, + // The last share: the leftover never reaches it, since leftover < shares. + kept = base.takeIf { includesSender }, + ) } /** [fiat] in its currency's minor unit, rounded half-up to that unit. */ 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 index 2f0c5404a4..b5bfb8e434 100644 --- 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 @@ -28,11 +28,12 @@ class GroupSendSplitVectorsTest { @Test fun `every vector`() { val vectors = fixture.getJSONArray("vectors") - assertEquals(14, vectors.length()) + assertEquals(23, 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")) + val includesSender = v.optBoolean("includesSender", false) + val plan = GroupSendSplit.split(cents(v.getString("total")), v.getInt("recipients"), includesSender) if (v.has("expectedError")) { val error = when (val rule = v.getString("expectedError")) { @@ -51,14 +52,28 @@ class GroupSendSplitVectorsTest { val parts = v.getJSONArray("parts") assertEquals((0 until parts.length()).map { cents(parts.getString(it)) }, pay.parts, name) + assertEquals(v.optString("kept").ifEmpty { null }?.let(::cents), pay.kept, name) + if (includesSender) assertEquals(cents(v.getString("sent")), pay.sent, 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) - } + assertEquals(v.optString("eachLabel").ifEmpty { null }, pay.takeIf { it.shares.size > 1 }?.let(::eachLabel), name) } } + // Not in the fixture: the paid parts are equal but the sender's is a cent less, so "~" must + // come from the sender's share. 5 cents between You and 2 pays 2 and 2 and keeps 1. + @Test + fun `the sender's smaller share makes the label approximate`() { + val pay = assertIs(GroupSendSplit.split(5, 2, includesSender = true)) + assertEquals(listOf(2L, 2L), pay.parts) + assertEquals(1L, pay.kept) + assertEquals("~$0.01 Each", eachLabel(pay)) + } + + private fun eachLabel(pay: GroupSendPlan.Pay): String { + val each = "$" + BigDecimal.valueOf(pay.smallest, 2).toPlainString() + " Each" + return if (pay.isEven) each else "~$each" + } + @Test fun `minor units round trip through Fiat`() { assertEquals(12345L, GroupSendSplit.minorUnits(Fiat("123.45", CurrencyCode.USD))) diff --git a/test-vectors/group-send-split.json b/test-vectors/group-send-split.json index 00685790ca..838f6db54e 100644 --- a/test-vectors/group-send-split.json +++ b/test-vectors/group-send-split.json @@ -1,7 +1,7 @@ { "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.", + "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. With includesSender, the sender's part is the last share, kept rather than paid: parts lists the paid members only, kept is the sender's part, sent is what the intent pays, and maxRecipients counts the sender's share.", "maxRecipients": 10, "minimumPart": "0.01", "vectors": [ @@ -176,6 +176,123 @@ "total": "1.00", "recipients": 0, "expectedError": "no-recipients" + }, + { + "name": "you and four", + "total": "5.00", + "recipients": 4, + "includesSender": true, + "intent": "SendPublicMultiPayment", + "parts": [ + "1.00", + "1.00", + "1.00", + "1.00" + ], + "kept": "1.00", + "sent": "4.00", + "eachLabel": "$1.00 Each" + }, + { + "name": "you and one", + "total": "5.00", + "recipients": 1, + "includesSender": true, + "intent": "SendPublicPayment", + "parts": [ + "2.50" + ], + "kept": "2.50", + "sent": "2.50", + "eachLabel": "$2.50 Each" + }, + { + "name": "you and two uneven", + "total": "1.00", + "recipients": 2, + "includesSender": true, + "intent": "SendPublicMultiPayment", + "parts": [ + "0.34", + "0.33" + ], + "kept": "0.33", + "sent": "0.67", + "eachLabel": "~$0.33 Each" + }, + { + "name": "you and three one cent left", + "total": "1.01", + "recipients": 3, + "includesSender": true, + "intent": "SendPublicMultiPayment", + "parts": [ + "0.26", + "0.25", + "0.25" + ], + "kept": "0.25", + "sent": "0.76", + "eachLabel": "~$0.25 Each" + }, + { + "name": "you and five exact minimum", + "total": "0.06", + "recipients": 5, + "includesSender": true, + "intent": "SendPublicMultiPayment", + "parts": [ + "0.01", + "0.01", + "0.01", + "0.01", + "0.01" + ], + "kept": "0.01", + "sent": "0.05", + "eachLabel": "$0.01 Each" + }, + { + "name": "you and nine", + "total": "10.00", + "recipients": 9, + "includesSender": true, + "intent": "SendPublicMultiPayment", + "parts": [ + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00" + ], + "kept": "1.00", + "sent": "9.00", + "eachLabel": "$1.00 Each" + }, + { + "name": "you and ten", + "total": "11.00", + "recipients": 10, + "includesSender": true, + "expectedError": "too-many-recipients" + }, + { + "name": "too small with you", + "total": "0.02", + "recipients": 2, + "includesSender": true, + "expectedError": "part-below-minimum" + }, + { + "name": "you alone", + "total": "1.00", + "recipients": 0, + "includesSender": true, + "expectedError": "no-recipients" } ] } From 166b8e0ff6d628057d1f1065bcd67c6fae1b6eda Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 17:22:40 -0400 Subject: [PATCH 2/3] test(group-send): sync the split fixture with orchestrator #83 --- test-vectors/group-send-split.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test-vectors/group-send-split.json b/test-vectors/group-send-split.json index 838f6db54e..748516c718 100644 --- a/test-vectors/group-send-split.json +++ b/test-vectors/group-send-split.json @@ -235,6 +235,20 @@ "sent": "0.76", "eachLabel": "~$0.25 Each" }, + { + "name": "you and two equal parts, less kept", + "total": "0.05", + "recipients": 2, + "includesSender": true, + "intent": "SendPublicMultiPayment", + "parts": [ + "0.02", + "0.02" + ], + "kept": "0.01", + "sent": "0.04", + "eachLabel": "~$0.01 Each" + }, { "name": "you and five exact minimum", "total": "0.06", From 439b95ced79b94b280f72988bf4341305b2fa09c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 18:53:04 -0400 Subject: [PATCH 3/3] feat(group-send): label the Select People button Next --- apps/flipcash/core/src/androidMain/res/values/strings.xml | 4 ---- .../main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt | 3 --- .../app/messenger/internal/screens/cash/GroupSendScreens.kt | 2 +- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index 8d17b0e5a3..5215446607 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1988,10 +1988,6 @@ 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 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 7660dff508..c7b8ff0be1 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 @@ -20,7 +20,6 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.compose.ui.text.style.TextAlign @@ -495,8 +494,6 @@ private fun FlowGroupSelectPeople(viewModel: ChatViewModel, onSendComplete: () - onBack = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendBack) }, onToggle = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(it.userId)) }, onSend = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed) }, - // "Send to N People" counts who is paid: You is never one of them. - buttonText = pluralStringResource(R.plurals.action_sendToPeople, groupSend.selected.size, groupSend.selected.size), buttonEnabled = groupSend.selected.isNotEmpty(), viewerId = groupSend.viewerId, ) 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 4491cd8098..f588edf462 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 @@ -76,7 +76,7 @@ internal fun GroupSelectPeopleContent( onBack: () -> Unit, onToggle: (ChatMember) -> Unit, onSend: () -> Unit, - buttonText: String = pluralStringResource(R.plurals.action_sendToPeople, selected.size, selected.size), + buttonText: String = stringResource(R.string.action_next), buttonEnabled: Boolean = selected.isNotEmpty(), /** Shown as "You" rather than by name, for a list the viewer is on. */ viewerId: ID? = null,