diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml
index 0e00a6b5f6..aa61563a7d 100644
--- a/apps/flipcash/core/src/androidMain/res/values/strings.xml
+++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml
@@ -1987,6 +1987,8 @@
Select People
+ Split Between
+ Total Amount To Send
Members
- Send to %1$d Person
@@ -2009,8 +2011,13 @@
- to %1$d person
- to %1$d people
- %1$s each
- ~%1$s each
+ %1$s Each
+ ~%1$s Each
+
+ %1$s each
+ ~%1$s each
+ %1$s Each, including You
+ ~%1$s Each, including You
%1$s sent
Sent
From
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..059ba04e2b 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
@@ -185,10 +185,10 @@ private fun chatEntryProvider(
FlowConversationScreen(openKeyboard)
}
annotatedEntry {
- FlowAmountEntryScreen(resultStateRegistry)
+ FlowAmountEntryScreen(resultStateRegistry, toMembers = false)
}
annotatedEntry {
- FlowAmountEntryScreen(resultStateRegistry)
+ FlowAmountEntryScreen(resultStateRegistry, toMembers = true)
}
annotatedEntry {
FlowGroupInviteSheet()
@@ -427,7 +427,7 @@ private fun FlowConversationScreen(
}
@Composable
-private fun FlowAmountEntryScreen(resultStateRegistry: NavResultStateRegistry) {
+private fun FlowAmountEntryScreen(resultStateRegistry: NavResultStateRegistry, toMembers: Boolean) {
val viewModel = flowSharedViewModel()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
// The chat flow's navigator, read before the sheet's own FlowHost below shadows it.
@@ -466,6 +466,8 @@ private fun FlowAmountEntryScreen(resultStateRegistry: NavResultStateRegistry) {
onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnConfirmRequested) },
onSendComplete = { resultBack.returnValue(ChatSendResult) }, // intra-flow result -> Conversation
onExit = dismissSheet,
+ // A group send picks its currency on the confirm sheet, so no pill here.
+ title = if (toMembers) stringResource(R.string.title_groupSendTotal) else null,
)
}
}
@@ -494,11 +496,15 @@ 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) },
+ you = state.groupSendViewer,
+ buttonEnabled = groupSend.paid.isNotEmpty(),
+ title = stringResource(R.string.title_splitBetween),
+ buttonText = stringResource(R.string.action_next),
)
if (groupSend.stage == GroupSendState.Stage.Confirm) {
GroupSendConfirmSheet(
groupSend = groupSend,
- recipients = groupSend.selected.mapNotNull { id -> candidates.find { it.userId == id } },
+ recipients = groupSend.paid.mapNotNull { id -> candidates.find { it.userId == id } },
swipePay = SwipePayUiState(
token = state.token,
progress = state.sendProgress,
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..3931fe73e9 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
@@ -175,6 +175,7 @@ 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.group.GroupSendSplit
import com.flipcash.shared.payments.TipPaymentDelegate
import com.getcode.libs.emojis.reactions.EmojiCatalogLoader
import com.getcode.libs.emojis.reactions.EmojiDrawability
@@ -685,6 +686,8 @@ internal class ChatViewModel @AssistedInject constructor(
/** 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()
+ val groupSendViewer: ChatMember?
+ get() = groupSend?.viewer(members.ifEmpty { knownMembers })
// 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.
@@ -1936,13 +1939,17 @@ internal class ChatViewModel @AssistedInject constructor(
)
}
+ // A group send has no ceiling: its currency is picked on the confirm sheet, which is the only
+ // place the balance is checked, so the amount step shows no "Enter up to" hint and no limit.
private val maxAmountFlow by lazy {
combine(
transactionController.limits,
tokenCoordinator.observeSelectedTokenMint()
.flatMapLatest { mint -> tokenCoordinator.balanceForToken(mint) },
exchange.observePreferredRate(),
- ) { limits, balance, rate ->
+ stateFlow.map { it.groupSend != null }.distinctUntilChanged(),
+ ) { limits, balance, rate, isGroupSend ->
+ if (isGroupSend) return@combine null
val balanceInLocal = balance.convertingTo(rate)
val sendLimit = limits?.sendLimitFor(rate.currency) ?: SendLimit.Zero
Fiat(min(sendLimit.nextTransaction, balanceInLocal.toDouble()), rate.currency)
@@ -4114,14 +4121,15 @@ 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
+ // The sender pays the parts of everyone but themselves, which can be less than the total
+ // entered. The confirm sheet can change the token, so this is the only balance check.
+ if (checkBalanceLimit(GroupSendSplit.fiat(plan.parts.sum(), total.currencyCode))) return@launch
dispatchEvent(Event.SendStateUpdated(loading = true))
val result = groupSendPayments.send(
chatId = chatId,
- recipients = groupSend.selected,
+ recipients = groupSend.paid,
plan = plan,
currency = total.currencyCode,
token = token,
@@ -4132,7 +4140,7 @@ internal class ChatViewModel @AssistedInject constructor(
holdPaymentSuccess(
show = { dispatchEvent(Event.SendStateUpdated(success = true)) },
release = {
- analytics.track(TransferEvents.sentCash(AnalyticsState.SUCCESS, total.analytics, null, SendContext.GROUP_SEND, groupSend.selected.size))
+ analytics.track(TransferEvents.sentCash(AnalyticsState.SUCCESS, total.analytics, null, SendContext.GROUP_SEND, groupSend.paid.size))
// 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))
@@ -4140,7 +4148,7 @@ internal class ChatViewModel @AssistedInject constructor(
)
}.onFailure { cause ->
dispatchEvent(Event.SendStateUpdated())
- analytics.track(TransferEvents.sentCash(AnalyticsState.FAILURE, total.analytics, cause.analytics, SendContext.GROUP_SEND, groupSend.selected.size))
+ analytics.track(TransferEvents.sentCash(AnalyticsState.FAILURE, total.analytics, cause.analytics, SendContext.GROUP_SEND, groupSend.paid.size))
val (title, message) = when (cause) {
is ComputeVerifiedFiatError.AmountBelowMinimum ->
R.string.error_title_amountTooSmall to R.string.error_description_amountTooSmall
@@ -4199,13 +4207,16 @@ internal class ChatViewModel @AssistedInject constructor(
val enteredAmount = fixedAmount?.toDouble() ?: amountDelegate.state.value.enteredAmount
val amount = fixedAmount ?: Fiat(enteredAmount, exchange.preferredRate.currency)
- if (checkBalanceLimit(amount) || checkSendLimit(enteredAmount)) return
+ // A group send's balance check waits for the confirm sheet (sendToMembers), where the
+ // currency is picked; the amount here only has to be positive.
+ val groupSend = stateFlow.value.groupSend
+ val overBalance = groupSend == null && checkBalanceLimit(amount)
+ if (overBalance || checkSendLimit(enteredAmount)) return
if (enteredAmount <= 0) return
val token = stateFlow.value.token ?: return
- val groupSend = stateFlow.value.groupSend
if (groupSend != null && fixedAmount == null) {
dispatchEvent(
Event.GroupSendUpdated(
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..f57bb7edc0 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
@@ -21,17 +21,38 @@ internal data class GroupSendState(
) {
enum class Stage { Amount, People, Confirm }
- /** The parts to pay, once [total] is known and someone is checked. */
+ /**
+ * Who is paid: [selected] without the sender. The sender can be checked to take a part of the
+ * split, but the contract requires every receiver to be another member.
+ */
+ val paid: List
+ get() = selected.filterNot { it == viewerId }
+
+ /** Whether the sender is checked, which counts them in the split without paying them. */
+ val includesViewer: Boolean
+ get() = viewerId != null && viewerId in selected
+
+ /**
+ * The parts to pay, once [total] is known and someone other than the sender is checked: the
+ * total is split between everyone checked, the sender included as the last share, then the
+ * sender's part is dropped.
+ */
val plan: GroupSendPlan
- get() = total?.let { GroupSendSplit.split(GroupSendSplit.minorUnits(it), selected.size) }
- ?: GroupSendPlan.NoRecipients
+ get() {
+ val total = total ?: return GroupSendPlan.NoRecipients
+ return GroupSendSplit.splitWithViewer(GroupSendSplit.minorUnits(total), paid.size, includesViewer)
+ }
- /** Who Select People lists: the roster without the sender, who never pays themselves. */
+ /** Who Select People lists: the roster without the sender, who has a "You" row of their own. */
fun candidates(roster: List): List =
roster.filter { it.userId != viewerId }.distinctBy { it.userId }
+ /** The sender's own row, from the roster, when they are in it. */
+ fun viewer(roster: List): ChatMember? = roster.find { it.userId == viewerId }
+
/**
- * [selected] with [member] checked or unchecked, or null when checking them would make an 11th.
+ * [selected] with [member] checked or unchecked, or null when checking them would make an 11th
+ * person. The sender counts toward the cap, as a share of the split.
*/
fun toggled(member: ID): List? = when {
member in selected -> selected.filterNot { it == member }
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 b560d12636..cd5b710bd9 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
@@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.screens.cash
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
@@ -15,6 +16,7 @@ import com.flipcash.shared.amountentry.AmountEntryScreen
import com.getcode.manager.BottomBarManager
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.opencode.model.financial.Token
+import com.getcode.theme.CodeTheme
import com.getcode.ui.components.AppBarDefaults
import com.getcode.ui.components.AppBarWithTitle
import com.getcode.util.resources.LocalResources
@@ -32,6 +34,8 @@ internal fun ChatAmountEntryContent(
onExit: () -> Unit,
onConfirm: () -> Unit,
onSendComplete: (() -> Unit)? = null,
+ /** A fixed title in place of the currency pill, for a send whose currency is chosen later. */
+ title: String? = null,
) {
val navigator = LocalCodeNavigator.current
val resources = LocalResources.current
@@ -67,15 +71,19 @@ internal fun ChatAmountEntryContent(
// the leading slot's width to position a Start-aligned title.
titleAlignment = Alignment.CenterHorizontally,
title = {
- TokenSelectionPill(
- modifier = Modifier
- .fillMaxWidth()
- .wrapContentHeight(),
- token = token
- ) {
- // A half sheet over this one, not the app's full-screen picker, so the
- // amount stays in view underneath.
- navigator.push(ChatStep.SendCurrency)
+ if (title != null) {
+ Text(text = title, style = CodeTheme.typography.screenTitle)
+ } else {
+ TokenSelectionPill(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ token = token
+ ) {
+ // 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 9b42cf5fc6..a24939214a 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
@@ -75,16 +75,19 @@ internal fun GroupSelectPeopleContent(
onBack: () -> Unit,
onToggle: (ChatMember) -> Unit,
onSend: () -> Unit,
+ title: String = stringResource(R.string.title_selectPeople),
buttonText: String = pluralStringResource(R.plurals.action_sendToPeople, selected.size, selected.size),
buttonEnabled: Boolean = selected.isNotEmpty(),
/** Shown as "You" rather than by name, for a list the viewer is on. */
viewerId: ID? = null,
+ /** The sender's own row, first, for a split they can take a part of. Null leaves it out. */
+ you: ChatMember? = null,
) {
val selectedIds = selected.toSet()
CodeScaffold(
topBar = {
AppBarWithTitle(
- title = stringResource(R.string.title_selectPeople),
+ title = title,
titleAlignment = Alignment.CenterHorizontally,
onBackIconClicked = onBack,
)
@@ -103,7 +106,7 @@ internal fun GroupSelectPeopleContent(
},
) { innerPadding ->
val unknownName = stringResource(R.string.title_unnamedUser)
- val you = stringResource(R.string.label_selectPeople_you)
+ val youLabel = stringResource(R.string.label_selectPeople_you)
LazyColumn(
modifier = Modifier
.fillMaxSize()
@@ -120,11 +123,21 @@ internal fun GroupSelectPeopleContent(
color = CodeTheme.colors.textSecondary,
)
}
+ if (you != null) {
+ item(key = "you") {
+ MemberCheckRow(
+ member = you,
+ name = youLabel,
+ selected = you.userId in selectedIds,
+ onClick = { onToggle(you) },
+ )
+ }
+ }
items(candidates, key = { it.userId }) { member ->
MemberCheckRow(
member = member,
name = if (member.userId == viewerId) {
- you
+ youLabel
} else {
chatterName(member.userProfile.displayName, member.userProfile.username, unknownName)
},
@@ -197,7 +210,7 @@ internal fun GroupSendConfirmSheet(
actions: SwipePayActions,
) {
val total = groupSend.total ?: return
- val parts = (groupSend.plan as? GroupSendPlan.Pay)?.parts ?: return
+ val pay = groupSend.plan as? GroupSendPlan.Pay ?: return
val unknownName = stringResource(R.string.title_unnamedUser)
SwipePaySheet(
@@ -226,9 +239,9 @@ internal fun GroupSendConfirmSheet(
style = CodeTheme.typography.displayMedium,
color = CodeTheme.colors.textMain,
)
- if (parts.size >= 2) {
+ if (pay.showsEach) {
Text(
- text = eachLabel(parts, total.currencyCode),
+ text = eachLabel(pay, total.currencyCode),
style = CodeTheme.typography.textSmall,
color = CodeTheme.colors.textSecondary,
)
@@ -238,12 +251,20 @@ internal fun GroupSendConfirmSheet(
)
}
-/** "$1.00 Each" when the parts are equal, else "~$0.33 Each" for the smallest. */
+/**
+ * "$1.00 Each" when every part, the sender's included, is equal, else "~$0.33 Each" for the
+ * smallest paid part; ", including You" when the sender is in the split.
+ */
@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)
+private fun eachLabel(pay: GroupSendPlan.Pay, currency: CurrencyCode): String {
+ val smallest = GroupSendSplit.fiat(pay.smallest, currency).formatted()
+ val res = when {
+ pay.includesViewer && pay.isEven -> R.string.label_groupSendEachIncludingYou
+ pay.includesViewer -> R.string.label_groupSendEachApproxIncludingYou
+ pay.isEven -> R.string.label_groupSendEach
+ else -> R.string.label_groupSendEachApprox
+ }
+ return stringResource(res, smallest)
}
/** The first few recipients' faces, overlapping, as in the design's pair of avatars. */
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..0d34356f90 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
@@ -44,6 +44,7 @@ import com.getcode.util.resources.ResourceHelper
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
+import io.mockk.verify
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -444,4 +445,152 @@ class ChatGroupCashLinkTest {
coVerify(exactly = 0) { chatCashLinks.sendToChat(any(), any(), any(), any()) }
assertTrue(events.any { it is ChatViewModel.Event.SendComplete })
}
+
+ @Test
+ fun `a group send total above the balance passes the amount step and stops at confirm`() = runTest(mainCoroutineRule.dispatcher) {
+ every { tokenCoordinator.balanceForToken(any()) } returns Fiat(1.0)
+ val vm = createViewModel()
+ selectingPeople(vm, total = 5.0)
+
+ // The amount step does not check the balance: it moves on to Select People.
+ assertEquals(GroupSendState.Stage.People, vm.stateFlow.value.groupSend?.stage)
+ verify(exactly = 0) { startChattingPayer.presentInsufficientBalance(any()) }
+
+ vm.check(2)
+ vm.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed)
+ assertEquals(GroupSendState.Stage.Confirm, vm.stateFlow.value.groupSend?.stage)
+
+ vm.dispatchEvent(ChatViewModel.Event.OnGroupSendRequested)
+ advanceUntilIdle()
+
+ verify(exactly = 1) { startChattingPayer.presentInsufficientBalance(any()) }
+ coVerify(exactly = 0) { groupSendPayments.send(any(), any(), any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `a group send has no hint or ceiling on the amount step`() = runTest(mainCoroutineRule.dispatcher) {
+ every { tokenCoordinator.balanceForToken(any()) } returns Fiat(1.0)
+ val vm = createViewModel()
+ backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.amountDelegate.config.collect { } }
+ vm.openGroup(isPrivate = true)
+ enterAmount(vm, 5.0)
+ advanceUntilIdle()
+ val hint = vm.amountDelegate.config.value.hint
+ assertEquals(com.flipcash.shared.amountentry.AmountEntryHint.None, hint)
+ assertTrue(vm.amountDelegate.config.value.canConfirm)
+ }
+
+ private fun ChatViewModel.checkYou() =
+ dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(viewerId))
+
+ private fun ChatViewModel.checkMember(index: Int) =
+ dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(roster[index].userId))
+
+ @Test
+ fun `thirty dollars between You and two others pays ten each and charges twenty`() = runTest(mainCoroutineRule.dispatcher) {
+ // Within the balance of the paid parts ($20) but not of the entered total ($30).
+ every { tokenCoordinator.balanceForToken(any()) } returns Fiat(25.0)
+ val vm = createViewModel()
+ selectingPeople(vm, total = 30.0)
+ vm.checkYou(); vm.checkMember(0); vm.checkMember(1)
+ vm.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed)
+
+ val groupSend = vm.stateFlow.value.groupSend
+ assertEquals(GroupSendState.Stage.Confirm, groupSend?.stage)
+ assertEquals(Fiat(30.0), groupSend?.total)
+ assertEquals(GroupSendPlan.Pay(listOf(1000L, 1000L), viewerPart = 1000L), groupSend?.plan)
+
+ vm.dispatchEvent(ChatViewModel.Event.OnGroupSendRequested)
+ advanceUntilIdle()
+
+ verify(exactly = 0) { startChattingPayer.presentInsufficientBalance(any()) }
+ coVerify(exactly = 1) {
+ groupSendPayments.send(
+ chatId = chatId,
+ recipients = listOf(roster[0].userId, roster[1].userId),
+ plan = GroupSendPlan.Pay(listOf(1000L, 1000L), viewerPart = 1000L),
+ currency = CurrencyCode.USD,
+ token = token,
+ source = any(),
+ )
+ }
+ }
+
+ @Test
+ fun `You is the last share whatever order it was checked in, so the others take the extra cent`() = runTest(mainCoroutineRule.dispatcher) {
+ val vm = createViewModel()
+ selectingPeople(vm, total = 1.0)
+ vm.checkYou(); vm.checkMember(0); vm.checkMember(1)
+
+ assertEquals(GroupSendPlan.Pay(listOf(34L, 33L), viewerPart = 33L), vm.stateFlow.value.groupSend?.plan)
+ assertEquals(listOf(roster[0].userId, roster[1].userId), vm.stateFlow.value.groupSend?.paid)
+ }
+
+ @Test
+ fun `only You checked pays no one and Next goes nowhere`() = runTest(mainCoroutineRule.dispatcher) {
+ val vm = createViewModel()
+ selectingPeople(vm, total = 1.0)
+ vm.checkYou()
+ vm.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed)
+ advanceUntilIdle()
+
+ val groupSend = vm.stateFlow.value.groupSend
+ assertTrue(groupSend?.paid?.isEmpty() == true)
+ assertEquals(GroupSendPlan.NoRecipients, groupSend?.plan)
+ assertEquals(GroupSendState.Stage.People, groupSend?.stage)
+ }
+
+ @Test
+ fun `You counts toward the cap so nine others plus You is the most`() = runTest(mainCoroutineRule.dispatcher) {
+ val vm = createViewModel()
+ selectingPeople(vm, total = 20.0)
+ vm.checkYou()
+ vm.check(10)
+ advanceUntilIdle()
+
+ val groupSend = vm.stateFlow.value.groupSend
+ assertEquals(9, groupSend?.paid?.size)
+ assertEquals(10, groupSend?.selected?.size)
+ assertTrue(groupSend?.plan is GroupSendPlan.Pay)
+ assertEquals(1, BottomBarManager.messages.value.size)
+ }
+
+ @Test
+ fun `the too-small check counts You in the split`() = runTest(mainCoroutineRule.dispatcher) {
+ val vm = createViewModel()
+ selectingPeople(vm, total = 0.05)
+ vm.checkYou(); vm.check(5)
+ vm.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed)
+ advanceUntilIdle()
+
+ assertEquals(GroupSendPlan.PartBelowMinimum, vm.stateFlow.value.groupSend?.plan)
+ assertEquals(GroupSendState.Stage.People, vm.stateFlow.value.groupSend?.stage)
+ assertEquals(1, BottomBarManager.messages.value.size)
+ }
+
+ @Test
+ fun `ten dollars between You and one member says only five is sent, including You`() = runTest(mainCoroutineRule.dispatcher) {
+ val vm = createViewModel()
+ selectingPeople(vm, total = 10.0)
+ vm.checkYou(); vm.checkMember(0)
+
+ val groupSend = vm.stateFlow.value.groupSend
+ assertEquals(Fiat(10.0), groupSend?.total)
+ val pay = groupSend?.plan as GroupSendPlan.Pay
+ assertEquals(listOf(500L), pay.parts)
+ assertEquals(500L, pay.viewerPart)
+ // The Each line shows for one paid member when You are in, as "$5.00 Each, including You".
+ assertTrue(pay.showsEach && pay.includesViewer && pay.isEven)
+ assertEquals(500L, pay.smallest)
+ }
+
+ @Test
+ fun `one paid member without You has no Each line`() = runTest(mainCoroutineRule.dispatcher) {
+ val vm = createViewModel()
+ selectingPeople(vm, total = 10.0)
+ vm.checkMember(0)
+
+ val pay = vm.stateFlow.value.groupSend?.plan as GroupSendPlan.Pay
+ assertTrue(!pay.showsEach)
+ }
}
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 ab4e9b71c5..3417d4ab3e 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
@@ -1216,7 +1216,7 @@ private fun GroupSendBubble(
val even = parts.all { it.nativeAmount == smallest.nativeAmount }
Text(
text = stringResource(
- if (even) R.string.label_groupSendEach else R.string.label_groupSendEachApprox,
+ if (even) R.string.label_groupBubbleEach else R.string.label_groupBubbleEachApprox,
content.partInDisplayCurrency(smallest).formatted(),
),
style = secondary,
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
index bb6df9fa71..28fa7e1da2 100644
--- 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
@@ -21,7 +21,9 @@ import javax.inject.Singleton
* 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].
+ * No UI. The caller checks the balance against the sum of [GroupSendPlan.Pay.parts] (what the sender
+ * pays, which is less than the entered total when the sender is in the split) and any send limit,
+ * before it calls [send].
*/
@Singleton
class GroupSendPaymentDelegate @Inject constructor(
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..e21b0baa63 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
@@ -10,10 +10,18 @@ import java.math.RoundingMode
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.
+ * payment; two or more are one multi-payment. [viewerPart] is the sender's own part when they
+ * are in the split: not paid, and kept out of [parts].
*/
- data class Pay(val parts: List) : GroupSendPlan {
- val isEven: Boolean get() = parts.all { it == parts.first() }
+ data class Pay(val parts: List, val viewerPart: Long? = null) : GroupSendPlan {
+ /** Whether the sender is in the split: their part was dropped, and stays with them. */
+ val includesViewer: Boolean get() = viewerPart != null
+
+ /** Every part equal, the sender's included, so "Each" needs no "~". */
+ val isEven: Boolean get() = (parts + listOfNotNull(viewerPart)).all { it == parts.first() }
+
+ /** Whether the confirm sheet has an "Each" line: with several paid, or to say You are in. */
+ val showsEach: Boolean get() = parts.size >= 2 || includesViewer
/** The part shown as "Each": every part when [isEven], the smallest otherwise. */
val smallest: Long get() = parts.min()
@@ -50,6 +58,20 @@ object GroupSendSplit {
return GroupSendPlan.Pay(List(recipients) { i -> if (i < leftover) base + 1 else base })
}
+ /**
+ * A split that the sender is part of but is not paid: [totalUnits] is divided between the
+ * [paid] members and the sender, whose part is always the last share, so they never take an
+ * extra unit. The sender's part is then dropped: the result's parts are the paid members',
+ * in check order. [includeViewer] false is a plain split. The sender counts toward
+ * [MAX_RECIPIENTS], and in the too-small rule.
+ */
+ fun splitWithViewer(totalUnits: Long, paid: Int, includeViewer: Boolean): GroupSendPlan {
+ if (paid <= 0) return GroupSendPlan.NoRecipients
+ val split = split(totalUnits, paid + if (includeViewer) 1 else 0)
+ if (split !is GroupSendPlan.Pay || !includeViewer) return split
+ return GroupSendPlan.Pay(parts = split.parts.dropLast(1), viewerPart = split.parts.last())
+ }
+
/** [fiat] in its currency's minor unit, rounded half-up to that unit. */
fun minorUnits(fiat: Fiat): Long =
BigDecimal.valueOf(fiat.quarks)
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..fd5e2e1f1f 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
@@ -59,6 +59,44 @@ class GroupSendSplitVectorsTest {
}
}
+ @Test
+ fun `every You vector`() {
+ val vectors = fixture.getJSONArray("youVectors")
+ assertEquals(9, vectors.length())
+ for (i in 0 until vectors.length()) {
+ val v = vectors.getJSONObject(i)
+ val name = v.getString("name")
+ val total = cents(v.getString("total"))
+ val plan = GroupSendSplit.splitWithViewer(total, v.getInt("recipients"), includeViewer = true)
+
+ 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)
+ assertEquals(cents(v.getString("paidTotal")), pay.parts.sum(), name)
+ assertEquals(cents(v.getString("youPart")), total - pay.parts.sum(), name)
+
+ assertEquals(v.has("eachLabel"), pay.showsEach, name)
+ if (v.has("eachLabel")) {
+ val each = "$" + BigDecimal.valueOf(pay.smallest, 2).toPlainString() + " Each, including You"
+ 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)))
diff --git a/test-vectors/group-send-split.json b/test-vectors/group-send-split.json
index 00685790ca..3b0e8b0ce2 100644
--- a/test-vectors/group-send-split.json
+++ b/test-vectors/group-send-split.json
@@ -177,5 +177,116 @@
"recipients": 0,
"expectedError": "no-recipients"
}
+ ],
+ "youVectors": [
+ {
+ "name": "you and two even",
+ "total": "30.00",
+ "recipients": 2,
+ "intent": "SendPublicMultiPayment",
+ "youPart": "10.00",
+ "parts": [
+ "10.00",
+ "10.00"
+ ],
+ "paidTotal": "20.00",
+ "eachLabel": "$10.00 Each, including You"
+ },
+ {
+ "name": "others take the extra cents",
+ "total": "1.00",
+ "recipients": 2,
+ "intent": "SendPublicMultiPayment",
+ "youPart": "0.33",
+ "parts": [
+ "0.34",
+ "0.33"
+ ],
+ "paidTotal": "0.67",
+ "eachLabel": "~$0.33 Each, including You"
+ },
+ {
+ "name": "you and one",
+ "total": "5.01",
+ "recipients": 1,
+ "intent": "SendPublicPayment",
+ "youPart": "2.50",
+ "parts": [
+ "2.51"
+ ],
+ "paidTotal": "2.51",
+ "eachLabel": "~$2.51 Each, including You"
+ },
+ {
+ "name": "nine plus you",
+ "total": "10.00",
+ "recipients": 9,
+ "intent": "SendPublicMultiPayment",
+ "youPart": "1.00",
+ "parts": [
+ "1.00",
+ "1.00",
+ "1.00",
+ "1.00",
+ "1.00",
+ "1.00",
+ "1.00",
+ "1.00",
+ "1.00"
+ ],
+ "paidTotal": "9.00",
+ "eachLabel": "$1.00 Each, including You"
+ },
+ {
+ "name": "nine plus you uneven",
+ "total": "1.01",
+ "recipients": 9,
+ "intent": "SendPublicMultiPayment",
+ "youPart": "0.10",
+ "parts": [
+ "0.11",
+ "0.10",
+ "0.10",
+ "0.10",
+ "0.10",
+ "0.10",
+ "0.10",
+ "0.10",
+ "0.10"
+ ],
+ "paidTotal": "0.91",
+ "eachLabel": "~$0.10 Each, including You"
+ },
+ {
+ "name": "you exact minimum",
+ "total": "0.03",
+ "recipients": 2,
+ "intent": "SendPublicMultiPayment",
+ "youPart": "0.01",
+ "parts": [
+ "0.01",
+ "0.01"
+ ],
+ "paidTotal": "0.02",
+ "eachLabel": "$0.01 Each, including You"
+ },
+ {
+ "name": "too small with you",
+ "total": "0.02",
+ "recipients": 2,
+ "expectedError": "part-below-minimum"
+ },
+ {
+ "name": "only you",
+ "total": "1.00",
+ "recipients": 0,
+ "expectedError": "no-recipients"
+ },
+ {
+ "name": "ten plus you",
+ "total": "11.00",
+ "recipients": 10,
+ "expectedError": "too-many-recipients"
+ }
]
}