Skip to content
Merged
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
11 changes: 9 additions & 2 deletions apps/flipcash/core/src/androidMain/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1987,6 +1987,8 @@

<!-- Group send, node 11293:6918 -->
<string name="title_selectPeople">Select People</string>
<string name="title_splitBetween">Split Between</string>
<string name="title_groupSendTotal">Total Amount To Send</string>
<string name="header_groupSendMembers">Members</string>
<plurals name="action_sendToPeople">
<item quantity="one">Send to %1$d Person</item>
Expand All @@ -2009,8 +2011,13 @@
<item quantity="one">to %1$d person</item>
<item quantity="other">to %1$d people</item>
</plurals>
<string name="label_groupSendEach">%1$s each</string>
<string name="label_groupSendEachApprox">~%1$s each</string>
<string name="label_groupSendEach">%1$s Each</string>
<string name="label_groupSendEachApprox">~%1$s Each</string>
<!-- The chat bubble's per-person line, lowercase; the confirm sheet's above is capitalized -->
<string name="label_groupBubbleEach">%1$s each</string>
<string name="label_groupBubbleEachApprox">~%1$s each</string>
<string name="label_groupSendEachIncludingYou">%1$s Each, including You</string>
<string name="label_groupSendEachApproxIncludingYou">~%1$s Each, including You</string>
<string name="subtitle_senderSent">%1$s sent</string>
<string name="label_cashDetails_sent">Sent</string>
<string name="label_cashDetails_from">From</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,10 @@ private fun chatEntryProvider(
FlowConversationScreen(openKeyboard)
}
annotatedEntry<ChatStep.AmountEntry> {
FlowAmountEntryScreen(resultStateRegistry)
FlowAmountEntryScreen(resultStateRegistry, toMembers = false)
}
annotatedEntry<ChatStep.GroupSendAmountEntry> {
FlowAmountEntryScreen(resultStateRegistry)
FlowAmountEntryScreen(resultStateRegistry, toMembers = true)
}
annotatedEntry<ChatStep.InviteToGroup> {
FlowGroupInviteSheet()
Expand Down Expand Up @@ -427,7 +427,7 @@ private fun FlowConversationScreen(
}

@Composable
private fun FlowAmountEntryScreen(resultStateRegistry: NavResultStateRegistry) {
private fun FlowAmountEntryScreen(resultStateRegistry: NavResultStateRegistry, toMembers: Boolean) {
val viewModel = flowSharedViewModel<ChatViewModel>()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
// The chat flow's navigator, read before the sheet's own FlowHost below shadows it.
Expand Down Expand Up @@ -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,
)
}
}
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ChatMember>
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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -4132,15 +4140,15 @@ 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))
},
)
}.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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ID>
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<ChatMember>): List<ChatMember> =
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>): 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<ID>? = when {
member in selected -> selected.filterNot { it == member }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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()
Expand All @@ -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)
},
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
)
Expand All @@ -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<Long>, 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. */
Expand Down
Loading
Loading