diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt index 63add1f9fe..a4c5eb7019 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt @@ -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, + ): 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 diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsContext.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsContext.kt index 15b9d04654..93c61ee13f 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsContext.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsContext.kt @@ -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?, 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, ), } \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt index 02e289b3f1..e3fc731e4e 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt @@ -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 diff --git a/common/ui-markets/src/test/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverterTest.kt b/common/ui-markets/src/test/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverterTest.kt index d6cecd4063..c470ad2253 100644 --- a/common/ui-markets/src/test/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverterTest.kt +++ b/common/ui-markets/src/test/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverterTest.kt @@ -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) = 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 + } } \ No newline at end of file diff --git a/common/ui-markets/src/test/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandlerUnavailabilityTest.kt b/common/ui-markets/src/test/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandlerUnavailabilityTest.kt new file mode 100644 index 0000000000..dd58eda8a8 --- /dev/null +++ b/common/ui-markets/src/test/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandlerUnavailabilityTest.kt @@ -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() + + 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) = CryptoCurrencyData( + userWallet = mockk(relaxed = true), + status = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading), + actions = actions, + isAccountMode = false, + account = mockk(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()) } + 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()) } + 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()) } + assertThat(handledActions).isNotEmpty() + } +} \ No newline at end of file diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 26eb9b5062..fbad39f2b3 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -80,7 +80,7 @@ Token hinzufügen Wähle den Token aus, den Du erhalten möchtest Wähle den Token, den Du tauschen möchtest - Guthaben hinzufügen + Einzahlen Tauschen Übertragung Zum Portfolio hinzufügen diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index b841808ce4..3597532cea 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -80,7 +80,7 @@ Añada tokens Seleccione el token que desea recibir Seleccione el token que desea intercambiar - Agregar fondos + Depositar Intercambiar Transferir Añadir a su portafolio diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 2c96087d42..4871755d8e 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -80,7 +80,7 @@ Ajouter des jetons Sélectionnez le jeton que vous souhaitez recevoir Sélectionnez le jeton que vous souhaitez échanger - Ajouter des fonds + Ajouter fonds Échanger Transférer Ajouter à votre portfolio diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index 019a3eebb1..9d2f9d61d6 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -80,7 +80,7 @@ Adicionar tokens Escolha o token que deseja receber. Escolha o token que deseja trocar. - Adicionar fundos + Depositar Trocar Transferir Adicione ao seu portfólio diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsModel.kt index d287b7b093..fddb84e852 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/model/ManageFundsModel.kt @@ -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) }