From 7c48d41b98992ccd54dc19fa629be50994cc4279 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 14 Sep 2026 17:21:37 -0400 Subject: [PATCH 1/3] chore(tipping): delete the unreachable tip amount entry TipUserModal was the only thing that pushed AppRoute.Sheets.TipAmountEntry, and it went with the scan-lands-in-chat change. Nothing opens the route now, so the route, its registration, the screen and its view model all go. The chat's own amount entry carried a comment pointing at this screen as the other sheet-presented amount entry; it loses the reference and keeps the reasoning. --- .../ui/navigation/AppScreenContent.kt | 2 - .../kotlin/com/flipcash/app/core/AppRoute.kt | 7 - .../screens/cash/ChatAmountEntryScreen.kt | 3 +- .../app/tipping/TipAmountEntryScreen.kt | 45 ------ .../internal/TipAmountEntryViewModel.kt | 134 ------------------ 5 files changed, 1 insertion(+), 190 deletions(-) delete mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipAmountEntryScreen.kt delete mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipAmountEntryViewModel.kt 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/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 } - } - } - } -} From 18b3731415cc8006c71d648c9ebf41f3483ee6f6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 14 Sep 2026 17:23:32 -0400 Subject: [PATCH 2/3] chore(tipping): delete the tip confirmation path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit confirmTip was the tip modal's submit button, and TipEvent.LaunchChat was the hand-off it emitted on success — open the tips flow, then the chat on top of it. Nothing calls either now: the chat charges the fee itself, and the scanned card goes straight to the chat with no tips list underneath. With LaunchChat gone the decorator's event collector handles one case, so the when collapses and navigateAll loses its last use there. TippingCoordinatorTest covered confirmTip's one invariant (a tip card always reports TipAction.TIP) through a reflection helper that existed only to feed it; both go. --- .../flipcash/app/core/tipping/TipSelection.kt | 11 -- .../app/bills/decor/TipCardDecorator.kt | 14 +-- .../flipcash/app/bills/OnBillPresentedTest.kt | 2 +- .../shared/tipping/TippingCoordinator.kt | 109 +----------------- .../shared/tipping/TippingCoordinatorTest.kt | 89 -------------- 5 files changed, 5 insertions(+), 220 deletions(-) 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/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, - ) - } - } } From 3d985b242927c8575c7c95ac20ce9623405aedba Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 14 Sep 2026 17:26:52 -0400 Subject: [PATCH 3/3] chore(tokens): drop the tip token purpose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TokenPurpose.Tip was constructed in one place — the tip modal's token picker — and nothing constructs it now, so the variant and its seven when branches go. Token select loses its one purpose-specific dismiss style with it: Tip was the only purpose forcing a close (X), so the screen now always takes the ambient one and the CompositionLocalProvider around it is redundant. title_amountToTip, label_of and content_description_customTipAmount were read only by the amount entry and the modal. action_swipeToTip still has a caller in ChatViewModel and stays for now. --- .../flipcash/app/core/tokens/TokenPurpose.kt | 1 - .../core/src/main/res/values/strings.xml | 3 -- .../flipcash/app/tokens/TokenSelectScreen.kt | 48 +++++++------------ .../app/tokens/internal/TokenSelectScreen.kt | 6 --- .../app/tokens/ui/SelectTokenViewModel.kt | 5 -- 5 files changed, 17 insertions(+), 46 deletions(-) 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/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/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