Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions apps/flipcash/core/src/androidMain/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1988,10 +1988,6 @@
<!-- Group send, node 11293:6918 -->
<string name="title_selectPeople">Select People</string>
<string name="header_groupSendMembers">Members</string>
<plurals name="action_sendToPeople">
<item quantity="one">Send to %1$d Person</item>
<item quantity="other">Send to %1$d People</item>
</plurals>
<string name="title_groupSendMaxPeople">10 People Max</string>
<string name="description_groupSendMaxPeople">The maximum number of people you can send to at once is 10 people</string>
<!-- Placeholder: the too-small-to-split alert has no design yet -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -490,10 +490,12 @@ 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) },
buttonEnabled = groupSend.selected.isNotEmpty(),
viewerId = groupSend.viewerId,
)
if (groupSend.stage == GroupSendState.Stage.Confirm) {
GroupSendConfirmSheet(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatMember>
get() = groupSend?.candidates(members.ifEmpty { knownMembers }).orEmpty()

Expand Down Expand Up @@ -4056,15 +4056,15 @@ internal class ChatViewModel @AssistedInject constructor(
eventFlow.filterIsInstance<Event.OnGroupSendMemberToggled>()
.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<Event.OnGroupSendBack>()
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ID> = 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<ID> 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<ChatMember>): List<ChatMember> =
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<ID>? = 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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -75,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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
)
Expand All @@ -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<Long>, 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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,15 +382,16 @@ 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()

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()) }
}

Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long>) : GroupSendPlan {
val isEven: Boolean get() = parts.all { it == parts.first() }
data class Pay(val parts: List<Long>, val kept: Long? = null) : GroupSendPlan {
/** Every part of the total, the sender's included: what "Each" and its "~" describe. */
val shares: List<Long> 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
}

Expand All @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand All @@ -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<GroupSendPlan.Pay>(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)))
Expand Down
Loading
Loading