Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 17:27:25 +04:00
commit 21418379dd
10 changed files with 261 additions and 6 deletions

View file

@ -3,6 +3,7 @@ package com.tangem.common.ui.markets.action
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
@ -32,10 +33,24 @@ object QuickActionsConverter {
)
}
},
disabledActions = states.filterNot { it.isEnabled }.map { it.action }.toImmutableSet(),
disabledActions = if (context.shouldShowUnavailableActionsAsEnabled) {
persistentSetOf()
} else {
states.filterNot { it.isEnabled }.map { it.action }.toImmutableSet()
},
)
}
/**
* Unavailability reason for [action] as produced by the domain [actions] list, or
* [ScenarioUnavailabilityReason.None] when the action is available or has no domain counterpart.
*/
fun unavailabilityReason(
action: TokenActionsBSContentUM.Action,
actions: List<TokenActionsState.ActionState>,
): ScenarioUnavailabilityReason = actions.firstOrNull { it.toBsAction() == action }?.unavailabilityReason
?: ScenarioUnavailabilityReason.None
private fun QuickActionUM.toHandledAction(): TokenActionsBSContentUM.Action = when (this) {
QuickActionUM.V1.Buy, QuickActionUM.V2.Buy -> TokenActionsBSContentUM.Action.Buy
is QuickActionUM.V1.Exchange, is QuickActionUM.V2.Exchange -> TokenActionsBSContentUM.Action.Exchange

View file

@ -10,23 +10,30 @@ import com.tangem.common.ui.markets.action.TokenActionsBSContentUM.Action
* in source order" (legacy Markets behaviour).
* @property swapPosition the [AppRoute.Swap.CurrencyPosition] used when the Swap/Exchange action is
* launched from this context.
* @property shouldShowUnavailableActionsAsEnabled when `true`, unavailable actions are rendered as active
* (not greyed) and stay tappable; tapping one shows its unavailability reason instead of navigating.
* When `false`, unavailable actions are greyed out and non-interactive.
*/
enum class TokenActionsContext(
val allowedActionsInOrder: List<Action>?,
val swapPosition: AppRoute.Swap.CurrencyPosition,
val shouldShowUnavailableActionsAsEnabled: Boolean,
) {
Markets(
allowedActionsInOrder = null,
swapPosition = AppRoute.Swap.CurrencyPosition.ANY,
shouldShowUnavailableActionsAsEnabled = false,
),
AddFunds(
allowedActionsInOrder = listOf(Action.Buy, Action.Exchange, Action.Receive),
swapPosition = AppRoute.Swap.CurrencyPosition.TO,
shouldShowUnavailableActionsAsEnabled = false,
),
Transfer(
// Action.SendWithSwap has no TokenActionsState.ActionState counterpart and is never produced from the
// domain action list; it is injected as a synthetic row by the converter when Swap is available.
allowedActionsInOrder = listOf(Action.Send, Action.Exchange, Action.SendWithSwap, Action.Sell),
swapPosition = AppRoute.Swap.CurrencyPosition.FROM,
shouldShowUnavailableActionsAsEnabled = true,
),
}

View file

@ -3,6 +3,7 @@ package com.tangem.common.ui.markets.action
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.common.ui.markets.R
import com.tangem.common.ui.tokens.getUnavailabilityReasonText
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
@ -21,6 +22,7 @@ import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.utils.Provider
import dagger.assisted.Assisted
@ -57,6 +59,7 @@ class TokenActionsHandler @AssistedInject constructor(
context: TokenActionsContext = TokenActionsContext.Markets,
) {
if (isTopUpBlockedByBackupError(action, cryptoCurrencyData.userWallet)) return
if (handleUnavailabilityReason(action, cryptoCurrencyData)) return
onHandleQuickAction(
HandledQuickAction(
@ -103,6 +106,17 @@ class TokenActionsHandler @AssistedInject constructor(
}
}
private fun handleUnavailabilityReason(
action: TokenActionsBSContentUM.Action,
cryptoCurrencyData: CryptoCurrencyData,
): Boolean {
val reason = QuickActionsConverter.unavailabilityReason(action, cryptoCurrencyData.actions)
if (reason == ScenarioUnavailabilityReason.None) return false
uiMessageSender.send(DialogMessage(message = reason.getUnavailabilityReasonText()))
return true
}
private fun isTopUpBlockedByBackupError(action: TokenActionsBSContentUM.Action, userWallet: UserWallet): Boolean {
val isBlockedAction = action == TokenActionsBSContentUM.Action.Buy ||
action == TokenActionsBSContentUM.Action.Receive

View file

@ -3,6 +3,7 @@ package com.tangem.common.ui.markets.action
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
import io.mockk.mockk
import org.junit.jupiter.api.Test
internal class QuickActionsConverterTest {
@ -178,4 +179,96 @@ internal class QuickActionsConverterTest {
).inOrder()
assertThat(result).doesNotContain(QuickActionUM.V2.SwapAndSend)
}
@Test
fun `GIVEN send and sell unavailable WHEN context is Transfer THEN no actions are marked disabled`() {
// Arrange
val actions = listOf(
TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.EmptyBalance(SEND_SCENARIO)),
TokenActionsState.ActionState.Sell(
ScenarioUnavailabilityReason.NotSupportedBySellService(cryptoCurrencyName = "BTC"),
),
)
// Act
val result = QuickActionsConverter.quickActions(
cryptoData = cryptoData(actions),
tokenActionsHandler = mockk(relaxed = true),
isRedesignEnabled = true,
context = TokenActionsContext.Transfer,
)
// Assert
assertThat(result.actions).containsExactly(QuickActionUM.V2.Send, QuickActionUM.V2.Sell).inOrder()
assertThat(result.disabledActions).isEmpty()
}
@Test
fun `GIVEN buy unavailable WHEN context is AddFunds THEN buy is marked disabled`() {
// Arrange
val actions = listOf(
TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.BuyUnavailable(cryptoCurrencyName = "BTC")),
TokenActionsState.ActionState.Receive(ScenarioUnavailabilityReason.None),
)
// Act
val result = QuickActionsConverter.quickActions(
cryptoData = cryptoData(actions),
tokenActionsHandler = mockk(relaxed = true),
isRedesignEnabled = true,
context = TokenActionsContext.AddFunds,
)
// Assert
assertThat(result.disabledActions).containsExactly(QuickActionUM.V2.Buy)
}
@Test
fun `GIVEN unavailable action in list WHEN unavailabilityReason THEN returns its reason`() {
// Arrange
val reason = ScenarioUnavailabilityReason.NotSupportedBySellService(cryptoCurrencyName = "BTC")
val actions = listOf(
TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.None),
TokenActionsState.ActionState.Sell(reason),
)
// Act
val result = QuickActionsConverter.unavailabilityReason(TokenActionsBSContentUM.Action.Sell, actions)
// Assert
assertThat(result).isEqualTo(reason)
}
@Test
fun `GIVEN available action WHEN unavailabilityReason THEN returns None`() {
// Arrange
val actions = listOf(TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.None))
// Act
val result = QuickActionsConverter.unavailabilityReason(TokenActionsBSContentUM.Action.Send, actions)
// Assert
assertThat(result).isEqualTo(ScenarioUnavailabilityReason.None)
}
@Test
fun `GIVEN action absent from list WHEN unavailabilityReason THEN returns None`() {
// Act
val result = QuickActionsConverter.unavailabilityReason(TokenActionsBSContentUM.Action.Sell, actions = emptyList())
// Assert
assertThat(result).isEqualTo(ScenarioUnavailabilityReason.None)
}
private fun cryptoData(actions: List<TokenActionsState.ActionState>) = CryptoCurrencyData(
userWallet = mockk(relaxed = true),
status = mockk(relaxed = true),
actions = actions,
isAccountMode = false,
account = mockk(relaxed = true),
)
private companion object {
val SEND_SCENARIO = ScenarioUnavailabilityReason.WithdrawalScenario.SEND
}
}

