Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-23 18:01:25 +05:00
parent 53b02cfb10
commit 2be7445212
81 changed files with 4444 additions and 3434 deletions

View file

@ -2,10 +2,11 @@ package com.tangem.features.onramp.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import com.tangem.utils.logging.TangemLogger
internal class DefaultSwapDeepLinkHandler @AssistedInject constructor(
router: AppRouter,
@ -19,7 +20,12 @@ internal class DefaultSwapDeepLinkHandler @AssistedInject constructor(
TangemLogger.e("Error on getting user wallet: $it")
},
ifRight = { userWallet ->
router.push(AppRoute.SwapCrypto(userWallet.walletId))
router.push(
AppRoute.Swap(
userWalletId = userWallet.walletId,
screenSource = AnalyticsParam.ScreensSources.Main.value,
),
)
},
)
}

View file

@ -114,8 +114,7 @@ internal class SwapSelectTokensModel @Inject constructor(
router.push(
route = AppRoute.Swap(
currencyFrom = requireNotNull(fromCurrencyStatus.value).currency,
currencyTo = status.currency,
cryptoCurrency = requireNotNull(fromCurrencyStatus.value).currency,
userWalletId = params.userWalletId,
screenSource = AnalyticsParam.ScreensSources.Main.value,
),

View file

@ -309,10 +309,7 @@ internal class OnrampTokenListModel @Inject constructor(
).isRight()
}
OnrampOperation.SWAP -> {
val isAvailable = rampStateManager.availableForSwap(
userWalletId = params.userWalletId,
cryptoCurrency = status.currency,
).isAvailable() && !status.currency.isCustom
val isAvailable = !status.currency.isCustom
val supplyStatus = status.value.yieldSupplyStatus
val isUnavailableByYieldSupply = supplyStatus?.isAllowedToSpend == false && supplyStatus.isActive

View file

@ -20,13 +20,11 @@ import com.tangem.core.ui.extensions.WrappedList
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.send.v2.api.entity.PredefinedValues
@ -44,7 +42,6 @@ import com.tangem.utils.extensions.orZero
import com.tangem.utils.isNullOrZero
import com.tangem.utils.transformer.update
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.properties.Delegates
@ -61,7 +58,6 @@ internal class SendAmountModel @Inject constructor(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val rampStateManager: RampStateManager,
private val sendAmountAlertFactory: SendAmountAlertFactory,
private val getWalletsUseCase: GetWalletsUseCase,
) : Model(), SendAmountClickIntents {
@ -73,7 +69,6 @@ internal class SendAmountModel @Inject constructor(
private val _uiState = MutableStateFlow(params.state)
val uiState = _uiState.asStateFlow()
private var isAvailableForSwap: Boolean = false
val isSendWithSwapAvailable: StateFlow<Boolean>
field = MutableStateFlow(false)
@ -87,12 +82,6 @@ internal class SendAmountModel @Inject constructor(
private var maxAmountBoundary: EnterAmountBoundary by Delegates.notNull()
init {
modelScope.launch {
isAvailableForSwap = rampStateManager.availableForSwap(
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
) == ScenarioUnavailabilityReason.None
}
configAmountNavigation()
initAppCurrency()
subscribeOnCryptoCurrencyStatusFlow()
@ -409,7 +398,7 @@ internal class SendAmountModel @Inject constructor(
val isMultiCurrency = userWallet?.isMultiCurrency == true
isSendWithSwapAvailable.update {
isAvailableForSwap && isMultiCurrency && !params.predefinedValues.isFromMainScreenQr
!params.cryptoCurrency.isCustom && isMultiCurrency && !params.predefinedValues.isFromMainScreenQr
}
}
}

View file

@ -26,12 +26,11 @@ import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils
import com.tangem.features.swap.v2.impl.common.ConfirmData
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import com.tangem.utils.logging.TangemLogger
import java.math.BigDecimal
import java.math.RoundingMode
@Suppress("LongParameterList")
internal class SwapTransactionSender @AssistedInject constructor(
@ -91,7 +90,7 @@ internal class SwapTransactionSender @AssistedInject constructor(
val feeValue = confirmData.fee?.amount?.value ?: return
val destination = confirmData.enteredDestination ?: return
val (swapDataRequestAmount, swapDataRequestCurrency) = when (confirmData.amountType) {
val swapDataRequestAmount = when (confirmData.amountType) {
SwapAmountType.From -> {
val amountValue = confirmData.enteredFromAmount ?: return
val subtracted = FeeCalculationUtils.checkAndCalculateSubtractedAmount(
@ -101,11 +100,11 @@ internal class SwapTransactionSender @AssistedInject constructor(
feeValue = feeValue,
reduceAmountBy = confirmData.reduceAmountBy,
)
subtracted to fromStatus
subtracted
}
SwapAmountType.To -> {
val amountValue = confirmData.enteredToAmount ?: return
amountValue to toStatus
amountValue
}
}
@ -117,7 +116,7 @@ internal class SwapTransactionSender @AssistedInject constructor(
val swapData = getSwapDataUseCase(
userWallet = userWallet,
fromCryptoCurrencyStatus = fromStatus,
amount = swapDataRequestAmount.toStringWithRightOffset(swapDataRequestCurrency.currency.decimals),
amount = swapDataRequestAmount,
amountType = confirmData.amountType,
toCryptoCurrency = toStatus.currency,
toAddress = destination,
@ -208,7 +207,8 @@ internal class SwapTransactionSender @AssistedInject constructor(
ifRight = { txHash ->
val timestamp = System.currentTimeMillis()
swapTransactionSentUseCase.invoke(
userWallet = userWallet,
fromUserWallet = userWallet,
toUserWallet = userWallet,
fromCryptoCurrencyStatus = fromStatus,
toCryptoCurrencyStatus = toStatus,
fromAccount = fromAccount,
@ -225,10 +225,6 @@ internal class SwapTransactionSender @AssistedInject constructor(
)
}
private fun BigDecimal.toStringWithRightOffset(decimals: Int): String {
return setScale(decimals, RoundingMode.HALF_DOWN).movePointRight(decimals).toPlainString()
}
@AssistedFactory
interface Factory {
fun create(userWallet: UserWallet): SwapTransactionSender

148
features/swap/CLAUDE.md Normal file
View file

@ -0,0 +1,148 @@
# Swap Feature
Token-to-token exchange feature. Users select FROM and TO tokens, get quotes from providers (DEX/CEX), approve ERC-20 allowances if needed, and execute swaps.
## Module Structure
```
features/swap/
api/ — Public contracts (SwapComponent, SwapEntryComponent, SwapFeatureToggles)
impl/ — UI, model, navigation, DI, token selection subfeature
domain/ — Business logic (SwapInteractor) + domain models
api/ — Domain interfaces
models/ — Domain model types (SwapPair, SwapProvider, SwapState, etc.)
data/ — Repository implementations, Retrofit APIs, Moshi DTOs
```
**Package naming:** API = `com.tangem.features.swap`, Impl = `com.tangem.feature.swap` (singular `feature`, legacy inconsistency).
## Key Components
### SwapComponent (API)
Entry point. `Params` requires `currencyFrom`, `userWalletId`, `screenSource`. Optional: `currencyTo`, `isInitialReverseOrder`, `tangemPayInput`, `preselectedToToken`, `preselectedAccount`.
### SwapEntryComponent (API)
Gateway component with sealed `Params`: `Story`, `Empty`, `Selected`, `Payment`. Routes to stories or directly to swap based on input type. See `entry/SwapEntryRoute.kt` for route definitions.
### DefaultSwapComponent (impl)
Decompose component. Creates `SwapModel` via `getOrCreateModel(params)`.
**Child navigation:**
- `childStack(SwapRoute)` for screen navigation — `SwapRoute.Main`, `SwapRoute.Success`, `SwapRoute.SelectToken(isFromDirection)` rendered via `Children` composable with fade animation
- `SlotNavigation<Unit>` for approval bottom sheet (`GiveApprovalComponent`)
- `SlotNavigation<FeeSelectorConfig>` for fee selector block
**Injected factories:** `SwapFeeSelectorBlockComponent.Factory`, `GiveApprovalComponent.Factory`, `ChooseTokenComponent.Factory`.
### SwapModel (impl)
`@ModelScoped`, extends `Model()`. The central coordinator — ~1500 lines.
**Key state:**
- `dataStateStateFlow: MutableStateFlow<SwapProcessDataState>` — reactive domain data (from/to tokens, pairs, providers, amounts, fees)
- `uiState: SwapStateHolder by mutableStateOf()` — Compose UI state built by `StateBuilder`
- `feeSelectorRepository: FeeSelectorRepository` — fee state management
- `stackNavigation: StackNavigation<SwapRoute>` — stack navigation exposed from `SwapRouter`
- `approvalSlotNavigation: SlotNavigation<Unit>` — approval bottom sheet
**Navigation:**
- `SwapRouter` wraps `AppRouter` + `StackNavigation<SwapRoute>` for screen switching and back navigation
- `swapRouter.openScreen(SwapRoute.SelectToken(isFromDirection))` to push token selection
- `swapRouter.openScreen(SwapRoute.Success)` replaces current with success screen
- `swapRouter.back()` — pops local stack or exits swap via AppRouter
**Initialization flow (init block):**
1. Subscribes to `chooseTokenBridge.onCurrencyChosen``onTokenSelect(result)`
2. Subscribes to `chooseTokenBridge.onClose` → pops slot navigation
3. Checks `ShouldShowStoriesUseCase` → pushes `AppRoute.Stories` if first-time swap
4. Resolves user country for FCA restrictions
5. Loads primary account status, initial currencies, and starts swap pair loading
**Token selection flow:**
1. User taps FROM or TO card → `onSelectTokenClick(direction)` pushes `SwapRoute.SelectToken(isFromDirection)` to stack
2. Stack creates `ChooseTokenComponent` with appropriate bridge (FROM or TO)
3. `ChooseTokenBridge` communicates selection result via Channel
4. `onTokenSelect(result)` assigns selected token to FROM or TO based on `isFromDirection`
**Swap execution flow:**
1. `onSwapClick()` — validates state, checks approval, initiates transaction
2. If approval needed → `approvalSlotNavigation.activate(Unit)`
3. On approval done → reloads quotes
4. On swap success → `swapRouter.openScreen(SwapRoute.Success)`
### StateBuilder (impl)
Pure transformation class. Takes `UiActions` + providers, builds `SwapStateHolder` from `SwapProcessDataState`.
Key methods: `createInitialLoadingState`, `createQuotesLoadedState`, `createSuccessState`, `loadingPermissionState`, `updateSwapAmount`, `addNotification`, `dismissBottomSheet`.
### SwapRouter (impl)
Wraps `AppRouter` + `StackNavigation<SwapRoute>`. Handles `openScreen(SwapRoute)` to push/replace stack entries and `back()` with special logic: SelectToken pops local stack, Success exits to screen before SwapCrypto in app stack, Main pops AppRouter. `openTokenDetails()` navigates to `AppRoute.CurrencyDetails`.
## Token Selection Subfeature (impl)
Self-contained within `choosetoken/` package:
- `ChooseTokenComponent` — API with `Params(bridge, settings, analyticsPayload)`
- `ChooseTokenBridge` — Channel-based communication: `onCurrencyChosen`, `onClose`, `onTokenSelected` (legacy), `onNewTokenAdded` (legacy). Has `settingsStateFlow` for dynamic settings.
- `ChooseTokenComponent.Settings``SwapFrom` (no market block) vs `SwapTo` (with market block)
- `ChooseTokenResult` — Contains `CryptoCurrencyStatus`, `AccountStatus`, `UserWallet`
- `DefaultChooseTokenComponent` — Has its own `ChooseTokenModel` and optional `AddToPortfolioComponent` bottom sheet slot
## Domain Layer
### SwapInteractor
Central domain interface. Methods:
- `getPair(from, to, filterProviderTypes)``Either<ExpressError, List<SwapPairLeast>>`
- `findBestQuote(from, to, providers, amount, ...)``Map<SwapProvider, SwapState>`
- `onSwap(from, to, provider, swapData, amount, fee, ...)``SwapTransactionState`
- `loadFeeForSwapTransaction(...)``Either<GetFeeError, TransactionFee/TransactionFeeExtended>`
- `getInitialCurrencyToSwap(accountStatusList, fromUserWallet, isReverse)``AccountCryptoCurrencyStatus?`
- `getTokenBalance(token)``SwapAmount`
### Key Domain Models
- `SwapPairLeast` — from/to token info + providers list
- `SwapProvider` — providerId, name, type (DEX/CEX/DEX_BRIDGE), rates, slippage, TOS links
- `SwapState` — sealed: `QuotesLoadedState`, `SwapError`, `EmptyAmountState`
- `SwapCurrencyStatus` — wraps `CryptoCurrencyStatus` + `UserWallet` + `Account`
- `SwapAmount` — value + decimals pair
- `SwapDataModel` — quote result with transaction data
## DI Modules
| Module | Scope | Bindings |
|--------|-------|----------|
| `SwapFeatureModule` | Singleton | `SwapComponent.Factory`, `SwapFeatureToggles` |
| `SwapModelModule` | ModelComponent | `SwapModel` into model map |
| `SwapEntryModule` | Singleton + Model | `SwapEntryComponent.Factory`, `SwapEntryModel` |
| `ChooseTokenModule` | Singleton + Model | `ChooseTokenComponent.Factory`, `ChooseTokenBridge.Factory`, `ChooseTokenModel` |
| `SwapSingletonModule` | Singleton | `AmountFormatter` |
## UI Layer
- `SwapScreen` — main swap composable (send card, receive card, swap button, provider, notifications, fee)
- `SwapSuccessScreen` — post-swap success with transaction details
- `SwapScreenContent` — layout with `ConstraintLayout` for card positioning
- `TransactionCard` / `TransactionCardEmpty` — token cards with amount input
- Token cards pass `TokenSelectionDirection.FROM` / `.TO` to `onSelectTokenClick`
## Navigation Summary
```
AppRouter (global)
└─ AppRoute.Swap → DefaultSwapComponent
├─ childStack(SwapRoute)
│ ├─ SwapRoute.Main → SwapMainChild (renders SwapScreen)
│ ├─ SwapRoute.Success → SwapSuccessChild (renders SwapSuccessScreen)
│ └─ SwapRoute.SelectToken → ChooseTokenComponent (FROM or TO bridge)
├─ SlotNavigation<Unit> (Approval)
│ └─ GiveApprovalComponent (bottom sheet)
└─ SlotNavigation<FeeSelectorConfig>
└─ SwapFeeSelectorBlockComponent (inline fee block)
```
## Build Commands
```bash
./gradlew :features:swap:impl:compileDebugKotlin
./gradlew :features:swap:api:compileDebugKotlin
./gradlew :features:swap:domain:compileDebugKotlin
./gradlew :features:swap:impl:detekt
```

View file

@ -9,11 +9,10 @@ import java.math.BigDecimal
interface SwapComponent : ComposableContentComponent {
data class Params(
val currencyFrom: CryptoCurrency,
val currencyTo: CryptoCurrency? = null,
val userWalletId: UserWalletId,
val isInitialReverseOrder: Boolean = false,
val cryptoCurrency: CryptoCurrency? = null,
val screenSource: String,
val currencyPosition: CurrencyPosition = CurrencyPosition.ANY,
val tangemPayInput: TangemPayInput? = null,
) {
data class TangemPayInput(
@ -22,6 +21,16 @@ interface SwapComponent : ComposableContentComponent {
val depositAddress: String,
val isWithdrawal: Boolean,
)
/** Preferred position of the pre-selected currency on the swap screen. */
enum class CurrencyPosition {
/** Force-place as the FROM (send) currency. */
FROM,
/** Force-place as the TO (receive) currency. */
TO,
/** Auto-determine position based on availability and balance. */
ANY,
}
}
interface Factory : ComponentFactory<Params, SwapComponent>

View file

@ -23,6 +23,7 @@ import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.feature.swap.converters.*
import com.tangem.feature.swap.domain.api.SwapRepository
@ -226,7 +227,8 @@ internal class DefaultSwapRepository(
}
override suspend fun getExchangeStatus(
userWallet: UserWallet,
userWallet: UserWallet?,
userWalletId: UserWalletId,
txId: String,
): Either<UnknownError, ExchangeStatusModel> {
return withContext(coroutineDispatcher.io) {
@ -236,7 +238,7 @@ internal class DefaultSwapRepository(
exchangeStatusConverter.convert(
tangemExpressApi
.getExchangeStatus(
userWalletId = userWallet.walletId.stringValue,
userWalletId = userWalletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,

View file

@ -41,7 +41,8 @@ internal class DefaultSwapTransactionRepository(
private val userTokensResponseFactory = UserTokensResponseFactory()
override suspend fun storeTransaction(
userWalletId: UserWalletId,
fromUserWalletId: UserWalletId,
toUserWalletId: UserWalletId,
fromCryptoCurrency: CryptoCurrency,
toCryptoCurrency: CryptoCurrency,
fromAccount: Account?,
@ -62,7 +63,8 @@ internal class DefaultSwapTransactionRepository(
val tokenTransactions = savedTransactions
?.firstOrNull { savedTx ->
savedTx.checkId(
checkUserWalletId = userWalletId,
fromUserWalletId = fromUserWalletId,
toUserWalletId = toUserWalletId,
fromCurrencyId = fromCryptoCurrency.id,
toCurrencyId = toCryptoCurrency.id,
)
@ -76,7 +78,8 @@ internal class DefaultSwapTransactionRepository(
mutablePreferences.setObject(
key = PreferencesKeys.SWAP_TRANSACTIONS_KEY,
value = savedTransactions?.updateList(
userWalletId = userWalletId,
fromUserWalletId = fromUserWalletId,
toUserWalletId = toUserWalletId,
fromCryptoCurrency = fromCryptoCurrency,
toCryptoCurrency = toCryptoCurrency,
fromAccount = fromAccount,
@ -84,7 +87,8 @@ internal class DefaultSwapTransactionRepository(
transactions = tokenTransactions,
) ?: listOf(
converter.default(
userWalletId = userWalletId,
fromUserWalletId = fromUserWalletId,
toUserWalletId = toUserWalletId,
fromCryptoCurrency = fromCryptoCurrency,
toCryptoCurrency = toCryptoCurrency,
fromAccount = fromAccount,
@ -111,13 +115,13 @@ internal class DefaultSwapTransactionRepository(
) { savedTransactions, txStatuses, accountList ->
val currencyToTxs = savedTransactions?.filter { savedTx ->
val isUserWallet = savedTx.userWalletId == userWallet.walletId.stringValue
val isUserWallet = savedTx.toUserWalletId == userWallet.walletId.stringValue
val isToCurrency = savedTx.toCryptoCurrencyId == cryptoCurrencyId.value
isUserWallet && isToCurrency
}
val currencyFromTxs = savedTransactions?.filter { savedTx ->
val isUserWallet = savedTx.userWalletId == userWallet.walletId.stringValue
val isUserWallet = savedTx.fromUserWalletId == userWallet.walletId.stringValue
val isFromCurrency = savedTx.fromCryptoCurrencyId == cryptoCurrencyId.value
isUserWallet && isFromCurrency
}
@ -227,18 +231,21 @@ internal class DefaultSwapTransactionRepository(
}
private fun SavedSwapTransactionListModelInner.checkId(
checkUserWalletId: UserWalletId,
fromUserWalletId: UserWalletId,
toUserWalletId: UserWalletId,
fromCurrencyId: CryptoCurrency.ID,
toCurrencyId: CryptoCurrency.ID,
): Boolean {
return userWalletId == checkUserWalletId.stringValue &&
return this.fromUserWalletId == fromUserWalletId.stringValue &&
this.toUserWalletId == toUserWalletId.stringValue &&
toCryptoCurrencyId == toCurrencyId.value &&
fromCryptoCurrencyId == fromCurrencyId.value
}
@Suppress("LongParameterList")
private fun List<SavedSwapTransactionListModelInner>.updateList(
userWalletId: UserWalletId,
fromUserWalletId: UserWalletId,
toUserWalletId: UserWalletId,
fromCryptoCurrency: CryptoCurrency,
toCryptoCurrency: CryptoCurrency,
fromAccount: Account?,
@ -247,7 +254,8 @@ internal class DefaultSwapTransactionRepository(
): List<SavedSwapTransactionListModelInner> {
return addOrReplace(
item = converter.default(
userWalletId = userWalletId,
fromUserWalletId = fromUserWalletId,
toUserWalletId = toUserWalletId,
fromCryptoCurrency = fromCryptoCurrency,
toCryptoCurrency = toCryptoCurrency,
fromAccount = fromAccount,
@ -256,7 +264,8 @@ internal class DefaultSwapTransactionRepository(
),
predicate = { savedTx ->
savedTx.checkId(
checkUserWalletId = userWalletId,
fromUserWalletId = fromUserWalletId,
toUserWalletId = toUserWalletId,
fromCurrencyId = fromCryptoCurrency.id,
toCurrencyId = toCryptoCurrency.id,
)

View file

@ -28,7 +28,8 @@ internal class SavedSwapTransactionListConverter(
private val userTokensResponseFactory = UserTokensResponseFactory()
override fun convert(value: SavedSwapTransactionListModel) = SavedSwapTransactionListModelInner(
userWalletId = value.userWalletId,
fromUserWalletId = value.fromUserWalletId,
toUserWalletId = value.toUserWalletId,
fromCryptoCurrencyId = value.fromCryptoCurrencyId,
toCryptoCurrencyId = value.toCryptoCurrencyId,
fromTokensResponse = userTokensResponseFactory.createResponseToken(
@ -98,7 +99,8 @@ internal class SavedSwapTransactionListConverter(
val statusWithRefundCurrency = status?.copy(refundCurrency = refundCurrency)
tx.copy(status = statusWithRefundCurrency)
},
userWalletId = value.userWalletId,
fromUserWalletId = value.fromUserWalletId,
toUserWalletId = value.toUserWalletId,
fromCryptoCurrencyId = value.fromCryptoCurrencyId,
toCryptoCurrencyId = value.toCryptoCurrencyId,
fromCryptoCurrency = fromCryptoCurrency,
@ -117,14 +119,16 @@ internal class SavedSwapTransactionListConverter(
@Suppress("LongParameterList")
fun default(
userWalletId: UserWalletId,
fromUserWalletId: UserWalletId,
toUserWalletId: UserWalletId,
fromCryptoCurrency: CryptoCurrency,
toCryptoCurrency: CryptoCurrency,
fromAccount: Account?,
toAccount: Account?,
tokenTransactions: List<SavedSwapTransactionModel>,
) = SavedSwapTransactionListModelInner(
userWalletId = userWalletId.stringValue,
fromUserWalletId = fromUserWalletId.stringValue,
toUserWalletId = toUserWalletId.stringValue,
fromCryptoCurrencyId = fromCryptoCurrency.id.value,
toCryptoCurrencyId = toCryptoCurrency.id.value,
fromTokensResponse = userTokensResponseFactory.createResponseToken(

View file

@ -20,6 +20,8 @@ dependencies {
kapt(deps.hilt.kapt)
/** Domain */
implementation(projects.domain.swap.models)
implementation(projects.domain.swap)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.card)

View file

@ -1,43 +0,0 @@
package com.tangem.feature.swap.domain
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.swap.domain.models.ui.AccountSwapCurrency
import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress
import com.tangem.feature.swap.domain.models.ui.getGroupWithReverse
import com.tangem.utils.extensions.orZero
internal class DefaultInitialToCurrencyResolver(
private val swapTransactionRepository: SwapTransactionRepository,
) : InitialToCurrencyResolver {
override suspend fun tryGetFromCache(
userWallet: UserWallet,
initialCryptoCurrency: CryptoCurrency,
state: TokensDataStateExpress,
isReverseFromTo: Boolean,
): AccountSwapCurrency? {
val id = swapTransactionRepository.getLastSwappedCryptoCurrencyId(userWallet.walletId) ?: return null
return if (id != initialCryptoCurrency.id.value) {
val group = state.getGroupWithReverse(isReverseFromTo)
group.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) ->
currencyList.find { it.isAvailable && it.cryptoCurrencyStatus.currency.id.value == id }
}
} else {
null
}
}
override fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): AccountSwapCurrency? {
val group = state.getGroupWithReverse(isReverseFromTo)
return group.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) ->
currencyList.maxByOrNull { swapAccountCurrency ->
swapAccountCurrency.cryptoCurrencyStatus.value.fiatAmount
.takeIf { swapAccountCurrency.isAvailable }
.orZero()
}
}
}
}

View file

@ -1,18 +0,0 @@
package com.tangem.feature.swap.domain
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.swap.domain.models.ui.AccountSwapCurrency
import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress
interface InitialToCurrencyResolver {
suspend fun tryGetFromCache(
userWallet: UserWallet,
initialCryptoCurrency: CryptoCurrency,
state: TokensDataStateExpress,
isReverseFromTo: Boolean,
): AccountSwapCurrency?
fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): AccountSwapCurrency?
}

View file

@ -2,87 +2,58 @@ package com.tangem.feature.swap.domain
import arrow.core.Either
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
import com.tangem.feature.swap.domain.models.domain.PermissionOptions
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.SwapTransactionState
import com.tangem.feature.swap.domain.models.ui.TxFee
import java.math.BigDecimal
interface SwapInteractor {
suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress
suspend fun getPair(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
filterProviderTypes: List<ExchangeProviderType>,
): Either<ExpressError, List<SwapPairLeast>>
/**
* Gives permission to swap, this starts scan card process
*
* @param networkId network in which selected token
* @param permissionOptions data to give permissions
*/
@Throws(IllegalStateException::class)
suspend fun givePermissionToSwap(networkId: String, permissionOptions: PermissionOptions): SwapTransactionState
suspend fun findProvidersForPairWithCheck(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
pairs: List<SwapPairLeast>,
): List<SwapProvider>
fun findProvidersForPair(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
pairs: List<SwapPairLeast>,
): List<SwapProvider>
/**
* Find best quote for given tokens to swap
* under the hood calls different methods to receive data, depends on permission for given token
*
* @param fromToken token from which want to swap
* @param fromAccount account from which swap will be made
* @param toToken token that receive after swap
* @param toAccount account to which receive token after swap
* @param providers list of providers to find quote
* @param amountToSwap amount you want to swap
* @param reduceBalanceBy amount to reduce from balance (used for fee calculation)
* @param txFeeSealedState selected fee to swap
* @return
*/
@Suppress("LongParameterList")
@Throws(IllegalStateException::class)
suspend fun findBestQuote(
fromToken: CryptoCurrencyStatus,
fromAccount: Account?,
toToken: CryptoCurrencyStatus,
toAccount: Account?,
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
providers: List<SwapProvider>,
amountToSwap: String,
reduceBalanceBy: BigDecimal,
txFeeSealedState: TxFeeSealedState,
): Map<SwapProvider, SwapState>
/**
* Starts swap transaction, perform sign transaction
*
* @param swapProvider swap provider to use
* @param swapData tx data to swap, contains data to sign
* @param currencyToSend crypto currency to send
* @param currencyToGet crypto currency to get
* @param fromAccount account from which swap will be made
* @param toAccount account to which receive token
* @param amountToSwap amount to swap
* @param includeFeeInAmount flag to include fee in amount
* @param fee for tx (can be null only for tangem pay withdrawal)
* @param expressOperationType type of express operation
* @return [SwapTransactionState]
*/
@Suppress("LongParameterList")
@Throws(IllegalStateException::class)
suspend fun onSwap(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
swapProvider: SwapProvider,
swapData: SwapDataModel?,
currencyToSend: CryptoCurrencyStatus,
currencyToGet: CryptoCurrencyStatus,
fromAccount: Account?,
toAccount: Account?,
amountToSwap: String,
includeFeeInAmount: IncludeFeeInAmount,
fee: TxFee?,
@ -90,14 +61,6 @@ interface SwapInteractor {
isTangemPayWithdrawal: Boolean,
): SwapTransactionState
// suspend fun updateQuotesStateWithSelectedFee(
// state: SwapState.QuotesLoadedState,
// selectedFee: FeeType,
// fromToken: CryptoCurrencyStatus,
// amountToSwap: String,
// reduceBalanceBy: BigDecimal,
// ): SwapState.QuotesLoadedState
/**
* Returns token in wallet balance
*
@ -105,27 +68,12 @@ interface SwapInteractor {
*/
fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount
/**
* Returns initial currency to swap as AccountSwapCurrency
*
* @param initialCryptoCurrency initial currency selected to swap
* @param state current tokens data state
* @param isReverseFromTo flag indicating the direction of the swap
*/
suspend fun getInitialCurrencyToSwap(
initialCryptoCurrency: CryptoCurrency,
state: TokensDataStateExpress,
isReverseFromTo: Boolean,
): AccountSwapCurrency?
suspend fun getNativeToken(network: Network): CryptoCurrency
suspend fun getNativeToken(swapCurrencyStatus: SwapCurrencyStatus): CryptoCurrency
@Suppress("LongParameterList")
suspend fun storeSwapTransaction(
currencyToSend: CryptoCurrencyStatus,
currencyToGet: CryptoCurrencyStatus,
fromAccount: Account?,
toAccount: Account?,
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
amount: SwapAmount,
swapProvider: SwapProvider,
swapDataModel: SwapDataModel,
@ -135,51 +83,19 @@ interface SwapInteractor {
averageDuration: Int? = null,
)
/**
* Loads fee for swap transaction
*
* @param fromToken token from which want to swap
* @param fromAccount account from which swap will be made
* @param toToken token that receive after swap
* @param toAccount account to which receive token after swap
* @param amount amount you want to swap
* @param reduceBalanceBy amount to reduce from balance (used for fee calculation)
* @param selectedFeeToken selected token to pay fee or null to pay fee with coin
*/
@Suppress("LongParameterList")
suspend fun loadFeeForSwapTransaction(
fromToken: CryptoCurrencyStatus,
fromAccount: Account?,
toToken: CryptoCurrencyStatus,
toAccount: Account?,
fromSwapCurrencyStatus: SwapCurrencyStatus,
amount: String,
reduceBalanceBy: BigDecimal,
provider: SwapProvider,
selectedFeeToken: CryptoCurrencyStatus?,
): Either<GetFeeError, TransactionFeeExtended>
/**
* Loads fee for swap transaction
*
* @param fromToken token from which want to swap
* @param fromAccount account from which swap will be made
* @param toToken token that receive after swap
* @param toAccount account to which receive token after swap
* @param amount amount you want to swap
* @param reduceBalanceBy amount to reduce from balance (used for fee calculation)
*/
@Suppress("LongParameterList")
suspend fun loadFeeForSwapTransaction(
fromToken: CryptoCurrencyStatus,
fromAccount: Account?,
toToken: CryptoCurrencyStatus,
toAccount: Account?,
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
amount: String,
reduceBalanceBy: BigDecimal,
provider: SwapProvider,
): Either<GetFeeError, TransactionFee>
interface Factory {
fun create(selectedWalletId: UserWalletId): SwapInteractor
}
}

View file

@ -14,7 +14,8 @@ interface SwapTransactionRepository {
@Suppress("LongParameterList")
suspend fun storeTransaction(
userWalletId: UserWalletId,
fromUserWalletId: UserWalletId,
toUserWalletId: UserWalletId,
fromCryptoCurrency: CryptoCurrency,
toCryptoCurrency: CryptoCurrency,
fromAccount: Account?,

View file

@ -4,6 +4,7 @@ import arrow.core.Either
import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.domain.*
@ -23,7 +24,11 @@ interface SwapRepository {
isIgnoreExpress: Boolean = false,
): PairsWithProviders
suspend fun getExchangeStatus(userWallet: UserWallet, txId: String): Either<UnknownError, ExchangeStatusModel>
suspend fun getExchangeStatus(
userWallet: UserWallet?,
userWalletId: UserWalletId,
txId: String,
): Either<UnknownError, ExchangeStatusModel>
@Suppress("LongParameterList")
suspend fun findBestQuote(

View file

@ -1,6 +1,10 @@
package com.tangem.feature.swap.domain.di
import com.tangem.feature.swap.domain.*
import com.tangem.feature.swap.domain.AllowPermissionsHandler
import com.tangem.feature.swap.domain.AllowPermissionsHandlerImpl
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.SwapInteractorImpl
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -16,20 +20,13 @@ internal class SwapDomainModule {
fun provideAllowPermissionsHandler(): AllowPermissionsHandler {
return AllowPermissionsHandlerImpl()
}
}
@Provides
@Singleton
fun provideSwapInteractorFactory(factory: SwapInteractorImpl.Factory): SwapInteractor.Factory {
return factory
}
@Module
@InstallIn(SingletonComponent::class)
internal interface SwapDomainBindModule {
@Provides
@Binds
@Singleton
fun provideInitialToCurrencyResolver(
swapTransactionRepository: SwapTransactionRepository,
): InitialToCurrencyResolver {
return DefaultInitialToCurrencyResolver(
swapTransactionRepository = swapTransactionRepository,
)
}
fun provideSwapInteractor(swapInteractor: SwapInteractorImpl): SwapInteractor
}

View file

@ -1,6 +0,0 @@
package com.tangem.feature.swap.domain.models.domain
data class NetworkInfo(
val name: String,
val blockchainId: String,
)

View file

@ -1,23 +0,0 @@
package com.tangem.feature.swap.domain.models.domain
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.ui.RequestApproveStateData
import com.tangem.feature.swap.domain.models.ui.TxFee
/**
* Permission options
*
* @param approveData tx data to give approve, it loaded from 1inch in findBestQuote if needed
* @param forTokenContractAddress token contract address for which needs permission
* @param fromTokenStatus which token will be swapping
* @param approveType unlimited or tx amount approve
* @param txFee fee for tx
*/
data class PermissionOptions(
val approveData: RequestApproveStateData,
val forTokenContractAddress: String,
val fromTokenStatus: CryptoCurrencyStatus,
val spenderAddress: String,
val approveType: SwapApproveType,
val txFee: TxFee,
)

View file

@ -5,12 +5,10 @@ import com.tangem.feature.swap.domain.models.SwapAmount
/**
* Prepared swap config state that contains flags to determine
*
* @property isAllowedToSpend shows is token allowed to spend
* @property isBalanceEnough shows is balance of token enough
*/
// todo Refactor this state
data class PreparedSwapConfigState(
val isAllowedToSpend: Boolean,
val isBalanceEnough: Boolean,
val feeState: SwapFeeState,
val hasOutgoingTransaction: Boolean,

View file

@ -8,7 +8,8 @@ import com.tangem.domain.models.currency.CryptoCurrency
import java.math.BigDecimal
data class SavedSwapTransactionListModel(
val userWalletId: String,
val fromUserWalletId: String,
val toUserWalletId: String,
val fromCryptoCurrencyId: String,
val toCryptoCurrencyId: String,
val fromCryptoCurrency: CryptoCurrency,
@ -25,7 +26,9 @@ data class SavedSwapTransactionListModel(
@JsonClass(generateAdapter = true)
data class SavedSwapTransactionListModelInner(
@Json(name = "userWalletId")
val userWalletId: String,
val fromUserWalletId: String,
@Json(name = "toUserWalletId")
val toUserWalletId: String = fromUserWalletId,
@Json(name = "fromCryptoCurrencyId")
val fromCryptoCurrencyId: String,
@Json(name = "toCryptoCurrencyId")

View file

@ -1,12 +0,0 @@
package com.tangem.feature.swap.domain.models.domain
enum class SwapApproveType {
LIMITED, UNLIMITED
}
fun SwapApproveType.getNameForAnalytics(): String {
return when (this) {
SwapApproveType.LIMITED -> "Transaction"
SwapApproveType.UNLIMITED -> "Unlimited"
}
}

View file

@ -1,11 +1,8 @@
package com.tangem.feature.swap.domain.models.domain
import com.tangem.domain.models.currency.CryptoCurrency
sealed class SwapFeeState {
data object Enough : SwapFeeState()
data class NotEnough(
val feeCurrency: CryptoCurrency? = null,
val currencyName: String? = null,
val currencySymbol: String? = null,
) : SwapFeeState()

View file

@ -59,11 +59,21 @@ data class SwapProvider(
@JsonClass(generateAdapter = false)
enum class ExchangeProviderType(val providerName: String) {
@Json(name = "DEX") DEX("DEX"),
@Json(name = "DEX")
DEX("DEX"),
@Json(name = "CEX") CEX("CEX"),
@Json(name = "CEX")
CEX("CEX"),
@Json(name = "DEX_BRIDGE") DEX_BRIDGE("DEX/Bridge"),
@Json(name = "DEX_BRIDGE")
DEX_BRIDGE("DEX/Bridge"),
;
companion object {
fun getSwapProviderTypes(): List<ExchangeProviderType> {
return listOf(CEX, DEX, DEX_BRIDGE)
}
}
}
/**
@ -73,7 +83,9 @@ enum class ExchangeProviderType(val providerName: String) {
*/
@JsonClass(generateAdapter = false)
enum class RateType {
@Json(name = "FLOAT") FLOAT,
@Json(name = "FLOAT")
FLOAT,
@Json(name = "FIXED") FIXED,
@Json(name = "FIXED")
FIXED,
}

View file

@ -3,8 +3,8 @@ package com.tangem.feature.swap.domain.models.ui
import androidx.compose.runtime.Immutable
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.feature.swap.domain.TransactionFeeResult
import com.tangem.feature.swap.domain.models.ExpressDataError
@ -23,7 +23,6 @@ sealed interface SwapState {
val toTokenInfo: TokenSwapInfo,
val priceImpact: PriceImpact,
val preparedSwapConfigState: PreparedSwapConfigState = PreparedSwapConfigState(
isAllowedToSpend = false,
isBalanceEnough = false,
feeState = SwapFeeState.NotEnough(),
hasOutgoingTransaction = false,
@ -81,17 +80,11 @@ data class PriceImpact(
sealed class PermissionDataState {
data class PermissionReadyForRequest(
val currency: String,
val amount: String,
val walletAddress: String,
val spenderAddress: String,
val requestApproveData: RequestApproveStateData,
data class PermissionRequired(
val isResetApproval: Boolean,
val spenderAddress: String,
) : PermissionDataState()
object PermissionFailed : PermissionDataState()
object PermissionLoading : PermissionDataState()
object Empty : PermissionDataState()
@ -100,8 +93,7 @@ sealed class PermissionDataState {
data class TokenSwapInfo(
val tokenAmount: SwapAmount,
val amountFiat: BigDecimal,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val account: Account?,
val swapCurrencyStatus: SwapCurrencyStatus,
)
data class RequestApproveStateData(

View file

@ -1,9 +1,8 @@
package com.tangem.feature.swap.domain.models.ui
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
@ -36,10 +35,8 @@ sealed class SwapTransactionState {
) : SwapTransactionState() {
data class StoreTransactionData(
val currencyToSend: CryptoCurrencyStatus,
val currencyToGet: CryptoCurrencyStatus,
val fromAccount: Account?,
val toAccount: Account?,
val fromSwapCurrencyStatus: SwapCurrencyStatus,
val toSwapCurrencyStatus: SwapCurrencyStatus,
val amount: SwapAmount,
val swapProvider: SwapProvider,
val swapDataModel: SwapDataModel,

View file

@ -11,6 +11,10 @@ android {
namespace = "com.tangem.feature.swap.presentation"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Api */
implementation(projects.features.commonFeatures.api)
@ -59,6 +63,8 @@ dependencies {
implementation(projects.domain.account.status)
implementation(projects.domain.visa)
implementation(projects.domain.markets)
implementation(projects.domain.swap)
implementation(projects.domain.swap.models)
/** Feature modules */
implementation(projects.features.swap.domain)
@ -106,4 +112,8 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Test */
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
}

View file

@ -1,30 +1,37 @@
package com.tangem.feature.swap
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.fade
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.essenty.lifecycle.subscribe
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.R
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.model.SwapModel
import com.tangem.feature.swap.router.SwapNavScreen
import com.tangem.feature.swap.models.SwapPermissionUM
import com.tangem.feature.swap.router.SwapRoute
import com.tangem.feature.swap.ui.SwapScreen
import com.tangem.feature.swap.ui.SwapSuccessScreen
import com.tangem.features.approval.api.GiveApprovalComponent
@ -46,17 +53,24 @@ internal class DefaultSwapComponent @AssistedInject constructor(
private val chooseTokenComponentFactory: ChooseTokenComponent.Factory,
) : SwapComponent, AppComponentContext by appComponentContext {
private val model: SwapModel = getOrCreateModel(params)
private val stackNavigation = StackNavigation<SwapRoute>()
private val innerRouter = InnerRouter<SwapRoute>(
stackNavigation = stackNavigation,
popCallback = { onChildBack() },
)
// todo swap create InnerRouter
private val chooseTokenComponent by lazy {
chooseTokenComponentFactory.create(
context = child("chooseTokenComponent"),
params = ChooseTokenComponent.Params(
bridge = model.chooseTokenBridge,
),
)
}
private val model: SwapModel = getOrCreateModel(params, router = innerRouter)
private val childStack = childStack(
key = STACK_KEY,
source = stackNavigation,
serializer = null,
initialConfiguration = SwapRoute.Main,
handleBackButton = true,
childFactory = { route, factoryContext ->
createChild(route, childByContext(factoryContext))
},
)
private val approvalSlot = childSlot(
key = APPROVAL_SLOT_KEY,
@ -81,7 +95,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
}
private val slotNavigation = SlotNavigation<FeeSelectorConfig>()
private val childSlot = childSlot(
private val feeSelectorSlot = childSlot(
source = slotNavigation,
serializer = null,
key = FEE_SELECTOR_SLOT_KEY,
@ -112,6 +126,19 @@ internal class DefaultSwapComponent @AssistedInject constructor(
)
}
private fun createChild(route: SwapRoute, factoryContext: AppComponentContext): ComposableContentComponent =
when (route) {
is SwapRoute.Main -> SwapMainChild()
is SwapRoute.Success -> SwapSuccessChild()
is SwapRoute.SelectToken -> {
val bridge = if (route.isFromDirection) model.chooseFromTokenBridge else model.chooseToTokenBridge
chooseTokenComponentFactory.create(
context = factoryContext,
params = ChooseTokenComponent.Params(bridge = bridge),
)
}
}
data class FeeSelectorConfig(
val sendingCurrencyStatus: CryptoCurrencyStatus,
val feeCurrencyStatus: CryptoCurrencyStatus,
@ -121,7 +148,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val dataState by model.dataStateStateFlow.collectAsStateWithLifecycle()
val fromCryptoCurrency by remember { derivedStateOf { dataState.fromCryptoCurrency } }
val fromCryptoCurrency by remember { derivedStateOf { dataState.fromSwapCurrencyStatus?.status } }
val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } }
val shouldHideBlock by remember {
derivedStateOf { toBigDecimalOrZero(dataState.amount).isZero() || model.uiState.isInsufficientFunds }
@ -158,67 +185,78 @@ internal class DefaultSwapComponent @AssistedInject constructor(
)
}
val feeSelectorChildStackState by childSlot.subscribeAsState()
val feeSelectorBlockComponent = feeSelectorChildStackState.child?.instance
val stackState by childStack.subscribeAsState()
Crossfade(
Children(
stack = stackState,
modifier = Modifier.background(TangemTheme.colors.background.secondary),
targetState = model.currentScreen,
label = "",
) { screen ->
when (screen) {
SwapNavScreen.Main -> SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
SwapNavScreen.Success -> {
val successState = model.uiState.successState
val feeSelectorState by model.feeSelectorRepository.state.collectAsStateWithLifecycle()
if (successState != null) {
SwapSuccessScreen(
state = successState,
feeSelectorUM = feeSelectorState,
onBack = model.uiState.onBackClicked,
)
} else {
SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
}
}
SwapNavScreen.SelectToken -> chooseTokenComponent.Content(Modifier)
}
animation = stackAnimation { fade() },
) { child ->
child.instance.Content(Modifier)
}
val approvalSlotState by approvalSlot.subscribeAsState()
approvalSlotState.child?.instance?.BottomSheet()
}
fun getApprovalParams(): GiveApprovalComponent.Params? {
val permissionState = model.uiState.permissionState as? GiveTxPermissionState.ReadyForRequest
?: return null
val fromCryptoCurrency = model.dataState.fromCryptoCurrency ?: return null
private inner class SwapMainChild : ComposableContentComponent {
@Composable
override fun Content(modifier: Modifier) {
val feeSelectorChildState by feeSelectorSlot.subscribeAsState()
val feeSelectorBlockComponent = feeSelectorChildState.child?.instance
SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
}
}
private inner class SwapSuccessChild : ComposableContentComponent {
@Composable
override fun Content(modifier: Modifier) {
val successState = model.uiState.successState
val feeSelectorState by model.feeSelectorRepository.state.collectAsStateWithLifecycle()
if (successState != null) {
SwapSuccessScreen(
state = successState,
feeSelectorUM = feeSelectorState,
onBack = router::pop,
)
} else {
val feeSelectorChildState by feeSelectorSlot.subscribeAsState()
val feeSelectorBlockComponent = feeSelectorChildState.child?.instance
SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
}
}
}
private fun getApprovalParams(): GiveApprovalComponent.Params? {
val permissionState = model.uiState.permissionUM as? SwapPermissionUM.PermissionRequired ?: return null
val fromSwapCurrencyStatus = model.dataState.fromSwapCurrencyStatus ?: return null
val feeCryptoCurrency = model.dataState.feePaidCryptoCurrency ?: return null
val providerName = model.dataState.selectedProvider?.name.orEmpty()
val isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet
return GiveApprovalComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrencyStatus = fromCryptoCurrency,
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
feeCryptoCurrencyStatus = feeCryptoCurrency,
amount = model.dataState.amount.orEmpty(),
spenderAddress = requireNotNull(model.dataState.approveDataModel).spenderAddress,
spenderAddress = permissionState.spenderAddress,
amountFooter = if (permissionState.isResetApproval) {
resourceReference(R.string.update_approval_permission_subtitle)
} else {
resourceReference(
id = R.string.give_permission_swap_subtitle,
formatArgs = wrappedList(providerName, permissionState.currency),
formatArgs = wrappedList(providerName, fromSwapCurrencyStatus.currency.symbol),
)
},
feeFooter = resourceReference(R.string.swap_give_permission_fee_footer),
isResetApproval = permissionState.isResetApproval,
isHoldToConfirm = model.isHoldToConfirmEnabled,
isHoldToConfirm = isHoldToConfirm,
callback = model.approvalCallback,
)
}
@ -227,13 +265,24 @@ internal class DefaultSwapComponent @AssistedInject constructor(
return bigDecimalString?.replace(",", ".")?.toBigDecimalOrNull() ?: BigDecimal.ZERO
}
private fun onChildBack() {
val isEmptyStack = childStack.value.backStack.isEmpty()
val isSuccess = model.uiState.successState != null
val isPopSend = isEmptyStack || isSuccess
when {
isPopSend -> router.pop()
else -> stackNavigation.pop()
}
}
@AssistedFactory
interface Factory : SwapComponent.Factory {
override fun create(context: AppComponentContext, params: SwapComponent.Params): DefaultSwapComponent
}
private companion object {
const val BOTTOM_SHEET_SLOT_KEY = "bottomSheetSlot"
const val STACK_KEY = "swapStack"
const val FEE_SELECTOR_SLOT_KEY = "feeSelectorSlot"
const val APPROVAL_SLOT_KEY = "approvalSlot"
}

View file

@ -1,6 +1,5 @@
package com.tangem.feature.swap.analytics
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO
@ -38,11 +37,6 @@ sealed class SwapEvents(
class SendTokenBalanceClicked : SwapEvents(event = "Send Token Balance Clicked")
class ChooseTokenScreenOpened(val hasAvailableTokens: Boolean) : SwapEvents(
event = "Choose Token Screen Opened",
params = mapOf("Available tokens" to if (hasAvailableTokens) "Yes" else "No"),
)
class ChooseTokenScreenResult(
val isTokenChosen: Boolean,
val token: String? = null,
@ -72,23 +66,6 @@ sealed class SwapEvents(
),
)
class ButtonPermissionApproveClicked(
val sendToken: String,
val receiveToken: String,
val approveType: ApproveType,
val provider: SwapProvider,
) : SwapEvents(
event = "Button - Permission Approve",
params = mapOf(
"Send Token" to sendToken,
"Receive Token" to receiveToken,
"Type" to if (approveType == ApproveType.LIMITED) "Current Transaction" else "Unlimited",
"Provider" to provider.name,
),
)
class ButtonPermissionCancelClicked : SwapEvents(event = "Button - Permission Cancel")
class ButtonSwipeClicked : SwapEvents(event = "Button - Swipe")
@Suppress("NullableToStringCall", "LongParameterList")

View file

@ -39,7 +39,7 @@ interface ChooseTokenBridge : ChooseTokenBridgeLegacy, ChooseTokenBridgeInternal
companion object {
val SwapFrom = Settings(
title = resourceReference(R.string.swapping_from_title),
isShowMarketBlock = false,
isShowMarketBlock = true,
isShowPaymentAccount = true,
)
val SwapTo = Settings(

View file

@ -13,6 +13,7 @@ import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent
import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel
import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenScreen
import com.tangem.feature.swap.models.AddToPortfolioRoute
import com.tangem.feature.swap.ui.SwapSelectTokenScreen
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
@ -42,10 +43,13 @@ internal class DefaultChooseTokenComponent @AssistedInject constructor(
val bottomSheet by bottomSheetSlot.subscribeAsState()
stateOld?.let { stateHolder ->
SwapSelectTokenScreen(state = stateHolder, onBack = { model.onBackClicked() })
bottomSheet.child?.instance?.BottomSheet()
// if old shown we should not show new screen
return
}
// todo swap uncomment
// val state by model.state.collectAsStateWithLifecycle()
// ChooseTokenScreen(state = state)
val state by model.state.collectAsStateWithLifecycle()
ChooseTokenScreen(state = state)
bottomSheet.child?.instance?.BottomSheet()
}

View file

@ -0,0 +1,225 @@
package com.tangem.feature.swap.model
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.swap.SwapComponent.Params.CurrencyPosition
import com.tangem.utils.extensions.orZero
import com.tangem.utils.isNullOrZero
import javax.inject.Inject
/**
* Resolves the initial FROM and TO currencies when the swap screen opens.
*
* Selection rules when [initialCryptoCurrency][CryptoCurrency] is provided:
* - [CurrencyPosition.FROM] places the currency as FROM, TO is null.
* - [CurrencyPosition.TO] places the currency as TO, FROM is null.
* - [CurrencyPosition.ANY] auto-places based on availability and balance:
* - available with balance FROM.
* - available without balance or unavailable without balance TO,
* and the best candidate from the SAME account as the initial currency is selected as FROM
* (the search is scoped to that account only, not the whole portfolio).
* - unavailable with balance FROM.
*
* When no initial currency is provided, selects the best token from crypto portfolio accounts:
* 1. If available tokens with balance exist the available token with the highest fiat balance.
* 2. If available tokens exist but none have balance the first token from the first account.
* 3. If no available tokens exist but tokens with balance exist the token with the highest fiat balance.
* 4. If no tokens have balance the first token from the first account.
*/
internal class InitialCurrenciesResolver @Inject constructor(
private val getUserWalletUseCase: GetUserWalletUseCase,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val rampStateManager: RampStateManager,
) {
/**
* Resolves the initial FROM/TO currency pair for the swap screen.
*
* @param userWalletId the wallet to resolve currencies for
* @param initialCryptoCurrency pre-selected currency, or null to auto-select
* @param swapCurrencyPosition preferred position for the initial currency
* @return pair of (from, to) [SwapCurrencyStatus]; either or both may be null
*/
suspend operator fun invoke(
userWalletId: UserWalletId,
initialCryptoCurrency: CryptoCurrency?,
swapCurrencyPosition: CurrencyPosition,
isPaymentAccount: Boolean,
): Pair<SwapCurrencyStatus?, SwapCurrencyStatus?> {
val walletAccountList = getWalletAccountCurrencyStatusList(userWalletId)
val cryptoPortfolioAccounts = walletAccountList.filterKeys { accountStatus ->
accountStatus is AccountStatus.CryptoPortfolio
}.mapKeys { (key, _) -> key as AccountStatus.CryptoPortfolio }
val cryptoPaymentAccounts = walletAccountList.filterKeys { accountStatus ->
accountStatus is AccountStatus.Payment
}
val cryptoCurrencyList = cryptoPortfolioAccounts.values.flatten()
return if (initialCryptoCurrency != null) {
val selectedSwapCurrencyStatus = if (isPaymentAccount) {
cryptoPaymentAccounts
} else {
cryptoPortfolioAccounts
}.firstNotNullOfOrNull { (_, currencyList) ->
currencyList.firstOrNull { currencyStatus ->
currencyStatus.currency.id == initialCryptoCurrency.id
}
}
if (selectedSwapCurrencyStatus == null) {
null to null
} else {
placeSelectedCurrency(
selectedSwapCurrencyStatus = selectedSwapCurrencyStatus,
swapCurrencyPosition = swapCurrencyPosition,
cryptoPortfolioAccountsMap = cryptoPortfolioAccounts,
)
}
} else {
selectCryptoCurrency(
cryptoPortfolioAccountsMap = cryptoPortfolioAccounts,
cryptoCurrencyList = cryptoCurrencyList,
) to null
}
}
/**
* Builds a map of [AccountStatus] to their [SwapCurrencyStatus] lists,
* enriching each currency with its swap availability from [RampStateManager].
*/
private suspend fun getWalletAccountCurrencyStatusList(
userWalletId: UserWalletId,
): Map<AccountStatus, List<SwapCurrencyStatus>> {
val userWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: return emptyMap()
val walletAccountCurrencyStatuses = singleAccountStatusListSupplier.getSyncOrNull(
SingleAccountStatusListProducer.Params(userWalletId),
)?.accountStatuses.orEmpty()
return walletAccountCurrencyStatuses.associateWith { accountStatus ->
val currencyStatuses = when (accountStatus) {
is AccountStatus.CryptoPortfolio -> accountStatus.flattenCurrencies()
is AccountStatus.Payment -> getPaymentAccountCurrencies(accountStatus)
}
val availabilityStates = rampStateManager.availableForSwap(
userWalletId,
currencyStatuses.map { it.currency },
)
currencyStatuses.map { cryptoCurrencyStatus ->
SwapCurrencyStatus(
userWallet = userWallet,
account = accountStatus.account,
status = cryptoCurrencyStatus,
isAvailableForSwap = availabilityStates[cryptoCurrencyStatus.currency] ==
ScenarioUnavailabilityReason.None,
)
}
}
}
private fun getPaymentAccountCurrencies(accountStatus: AccountStatus.Payment): List<CryptoCurrencyStatus> {
val paymentCryptoCurrencyStatus = when (val statusValue = accountStatus.value) {
is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus
else -> null
}
return listOfNotNull(paymentCryptoCurrencyStatus)
}
/**
* Places the [selectedSwapCurrencyStatus] into the FROM or TO slot based on [swapCurrencyPosition].
*
* For [CurrencyPosition.ANY], the position is determined by availability and balance:
* currencies that are available with balance go to FROM; otherwise, the selected currency
* goes to TO and a best-candidate FROM is resolved via [selectCryptoCurrency] scoped to the
* SAME account that the selected currency belongs to, so we never pull a FROM candidate from a
* different account in the portfolio.
*/
private fun placeSelectedCurrency(
selectedSwapCurrencyStatus: SwapCurrencyStatus,
swapCurrencyPosition: CurrencyPosition,
cryptoPortfolioAccountsMap: Map<AccountStatus.CryptoPortfolio, List<SwapCurrencyStatus>>,
): Pair<SwapCurrencyStatus?, SwapCurrencyStatus?> {
return when (swapCurrencyPosition) {
CurrencyPosition.FROM -> {
selectedSwapCurrencyStatus to null
}
CurrencyPosition.TO -> {
null to selectedSwapCurrencyStatus
}
CurrencyPosition.ANY -> {
val isAvailable = selectedSwapCurrencyStatus.isAvailableForSwap
val hasBalance = !selectedSwapCurrencyStatus.status.value.fiatAmount.isNullOrZero()
if (isAvailable && hasBalance) {
selectedSwapCurrencyStatus to null
} else if (isAvailable || !hasBalance) {
val selectedCurrency = selectedSwapCurrencyStatus.currency
val selectedAccountId = selectedSwapCurrencyStatus.account.accountId
val sameAccountEntry = cryptoPortfolioAccountsMap.entries
.firstOrNull { (accountStatus, _) -> accountStatus.account.accountId == selectedAccountId }
if (sameAccountEntry == null) {
null to selectedSwapCurrencyStatus
} else {
val scopedList = sameAccountEntry.value
.filterNot { it.currency.isSameTokenAs(selectedCurrency) }
selectCryptoCurrency(
cryptoPortfolioAccountsMap = mapOf(sameAccountEntry.key to scopedList),
cryptoCurrencyList = scopedList,
) to selectedSwapCurrencyStatus
}
} else {
selectedSwapCurrencyStatus to null
}
}
}
}
/**
* Checks whether two currencies refer to the same asset on the same network, regardless of the
* owning account. Two instances of the same token in different accounts have distinct
* [CryptoCurrency.ID] values (their derivation path differs), so id equality is not sufficient
* to detect duplicates when auto-picking a FROM candidate.
*/
private fun CryptoCurrency.isSameTokenAs(other: CryptoCurrency): Boolean {
return id.rawNetworkId == other.id.rawNetworkId &&
id.contractAddress == other.id.contractAddress
}
/**
* Selects the best token from the crypto portfolio when no initial currency is specified.
*
* Prioritizes available-for-swap tokens. Among the candidates, picks the one with the highest
* [fiatAmount][CryptoCurrencyStatus.Value.fiatAmount]. Falls back to the first token from the
* first account if no candidate has a positive balance.
*/
private fun selectCryptoCurrency(
cryptoPortfolioAccountsMap: Map<AccountStatus.CryptoPortfolio, List<SwapCurrencyStatus>>,
cryptoCurrencyList: List<SwapCurrencyStatus>,
): SwapCurrencyStatus? {
return if (cryptoCurrencyList.isEmpty()) {
null
} else {
val hasAvailable = cryptoCurrencyList.any { it.isAvailableForSwap }
val candidates = if (hasAvailable) {
cryptoCurrencyList.filter { it.isAvailableForSwap }
} else {
cryptoCurrencyList
}
candidates
.filter { !it.status.value.fiatAmount.isNullOrZero() }
.maxByOrNull { it.status.value.fiatAmount.orZero() }
?: cryptoPortfolioAccountsMap.entries.firstOrNull()?.value?.firstOrNull()
}
}
}

View file

@ -37,15 +37,6 @@ internal class SwapNotificationsFactory(
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
) {
fun getInitialErrorStateNotifications(code: Int, onRefreshClick: () -> Unit): ImmutableList<NotificationUM> {
return persistentListOf(
SwapNotificationUM.Warning.ExpressGeneralError(
code = code,
onConfirmClick = onRefreshClick,
),
)
}
fun getGeneralErrorStateNotifications(
message: TextReference?,
onClick: () -> Unit,
@ -104,7 +95,6 @@ internal class SwapNotificationsFactory(
@Suppress("LongParameterList")
fun getConfirmationStateNotifications(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
selectedFeeType: FeeType,
providerName: String,
@ -114,9 +104,9 @@ internal class SwapNotificationsFactory(
maybeAddRentExemptionError(quoteModel)
maybeAddDomainWarnings(quoteModel, feeCryptoCurrencyStatus, selectedFeeType)
maybeAddNeedReserveToCreateAccountWarning(quoteModel)
maybeAddPermissionNeededWarning(quoteModel, fromToken, providerName)
maybeAddPermissionNeededWarning(quoteModel, providerName)
maybeAddNetworkFeeCoverageWarning(quoteModel, selectedFeeType)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken, hideFee)
maybeAddUnableCoverFeeWarning(quoteModel, feeCryptoCurrencyStatus, hideFee)
maybeAddTransactionInProgressWarning(quoteModel)
maybeAddPriceImpactNotification(quoteModel.priceImpact)
}
@ -135,7 +125,7 @@ internal class SwapNotificationsFactory(
if (quoteModel.permissionState is PermissionDataState.PermissionLoading) {
add(SwapNotificationUM.Error.ApprovalInProgressWarning)
} else if (quoteModel.preparedSwapConfigState.hasOutgoingTransaction) {
val fromCurrency = quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency
val fromCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus.currency
add(
SwapNotificationUM.Error.TransactionInProgressWarning(
currencySymbol = fromCurrency.network.currencySymbol,
@ -162,7 +152,7 @@ internal class SwapNotificationsFactory(
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
selectedFeeType: FeeType,
) {
val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus
val swapCurrencyStatus = quoteModel.fromTokenInfo.swapCurrencyStatus
val includeFeeInAmount = quoteModel.preparedSwapConfigState.includeFeeInAmount
val amount = quoteModel.fromTokenInfo.tokenAmount
val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) {
@ -179,14 +169,14 @@ internal class SwapNotificationsFactory(
}
is TxFeeState.SingleFeeState -> feeState.fee
}
val isCardano = BlockchainUtils.isCardano(fromCurrencyStatus.currency.network.rawId)
val isCardano = BlockchainUtils.isCardano(swapCurrencyStatus.currency.network.rawId)
// blockchain specific
addExistentialWarningNotification(
existentialDeposit = quoteModel.currencyCheck?.existentialDeposit,
feeAmount = fee?.fee?.amount?.value.orZero(),
sendingAmount = amountToRequest.value,
cryptoCurrencyStatus = fromCurrencyStatus,
cryptoCurrencyStatus = swapCurrencyStatus.status,
onReduceClick = { reduceBy, reduceByDiff, _ ->
actions.onReduceByAmount(
// use in swap notification amountToRequest because fee is already subtracted
@ -198,7 +188,7 @@ internal class SwapNotificationsFactory(
addValidateTransactionNotifications(
dustValue = quoteModel.currencyCheck?.dustValue.orZero(),
validationError = quoteModel.validationResult,
cryptoCurrency = fromCurrencyStatus.currency,
cryptoCurrency = swapCurrencyStatus.currency,
minAdaValue = quoteModel.minAdaValue,
onReduceClick = { reduceTo, _ ->
actions.onReduceToAmount(amount.copy(value = reduceTo))
@ -209,26 +199,26 @@ internal class SwapNotificationsFactory(
dustValue = quoteModel.currencyCheck?.dustValue,
feeValue = fee?.fee?.amount?.value.orZero(),
sendingAmount = amountToRequest.value,
cryptoCurrencyStatus = fromCurrencyStatus,
cryptoCurrencyStatus = swapCurrencyStatus.status,
feeCurrencyStatus = feeCryptoCurrencyStatus,
)
}
addReserveAmountErrorNotification(
reserveAmount = quoteModel.currencyCheck?.reserveAmount,
sendingAmount = amountToRequest.value,
cryptoCurrency = fromCurrencyStatus.currency,
cryptoCurrency = swapCurrencyStatus.currency,
feeCryptoCurrency = feeCryptoCurrencyStatus?.currency,
isAccountFunded = true, // consider the account is funded on the provider side
)
addReduceAmountNotification(
cryptoCurrencyStatus = fromCurrencyStatus,
cryptoCurrencyStatus = swapCurrencyStatus.status,
fromAmount = quoteModel.fromTokenInfo.tokenAmount,
onReduceByAmount = actions.onReduceByAmount,
)
addTransactionLimitErrorNotification(
currencyCheck = quoteModel.currencyCheck,
sendingAmount = amountToRequest.value,
cryptoCurrencyStatus = fromCurrencyStatus,
cryptoCurrencyStatus = swapCurrencyStatus.status,
feeCurrencyStatus = feeCryptoCurrencyStatus,
feeValue = fee?.feeValue.orZero(),
onReduceClick = { reduceTo, _ ->
@ -240,11 +230,11 @@ internal class SwapNotificationsFactory(
private fun MutableList<NotificationUM>.maybeAddNeedReserveToCreateAccountWarning(
quoteModel: SwapState.QuotesLoadedState,
) {
val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value
val status = quoteModel.toTokenInfo.swapCurrencyStatus.status.value
if (status is CryptoCurrencyStatus.NoAccount) {
val amount = quoteModel.toTokenInfo.tokenAmount.value
val amountToCreateAccount = status.amountToCreateAccount
val currencyTo = quoteModel.toTokenInfo.cryptoCurrencyStatus.currency
val currencyTo = quoteModel.toTokenInfo.swapCurrencyStatus.currency
if (amount < amountToCreateAccount) {
add(
SwapNotificationUM.Warning.NeedReserveToCreateAccount(
@ -258,17 +248,13 @@ internal class SwapNotificationsFactory(
private fun MutableList<NotificationUM>.maybeAddPermissionNeededWarning(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
providerName: String,
) {
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend &&
quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough &&
quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest
) {
if (quoteModel.permissionState is PermissionDataState.PermissionRequired) {
add(
SwapNotificationUM.Info.PermissionNeeded(
providerName = providerName,
fromTokenSymbol = fromToken.symbol,
fromTokenSymbol = quoteModel.fromTokenInfo.swapCurrencyStatus.currency.symbol,
onApproveClick = actions.openPermissionBottomSheet,
),
)
@ -308,27 +294,28 @@ internal class SwapNotificationsFactory(
private fun MutableList<NotificationUM>.maybeAddUnableCoverFeeWarning(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
hideFee: Boolean,
) {
if (hideFee) return
val fromCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus.currency
val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough ?: return
val shouldShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.permissionState !is PermissionDataState.PermissionLoading &&
feeEnoughState.feeCurrency != fromToken
feeCryptoCurrencyStatus?.currency != fromCurrency
val isNotEnoughFee =
quoteModel.preparedSwapConfigState.includeFeeInAmount is IncludeFeeInAmount.BalanceNotEnough
val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromToken.network) &&
val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromCurrency.network) &&
quoteModel.swapProvider.type == ExchangeProviderType.CEX
if (shouldShowCoverWarning && !isGaslessAvailable || isNotEnoughFee) {
add(
SwapNotificationUM.Error.UnableToCoverFeeWarning(
fromToken = fromToken,
feeCurrency = feeEnoughState.feeCurrency,
currencyName = feeEnoughState.currencyName ?: fromToken.network.name,
currencySymbol = feeEnoughState.currencySymbol ?: fromToken.network.currencySymbol,
fromToken = fromCurrency,
feeCurrency = feeCryptoCurrencyStatus?.currency,
currencyName = feeEnoughState.currencyName ?: fromCurrency.network.name,
currencySymbol = feeEnoughState.currencySymbol ?: fromCurrency.network.currencySymbol,
onConfirmClick = actions.onBuyClick,
),
)

View file

@ -1,10 +1,10 @@
package com.tangem.feature.swap.model
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.domain.SwapPairLeast
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.RequestApproveStateData
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress
import com.tangem.feature.swap.domain.models.ui.TxFee
@ -12,20 +12,24 @@ import java.math.BigDecimal
data class SwapProcessDataState(
// Initial network id
val fromCryptoCurrency: CryptoCurrencyStatus? = null,
val toCryptoCurrency: CryptoCurrencyStatus? = null,
val fromSwapCurrencyStatus: SwapCurrencyStatus? = null,
val toSwapCurrencyStatus: SwapCurrencyStatus? = null,
val feePaidCryptoCurrency: CryptoCurrencyStatus? = null,
val fromAccount: Account? = null,
val toAccount: Account? = null,
// swap info
val pairs: List<SwapPairLeast> = emptyList(),
val selectedPairProviders: List<SwapProvider> = emptyList(),
val selectedProvider: SwapProvider? = null,
val lastLoadedSwapStates: Map<SwapProvider, SwapState> = emptyMap(),
// Amount from input
val amount: String? = null,
val reduceBalanceBy: BigDecimal = BigDecimal.ZERO,
val approveDataModel: RequestApproveStateData? = null,
val swapDataModel: SwapDataModel? = null,
val selectedFee: TxFee.Legacy? = null,
val tokensDataState: TokensDataStateExpress? = null,
val selectedProvider: SwapProvider? = null,
val lastLoadedSwapStates: Map<SwapProvider, SwapState> = emptyMap(),
) {
fun getCurrentLoadedSwapState(): SwapState.QuotesLoadedState? {

View file

@ -4,11 +4,10 @@ import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
@ -18,14 +17,13 @@ import kotlinx.collections.immutable.persistentListOf
internal data class SwapStateHolder(
val sendCardData: SwapCardState,
val receiveCardData: SwapCardState,
val blockchainId: String, // not the same as networkId, its local id in app
val notifications: ImmutableList<NotificationUM> = persistentListOf(),
val isInsufficientFunds: Boolean,
val changeCardsButtonState: ChangeCardsButtonState,
val providerState: ProviderState,
val fee: FeeItemState = FeeItemState.Empty,
val permissionState: GiveTxPermissionState = GiveTxPermissionState.Empty,
val permissionUM: SwapPermissionUM = SwapPermissionUM.Empty,
val priceImpact: PriceImpact,
val successState: SwapSuccessStateHolder? = null,
@ -37,34 +35,35 @@ internal data class SwapStateHolder(
val onRefresh: () -> Unit,
val onBackClicked: () -> Unit,
val onChangeCardsClicked: () -> Unit,
val onSelectTokenClick: (() -> Unit),
val onSelectTokenClick: ((TokenSelectionDirection) -> Unit),
val onSuccess: (() -> Unit),
val onMaxAmountSelected: (() -> Unit)? = null,
val onShowPermissionBottomSheet: () -> Unit = {},
)
@Immutable
sealed class SwapCardState {
abstract val type: TransactionCardType
data class SwapCardData(
@DrawableRes val networkIconRes: Int?,
val type: TransactionCardType,
override val type: TransactionCardType,
val currencyIconState: CurrencyIconState,
val tokenSymbol: TextReference,
val amountEquivalent: TextReference?,
val token: CryptoCurrencyStatus?,
val coinId: String?,
val amountTextFieldValue: TextFieldValue?,
val tokenIconUrl: String?,
val tokenCurrency: String,
val balance: String,
val isBalanceHidden: Boolean,
val isNotNativeToken: Boolean,
val canSelectAnotherToken: Boolean = false,
) : SwapCardState()
data class Empty(
val type: TransactionCardType,
val amountEquivalent: TextReference?,
override val type: TransactionCardType,
val amountEquivalent: TextReference,
val amountTextFieldValue: TextFieldValue?,
val canSelectAnotherToken: Boolean = false,
) : SwapCardState()
data class Loading(
override val type: TransactionCardType,
) : SwapCardState()
}
@ -79,21 +78,21 @@ data class SwapButton(
@Immutable
sealed interface TransactionCardType {
val accountTitleUM: AccountTitleUM?
val accountTitleUM: AccountTitleUM
val inputError: InputError
data class Inputtable(
val onAmountChanged: ((String) -> Unit),
val onFocusChanged: ((Boolean) -> Unit),
override val inputError: InputError,
override val accountTitleUM: AccountTitleUM?,
override val accountTitleUM: AccountTitleUM,
) : TransactionCardType
data class ReadOnly(
val shouldShowWarning: Boolean = false,
val onWarningClick: (() -> Unit)? = null,
override val inputError: InputError = InputError.Empty,
override val accountTitleUM: AccountTitleUM? = null,
override val accountTitleUM: AccountTitleUM,
) : TransactionCardType
sealed interface InputError {
@ -116,4 +115,14 @@ data class LegalState(
enum class ChangeCardsButtonState {
ENABLED, DISABLED, UPDATE_IN_PROGRESS
}
sealed class SwapPermissionUM {
data class PermissionRequired(
val isResetApproval: Boolean,
val spenderAddress: String,
) : SwapPermissionUM()
object Empty : SwapPermissionUM()
}

View file

@ -0,0 +1,6 @@
package com.tangem.feature.swap.models
internal enum class TokenSelectionDirection {
FROM,
TO,
}

View file

@ -1,33 +1,28 @@
package com.tangem.feature.swap.models
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.TxFee
import java.math.BigDecimal
data class UiActions(
internal data class UiActions(
val onAmountChanged: (String) -> Unit,
val onAmountSelected: (Boolean) -> Unit,
val onSwapClick: () -> Unit,
val onGivePermissionClick: () -> Unit,
val onChangeCardsClicked: () -> Unit,
val onBackClicked: () -> Unit,
val onMaxAmountSelected: () -> Unit,
val onReduceToAmount: (SwapAmount) -> Unit,
val onReduceByAmount: (SwapAmount, reduceBy: BigDecimal) -> Unit,
val openPermissionBottomSheet: () -> Unit,
val onChangeApproveType: (ApproveType) -> Unit,
// region new actions
val onRetryClick: () -> Unit,
val onClickFee: () -> Unit,
val onSelectFeeType: (TxFee.Legacy) -> Unit,
val onProviderClick: (String) -> Unit,
val onProviderSelect: (String) -> Unit,
val onBuyClick: (CryptoCurrency) -> Unit,
val onSelectTokenClick: () -> Unit,
val onBuyClick: () -> Unit,
val onSelectTokenClick: (TokenSelectionDirection) -> Unit,
val onSuccess: () -> Unit,
val onLinkClick: (String) -> Unit,
val onReceiveCardWarningClick: () -> Unit,
val onOpenLearnMoreAboutApproveClick: () -> Unit,
)

View file

@ -60,7 +60,7 @@ internal object SwapNotificationUM {
val currencyName: String,
val currencySymbol: String,
val feeCurrency: CryptoCurrency?,
val onConfirmClick: (CryptoCurrency) -> Unit,
val onConfirmClick: () -> Unit,
) : Error(
title = resourceReference(
R.string.warning_express_not_enough_fee_for_token_tx_title,
@ -74,7 +74,7 @@ internal object SwapNotificationUM {
buttonState = feeCurrency?.let {
NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_buy_currency, wrappedList(currencySymbol)),
onClick = { onConfirmClick(it) },
onClick = onConfirmClick,
)
},
)

View file

@ -0,0 +1,9 @@
package com.tangem.feature.swap.router
import com.tangem.core.decompose.navigation.Route
internal sealed interface SwapRoute : Route {
data object Main : SwapRoute
data object Success : SwapRoute
data class SelectToken(val isFromDirection: Boolean) : SwapRoute
}

View file

@ -1,71 +0,0 @@
package com.tangem.feature.swap.router
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
internal class SwapRouter(
private val router: AppRouter,
) {
var currentScreen by mutableStateOf(SwapNavScreen.Main)
private set
fun openScreen(screen: SwapNavScreen) {
currentScreen = screen
}
fun back() {
if (currentScreen == SwapNavScreen.SelectToken) {
currentScreen = SwapNavScreen.Main
} else {
val selectTokensIndex = router.stack.getSelectTokensRouteIndexOrNull()
/*
* If select token screen is not in stack, then just pop to previous screen.
* Otherwise, pop to previous screen that was before select token screen.
*/
if (currentScreen == SwapNavScreen.Success && selectTokensIndex != null) {
// find previous screen that was before select token
val prevRoute = router.stack.getOrNull(index = selectTokensIndex - 1)
if (prevRoute != null) {
router.popTo(prevRoute)
} else {
router.pop()
}
} else {
router.pop()
}
}
}
fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency) {
val route = AppRoute.CurrencyDetails(
userWalletId = userWalletId,
currency = currency,
)
if (route in router.stack) {
router.popTo(route)
} else {
router.pop {
router.push(route)
}
}
}
private fun List<AppRoute>.getSelectTokensRouteIndexOrNull(): Int? {
return this
.indexOfFirst { it::class == AppRoute.SwapCrypto::class }
.takeIf { it != -1 }
}
}
enum class SwapNavScreen {
Main, Success, SelectToken
}

View file

@ -9,8 +9,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import com.tangem.common.ui.bottomsheet.permission.GiveTxPermissionBottomSheet
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@ -44,7 +42,7 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent:
feeBlock = if (feeSelectorBlockComponent != null) {
@Composable { modifier: Modifier ->
feeSelectorBlockComponent.Content(
modifier = Modifier
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
)
@ -61,7 +59,6 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent:
val config = stateHolder.bottomSheetConfig
when (config.content) {
is GiveTxPermissionBottomSheetConfig -> GiveTxPermissionBottomSheet(config = config)
is ChooseProviderBottomSheetConfig -> ChooseProviderBottomSheet(config = config)
is ChooseFeeBottomSheetConfig -> ChooseFeeBottomSheet(config = config)
}

View file

@ -24,25 +24,20 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
import com.tangem.common.ui.extensions.iconResId
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SwapTokenScreenTestTags
import com.tangem.domain.models.network.Network
import com.tangem.feature.swap.domain.models.ui.FeeType
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.*
@ -50,6 +45,8 @@ import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview.receiveCard
import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview.sendCard
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongMethod")
@ -132,22 +129,22 @@ private fun MainInfo(state: SwapStateHolder) {
) {
val (topCard, bottomCard, button) = createRefs()
val priceImpact = state.priceImpact
TransactionCardData(
TransactionCard(
priceImpact = priceImpact,
swapCardState = state.sendCardData,
modifier = Modifier.constrainAs(topCard) {
top.linkTo(parent.top)
},
onSelectTokenClick = state.onSelectTokenClick,
onSelectTokenClick = { state.onSelectTokenClick(TokenSelectionDirection.FROM) },
)
val marginCard = TangemTheme.dimens.spacing12
TransactionCardData(
TransactionCard(
priceImpact = priceImpact,
swapCardState = state.receiveCardData,
modifier = Modifier.constrainAs(bottomCard) {
top.linkTo(topCard.bottom, margin = marginCard)
},
onSelectTokenClick = state.onSelectTokenClick,
onSelectTokenClick = { state.onSelectTokenClick(TokenSelectionDirection.TO) },
)
val marginButton = TangemTheme.dimens.spacing30
SwapButton(
@ -161,43 +158,6 @@ private fun MainInfo(state: SwapStateHolder) {
}
}
@Composable
private fun TransactionCardData(
priceImpact: PriceImpact,
swapCardState: SwapCardState,
onSelectTokenClick: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
when (swapCardState) {
is SwapCardState.Empty -> {
TransactionCardEmpty(
type = swapCardState.type,
amountEquivalent = swapCardState.amountEquivalent,
textFieldValue = swapCardState.amountTextFieldValue,
onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null,
modifier = modifier,
)
}
is SwapCardState.SwapCardData -> {
TransactionCard(
type = swapCardState.type,
balance = swapCardState.balance.orMaskWithStars(swapCardState.isBalanceHidden),
textFieldValue = swapCardState.amountTextFieldValue,
amountEquivalent = swapCardState.amountEquivalent,
tokenIconUrl = swapCardState.tokenIconUrl.orEmpty(),
tokenCurrency = swapCardState.tokenCurrency,
priceImpact = priceImpact,
networkIconRes = if (swapCardState.isNotNativeToken) swapCardState.networkIconRes else null,
iconPlaceholder = swapCardState.coinId?.let {
Network.RawID(it).iconResId
},
onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null,
modifier = modifier,
)
}
}
}
@Composable
private fun ProviderTos(tosState: TosState, modifier: Modifier = Modifier) {
val tos = tosState.tosLink
@ -410,41 +370,6 @@ private fun MainButton(state: SwapStateHolder) {
// region preview
private val sendCard = SwapCardState.SwapCardData(
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = null,
),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = stringReference("1 000 000"),
tokenIconUrl = "",
tokenCurrency = "DAI",
isNotNativeToken = true,
canSelectAnotherToken = false,
balance = "123",
coinId = "",
token = null,
networkIconRes = R.drawable.img_polygon_22,
isBalanceHidden = false,
)
private val receiveCard = SwapCardState.SwapCardData(
type = TransactionCardType.ReadOnly(),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = stringReference("1 000 000"),
tokenIconUrl = "",
tokenCurrency = "DAI",
isNotNativeToken = true,
canSelectAnotherToken = true,
balance = "33333",
coinId = "",
token = null,
networkIconRes = R.drawable.img_polygon_22,
isBalanceHidden = false,
)
private val state = SwapStateHolder(
sendCardData = sendCard,
receiveCardData = receiveCard,
@ -469,14 +394,13 @@ private val state = SwapStateHolder(
onRefresh = {},
onBackClicked = {},
onChangeCardsClicked = {},
permissionState = GiveTxPermissionState.InProgress,
blockchainId = "POLYGON",
permissionUM = SwapPermissionUM.Empty,
providerState = ProviderState.Loading(),
priceImpact = PriceImpact.Empty,
shouldShowMaxAmount = true,
isInsufficientFunds = false,
onSuccess = {},
onSelectTokenClick = {},
onSelectTokenClick = { _ -> },
tosState = TosState(
tosLink = LegalState(
title = stringReference("Terms of Use"),

View file

@ -1,84 +1,99 @@
package com.tangem.feature.swap.ui
import androidx.annotation.DrawableRes
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.common.ui.account.AccountNameUM
import com.tangem.common.ui.account.AccountTitle
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SwapTokenScreenTestTags
import com.tangem.core.ui.utils.ImageBackgroundContrastChecker
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.SwapCardState
import com.tangem.feature.swap.models.TransactionCardType
import kotlinx.coroutines.launch
import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview
@Suppress("LongParameterList")
@Composable
fun TransactionCard(
type: TransactionCardType,
balance: String,
tokenIconUrl: String,
tokenCurrency: String,
amountEquivalent: TextReference?,
internal fun TransactionCard(
priceImpact: PriceImpact,
textFieldValue: TextFieldValue?,
swapCardState: SwapCardState,
onSelectTokenClick: () -> Unit,
modifier: Modifier = Modifier,
@DrawableRes iconPlaceholder: Int? = null,
@DrawableRes networkIconRes: Int? = null,
onChangeTokenClick: (() -> Unit)? = null,
) {
val cardTag = when (type) {
val cardTag = when (swapCardState.type) {
is TransactionCardType.Inputtable ->
SwapTokenScreenTestTags.SWAP_CARD
is TransactionCardType.ReadOnly ->
SwapTokenScreenTestTags.RECEIVE_CARD
}
when (swapCardState) {
is SwapCardState.Empty -> {
TransactionCardEmpty(
cardState = swapCardState,
onChangeTokenClick = onSelectTokenClick,
modifier = modifier.testTag(cardTag),
)
}
is SwapCardState.SwapCardData -> {
TransactionCardData(
cardState = swapCardState,
priceImpact = priceImpact,
onChangeTokenClick = onSelectTokenClick,
modifier = modifier.testTag(cardTag),
)
}
is SwapCardState.Loading -> TransactionCardLoading(
modifier = modifier.testTag(cardTag),
)
}
}
@Composable
private fun TransactionCardData(
cardState: SwapCardState.SwapCardData,
priceImpact: PriceImpact,
modifier: Modifier = Modifier,
onChangeTokenClick: (() -> Unit)? = null,
) {
Box(
modifier = modifier
.background(
shape = RoundedCornerShape(TangemTheme.dimens.radius16),
color = TangemTheme.colors.background.primary,
)
.fillMaxSize()
.testTag(cardTag),
.fillMaxWidth(),
) {
Column(
modifier = Modifier
@ -86,22 +101,26 @@ fun TransactionCard(
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.Start,
) {
Header(balance = stringResourceSafe(R.string.common_balance, balance), type = type)
Header(
balance = stringResourceSafe(
R.string.common_balance,
cardState.balance,
).orMaskWithStars(cardState.isBalanceHidden),
type = cardState.type,
)
Content(
type = type,
amountEquivalent = amountEquivalent,
textFieldValue = textFieldValue,
type = cardState.type,
amountEquivalent = cardState.amountEquivalent,
textFieldValue = cardState.amountTextFieldValue,
priceImpact = priceImpact,
)
}
Box(modifier = Modifier.align(Alignment.BottomEnd)) {
Token(
tokenIconUrl = tokenIconUrl,
tokenCurrency = tokenCurrency,
networkIconRes = networkIconRes,
iconPlaceholder = iconPlaceholder,
currencyIconState = cardState.currencyIconState,
tokenSymbol = cardState.tokenSymbol,
)
}
@ -124,61 +143,134 @@ fun TransactionCard(
}
@Composable
fun TransactionCardEmpty(
type: TransactionCardType,
amountEquivalent: TextReference?,
textFieldValue: TextFieldValue?,
private fun TransactionCardEmpty(
cardState: SwapCardState.Empty,
modifier: Modifier = Modifier,
onChangeTokenClick: (() -> Unit)? = null,
onChangeTokenClick: () -> Unit,
) {
Box(
Column(
modifier = modifier
.background(
shape = RoundedCornerShape(TangemTheme.dimens.radius12),
color = TangemTheme.colors.background.primary,
)
.fillMaxSize(),
.padding(
top = 12.dp,
start = 12.dp,
end = 12.dp,
bottom = 16.dp,
)
.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth(),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.Start,
AccountTitle(
accountTitleUM = cardState.type.accountTitleUM,
modifier = Modifier.fillMaxWidth(),
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Header(
balance = stringResourceSafe(id = R.string.swapping_token_not_available),
type = type,
)
Content(
type = type,
amountEquivalent = amountEquivalent,
textFieldValue = textFieldValue,
priceImpact = PriceImpact.Empty,
)
}
Box(modifier = Modifier.align(Alignment.BottomEnd)) {
Token(
tokenIconUrl = "",
tokenCurrency = "",
iconPlaceholder = R.drawable.ic_no_token_44,
)
}
if (onChangeTokenClick != null) {
Box(modifier = Modifier.align(Alignment.CenterEnd)) {
ChangeTokenSelector()
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = cardState.amountTextFieldValue?.text.orEmpty(),
color = TangemTheme.colors.text.disabled,
style = TangemTheme.typography.h2,
autoSize = TextAutoSize.StepBased(
minFontSize = 16.sp,
maxFontSize = TangemTheme.typography.h2.fontSize,
),
maxLines = 1,
modifier = Modifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
)
Text(
text = cardState.amountEquivalent.resolveAnnotatedReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier
.defaultMinSize(minHeight = TangemTheme.dimens.size20)
.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
)
}
Box(
Modifier
.align(Alignment.CenterEnd)
.height(TangemTheme.dimens.size116)
.width(TangemTheme.dimens.size102)
.clickable(
indication = ripple(bounded = false),
interactionSource = remember { MutableInteractionSource() },
) { onChangeTokenClick() },
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.common_choose_token),
icon = TangemButtonIconPosition.End(R.drawable.ic_chevron_24),
onClick = onChangeTokenClick,
),
)
}
}
}
@Composable
private fun TransactionCardLoading(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(
shape = RoundedCornerShape(TangemTheme.dimens.radius12),
color = TangemTheme.colors.background.primary,
)
.padding(
top = 12.dp,
start = 12.dp,
end = 12.dp,
bottom = 16.dp,
)
.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
TextShimmer(
text = stringResourceSafe(R.string.swapping_to_title),
style = TangemTheme.typography.subtitle2,
)
TextShimmer(
style = TangemTheme.typography.body2,
modifier = Modifier
.testTag(SwapTokenScreenTestTags.BALANCE)
.width(60.dp),
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextShimmer(
style = TangemTheme.typography.h2,
modifier = Modifier
.width(100.dp)
.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
)
TextShimmer(
style = TangemTheme.typography.body2,
modifier = Modifier
.defaultMinSize(
minHeight = 20.dp,
minWidth = 40.dp,
)
.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
)
}
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.common_choose_token),
icon = TangemButtonIconPosition.End(R.drawable.ic_chevron_24),
isEnabled = false,
onClick = {},
),
)
}
}
@ -376,12 +468,7 @@ private fun Content(
@Suppress("MagicNumber")
@Composable
fun Token(
tokenIconUrl: String,
tokenCurrency: String,
@DrawableRes iconPlaceholder: Int? = null,
@DrawableRes networkIconRes: Int? = null,
) {
fun Token(currencyIconState: CurrencyIconState, tokenSymbol: TextReference) {
Column(
modifier = Modifier
.padding(
@ -392,15 +479,13 @@ fun Token(
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.End,
) {
TokenIcon(
tokenIconUrl = tokenIconUrl,
tokenCurrency = tokenCurrency,
iconPlaceholder = iconPlaceholder,
networkIconRes = networkIconRes,
CurrencyIcon(
state = currencyIconState,
modifier = Modifier.padding(end = TangemTheme.dimens.spacing16),
)
SpacerH4()
Text(
text = tokenCurrency,
text = tokenSymbol.resolveReference(),
color = TangemTheme.colors.text.primary1,
maxLines = 1,
style = TangemTheme.typography.subtitle2,
@ -412,89 +497,10 @@ fun Token(
}
}
@Suppress("NullableToStringCall")
@Composable
private fun TokenIcon(
tokenIconUrl: String,
tokenCurrency: String,
@DrawableRes iconPlaceholder: Int? = null,
@DrawableRes networkIconRes: Int? = null,
) {
var iconBackgroundColor by remember { mutableStateOf(Color.Transparent) }
var isBackgroundColorDefined by remember { mutableStateOf(false) }
val itemBackgroundColor = TangemTheme.colors.background.primary.toArgb()
val isDarkTheme = isSystemInDarkTheme()
val coroutineScope = rememberCoroutineScope()
Box(
modifier = Modifier
.padding(end = TangemTheme.dimens.spacing16)
.size(TangemTheme.dimens.size42)
.testTag(SwapTokenScreenTestTags.TOKEN_ICON),
) {
val tokenImageModifier = Modifier
.align(Alignment.BottomStart)
.size(TangemTheme.dimens.size36)
.background(
color = iconBackgroundColor,
shape = TangemTheme.shapes.roundedCorners8,
)
.clip(TangemTheme.shapes.roundedCorners8)
val data = tokenIconUrl.ifEmpty { iconPlaceholder }
val pixelsSize = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() }
SubcomposeAsyncImage(
modifier = tokenImageModifier,
model = ImageRequest.Builder(LocalContext.current)
.data(data)
.size(size = pixelsSize)
.memoryCacheKey(key = data.toString() + pixelsSize)
.crossfade(true)
.allowHardware(false)
.listener(
onSuccess = { _, result ->
if (isDarkTheme) {
coroutineScope.launch {
val color = ImageBackgroundContrastChecker(
drawable = result.drawable,
backgroundColor = itemBackgroundColor,
size = pixelsSize,
).getContrastColor(true)
iconBackgroundColor = color
isBackgroundColorDefined = true
}
}
},
).build(),
loading = { CircleShimmer(modifier = tokenImageModifier) },
contentDescription = tokenCurrency,
)
if (networkIconRes != null) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.size(TangemTheme.dimens.size18)
.background(color = TangemTheme.colors.background.primary, shape = CircleShape),
contentAlignment = Alignment.Center,
) {
Image(
modifier = Modifier.padding(all = TangemTheme.dimens.spacing2),
painter = painterResource(id = networkIconRes),
contentDescription = null,
)
}
}
}
}
@Composable
fun ChangeTokenSelector() {
Box(
modifier = Modifier
.fillMaxHeight()
.padding(
top = TangemTheme.dimens.spacing12,
start = TangemTheme.dimens.spacing24,
@ -513,129 +519,29 @@ fun ChangeTokenSelector() {
}
}
// region preview
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
// region Preview
@Composable
private fun Preview_TransactionCard_InLightTheme() {
TangemThemePreview(isDark = false) {
TransactionCardPreview()
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TransactionCard_Preview(@PreviewParameter(PreviewProvider::class) params: SwapCardState) {
TangemThemePreview {
TransactionCard(
priceImpact = PriceImpact.Empty,
swapCardState = params,
onSelectTokenClick = {},
modifier = Modifier,
)
}
}
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
@Composable
private fun Preview_TransactionCardWithPriceImpact_InLightTheme() {
TangemThemePreview(isDark = false) {
TransactionCardPreviewWithPriceImpact()
}
private class PreviewProvider : PreviewParameterProvider<SwapCardState> {
override val values: Sequence<SwapCardState>
get() = sequenceOf(
SwapTransactionCardPreview.sendCard,
SwapTransactionCardPreview.receiveCard,
SwapTransactionCardPreview.emptyReadOnlyCard,
SwapTransactionCardPreview.emptyInputtableCard,
SwapTransactionCardPreview.loadingCard,
)
}
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
@Composable
private fun Preview_TransactionCardWithoutPriceImpact_InLightTheme() {
TangemThemePreview(isDark = false) {
TransactionCardPreviewWithoutPriceImpact()
}
}
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
@Composable
private fun Preview_TransactionCard_InDarkTheme() {
TangemThemePreview(isDark = false) {
TransactionCardPreview()
}
}
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
@Composable
private fun Preview_TransactionCardWithPriceImpact_InDarkTheme() {
TangemThemePreview(isDark = false) {
TransactionCardPreviewWithPriceImpact()
}
}
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
@Composable
private fun Preview_TransactionCardWithoutPriceImpact_InDarkTheme() {
TangemThemePreview(isDark = false) {
TransactionCardPreviewWithoutPriceImpact()
}
}
@Composable
private fun TransactionCardPreview() {
TransactionCard(
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = null,
),
amountEquivalent = stringReference("1 000 000"),
tokenIconUrl = "",
tokenCurrency = "DAI",
networkIconRes = R.drawable.img_polygon_22,
onChangeTokenClick = {},
balance = "123",
textFieldValue = TextFieldValue(),
priceImpact = PriceImpact.Empty,
)
}
@Composable
@Suppress("MagicNumber")
private fun TransactionCardPreviewWithPriceImpact() {
TransactionCard(
type = TransactionCardType.ReadOnly(
shouldShowWarning = true,
accountTitleUM = AccountTitleUM.Account(
prefixText = resourceReference(R.string.common_from),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountEquivalent = combinedReference(
stringReference("1 000 000 $"),
styledStringReference(
" (-15%)",
{ SpanStyle(color = TangemTheme.colors.text.attention) },
),
),
tokenIconUrl = "",
tokenCurrency = "DAI",
networkIconRes = R.drawable.img_polygon_22,
onChangeTokenClick = {},
balance = "123",
textFieldValue = TextFieldValue("1000000.0000000000000000000000000"),
priceImpact = PriceImpact(
value = 0.15F.toBigDecimal(),
type = PriceImpact.Type.MEDIUM,
amountSignificance = PriceImpact.AmountSignificance.HIGH,
),
)
}
@Composable
@Suppress("MagicNumber")
private fun TransactionCardPreviewWithoutPriceImpact() {
TransactionCard(
type = TransactionCardType.ReadOnly(
accountTitleUM = AccountTitleUM.Account(
prefixText = resourceReference(R.string.common_from),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountEquivalent = stringReference("1 000 000"),
tokenIconUrl = "",
tokenCurrency = "DAI",
networkIconRes = R.drawable.img_polygon_22,
onChangeTokenClick = {},
balance = "123",
textFieldValue = TextFieldValue(),
priceImpact = PriceImpact.Empty,
)
}
// endregion preview
// endregion

View file

@ -0,0 +1,79 @@
package com.tangem.feature.swap.ui.preview
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.ui.account.AccountNameUM
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.feature.swap.models.SwapCardState
import com.tangem.feature.swap.models.TransactionCardType
import com.tangem.feature.swap.presentation.R
internal object SwapTransactionCardPreview {
val sendCard = SwapCardState.SwapCardData(
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = AccountTitleUM.Account(
prefixText = stringReference("From"),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = stringReference("1 000 000"),
currencyIconState = CurrencyIconState.Loading,
tokenSymbol = stringReference("DAI"),
balance = "123",
isBalanceHidden = false,
)
val receiveCard = SwapCardState.SwapCardData(
type = TransactionCardType.ReadOnly(
accountTitleUM = AccountTitleUM.Account(
prefixText = stringReference("To"),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = stringReference("1 000 000"),
currencyIconState = CurrencyIconState.Loading,
tokenSymbol = stringReference("DAI"),
balance = "33333",
isBalanceHidden = false,
)
val emptyReadOnlyCard = SwapCardState.Empty(
type = TransactionCardType.ReadOnly(
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)),
),
amountEquivalent = stringReference("$0.00"),
amountTextFieldValue = null,
)
val emptyInputtableCard = SwapCardState.Empty(
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_from_title)),
),
amountEquivalent = stringReference("$0.00"),
amountTextFieldValue = null,
)
val loadingCard = SwapCardState.Loading(
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)),
),
)
}

View file

@ -0,0 +1,867 @@
package com.tangem.feature.swap
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.*
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.quote.PriceChange
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.swap.model.InitialCurrenciesResolver
import com.tangem.features.swap.SwapComponent.Params.CurrencyPosition
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultInitialCurrenciesResolverTest {
private val getUserWalletUseCase = mockk<GetUserWalletUseCase>()
private val singleAccountStatusListSupplier = mockk<SingleAccountStatusListSupplier>()
private val rampStateManager = mockk<RampStateManager>()
private val userWalletId = UserWalletId("0011")
private val userWallet = mockk<UserWallet> {
every { walletId } returns userWalletId
}
private val resolver = InitialCurrenciesResolver(
getUserWalletUseCase = getUserWalletUseCase,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
rampStateManager = rampStateManager,
)
private var uniqueIndex = 0
@BeforeEach
fun setup() {
coEvery { getUserWalletUseCase(userWalletId) } returns userWallet.right()
}
// region no initial currency
@Test
fun `GIVEN available tokens with balance WHEN no initial currency THEN returns available token with max fiat balance`() =
runTest {
val currency1 = mockCryptoCurrency()
val currency2 = mockCryptoCurrency()
val currency3 = mockCryptoCurrency()
val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("100"))
val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("300"))
val status3 = createCurrencyStatus(currency3, fiatAmount = BigDecimal("500"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2, status3))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(currency1 to true, currency2 to true, currency3 to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = null,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status2)
assertThat(to).isNull()
}
@Test
fun `GIVEN available tokens without balance WHEN no initial currency THEN returns first token from first account`() =
runTest {
val currency1 = mockCryptoCurrency()
val currency2 = mockCryptoCurrency()
val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal.ZERO)
val status2 = createCurrencyStatus(currency2, fiatAmount = null)
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(currency1 to true, currency2 to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = null,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status1)
assertThat(to).isNull()
}
@Test
fun `GIVEN no available tokens with balance WHEN no initial currency THEN returns token with max fiat balance`() =
runTest {
val currency1 = mockCryptoCurrency()
val currency2 = mockCryptoCurrency()
val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("100"))
val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("200"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(currency1 to false, currency2 to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = null,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status2)
assertThat(to).isNull()
}
@Test
fun `GIVEN no available tokens without balance WHEN no initial currency THEN returns first token from first account`() =
runTest {
val currency1 = mockCryptoCurrency()
val currency2 = mockCryptoCurrency()
val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal.ZERO)
val status2 = createCurrencyStatus(currency2, fiatAmount = null)
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(currency1 to false, currency2 to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = null,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status1)
assertThat(to).isNull()
}
@Test
fun `GIVEN empty accounts WHEN no initial currency THEN returns null pair`() = runTest {
setupSupplier(emptyList())
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = null,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from).isNull()
assertThat(to).isNull()
}
@Test
fun `GIVEN multiple accounts WHEN fallback to first THEN returns first token from first account`() =
runTest {
val currency1 = mockCryptoCurrency()
val currency2 = mockCryptoCurrency()
val status1 = createCurrencyStatus(currency1, fiatAmount = null)
val status2 = createCurrencyStatus(currency2, fiatAmount = null)
val account1Status = createCryptoPortfolioAccountStatus(listOf(status1))
val account2Status = createCryptoPortfolioAccountStatus(listOf(status2))
setupSupplier(listOf(account1Status, account2Status))
setupAvailability(linkedMapOf(currency1 to true))
setupAvailability(linkedMapOf(currency2 to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = null,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status1)
assertThat(to).isNull()
}
@Test
fun `GIVEN mixed availability and balance across accounts WHEN no initial currency THEN returns best available with balance`() =
runTest {
val currency1 = mockCryptoCurrency()
val currency2 = mockCryptoCurrency()
val currency3 = mockCryptoCurrency()
val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("50"))
val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("200"))
val status3 = createCurrencyStatus(currency3, fiatAmount = BigDecimal("100"))
val account1Status = createCryptoPortfolioAccountStatus(listOf(status1))
val account2Status = createCryptoPortfolioAccountStatus(listOf(status2, status3))
setupSupplier(listOf(account1Status, account2Status))
setupAvailability(linkedMapOf(currency1 to true))
setupAvailability(linkedMapOf(currency2 to false, currency3 to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = null,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status3)
assertThat(to).isNull()
}
// endregion
// region initial currency tests
@Test
fun `GIVEN initial currency not found WHEN invoke THEN returns null pair`() = runTest {
val initialCurrency = mockCryptoCurrency()
val otherCurrency = mockCryptoCurrency()
val status = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("100"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(otherCurrency to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from).isNull()
assertThat(to).isNull()
}
@Test
fun `GIVEN initial currency available with balance WHEN invoke THEN returns it as from`() = runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status)
assertThat(to).isNull()
}
@Test
fun `GIVEN initial currency available without balance WHEN invoke THEN returns it as to and best as from`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val otherCurrency = mockCryptoCurrency()
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal.ZERO)
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("200"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to true, otherCurrency to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(otherStatus)
assertThat(to?.status).isSameInstanceAs(initialStatus)
}
@Test
fun `GIVEN initial currency available without balance and is only token WHEN invoke THEN returns it as to and from is null`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal.ZERO)
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from).isNull()
assertThat(to?.status).isSameInstanceAs(status)
}
@Test
fun `GIVEN initial currency not available with balance WHEN invoke THEN returns it as from`() = runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status)
assertThat(to).isNull()
}
@Test
fun `GIVEN initial currency not available with balance and other available with higher balance WHEN invoke THEN returns initial as from`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val otherCurrency = mockCryptoCurrency()
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("500"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to false, otherCurrency to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(initialStatus)
assertThat(to).isNull()
}
@Test
fun `GIVEN initial currency not available without balance and other available with balance WHEN invoke THEN returns best available as from`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val other1 = mockCryptoCurrency()
val other2 = mockCryptoCurrency()
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null)
val other1Status = createCurrencyStatus(other1, fiatAmount = BigDecimal("100"))
val other2Status = createCurrencyStatus(other2, fiatAmount = BigDecimal("300"))
val accountStatus = createCryptoPortfolioAccountStatus(
listOf(initialStatus, other1Status, other2Status),
)
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to false, other1 to true, other2 to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(other2Status)
assertThat(to?.status).isSameInstanceAs(initialStatus)
}
@Test
fun `GIVEN initial currency not available without balance and other available without balance WHEN invoke THEN returns first token as from`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val otherCurrency = mockCryptoCurrency()
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null)
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal.ZERO)
val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, initialStatus))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(otherCurrency to true, accountCurrency to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(otherStatus)
assertThat(to?.status).isSameInstanceAs(initialStatus)
}
@Test
fun `GIVEN initial currency not available without balance and no available tokens with balance WHEN invoke THEN returns best by balance as from`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val otherCurrency = mockCryptoCurrency()
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null)
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("200"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to false, otherCurrency to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(otherStatus)
assertThat(to?.status).isSameInstanceAs(initialStatus)
}
@Test
fun `GIVEN initial currency not available without balance and no available tokens without balance WHEN invoke THEN returns first token as from`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val otherCurrency = mockCryptoCurrency()
val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null)
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal.ZERO)
val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, initialStatus))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(otherCurrency to false, accountCurrency to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(otherStatus)
assertThat(to?.status).isSameInstanceAs(initialStatus)
}
@Test
fun `GIVEN initial currency not available without balance and is only token WHEN invoke THEN returns it as to and from is null`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val status = createCurrencyStatus(accountCurrency, fiatAmount = null)
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from).isNull()
assertThat(to?.status).isSameInstanceAs(status)
}
@Test
fun `GIVEN initial from second account without balance and same token in main with balance WHEN invoke THEN does not pick same token as from`() =
runTest {
val sharedNetworkId = "ethereum"
val sharedContractAddress = "0xUSDT"
// Same token (same network + contract), different ids (simulates different derivations)
val idInSecondary = mockCurrencyId(sharedNetworkId, sharedContractAddress)
val idInMain = mockCurrencyId(sharedNetworkId, sharedContractAddress)
val initialCurrency = mockCryptoCurrency(id = idInSecondary)
val usdtInSecondary = mockCryptoCurrency(id = idInSecondary)
val usdtInMain = mockCryptoCurrency(id = idInMain)
val statusInSecondary = createCurrencyStatus(usdtInSecondary, fiatAmount = null)
val statusInMain = createCurrencyStatus(usdtInMain, fiatAmount = BigDecimal("1000"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(statusInMain, statusInSecondary))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(usdtInMain to true, usdtInSecondary to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
// Selected goes to TO; FROM must not be the same token from another account
assertThat(from).isNull()
assertThat(to?.status).isSameInstanceAs(statusInSecondary)
}
@Test
fun `GIVEN initial from second account with balance WHEN invoke THEN returns it as from`() = runTest {
val idInSecondary = mockCurrencyId("ethereum", "0xUSDT")
val initialCurrency = mockCryptoCurrency(id = idInSecondary)
val usdtInSecondary = mockCryptoCurrency(id = idInSecondary)
val otherCurrency = mockCryptoCurrency()
val statusInSecondary = createCurrencyStatus(usdtInSecondary, fiatAmount = BigDecimal("200"))
val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("500"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, statusInSecondary))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(otherCurrency to true, usdtInSecondary to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(statusInSecondary)
assertThat(to).isNull()
}
@Test
fun `GIVEN initial in secondary account placed in TO WHEN invoke THEN FROM is picked only from same account`() =
runTest {
// Main account has a high-balance available currency.
val mainOnlyCurrency = mockCryptoCurrency()
val mainStatus = createCurrencyStatus(mainOnlyCurrency, fiatAmount = BigDecimal("10000"))
val mainAccount = createCryptoPortfolioAccountStatus(
currencies = listOf(mainStatus),
derivationIndexValue = 0,
)
// Secondary account holds the initial currency (available, zero balance → TO)
// plus another available currency with balance.
val initialId = mockCurrencyId("ethereum", "0xUSDT")
val initialCurrency = mockCryptoCurrency(id = initialId)
val initialInSecondary = mockCryptoCurrency(id = initialId)
val secondaryCompanion = mockCryptoCurrency()
val initialStatus = createCurrencyStatus(initialInSecondary, fiatAmount = BigDecimal.ZERO)
val secondaryStatus = createCurrencyStatus(secondaryCompanion, fiatAmount = BigDecimal("50"))
val secondaryAccount = createCryptoPortfolioAccountStatus(
currencies = listOf(initialStatus, secondaryStatus),
derivationIndexValue = 1,
)
setupSupplier(listOf(mainAccount, secondaryAccount))
setupAvailability(linkedMapOf(mainOnlyCurrency to true))
setupAvailability(linkedMapOf(initialInSecondary to true, secondaryCompanion to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
// FROM must come from secondary account only — never the main account's high-balance currency.
assertThat(from?.status).isSameInstanceAs(secondaryStatus)
assertThat(to?.status).isSameInstanceAs(initialStatus)
}
@Test
fun `GIVEN initial in secondary account is only token in that account WHEN invoke THEN FROM is null`() =
runTest {
// Main account has candidates that must NOT be picked as FROM.
val mainOnlyCurrency = mockCryptoCurrency()
val mainStatus = createCurrencyStatus(mainOnlyCurrency, fiatAmount = BigDecimal("500"))
val mainAccount = createCryptoPortfolioAccountStatus(
currencies = listOf(mainStatus),
derivationIndexValue = 0,
)
// Secondary account has only the initial currency (available, no balance → TO).
val initialId = mockCurrencyId("ethereum", "0xUSDT")
val initialCurrency = mockCryptoCurrency(id = initialId)
val initialInSecondary = mockCryptoCurrency(id = initialId)
val initialStatus = createCurrencyStatus(initialInSecondary, fiatAmount = BigDecimal.ZERO)
val secondaryAccount = createCryptoPortfolioAccountStatus(
currencies = listOf(initialStatus),
derivationIndexValue = 1,
)
setupSupplier(listOf(mainAccount, secondaryAccount))
setupAvailability(linkedMapOf(mainOnlyCurrency to true))
setupAvailability(linkedMapOf(initialInSecondary to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.ANY,
isPaymentAccount = false,
)
// Secondary account has no other candidates; FROM must be null, not pulled from main.
assertThat(from).isNull()
assertThat(to?.status).isSameInstanceAs(initialStatus)
}
// endregion
// region currency position FROM
@Test
fun `GIVEN position FROM and available with balance WHEN invoke THEN returns selected as from`() = runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.FROM,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status)
assertThat(to).isNull()
}
@Test
fun `GIVEN position FROM and not available without balance WHEN invoke THEN still returns selected as from`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val status = createCurrencyStatus(accountCurrency, fiatAmount = null)
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.FROM,
isPaymentAccount = false,
)
assertThat(from?.status).isSameInstanceAs(status)
assertThat(to).isNull()
}
// endregion
// region currency position TO
@Test
fun `GIVEN position TO and available with balance WHEN invoke THEN returns selected as to`() = runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.TO,
isPaymentAccount = false,
)
assertThat(from).isNull()
assertThat(to?.status).isSameInstanceAs(status)
}
@Test
fun `GIVEN position TO and not available without balance WHEN invoke THEN still returns selected as to`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
val status = createCurrencyStatus(accountCurrency, fiatAmount = null)
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to false))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.TO,
isPaymentAccount = false,
)
assertThat(from).isNull()
assertThat(to?.status).isSameInstanceAs(status)
}
// endregion
// region helpers
private fun mockCryptoCurrency(
id: CryptoCurrency.ID = mockCurrencyId(),
): CryptoCurrency = mockk(relaxed = true) {
every { this@mockk.id } returns id
}
private fun mockCurrencyId(
rawNetworkId: String = "net-${uniqueIndex++}",
contractAddress: String = "contract-${uniqueIndex++}",
): CryptoCurrency.ID = mockk(relaxed = true) {
every { this@mockk.rawNetworkId } returns rawNetworkId
every { this@mockk.contractAddress } returns contractAddress
}
private fun createCurrencyStatus(
currency: CryptoCurrency,
fiatAmount: BigDecimal?,
): CryptoCurrencyStatus {
val value = mockk<CryptoCurrencyStatus.Value> {
every { this@mockk.fiatAmount } returns fiatAmount
}
return CryptoCurrencyStatus(currency = currency, value = value)
}
private fun createCryptoPortfolioAccountStatus(
currencies: List<CryptoCurrencyStatus>,
): AccountStatus.CryptoPortfolio {
val account = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)
return AccountStatus.CryptoPortfolio(
account = account,
tokenList = TokenList.Ungrouped(
totalFiatBalance = TotalFiatBalance.Loaded(
amount = BigDecimal.ZERO,
source = StatusSource.ACTUAL,
),
sortedBy = TokensSortType.NONE,
currencies = currencies,
),
priceChangeLce = Lce.Content(PriceChange(value = BigDecimal.ZERO, source = StatusSource.ACTUAL)),
)
}
private fun createCryptoPortfolioAccountStatus(
currencies: List<CryptoCurrencyStatus>,
derivationIndexValue: Int,
): AccountStatus.CryptoPortfolio {
val derivationIndex = requireNotNull(DerivationIndex(value = derivationIndexValue).getOrNull()) {
"Invalid derivation index for test: $derivationIndexValue"
}
val accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = derivationIndex,
)
val accountName = if (derivationIndex.isMain) {
AccountName.DefaultMain
} else {
requireNotNull(AccountName.Custom(value = "Account $derivationIndexValue").getOrNull()) {
"Invalid account name for test"
}
}
val account = Account.CryptoPortfolio(
accountId = accountId,
accountName = accountName,
icon = CryptoPortfolioIcon.ofMainAccount(userWalletId),
derivationIndex = derivationIndex,
)
return AccountStatus.CryptoPortfolio(
account = account,
tokenList = TokenList.Ungrouped(
totalFiatBalance = TotalFiatBalance.Loaded(
amount = BigDecimal.ZERO,
source = StatusSource.ACTUAL,
),
sortedBy = TokensSortType.NONE,
currencies = currencies,
),
priceChangeLce = Lce.Content(PriceChange(value = BigDecimal.ZERO, source = StatusSource.ACTUAL)),
)
}
private fun setupSupplier(accountStatuses: List<AccountStatus>) {
val accountStatusList = if (accountStatuses.isEmpty()) {
null
} else {
AccountStatusList(
userWalletId = userWalletId,
accountStatuses = accountStatuses,
totalAccounts = accountStatuses.size,
totalArchivedAccounts = 0,
totalFiatBalance = TotalFiatBalance.Loaded(
amount = BigDecimal.ZERO,
source = StatusSource.ACTUAL,
),
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
)
}
coEvery {
singleAccountStatusListSupplier.getSyncOrNull(any<SingleAccountStatusListProducer.Params>(), any())
} returns accountStatusList
}
private fun setupAvailability(currenciesAvailability: LinkedHashMap<CryptoCurrency, Boolean>) {
val result = currenciesAvailability.map { (currency, available) ->
val reason = if (available) {
ScenarioUnavailabilityReason.None
} else {
ScenarioUnavailabilityReason.Unreachable
}
currency to reason
}.toMap()
coEvery { rampStateManager.availableForSwap(userWalletId, currenciesAvailability.keys.toList()) } returns result
}
// endregion
}

View file

@ -207,9 +207,9 @@ internal class TangemPayCardPageModel @Inject constructor(
bottomSheetNavigation.dismiss()
router.push(
AppRoute.Swap(
currencyFrom = data.currency,
cryptoCurrency = data.currency,
userWalletId = data.walletId,
isInitialReverseOrder = true,
currencyPosition = AppRoute.Swap.CurrencyPosition.TO,
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
tangemPayInput = AppRoute.Swap.TangemPayInput(
cryptoAmount = data.cryptoBalance,

View file

@ -55,6 +55,7 @@ import com.tangem.features.tokendetails.ExpressTransactionsEventListener
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.logging.TangemLogger
import com.tangem.utils.transformer.update
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@ -62,7 +63,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass")
@ -314,10 +314,10 @@ internal class TangemPayDetailsModel @Inject constructor(
) {
router.push(
AppRoute.Swap(
currencyFrom = currency,
cryptoCurrency = currency,
userWalletId = params.userWalletId,
isInitialReverseOrder = false,
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
currencyPosition = AppRoute.Swap.CurrencyPosition.FROM,
tangemPayInput = AppRoute.Swap.TangemPayInput(
cryptoAmount = currentBalance.availableForWithdrawal,
fiatAmount = currentBalance.availableForWithdrawal,
@ -425,10 +425,10 @@ internal class TangemPayDetailsModel @Inject constructor(
bottomSheetNavigation.dismiss()
router.push(
AppRoute.Swap(
currencyFrom = data.currency,
cryptoCurrency = data.currency,
userWalletId = data.walletId,
isInitialReverseOrder = true,
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
currencyPosition = AppRoute.Swap.CurrencyPosition.TO,
tangemPayInput = AppRoute.Swap.TangemPayInput(
cryptoAmount = data.cryptoBalance,
fiatAmount = data.fiatBalance,

View file

@ -787,7 +787,7 @@ internal class TokenDetailsModel @Inject constructor(
} else {
appRouter.push(
AppRoute.Swap(
currencyFrom = cryptoCurrency,
cryptoCurrency = cryptoCurrency,
userWalletId = userWalletId,
screenSource = AnalyticsParam.ScreensSources.Token.value,
),
@ -1307,7 +1307,7 @@ internal class TokenDetailsModel @Inject constructor(
TokenAction.Send -> sendCurrency()
TokenAction.Swap -> appRouter.push(
AppRoute.Swap(
currencyFrom = cryptoCurrency,
cryptoCurrency = cryptoCurrency,
userWalletId = userWalletId,
screenSource = AnalyticsParam.ScreensSources.Token.value,
),

View file

@ -3,6 +3,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.express
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateInfoUM
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification
@ -15,6 +16,7 @@ internal data class ExchangeUM(
val statuses: ImmutableList<ExchangeStatusState>,
val notification: ExchangeStatusNotification?,
val showProviderLink: Boolean,
val fromUserWalletId: UserWalletId,
val fromCryptoCurrency: CryptoCurrency,
val toCryptoCurrency: CryptoCurrency,
val hasLongTime: Boolean,

View file

@ -19,6 +19,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.quote.mapData
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isFailed
@ -98,6 +99,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
val showProviderLink = getShowProviderLink(notification, transaction.status)
result.add(
ExchangeUM(
fromUserWalletId = UserWalletId(swapTransaction.fromUserWalletId),
provider = transaction.provider,
statuses = getStatuses(statusModel?.status),
notification = notification,

View file

@ -12,8 +12,10 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.swap.domain.SwapTransactionRepository
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.models.domain.*
@ -21,6 +23,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTr
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter
import com.tangem.utils.Provider
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -29,7 +32,6 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.map
import com.tangem.utils.logging.TangemLogger
@Suppress("LongParameterList")
internal class ExchangeStatusFactory @AssistedInject constructor(
@ -40,6 +42,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor(
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val swapTransactionStatusStore: SwapTransactionStatusStore,
private val analyticsEventsHandler: AnalyticsEventHandler,
private val getUserWalletUseCase: GetUserWalletUseCase,
@Assisted private val clickIntents: ExpressTransactionsClickIntents,
@Assisted private val appCurrencyProvider: Provider<AppCurrency>,
@Assisted private val currentStateProvider: Provider<ExpressTransactionsBlockState>,
@ -59,6 +62,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor(
operator fun invoke(): Flow<PersistentList<ExchangeUM>> {
return swapTransactionRepository.getTransactions(
userWallet = userWallet,
cryptoCurrencyId = cryptoCurrency.id,
).conflate()
.map { savedTransactions ->
@ -93,7 +97,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor(
return if (swapTx.activeStatus?.isTerminal == true) {
swapTx
} else {
val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider)
val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider, swapTx.fromUserWalletId)
if (statusModel != null) {
swapTransactionsStateConverter.updateTxStatus(
@ -106,15 +110,24 @@ internal class ExchangeStatusFactory @AssistedInject constructor(
}
}
private suspend fun getExchangeStatus(txId: String, provider: SwapProvider): ExchangeStatusModel? {
return swapRepository.getExchangeStatus(userWallet = userWallet, txId = txId)
private suspend fun getExchangeStatus(
txId: String,
provider: SwapProvider,
fromUserWalletId: UserWalletId,
): ExchangeStatusModel? {
val fromUserWallet = getUserWalletUseCase(fromUserWalletId).getOrNull()
return swapRepository.getExchangeStatus(
userWallet = fromUserWallet,
userWalletId = fromUserWalletId,
txId = txId,
)
.fold(
ifLeft = { null },
ifRight = { statusModel ->
sendStatusUpdateAnalytics(statusModel, provider)
val accountId = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = userWallet.walletId,
userWalletId = fromUserWalletId,
currency = cryptoCurrency,
)
.map { it.account.accountId }

View file

@ -1,5 +1,6 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.express
import arrow.core.getOrElse
import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.datasource.local.swap.ExpressAnalyticsStatus
@ -11,8 +12,10 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.swap.domain.SwapTransactionRepository
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.models.domain.*
@ -21,6 +24,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter
import com.tangem.utils.Provider
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -29,7 +33,6 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.map
import com.tangem.utils.logging.TangemLogger
import kotlin.coroutines.cancellation.CancellationException
@Suppress("LongParameterList")
@ -41,6 +44,7 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor(
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val swapTransactionStatusStore: SwapTransactionStatusStore,
private val analyticsEventsHandler: AnalyticsEventHandler,
private val getUserWalletUseCase: GetUserWalletUseCase,
@Assisted private val clickIntents: ExpressTransactionsClickIntents,
@Assisted private val appCurrencyProvider: Provider<AppCurrency>,
@Assisted private val currentStateProvider: Provider<TokenDetailsState>,
@ -94,7 +98,7 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor(
return if (swapTx.activeStatus?.isTerminal == true) {
swapTx
} else {
val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider)
val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider, swapTx.fromUserWalletId)
if (statusModel != null) {
swapTransactionsStateConverter.updateTxStatus(
@ -107,43 +111,54 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor(
}
}
private suspend fun getExchangeStatus(txId: String, provider: SwapProvider): ExchangeStatusModel? {
return swapRepository.getExchangeStatus(userWallet = userWallet, txId = txId)
.fold(
ifLeft = { null },
ifRight = { statusModel ->
sendStatusUpdateAnalytics(statusModel, provider)
private suspend fun getExchangeStatus(
txId: String,
provider: SwapProvider,
fromUserWalletId: UserWalletId,
): ExchangeStatusModel? {
val fromUserWallet = getUserWalletUseCase(fromUserWalletId).getOrElse { error ->
TangemLogger.e("Couldn't find userWallet: $error")
return null
}
return swapRepository.getExchangeStatus(
userWallet = fromUserWallet,
userWalletId = fromUserWalletId,
txId = txId,
).fold(
ifLeft = { null },
ifRight = { statusModel ->
sendStatusUpdateAnalytics(statusModel, provider)
val accountId = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = userWallet.walletId,
currency = cryptoCurrency,
)
.map { it.account.accountId }
.getOrNull()
val accountId = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = fromUserWalletId,
currency = cryptoCurrency,
)
.map { it.account.accountId }
.getOrNull()
val refundTokenCurrency = if (accountId != null) {
addRefundCurrencyIfNeeded(
accountId = accountId,
status = statusModel,
type = provider.type,
)
} else {
TangemLogger.e("Account ID is null, cannot add refund currency ${cryptoCurrency.id}")
null
}
swapTransactionRepository.storeTransactionState(
txId = txId,
val refundTokenCurrency = if (accountId != null) {
addRefundCurrencyIfNeeded(
accountId = accountId,
status = statusModel,
accountWithCurrency = if (refundTokenCurrency != null) {
Pair(accountId, refundTokenCurrency)
} else {
null
},
type = provider.type,
)
statusModel.copy(refundCurrency = refundTokenCurrency)
},
)
} else {
TangemLogger.e("Account ID is null, cannot add refund currency ${cryptoCurrency.id}")
null
}
swapTransactionRepository.storeTransactionState(
txId = txId,
status = statusModel,
accountWithCurrency = if (refundTokenCurrency != null) {
Pair(accountId, refundTokenCurrency)
} else {
null
},
)
statusModel.copy(refundCurrency = refundTokenCurrency)
},
)
}
private suspend fun sendStatusUpdateAnalytics(statusModel: ExchangeStatusModel, provider: SwapProvider) {

View file

@ -9,6 +9,7 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrency.ID
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.SwapProvider
@ -23,6 +24,7 @@ class ExpressStatusBottomSheetStateProvider : PreviewParameterProvider<ExpressSt
get() = sequenceOf(
ExpressStatusBottomSheetConfig(
ExchangeUM(
fromUserWalletId = UserWalletId("AnyUserWalletID"),
info = ExpressTransactionStateInfoUM(
title = TextReference.Str("Transaction Status"),
status = ExpressStatusUM(

View file

@ -17,13 +17,13 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.haptic.VibratorHapticManager
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.extenstions.unwrap
@ -44,7 +44,6 @@ import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.promo.GetStoryContentUseCase
import com.tangem.domain.promo.models.StoryContentIds
import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase
import com.tangem.domain.tokens.NeedShowYieldSupplyDepositedWarningUseCase
import com.tangem.domain.tokens.SaveViewedTokenReceiveWarningUseCase
import com.tangem.domain.tokens.SaveViewedYieldSupplyWarningUseCase
@ -455,13 +454,10 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
override fun onMultiWalletSwapClick(userWalletId: UserWalletId) {
val selectedWallet = stateHolder.getSelectedWallet() as? WalletState.MultiCurrency.Content ?: return
when (val tokenListState = selectedWallet.tokensListState) {
is WalletTokensListState.ContentState.Content -> checkSwapCryptoAvailability(
tokenCount = tokenListState.items.count { it is TokensListItemUM.Token },
)
is WalletTokensListState.ContentState.PortfolioContent -> checkSwapCryptoAvailability(
tokenCount = tokenListState.items.sumOf { it.tokens.count { it is TokensListItemUM.Token } },
)
when (selectedWallet.tokensListState) {
is WalletTokensListState.ContentState.Content,
is WalletTokensListState.ContentState.PortfolioContent,
-> Unit
WalletTokensListState.ContentState.Loading,
WalletTokensListState.ContentState.Locked,
WalletTokensListState.Empty,
@ -470,7 +466,10 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
modelScope.launch {
val swapRoute = getSwapRoute(
AppRoute.SwapCrypto(userWalletId = userWalletId),
AppRoute.Swap(
userWalletId = userWalletId,
screenSource = AnalyticsParam.ScreensSources.Main.value,
),
)
onMultiWalletActionClick(
statusFlow = rampStateManager.getExpressInitializationStatus(userWalletId),
@ -660,7 +659,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
private fun navigateToSwap(cryptoCurrencyStatus: CryptoCurrencyStatus, userWalletId: UserWalletId) {
appRouter.push(
AppRoute.Swap(
currencyFrom = cryptoCurrencyStatus.currency,
cryptoCurrency = cryptoCurrencyStatus.currency,
userWalletId = userWalletId,
screenSource = AnalyticsParam.ScreensSources.LongTap.value,
),
@ -675,11 +674,4 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
}
}
}
private fun checkSwapCryptoAvailability(tokenCount: Int) {
if (tokenCount < 2) {
analyticsEventHandler.send(event = MainScreenAnalyticsEvent.ButtonSwap(AnalyticsParam.Status.Error))
uiMessageSender.send(WalletAlertUM.insufficientTokensCountForSwapping())
}
}
}