From 4e9783858c88951949e4f6e17ef0198fbffc36ca Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 14:53:27 -0400 Subject: [PATCH 1/4] docs: plan swipe sheets as navigation destinations --- docs/swipe-sheet-destinations.md | 132 +++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 docs/swipe-sheet-destinations.md diff --git a/docs/swipe-sheet-destinations.md b/docs/swipe-sheet-destinations.md new file mode 100644 index 0000000000..5ffcffe215 --- /dev/null +++ b/docs/swipe-sheet-destinations.md @@ -0,0 +1,132 @@ +# Swipe sheets as navigation destinations + +Status: plan. Nothing here is built yet; the groundwork is in place. + +## Where things stand + +Three flows end in a swipe-to-pay sheet: pay a request (`RequestDetailsScreen`), group send +(`GroupSendConfirmSheet` in `ChatFlowScreen`) and Currency Info send (`CurrencySendScreen`). +They used to be Material `ModalBottomSheet`s, which are windows of their own, so a bottom bar +raised while one was up drew underneath it. + +They are now `SwipePaySheet` (`messenger/internal/swipepay`), a stateless composable over +`InWindowBottomSheet` (`ui/navigation`), drawn inside the route's content. The route's entry +decorator draws the bar after `entry.Content()` in the same window, so the bar lands above the +sheet. Each flow's view model exposes its sheet's actions as one `SwipePayActions` +(`swipePayActions`, `groupSendSwipeActions`), and the screen builds a `SwipePayUiState` from the +view model state. The view models still own the pay and send logic. + +This is what option A builds on: the UI already takes only a `SwipePayUiState` and `SwipePayActions`. + +## Target shape + +- One navigation destination per flow: `PayRequestSheet`, `GroupSendConfirmSheet`, + `CurrencySendConfirmSheet`. Each is a `Sheet` (wrap-content) route handled by + `ModalBottomSheetSceneStrategy`, so it gets the scene's drag, scrim, back handling and insets + without `InWindowBottomSheet`. +- `SwipePaySheet` content is shared unchanged. The destination supplies header and amount slots; + `InWindowBottomSheet` and `rememberSwipePaySheetState` go away, because the scene owns the + sheet state. The lock while a swipe is in flight becomes `navigator.sheetDismissDisabled`, which + the scene already honours. +- One entry-scoped ViewModel per destination, holding the `SwipePayUiState` and implementing + `SwipePayActions`. It owns the pay or send call and the post-swipe checks that today live in + the parent view model. + +## What each destination is given + +Pass ids and small values in the key; re-derive the rest in the destination's view model. +Nothing live (a `ChatMember`, a `GroupSendPlan`, a `ConversationReference`) goes in a key. + +| Flow | In the key | Re-derived in the destination view model | +|---|---|---| +| Pay request | `chatId`, `requestId` | `payments.prepare(requestId, viewer)` for the `Share` (it already does this on Pay tap and again after a failed swipe), the creator from the chat roster, the selected token | +| Group send | `chatId`, `total` (as currency code plus minor units), `selected` member ids | The roster for avatars and names, the plan from `GroupSendSplit.split(total, selected.size)` (pure), the selected token | +| Currency send | `mint`, `total`, `recipient.chatId` | The `ConversationReference` from the conversations source by chat id, the token from `tokenCoordinator.cachedToken(mint)` | + +The token pill changes the selected token through the shared `SelectTokenViewModel`, not through +the flow. When the token changes inside the sheet, the destination view model re-derives from +the new token, not from anything it was handed: + +- Pay request: the share's fiat amount is fixed; only the balance check changes. Re-run + `payBlock` (balance and send limit) on the new token before the swipe is accepted. +- Group send: the plan depends on the total and the selection, not the token, so it stands. The + balance check (`checkBalanceLimit`) re-runs, as `sendToMembers` does today. +- Currency send: the currency is fixed to `mint`; there is no picker. + +## Result path + +A destination reports back through the existing nav result store (`NavResultKey` with +`deliverOrPersist`), the same way other sheets do: + +- `Paid` or `Sent`: the parent closes its step. Group send replaces `Event.SendComplete`; Currency + send replaces the `done` flow that `finishSend` collects. +- `Canceled` (back, scrim, drag): delivered by the scene already. The parent returns to its + previous step, as `onConfirmDismissed` and `OnGroupSendBack` do now. +- `AddMoney`: the destination pops itself and the parent opens the deposit screen, replacing the + close-then-navigate in `presentDeposit`. + +The sheet pops itself after the success checkmark has held (`holdPaymentSuccess`), then delivers +`Paid`. A failed swipe delivers nothing: the sheet stays and the modal shows over it. + +## `hostsBars` + +`hostsBars(isTopEntry, isSheet, coversScreen)` returns false for a partial sheet: its entry +leaves bars to the route beneath, which the sheet scene covers. That is fine while the sheet is +a layer inside a route (today), and wrong once the sheet is its own entry: a bar raised from it +would draw behind it. + +The change: a sheet entry hosts bars when it is the top entry, and the bar is drawn by the +scene's own content rather than the entry decorator, so it sits inside the scene's +`SharedTransitionLayout` above the sheet surface and below nothing. Concretely, move +`BottomBarContainer` into `ModalBottomSheetScene.content` for sheets that opt in +(`NavMetadataKeys.HostsBars`), and make `hostsBars` false for those entries so the bar is not +drawn twice. `BarHostingTest` already pins the table; extend it with the opt-in case. + +Two things to settle when doing it: a bar inside a partial sheet is clipped to the sheet's +bounds, so the scene should draw it outside the clipped `Sheet` but inside the scene's `Box`; +and the scrim must not dim the bar. + +## Group send and the nested `FlowHost` + +`GroupSendConfirmSheet` is shown from the chat route's `FlowHost`, which has its own `NavDisplay` +and no entry decorators. A sheet pushed on that stack would be a scene of the inner display, so +the outer app decorator and the app's `ModalBottomSheetSceneStrategy` never see it. + +Options, in the order to prefer: + +1. Give the chat `FlowHost` the same scene strategy and a `NavMessagingEntryDecorator`-style + bar host for its sheet entries. The inner display then renders the sheet and draws bars + above it. Cost: a second bar host, so the outer one must not also draw for the same message + (host by the topmost display only). +2. Push the destination on the app back stack instead, with the chat route staying beneath it. + The sheet and the bar are then handled by the app's display with no new plumbing. Cost: the + group-send state (`GroupSendState` in `ChatViewModel`) is shared between steps, so the + destination has to take its inputs by key (above) and report back by result, which is what + the table already assumes. + +Prefer 2 unless the inner flow needs to keep swipe state across process death on its own stack. + +## Order + +One flow per PR, each shippable on its own: + +1. Currency send. No nested host, and its `finishSend` (hide, then pop) maps directly onto + "pop after the checkmark". Introduces the destination pattern, the result key and the + `HostsBars` metadata with its test. +2. Pay request. Moves `payTapped`, `confirmPay` and the `ready` share into the destination view + model; keeps `RequestDetailsViewModel` for the screen behind it. +3. Group send. Last because of the nested `FlowHost`; do it once 1 and 2 have settled the + result and bar handling. +4. Delete `InWindowBottomSheet` when nothing uses it. + +## Other sheets with the same problem + +These are still Material `ModalBottomSheet`s, so a bar raised over them draws underneath. Not +changed here: + +- `StartChattingSheet` (`StartChattingSheet.kt`) +- `RequestMoneyScreen` (`RequestMoneyScreen.kt`) +- `TimeToPaySheet` (`TimeToPaySheet.kt`) +- the confirm and options `ActionSheet` in `RequestDetailsScreen` + +They can move to `InWindowBottomSheet` independently if one of them starts raising a bar. From 78a554e833c86ad0887a94734fd28730e2212741 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 14:53:34 -0400 Subject: [PATCH 2/4] fix(payments): show insufficient funds above the swipe sheets Request pay, group send and Currency Info send drew their swipe sheet in a Material ModalBottomSheet, a window of its own, so the insufficient-funds modal (drawn in the Activity window) appeared behind it. Draw the sheets in the route's own window instead, through a new InWindowBottomSheet, so the bar the entry decorator draws lands above them. The sheet content moves to one stateless SwipePaySheet fed by a SwipePayUiState and SwipePayActions. Request pay and Currency Info send also check the balance before the sheet opens, and Add Money closes the sheet before opening the deposit screen. --- .../flipcash/app/messenger/ChatFlowScreen.kt | 12 +- .../app/messenger/CurrencySendScreen.kt | 117 ++++-------- .../app/messenger/internal/ChatViewModel.kt | 16 ++ .../currencysend/CurrencySendViewModel.kt | 44 +++++ .../internal/request/RequestDetailsScreen.kt | 81 +++----- .../request/RequestDetailsViewModel.kt | 60 ++++-- .../internal/screens/cash/GroupSendScreens.kt | 77 ++------ .../internal/swipepay/SwipePaySheet.kt | 180 ++++++++++++++++++ .../CurrencySendInsufficientFundsTest.kt | 134 +++++++++++++ .../RequestDetailsInsufficientFundsTest.kt | 156 +++++++++++++++ .../navigation/scenes/InWindowBottomSheet.kt | 145 ++++++++++++++ .../scenes/ModalBottomSheetSceneStrategy.kt | 2 +- 12 files changed, 804 insertions(+), 220 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/swipepay/SwipePaySheet.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendInsufficientFundsTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsInsufficientFundsTest.kt create mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/InWindowBottomSheet.kt 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 a541ce1242..e9cab21ba0 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 @@ -1,5 +1,6 @@ package com.flipcash.app.messenger +import com.flipcash.app.messenger.internal.swipepay.SwipePayUiState import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.material3.Text @@ -439,10 +440,13 @@ private fun FlowGroupSelectPeople(viewModel: ChatViewModel, onSendComplete: () - 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) }, + swipePay = SwipePayUiState( + token = state.token, + progress = state.sendProgress, + tokenPickable = true, + backOnly = true, + ), + actions = remember(viewModel) { viewModel.groupSendSwipeActions }, ) } LaunchedEffect(viewModel) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt index bd0ac9358a..d521a203ba 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt @@ -1,5 +1,10 @@ package com.flipcash.app.messenger +import com.flipcash.app.messenger.internal.swipepay.SwipePayActions +import com.flipcash.app.messenger.internal.swipepay.SwipePaySheet +import com.flipcash.app.messenger.internal.swipepay.SwipePayUiState +import com.flipcash.app.messenger.internal.swipepay.SwipePaySheetState +import com.flipcash.app.messenger.internal.swipepay.rememberSwipePaySheetState import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -106,23 +111,22 @@ fun CurrencySendScreen(mint: Mint, resultStateRegistry: NavResultStateRegistry) LaunchedEffect(viewModel, mint) { viewModel.start(mint) } val session = LocalSessionController.current + LaunchedEffect(viewModel) { + viewModel.effectFlow.collect { effect -> + when (effect) { + is CurrencySendViewModel.Effect.OpenScreen -> navigator.navigate(effect.route) + } + } + } LaunchedEffect(viewModel, session) { viewModel.bill.collect { session?.showBill(it) } } - // Raised once the checkmark has held: from then on the sheet may close, and only by our hand. - var hiding by remember { mutableStateOf(false) } - val locked by rememberUpdatedState(!state.sendProgress.isIdle && !hiding) - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true, - confirmValueChange = { !locked }, - ) + // Locked from the swipe until the checkmark has held; then only our own hide() closes it. + val sheetState = rememberSwipePaySheetState(locked = !state.sendProgress.isIdle) LaunchedEffect(viewModel, sheetState) { viewModel.done.collect { finishSend( - hide = { - hiding = true - sheetState.hide() - }, + hide = { sheetState.sheet.hide() }, navigateBack = { navigator.pop() }, ) } @@ -205,12 +209,13 @@ fun CurrencySendScreen(mint: Mint, resultStateRegistry: NavResultStateRegistry) SwipeToSendSheet( recipient = selected, amountText = amount.formatted(), - token = state.token, - sendProgress = state.sendProgress, + swipePay = SwipePayUiState( + token = state.token, + progress = state.sendProgress, + tokenPickable = false, + ), + actions = viewModel.swipePayActions, sheetState = sheetState, - locked = locked, - onConfirm = viewModel::send, - onDismiss = { if (!hiding) viewModel.onConfirmDismissed() }, ) } } @@ -402,34 +407,19 @@ internal suspend fun finishSend(hide: suspend () -> Unit, navigateBack: () -> Un * The confirm sheet over Select Recipient: who is being paid, the amount, the currency with no * picker, and the swipe. Closed by a swipe down, a tap on the scrim or back, which returns to the list and sends nothing; locked once the send starts. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SwipeToSendSheet( recipient: ConversationReference, amountText: String, - token: com.getcode.opencode.model.financial.Token?, - sendProgress: com.getcode.view.LoadingSuccessState, - sheetState: SheetState, - locked: Boolean, - onConfirm: () -> Unit, - onDismiss: () -> Unit, + swipePay: SwipePayUiState, + actions: SwipePayActions, + sheetState: SwipePaySheetState, ) { - ModalBottomSheet( - onDismissRequest = { if (!locked) onDismiss() }, + SwipePaySheet( + state = swipePay, + actions = actions, sheetState = sheetState, - dragHandle = null, - containerColor = CodeTheme.colors.background, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(horizontal = CodeTheme.dimens.inset) - .padding(top = CodeTheme.dimens.grid.x6, bottom = CodeTheme.dimens.grid.x4), - horizontalAlignment = Alignment.CenterHorizontally, - // Section gap 40 (node 11622:2232). - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x8), - ) { + header = { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), @@ -459,43 +449,18 @@ private fun SwipeToSendSheet( } } } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - ) { - Text( - text = amountText, - // SemiBold 44, tracking -1 (node 11881:2467). - style = CodeTheme.typography.displayMedium.copy( - fontSize = 44.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = (-1).sp, - ), - color = CodeTheme.colors.textMain, - ) - // Node 11881:2468: 36 tall, 20 coin, SemiBold 17, 5% fill. No chevron: the currency is locked. - TokenSelectionPill( - modifier = Modifier.height(CodeTheme.dimens.grid.x7), - token = token, - background = White05, - textStyle = CodeTheme.typography.textMedium.copy(fontSize = 17.sp), - imageSize = CodeTheme.dimens.staticGrid.x4, - contentPadding = PaddingValues( - horizontal = CodeTheme.dimens.grid.x2, - vertical = CodeTheme.dimens.grid.x1, - ), - showsPicker = false, - onClick = {}, - ) - } - SlideToConfirm( - onConfirm = onConfirm, - modifier = Modifier.fillMaxWidth(), - enabled = sendProgress.isIdle, - isLoading = sendProgress.loading, - isSuccess = sendProgress.success, - label = stringResource(R.string.action_swipeToSend), + }, + amount = { + Text( + text = amountText, + // SemiBold 44, tracking -1 (node 11881:2467). + style = CodeTheme.typography.displayMedium.copy( + fontSize = 44.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-1).sp, + ), + color = CodeTheme.colors.textMain, ) - } - } + }, + ) } 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 9c02c19544..5d9bb97600 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,5 +1,6 @@ package com.flipcash.app.messenger.internal +import com.flipcash.app.messenger.internal.swipepay.SwipePayActions import com.getcode.view.PaymentSuccessHoldDuration import com.getcode.view.holdPaymentSuccess import com.flipcash.shared.chat.ui.settledUsd @@ -3618,6 +3619,8 @@ internal class ChatViewModel @AssistedInject constructor( eventFlow .filterIsInstance() .onEach { + // Add Money from a modal over the confirm sheet: close it, then open the deposit. + closeGroupSendConfirm() startChattingPayer.depositRoute()?.let { route -> dispatchEvent(Event.OpenScreen(route)) } @@ -3909,6 +3912,12 @@ internal class ChatViewModel @AssistedInject constructor( .launchIn(viewModelScope) } + /** What the group-send confirm sheet asks of this view model, in one place. */ + val groupSendSwipeActions = SwipePayActions( + onConfirm = { dispatchEvent(Event.OnGroupSendRequested) }, + onDismiss = { dispatchEvent(Event.OnGroupSendBack) }, + ) + private fun sendToMembers() = viewModelScope.launch { val groupSend = stateFlow.value.groupSend ?: return@launch val plan = groupSend.plan as? GroupSendPlan.Pay ?: return@launch @@ -3958,6 +3967,13 @@ internal class ChatViewModel @AssistedInject constructor( } } + /** Takes the group send's confirm sheet down, back to Select People. */ + private fun closeGroupSendConfirm() { + val groupSend = stateFlow.value.groupSend ?: return + if (groupSend.stage != GroupSendState.Stage.Confirm) return + dispatchEvent(Event.GroupSendUpdated(groupSend.copy(stage = GroupSendState.Stage.People))) + } + private fun checkBalanceLimit(amount: Fiat): Boolean { val token = stateFlow.value.token ?: return false val rate = exchange.preferredRate diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt index 7dc7d47523..a3068b0f04 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.currencysend +import com.flipcash.app.messenger.internal.swipepay.SwipePayActions import com.getcode.view.PaymentSuccessHoldDuration import com.getcode.view.holdPaymentSuccess import androidx.lifecycle.ViewModel @@ -22,6 +23,8 @@ import com.flipcash.shared.chat.chatListFeed import com.flipcash.shared.chat.ui.ConversationReference import com.flipcash.shared.chat.ui.toConversationReference import com.flipcash.shared.payments.TipPaymentDelegate +import com.flipcash.app.core.AppRoute +import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.exchange.Exchange @@ -73,8 +76,16 @@ internal class CurrencySendViewModel @Inject constructor( private val verifiedFiatCalculator: VerifiedFiatCalculator, private val tipPaymentDelegate: TipPaymentDelegate, private val analytics: FlipcashAnalytics, + private val payer: StartChattingPayer, ) : ViewModel() { + sealed interface Effect { + data class OpenScreen(val route: AppRoute) : Effect + } + + private val effects = Channel(Channel.BUFFERED) + val effectFlow: Flow = effects.receiveAsFlow() + enum class Stage { Amount, Recipients } data class State( @@ -172,6 +183,8 @@ internal class CurrencySendViewModel @Inject constructor( fun onAmountConfirmed() { val entered = amountDelegate.state.value.enteredAmount if (entered <= 0) return + // Over balance (or the send limit) stops here rather than at the swipe. + if (blockedAtAmount(entered)) return _state.update { it.copy( amount = Fiat(entered, exchange.preferredRate.currency), @@ -180,6 +193,34 @@ internal class CurrencySendViewModel @Inject constructor( } } + private fun blockedAtAmount(entered: Double): Boolean { + val token = _state.value.token ?: return false + val rate = exchange.preferredRate + val amount = Fiat(entered, rate.currency) + val balance = tokenCoordinator.balanceForToken(token).convertingTo(rate) + if (amount.valueGreaterThan(balance)) { + payer.presentInsufficientBalance(onAddMoney = ::presentDeposit) + return true + } + val limit = transactionController.limits.value?.sendLimitFor(rate.currency) + if (limit != null && entered > limit.nextTransaction) { + BottomBarManager.showAlert( + resources.getString(R.string.error_title_sendLimitReached), + resources.getString(R.string.error_description_sendLimitReached), + ) + return true + } + return false + } + + /** "Add Money" from a modal: the swipe sheet closes, then the deposit screen opens. */ + private fun presentDeposit() { + _state.update { if (it.sendProgress.isIdle) it.copy(selected = null) else it } + viewModelScope.launch { + payer.depositRoute()?.let { effects.send(Effect.OpenScreen(it)) } + } + } + /** Back from Select Recipient to the amount, which keeps what was typed. */ fun onBackToAmount() { _state.update { it.copy(stage = Stage.Amount, query = "", selected = null) } @@ -193,6 +234,9 @@ internal class CurrencySendViewModel @Inject constructor( } /** Closes the swipe sheet, back to the list. A send in flight can't be backed out of. */ + /** What the confirm sheet asks of this flow, in one place; see [SwipePayActions]. */ + val swipePayActions = SwipePayActions(onConfirm = ::send, onDismiss = ::onConfirmDismissed) + fun onConfirmDismissed() { if (!_state.value.sendProgress.isIdle) return _state.update { it.copy(selected = null) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt index d3f5114079..ab960d39c6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt @@ -70,7 +70,10 @@ import com.getcode.ui.components.SlideToConfirm import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold -import com.getcode.view.LoadingSuccessState +import com.flipcash.app.messenger.internal.swipepay.SwipePayActions +import com.flipcash.app.messenger.internal.swipepay.SwipePaySheet +import com.flipcash.app.messenger.internal.swipepay.SwipePayUiState +import com.flipcash.app.messenger.internal.swipepay.rememberSwipePaySheetState import kotlin.time.Instant /** @@ -189,10 +192,13 @@ internal fun RequestDetailsScreen( PayShareSheet( creator = members[current.creator], share = share, - token = token, - progress = state.payProgress, - onConfirm = viewModel::confirmPay, - onDismiss = viewModel::dismissSheet, + swipePay = SwipePayUiState( + token = token, + progress = state.payProgress, + tokenPickable = true, + swipeTestTag = "request_pay_swipe", + ), + actions = viewModel.swipePayActions, ) } } @@ -368,41 +374,18 @@ private fun ShareRow( * Frame 10: who is being paid, the share, the token, and the swipe. The token pill swaps this * sheet's content for the currency list, as on the group send confirm sheet. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun PayShareSheet( creator: ChatMember?, share: Share, - token: Token?, - progress: LoadingSuccessState, - onConfirm: () -> Unit, - onDismiss: () -> Unit, + swipePay: SwipePayUiState, + actions: SwipePayActions, ) { - val locked by rememberUpdatedState(!progress.isIdle) - var pickingCurrency by rememberSaveable { mutableStateOf(false) } - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true, - confirmValueChange = { !locked && !pickingCurrency }, - ) - ModalBottomSheet( - onDismissRequest = { if (!locked) onDismiss() }, - sheetState = sheetState, - 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(bottom = CodeTheme.dimens.grid.x4), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - ) { + SwipePaySheet( + state = swipePay, + actions = actions, + sheetState = rememberSwipePaySheetState(locked = swipePay.locked), + header = { MemberAvatar(creator, Modifier.requiredSize(CodeTheme.dimens.staticGrid.x10)) Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( @@ -419,35 +402,15 @@ private fun PayShareSheet( ) } } + }, + amount = { Text( text = share.amount.requestText(), style = CodeTheme.typography.displayMedium, color = CodeTheme.colors.textMain, ) - 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) - .testTag("request_pay_swipe"), - enabled = progress.isIdle, - isLoading = progress.loading, - isSuccess = progress.success, - label = stringResource(R.string.action_swipeToSend), - ) - } - } + }, + ) } /** Frames 28, 29, 33 and 34: an optional title and body, one action, and Cancel. */ diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt index 052cb02c8f..cfc4a4a997 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.request +import com.flipcash.app.messenger.internal.swipepay.SwipePayActions import com.getcode.view.PaymentSuccessHoldDuration import com.getcode.view.holdPaymentSuccess import androidx.lifecycle.ViewModel @@ -38,6 +39,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.receiveAsFlow @@ -183,8 +185,15 @@ internal class RequestDetailsViewModel @Inject constructor( _state.update { it.copy(preparing = false) } when (found) { is ShareToPay.Ready -> { - ready = found - _state.update { it.copy(sheet = Sheet.Pay, payProgress = LoadingSuccessState()) } + // Checked before the swipe sheet opens, so an empty wallet never reaches the swipe. + val token = token.value ?: payer.observeSelectedToken().first() + val blocked = payBlock(found.share.amount, token) + if (blocked != null) { + present(blocked) + } else { + ready = found + _state.update { it.copy(sheet = Sheet.Pay, payProgress = LoadingSuccessState()) } + } } is ShareToPay.NothingToPay -> _state.update { it.copy(hasPaymentIntent = false) } is ShareToPay.Closed -> presentClosed() @@ -197,13 +206,23 @@ internal class RequestDetailsViewModel @Inject constructor( } } + /** + * Everything the pay sheet asks of this flow, in one place: a later destination that owns the + * sheet can take this object, and the [SwipePayUiState] built from [state], as they are. + */ + val swipePayActions = SwipePayActions(onConfirm = ::confirmPay, onDismiss = ::dismissSheet) + /** The swipe on frame 10. A second swipe after a failure pays under the same intent id. */ fun confirmPay() { val pending = ready ?: return val token = token.value ?: return val owner = userManager.accountCluster ?: return if (!_state.value.payProgress.isIdle) return - if (overBalance(pending.share.amount, token) || overSendLimit(pending.share.amount)) return + // The token or the balance can change while the sheet is up; the modal shows over it. + payBlock(pending.share.amount, token)?.let { blocked -> + present(blocked) + return + } _state.update { it.copy(payProgress = LoadingSuccessState(loading = true)) } viewModelScope.launch { @@ -269,7 +288,9 @@ internal class RequestDetailsViewModel @Inject constructor( _state.update { it.copy(sheet = Sheet.None, payProgress = LoadingSuccessState()) } } + /** "Add Money" from a modal: the swipe sheet closes, then the deposit screen opens. */ fun presentDeposit() { + dismissSheet() viewModelScope.launch { payer.depositRoute()?.let { effects.send(Effect.OpenScreen(it)) } } @@ -296,24 +317,25 @@ internal class RequestDetailsViewModel @Inject constructor( } } - private fun overBalance(amount: Fiat, token: Token): Boolean { - val rate = exchange.rateFor(amount.currencyCode) ?: return false - val balance = tokenCoordinator.balanceForToken(token).convertingTo(rate) - val over = amount.valueGreaterThan(balance) - if (over) payer.presentInsufficientBalance(onAddMoney = ::presentDeposit) - return over - } + private enum class PayBlock { OverBalance, OverSendLimit } - private fun overSendLimit(amount: Fiat): Boolean { - val limit = transactionController.limits.value?.sendLimitFor(amount.currencyCode) ?: return false - val over = amount.toDouble() > limit.nextTransaction - if (over) { - BottomBarManager.showAlert( - resources.getString(R.string.error_title_sendLimitReached), - resources.getString(R.string.error_description_sendLimitReached), - ) + /** Why [amount] can't be paid from [token] right now, or null when it can. Presents nothing. */ + private fun payBlock(amount: Fiat, token: Token): PayBlock? { + exchange.rateFor(amount.currencyCode)?.let { rate -> + val balance = tokenCoordinator.balanceForToken(token).convertingTo(rate) + if (amount.valueGreaterThan(balance)) return PayBlock.OverBalance } - return over + val limit = transactionController.limits.value?.sendLimitFor(amount.currencyCode) + if (limit != null && amount.toDouble() > limit.nextTransaction) return PayBlock.OverSendLimit + return null + } + + private fun present(block: PayBlock) = when (block) { + PayBlock.OverBalance -> payer.presentInsufficientBalance(onAddMoney = ::presentDeposit) + PayBlock.OverSendLimit -> BottomBarManager.showAlert( + resources.getString(R.string.error_title_sendLimitReached), + resources.getString(R.string.error_description_sendLimitReached), + ) } private fun presentClosed() { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt index 71ac8d3c69..9b42cf5fc6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt @@ -1,5 +1,9 @@ package com.flipcash.app.messenger.internal.screens.cash +import com.flipcash.app.messenger.internal.swipepay.SwipePayActions +import com.flipcash.app.messenger.internal.swipepay.SwipePaySheet +import com.flipcash.app.messenger.internal.swipepay.SwipePayUiState +import com.flipcash.app.messenger.internal.swipepay.rememberSwipePaySheetState import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -183,51 +187,24 @@ private fun MemberCheckRow( * 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. + * sheet of its own, which a navigation sheet would draw over this one. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun GroupSendConfirmSheet( groupSend: GroupSendState, recipients: List, - token: Token?, - sendProgress: LoadingSuccessState, - onConfirm: () -> Unit, - onDismiss: () -> Unit, + swipePay: SwipePayUiState, + actions: SwipePayActions, ) { - 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), - ) { + SwipePaySheet( + state = swipePay, + actions = actions, + sheetState = rememberSwipePaySheetState(locked = swipePay.locked), + header = { RecipientAvatars(recipients) val first = recipients.firstOrNull()?.let { @@ -241,7 +218,8 @@ internal fun GroupSendConfirmSheet( color = CodeTheme.colors.textMain, textAlign = TextAlign.Center, ) - + }, + amount = { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = total.formatted(), @@ -256,31 +234,8 @@ internal fun GroupSendConfirmSheet( ) } } - - 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. */ diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/swipepay/SwipePaySheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/swipepay/SwipePaySheet.kt new file mode 100644 index 0000000000..44bb9cba91 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/swipepay/SwipePaySheet.kt @@ -0,0 +1,180 @@ +package com.flipcash.app.messenger.internal.swipepay + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.flipcash.app.core.ui.TokenSelectionPill +import com.flipcash.app.messenger.internal.screens.boost.CurrencyPickerContent +import com.flipcash.features.messenger.R +import com.getcode.opencode.model.financial.Token +import com.getcode.navigation.scenes.InWindowBottomSheet +import com.getcode.navigation.scenes.InWindowSheetState +import com.getcode.navigation.scenes.rememberInWindowSheetState +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 +import com.getcode.theme.White10 +import com.getcode.ui.components.SlideToConfirm +import com.getcode.view.LoadingSuccessState + +/** + * What a swipe-to-pay sheet shows, apart from the lines that say who and how much. + * + * Every flow that ends in this sheet (pay a request, send to a group, send from Currency Info) + * builds one of these from its own state, so the sheet draws the same from any of them and a + * destination that owns the sheet can take it as its state unchanged. + */ +@Immutable +internal data class SwipePayUiState( + val token: Token?, + val progress: LoadingSuccessState, + /** True to show the currency picker on the pill; false for a currency fixed by the flow. */ + val tokenPickable: Boolean, + /** True when only back closes the sheet: no drag, no tap on the scrim. */ + val backOnly: Boolean = false, + val swipeTestTag: String? = null, +) { + /** A swipe is in flight or has finished; the sheet holds still until the flow lets go. */ + val locked: Boolean get() = !progress.isIdle +} + +/** What the sheet asks of the flow that owns it. */ +@Immutable +internal class SwipePayActions( + val onConfirm: () -> Unit, + val onDismiss: () -> Unit, +) + +@Stable +internal class SwipePaySheetState internal constructor( + val sheet: InWindowSheetState, + private val picking: MutableState, +) { + var pickingCurrency: Boolean + get() = picking.value + set(value) { picking.value = value } +} + +/** + * The sheet's state. Locked while a swipe is in flight, and while the currency list is up: a + * drag or back then returns to the confirm content rather than closing the sheet. + */ +@Composable +internal fun rememberSwipePaySheetState(locked: Boolean): SwipePaySheetState { + val picking = rememberSaveable { mutableStateOf(false) } + val lockedNow by rememberUpdatedState(locked) + val sheet = rememberInWindowSheetState { lockedNow || picking.value } + return SwipePaySheetState(sheet, picking) +} + +/** + * The swipe-to-pay sheet: [header] (who), [amount], the currency pill, and the swipe. + * + * Drawn in the host's own window, so a bottom bar the route raises lands above it. A token pick + * swaps the content for the currency list and back; the pick itself is the shared selected-token + * state, so the owning flow sees it through its token without a callback here. + */ +@Composable +internal fun SwipePaySheet( + state: SwipePayUiState, + actions: SwipePayActions, + sheetState: SwipePaySheetState, + modifier: Modifier = Modifier, + header: @Composable () -> Unit, + amount: @Composable () -> Unit, +) { + InWindowBottomSheet( + state = sheetState.sheet, + onDismissRequest = actions.onDismiss, + modifier = modifier, + dismissOnScrimTap = !state.backOnly, + gesturesEnabled = !state.backOnly, + ) { + BackHandler(enabled = sheetState.pickingCurrency) { sheetState.pickingCurrency = false } + if (sheetState.pickingCurrency) { + CurrencyPickerContent(onPicked = { sheetState.pickingCurrency = false }) + return@InWindowBottomSheet + } + val swipe: @Composable (Modifier) -> Unit = { extra -> + SlideToConfirm( + onConfirm = actions.onConfirm, + modifier = Modifier.fillMaxWidth().then(extra).let { + if (state.swipeTestTag != null) it.testTag(state.swipeTestTag) else it + }, + enabled = state.progress.isIdle, + isLoading = state.progress.loading, + isSuccess = state.progress.success, + label = stringResource(R.string.action_swipeToSend), + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x6, bottom = CodeTheme.dimens.grid.x4), + horizontalAlignment = Alignment.CenterHorizontally, + // Section gap 40 (node 11622:2232) when the currency is fixed. + verticalArrangement = Arrangement.spacedBy( + if (state.tokenPickable) CodeTheme.dimens.grid.x3 else CodeTheme.dimens.grid.x8 + ), + ) { + header() + if (state.tokenPickable) { + amount() + TokenSelectionPill( + token = state.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 (!state.locked) sheetState.pickingCurrency = true }, + ) + swipe(Modifier.padding(top = CodeTheme.dimens.grid.x3)) + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + amount() + // Node 11881:2468: 36 tall, 20 coin, SemiBold 17, 5% fill. No chevron. + TokenSelectionPill( + modifier = Modifier.height(CodeTheme.dimens.grid.x7), + token = state.token, + background = White05, + textStyle = CodeTheme.typography.textMedium.copy(fontSize = 17.sp), + imageSize = CodeTheme.dimens.staticGrid.x4, + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.grid.x2, + vertical = CodeTheme.dimens.grid.x1, + ), + showsPicker = false, + onClick = {}, + ) + } + swipe(Modifier) + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendInsufficientFundsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendInsufficientFundsTest.kt new file mode 100644 index 0000000000..683d7f52e4 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendInsufficientFundsTest.kt @@ -0,0 +1,134 @@ +package com.flipcash.app.messenger.internal.currencysend + +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.messenger.internal.payment.StartChattingPayer +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.payments.TipPaymentDelegate +import com.flipcash.shared.chat.ui.ConversationReference +import com.getcode.manager.BottomBarManager +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.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.Mint +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Next with more than the balance is stopped on the amount step. A send that fails after the swipe + * leaves the sheet up: it is drawn in the route's window, so the modal lands over it. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CurrencySendInsufficientFundsTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val exchange = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val transactionController = mockk(relaxed = true) + private val chatCoordinator = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val verifiedFiatCalculator = mockk(relaxed = true) + private val tipPaymentDelegate = mockk(relaxed = true) + private val analytics = mockk(relaxed = true) + private val payer = mockk(relaxed = true) + private val token = mockk(relaxed = true) + private val mint = mockk(relaxed = true) + + private var balance = Fiat(10.0) + + @Before + fun setUp() { + BottomBarManager.clear() + every { exchange.preferredRate } returns Rate.oneToOne + every { exchange.observePreferredRate() } returns emptyFlow() + every { tokenCoordinator.cachedToken(any()) } returns token + every { tokenCoordinator.balanceForToken(any()) } answers { balance } + every { tokenCoordinator.balanceForToken(any()) } returns flowOf(balance) + every { transactionController.limits } returns MutableStateFlow(null) + every { userManager.accountCluster } returns mockk(relaxed = true) + every { chatCoordinator.feed(*anyVararg()) } returns flowOf(emptyList()) + } + + @After + fun tearDown() { + BottomBarManager.clear() + } + + private fun viewModel() = CurrencySendViewModel( + exchange = exchange, + tokenCoordinator = tokenCoordinator, + transactionController = transactionController, + chatCoordinator = chatCoordinator, + userManager = userManager, + resources = resources, + verifiedFiatCalculator = verifiedFiatCalculator, + tipPaymentDelegate = tipPaymentDelegate, + analytics = analytics, + payer = payer, + ).also { it.start(mint) } + + private fun CurrencySendViewModel.type(vararg digits: Int) = digits.forEach(amountDelegate::onNumber) + + @Test + fun `Next over balance presents insufficient funds and stays on the amount`() = runTest { + val vm = viewModel() + vm.type(5, 0) // 50 against a balance of 10 + + vm.onAmountConfirmed() + + assertEquals(CurrencySendViewModel.Stage.Amount, vm.state.value.stage) + assertNull(vm.state.value.amount) + assertNull(vm.state.value.selected) + verify(exactly = 1) { payer.presentInsufficientBalance(any()) } + } + + @Test + fun `Next within balance moves on to the recipients`() = runTest { + val vm = viewModel() + vm.type(5) + + vm.onAmountConfirmed() + + assertEquals(CurrencySendViewModel.Stage.Recipients, vm.state.value.stage) + verify(exactly = 0) { payer.presentInsufficientBalance(any()) } + } + + @Test + fun `a send that fails after the swipe keeps the sheet open under the error`() = runTest { + val vm = viewModel() + vm.type(5) + vm.onAmountConfirmed() + val recipient = mockk(relaxed = true) { every { userId } returns listOf(7) } + vm.onRecipientTapped(recipient) + coEvery { verifiedFiatCalculator.compute(any(), any(), any(), any()) } returns + Result.failure(RuntimeException("stale")) + vm.send() + + assertEquals(recipient, vm.state.value.selected) + assertEquals(1, BottomBarManager.messages.value.size) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsInsufficientFundsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsInsufficientFundsTest.kt new file mode 100644 index 0000000000..fc03511017 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsInsufficientFundsTest.kt @@ -0,0 +1,156 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.messenger.internal.payment.StartChattingPayer +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.GroupRosters +import com.flipcash.shared.chat.PaymentRequests +import com.flipcash.shared.payments.request.PaymentRequestPaymentDelegate +import com.flipcash.shared.payments.request.ShareToPay +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +/** + * Paying a request over balance is stopped before the swipe sheet opens. Anything that fails after + * the swipe leaves the sheet up: it is drawn in the route's window, so the modal lands over it. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class RequestDetailsInsufficientFundsTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val payments = mockk(relaxed = true) + private val payer = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val exchange = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val rosters = mockk(relaxed = true) + private val transactionController = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val token = mockk(relaxed = true) + private val owner = mockk(relaxed = true) + + private var balance = Fiat(100.0) + private val ready = ShareToPay.Ready( + request = mockk(relaxed = true), + share = mockk(relaxed = true) { every { amount } returns Fiat(50.0) }, + intentId = mockk(relaxed = true), + ) + + @Before + fun setUp() { + BottomBarManager.clear() + every { userManager.accountId } returns listOf(1) + every { userManager.accountCluster } returns owner + every { owner.withTimelockForToken(any()) } returns owner + every { rosters.state } returns MutableStateFlow(emptyMap()) + every { payer.observeSelectedToken() } returns flowOf(token) + every { exchange.rateFor(any()) } returns Rate.oneToOne + every { tokenCoordinator.balanceForToken(any()) } answers { balance } + every { transactionController.limits } returns MutableStateFlow(null) + coEvery { payments.prepare(any(), any()) } returns Result.success(ready) + } + + // The view model polls the clock on a loop, so these tests run eagerly on the unconfined Main + // rather than under runTest, which would spin that loop through virtual time forever. + private val subscribers = CoroutineScope(Dispatchers.Unconfined) + + /** `token` only holds a value while something collects it, as the screen does. */ + private fun collectToken(vm: RequestDetailsViewModel) { + subscribers.launch { vm.token.collect {} } + } + + @After + fun tearDown() { + subscribers.cancel() + BottomBarManager.clear() + } + + private fun viewModel() = RequestDetailsViewModel( + requests = mockk(relaxed = true), + rosters = rosters, + payments = payments, + payer = payer, + tokenCoordinator = tokenCoordinator, + transactionController = transactionController, + exchange = exchange, + userManager = userManager, + resources = resources, + ).also { it.open(ChatId(ByteArray(1)), "2") } + + @Test + fun `over balance on Pay presents insufficient funds and never opens the swipe sheet`() { + balance = Fiat(10.0) + val vm = viewModel() + + vm.payTapped() + + assertEquals(RequestDetailsViewModel.Sheet.None, vm.state.value.sheet) + verify(exactly = 1) { payer.presentInsufficientBalance(any()) } + } + + @Test + fun `enough balance on Pay opens the swipe sheet without a modal`() { + val vm = viewModel() + + vm.payTapped() + + assertEquals(RequestDetailsViewModel.Sheet.Pay, vm.state.value.sheet) + verify(exactly = 0) { payer.presentInsufficientBalance(any()) } + } + + @Test + fun `balance dropping under the share after the sheet opened shows the modal over the open sheet`() { + val vm = viewModel() + collectToken(vm) + vm.payTapped() + assertEquals(RequestDetailsViewModel.Sheet.Pay, vm.state.value.sheet) + + balance = Fiat(10.0) + vm.confirmPay() + + assertEquals(RequestDetailsViewModel.Sheet.Pay, vm.state.value.sheet) + verify(exactly = 1) { payer.presentInsufficientBalance(any()) } + } + + @Test + fun `a failed payment keeps the sheet open under the error`() { + val vm = viewModel() + collectToken(vm) + vm.payTapped() + coEvery { payments.pay(any(), any(), any()) } returns Result.failure(RuntimeException("boom")) + // The refetch after a failure finds the share still payable. + coEvery { payments.prepare(any(), any()) } returns Result.success(ready) + + vm.confirmPay() + + assertEquals(RequestDetailsViewModel.Sheet.Pay, vm.state.value.sheet) + assertEquals(1, BottomBarManager.messages.value.size) + } +} diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/InWindowBottomSheet.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/InWindowBottomSheet.kt new file mode 100644 index 0000000000..7234527a52 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/InWindowBottomSheet.kt @@ -0,0 +1,145 @@ +package com.getcode.navigation.scenes + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.composeunstyled.BottomSheetState +import com.composeunstyled.Sheet +import com.composeunstyled.SheetDetent +import com.composeunstyled.UnstyledBottomSheet +import com.composeunstyled.rememberBottomSheetState +import com.getcode.theme.CodeTheme +import com.getcode.ui.core.noRippleClickable +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * State of an [InWindowBottomSheet]. Create with [rememberInWindowSheetState]. + * + * [isLocked] is read live: while it is true the sheet can't be closed by a drag, the scrim or back. + * [hide] ignores it, so a flow that locks the sheet for the length of a send can still take it + * down once the send is done. + */ +@Stable +class InWindowSheetState internal constructor( + internal val isLocked: () -> Boolean, +) { + internal lateinit var sheet: BottomSheetState + + /** True once [hide] has been asked for: the caller closed it, so no dismiss request follows. */ + internal var hidingByCaller by mutableStateOf(false) + private set + + /** Slides the sheet down and returns when it has settled. Does not call `onDismissRequest`. */ + suspend fun hide() { + hidingByCaller = true + sheet.animateTo(SheetDetent.Hidden) + } +} + +@Composable +fun rememberInWindowSheetState(isLocked: () -> Boolean = { false }): InWindowSheetState { + val locked = rememberUpdatedState(isLocked) + val state = remember { InWindowSheetState { locked.value() } } + state.sheet = rememberBottomSheetState( + initialDetent = SheetDetent.Hidden, + detents = listOf(SheetDetent.Hidden, WrapContent), + confirmDetentChange = { detent -> + detent != SheetDetent.Hidden || state.hidingByCaller || !state.isLocked() + }, + ) + return state +} + +/** + * A bottom sheet drawn inside the composition that hosts it, not in a window of its own. + * + * Material's `ModalBottomSheet` is a separate window, so anything the Activity window draws, such + * as a bottom bar, is covered by it. This one is an ordinary layer of the host's content: fill + * the route with it (it takes the whole parent), and what the route's entry decorator draws after + * the content, the bar included, lands above it. + * + * Opens to its content height, capped at the same fraction the navigation sheets use. A drag + * down past the threshold, a tap on the scrim ([dismissOnScrimTap]) or back calls + * [onDismissRequest]; none of them does anything while the state is locked. + */ +@Composable +fun InWindowBottomSheet( + state: InWindowSheetState, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + containerColor: Color = CodeTheme.colors.background, + dismissOnScrimTap: Boolean = true, + gesturesEnabled: Boolean = true, + content: @Composable () -> Unit, +) { + val sheet = state.sheet + val scope = rememberCoroutineScope() + val currentOnDismiss by rememberUpdatedState(onDismissRequest) + + LaunchedEffect(sheet) { sheet.animateTo(WrapContent) } + + // [UnstyledBottomSheet] has no dismissal callback: watch for it settling Hidden after it was + // shown, the way the navigation sheets do, and ignore a close the caller asked for itself. + LaunchedEffect(sheet) { + snapshotFlow { sheet.targetDetent }.first { it != SheetDetent.Hidden } + snapshotFlow { sheet.isIdle && sheet.currentDetent == SheetDetent.Hidden }.first { it } + if (!state.hidingByCaller) currentOnDismiss() + } + + val requestClose: () -> Unit = { + if (!state.isLocked() && sheet.currentDetent != SheetDetent.Hidden) { + scope.launch { sheet.animateTo(SheetDetent.Hidden) } + } + } + // Always taken, locked or not: back mid-send must not fall through and pop the route. + BackHandler(enabled = true, onBack = requestClose) + + val scrimColor = CodeTheme.colors.scrim + Box(modifier = modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .drawBehind { + val progress = sheet.progress(SheetDetent.Hidden, WrapContent).coerceIn(0f, 1f) + drawRect(scrimColor.copy(alpha = scrimColor.alpha * progress)) + } + .then(if (dismissOnScrimTap) Modifier.noRippleClickable(onClick = requestClose) else { + // Still swallows touches, so nothing underneath is reachable while it is up. + Modifier.noRippleClickable { } + }), + ) + UnstyledBottomSheet( + state = sheet, + modifier = Modifier.fillMaxSize(), + enabled = gesturesEnabled && !state.isLocked(), + ) { + Sheet( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)) + .background(containerColor), + ) { + content() + } + } + } +} diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt index c792fd1e50..f4f906a8cc 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt @@ -79,7 +79,7 @@ private val Expanded = SheetDetent("expanded") { containerHeight, _ -> * to the content — so dropping `fillMaxHeight()` from the sheet body is not enough on its own. This * is the half of wrap-content that makes the sheet hug three lines instead of claiming the screen. */ -private val WrapContent = SheetDetent("wrap-content") { containerHeight, sheetHeight -> +internal val WrapContent = SheetDetent("wrap-content") { containerHeight, sheetHeight -> sheetHeight.coerceAtMost(containerHeight * 0.925f) } From 1ab42039b9df793e70b3343c57769dd12e03da9f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 15:09:26 -0400 Subject: [PATCH 3/4] fix(payments): open the request pay sheet without a balance pre-check The payer picks the token in the sheet, so the check runs after the swipe against that pick. CodeButton latches its size from the label only, so a button first composed while loading no longer clips its text. --- .../internal/request/RequestDetailsViewModel.kt | 13 ++++--------- .../request/RequestDetailsInsufficientFundsTest.kt | 6 +++--- .../main/kotlin/com/getcode/ui/theme/CodeButton.kt | 8 +++++++- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt index cfc4a4a997..e329b247b3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt @@ -185,15 +185,10 @@ internal class RequestDetailsViewModel @Inject constructor( _state.update { it.copy(preparing = false) } when (found) { is ShareToPay.Ready -> { - // Checked before the swipe sheet opens, so an empty wallet never reaches the swipe. - val token = token.value ?: payer.observeSelectedToken().first() - val blocked = payBlock(found.share.amount, token) - if (blocked != null) { - present(blocked) - } else { - ready = found - _state.update { it.copy(sheet = Sheet.Pay, payProgress = LoadingSuccessState()) } - } + // No balance check here: the payer picks the token in the sheet, and the + // check runs against that pick when they swipe. + ready = found + _state.update { it.copy(sheet = Sheet.Pay, payProgress = LoadingSuccessState()) } } is ShareToPay.NothingToPay -> _state.update { it.copy(hasPaymentIntent = false) } is ShareToPay.Closed -> presentClosed() diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsInsufficientFundsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsInsufficientFundsTest.kt index fc03511017..3a8cbd456f 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsInsufficientFundsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsInsufficientFundsTest.kt @@ -105,14 +105,14 @@ class RequestDetailsInsufficientFundsTest { ).also { it.open(ChatId(ByteArray(1)), "2") } @Test - fun `over balance on Pay presents insufficient funds and never opens the swipe sheet`() { + fun `Pay opens the swipe sheet even when the selected token is short, since the token is picked in the sheet`() { balance = Fiat(10.0) val vm = viewModel() vm.payTapped() - assertEquals(RequestDetailsViewModel.Sheet.None, vm.state.value.sheet) - verify(exactly = 1) { payer.presentInsufficientBalance(any()) } + assertEquals(RequestDetailsViewModel.Sheet.Pay, vm.state.value.sheet) + verify(exactly = 0) { payer.presentInsufficientBalance(any()) } } @Test diff --git a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeButton.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeButton.kt index 9eb8093356..e99c10e82c 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeButton.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeButton.kt @@ -356,7 +356,13 @@ fun CodeButton( Box( modifier = Modifier .addIf(size.isSpecified) { Modifier.size(size) } - .addIf(size.isUnspecified) { Modifier.measured { if (it.width > 0.dp && it.height > 0.dp) size = it } } + // Latch the size from the label only: a button first composed while loading would + // otherwise freeze at the spinner's size and clip its text once loading ends. + .addIf(size.isUnspecified) { + Modifier.measured { + if (contentState == ButtonContentState.Content && it.width > 0.dp && it.height > 0.dp) size = it + } + } .width(IntrinsicSize.Max) .height(IntrinsicSize.Min) ) { From 52c18eca03e4080b03ae9a1ed6cb23aecf9ae2cf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 15:17:57 -0400 Subject: [PATCH 4/4] feat(requests): pay from the deck over the chat, not through Request details Extract the pay logic into RequestPayFlow, shared by Request details and a new chat-side RequestPayViewModel. The deck's Pay now opens the swipe sheet in the chat screen; the card body still opens details. Drops the pay flag on ChatStep.RequestDetails and payOnOpen. --- .../com/flipcash/app/core/chat/ChatStep.kt | 2 +- .../flipcash/app/messenger/ChatFlowScreen.kt | 1 - .../internal/request/RequestDetailsScreen.kt | 77 ++----- .../request/RequestDetailsViewModel.kt | 173 ++------------- .../internal/request/RequestPayFlow.kt | 207 ++++++++++++++++++ .../internal/request/RequestPaySheet.kt | 150 +++++++++++++ .../internal/request/RequestPayViewModel.kt | 80 +++++++ .../internal/screens/MessengerScreen.kt | 23 +- .../RequestDetailsInsufficientFundsTest.kt | 15 +- .../internal/request/RequestPayFlowTest.kt | 143 ++++++++++++ docs/swipe-sheet-destinations.md | 6 +- 11 files changed, 648 insertions(+), 229 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPayFlow.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPaySheet.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPayViewModel.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestPayFlowTest.kt 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 3c8f6c8ad5..ef38496c65 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 @@ -207,7 +207,7 @@ sealed interface ChatStep : FlowStep, Parcelable { */ @Parcelize @Serializable - data class RequestDetails(val chatId: ChatId, val requestId: String, val pay: Boolean = false) : + data class RequestDetails(val chatId: ChatId, val requestId: String) : ChatStep, Sheet, FullscreenSheet 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 e9cab21ba0..e1e5f6062b 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 @@ -271,7 +271,6 @@ private fun chatEntryProvider( RequestDetailsScreen( chatId = step.chatId, requestId = step.requestId, - payOnOpen = step.pay, onBack = { dismissSheet() }, onOpenScreen = { route -> navigator.navigate(route) }, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt index ab960d39c6..a3f2716781 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt @@ -87,12 +87,10 @@ internal fun RequestDetailsScreen( onBack: () -> Unit, onOpenScreen: (AppRoute) -> Unit, pushed: Boolean = false, - payOnOpen: Boolean = false, ) { val viewModel = hiltViewModel() LaunchedEffect(chatId, requestId) { viewModel.open(chatId, requestId) - if (payOnOpen) viewModel.payOnOpen() } // Fetch on open and every 45 s while the screen is visible with the app in the foreground. @@ -112,6 +110,7 @@ internal fun RequestDetailsScreen( val request by viewModel.request.collectAsStateWithLifecycle() val members by viewModel.members.collectAsStateWithLifecycle() val token by viewModel.token.collectAsStateWithLifecycle() + val pay by viewModel.pay.collectAsStateWithLifecycle() val now by viewModel.now.collectAsStateWithLifecycle() val viewerId = viewModel.viewerId @@ -165,7 +164,7 @@ internal fun RequestDetailsScreen( .testTag("request_details_send"), text = stringResource(R.string.action_sendShare, share.amount.requestText()), buttonState = ButtonState.Filled, - isLoading = state.preparing, + isLoading = pay.preparing, onClick = viewModel::payTapped, ) } @@ -184,24 +183,17 @@ internal fun RequestDetailsScreen( val unnamed = stringResource(R.string.title_unnamedUser) val name = { userId: ID -> memberName(members[userId], unnamed) } + RequestPaySheet( + pay = pay, + request = current, + members = members, + viewerId = viewerId, + now = now, + token = token, + actions = viewModel.swipePayActions, + ) when (val sheet = state.sheet) { RequestDetailsViewModel.Sheet.None -> Unit - RequestDetailsViewModel.Sheet.Pay -> { - val share = current?.let { unpaidShareOf(it, viewerId, now) } - if (current != null && share != null) { - PayShareSheet( - creator = members[current.creator], - share = share, - swipePay = SwipePayUiState( - token = token, - progress = state.payProgress, - tokenPickable = true, - swipeTestTag = "request_pay_swipe", - ), - actions = viewModel.swipePayActions, - ) - } - } RequestDetailsViewModel.Sheet.Options -> ActionSheet( title = null, body = null, @@ -370,49 +362,6 @@ private fun ShareRow( } } -/** - * Frame 10: who is being paid, the share, the token, and the swipe. The token pill swaps this - * sheet's content for the currency list, as on the group send confirm sheet. - */ -@Composable -private fun PayShareSheet( - creator: ChatMember?, - share: Share, - swipePay: SwipePayUiState, - actions: SwipePayActions, -) { - SwipePaySheet( - state = swipePay, - actions = actions, - sheetState = rememberSwipePaySheetState(locked = swipePay.locked), - header = { - MemberAvatar(creator, Modifier.requiredSize(CodeTheme.dimens.staticGrid.x10)) - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = memberName(creator), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - textAlign = TextAlign.Center, - ) - creator?.userProfile?.username?.takeIf { it.isNotBlank() }?.let { username -> - Text( - text = "@$username", - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - } - } - }, - amount = { - Text( - text = share.amount.requestText(), - style = CodeTheme.typography.displayMedium, - color = CodeTheme.colors.textMain, - ) - }, - ) -} - /** Frames 28, 29, 33 and 34: an optional title and body, one action, and Cancel. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -467,7 +416,7 @@ private fun ActionSheet( } @Composable -private fun memberName(member: ChatMember?): String = +internal fun memberName(member: ChatMember?): String = memberName(member, stringResource(R.string.title_unnamedUser)) private fun memberName(member: ChatMember?, fallback: String): String = @@ -475,7 +424,7 @@ private fun memberName(member: ChatMember?, fallback: String): String = /** A member's avatar, or an empty circle of the same size while the roster loads. */ @Composable -private fun MemberAvatar(member: ChatMember?, modifier: Modifier) { +internal fun MemberAvatar(member: ChatMember?, modifier: Modifier) { if (member != null) { ContactAvatar(userProfile = member.userProfile, modifier = modifier.clip(CircleShape)) } else { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt index e329b247b3..ba50c9372f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt @@ -56,8 +56,7 @@ import kotlin.time.Instant * Request details, frames 09–11, 24 and 35 of the group requests spec: one request, the viewer's * share to pay, and the creator's close and mark-paid actions. * - * The share's payment intent id lives in [ready] only, for as long as the swipe sheet is up. It - * is never put in [State], so it can't reach a log through the state's toString. + * Paying the viewer's share is [RequestPayFlow]; this keeps the creator's sheets and the screen's data. */ @HiltViewModel internal class RequestDetailsViewModel @Inject constructor( @@ -74,8 +73,6 @@ internal class RequestDetailsViewModel @Inject constructor( sealed interface Sheet { data object None : Sheet - /** Frame 10: swipe to pay the viewer's share. */ - data object Pay : Sheet /** Frame 33: the creator's "•••". */ data object Options : Sheet /** Frame 34. */ @@ -90,9 +87,6 @@ internal class RequestDetailsViewModel @Inject constructor( val chatId: ChatId? = null, val requestId: ID? = null, val sheet: Sheet = Sheet.None, - /** Fetching the request before the swipe sheet opens: the Send button spins. */ - val preparing: Boolean = false, - val payProgress: LoadingSuccessState = LoadingSuccessState(), /** A close or mark-paid call is out. */ val working: Boolean = false, /** @@ -128,11 +122,7 @@ internal class RequestDetailsViewModel @Inject constructor( all[s.chatId].orEmpty().associateBy { it.userId } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) - val token: StateFlow = payer.observeSelectedToken() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - - /** The share being paid, with its intent id. Only set while [Sheet.Pay] is up. */ - private var ready: ShareToPay.Ready? = null + val token: StateFlow get() = payFlow.token init { viewModelScope.launch { @@ -164,94 +154,31 @@ internal class RequestDetailsViewModel @Inject constructor( } } - private var paidOnOpen = false + /** The pay flow: the sheet and everything the viewer's share needs, shared with the chat's deck Pay. */ + private val payFlow = RequestPayFlow( + scope = viewModelScope, + payments = payments, + payer = payer, + tokenCoordinator = tokenCoordinator, + transactionController = transactionController, + exchange = exchange, + userManager = userManager, + resources = resources, + openScreen = { route -> effects.trySend(Effect.OpenScreen(route)) }, + onNothingToPay = { _state.update { it.copy(hasPaymentIntent = false) } }, + ) - /** The deck's Pay button: [payTapped] once, as the screen opens, and not again on its return. */ - fun payOnOpen() { - if (paidOnOpen) return - paidOnOpen = true - payTapped() - } + val pay: StateFlow = payFlow.state + val swipePayActions: SwipePayActions = payFlow.actions - /** "Send ": refetch for the intent id, then show the swipe sheet if there is still a share to pay. */ + /** "Send ". */ fun payTapped() { - val id = _state.value.requestId ?: return - val viewer = viewerId ?: return - if (_state.value.preparing) return - _state.update { it.copy(preparing = true) } - viewModelScope.launch { - payments.prepare(id, viewer) - .onSuccess { found -> - _state.update { it.copy(preparing = false) } - when (found) { - is ShareToPay.Ready -> { - // No balance check here: the payer picks the token in the sheet, and the - // check runs against that pick when they swipe. - ready = found - _state.update { it.copy(sheet = Sheet.Pay, payProgress = LoadingSuccessState()) } - } - is ShareToPay.NothingToPay -> _state.update { it.copy(hasPaymentIntent = false) } - is ShareToPay.Closed -> presentClosed() - } - } - .onFailure { - _state.update { it.copy(preparing = false) } - presentSendFailed() - } - } + _state.value.requestId?.let(payFlow::start) } - /** - * Everything the pay sheet asks of this flow, in one place: a later destination that owns the - * sheet can take this object, and the [SwipePayUiState] built from [state], as they are. - */ - val swipePayActions = SwipePayActions(onConfirm = ::confirmPay, onDismiss = ::dismissSheet) - - /** The swipe on frame 10. A second swipe after a failure pays under the same intent id. */ - fun confirmPay() { - val pending = ready ?: return - val token = token.value ?: return - val owner = userManager.accountCluster ?: return - if (!_state.value.payProgress.isIdle) return - // The token or the balance can change while the sheet is up; the modal shows over it. - payBlock(pending.share.amount, token)?.let { blocked -> - present(blocked) - return - } - - _state.update { it.copy(payProgress = LoadingSuccessState(loading = true)) } - viewModelScope.launch { - payments.pay(pending, token, owner.withTimelockForToken(token)) - .onSuccess { - holdPaymentSuccess( - show = { _state.update { it.copy(payProgress = LoadingSuccessState(success = true)) } }, - release = { dismissSheet() }, - ) - } - .onFailure { cause -> afterFailedPayment(pending, cause) } - } - } + fun confirmPay() = payFlow.confirm() - /** - * The outcome is unknown, so refetch before anything else: a share that landed after all is - * paid, and a request closed meanwhile is "Request Closed". - */ - private suspend fun afterFailedPayment(pending: ShareToPay.Ready, cause: Throwable) { - _state.update { it.copy(payProgress = LoadingSuccessState()) } - val viewer = viewerId ?: return - when (val found = payments.prepare(pending.request.id, viewer).getOrNull()) { - is ShareToPay.Closed -> { - dismissSheet() - presentClosed() - } - is ShareToPay.NothingToPay -> dismissSheet() - is ShareToPay.Ready -> { - ready = found - presentPaymentError(cause) - } - null -> presentPaymentError(cause) - } - } + fun presentDeposit() = payFlow.presentDeposit() fun showOptions() = setSheet(Sheet.Options) @@ -277,18 +204,8 @@ internal class RequestDetailsViewModel @Inject constructor( } fun dismissSheet() { - if (!_state.value.payProgress.isIdle && !_state.value.payProgress.success) return if (_state.value.working) return - ready = null - _state.update { it.copy(sheet = Sheet.None, payProgress = LoadingSuccessState()) } - } - - /** "Add Money" from a modal: the swipe sheet closes, then the deposit screen opens. */ - fun presentDeposit() { - dismissSheet() - viewModelScope.launch { - payer.depositRoute()?.let { effects.send(Effect.OpenScreen(it)) } - } + _state.update { it.copy(sheet = Sheet.None) } } private fun setSheet(sheet: Sheet) { @@ -312,52 +229,6 @@ internal class RequestDetailsViewModel @Inject constructor( } } - private enum class PayBlock { OverBalance, OverSendLimit } - - /** Why [amount] can't be paid from [token] right now, or null when it can. Presents nothing. */ - private fun payBlock(amount: Fiat, token: Token): PayBlock? { - exchange.rateFor(amount.currencyCode)?.let { rate -> - val balance = tokenCoordinator.balanceForToken(token).convertingTo(rate) - if (amount.valueGreaterThan(balance)) return PayBlock.OverBalance - } - val limit = transactionController.limits.value?.sendLimitFor(amount.currencyCode) - if (limit != null && amount.toDouble() > limit.nextTransaction) return PayBlock.OverSendLimit - return null - } - - private fun present(block: PayBlock) = when (block) { - PayBlock.OverBalance -> payer.presentInsufficientBalance(onAddMoney = ::presentDeposit) - PayBlock.OverSendLimit -> BottomBarManager.showAlert( - resources.getString(R.string.error_title_sendLimitReached), - resources.getString(R.string.error_description_sendLimitReached), - ) - } - - private fun presentClosed() { - BottomBarManager.showAlert( - resources.getString(R.string.error_title_requestClosed), - resources.getString(R.string.error_description_requestClosed), - ) - } - - private fun presentSendFailed() { - BottomBarManager.showError( - title = resources.getString(R.string.error_title_cashFailedToSend), - message = resources.getString(R.string.error_description_cashFailedToSend), - ) - } - - private fun presentPaymentError(cause: Throwable) { - 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)) - } } /** Whether the viewer, as creator, can mark [userId]'s share paid: open request, unpaid, not their own. */ diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPayFlow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPayFlow.kt new file mode 100644 index 0000000000..e2f1356e8b --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPayFlow.kt @@ -0,0 +1,207 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.app.core.AppRoute +import com.flipcash.app.messenger.internal.payment.StartChattingPayer +import com.flipcash.app.messenger.internal.swipepay.SwipePayActions +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.features.messenger.R +import com.flipcash.services.user.UserManager +import com.flipcash.shared.payments.request.PaymentRequestPaymentDelegate +import com.flipcash.shared.payments.request.ShareToPay +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +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.Token +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.LoadingSuccessState +import com.getcode.view.holdPaymentSuccess +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Paying the viewer's share of one payment request: refetch for the intent id, the swipe sheet, + * the post-swipe balance and limit check against the token picked in the sheet, and every outcome + * that follows. Shared by Request details and the chat, which each own an instance on their own + * scope and draw the sheet from [state]. + * + * The share's payment intent id lives in [ready] only, for as long as the sheet is up. It is never + * put in [State], so it can't reach a log through the state's toString. + */ +internal class RequestPayFlow( + private val scope: CoroutineScope, + private val payments: PaymentRequestPaymentDelegate, + private val payer: StartChattingPayer, + private val tokenCoordinator: TokenCoordinator, + private val transactionController: TransactionController, + private val exchange: Exchange, + private val userManager: UserManager, + private val resources: ResourceHelper, + /** Where "Add Money" goes, once the sheet has closed. */ + private val openScreen: (AppRoute) -> Unit, + /** The server has no payment intent for the viewer on this request any more. */ + private val onNothingToPay: () -> Unit = {}, +) { + data class State( + val requestId: ID? = null, + /** Refetching before the sheet opens: the caller's Send button spins. */ + val preparing: Boolean = false, + /** The swipe sheet is up. */ + val open: Boolean = false, + val progress: LoadingSuccessState = LoadingSuccessState(), + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + val token: StateFlow = payer.observeSelectedToken() + .stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) + + private var ready: ShareToPay.Ready? = null + + private val viewerId: ID? get() = userManager.accountId + + /** Everything the sheet asks of this flow, in one place. */ + val actions = SwipePayActions(onConfirm = ::confirm, onDismiss = ::dismiss) + + /** "Send " / the deck's Pay: refetch for the intent id, then show the sheet if there is still a share to pay. */ + fun start(requestId: ID) { + val viewer = viewerId ?: return + if (_state.value.preparing || _state.value.open) return + _state.update { it.copy(requestId = requestId, preparing = true) } + scope.launch { + payments.prepare(requestId, viewer) + .onSuccess { found -> + _state.update { it.copy(preparing = false) } + when (found) { + is ShareToPay.Ready -> { + // No balance check here: the payer picks the token in the sheet, and the + // check runs against that pick when they swipe. + ready = found + _state.update { it.copy(open = true, progress = LoadingSuccessState()) } + } + is ShareToPay.NothingToPay -> onNothingToPay() + is ShareToPay.Closed -> presentClosed() + } + } + .onFailure { + _state.update { it.copy(preparing = false) } + presentSendFailed() + } + } + } + + /** The swipe. A second swipe after a failure pays under the same intent id. */ + fun confirm() { + val pending = ready ?: return + val token = token.value ?: return + val owner = userManager.accountCluster ?: return + if (!_state.value.progress.isIdle) return + // The token or the balance can change while the sheet is up; the modal shows over it. + payBlock(pending.share.amount, token)?.let { blocked -> + present(blocked) + return + } + + _state.update { it.copy(progress = LoadingSuccessState(loading = true)) } + scope.launch { + payments.pay(pending, token, owner.withTimelockForToken(token)) + .onSuccess { + holdPaymentSuccess( + show = { _state.update { it.copy(progress = LoadingSuccessState(success = true)) } }, + release = { dismiss() }, + ) + } + .onFailure { cause -> afterFailedPayment(pending, cause) } + } + } + + /** + * The outcome is unknown, so refetch before anything else: a share that landed after all is + * paid, and a request closed meanwhile is "Request Closed". + */ + private suspend fun afterFailedPayment(pending: ShareToPay.Ready, cause: Throwable) { + _state.update { it.copy(progress = LoadingSuccessState()) } + val viewer = viewerId ?: return + when (val found = payments.prepare(pending.request.id, viewer).getOrNull()) { + is ShareToPay.Closed -> { + dismiss() + presentClosed() + } + is ShareToPay.NothingToPay -> dismiss() + is ShareToPay.Ready -> { + ready = found + presentPaymentError(cause) + } + null -> presentPaymentError(cause) + } + } + + fun dismiss() { + if (!_state.value.progress.isIdle && !_state.value.progress.success) return + ready = null + _state.update { it.copy(open = false, progress = LoadingSuccessState()) } + } + + /** "Add Money" from a modal: the sheet closes, then the deposit screen opens. */ + fun presentDeposit() { + dismiss() + scope.launch { payer.depositRoute()?.let(openScreen) } + } + + private enum class PayBlock { OverBalance, OverSendLimit } + + /** Why [amount] can't be paid from [token] right now, or null when it can. Presents nothing. */ + private fun payBlock(amount: Fiat, token: Token): PayBlock? { + exchange.rateFor(amount.currencyCode)?.let { rate -> + val balance = tokenCoordinator.balanceForToken(token).convertingTo(rate) + if (amount.valueGreaterThan(balance)) return PayBlock.OverBalance + } + val limit = transactionController.limits.value?.sendLimitFor(amount.currencyCode) + if (limit != null && amount.toDouble() > limit.nextTransaction) return PayBlock.OverSendLimit + return null + } + + private fun present(block: PayBlock) = when (block) { + PayBlock.OverBalance -> payer.presentInsufficientBalance(onAddMoney = ::presentDeposit) + PayBlock.OverSendLimit -> BottomBarManager.showAlert( + resources.getString(R.string.error_title_sendLimitReached), + resources.getString(R.string.error_description_sendLimitReached), + ) + } + + private fun presentClosed() { + BottomBarManager.showAlert( + resources.getString(R.string.error_title_requestClosed), + resources.getString(R.string.error_description_requestClosed), + ) + } + + private fun presentSendFailed() { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_cashFailedToSend), + message = resources.getString(R.string.error_description_cashFailedToSend), + ) + } + + private fun presentPaymentError(cause: Throwable) { + 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)) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPaySheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPaySheet.kt new file mode 100644 index 0000000000..d0766fd7a0 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPaySheet.kt @@ -0,0 +1,150 @@ +package com.flipcash.app.messenger.internal.request + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import com.flipcash.shared.chat.ui.ActionCapsuleButton +import com.flipcash.shared.chat.ui.ActionCapsule +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.ui.TokenSelectionPill +import com.flipcash.app.messenger.internal.screens.boost.CurrencyPickerContent +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.paymentrequest.PaymentRequest +import com.flipcash.services.models.paymentrequest.Share +import com.flipcash.services.models.paymentrequest.statusLine +import com.flipcash.shared.chat.ui.request.RequestProgressBar +import com.flipcash.shared.chat.ui.request.progressText +import com.flipcash.shared.chat.ui.request.requestText +import com.flipcash.shared.chat.ui.request.text +import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.profile.chatterName +import com.getcode.opencode.model.core.ID +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.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import com.flipcash.app.messenger.internal.swipepay.SwipePayActions +import com.flipcash.app.messenger.internal.swipepay.SwipePaySheet +import com.flipcash.app.messenger.internal.swipepay.SwipePayUiState +import com.flipcash.app.messenger.internal.swipepay.rememberSwipePaySheetState +import kotlin.time.Instant + +/** + * The swipe-to-pay sheet for the viewer's unpaid share of [request], drawn while [pay] is open. + * In-window, so the entry decorator's bar lands above it. Shared by Request details and the chat. + */ +@Composable +internal fun RequestPaySheet( + pay: RequestPayFlow.State, + request: PaymentRequest?, + members: Map, + viewerId: ID?, + now: Instant, + token: Token?, + actions: SwipePayActions, +) { + if (!pay.open || request == null) return + val share = unpaidShareOf(request, viewerId, now) ?: return + PayShareSheet( + creator = members[request.creator], + share = share, + swipePay = SwipePayUiState( + token = token, + progress = pay.progress, + tokenPickable = true, + swipeTestTag = "request_pay_swipe", + ), + actions = actions, + ) +} + +/** + * Frame 10: who is being paid, the share, the token, and the swipe. The token pill swaps this + * sheet's content for the currency list, as on the group send confirm sheet. + */ +@Composable +private fun PayShareSheet( + creator: ChatMember?, + share: Share, + swipePay: SwipePayUiState, + actions: SwipePayActions, +) { + SwipePaySheet( + state = swipePay, + actions = actions, + sheetState = rememberSwipePaySheetState(locked = swipePay.locked), + header = { + MemberAvatar(creator, Modifier.requiredSize(CodeTheme.dimens.staticGrid.x10)) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = memberName(creator), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + creator?.userProfile?.username?.takeIf { it.isNotBlank() }?.let { username -> + Text( + text = "@$username", + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } + }, + amount = { + Text( + text = share.amount.requestText(), + style = CodeTheme.typography.displayMedium, + color = CodeTheme.colors.textMain, + ) + }, + ) +} + diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPayViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPayViewModel.kt new file mode 100644 index 0000000000..98cfbcb0f8 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPayViewModel.kt @@ -0,0 +1,80 @@ +package com.flipcash.app.messenger.internal.request + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.AppRoute +import com.flipcash.app.messenger.internal.payment.StartChattingPayer +import com.flipcash.app.messenger.internal.swipepay.SwipePayActions +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.GroupRosters +import com.flipcash.shared.payments.request.PaymentRequestPaymentDelegate +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Token +import com.getcode.util.resources.ResourceHelper +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The chat's side of paying a request: the deck's Pay opens the swipe sheet over the conversation, + * without going through Request details. The logic is [RequestPayFlow], the same one details uses. + */ +@HiltViewModel +internal class RequestPayViewModel @Inject constructor( + private val rosters: GroupRosters, + payments: PaymentRequestPaymentDelegate, + payer: StartChattingPayer, + tokenCoordinator: TokenCoordinator, + transactionController: TransactionController, + exchange: Exchange, + userManager: UserManager, + resources: ResourceHelper, +) : ViewModel() { + + private val effects = Channel(Channel.BUFFERED) + + /** Routes to open once the sheet has closed, i.e. Add Money. */ + val routes: Flow = effects.receiveAsFlow() + + private val flow = RequestPayFlow( + scope = viewModelScope, + payments = payments, + payer = payer, + tokenCoordinator = tokenCoordinator, + transactionController = transactionController, + exchange = exchange, + userManager = userManager, + resources = resources, + openScreen = { route -> effects.trySend(route) }, + ) + + private val chatId = MutableStateFlow(null) + + val state: StateFlow = flow.state + val token: StateFlow get() = flow.token + val actions: SwipePayActions = flow.actions + + /** The chat's members by id, for the creator's name and avatar on the sheet. */ + val members: StateFlow> = combine(chatId, rosters.state) { id, all -> + all[id].orEmpty().associateBy { it.userId } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) + + fun pay(chatId: ChatId, requestId: ID) { + this.chatId.value = chatId + if (rosters.state.value[chatId] == null) viewModelScope.launch { rosters.load(chatId) } + flow.start(requestId) + } +} 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 309c2d26be..97fdeddc67 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 @@ -114,6 +114,8 @@ import com.flipcash.shared.chat.ui.request.LocalPaymentRequestCards import com.flipcash.services.models.chat.ChatType import com.getcode.utils.base58 import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.flipcash.app.messenger.internal.request.RequestPaySheet +import com.flipcash.app.messenger.internal.request.RequestPayViewModel @Composable internal fun MessengerScreen(viewModel: ChatViewModel) { @@ -491,6 +493,12 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } + val requestPay = hiltViewModel() + val requestPayState by requestPay.state.collectAsStateWithLifecycle() + val requestPayMembers by requestPay.members.collectAsStateWithLifecycle() + val requestPayToken by requestPay.token.collectAsStateWithLifecycle() + LaunchedEffect(requestPay) { requestPay.routes.collect { route -> navigator.navigate(route) } } + // The collapsed pill's last bounds, where the expanded deck puts its close button. var deckPill by remember { mutableStateOf(null) } @@ -541,11 +549,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { rest = deckHosts.rest.host, onExpand = expandDeck, onCycle = requestsViewModel::cycle, + // Straight to the swipe sheet over the chat; the card body opens details. onPay = { request -> state.chatId?.let { chatId -> - keyboard.hideIfVisible { - navigator.push(ChatStep.RequestDetails(chatId, request.id.base58, pay = true)) - } + keyboard.hideIfVisible { requestPay.pay(chatId, request.id) } } }, onHeightChange = { deckHosts.stripPx = it.toFloat() }, @@ -678,6 +685,16 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } + // In-window, over the conversation and the deck, so the bottom bar draws above it. + RequestPaySheet( + pay = requestPayState, + request = deck.firstOrNull { it.id == requestPayState.requestId }, + members = requestPayMembers, + viewerId = viewerId, + now = deckNow, + token = requestPayToken, + actions = requestPay.actions, + ) ExpandedRequests( deck = drawnDeck, now = deckNow, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsInsufficientFundsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsInsufficientFundsTest.kt index 3a8cbd456f..211a408307 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsInsufficientFundsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsInsufficientFundsTest.kt @@ -36,8 +36,9 @@ import org.junit.Test import kotlin.test.assertEquals /** - * Paying a request over balance is stopped before the swipe sheet opens. Anything that fails after - * the swipe leaves the sheet up: it is drawn in the route's window, so the modal lands over it. + * Request details' "Send" opens the swipe sheet with no balance check, since the token is picked + * in the sheet. Anything that fails after the swipe leaves the sheet up: it is drawn in the route, + * so the modal lands over it. */ @OptIn(ExperimentalCoroutinesApi::class) class RequestDetailsInsufficientFundsTest { @@ -111,7 +112,7 @@ class RequestDetailsInsufficientFundsTest { vm.payTapped() - assertEquals(RequestDetailsViewModel.Sheet.Pay, vm.state.value.sheet) + assertEquals(true, vm.pay.value.open) verify(exactly = 0) { payer.presentInsufficientBalance(any()) } } @@ -121,7 +122,7 @@ class RequestDetailsInsufficientFundsTest { vm.payTapped() - assertEquals(RequestDetailsViewModel.Sheet.Pay, vm.state.value.sheet) + assertEquals(true, vm.pay.value.open) verify(exactly = 0) { payer.presentInsufficientBalance(any()) } } @@ -130,12 +131,12 @@ class RequestDetailsInsufficientFundsTest { val vm = viewModel() collectToken(vm) vm.payTapped() - assertEquals(RequestDetailsViewModel.Sheet.Pay, vm.state.value.sheet) + assertEquals(true, vm.pay.value.open) balance = Fiat(10.0) vm.confirmPay() - assertEquals(RequestDetailsViewModel.Sheet.Pay, vm.state.value.sheet) + assertEquals(true, vm.pay.value.open) verify(exactly = 1) { payer.presentInsufficientBalance(any()) } } @@ -150,7 +151,7 @@ class RequestDetailsInsufficientFundsTest { vm.confirmPay() - assertEquals(RequestDetailsViewModel.Sheet.Pay, vm.state.value.sheet) + assertEquals(true, vm.pay.value.open) assertEquals(1, BottomBarManager.messages.value.size) } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestPayFlowTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestPayFlowTest.kt new file mode 100644 index 0000000000..2244f31248 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestPayFlowTest.kt @@ -0,0 +1,143 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.messenger.internal.payment.StartChattingPayer +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.GroupRosters +import com.flipcash.shared.chat.PaymentRequests +import com.flipcash.shared.payments.request.PaymentRequestPaymentDelegate +import com.flipcash.shared.payments.request.ShareToPay +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +/** The pay unit on its own: what the chat's deck Pay runs, with no Request details around it. */ +@OptIn(ExperimentalCoroutinesApi::class) +class RequestPayFlowTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val payments = mockk(relaxed = true) + private val payer = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val exchange = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val transactionController = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val token = mockk(relaxed = true) + private val owner = mockk(relaxed = true) + private val scope = CoroutineScope(Dispatchers.Unconfined) + + private var balance = Fiat(100.0) + private var nothingToPay = 0 + private val ready = ShareToPay.Ready( + request = mockk(relaxed = true), + share = mockk(relaxed = true) { every { amount } returns Fiat(50.0) }, + intentId = mockk(relaxed = true), + ) + + @Before + fun setUp() { + BottomBarManager.clear() + every { userManager.accountId } returns listOf(1) + every { userManager.accountCluster } returns owner + every { owner.withTimelockForToken(any()) } returns owner + every { payer.observeSelectedToken() } returns flowOf(token) + every { exchange.rateFor(any()) } returns Rate.oneToOne + every { tokenCoordinator.balanceForToken(any()) } answers { balance } + every { transactionController.limits } returns MutableStateFlow(null) + coEvery { payments.prepare(any(), any()) } returns Result.success(ready) + } + + @After + fun tearDown() { + scope.cancel() + BottomBarManager.clear() + } + + private fun flow() = RequestPayFlow( + scope = scope, + payments = payments, + payer = payer, + tokenCoordinator = tokenCoordinator, + transactionController = transactionController, + exchange = exchange, + userManager = userManager, + resources = resources, + openScreen = {}, + onNothingToPay = { nothingToPay++ }, + ).also { f -> scope.launch { f.token.collect {} } } + + @Test + fun `a ready share opens the sheet even when the selected token is short`() { + balance = Fiat(10.0) + val f = flow() + + f.start(listOf(2)) + + assertEquals(true, f.state.value.open) + verify(exactly = 0) { payer.presentInsufficientBalance(any()) } + } + + @Test + fun `insufficient funds after the swipe presents the modal and keeps the sheet up`() { + val f = flow() + f.start(listOf(2)) + balance = Fiat(10.0) + + f.confirm() + + assertEquals(true, f.state.value.open) + verify(exactly = 1) { payer.presentInsufficientBalance(any()) } + coVerify(exactly = 0) { payments.pay(any(), any(), any()) } + } + + @Test + fun `a closed request presents the closed alert and never opens the sheet`() { + coEvery { payments.prepare(any(), any()) } returns Result.success(ShareToPay.Closed(mockk(relaxed = true))) + val f = flow() + + f.start(listOf(2)) + + assertEquals(false, f.state.value.open) + assertEquals(1, BottomBarManager.messages.value.size) + } + + @Test + fun `nothing to pay reports it and never opens the sheet`() { + coEvery { payments.prepare(any(), any()) } returns Result.success(ShareToPay.NothingToPay(mockk(relaxed = true))) + val f = flow() + + f.start(listOf(2)) + + assertEquals(false, f.state.value.open) + assertEquals(1, nothingToPay) + assertEquals(0, BottomBarManager.messages.value.size) + } +} diff --git a/docs/swipe-sheet-destinations.md b/docs/swipe-sheet-destinations.md index 5ffcffe215..db974cf26b 100644 --- a/docs/swipe-sheet-destinations.md +++ b/docs/swipe-sheet-destinations.md @@ -113,8 +113,10 @@ One flow per PR, each shippable on its own: 1. Currency send. No nested host, and its `finishSend` (hide, then pop) maps directly onto "pop after the checkmark". Introduces the destination pattern, the result key and the `HostsBars` metadata with its test. -2. Pay request. Moves `payTapped`, `confirmPay` and the `ready` share into the destination view - model; keeps `RequestDetailsViewModel` for the screen behind it. +2. Pay request. Partly done: `payTapped`, `confirmPay` and the `ready` share now live in + `RequestPayFlow`, which `RequestDetailsViewModel` and the chat's `RequestPayViewModel` each own + (the deck's Pay opens the sheet over the chat, not Request details). What is left is hosting + that sheet in a destination instead of in each screen's content. 3. Group send. Last because of the nested `FlowHost`; do it once 1 and 2 have settled the result and bar handling. 4. Delete `InWindowBottomSheet` when nothing uses it.