View file

@ -0,0 +1,122 @@
package com.tangem.common.ui.markets.action
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.utils.Provider
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class TokenActionsHandlerUnavailabilityTest {
private val router: Router = mockk(relaxed = true)
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
private val handledActions = mutableListOf<TokenActionsHandler.HandledQuickAction>()
private val handler = TokenActionsHandler(
router = router,
clipboardManager = mockk(relaxed = true),
uiMessageSender = uiMessageSender,
getOfframpUrlUseCase = mockk(relaxed = true),
urlOpener = mockk(relaxed = true),
analyticsEventHandler = mockk(relaxed = true),
currentAppCurrency = Provider { mockk(relaxed = true) },
onHandleQuickAction = { handled, _ -> handledActions.add(handled) },
coroutineScope = CoroutineScope(UnconfinedTestDispatcher()),
isDemoCardUseCase = mockk(relaxed = true),
isWalletBackupProblematicUseCase = mockk(relaxed = true),
sendBackupProblemEmailUseCase = mockk(relaxed = true),
messageSender = mockk(relaxed = true),
)
private val currency: CryptoCurrency = MockCryptoCurrencyFactory().ethereum
private fun data(actions: List<TokenActionsState.ActionState>) = CryptoCurrencyData(
userWallet = mockk<UserWallet>(relaxed = true),
status = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading),
actions = actions,
isAccountMode = false,
account = mockk<AccountStatus.CryptoPortfolio>(relaxed = true),
)
@Test
fun `GIVEN sell not supported WHEN handle Sell THEN reason shown and flow not continued`() {
// Arrange
val data = data(
actions = listOf(
TokenActionsState.ActionState.Sell(
ScenarioUnavailabilityReason.NotSupportedBySellService(cryptoCurrencyName = "ETH"),
),
),
)
// Act
handler.handle(
action = TokenActionsBSContentUM.Action.Sell,
cryptoCurrencyData = data,
context = TokenActionsContext.Transfer,
)
// Assert
verify { uiMessageSender.send(any<DialogMessage>()) }
verify(exactly = 0) { router.push(any(), any()) }
assertThat(handledActions).isEmpty()
}
@Test
fun `GIVEN send with empty balance WHEN handle Send THEN reason shown and sheet not dismissed`() {
// Arrange
val data = data(
actions = listOf(
TokenActionsState.ActionState.Send(
ScenarioUnavailabilityReason.EmptyBalance(ScenarioUnavailabilityReason.WithdrawalScenario.SEND),
),
),
)
// Act
handler.handle(
action = TokenActionsBSContentUM.Action.Send,
cryptoCurrencyData = data,
context = TokenActionsContext.Transfer,
)
// Assert
verify { uiMessageSender.send(any<DialogMessage>()) }
verify(exactly = 0) { router.push(any(), any()) }
assertThat(handledActions).isEmpty()
}
@Test
fun `GIVEN send available WHEN handle Send THEN navigates and no reason shown`() {
// Arrange
val data = data(
actions = listOf(TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.None)),
)
// Act
handler.handle(
action = TokenActionsBSContentUM.Action.Send,
cryptoCurrencyData = data,
context = TokenActionsContext.Transfer,
)
// Assert
verify { router.push(any(), any()) }
verify(exactly = 0) { uiMessageSender.send(any<DialogMessage>()) }
assertThat(handledActions).isNotEmpty()
}
}

