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