Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c3e3ac9
feat(payments): split a group send's total between its recipients
bmc08gt Oct 24, 2026
4c501cc
feat(payments): pay a group send's parts in one intent
bmc08gt Oct 24, 2026
56d7228
docs(test-vectors): list group-send-split.json
bmc08gt Oct 24, 2026
060e42d
feat(messenger): pay checked members from a private group's amount sheet
bmc08gt Oct 24, 2026
3386b75
feat(messenger): add Select People and the group send confirm sheet
bmc08gt Oct 24, 2026
d746fee
feat(chat): draw group sends as one bubble with a recipients sheet
bmc08gt Oct 24, 2026
8125d91
fix(chat): credit and label only the viewer's own part of a group send
bmc08gt Oct 24, 2026
e30268f
fix(messenger): let the group send confirm sheet change the token
bmc08gt Oct 24, 2026
57bcc0a
fix(chat): loosen the group send bubble and details sheet
bmc08gt Oct 25, 2026
0022d6a
fix(chat): show the group send token name at full opacity
bmc08gt Oct 25, 2026
ee564a7
fix(messenger): match the confirm sheet token pill to other cash sheets
bmc08gt Oct 25, 2026
06985a5
fix(chat): size the group send token row like the single send bubble
bmc08gt Oct 25, 2026
edeef2a
fix(chat): drop the group send "You received" line, name the viewer f…
bmc08gt Oct 25, 2026
9988109
fix(chat): trim the group send bubble's vertical padding
bmc08gt Oct 25, 2026
5aa78c0
fix(chat): show the viewer's avatar on the group send details sheet
bmc08gt Oct 25, 2026
d842b00
feat(messenger): open a recipient's profile from the group send details
bmc08gt Oct 25, 2026
2761946
fix(messenger): show a group payment to you alone as the group card
bmc08gt Oct 25, 2026
83a7110
fix(messenger): list the payee of a one-recipient group payment in it…
bmc08gt Oct 25, 2026
dbd63b8
feat(messenger): pick the send currency from a half sheet in the chat…
bmc08gt Oct 25, 2026
a9702f9
fix(messenger): open the sender and token from group send details
bmc08gt Oct 25, 2026
dc3692f
fix(messenger): slide Select People in on the amount sheet's own nav …
bmc08gt Oct 25, 2026
7da35d2
fix(messenger): present group send like the share sheet
bmc08gt Oct 25, 2026
65bdc41
fix(messenger): drop the confirm sheet's X to match iOS
bmc08gt Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,6 +50,20 @@ sealed interface ChatStep : FlowStep, Parcelable {
@Serializable
data object AmountEntry : ChatStep, NavigationRetVal<ChatSendResult>, 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<ChatSendResult>,
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.
Expand Down Expand Up @@ -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.
Expand All @@ -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<String>,
) : ChatStep, Sheet, WrapContentSheet

@Parcelize
@Serializable
data class Reactors(val chatId: ChatId, val messageId: Long) : ChatStep, Sheet, HalfSheet
Expand Down
40 changes: 40 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1650,4 +1650,44 @@
<string name="title_getNotified">Get Notified</string>
<string name="description_getNotified">We\'ll let you know when your transactions complete, even if you leave the app</string>
<string name="action_turnOnNotifications">Turn On Notifications</string>

