Updated on 2026-08-14
This commit is contained in:
commit
21418379dd
10 changed files with 261 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue