diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 7294ef6f58..711f499379 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -26,7 +26,6 @@ import com.flipcash.app.cash.CashScreen import com.flipcash.app.contact.verification.VerificationFlowScreen import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen import com.flipcash.app.tipping.NewChatScreen -import com.flipcash.app.tipping.TipAmountEntryScreen import com.flipcash.app.tipping.TippingFlowScreen import com.flipcash.shared.transactionhistory.ActivityHistoryScreen import com.flipcash.app.core.AppRoute @@ -98,7 +97,6 @@ fun appEntryProvider( TippingFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } annotatedEntry { key -> TokenSelectScreen(key.purpose) } - annotatedEntry { TipAmountEntryScreen() } annotatedEntry { // The wallet hosts the card-expand overlay in-entry so a pushed action (Give/Convert/Withdraw) // covers the expanded currency-info with correct z-order (iOS WalletScreen structure). diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 1fae42a78d..cabf6d3534 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -173,13 +173,6 @@ sealed interface AppRoute : NavKey, Parcelable { data class Tips(val resumed: Boolean = false): Sheets { } - /** - * Custom tip-amount entry, opened over the still-visible tip card + modal. The entered - * amount is written back to the shared tip selection so the modal reflects it on dismiss. - */ - @Serializable - data object TipAmountEntry : Sheets - @Serializable data object Wallet : Sheets diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipSelection.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipSelection.kt index abe40f32c5..4e951d88e1 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipSelection.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipSelection.kt @@ -1,7 +1,6 @@ package com.flipcash.app.core.tipping import androidx.compose.runtime.staticCompositionLocalOf -import com.flipcash.app.core.chat.ChatIdentifier import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Token @@ -68,9 +67,6 @@ interface TipSelectionHolder { /** Selects [amount] (a [TipAmount.Preset] or [TipAmount.Custom]); pass `null` to clear it. */ fun selectAmount(amount: TipAmount?) - /** Submits the current tip selection, driving [TipSelectionState.sendState] loading → success. */ - fun confirmTip() - /** * One-shot UI events the tip decorator should act on — e.g. opening the deposit flow * after the user picks "Add Money" from an insufficient-balance prompt. Emitted by the @@ -82,7 +78,6 @@ interface TipSelectionHolder { object Empty : TipSelectionHolder { override val selection: StateFlow = MutableStateFlow(TipSelectionState()) override fun selectAmount(amount: TipAmount?) = Unit - override fun confirmTip() = Unit override val events: Flow = emptyFlow() } } @@ -91,12 +86,6 @@ interface TipSelectionHolder { sealed interface TipEvent { /** Open [route] (e.g. the deposit flow) using the tip UI's navigator. */ data class OpenRoute(val route: AppRoute) : TipEvent - - /** - * Launch the DM chat for a completed tip. The tip UI opens it through the tips flow so the - * tips list sits beneath the chat in the back stack (back returns to the list). - */ - data class LaunchChat(val identifier: ChatIdentifier) : TipEvent } /** Provides the active [TipSelectionHolder] (the tipping coordinator) to composables. */ diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt index 4022191533..ad73d2af65 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt @@ -31,7 +31,6 @@ sealed interface TokenPurpose: Parcelable { */ @Serializable data class BuyFunding(val target: Mint, val current: Mint) : TokenPurpose @Serializable data class LaunchFunding(val amount: Fiat): TokenPurpose - @Serializable data class Tip(val amount: Fiat?): TriggersChange @Serializable data object Withdraw: TokenPurpose @Serializable data object Deposit: TokenPurpose @Serializable data object Balance : TokenPurpose diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 299089bc48..99ff557a2d 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1033,10 +1033,7 @@ Show My Tip Card Send a Tip Send %1$s to Start Chatting - Amount to Tip - of Swipe to Tip - Enter a custom amount %1$s minimum %1$s Minimum Tip Please enter a higher amount diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt index 36b97d7e30..dbdf72d80f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt @@ -81,8 +81,7 @@ internal fun ChatAmountEntryContent( } }, // A close X, not an up arrow: this is a sheet over the conversation, and dismissing - // it drops the amount rather than stepping back to a previous screen. Matches the - // other sheet-presented amount entry, TipAmountEntryScreen. + // it drops the amount rather than stepping back to a previous screen. rightContents = { AppBarDefaults.Close { onExit() } }, diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipAmountEntryScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipAmountEntryScreen.kt deleted file mode 100644 index 026e7b5629..0000000000 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipAmountEntryScreen.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.flipcash.app.tipping - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.res.stringResource -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.flipcash.app.core.AppRoute -import com.flipcash.app.tipping.internal.TipAmountEntryViewModel -import com.flipcash.features.tipping.R -import com.flipcash.shared.amountentry.AmountEntryScreen -import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.ui.components.AppBarDefaults -import com.getcode.ui.components.AppBarWithTitle -import kotlinx.coroutines.flow.filterIsInstance - -/** - * Custom tip-amount entry, presented as a bottom sheet over the still-visible tip card + modal. - * Confirming commits the amount to the shared tip selection (see [TipAmountEntryViewModel]) and - * dismisses the sheet; the underlying tip modal then reflects the chosen amount. - */ -@Composable -fun TipAmountEntryScreen() { - val viewModel = hiltViewModel() - val navigator = LocalCodeNavigator.current - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .collect { navigator.pop() } - } - - AmountEntryScreen( - controller = viewModel.amountDelegate, - onConfirm = { viewModel.dispatchEvent(TipAmountEntryViewModel.Event.ConfirmRequested) }, - onChangeCurrency = { navigator.push(AppRoute.Main.RegionSelection) }, - appBar = { - AppBarWithTitle( - title = stringResource(R.string.title_amountToTip), - titleAlignment = Alignment.CenterHorizontally, - endContent = { AppBarDefaults.Close { navigator.hide() } }, - ) - }, - ) -} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipAmountEntryViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipAmountEntryViewModel.kt deleted file mode 100644 index 5d34a1f611..0000000000 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipAmountEntryViewModel.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.flipcash.app.tipping.internal - -import androidx.lifecycle.viewModelScope -import com.flipcash.app.core.tipping.TipAmount -import com.flipcash.app.core.ui.ConfirmationStyle -import com.flipcash.shared.amountentry.AmountEntryDelegate -import com.flipcash.shared.amountentry.AmountEntryLabel -import com.flipcash.shared.amountentry.AmountEntryStyle -import com.flipcash.shared.tipping.TippingCoordinator -import com.flipcash.features.tipping.R -import com.getcode.opencode.exchange.Exchange -import com.getcode.opencode.model.financial.CurrencyCode -import com.getcode.manager.BottomBarManager -import com.getcode.opencode.model.financial.Fiat -import com.getcode.util.resources.ResourceHelper -import com.getcode.view.BaseViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import javax.inject.Inject - -/** - * Backs the custom tip-amount entry sheet. Hosts an [AmountEntryDelegate] keypad (in the user's - * preferred currency, mirroring the tip presets) and commits the entered value to the shared tip - * selection on [TippingCoordinator], so the still-visible tip modal reflects it once the sheet is - * dismissed. - */ -@HiltViewModel -internal class TipAmountEntryViewModel @Inject constructor( - exchange: Exchange, - resources: ResourceHelper, - private val tippingCoordinator: TippingCoordinator, -) : BaseViewModel( - initialState = State(), - updateStateForEvent = updateStateForEvent, -) { - - data class State( - /** Currency the keypad is entering in — kept in sync with the preferred rate. */ - val currency: CurrencyCode = CurrencyCode.USD, - ) - - sealed interface Event { - /** Preferred currency resolved/changed; keeps the entry currency in sync. */ - data class CurrencyChanged(val currency: CurrencyCode) : Event - - /** User asked to confirm the currently entered amount. */ - data object ConfirmRequested : Event - - /** The entered amount was committed to the tip selection — the screen should dismiss. */ - data object Confirmed : Event - } - - val amountDelegate = AmountEntryDelegate( - exchange = exchange, - scope = viewModelScope, - style = AmountEntryStyle( - actionLabel = AmountEntryLabel.Plain(resources.getString(R.string.action_next)), - actionStyle = ConfirmationStyle.Button, - infoHint = { resources.getString(R.string.subtitle_sendHint, it) }, - overMaxHint = { resources.getString(R.string.subtitle_sendHintLimitExceeded, it) }, - belowMinHint = { resources.getString(R.string.subtitle_tipHintMinimum, it) }, - ), - // Cap entry at the selected token's min(send limit, balance) — the "enter up to" hint — - // and floor it at the lowest preset so a custom amount can't undercut the presets. - maxAmount = tippingCoordinator.maxTipAmount, - minimumAmount = tippingCoordinator.minTipAmount, - ) - - init { - exchange.observePreferredRate() - .onEach { rate -> - exchange.getCurrency(rate.currency.name)?.let { amountDelegate.onCurrencyChanged(it) } - dispatchEvent(Event.CurrencyChanged(rate.currency)) - } - .launchIn(viewModelScope) - - // Prefill only when the current selection is itself a custom amount, so re-opening the sheet - // keeps a custom value — but opening it with a preset picked starts blank. - (tippingCoordinator.selection.value.amount as? TipAmount.Custom)?.let { custom -> - amountDelegate.prefill(custom.value.decimalValue) - } - - // Commit the entered amount on confirm, then signal the screen to dismiss. - eventFlow - .filterIsInstance() - .onEach { - val entered = amountDelegate.state.value.enteredAmount - if (entered <= 0.0) return@onEach - val amount = Fiat(entered, stateFlow.value.currency) - // The floor the coordinator resolved for this card: the recipient's own minimum - // when this tip opens the DM, the system minimum once one exists. - val min = tippingCoordinator.minTipAmount.value - if (min != null && amount.valueLessThan(min)) { - // Info, not an alert: nothing failed, the amount is just under the floor — - // matching the same prompt in the chat and tip-card paths. - BottomBarManager.showInfo( - title = resources.getString(R.string.error_title_tipMinimum, min.formatted()), - message = resources.getString(R.string.error_description_tipMinimum), - ) - return@onEach - } - // Over the send limit → block with the limit message. - if (tippingCoordinator.exceedsSendLimit(amount)) { - BottomBarManager.showAlert( - title = resources.getString(R.string.error_title_sendLimitReached), - message = resources.getString(R.string.error_description_sendLimitReached), - ) - return@onEach - } - // Over the max (min(limit, balance)) but within the limit → over balance. Prompt to - // add money instead of committing, mirroring the send-time gate in confirmTip. - val max = tippingCoordinator.maxTipAmount.value - if (max != null && amount.valueGreaterThan(max)) { - tippingCoordinator.promptInsufficientBalance() - return@onEach - } - tippingCoordinator.selectAmount(TipAmount.Custom(amount)) - dispatchEvent(Event.Confirmed) - } - .launchIn(viewModelScope) - } - - companion object { - private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> - when (event) { - is Event.CurrencyChanged -> { state -> state.copy(currency = event.currency) } - is Event.ConfirmRequested -> { state -> state } - is Event.Confirmed -> { state -> state } - } - } - } -} diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt index b4f7e09e5f..f4253e55d6 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -18,8 +17,6 @@ import com.flipcash.app.tokens.ui.SelectTokenViewModel import com.flipcash.app.tokens.ui.TokenListPresentation import com.flipcash.features.tokens.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.flow.FlowDismissStyle -import com.getcode.navigation.flow.LocalFlowDismissStyle import com.getcode.ui.components.AppBarWithTitle import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance @@ -36,34 +33,24 @@ fun TokenSelectScreen( val navigator = LocalCodeNavigator.current val viewModel = hiltViewModel() - // Standalone selection sheets (Select / Tip) dismiss with a close (X); when the screen is a - // step pushed onto another stack we keep the ambient style — a flow host's back arrow (Swap) or - // the default. AppBarWithTitle auto-swaps the icon off LocalFlowDismissStyle. - val dismissStyle = when (purpose) { - is TokenPurpose.Tip -> FlowDismissStyle.Close - else -> LocalFlowDismissStyle.current - } - - CompositionLocalProvider(LocalFlowDismissStyle provides dismissStyle) { - Column( - modifier = if (presentation.wrapHeight) Modifier.fillMaxWidth() - else Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (showTopBar) { - AppBarWithTitle( - title = when (purpose) { - is TokenPurpose.Swap -> stringResource(R.string.title_selectPaymentCurrency) - is TokenPurpose.LaunchFunding -> stringResource(R.string.title_selectPaymentCurrency) - else -> stringResource(R.string.title_selectCurrency) - }, - onBackIconClicked = { navigator.pop() }, - titleAlignment = Alignment.CenterHorizontally, - ) - } - - SelectTokenScreen(viewModel, presentation = presentation) + Column( + modifier = if (presentation.wrapHeight) Modifier.fillMaxWidth() + else Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (showTopBar) { + AppBarWithTitle( + title = when (purpose) { + is TokenPurpose.Swap -> stringResource(R.string.title_selectPaymentCurrency) + is TokenPurpose.LaunchFunding -> stringResource(R.string.title_selectPaymentCurrency) + else -> stringResource(R.string.title_selectCurrency) + }, + onBackIconClicked = { navigator.pop() }, + titleAlignment = Alignment.CenterHorizontally, + ) } + + SelectTokenScreen(viewModel, presentation = presentation) } LaunchedEffect(viewModel) { @@ -96,7 +83,6 @@ fun TokenSelectScreen( navigator.push(Deposit()) } - is TokenPurpose.Tip -> Unit is TokenPurpose.LaunchFunding -> Unit is TokenPurpose.Swap -> Unit is TokenPurpose.ConvertDestination -> Unit diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectScreen.kt index fa2c00c843..124457edea 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectScreen.kt @@ -71,7 +71,6 @@ private fun SelectTokenScreenContent( is TokenPurpose.Swap -> TokenSelectionStyle.Chevron is TokenPurpose.LaunchFunding -> TokenSelectionStyle.Chevron is TokenPurpose.Select -> TokenSelectionStyle.Checkbox - is TokenPurpose.Tip -> TokenSelectionStyle.Checkbox is TokenPurpose.ConvertDestination -> TokenSelectionStyle.Checkbox is TokenPurpose.BuyFunding -> TokenSelectionStyle.Checkbox TokenPurpose.Withdraw -> TokenSelectionStyle.Chevron @@ -97,7 +96,6 @@ private fun SelectTokenScreenContent( is TokenPurpose.Select -> false is TokenPurpose.Swap -> false is TokenPurpose.LaunchFunding -> false - is TokenPurpose.Tip -> false is TokenPurpose.ConvertDestination -> false is TokenPurpose.BuyFunding -> false else -> true @@ -107,10 +105,6 @@ private fun SelectTokenScreenContent( is TokenPurpose.LaunchFunding -> { amount.nativeAmount.valueGreaterThanOrEqualTo(purpose.amount) } - is TokenPurpose.Tip -> { - val target = purpose.amount ?: return@atLeast true - amount.nativeAmount.valueGreaterThanOrEqualTo(target) - } else -> true } }, diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt index 1e2694b279..004dae814b 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/TipCardDecorator.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.LaunchedEffect import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.chat.ChatIdentifier -import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.tipping.LocalTipCoordinator import com.flipcash.app.core.tipping.TipEvent @@ -65,18 +64,7 @@ internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : S LaunchedEffect(tipCoordinator) { tipCoordinator.events.collect { event -> when (event) { - is TipEvent.OpenRoute -> { - navigator.openAsSheet(event.route) - } - is TipEvent.LaunchChat -> { - navigator.navigateAll( - listOf( - AppRoute.Sheets.Tips(), - AppRoute.Messaging.Chat(event.identifier, openKeyboard = true), - ), - ) - context.onDismiss(Grabbed) - } + is TipEvent.OpenRoute -> navigator.openAsSheet(event.route) } } } diff --git a/apps/flipcash/shared/bills/src/test/kotlin/com/flipcash/app/bills/OnBillPresentedTest.kt b/apps/flipcash/shared/bills/src/test/kotlin/com/flipcash/app/bills/OnBillPresentedTest.kt index dbbccb9ed6..e44cfabd05 100644 --- a/apps/flipcash/shared/bills/src/test/kotlin/com/flipcash/app/bills/OnBillPresentedTest.kt +++ b/apps/flipcash/shared/bills/src/test/kotlin/com/flipcash/app/bills/OnBillPresentedTest.kt @@ -42,7 +42,7 @@ class OnBillPresentedTest { fun `a bill already up at first composition is not a presentation`() { watch(initiallyPresented = true) - // The post-tip hand-off case: LaunchChat opens the chat with the keyboard up on purpose, + // The hand-off case: a scanned tip card opens the chat with the keyboard up on purpose, // and the chat's fresh overlay must not take it straight back down. assertEquals(0, presentations) } diff --git a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt index ed01ba87dd..9e8de38e87 100644 --- a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt +++ b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt @@ -121,7 +121,7 @@ class TippingCoordinator @Inject constructor( /** * The smallest tippable amount for the card on screen: the fee its owner charges to open a DM * when this tip would open one, and the system minimum otherwise. Surfaced by the amount entry - * as a standing hint and enforced by [confirmTip]. + * as a standing hint. */ @OptIn(ExperimentalCoroutinesApi::class) val minTipAmount: StateFlow = @@ -175,108 +175,10 @@ class TippingCoordinator @Inject constructor( _amount.value = amount } - override fun confirmTip() { - val amount = _amount.value ?: return - val userForTip = _userId.value ?: return - if (!_sendState.value.isIdle) return - val owner = userManager.accountCluster ?: return - val rate = exchange.preferredRate - - scope.launch { - setSendState(LoadingSuccessState(loading = true)) - val token = selectedToken.firstOrNull() ?: return@launch - - // The floor, checked here as well as in the amount entry: a preset chip never passes - // through that entry. - val floor = minTipAmount.value - if (floor != null && amount.value.valueLessThan(floor)) { - setSendState(LoadingSuccessState()) - BottomBarManager.showInfo( - title = resources.getString(R.string.error_title_tipMinimum, floor.formatted()), - message = resources.getString(R.string.error_description_tipMinimum), - ) - return@launch - } - - // Fast-fail before the loading state if the tip exceeds the token balance: - // prompt to add money (or enter a smaller amount) instead of attempting a send. - val balanceInLocal = tokenCoordinator.balanceForToken(token).convertingTo(rate) - if (amount.value.valueGreaterThan(balanceInLocal)) { - setSendState(LoadingSuccessState()) - promptInsufficientBalance() - return@launch - } - - - val source = owner.withTimelockForToken(token) - - val balance = tokenCoordinator.balanceForToken(token) - val verifiedFiat = verifiedFiatCalculator.compute( - amount = amount.value, - token = token, - balance = balance, - rate = rate, - ).getOrElse { error -> - setSendState(LoadingSuccessState()) - val (title, message) = when (error) { - is ComputeVerifiedFiatError.AmountBelowMinimum -> { - R.string.error_title_amountTooSmall to R.string.error_description_amountTooSmall - } - - else -> { - R.string.error_title_staleRates to R.string.error_description_staleRates - } - } - BottomBarManager.showAlert( - title = resources.getString(title), - message = resources.getString(message), - ) - return@launch - } - - tipPaymentDelegate.send( - userId = userForTip, - verifiedFiat = verifiedFiat, - token = token, - source = source, - origin = TipOrigin.TIPCARD, - // A tip card is always a genuine tip — there is no "send cash" path through it. - action = TipAction.TIP, - ).onSuccess { canonicalChatId -> - setSendState(LoadingSuccessState(success = true)) - delay(400.milliseconds) - setSendState(LoadingSuccessState()) - analytics.transfer( - event = Analytics.Transfer.SentTip, - amount = verifiedFiat.localFiat, - successful = true, - ) - - // Hand off to the tipped user's chat via the tips flow, so backing out of the - // chat lands on the tips list. - canonicalChatId?.let { - _events.emit(TipEvent.LaunchChat(ChatIdentifier.ByChatId(it))) - } - }.onFailure { cause -> - setSendState(LoadingSuccessState()) - analytics.transfer( - event = Analytics.Transfer.SentTip, - amount = verifiedFiat.localFiat, - error = cause, - ) - BottomBarManager.showError( - title = resources.getString(R.string.error_title_cashFailedToSend), - message = resources.getString(R.string.error_description_cashFailedToSend), - ) - } - } - } - /** * Shows the insufficient-balance prompt with an "Add Money" action, mirroring the * give/currency-creator flows. Choosing "Add Money" resolves a deposit route via * [PurchaseMethodController] and emits it as a [TipEvent.OpenRoute] for the tip UI to open. - * Shared by the send path ([confirmTip]) and the amount-entry over-balance gate. */ fun promptInsufficientBalance() { BottomBarManager.showInfo( @@ -366,8 +268,8 @@ class TippingCoordinator @Inject constructor( /** * Dual gating, like the send / currency-creator flows: the presentation gate only asks "is * there any giveable balance?" (no amount threshold, so it stays currency-agnostic). The - * minimum-tip and per-amount affordability are enforced downstream — the amount entry's - * below-min / over-balance gates and confirmTip. + * minimum-tip and per-amount affordability are enforced downstream, by the chat's own amount + * entry. */ private suspend fun onCardResolved(userId: ID, profile: UserProfile) { _canTip.value = tokenCoordinator.hasGiveableBalance() @@ -376,11 +278,6 @@ class TippingCoordinator @Inject constructor( vibrator.vibrate() } - /** Updates the tip submission's processing state; used by the send path (see [confirmTip]). */ - private fun setSendState(state: LoadingSuccessState) { - _sendState.value = state - } - /** * Assembles the scannable [Scannable.TipCard]: the tip [OpenCodePayload] encoding [userId] * as the scannable code data, plus [profile] for rendering. Tip presets are surfaced reactively diff --git a/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt b/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt index bb97e74d67..693bedd71f 100644 --- a/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt +++ b/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt @@ -1,35 +1,19 @@ package com.flipcash.shared.tipping import com.flipcash.app.analytics.FlipcashAnalyticsService -import com.flipcash.app.core.tipping.TipAmount import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.controllers.ProfileController -import com.flipcash.services.models.TipAction -import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile -import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager import com.flipcash.shared.payments.TipPaymentDelegate import com.getcode.opencode.exchange.Exchange -import com.getcode.opencode.exchange.VerifiedFiat import com.getcode.opencode.exchange.VerifiedFiatCalculator -import com.getcode.opencode.model.accounts.AccountCluster -import com.getcode.opencode.model.financial.CurrencyCode -import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.model.financial.LocalFiat -import com.getcode.opencode.model.financial.Rate -import com.getcode.opencode.model.core.ID -import com.getcode.opencode.model.financial.Token -import com.getcode.solana.keys.Mint import com.getcode.util.resources.ResourceHelper import com.getcode.util.vibration.Vibrator import io.mockk.coEvery -import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals @@ -109,77 +93,4 @@ class TippingCoordinatorTest { assertEquals(id, coordinator.currentUserId) } - - /** - * Reaches into [TippingCoordinator]'s private `_userId`/`_recipient`/`_canTip` state to select - * a tip-card recipient without going through [TippingCoordinator.resolveTipCard] — that method - * builds a real scannable [com.getcode.opencode.model.core.OpenCodePayload], which loads the - * native `kikCodes`/`codeScanner` libraries a plain JVM unit test has no access to - * (`UnsatisfiedLinkError`). Only [TippingCoordinator.confirmTip]'s wiring is under test here. - */ - @Suppress("UNCHECKED_CAST") - private fun TippingCoordinator.privateStateFlow(name: String): MutableStateFlow { - val field = TippingCoordinator::class.java.getDeclaredField(name) - field.isAccessible = true - return field.get(this) as MutableStateFlow - } - - @Test - fun `confirmTip sends the tip card as a TIP action`() = runTest { - // A tip card has no "send cash" path — every payment through it is a genuine tip, so - // confirmTip must always report TipAction.TIP on the wire, never SEND. - val userId = listOf(4, 5, 6) - - val mint = Mint.usdf - val token = mockk(relaxed = true) { - every { address } returns mint - } - val rate = Rate.oneToOne - val amount = Fiat(5.0, CurrencyCode.USD) - val owner = mockk(relaxed = true) { - every { withTimelockForToken(token) } returns this - } - val verifiedFiat = VerifiedFiat(LocalFiat.Zero, null) - - every { exchange.preferredRate } returns rate - // selectedToken is a plain `val` built once at construction from these two calls, not a - // lazily-recomputed property — stubbing them has to happen before TippingCoordinator is - // built, or the coordinator captures whatever the mock's default (unstubbed) answer was. - every { tokenCoordinator.observeSelectedTokenMint() } returns flowOf(mint) - every { tokenCoordinator.tokens } returns flowOf(listOf(token)) - every { tokenCoordinator.balanceForToken(token) } returns amount - every { userManager.accountCluster } returns owner - every { tipPaymentDelegate.minimumTipFor(userId, any()) } returns flowOf(null) - coEvery { - verifiedFiatCalculator.compute(any(), any(), any(), any(), any()) - } returns Result.success(verifiedFiat) - coEvery { - tipPaymentDelegate.send(any(), any(), any(), any(), any(), any()) - } returns Result.success(null) - - val coordinator = buildCoordinator() - - // Selects the tip card the same way onCardResolved would, minus the native-dependent - // OpenCodePayload construction resolveTipCard performs. - coordinator.privateStateFlow("_userId").value = userId - coordinator.privateStateFlow("_recipient").value = profile("Bob") - coordinator.privateStateFlow("_canTip").value = true - - coordinator.selectAmount(TipAmount.Custom(amount)) - coordinator.confirmTip() - - // confirmTip fires into the coordinator's own background scope rather than this test's, - // so the send lands asynchronously — coVerify's timeout polls for it instead of asserting - // immediately after the (non-suspend) call above returns. - coVerify(timeout = 5_000) { - tipPaymentDelegate.send( - userId = userId, - verifiedFiat = verifiedFiat, - token = token, - source = owner, - origin = TipOrigin.TIPCARD, - action = TipAction.TIP, - ) - } - } } diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt index 70f08b72af..3758ebbabb 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt @@ -153,7 +153,6 @@ class SelectTokenViewModel @Inject constructor( is TokenPurpose.ConvertDestination, is TokenPurpose.BuyFunding, is TokenPurpose.LaunchFunding, - is TokenPurpose.Tip, TokenPurpose.Deposit, TokenPurpose.Withdraw -> { it.token.name @@ -177,10 +176,6 @@ class SelectTokenViewModel @Inject constructor( hasBalance } - is TokenPurpose.Tip -> { - hasBalance - } - is TokenPurpose.Swap -> { if (it.token.address != purpose.desiredToken) { hasBalance