<!-- Group send, node 11293:6918 -->
<string name="title_selectPeople">Select People</string>
<string name="header_groupSendMembers">Members</string>
<plurals name="action_sendToPeople">
<item quantity="one">Send to %1$d Person</item>
<item quantity="other">Send to %1$d People</item>
</plurals>
<string name="title_groupSendMaxPeople">10 People Max</string>
<string name="description_groupSendMaxPeople">The maximum number of people you can send to at once is 10 people</string>
<!-- Placeholder: the too-small-to-split alert has no design yet -->
<string name="error_title_groupSendTooSmall">Amount Too Small</string>
<plurals name="error_description_groupSendTooSmall">
<item quantity="one">This amount is too small to send to %1$d person. Enter a larger amount</item>
<item quantity="other">This amount is too small to split between %1$d people. Pick fewer people or enter a larger amount</item>
</plurals>
<plurals name="label_groupSendRecipients">
<item quantity="one">%1$s and %2$d Other</item>
<item quantity="other">%1$s and %2$d Others</item>
</plurals>
<string name="label_groupSendTo">to %1$s</string>
<plurals name="label_groupSendToPeople">
<item quantity="one">to %1$d person</item>
<item quantity="other">to %1$d people</item>
</plurals>
<string name="label_groupSendEach">%1$s each</string>
<string name="label_groupSendEachApprox">~%1$s each</string>
<string name="subtitle_senderSent">%1$s sent</string>
<string name="label_cashDetails_sent">Sent</string>
<string name="label_cashDetails_from">From</string>
<string name="label_cashDetails_to">To</string>
<string name="action_groupSendDetailsClose">Close</string>
<plurals name="label_chat_preview_groupSend_bySender">
<item quantity="one">%1$s sent %2$s to %3$d person</item>
<item quantity="other">%1$s sent %2$s to %3$d people</item>
</plurals>
<plurals name="label_chat_preview_groupSend">
<item quantity="one">You sent %1$s to %2$d person</item>
<item quantity="other">You sent %1$s to %2$d people</item>
</plurals>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -141,12 +148,16 @@ fun ChatFlowScreen(
@Composable
private fun chatEntryProvider(
openKeyboard: Boolean,
resultStateRegistry: NavResultStateRegistry,
): (NavKey) -> NavEntry<NavKey> = entryProvider {
annotatedEntry<ChatStep.Conversation> {
FlowConversationScreen(openKeyboard)
}
annotatedEntry<ChatStep.AmountEntry> {
FlowAmountEntryScreen()
FlowAmountEntryScreen(resultStateRegistry)
}
annotatedEntry<ChatStep.GroupSendAmountEntry> {
FlowAmountEntryScreen(resultStateRegistry)
}
annotatedEntry<ChatStep.InviteToGroup> {
FlowGroupInviteSheet()
Expand Down Expand Up @@ -183,6 +194,32 @@ private fun chatEntryProvider(
FlowEditGroupDescriptionScreen()
}

annotatedEntry<ChatStep.GroupSendDetails> { step ->
val viewModel = flowSharedViewModel<ChatViewModel>()
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<ChatStep.ReactionPicker> { step ->
FlowReactionPickerScreen(step.messageId)
}
Expand All @@ -192,6 +229,9 @@ private fun chatEntryProvider(
annotatedEntry<ChatStep.ReactionBoostCurrency> {
ReactionBoostCurrencySheet()
}
annotatedEntry<ChatStep.SendCurrency> {
ReactionBoostCurrencySheet()
}
annotatedEntry<ChatStep.ReactionBoostAmount> { step ->
FlowReactionBoostAmountScreen(step)
}
Expand Down Expand Up @@ -226,10 +266,11 @@ private fun FlowConversationScreen(
LaunchedEffect(viewModel) {
viewModel.eventFlow
.filterIsInstance<ChatViewModel.Event.NavigateToAmountEntry>()
.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<ChatSendResult>(ChatStep.AmountEntry) { result ->
val step = if (event.toMembers) ChatStep.GroupSendAmountEntry else ChatStep.AmountEntry
navigator.navigateForResult<ChatSendResult>(step) { result ->
if (result is NavResultOrCanceled.ReturnValue) {
viewModel.dispatchEvent(ChatViewModel.Event.OnStartMessageInput)
}
Expand Down Expand Up @@ -261,30 +302,90 @@ private fun FlowConversationScreen(
}

@Composable
private fun FlowAmountEntryScreen() {
private fun FlowAmountEntryScreen(resultStateRegistry: NavResultStateRegistry) {
val viewModel = flowSharedViewModel<ChatViewModel>()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
// The chat flow's navigator, read before the sheet's own FlowHost below shadows it.
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
// instead of closing.
val dismissSheet = LocalBottomSheetDismissDispatcher.current
val resultBack = resultBackNavigator<ChatSendResult>(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<GroupSendStep, Parcelable>(
initialStack = listOf(GroupSendStep.Amount),
resultStateRegistry = resultStateRegistry,
onExit = { _, _ -> dismissSheet() },
entryProvider = entryProvider {
annotatedEntry<GroupSendStep.Amount> {
val flowNavigator = rememberFlowNavigator<GroupSendStep, Parcelable>()
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<GroupSendStep.People> {
val flowNavigator = rememberFlowNavigator<GroupSendStep, Parcelable>()
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<ChatViewModel.Event.SendComplete>()
.collect { onSendComplete() }
}
}

/**
* The invite sheet, on the conversation's own view model.
*
Expand Down
Loading
Loading