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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -270,7 +271,6 @@ private fun chatEntryProvider(
RequestDetailsScreen(
chatId = step.chatId,
requestId = step.requestId,
payOnOpen = step.pay,
onBack = { dismissSheet() },
onOpenScreen = { route -> navigator.navigate(route) },
)
Expand Down Expand Up @@ -439,10 +439,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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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() },
)
}
Expand Down Expand Up @@ -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() },
)
}
}
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
)
}
}
},
)
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -3618,6 +3619,8 @@ internal class ChatViewModel @AssistedInject constructor(
eventFlow
.filterIsInstance<Event.PresentDepositOptions>()
.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))
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Effect>(Channel.BUFFERED)
val effectFlow: Flow<Effect> = effects.receiveAsFlow()

enum class Stage { Amount, Recipients }

data class State(
Expand Down Expand Up @@ -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),
Expand All @@ -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) }
Expand All @@ -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) }
Expand Down
Loading
Loading