View file

@ -80,7 +80,7 @@
<string name="action_buttons_swap_not_enough_tokens_alert_title">Token hinzufügen</string>
<string name="action_buttons_you_want_to_receive">Wähle den Token aus, den Du erhalten möchtest</string>
<string name="action_buttons_you_want_to_swap">Wähle den Token, den Du tauschen möchtest</string>
<string name="actionbutton_addfunds_title">Guthaben hinzufügen</string>
<string name="actionbutton_addfunds_title">Einzahlen</string>
<string name="actionbutton_swap_title">Tauschen</string>
<string name="actionbutton_transfer_title">Übertragung</string>
<string name="add_and_manage_sheet_manage_subtitle">Zum Portfolio hinzufügen</string>

View file

@ -80,7 +80,7 @@
<string name="action_buttons_swap_not_enough_tokens_alert_title">Añada tokens</string>
<string name="action_buttons_you_want_to_receive">Seleccione el token que desea recibir</string>
<string name="action_buttons_you_want_to_swap">Seleccione el token que desea intercambiar</string>
<string name="actionbutton_addfunds_title">Agregar fondos</string>
<string name="actionbutton_addfunds_title">Depositar</string>
<string name="actionbutton_swap_title">Intercambiar</string>
<string name="actionbutton_transfer_title">Transferir</string>
<string name="add_and_manage_sheet_manage_subtitle">Añadir a su portafolio</string>

View file

@ -80,7 +80,7 @@
<string name="action_buttons_swap_not_enough_tokens_alert_title">Ajouter des jetons</string>
<string name="action_buttons_you_want_to_receive">Sélectionnez le jeton que vous souhaitez recevoir</string>
<string name="action_buttons_you_want_to_swap">Sélectionnez le jeton que vous souhaitez échanger</string>
<string name="actionbutton_addfunds_title">Ajouter des fonds</string>
<string name="actionbutton_addfunds_title">Ajouter fonds</string>
<string name="actionbutton_swap_title">Échanger</string>
<string name="actionbutton_transfer_title">Transférer</string>
<string name="add_and_manage_sheet_manage_subtitle">Ajouter à votre portfolio</string>

View file

@ -80,7 +80,7 @@
<string name="action_buttons_swap_not_enough_tokens_alert_title">Adicionar tokens</string>
<string name="action_buttons_you_want_to_receive">Escolha o token que deseja receber.</string>
<string name="action_buttons_you_want_to_swap">Escolha o token que deseja trocar.</string>
<string name="actionbutton_addfunds_title">Adicionar fundos</string>
<string name="actionbutton_addfunds_title">Depositar</string>
<string name="actionbutton_swap_title">Trocar</string>
<string name="actionbutton_transfer_title">Transferir</string>
<string name="add_and_manage_sheet_manage_subtitle">Adicione ao seu portfólio</string>

View file

@ -337,7 +337,11 @@ internal class ManageFundsModel @Inject constructor(
}
private fun tokenActionsRoute(status: CryptoCurrencyStatus): UiRoute.TokenActions {
val title = resourceReference(R.string.get_token_title, wrappedList(status.currency.name))
val title = when (flowType) {
ManageFundsComponent.FlowType.Transfer -> resourceReference(R.string.common_transfer)
ManageFundsComponent.FlowType.AddFunds ->
resourceReference(R.string.get_token_title, wrappedList(status.currency.name))
}
return UiRoute.TokenActions(title = title)
}