Updated on 2026-08-14
This commit is contained in:
commit
531eb86ac5
1243 changed files with 49302 additions and 12422 deletions
|
|
@ -2,18 +2,33 @@ package com.tangem.feature.swap
|
|||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
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.essenty.lifecycle.subscribe
|
||||
import com.tangem.common.ui.swapStoriesScreen.SwapStoriesScreen
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
|
||||
import com.tangem.feature.swap.model.SwapModel
|
||||
import com.tangem.feature.swap.models.AddToPortfolioRoute
|
||||
import com.tangem.feature.swap.router.SwapNavScreen
|
||||
import com.tangem.feature.swap.ui.SwapScreen
|
||||
import com.tangem.feature.swap.ui.SwapSelectTokenScreen
|
||||
import com.tangem.feature.swap.ui.SwapSuccessScreen
|
||||
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
|
||||
import com.tangem.features.send.v2.api.SendFeatureToggles
|
||||
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
|
||||
import com.tangem.features.swap.SwapComponent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
|
|
@ -22,11 +37,22 @@ import dagger.assisted.AssistedInject
|
|||
@Suppress("UnusedPrivateMember")
|
||||
internal class DefaultSwapComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
@Assisted params: SwapComponent.Params,
|
||||
@Assisted private val params: SwapComponent.Params,
|
||||
private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory,
|
||||
private val sendFeatureToggles: SendFeatureToggles,
|
||||
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
|
||||
) : SwapComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: SwapModel = getOrCreateModel(params)
|
||||
|
||||
private val bottomSheetSlot = childSlot(
|
||||
source = model.bottomSheetNavigation,
|
||||
serializer = AddToPortfolioRoute.serializer(),
|
||||
key = BOTTOM_SHEET_SLOT_KEY,
|
||||
handleBackButton = false,
|
||||
childFactory = { configuration, context -> bottomSheetChild(context) },
|
||||
)
|
||||
|
||||
init {
|
||||
lifecycle.subscribe(
|
||||
onStart = model::onStart,
|
||||
|
|
@ -34,8 +60,81 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
val slotNavigation = SlotNavigation<FeeSelectorConfig>()
|
||||
val childSlot = childSlot(
|
||||
source = slotNavigation,
|
||||
serializer = null,
|
||||
key = FEE_SELECTOR_SLOT_KEY,
|
||||
childFactory = { config, context ->
|
||||
createSwapFeeSelectorBlockComponent(
|
||||
context = childByContext(context),
|
||||
config = config,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
private fun createSwapFeeSelectorBlockComponent(
|
||||
context: AppComponentContext,
|
||||
config: FeeSelectorConfig,
|
||||
): SwapFeeSelectorBlockComponent {
|
||||
return swapFeeSelectorBlockComponentFactory.create(
|
||||
context = context,
|
||||
params = SwapFeeSelectorBlockComponent.Params(
|
||||
repository = model.feeSelectorRepository,
|
||||
userWalletId = params.userWalletId,
|
||||
sendingCryptoCurrencyStatus = config.sendingCurrencyStatus,
|
||||
feeCryptoCurrencyStatus = config.feeCurrencyStatus,
|
||||
analyticsParams = SwapFeeSelectorBlockComponent.AnalyticsParams(
|
||||
analyticsCategoryName = CommonSendAnalyticEvents.SWAP_CATEGORY,
|
||||
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Swap,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
data class FeeSelectorConfig(
|
||||
val sendingCurrencyStatus: CryptoCurrencyStatus,
|
||||
val feeCurrencyStatus: CryptoCurrencyStatus,
|
||||
)
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
if (sendFeatureToggles.isGaslessTransactionsEnabled) {
|
||||
val dataState by model.dataStateStateFlow.collectAsStateWithLifecycle()
|
||||
val fromCryptoCurrency by remember { derivedStateOf { dataState.fromCryptoCurrency } }
|
||||
val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } }
|
||||
val amount by remember { derivedStateOf { dataState.amount } }
|
||||
|
||||
LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, amount.isNullOrBlank()) {
|
||||
if (amount.isNullOrBlank()) {
|
||||
slotNavigation.dismiss()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val sendingCryptoCurrencyStatus = fromCryptoCurrency ?: run {
|
||||
slotNavigation.dismiss()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val feeCurrencyStatus = feePaidCryptoCurrency ?: run {
|
||||
slotNavigation.dismiss()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
slotNavigation.activate(
|
||||
FeeSelectorConfig(
|
||||
sendingCurrencyStatus = sendingCryptoCurrencyStatus,
|
||||
feeCurrencyStatus = feeCurrencyStatus,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val feeSelectorChildStackState by childSlot.subscribeAsState()
|
||||
val feeSelectorBlockComponent = feeSelectorChildStackState.child?.instance
|
||||
val bottomSheet by bottomSheetSlot.subscribeAsState()
|
||||
|
||||
Crossfade(
|
||||
modifier = Modifier.background(TangemTheme.colors.background.secondary),
|
||||
targetState = model.currentScreen,
|
||||
|
|
@ -47,16 +146,30 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
|||
if (storiesConfig != null) {
|
||||
SwapStoriesScreen(config = storiesConfig)
|
||||
} else {
|
||||
SwapScreen(stateHolder = model.uiState)
|
||||
SwapScreen(
|
||||
stateHolder = model.uiState,
|
||||
feeSelectorBlockComponent = feeSelectorBlockComponent,
|
||||
)
|
||||
}
|
||||
}
|
||||
SwapNavScreen.Main -> SwapScreen(stateHolder = model.uiState)
|
||||
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, model.uiState.onBackClicked)
|
||||
SwapSuccessScreen(
|
||||
state = successState,
|
||||
feeSelectorUM = feeSelectorState,
|
||||
onBack = model.uiState.onBackClicked,
|
||||
)
|
||||
} else {
|
||||
SwapScreen(stateHolder = model.uiState)
|
||||
SwapScreen(
|
||||
stateHolder = model.uiState,
|
||||
feeSelectorBlockComponent = feeSelectorBlockComponent,
|
||||
)
|
||||
}
|
||||
}
|
||||
SwapNavScreen.SelectToken -> {
|
||||
|
|
@ -64,15 +177,37 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
|||
if (tokenState != null) {
|
||||
SwapSelectTokenScreen(state = tokenState, onBack = model.uiState.onBackClicked)
|
||||
} else {
|
||||
SwapScreen(stateHolder = model.uiState)
|
||||
SwapScreen(
|
||||
stateHolder = model.uiState,
|
||||
feeSelectorBlockComponent = feeSelectorBlockComponent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bottomSheet.child?.instance?.BottomSheet()
|
||||
}
|
||||
|
||||
@Suppress("UnsafeCallOnNullableType")
|
||||
private fun bottomSheetChild(componentContext: ComponentContext): ComposableBottomSheetComponent {
|
||||
return addToPortfolioComponentFactory.create(
|
||||
context = childByContext(componentContext),
|
||||
params = AddToPortfolioComponent.Params(
|
||||
addToPortfolioManager = model.addToPortfolioManager!!,
|
||||
callback = model.addToPortfolioCallback,
|
||||
shouldSkipTokenActionsScreen = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@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 FEE_SELECTOR_SLOT_KEY = "feeSelectorSlot"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
package com.tangem.feature.swap
|
||||
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
|
||||
internal class DefaultSwapFeatureToggles : SwapFeatureToggles
|
||||
internal class DefaultSwapFeatureToggles(
|
||||
private val featureTogglesManager: FeatureTogglesManager,
|
||||
) : SwapFeatureToggles {
|
||||
override val isMarketListFeatureEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled("SWAP_MARKET_LIST_ENABLED")
|
||||
}
|
||||
|
|
@ -2,11 +2,16 @@ 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
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_TOKEN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_TOKEN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
|
||||
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
|
||||
import com.tangem.core.analytics.models.getReferralParams
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
|
|
@ -20,9 +25,15 @@ sealed class SwapEvents(
|
|||
params: Map<String, String> = emptyMap(),
|
||||
) : AnalyticsEvent(SWAP_CATEGORY, event, params) {
|
||||
|
||||
data class SwapScreenOpened(val token: String) : SwapEvents(
|
||||
data class SwapScreenOpened(
|
||||
val token: String,
|
||||
val blockchain: String,
|
||||
) : SwapEvents(
|
||||
event = "Swap Screen Opened",
|
||||
params = mapOf("Token" to token),
|
||||
params = mapOf(
|
||||
TOKEN_PARAM to token,
|
||||
BLOCKCHAIN to blockchain,
|
||||
),
|
||||
), AppsFlyerIncludedEvent
|
||||
|
||||
class SendTokenBalanceClicked : SwapEvents(event = "Send Token Balance Clicked")
|
||||
|
|
@ -85,21 +96,24 @@ sealed class SwapEvents(
|
|||
val receiveBlockchain: String,
|
||||
val sendToken: String,
|
||||
val receiveToken: String,
|
||||
val feeToken: String,
|
||||
val fromDerivationIndex: Int?,
|
||||
val toDerivationIndex: Int?,
|
||||
val referralId: String?,
|
||||
) : SwapEvents(
|
||||
event = "Swap in Progress Screen Opened",
|
||||
params = mapOf(
|
||||
"Provider" to provider.name,
|
||||
"Commission" to if (commission == FeeType.NORMAL) "Market" else "Fast",
|
||||
"Send Token" to sendToken,
|
||||
"Receive Token" to receiveToken,
|
||||
"Send Blockchain" to sendBlockchain,
|
||||
"Receive Blockchain" to receiveBlockchain,
|
||||
"Account Derivation From or To (optional)" to "$fromDerivationIndex, $toDerivationIndex",
|
||||
*getReferralParams(referralId).toTypedArray(),
|
||||
),
|
||||
params = buildMap {
|
||||
put("Provider", provider.name)
|
||||
put("Commission", if (commission == FeeType.NORMAL) "Market" else "Fast")
|
||||
put("Send Token", sendToken)
|
||||
put("Receive Token", receiveToken)
|
||||
put("Send Blockchain", sendBlockchain)
|
||||
put("Receive Blockchain", receiveBlockchain)
|
||||
if (fromDerivationIndex != null) put(ACCOUNT_DERIVATION_FROM, fromDerivationIndex.toString())
|
||||
if (toDerivationIndex != null) put(ACCOUNT_DERIVATION_TO, toDerivationIndex.toString())
|
||||
put(FEE_TOKEN, feeToken)
|
||||
putAll(getReferralParams(referralId))
|
||||
},
|
||||
), AppsFlyerIncludedEvent
|
||||
|
||||
class ProviderClicked : SwapEvents("Provider Clicked")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
package com.tangem.feature.swap.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import arrow.core.Either
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.child
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
|
||||
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
|
||||
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.v2.api.params.FeeSelectorParams
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
class SwapFeeSelectorBlockComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
@Assisted params: Params,
|
||||
feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory,
|
||||
) : AppComponentContext by appComponentContext, ComposableContentComponent {
|
||||
|
||||
private val feeSelectorBlockComponent =
|
||||
feeSelectorBlockComponentFactory.create(
|
||||
context = child("swapFeeSelectorBlock"),
|
||||
params = FeeSelectorParams.FeeSelectorBlockParams(
|
||||
state = params.repository.state.value,
|
||||
userWalletId = params.userWalletId,
|
||||
onLoadFee = params.repository::loadFee,
|
||||
onLoadFeeExtended = if (params.repository is ModelRepositoryExtended) {
|
||||
params.repository::loadFeeExtended
|
||||
} else {
|
||||
null
|
||||
},
|
||||
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
|
||||
feeStateConfiguration = FeeSelectorParams.FeeStateConfiguration.ExcludeLow,
|
||||
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
|
||||
cryptoCurrencyStatus = params.sendingCryptoCurrencyStatus,
|
||||
analyticsCategoryName = params.analyticsParams.analyticsCategoryName,
|
||||
analyticsSendSource = params.analyticsParams.analyticsSendSource,
|
||||
bottomSheetShown = params.repository::choosingInProgress,
|
||||
),
|
||||
onResult = params.repository::onResult,
|
||||
)
|
||||
|
||||
init {
|
||||
params.repository.state
|
||||
.onEach(feeSelectorBlockComponent::updateState)
|
||||
.launchIn(componentScope)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
feeSelectorBlockComponent.Content(modifier = modifier)
|
||||
}
|
||||
|
||||
interface ModelRepository {
|
||||
val state: StateFlow<FeeSelectorUM>
|
||||
get() = MutableStateFlow<FeeSelectorUM>(FeeSelectorUM.Loading)
|
||||
|
||||
fun onResult(newState: FeeSelectorUM)
|
||||
|
||||
suspend fun loadFee(): Either<GetFeeError, TransactionFee>
|
||||
|
||||
fun choosingInProgress(updatedState: Boolean)
|
||||
}
|
||||
|
||||
interface ModelRepositoryExtended : ModelRepository {
|
||||
suspend fun loadFeeExtended(
|
||||
selectedToken: CryptoCurrencyStatus? = null,
|
||||
): Either<GetFeeError, TransactionFeeExtended>
|
||||
}
|
||||
|
||||
class AnalyticsParams(
|
||||
val analyticsCategoryName: String,
|
||||
val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
|
||||
)
|
||||
|
||||
class Params(
|
||||
val userWalletId: UserWalletId,
|
||||
val sendingCryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
val analyticsParams: AnalyticsParams,
|
||||
val repository: ModelRepository,
|
||||
)
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : ComponentFactory<Params, SwapFeeSelectorBlockComponent>
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.tangem.feature.swap.converters
|
||||
|
||||
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
|
||||
import com.tangem.domain.markets.TokenMarketInfo
|
||||
import com.tangem.domain.markets.TokenMarketParams
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal class TokenMarketInfoToParamsConverter : Converter<TokenMarketInfo, TokenMarketParams> {
|
||||
|
||||
override fun convert(value: TokenMarketInfo): TokenMarketParams {
|
||||
val tokenId = CryptoCurrency.RawID(value.id)
|
||||
return TokenMarketParams(
|
||||
id = tokenId,
|
||||
name = value.name,
|
||||
symbol = value.symbol,
|
||||
tokenQuotes = TokenMarketParams.Quotes(
|
||||
currentPrice = value.quotes.currentPrice,
|
||||
h24Percent = value.quotes.h24ChangePercent,
|
||||
weekPercent = value.quotes.weekChangePercent,
|
||||
monthPercent = value.quotes.monthChangePercent,
|
||||
),
|
||||
imageUrl = getTokenIconUrlFromDefaultHost(tokenId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -31,32 +31,44 @@ internal class TokensDataConverterV2(
|
|||
|
||||
override fun transform(prevState: SwapStateHolder): SwapStateHolder {
|
||||
val accountList = tokensDataState.accountCurrencyList
|
||||
val currentMarketsState = prevState.selectTokenState?.marketsState
|
||||
return prevState.copy(
|
||||
selectTokenState = SwapSelectTokenStateHolder(
|
||||
availableTokens = persistentListOf(),
|
||||
unavailableTokens = persistentListOf(),
|
||||
tokensListData = if (isAccountsMode) {
|
||||
val portfolioList = accountListItemConverter.convertList(accountList).toPersistentList()
|
||||
val totalTokensCount = portfolioList.sumOf { it.tokens.size }
|
||||
TokenListUMData.AccountList(
|
||||
tokensList = accountListItemConverter.convertList(accountList).toPersistentList(),
|
||||
tokensList = portfolioList,
|
||||
totalTokensCount = totalTokensCount,
|
||||
)
|
||||
} else {
|
||||
TokenListUMData.TokenList(
|
||||
tokensList = persistentListOf(
|
||||
TokensListItemUM.GroupTitle(
|
||||
id = "available_tokens_title",
|
||||
text = resourceReference(R.string.exchange_tokens_available_tokens_header),
|
||||
),
|
||||
) + accountList.flatMap { (_, currencyList) ->
|
||||
currencyList.asSequence().map { accountSwapCurrency ->
|
||||
if (accountSwapCurrency.isAvailable) {
|
||||
accountListItemConverter.createAvailableItemConverter()
|
||||
} else {
|
||||
accountListItemConverter.createUnavailableItemConverter()
|
||||
}.convert(accountSwapCurrency.cryptoCurrencyStatus)
|
||||
}.map(TokensListItemUM::Token).toPersistentList()
|
||||
}.toPersistentList(),
|
||||
)
|
||||
val tokensList = accountList.flatMap { (_, currencyList) ->
|
||||
currencyList.asSequence().map { accountSwapCurrency ->
|
||||
if (accountSwapCurrency.isAvailable) {
|
||||
accountListItemConverter.createAvailableItemConverter()
|
||||
} else {
|
||||
accountListItemConverter.createUnavailableItemConverter()
|
||||
}.convert(accountSwapCurrency.cryptoCurrencyStatus)
|
||||
}.map(TokensListItemUM::Token).toPersistentList()
|
||||
}.toPersistentList()
|
||||
|
||||
if (tokensList.isNotEmpty()) {
|
||||
TokenListUMData.TokenList(
|
||||
tokensList = persistentListOf(
|
||||
TokensListItemUM.GroupTitle(
|
||||
id = "available_tokens_title",
|
||||
text = resourceReference(R.string.exchange_tokens_available_tokens_header),
|
||||
),
|
||||
) + tokensList,
|
||||
totalTokensCount = tokensList.size,
|
||||
)
|
||||
} else {
|
||||
TokenListUMData.EmptyList
|
||||
}
|
||||
},
|
||||
marketsState = currentMarketsState,
|
||||
onSearchEntered = onSearchEntered,
|
||||
onTokenSelected = onTokenSelected,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.feature.swap.di
|
||||
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.feature.swap.DefaultSwapComponent
|
||||
import com.tangem.feature.swap.DefaultSwapFeatureToggles
|
||||
import com.tangem.features.swap.SwapComponent
|
||||
|
|
@ -17,8 +18,8 @@ internal object SwapFeatureModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSwapFeatureToggles(): SwapFeatureToggles {
|
||||
return DefaultSwapFeatureToggles()
|
||||
fun provideSwapFeatureToggles(featureTogglesManager: FeatureTogglesManager): SwapFeatureToggles {
|
||||
return DefaultSwapFeatureToggles(featureTogglesManager)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,15 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.setValue
|
||||
import arrow.core.Either
|
||||
import arrow.core.getOrElse
|
||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||
import com.arkivanov.decompose.router.slot.activate
|
||||
import com.arkivanov.decompose.router.slot.dismiss
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState.InProgress.getApproveTypeOrNull
|
||||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
import com.tangem.core.analytics.api.AnalyticsErrorHandler
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
|
|
@ -28,16 +34,20 @@ import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase
|
|||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
|
||||
import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains
|
||||
import com.tangem.domain.express.models.ExpressOperationType
|
||||
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
|
||||
import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
|
||||
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
|
||||
import com.tangem.domain.feedback.models.BlockchainErrorInfo
|
||||
import com.tangem.domain.feedback.models.FeedbackEmailType
|
||||
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
|
||||
import com.tangem.domain.markets.GetTokenMarketInfoUseCase
|
||||
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.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.WithdrawalResult
|
||||
import com.tangem.domain.promo.GetStoryContentUseCase
|
||||
|
|
@ -47,35 +57,55 @@ import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
|
|||
import com.tangem.domain.settings.usercountry.models.UserCountry
|
||||
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
|
||||
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
|
||||
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
|
||||
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
|
||||
import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase
|
||||
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
|
||||
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
|
||||
import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.feature.swap.analytics.StoriesEvents
|
||||
import com.tangem.feature.swap.analytics.SwapEvents
|
||||
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
|
||||
import com.tangem.feature.swap.converters.TokenMarketInfoToParamsConverter
|
||||
import com.tangem.feature.swap.domain.SwapInteractor
|
||||
import com.tangem.feature.swap.domain.TransactionFeeResult
|
||||
import com.tangem.feature.swap.domain.TxFeeSealedState
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.ExpressException
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.*
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import com.tangem.feature.swap.models.AddToPortfolioRoute
|
||||
import com.tangem.feature.swap.models.SwapStateHolder
|
||||
import com.tangem.feature.swap.models.UiActions
|
||||
import com.tangem.feature.swap.models.market.SwapMarketsListBatchFlowManager
|
||||
import com.tangem.feature.swap.models.market.state.SwapMarketState
|
||||
import com.tangem.feature.swap.models.states.SwapNotificationUM
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import com.tangem.feature.swap.router.SwapNavScreen
|
||||
import com.tangem.feature.swap.router.SwapRouter
|
||||
import com.tangem.feature.swap.ui.StateBuilder
|
||||
import com.tangem.feature.swap.utils.formatToUIRepresentation
|
||||
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
|
||||
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager
|
||||
import com.tangem.features.send.v2.api.SendFeatureToggles
|
||||
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
|
||||
import com.tangem.features.swap.SwapComponent
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import com.tangem.lib.crypto.BlockchainUtils
|
||||
import com.tangem.utils.Provider
|
||||
import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP
|
||||
import com.tangem.utils.coroutines.*
|
||||
import com.tangem.utils.isNullOrZero
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -119,6 +149,16 @@ internal class SwapModel @Inject constructor(
|
|||
private val accountsFeatureToggles: AccountsFeatureToggles,
|
||||
private val getTangemPayCurrencyStatusUseCase: GetTangemPayCurrencyStatusUseCase,
|
||||
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase,
|
||||
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
|
||||
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger,
|
||||
private val sendFeatureToggles: SendFeatureToggles,
|
||||
private val getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
|
||||
swapFeatureToggles: SwapFeatureToggles,
|
||||
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
|
||||
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
|
||||
private val excludedBlockchains: ExcludedBlockchains,
|
||||
private val getUserWalletsUseCase: GetWalletsUseCase,
|
||||
private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase,
|
||||
private val appsFlyerStore: AppsFlyerStore,
|
||||
) : Model() {
|
||||
|
||||
|
|
@ -151,6 +191,7 @@ internal class SwapModel @Inject constructor(
|
|||
isBalanceHiddenProvider = Provider { isBalanceHidden },
|
||||
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
|
||||
isAccountsModeProvider = Provider { isAccountsMode },
|
||||
iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork,
|
||||
)
|
||||
|
||||
private val inputNumberFormatter =
|
||||
|
|
@ -159,9 +200,15 @@ internal class SwapModel @Inject constructor(
|
|||
?: error("NumberFormat is not DecimalFormat"),
|
||||
)
|
||||
private val amountDebouncer = Debouncer()
|
||||
private val searchDebouncer = Debouncer()
|
||||
private val singleTaskScheduler = SingleTaskScheduler<Map<SwapProvider, SwapState>>()
|
||||
|
||||
private var dataState by mutableStateOf(SwapProcessDataState())
|
||||
val dataStateStateFlow = MutableStateFlow(SwapProcessDataState())
|
||||
var dataState
|
||||
get() = dataStateStateFlow.value
|
||||
set(value) {
|
||||
dataStateStateFlow.value = value
|
||||
}
|
||||
|
||||
var uiState: SwapStateHolder by mutableStateOf(
|
||||
stateBuilder.createInitialLoadingState(
|
||||
|
|
@ -172,16 +219,26 @@ internal class SwapModel @Inject constructor(
|
|||
)
|
||||
private set
|
||||
|
||||
val feeSelectorRepository = FeeSelectorRepository()
|
||||
|
||||
// shows currency order (direct - swap initial to selected, reversed = selected to initial)
|
||||
private var isOrderReversed = false
|
||||
var isOrderReversed by mutableStateOf(false)
|
||||
private val lastAmount = mutableStateOf(INITIAL_AMOUNT)
|
||||
private val lastReducedBalanceBy = mutableStateOf(BigDecimal.ZERO)
|
||||
private val swapRouter: SwapRouter = SwapRouter(router = router)
|
||||
private var userCountry: UserCountry? = null
|
||||
|
||||
private lateinit var fromAccountCurrencyStatus: AccountCryptoCurrencyStatus
|
||||
private var fromAccountCurrencyStatus: AccountCryptoCurrencyStatus? = null
|
||||
private var toAccountCurrencyStatus: AccountCryptoCurrencyStatus? = null
|
||||
|
||||
/**
|
||||
* If accountsFeatureToggles is off OR user came from Tangem Pay -> fromAccountCurrencyStatus == null
|
||||
* If accountsFeatureToggles is on AND user didn't come from Tangem Pay -> fromAccountCurrencyStatus != null
|
||||
*
|
||||
* Remove when accounts are integrated into Tangem Pay
|
||||
*/
|
||||
private val canUseFromAccountCurrencyStatus = accountsFeatureToggles.isFeatureEnabled && tangemPayInput == null
|
||||
|
||||
private val isUserResolvableError: (SwapState) -> Boolean = { swapState ->
|
||||
swapState is SwapState.SwapError &&
|
||||
(
|
||||
|
|
@ -192,13 +249,52 @@ internal class SwapModel @Inject constructor(
|
|||
|
||||
private val fromTokenBalanceJobHolder = JobHolder()
|
||||
private val toTokenBalanceJobHolder = JobHolder()
|
||||
private val addToPortfolioJobHolder = JobHolder()
|
||||
|
||||
private var isAmountChangedByUser: Boolean = false
|
||||
private var lastPermissionNotificationTokens: Pair<String, String>? = null
|
||||
|
||||
private val searchQueryState = MutableStateFlow("")
|
||||
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
|
||||
private val searchMarketsListManager by lazy {
|
||||
SwapMarketsListBatchFlowManager(
|
||||
getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase,
|
||||
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search,
|
||||
currentAppCurrency = Provider { selectedAppCurrencyFlow.value },
|
||||
currentSearchText = Provider { searchQueryState.value },
|
||||
modelScope = modelScope,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
|
||||
val currentScreen: SwapNavScreen
|
||||
get() = swapRouter.currentScreen
|
||||
|
||||
val bottomSheetNavigation: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
|
||||
val addToPortfolioCallback = object : AddToPortfolioComponent.Callback {
|
||||
override fun onDismiss() = bottomSheetNavigation.dismiss()
|
||||
|
||||
override fun onSuccess(addedToken: CryptoCurrency) {
|
||||
modelScope.launch {
|
||||
bottomSheetNavigation.dismiss()
|
||||
uiState.selectTokenState?.let { currentSelectState ->
|
||||
uiState = uiState.copy(
|
||||
selectTokenState = currentSelectState.copy(
|
||||
marketsState = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
getAccountCurrencyStatusUseCase.invoke(userWalletId, addedToken)
|
||||
.firstOrNull {
|
||||
it.status.value is CryptoCurrencyStatus.Loaded
|
||||
}?.let { (account, status) ->
|
||||
applyAddedToken(status, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var addToPortfolioManager: AddToPortfolioManager? = null
|
||||
|
||||
init {
|
||||
userCountry = getUserCountryUseCase.invokeSync().getOrNull()
|
||||
?: UserCountry.Other(Locale.getDefault().country)
|
||||
|
|
@ -208,7 +304,7 @@ internal class SwapModel @Inject constructor(
|
|||
}
|
||||
|
||||
modelScope.launch(dispatchers.io) {
|
||||
if (accountsFeatureToggles.isFeatureEnabled && tangemPayInput == null) {
|
||||
if (canUseFromAccountCurrencyStatus) {
|
||||
isAccountsMode = isAccountsModeEnabledUseCase.invokeSync()
|
||||
|
||||
val fromAccountStatus = getAccountCurrencyStatusUseCase.invokeSync(
|
||||
|
|
@ -250,7 +346,12 @@ internal class SwapModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
analyticsEventHandler.send(SwapEvents.SwapScreenOpened(initialCurrencyFrom.symbol))
|
||||
analyticsEventHandler.send(
|
||||
SwapEvents.SwapScreenOpened(
|
||||
token = initialCurrencyFrom.symbol,
|
||||
blockchain = initialCurrencyFrom.network.name,
|
||||
),
|
||||
)
|
||||
|
||||
getBalanceHidingSettingsUseCase()
|
||||
.onEach { settings ->
|
||||
|
|
@ -258,6 +359,63 @@ internal class SwapModel @Inject constructor(
|
|||
uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden)
|
||||
}
|
||||
.launchIn(modelScope)
|
||||
|
||||
if (swapFeatureToggles.isMarketListFeatureEnabled) {
|
||||
combine(
|
||||
flow = searchQueryState
|
||||
.onEach { searchQuery ->
|
||||
searchMarketsListManager.reload(searchQuery)
|
||||
},
|
||||
flow2 = searchMarketsListManager.uiItems,
|
||||
flow3 = searchMarketsListManager.isInInitialLoadingErrorState,
|
||||
flow4 = searchMarketsListManager.isSearchNotFoundState,
|
||||
flow5 = searchMarketsListManager.totalCount.filterNotNull(),
|
||||
) { searchQuery, uiItems, isError, isSearchNotFound, total ->
|
||||
when {
|
||||
searchQuery.isEmpty() -> {
|
||||
visibleMarketItemIds.value = emptyList()
|
||||
null
|
||||
}
|
||||
isError -> SwapMarketState.LoadingError(
|
||||
onRetryClicked = { searchMarketsListManager.reload(searchQuery) },
|
||||
)
|
||||
isSearchNotFound -> SwapMarketState.SearchNothingFound
|
||||
uiItems.isEmpty() -> SwapMarketState.Loading
|
||||
else -> SwapMarketState.Content(
|
||||
items = uiItems,
|
||||
loadMore = { searchMarketsListManager.loadMore() },
|
||||
onItemClick = { item ->
|
||||
addToPortfolioItem(item)
|
||||
},
|
||||
visibleIdsChanged = { visibleMarketItemIds.value = it },
|
||||
total = total,
|
||||
)
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEach { marketsState ->
|
||||
uiState.selectTokenState?.let { currentSelectState ->
|
||||
uiState = uiState.copy(
|
||||
selectTokenState = currentSelectState.copy(
|
||||
marketsState = marketsState,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
modelScope.launch {
|
||||
visibleMarketItemIds.mapNotNull { rawIDS ->
|
||||
if (rawIDS.isNotEmpty()) {
|
||||
searchMarketsListManager.getBatchKeysByItemIds(rawIDS)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.distinctUntilChanged().collectLatest { visibleBatchKeys ->
|
||||
searchMarketsListManager.loadCharts(visibleBatchKeys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onStart() {
|
||||
|
|
@ -283,6 +441,7 @@ internal class SwapModel @Inject constructor(
|
|||
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(hasAvailableTokens = isAnyAvailableTokens))
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun initTokens(isReverseFromTo: Boolean) {
|
||||
modelScope.launch(dispatchers.main) {
|
||||
runCatching(dispatchers.io) {
|
||||
|
|
@ -318,21 +477,22 @@ internal class SwapModel @Inject constructor(
|
|||
isReverseFromTo = isReverseFromTo,
|
||||
)
|
||||
|
||||
(dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin ->
|
||||
subscribeToCoinBalanceUpdates(
|
||||
userWalletId = userWalletId,
|
||||
coin = coin,
|
||||
isFromCurrency = true,
|
||||
val fromCryptoCurrency = if (isOrderReversed) {
|
||||
dataState.toCryptoCurrency
|
||||
} else {
|
||||
dataState.fromCryptoCurrency
|
||||
}
|
||||
|
||||
fromCryptoCurrency?.let { cryptoCurrency ->
|
||||
dataState = dataState.copy(
|
||||
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrencyStatus = cryptoCurrency,
|
||||
).getOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
(dataState.toCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin ->
|
||||
subscribeToCoinBalanceUpdates(
|
||||
userWalletId = userWalletId,
|
||||
coin = coin,
|
||||
isFromCurrency = false,
|
||||
)
|
||||
}
|
||||
subscribeToCoinBalanceUpdatesIfNeeded()
|
||||
}.onFailure { error ->
|
||||
Timber.e(error)
|
||||
|
||||
|
|
@ -358,6 +518,47 @@ internal class SwapModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun applyAddedToken(addedToken: CryptoCurrencyStatus, addedAccount: Account.CryptoPortfolio?) {
|
||||
modelScope.launch {
|
||||
runCatching(dispatchers.io) {
|
||||
swapInteractor.getTokensDataState(initialCurrencyFrom)
|
||||
}.onSuccess { state ->
|
||||
updateTokensState(state)
|
||||
|
||||
applyInitialTokenChoice(
|
||||
state = state,
|
||||
selectedCurrency = addedToken,
|
||||
selectedAccount = addedAccount,
|
||||
isReverseFromTo = isOrderReversed,
|
||||
)
|
||||
|
||||
subscribeToCoinBalanceUpdatesIfNeeded()
|
||||
|
||||
swapRouter.back()
|
||||
}.onFailure { error ->
|
||||
Timber.e(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeToCoinBalanceUpdatesIfNeeded() {
|
||||
(dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin ->
|
||||
subscribeToCoinBalanceUpdates(
|
||||
userWalletId = userWalletId,
|
||||
coin = coin,
|
||||
isFromCurrency = true,
|
||||
)
|
||||
}
|
||||
|
||||
(dataState.toCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin ->
|
||||
subscribeToCoinBalanceUpdates(
|
||||
userWalletId = userWalletId,
|
||||
coin = coin,
|
||||
isFromCurrency = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initStories() {
|
||||
modelScope.launch {
|
||||
getStoryContentUseCase.invokeSync(StoryContentIds.STORY_FIRST_TIME_SWAP.id).fold(
|
||||
|
|
@ -394,11 +595,11 @@ internal class SwapModel @Inject constructor(
|
|||
} else {
|
||||
initialFromStatus to selectedCurrency
|
||||
}
|
||||
val (fromAccount, toAccount) = if (accountsFeatureToggles.isFeatureEnabled && tangemPayInput == null) {
|
||||
val (fromAccount, toAccount) = if (canUseFromAccountCurrencyStatus) {
|
||||
if (isOrderReversed) {
|
||||
selectedAccount to fromAccountCurrencyStatus.account
|
||||
selectedAccount to requireNotNull(fromAccountCurrencyStatus).account
|
||||
} else {
|
||||
fromAccountCurrencyStatus.account to selectedAccount
|
||||
requireNotNull(fromAccountCurrencyStatus).account to selectedAccount
|
||||
}
|
||||
} else {
|
||||
null to null
|
||||
|
|
@ -448,6 +649,7 @@ internal class SwapModel @Inject constructor(
|
|||
reduceBalanceBy: BigDecimal,
|
||||
toProvidersList: List<SwapProvider>,
|
||||
isSilent: Boolean = false,
|
||||
updateFeeBlock: Boolean = true,
|
||||
) {
|
||||
singleTaskScheduler.cancelTask()
|
||||
if (!isSilent) {
|
||||
|
|
@ -459,6 +661,7 @@ internal class SwapModel @Inject constructor(
|
|||
toAccount = toAccount,
|
||||
mainTokenId = initialCurrencyFrom.id.value,
|
||||
)
|
||||
feeSelectorRepository.state.value = FeeSelectorUM.Loading
|
||||
}
|
||||
singleTaskScheduler.scheduleTask(
|
||||
modelScope,
|
||||
|
|
@ -470,11 +673,12 @@ internal class SwapModel @Inject constructor(
|
|||
amount = amount,
|
||||
reduceBalanceBy = reduceBalanceBy,
|
||||
toProvidersList = toProvidersList,
|
||||
updateFeeBlock = updateFeeBlock,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun startLoadingQuotesFromLastState(isSilent: Boolean = false) {
|
||||
private fun startLoadingQuotesFromLastState(isSilent: Boolean = false, updateFeeBlock: Boolean = true) {
|
||||
val fromCurrency = dataState.fromCryptoCurrency
|
||||
val toCurrency = dataState.toCryptoCurrency
|
||||
val amount = dataState.amount
|
||||
|
|
@ -488,6 +692,7 @@ internal class SwapModel @Inject constructor(
|
|||
isSilent = isSilent,
|
||||
reduceBalanceBy = dataState.reduceBalanceBy,
|
||||
toProvidersList = findSwapProviders(fromCurrency, toCurrency),
|
||||
updateFeeBlock = updateFeeBlock,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -500,7 +705,9 @@ internal class SwapModel @Inject constructor(
|
|||
amount: String,
|
||||
reduceBalanceBy: BigDecimal,
|
||||
toProvidersList: List<SwapProvider>,
|
||||
updateFeeBlock: Boolean = true,
|
||||
): PeriodicTask<Map<SwapProvider, SwapState>> {
|
||||
var shouldUpdateFeeBlock = updateFeeBlock
|
||||
return PeriodicTask(
|
||||
delay = UPDATE_DELAY,
|
||||
task = {
|
||||
|
|
@ -520,7 +727,7 @@ internal class SwapModel @Inject constructor(
|
|||
providers = toProvidersList,
|
||||
amountToSwap = amount,
|
||||
reduceBalanceBy = reduceBalanceBy,
|
||||
selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
|
||||
txFeeSealedState = getSelectedFeeState(),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -536,12 +743,19 @@ internal class SwapModel @Inject constructor(
|
|||
tokenSwapInfoForProviders = successStates.entries
|
||||
.associate { it.key.providerId to it.value.toTokenInfo },
|
||||
)
|
||||
if (shouldUpdateFeeBlock) {
|
||||
modelScope.launch { feeSelectorReloadTrigger.triggerUpdate() }
|
||||
} else {
|
||||
shouldUpdateFeeBlock = true
|
||||
}
|
||||
} else {
|
||||
feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true)
|
||||
Timber.e("Accidentally empty quotes list")
|
||||
}
|
||||
},
|
||||
onError = { error ->
|
||||
Timber.e("Error when loading quotes: $error")
|
||||
feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true)
|
||||
uiState = stateBuilder.addNotification(uiState, null) { startLoadingQuotesFromLastState() }
|
||||
},
|
||||
)
|
||||
|
|
@ -581,7 +795,7 @@ internal class SwapModel @Inject constructor(
|
|||
swapProvider = provider,
|
||||
bestRatedProviderId = bestRatedProviderId,
|
||||
isNeedBestRateBadge = dataState.lastLoadedSwapStates.consideredProvidersStates().size > 1,
|
||||
selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
|
||||
selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL,
|
||||
isReverseSwapPossible = isReverseSwapPossible(),
|
||||
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
|
||||
hideFee = tangemPayInput?.isWithdrawal == true,
|
||||
|
|
@ -723,8 +937,8 @@ internal class SwapModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateOrSelectFee(state: SwapState.QuotesLoadedState): TxFee? {
|
||||
val selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL
|
||||
private fun updateOrSelectFee(state: SwapState.QuotesLoadedState): TxFee.Legacy? {
|
||||
val selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL
|
||||
return when (val txFee = state.txFee) {
|
||||
TxFeeState.Empty -> null
|
||||
is TxFeeState.MultipleFeeState -> {
|
||||
|
|
@ -751,10 +965,14 @@ internal class SwapModel @Inject constructor(
|
|||
return
|
||||
}
|
||||
val fromCurrency = requireNotNull(dataState.fromCryptoCurrency)
|
||||
val fee = dataState.selectedFee
|
||||
val fee = getSelectedFee()
|
||||
|
||||
if (fee == null && tangemPayInput?.isWithdrawal != true) {
|
||||
makeDefaultAlert(resourceReference(R.string.swapping_fee_estimation_error_text))
|
||||
modelScope.launch {
|
||||
delay(SWAP_IN_PROGRESS_DELAY)
|
||||
startLoadingQuotesFromLastState()
|
||||
}
|
||||
return
|
||||
}
|
||||
modelScope.launch(dispatchers.main) {
|
||||
|
|
@ -779,10 +997,12 @@ internal class SwapModel @Inject constructor(
|
|||
makeDefaultAlert(resourceReference(R.string.swapping_fee_estimation_error_text))
|
||||
return@onSuccess
|
||||
}
|
||||
sendSuccessSwapEvent(fromCurrency.currency, fee.feeType)
|
||||
sendSuccessSwapEvent(
|
||||
fromCurrency.currency,
|
||||
(getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL,
|
||||
)
|
||||
val url = getExplorerTransactionUrlUseCase(
|
||||
txHash = swapTransactionState.txHash,
|
||||
networkId = fromCurrency.currency.network.id,
|
||||
currency = fromCurrency.currency,
|
||||
).getOrElse {
|
||||
Timber.i("tx hash explore not supported")
|
||||
|
|
@ -892,7 +1112,7 @@ internal class SwapModel @Inject constructor(
|
|||
|
||||
private suspend fun sendSuccessEvent() {
|
||||
val provider = dataState.selectedProvider ?: return
|
||||
val fee = dataState.selectedFee?.feeType ?: return
|
||||
val fee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL
|
||||
val fromCurrency = dataState.fromCryptoCurrency?.currency ?: return
|
||||
val toCurrency = dataState.toCryptoCurrency?.currency ?: return
|
||||
val fromDerivationIndex = dataState.fromAccount?.derivationIndex?.value
|
||||
|
|
@ -906,6 +1126,7 @@ internal class SwapModel @Inject constructor(
|
|||
receiveBlockchain = toCurrency.network.name,
|
||||
sendToken = fromCurrency.symbol,
|
||||
receiveToken = toCurrency.symbol,
|
||||
feeToken = getFeeToken().symbol,
|
||||
fromDerivationIndex = fromDerivationIndex,
|
||||
toDerivationIndex = toDerivationIndex,
|
||||
referralId = appsFlyerStore.get()?.refcode,
|
||||
|
|
@ -953,6 +1174,7 @@ internal class SwapModel @Inject constructor(
|
|||
}.onSuccess { swapTransactionState ->
|
||||
when (swapTransactionState) {
|
||||
is SwapTransactionState.TxSent -> {
|
||||
// TODO [REDACTED_TASK_KEY] gasless analytics
|
||||
sendApproveSuccessEvent(fromToken, feeForPermission.feeType, approveType)
|
||||
updateWalletBalance()
|
||||
uiState = stateBuilder.loadingPermissionState(uiState)
|
||||
|
|
@ -988,8 +1210,10 @@ internal class SwapModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun onSearchEntered(searchQuery: String) {
|
||||
modelScope.launch(dispatchers.io) {
|
||||
val tokenDataState = dataState.tokensDataState ?: return@launch
|
||||
searchDebouncer.debounce(modelScope, DEBOUNCE_SEARCH_DELAY) {
|
||||
searchQueryState.value = searchQuery
|
||||
|
||||
val tokenDataState = dataState.tokensDataState ?: return@debounce
|
||||
val group = if (isOrderReversed) {
|
||||
tokenDataState.fromGroup
|
||||
} else {
|
||||
|
|
@ -1062,7 +1286,7 @@ internal class SwapModel @Inject constructor(
|
|||
fromAccount = foundAccount
|
||||
toToken = initialFromStatus
|
||||
toAccount = if (accountsFeatureToggles.isFeatureEnabled) {
|
||||
fromAccountCurrencyStatus.account
|
||||
fromAccountCurrencyStatus?.account
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
@ -1080,7 +1304,7 @@ internal class SwapModel @Inject constructor(
|
|||
} else {
|
||||
fromToken = initialFromStatus
|
||||
fromAccount = if (accountsFeatureToggles.isFeatureEnabled) {
|
||||
fromAccountCurrencyStatus.account
|
||||
fromAccountCurrencyStatus?.account
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
@ -1166,12 +1390,14 @@ internal class SwapModel @Inject constructor(
|
|||
.onEach { (account, currencyStatus) ->
|
||||
Timber.d("${coin.id} balance is ${currencyStatus.value.amount ?: "null"}")
|
||||
|
||||
dataState = dataState.copy(
|
||||
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrencyStatus = currencyStatus,
|
||||
).getOrNull() ?: currencyStatus,
|
||||
)
|
||||
if (isFromCurrency) {
|
||||
dataState = dataState.copy(
|
||||
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrencyStatus = currencyStatus,
|
||||
).getOrNull() ?: currencyStatus,
|
||||
)
|
||||
}
|
||||
|
||||
uiState = when {
|
||||
isFromCurrency && currencyStatus.currency.id == dataState.fromCryptoCurrency?.currency?.id -> {
|
||||
|
|
@ -1204,12 +1430,14 @@ internal class SwapModel @Inject constructor(
|
|||
.onEach { status ->
|
||||
Timber.d("${coin.id} balance is ${status.value.amount ?: "null"}")
|
||||
|
||||
dataState = dataState.copy(
|
||||
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrencyStatus = status,
|
||||
).getOrNull() ?: status,
|
||||
)
|
||||
if (isFromCurrency) {
|
||||
dataState = dataState.copy(
|
||||
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrencyStatus = status,
|
||||
).getOrNull() ?: status,
|
||||
)
|
||||
}
|
||||
|
||||
uiState = when {
|
||||
isFromCurrency && status.currency.id == dataState.fromCryptoCurrency?.currency?.id -> {
|
||||
|
|
@ -1416,7 +1644,7 @@ internal class SwapModel @Inject constructor(
|
|||
uiState = stateBuilder.updateApproveType(uiState, approveType)
|
||||
},
|
||||
onClickFee = {
|
||||
val selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL
|
||||
val selectedFee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL
|
||||
val txFeeState =
|
||||
dataState.getCurrentLoadedSwapState()?.txFee as? TxFeeState.MultipleFeeState ?: return@UiActions
|
||||
uiState = stateBuilder.showSelectFeeBottomSheet(
|
||||
|
|
@ -1427,9 +1655,9 @@ internal class SwapModel @Inject constructor(
|
|||
uiState = stateBuilder.dismissBottomSheet(uiState)
|
||||
}
|
||||
},
|
||||
onSelectFeeType = { feeType ->
|
||||
onSelectFeeType = { txFee ->
|
||||
uiState = stateBuilder.dismissBottomSheet(uiState)
|
||||
dataState = dataState.copy(selectedFee = feeType)
|
||||
dataState = dataState.copy(selectedFee = txFee)
|
||||
modelScope.launch(dispatchers.io) {
|
||||
startLoadingQuotesFromLastState(false)
|
||||
}
|
||||
|
|
@ -1451,6 +1679,10 @@ internal class SwapModel @Inject constructor(
|
|||
val swapState = dataState.lastLoadedSwapStates[provider]
|
||||
val fromToken = dataState.fromCryptoCurrency
|
||||
if (provider != null && swapState != null && fromToken != null) {
|
||||
modelScope.launch {
|
||||
feeSelectorRepository.state.value = FeeSelectorUM.Loading
|
||||
feeSelectorReloadTrigger.triggerUpdate()
|
||||
}
|
||||
analyticsEventHandler.send(SwapEvents.ProviderChosen(provider))
|
||||
uiState = stateBuilder.dismissBottomSheet(uiState)
|
||||
setupLoadedState(
|
||||
|
|
@ -1511,6 +1743,7 @@ internal class SwapModel @Inject constructor(
|
|||
blockchain = fromToken.network.name,
|
||||
token = fromToken.symbol,
|
||||
feeType = AnalyticsParam.FeeType.fromString(feeType.getNameForAnalytics()),
|
||||
feeToken = getFeeToken().symbol,
|
||||
)
|
||||
analyticsEventHandler.send(
|
||||
Basic.TransactionSent(
|
||||
|
|
@ -1520,12 +1753,26 @@ internal class SwapModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private fun getFeeToken(): CryptoCurrency {
|
||||
val fromToken = requireNotNull(dataState.fromCryptoCurrency) {
|
||||
"fromCryptoCurrency should not be null"
|
||||
}
|
||||
return when (val fee = getSelectedFee()) {
|
||||
is TxFee.FeeComponent -> fee.selectedToken?.currency ?: fromToken.currency
|
||||
is TxFee.Legacy,
|
||||
null,
|
||||
-> fromToken.currency
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendApproveSuccessEvent(fromToken: CryptoCurrency, feeType: FeeType, approveType: SwapApproveType) {
|
||||
val feeToken = getFeeToken().symbol
|
||||
val event = AnalyticsParam.TxSentFrom.Approve(
|
||||
blockchain = fromToken.network.name,
|
||||
token = fromToken.symbol,
|
||||
feeType = AnalyticsParam.FeeType.fromString(feeType.getNameForAnalytics()),
|
||||
permissionType = approveType.getNameForAnalytics(),
|
||||
feeToken = feeToken,
|
||||
)
|
||||
analyticsEventHandler.send(
|
||||
Basic.TransactionSent(
|
||||
|
|
@ -1753,16 +2000,20 @@ internal class SwapModel @Inject constructor(
|
|||
uiState = uiState,
|
||||
error = SwapTransactionState.Error.TangemPayWithdrawalError(txId.orEmpty()),
|
||||
onDismiss = { uiState = stateBuilder.clearAlert(uiState) },
|
||||
onSupportClick = ::onTangemPaySupportClick,
|
||||
onSupportClick = {
|
||||
val customerId = getTangemPayCustomerIdUseCase(userWallet.walletId).getOrNull() ?: "Unknown"
|
||||
onTangemPaySupportClick(customerId = customerId, txId = txId)
|
||||
},
|
||||
isReverseSwapPossible = isReverseSwapPossible(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onTangemPaySupportClick(txId: String?) {
|
||||
private fun onTangemPaySupportClick(customerId: String, txId: String?) {
|
||||
modelScope.launch {
|
||||
val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch
|
||||
val email = FeedbackEmailType.Visa.Withdrawal(
|
||||
walletMetaInfo = metaInfo,
|
||||
customerId = customerId,
|
||||
providerName = dataState.selectedProvider?.name.orEmpty(),
|
||||
txId = txId.orEmpty(),
|
||||
)
|
||||
|
|
@ -1784,7 +2035,11 @@ internal class SwapModel @Inject constructor(
|
|||
destinationAddress = transaction?.txTo.orEmpty(),
|
||||
tokenSymbol = fromCurrencyStatus.currency.symbol,
|
||||
amount = dataState.amount.orEmpty(),
|
||||
fee = dataState.selectedFee?.feeCryptoFormatted.orEmpty(),
|
||||
fee = when (val fee = getSelectedFee()) {
|
||||
is TxFee.FeeComponent -> fee.fee.amount.value?.toString()
|
||||
is TxFee.Legacy -> fee.feeCryptoFormatted
|
||||
null -> ""
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -1801,6 +2056,42 @@ internal class SwapModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun addToPortfolioItem(item: MarketsListItemUM) {
|
||||
modelScope.launch {
|
||||
val tokenInfo = getTokenMarketInfoUseCase(
|
||||
selectedAppCurrencyFlow.value,
|
||||
item.id,
|
||||
item.currencySymbol,
|
||||
).getOrNull() ?: return@launch
|
||||
|
||||
val converter = TokenMarketInfoToParamsConverter()
|
||||
val param = converter.convert(tokenInfo)
|
||||
val hasOnlyHotWallets = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot }
|
||||
|
||||
val networks = tokenInfo.networks?.filter { network ->
|
||||
BlockchainUtils.isSupportedNetworkId(
|
||||
blockchainId = network.networkId,
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
hotExcludedBlockchains = hotWalletExcludedBlockchains,
|
||||
hasOnlyHotWallets = hasOnlyHotWallets,
|
||||
)
|
||||
}.orEmpty()
|
||||
|
||||
addToPortfolioManager = addToPortfolioManagerFactory
|
||||
.create(
|
||||
scope = modelScope,
|
||||
token = param,
|
||||
analyticsParams = null,
|
||||
).apply {
|
||||
setTokenNetworks(networks)
|
||||
}
|
||||
|
||||
addToPortfolioManager?.state
|
||||
?.firstOrNull { it is AddToPortfolioManager.State.AvailableToAdd }
|
||||
?.run { bottomSheetNavigation.activate(AddToPortfolioRoute) }
|
||||
}.saveIn(addToPortfolioJobHolder)
|
||||
}
|
||||
|
||||
private fun CryptoCurrency.getNetworkInfo(): NetworkInfo {
|
||||
return NetworkInfo(
|
||||
name = this.network.name,
|
||||
|
|
@ -1824,11 +2115,167 @@ internal class SwapModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun getSelectedFeeState(): TxFeeSealedState {
|
||||
if (!sendFeatureToggles.isGaslessTransactionsEnabled) {
|
||||
return TxFeeSealedState.Legacy(
|
||||
txFeeState = TxFeeState.Empty,
|
||||
selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
|
||||
)
|
||||
}
|
||||
|
||||
val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content
|
||||
?: return TxFeeSealedState.Legacy(
|
||||
txFeeState = TxFeeState.Empty,
|
||||
selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
|
||||
)
|
||||
|
||||
val transactionFeeExtended = feeStateUM.feeExtraInfo.transactionFeeExtended
|
||||
return TxFeeSealedState.Component(
|
||||
txFee = TxFee.FeeComponent(
|
||||
transactionFeeResult = transactionFeeExtended?.let { TransactionFeeResult.from(it) }
|
||||
?: TransactionFeeResult.from(feeStateUM.fees),
|
||||
fee = feeStateUM.selectedFeeItem.fee,
|
||||
selectedToken = feeStateUM.feeExtraInfo.feeCryptoCurrencyStatus,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getSelectedFee(): TxFee? {
|
||||
if (!sendFeatureToggles.isGaslessTransactionsEnabled) {
|
||||
return dataState.selectedFee
|
||||
}
|
||||
|
||||
val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content ?: return null
|
||||
val transactionFeeExtended = feeStateUM.feeExtraInfo.transactionFeeExtended
|
||||
|
||||
return TxFee.FeeComponent(
|
||||
transactionFeeResult = transactionFeeExtended?.let { TransactionFeeResult.from(it) }
|
||||
?: TransactionFeeResult.from(feeStateUM.fees),
|
||||
fee = feeStateUM.selectedFeeItem.fee,
|
||||
selectedToken = feeStateUM.feeExtraInfo.feeCryptoCurrencyStatus,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UnsafeCallOnNullableType")
|
||||
inner class FeeSelectorRepository : SwapFeeSelectorBlockComponent.ModelRepositoryExtended {
|
||||
|
||||
override val state = MutableStateFlow<FeeSelectorUM>(
|
||||
FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true),
|
||||
)
|
||||
|
||||
override suspend fun loadFeeExtended(
|
||||
selectedToken: CryptoCurrencyStatus?,
|
||||
): Either<GetFeeError, TransactionFeeExtended> {
|
||||
val fromToken = dataState.fromCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError)
|
||||
val toToken = dataState.toCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError)
|
||||
val selectedProvider = dataStateStateFlow.first { it.selectedProvider != null }.selectedProvider!!
|
||||
|
||||
if (selectedProvider.type != ExchangeProviderType.CEX) {
|
||||
return Either.Left(GetFeeError.GaslessError.NetworkIsNotSupported)
|
||||
}
|
||||
|
||||
if (dataState.lastLoadedSwapStates[selectedProvider] !is SwapState.QuotesLoadedState) {
|
||||
return Either.Left(GetFeeError.UnknownError)
|
||||
}
|
||||
|
||||
if (isPermissionNotificationShown()) {
|
||||
return Either.Left(GetFeeError.UnknownError)
|
||||
}
|
||||
|
||||
return swapInteractor.loadFeeForSwapTransaction(
|
||||
fromToken = fromToken,
|
||||
fromAccount = dataState.fromAccount,
|
||||
toToken = toToken,
|
||||
toAccount = dataState.toAccount,
|
||||
provider = selectedProvider,
|
||||
amount = lastAmount.value,
|
||||
reduceBalanceBy = lastReducedBalanceBy.value,
|
||||
selectedFeeToken = selectedToken,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResult(newState: FeeSelectorUM) {
|
||||
if (isPermissionNotificationShown()) {
|
||||
state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true)
|
||||
return
|
||||
}
|
||||
|
||||
state.value = newState
|
||||
|
||||
// If fee currency is same as from currency, we need to reload quotes to update fee info
|
||||
val isFeeCurrencySameAsFromCurrency = newState is FeeSelectorUM.Content &&
|
||||
dataState.fromCryptoCurrency?.currency?.id == newState.feeExtraInfo.feeCryptoCurrencyStatus.currency.id
|
||||
|
||||
// If fee currency is coin, we need to reload quotes to update fee related warnings (e.g. insufficient funds)
|
||||
val isCoinFeeSelected = newState is FeeSelectorUM.Content &&
|
||||
newState.feeExtraInfo.feeCryptoCurrencyStatus.currency is CryptoCurrency.Coin
|
||||
|
||||
if (isFeeCurrencySameAsFromCurrency || isCoinFeeSelected) {
|
||||
// block swap button until fee is loaded
|
||||
uiState = uiState.copy(
|
||||
swapButton = uiState.swapButton.copy(
|
||||
isEnabled = false,
|
||||
isInProgress = false,
|
||||
),
|
||||
)
|
||||
modelScope.launch {
|
||||
startLoadingQuotesFromLastState(
|
||||
isSilent = true,
|
||||
updateFeeBlock = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPermissionNotificationShown(): Boolean {
|
||||
val permissionState = dataState.getCurrentLoadedSwapState()?.permissionState
|
||||
return permissionState != null && permissionState !is PermissionDataState.Empty
|
||||
}
|
||||
|
||||
override suspend fun loadFee(): Either<GetFeeError, TransactionFee> {
|
||||
val fromToken = dataState.fromCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError)
|
||||
val toToken = dataState.toCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError)
|
||||
val selectedProvider = dataStateStateFlow.first { it.selectedProvider != null }.selectedProvider!!
|
||||
|
||||
if (dataState.lastLoadedSwapStates[selectedProvider] !is SwapState.QuotesLoadedState) {
|
||||
return Either.Left(GetFeeError.UnknownError)
|
||||
}
|
||||
|
||||
if (isPermissionNotificationShown()) {
|
||||
return Either.Left(GetFeeError.UnknownError)
|
||||
}
|
||||
|
||||
return swapInteractor.loadFeeForSwapTransaction(
|
||||
fromToken = fromToken,
|
||||
fromAccount = dataState.fromAccount,
|
||||
toToken = toToken,
|
||||
toAccount = dataState.toAccount,
|
||||
provider = selectedProvider,
|
||||
amount = lastAmount.value,
|
||||
reduceBalanceBy = lastReducedBalanceBy.value,
|
||||
)
|
||||
}
|
||||
|
||||
override fun choosingInProgress(updatedState: Boolean) {
|
||||
// We shouldn't load quotes while user is choosing fee
|
||||
if (updatedState) {
|
||||
singleTaskScheduler.cancelTask()
|
||||
} else {
|
||||
startLoadingQuotesFromLastState(
|
||||
isSilent = true,
|
||||
updateFeeBlock = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val INITIAL_AMOUNT = ""
|
||||
const val UPDATE_DELAY = 10000L
|
||||
const val DEBOUNCE_AMOUNT_DELAY = 1000L
|
||||
const val DEBOUNCE_SEARCH_DELAY = 500L
|
||||
const val UPDATE_BALANCE_DELAY_MILLIS = 11000L
|
||||
const val SWAP_IN_PROGRESS_DELAY = 200L
|
||||
const val CHANGELLY_PROVIDER_ID = "changelly"
|
||||
}
|
||||
}
|
||||
|
|
@ -12,8 +12,10 @@ import com.tangem.core.ui.format.bigdecimal.format
|
|||
import com.tangem.core.ui.utils.parseBigDecimal
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapFeeState
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
|
|
@ -32,6 +34,7 @@ import java.math.BigDecimal
|
|||
@Suppress("LargeClass")
|
||||
internal class SwapNotificationsFactory(
|
||||
private val actions: UiActions,
|
||||
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
|
||||
) {
|
||||
|
||||
fun getInitialErrorStateNotifications(code: Int, onRefreshClick: () -> Unit): ImmutableList<NotificationUM> {
|
||||
|
|
@ -162,7 +165,7 @@ internal class SwapNotificationsFactory(
|
|||
|
||||
addExistentialWarningNotification(
|
||||
existentialDeposit = quoteModel.currencyCheck?.existentialDeposit,
|
||||
feeAmount = fee?.feeValue.orZero(),
|
||||
feeAmount = fee?.fee?.amount?.value.orZero(),
|
||||
sendingAmount = amountToRequest.value,
|
||||
cryptoCurrencyStatus = fromCurrencyStatus,
|
||||
onReduceClick = { reduceBy, reduceByDiff, _ ->
|
||||
|
|
@ -185,7 +188,7 @@ internal class SwapNotificationsFactory(
|
|||
if (!isCardano) {
|
||||
addDustWarningNotification(
|
||||
dustValue = quoteModel.currencyCheck?.dustValue,
|
||||
feeValue = fee?.feeValue.orZero(),
|
||||
feeValue = fee?.fee?.amount?.value.orZero(),
|
||||
sendingAmount = amountToRequest.value,
|
||||
cryptoCurrencyStatus = fromCurrencyStatus,
|
||||
feeCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
|
|
@ -273,7 +276,7 @@ internal class SwapNotificationsFactory(
|
|||
}
|
||||
}
|
||||
|
||||
private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): TxFee? {
|
||||
private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): TxFee.Legacy? {
|
||||
return when (txFeeState) {
|
||||
TxFeeState.Empty -> null
|
||||
is TxFeeState.SingleFeeState -> txFeeState.fee
|
||||
|
|
@ -294,7 +297,13 @@ internal class SwapNotificationsFactory(
|
|||
val shouldShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough &&
|
||||
quoteModel.permissionState !is PermissionDataState.PermissionLoading &&
|
||||
feeEnoughState.feeCurrency != fromToken
|
||||
if (shouldShowCoverWarning) {
|
||||
|
||||
val isNotEnoughFee =
|
||||
quoteModel.preparedSwapConfigState.includeFeeInAmount is IncludeFeeInAmount.BalanceNotEnough
|
||||
|
||||
val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromToken.network) &&
|
||||
quoteModel.swapProvider.type == ExchangeProviderType.CEX
|
||||
if (shouldShowCoverWarning && !isGaslessAvailable || isNotEnoughFee) {
|
||||
add(
|
||||
SwapNotificationUM.Error.UnableToCoverFeeWarning(
|
||||
fromToken = fromToken,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ data class SwapProcessDataState(
|
|||
val reduceBalanceBy: BigDecimal = BigDecimal.ZERO,
|
||||
val approveDataModel: RequestApproveStateData? = null,
|
||||
val swapDataModel: SwapDataModel? = null,
|
||||
val selectedFee: TxFee? = null,
|
||||
val selectedFee: TxFee.Legacy? = null,
|
||||
val tokensDataState: TokensDataStateExpress? = null,
|
||||
val selectedProvider: SwapProvider? = null,
|
||||
val lastLoadedSwapStates: Map<SwapProvider, SwapState> = emptyMap(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package com.tangem.feature.swap.models
|
||||
|
||||
import com.tangem.core.decompose.navigation.Route
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal data object AddToPortfolioRoute : Route
|
||||
|
|
@ -3,12 +3,14 @@ package com.tangem.feature.swap.models
|
|||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.feature.swap.models.market.state.SwapMarketState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
internal data class SwapSelectTokenStateHolder(
|
||||
val availableTokens: ImmutableList<TokenToSelectState>,
|
||||
val unavailableTokens: ImmutableList<TokenToSelectState>,
|
||||
val marketsState: SwapMarketState? = null,
|
||||
val tokensListData: TokenListUMData,
|
||||
val isBalanceHidden: Boolean,
|
||||
val isAfterSearch: Boolean,
|
||||
|
|
@ -39,16 +41,34 @@ internal data class TokenBalanceData(
|
|||
internal sealed interface TokenListUMData {
|
||||
|
||||
val tokensList: ImmutableList<TokensListItemUM>
|
||||
val totalTokensCount: Int
|
||||
|
||||
data class AccountList(
|
||||
override val tokensList: ImmutableList<TokensListItemUM.Portfolio>,
|
||||
override val totalTokensCount: Int,
|
||||
) : TokenListUMData
|
||||
|
||||
data class TokenList(
|
||||
override val tokensList: ImmutableList<TokensListItemUM>,
|
||||
override val totalTokensCount: Int,
|
||||
) : TokenListUMData
|
||||
|
||||
data object EmptyList : TokenListUMData {
|
||||
override val tokensList: ImmutableList<TokensListItemUM> = persistentListOf()
|
||||
override val totalTokensCount: Int = EMPTY_TOKENS_COUNT
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val EMPTY_TOKENS_COUNT = 0
|
||||
}
|
||||
}
|
||||
|
||||
internal val SwapSelectTokenStateHolder.isNotFoundState: Boolean
|
||||
get() = availableTokens.isEmpty() && unavailableTokens.isEmpty() &&
|
||||
tokensListData.tokensList.isEmpty() && isAfterSearch &&
|
||||
marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading
|
||||
|
||||
internal val SwapSelectTokenStateHolder.isEmptyState: Boolean
|
||||
get() = availableTokens.isEmpty() && unavailableTokens.isEmpty() &&
|
||||
tokensListData.tokensList.isEmpty() && !isAfterSearch &&
|
||||
marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading
|
||||
|
|
@ -78,6 +78,8 @@ sealed class SwapCardState {
|
|||
data class SwapButton(
|
||||
@DrawableRes val walletInteractionIcon: Int?,
|
||||
val isEnabled: Boolean,
|
||||
val isInProgress: Boolean = false,
|
||||
val isHoldToConfirm: Boolean = false,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import com.tangem.core.ui.extensions.TextReference
|
|||
data class SwapSuccessStateHolder(
|
||||
val timestamp: Long,
|
||||
val txUrl: String,
|
||||
val fee: TextReference,
|
||||
val fee: TextReference?,
|
||||
val rate: TextReference,
|
||||
val shouldShowStatusButton: Boolean,
|
||||
val providerName: TextReference,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ data class UiActions(
|
|||
val onStoriesClose: (Int) -> Unit,
|
||||
val onRetryClick: () -> Unit,
|
||||
val onClickFee: () -> Unit,
|
||||
val onSelectFeeType: (TxFee) -> Unit,
|
||||
val onSelectFeeType: (TxFee.Legacy) -> Unit,
|
||||
val onProviderClick: (String) -> Unit,
|
||||
val onProviderSelect: (String) -> Unit,
|
||||
val onBuyClick: (CryptoCurrency) -> Unit,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
package com.tangem.feature.swap.models.market
|
||||
|
||||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.markets.*
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.feature.swap.models.market.converter.SwapMarketsTokenItemConverter
|
||||
import com.tangem.pagination.Batch
|
||||
import com.tangem.pagination.BatchAction
|
||||
import com.tangem.pagination.PaginationStatus
|
||||
import com.tangem.utils.Provider
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.JobHolder
|
||||
import com.tangem.utils.coroutines.saveIn
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class SwapMarketsListBatchFlowManager(
|
||||
getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
|
||||
private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType,
|
||||
private val currentAppCurrency: Provider<AppCurrency>,
|
||||
private val currentSearchText: Provider<String?>,
|
||||
private val modelScope: CoroutineScope,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) {
|
||||
private val actionsFlow = MutableSharedFlow<BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>>()
|
||||
private val updateStateJob = JobHolder()
|
||||
|
||||
private val batchFlow = getMarketsTokenListFlowUseCase(
|
||||
batchingContext = TokenListBatchingContext(
|
||||
actionsFlow = actionsFlow,
|
||||
coroutineScope = modelScope,
|
||||
),
|
||||
batchFlowType = batchFlowType,
|
||||
)
|
||||
|
||||
private val resultBatches = MutableStateFlow(ResultBatches())
|
||||
private val uiBatches = resultBatches.map { it.uiBatches }
|
||||
|
||||
val uiItems: StateFlow<ImmutableList<MarketsListItemUM>>
|
||||
get() = uiBatches
|
||||
.map { batches ->
|
||||
batches.asSequence()
|
||||
.map { it.data }
|
||||
.flatten()
|
||||
.toImmutableList()
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = modelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = persistentListOf(),
|
||||
)
|
||||
|
||||
val isInInitialLoadingErrorState = batchFlow.state
|
||||
.map { it.status is PaginationStatus.InitialLoadingError }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = modelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
val isSearchNotFoundState = batchFlow.state
|
||||
.map { batchListState ->
|
||||
currentSearchText().isNullOrEmpty().not() &&
|
||||
batchListState.status is PaginationStatus.EndOfPagination &&
|
||||
batchListState.data.isEmpty()
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = modelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
val totalCount: StateFlow<Int?> = batchFlow.state
|
||||
.map { it.totalCount }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = modelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
init {
|
||||
batchFlow.state
|
||||
.map { it.data }
|
||||
.distinctUntilChanged { a, b ->
|
||||
a.size == b.size &&
|
||||
a.map { it.key } == b.map { it.key } &&
|
||||
a.map { it.data }.flatten() == b.map { it.data }.flatten()
|
||||
}
|
||||
.onEach {
|
||||
coroutineScope {
|
||||
launch {
|
||||
updateState(it)
|
||||
}.saveIn(updateStateJob)
|
||||
}
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private suspend fun updateState(newList: List<Batch<Int, List<TokenMarket>>>, forceUpdate: Boolean = false) =
|
||||
withContext(dispatchers.default) {
|
||||
resultBatches.update { resultBatches ->
|
||||
val items = resultBatches.uiBatches
|
||||
val previousList = resultBatches.processedItems
|
||||
|
||||
val converter = SwapMarketsTokenItemConverter(appCurrency = currentAppCurrency())
|
||||
|
||||
if (newList.isEmpty()) {
|
||||
return@update ResultBatches(processedItems = emptyList())
|
||||
}
|
||||
|
||||
val isInitialLoading =
|
||||
forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key
|
||||
|
||||
val outItems = if (isInitialLoading) {
|
||||
newList.map { batch ->
|
||||
Batch(
|
||||
key = batch.key,
|
||||
data = converter.convertList(batch.data),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (previousList.size != newList.size) {
|
||||
val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet())
|
||||
val newBatches = newList.filter { keysToAdd.contains(it.key) }
|
||||
|
||||
items + newBatches.map { batch ->
|
||||
Batch(
|
||||
key = batch.key,
|
||||
data = converter.convertList(batch.data),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items.mapIndexed { batchIndex, batch ->
|
||||
val prevBatch = previousList[batchIndex]
|
||||
val newBatch = newList[batchIndex]
|
||||
if (prevBatch == newBatch) return@mapIndexed batch
|
||||
|
||||
Batch(
|
||||
key = batch.key,
|
||||
data = batch.data.mapIndexed { index, marketsListItemUM ->
|
||||
val prevItem = prevBatch.data.getOrNull(index)
|
||||
val newItem = newBatch.data.getOrNull(index)
|
||||
if (prevItem != null && newItem != null) {
|
||||
converter.update(prevItem, marketsListItemUM, newItem)
|
||||
} else {
|
||||
newItem?.let { converter.convert(it) } ?: marketsListItemUM
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentCoroutineContext().ensureActive()
|
||||
|
||||
ResultBatches(
|
||||
uiBatches = outItems,
|
||||
processedItems = newList,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun reload(searchText: String? = null) {
|
||||
modelScope.launch {
|
||||
resultBatches.value = ResultBatches()
|
||||
actionsFlow.emit(
|
||||
BatchAction.Reload(
|
||||
requestParams = TokenMarketListConfig(
|
||||
fiatPriceCurrency = currentAppCurrency().code,
|
||||
searchText = if (currentSearchText() == null) {
|
||||
null
|
||||
} else {
|
||||
searchText ?: currentSearchText()
|
||||
},
|
||||
priceChangeInterval = TokenMarketListConfig.Interval.H24,
|
||||
order = TokenMarketListConfig.Order.ByRating,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
modelScope.launch {
|
||||
actionsFlow.emit(BatchAction.LoadMore())
|
||||
}
|
||||
}
|
||||
|
||||
fun loadCharts(batchKeys: Set<Int>) {
|
||||
if (batchKeys.isEmpty()) return
|
||||
|
||||
modelScope.launch {
|
||||
val currentData = batchFlow.state.value.data
|
||||
val alreadyLoadedChartsBatchKeys = currentData
|
||||
.filter { batch ->
|
||||
val first = batch.data.firstOrNull() ?: return@filter false
|
||||
first.tokenCharts.h24 != null
|
||||
}
|
||||
.map { it.key }
|
||||
.toSet()
|
||||
|
||||
val batchesKeysToLoad = batchKeys.minus(alreadyLoadedChartsBatchKeys)
|
||||
|
||||
if (batchesKeysToLoad.isNotEmpty()) {
|
||||
actionsFlow.emit(
|
||||
BatchAction.UpdateBatches(
|
||||
keys = batchesKeysToLoad,
|
||||
updateRequest = TokenMarketUpdateRequest.UpdateChart(
|
||||
interval = TokenMarketListConfig.Interval.H24,
|
||||
currency = currentAppCurrency().code,
|
||||
),
|
||||
async = true,
|
||||
operationId = batchesKeysToLoad.toString() + "h24",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getBatchKeysByItemIds(ids: List<CryptoCurrency.RawID>): Set<Int> {
|
||||
val currentData = batchFlow.state.value.data
|
||||
|
||||
return currentData
|
||||
.filter { d -> d.data.any { ids.contains(it.id) } }
|
||||
.map { it.key }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private data class ResultBatches(
|
||||
val uiBatches: List<Batch<Int, List<MarketsListItemUM>>> = emptyList(),
|
||||
val processedItems: List<Batch<Int, List<TokenMarket>>>? = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
package com.tangem.feature.swap.models.market.converter
|
||||
|
||||
import com.tangem.common.ui.charts.state.MarketChartData
|
||||
import com.tangem.common.ui.charts.state.MarketChartRawData
|
||||
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
|
||||
import com.tangem.common.ui.charts.state.sorted
|
||||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
import com.tangem.core.ui.components.marketprice.PriceChangeType
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.core.ui.format.bigdecimal.compact
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.format.bigdecimal.percent
|
||||
import com.tangem.core.ui.format.bigdecimal.price
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.markets.TokenMarket
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import com.tangem.utils.converter.Converter
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
internal class SwapMarketsTokenItemConverter(
|
||||
private val appCurrency: AppCurrency,
|
||||
) : Converter<TokenMarket, MarketsListItemUM> {
|
||||
|
||||
private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(shouldFormatAxis = false)
|
||||
|
||||
override fun convert(value: TokenMarket): MarketsListItemUM {
|
||||
return MarketsListItemUM(
|
||||
id = value.id,
|
||||
name = value.name,
|
||||
currencySymbol = value.symbol,
|
||||
ratingPosition = value.marketRating?.toString(),
|
||||
marketCap = value.getMarketCap(),
|
||||
iconUrl = value.imageUrlLarge,
|
||||
price = value.getCurrentPrice(),
|
||||
trendPercentText = value.getTrendPercent(),
|
||||
trendType = value.getTrendType(),
|
||||
chartData = value.getChartData(),
|
||||
isUnder100kMarketCap = value.isUnderMarketCapLimit,
|
||||
stakingRate = value.yieldRate?.format { percent() }?.let {
|
||||
resourceReference(R.string.markets_apy_placeholder, wrappedList(it))
|
||||
},
|
||||
updateTimestamp = value.updateTimestamp,
|
||||
)
|
||||
}
|
||||
|
||||
fun update(prev: TokenMarket, prevUI: MarketsListItemUM, new: TokenMarket): MarketsListItemUM {
|
||||
require(prev.id == new.id) {
|
||||
"Ids is not the same during update TokenMarket item: previousItem[${prev.id}] != newItem[${new.id}]"
|
||||
}
|
||||
|
||||
return prevUI.copy(
|
||||
name = new.name,
|
||||
currencySymbol = new.symbol,
|
||||
ratingPosition = new.marketRating?.toString(),
|
||||
marketCap = ifChanged(prev.marketCap, new.marketCap, prevUI.marketCap) { new.getMarketCap() },
|
||||
iconUrl = new.imageUrlLarge,
|
||||
price = ifChanged(prev = prev.tokenQuotesShort, new = new.tokenQuotesShort, prevR = prevUI.price) {
|
||||
new.getCurrentPrice(
|
||||
prev = prev,
|
||||
)
|
||||
},
|
||||
trendPercentText = ifChanged(
|
||||
prev.tokenQuotesShort,
|
||||
new.tokenQuotesShort,
|
||||
prevUI.trendPercentText,
|
||||
) { new.getTrendPercent() },
|
||||
trendType = ifChanged(prev.tokenQuotesShort, new.tokenQuotesShort, prevUI.trendType) { new.getTrendType() },
|
||||
chartData = ifChanged(prev.tokenCharts, new.tokenCharts, prevUI.chartData) { new.getChartData() },
|
||||
)
|
||||
}
|
||||
|
||||
private inline fun <T, R> ifChanged(prev: T, new: T, prevR: R, force: Boolean = false, change: (T) -> R): R {
|
||||
return if (force || prev != new) change(new) else prevR
|
||||
}
|
||||
|
||||
private fun TokenMarket.getMarketCap(): String? {
|
||||
val value = marketCap?.takeIf { marketCap != BigDecimal.ZERO } ?: return null
|
||||
|
||||
return value.format {
|
||||
fiat(
|
||||
fiatCurrencyCode = appCurrency.code,
|
||||
fiatCurrencySymbol = appCurrency.symbol,
|
||||
).compact(
|
||||
threeDigitsMethod = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TokenMarket.getCurrentPrice(prev: TokenMarket? = null): MarketsListItemUM.Price {
|
||||
val prevPrice = prev?.tokenQuotesShort?.currentPrice
|
||||
|
||||
val priceText = tokenQuotesShort.currentPrice.format {
|
||||
fiat(
|
||||
fiatCurrencyCode = appCurrency.code,
|
||||
fiatCurrencySymbol = appCurrency.symbol,
|
||||
).price()
|
||||
}
|
||||
|
||||
val changeType = if (prevPrice != null) {
|
||||
if (tokenQuotesShort.currentPrice > prevPrice) {
|
||||
PriceChangeType.UP
|
||||
} else {
|
||||
PriceChangeType.DOWN
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return MarketsListItemUM.Price(
|
||||
text = priceText,
|
||||
changeType = changeType,
|
||||
)
|
||||
}
|
||||
|
||||
private fun TokenMarket.getChartData(): MarketChartRawData? {
|
||||
val chart = tokenCharts.h24
|
||||
|
||||
return chart?.let { ct ->
|
||||
priceAndTimePointValuesConverter.convert(
|
||||
MarketChartData.Data(
|
||||
y = ct.priceY.toImmutableList(),
|
||||
x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
|
||||
).sorted(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun TokenMarket.getTrendType(): PriceChangeType {
|
||||
val percent = tokenQuotesShort.h24ChangePercent
|
||||
val scaled = percent?.setScale(4, RoundingMode.HALF_UP)
|
||||
return when {
|
||||
scaled == null -> PriceChangeType.NEUTRAL
|
||||
scaled > BigDecimal.ZERO -> PriceChangeType.UP
|
||||
scaled < BigDecimal.ZERO -> PriceChangeType.DOWN
|
||||
else -> PriceChangeType.NEUTRAL
|
||||
}
|
||||
}
|
||||
|
||||
private fun TokenMarket.getTrendPercent(): String {
|
||||
val percent = tokenQuotesShort.h24ChangePercent
|
||||
return percent.format { percent() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.tangem.feature.swap.models.market.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
internal sealed class SwapMarketState {
|
||||
|
||||
data class Content(
|
||||
val items: ImmutableList<MarketsListItemUM>,
|
||||
val total: Int,
|
||||
val loadMore: () -> Unit,
|
||||
val onItemClick: (MarketsListItemUM) -> Unit,
|
||||
val visibleIdsChanged: (List<CryptoCurrency.RawID>) -> Unit,
|
||||
) : SwapMarketState()
|
||||
|
||||
data object Loading : SwapMarketState()
|
||||
|
||||
data class LoadingError(
|
||||
val onRetryClicked: () -> Unit,
|
||||
) : SwapMarketState()
|
||||
|
||||
data object SearchNothingFound : SwapMarketState()
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ package com.tangem.feature.swap.preview
|
|||
|
||||
import com.tangem.common.ui.account.AccountNameUM
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.account.toUM
|
||||
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
|
|
@ -22,12 +22,12 @@ internal data object SwapSuccessStatePreview {
|
|||
fromTitle = AccountTitleUM.Account(
|
||||
prefixText = stringReference("From"),
|
||||
name = AccountNameUM.DefaultMain.value,
|
||||
icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(),
|
||||
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
|
||||
),
|
||||
toTitle = AccountTitleUM.Account(
|
||||
prefixText = stringReference("To"),
|
||||
name = AccountNameUM.DefaultMain.value,
|
||||
icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(),
|
||||
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
|
||||
),
|
||||
fromTokenAmount = TextReference.Str("1 000 DAI"),
|
||||
toTokenAmount = TextReference.Str("1 000 MATIC"),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import androidx.compose.foundation.text.ClickableText
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
|
@ -20,7 +19,6 @@ import com.tangem.core.ui.components.rows.SelectorRowItem
|
|||
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.SelectNetworkFeeBottomSheetTestTags
|
||||
import com.tangem.feature.swap.domain.models.ui.FeeType
|
||||
import com.tangem.feature.swap.models.states.ChooseFeeBottomSheetConfig
|
||||
import com.tangem.feature.swap.models.states.FeeItemState
|
||||
|
|
@ -90,8 +88,7 @@ private fun FooterBlock(readMore: TextReference, onReadMoreClick: () -> Unit) {
|
|||
.padding(
|
||||
vertical = TangemTheme.dimens.spacing8,
|
||||
horizontal = TangemTheme.dimens.spacing16,
|
||||
)
|
||||
.testTag(SelectNetworkFeeBottomSheetTestTags.READ_MORE_TEXT),
|
||||
),
|
||||
style = TangemTheme.typography.caption2.copy(textAlign = TextAlign.Start),
|
||||
onClick = click,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ package com.tangem.feature.swap.ui
|
|||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -16,10 +18,13 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
|
||||
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
|
||||
import com.tangem.core.ui.components.notifications.Notification
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.selectedBorder
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
|
@ -32,13 +37,20 @@ import kotlinx.collections.immutable.persistentListOf
|
|||
|
||||
@Composable
|
||||
fun ChooseProviderBottomSheet(config: TangemBottomSheetConfig) {
|
||||
TangemBottomSheet(
|
||||
TangemModalBottomSheet<ChooseProviderBottomSheetConfig>(
|
||||
config = config,
|
||||
containerColor = TangemTheme.colors.background.tertiary,
|
||||
titleText = resourceReference(R.string.express_choose_providers_title),
|
||||
) { content: ChooseProviderBottomSheetConfig ->
|
||||
ChooseProviderBottomSheetContent(content = content)
|
||||
}
|
||||
title = {
|
||||
TangemModalBottomSheetTitle(
|
||||
title = resourceReference(R.string.express_choose_providers_title),
|
||||
endIconRes = R.drawable.ic_close_24,
|
||||
onEndClick = config.onDismissRequest,
|
||||
)
|
||||
},
|
||||
content = { content ->
|
||||
ChooseProviderBottomSheetContent(content = content)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
|
|
@ -50,10 +62,7 @@ private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetC
|
|||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.secondary,
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
top = 10.dp,
|
||||
bottom = 16.dp,
|
||||
)
|
||||
.padding(bottom = 14.dp)
|
||||
.padding(horizontal = TangemTheme.dimens.spacing56),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
|
@ -76,31 +85,30 @@ private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetC
|
|||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 14.dp,
|
||||
)
|
||||
.background(
|
||||
color = TangemTheme.colors.background.action,
|
||||
shape = TangemTheme.shapes.roundedCornersXMedium,
|
||||
)
|
||||
.clip(shape = TangemTheme.shapes.roundedCornersXMedium),
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
content.providers.forEach { provider ->
|
||||
val isSelected = provider.id == content.selectedProviderId
|
||||
ProviderItem(
|
||||
state = provider,
|
||||
isSelected = isSelected,
|
||||
isSelected = false,
|
||||
modifier = Modifier
|
||||
.selectedBorder(isSelected = isSelected)
|
||||
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
|
||||
.background(color = TangemTheme.colors.background.action)
|
||||
.clickable(
|
||||
enabled = provider.onProviderClick != null,
|
||||
onClick = { provider.onProviderClick?.invoke(provider.id) },
|
||||
)
|
||||
.padding(
|
||||
top = TangemTheme.dimens.spacing12,
|
||||
bottom = TangemTheme.dimens.spacing12,
|
||||
end = TangemTheme.dimens.spacing12,
|
||||
vertical = 16.dp,
|
||||
horizontal = 2.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
SpacerH(6.dp)
|
||||
Icon(
|
||||
painterResource(id = R.drawable.ic_lightning_16),
|
||||
contentDescription = null,
|
||||
|
|
@ -111,7 +119,7 @@ private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetC
|
|||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.icon.informative,
|
||||
modifier = Modifier
|
||||
.padding(top = TangemTheme.dimens.spacing6, bottom = TangemTheme.dimens.spacing16)
|
||||
.padding(top = 4.dp, bottom = 32.dp)
|
||||
.padding(horizontal = TangemTheme.dimens.spacing56),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.animation.AnimatedContent
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
|
|
@ -99,7 +100,7 @@ private fun ProviderContentState(
|
|||
isSelected: Boolean = false,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
Row {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
SubcomposeAsyncImage(
|
||||
modifier = Modifier
|
||||
.padding(start = TangemTheme.dimens.spacing12)
|
||||
|
|
@ -120,8 +121,10 @@ private fun ProviderContentState(
|
|||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.heightIn(min = TangemTheme.dimens.size40)
|
||||
.padding(start = TangemTheme.dimens.spacing12)
|
||||
.testTag(SwapTokenScreenTestTags.PROVIDERS_BLOCK),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row {
|
||||
if (state.namePrefix == ProviderState.PrefixType.PROVIDED_BY) {
|
||||
|
|
@ -158,7 +161,6 @@ private fun ProviderContentState(
|
|||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(
|
||||
top = TangemTheme.dimens.spacing6,
|
||||
end = TangemTheme.dimens.spacing56,
|
||||
),
|
||||
) {
|
||||
|
|
@ -205,7 +207,7 @@ private fun ProviderUnavailableState(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
Row {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val (alpha, colorFilter) = GRAY_SCALE_ALPHA to GrayscaleColorFilter
|
||||
SubcomposeAsyncImage(
|
||||
modifier = Modifier
|
||||
|
|
@ -228,7 +230,10 @@ private fun ProviderUnavailableState(
|
|||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(start = TangemTheme.dimens.spacing12),
|
||||
modifier = Modifier
|
||||
.heightIn(min = TangemTheme.dimens.size40)
|
||||
.padding(start = TangemTheme.dimens.spacing12),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row {
|
||||
AnimatedContent(targetState = state.name, label = "") { name ->
|
||||
|
|
@ -247,12 +252,16 @@ private fun ProviderUnavailableState(
|
|||
)
|
||||
}
|
||||
}
|
||||
AnimatedContent(targetState = state.alertText, label = "") { alertText ->
|
||||
AnimatedContent(
|
||||
targetState = state.alertText,
|
||||
contentAlignment = Alignment.BottomStart,
|
||||
label = "",
|
||||
) { alertText ->
|
||||
Text(
|
||||
text = alertText.resolveReference(),
|
||||
style = TangemTheme.typography.body2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
modifier = Modifier.padding(top = TangemTheme.dimens.spacing6),
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.tangem.feature.swap.ui
|
|||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
|
||||
import com.tangem.common.ui.account.toUM
|
||||
import com.tangem.common.ui.alerts.models.AlertDemoModeUM
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.*
|
||||
|
|
@ -24,7 +25,9 @@ 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.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.isHotWallet
|
||||
import com.tangem.domain.promo.models.StoryContent
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter
|
||||
import com.tangem.feature.swap.converters.TokensDataConverter
|
||||
import com.tangem.feature.swap.converters.TokensDataConverterV2
|
||||
|
|
@ -64,6 +67,7 @@ internal class StateBuilder(
|
|||
private val isBalanceHiddenProvider: Provider<Boolean>,
|
||||
private val appCurrencyProvider: Provider<AppCurrency>,
|
||||
private val isAccountsModeProvider: Provider<Boolean>,
|
||||
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
|
||||
) {
|
||||
|
||||
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
|
||||
|
|
@ -76,7 +80,7 @@ internal class StateBuilder(
|
|||
)
|
||||
|
||||
private val notificationsFactory by lazy(LazyThreadSafetyMode.NONE) {
|
||||
SwapNotificationsFactory(actions)
|
||||
SwapNotificationsFactory(actions, iGaslessFeeSupportedForNetwork)
|
||||
}
|
||||
|
||||
fun createInitialLoadingState(
|
||||
|
|
@ -123,6 +127,7 @@ internal class StateBuilder(
|
|||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(userWalletProvider()),
|
||||
isEnabled = false,
|
||||
isHoldToConfirm = userWalletProvider().isHotWallet,
|
||||
onClick = {},
|
||||
),
|
||||
onRefresh = {},
|
||||
|
|
@ -182,6 +187,7 @@ internal class StateBuilder(
|
|||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(userWalletProvider()),
|
||||
isEnabled = false,
|
||||
isHoldToConfirm = userWalletProvider().isHotWallet,
|
||||
onClick = { },
|
||||
),
|
||||
changeCardsButtonState = ChangeCardsButtonState.DISABLED,
|
||||
|
|
@ -247,6 +253,7 @@ internal class StateBuilder(
|
|||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(userWalletProvider()),
|
||||
isEnabled = false,
|
||||
isHoldToConfirm = userWalletProvider().isHotWallet,
|
||||
onClick = {},
|
||||
),
|
||||
providerState = ProviderState.Loading(),
|
||||
|
|
@ -369,6 +376,7 @@ internal class StateBuilder(
|
|||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(userWalletProvider()),
|
||||
isEnabled = getSwapButtonEnabled(notifications),
|
||||
isHoldToConfirm = userWalletProvider().isHotWallet,
|
||||
onClick = actions.onSwapClick,
|
||||
),
|
||||
changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible),
|
||||
|
|
@ -497,6 +505,7 @@ internal class StateBuilder(
|
|||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(userWalletProvider()),
|
||||
isEnabled = false,
|
||||
isHoldToConfirm = userWalletProvider().isHotWallet,
|
||||
onClick = actions.onSwapClick,
|
||||
),
|
||||
changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible),
|
||||
|
|
@ -594,6 +603,7 @@ internal class StateBuilder(
|
|||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(userWalletProvider()),
|
||||
isEnabled = false,
|
||||
isHoldToConfirm = userWalletProvider().isHotWallet,
|
||||
onClick = { },
|
||||
),
|
||||
changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible),
|
||||
|
|
@ -606,6 +616,7 @@ internal class StateBuilder(
|
|||
return uiState.copy(
|
||||
swapButton = uiState.swapButton.copy(
|
||||
isEnabled = false,
|
||||
isInProgress = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -615,13 +626,14 @@ internal class StateBuilder(
|
|||
fromToken: CryptoCurrency,
|
||||
tokensDataState: CurrenciesGroup,
|
||||
): SwapStateHolder {
|
||||
val currentMarketsState = uiState.selectTokenState?.marketsState
|
||||
return uiState.copy(
|
||||
selectTokenState = tokensDataConverter.convert(
|
||||
value = CurrenciesGroupWithFromCurrency(
|
||||
fromCurrency = fromToken,
|
||||
group = tokensDataState,
|
||||
),
|
||||
),
|
||||
).copy(marketsState = currentMarketsState),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -808,6 +820,7 @@ internal class StateBuilder(
|
|||
return uiState.copy(
|
||||
swapButton = uiState.swapButton.copy(
|
||||
isEnabled = false,
|
||||
isInProgress = false,
|
||||
),
|
||||
permissionState = GiveTxPermissionState.InProgress,
|
||||
notifications = notificationsFactory.getApprovalInProgressStateNotification(uiState.notifications),
|
||||
|
|
@ -823,7 +836,6 @@ internal class StateBuilder(
|
|||
onStatusClick: () -> Unit,
|
||||
txUrl: String,
|
||||
): SwapStateHolder {
|
||||
val fee = requireNotNull(dataState.selectedFee)
|
||||
val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency)
|
||||
val toCryptoCurrency = requireNotNull(dataState.toCryptoCurrency)
|
||||
val fromAmount = swapTransactionState.fromAmountValue ?: BigDecimal.ZERO
|
||||
|
|
@ -843,7 +855,9 @@ internal class StateBuilder(
|
|||
shouldShowStatusButton = shouldShowStatus,
|
||||
providerIcon = providerState.iconUrl,
|
||||
rate = providerState.subtitle,
|
||||
fee = stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})"),
|
||||
fee = dataState.selectedFee?.let { fee ->
|
||||
stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})")
|
||||
},
|
||||
fromTitle = getFromCardAccountTitle(fromAccount = dataState.fromAccount),
|
||||
toTitle = getToCardAccountTitle(toAccount = dataState.toAccount),
|
||||
fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()),
|
||||
|
|
@ -1415,7 +1429,7 @@ internal class StateBuilder(
|
|||
AccountTitleUM.Account(
|
||||
prefixText = resourceReference(R.string.common_from),
|
||||
name = fromAccount.accountName.toUM().value,
|
||||
icon = fromAccount.icon.toUM(),
|
||||
icon = CryptoPortfolioIconConverter.convert(fromAccount.icon),
|
||||
)
|
||||
} else {
|
||||
AccountTitleUM.Text(resourceReference(R.string.swapping_from_title))
|
||||
|
|
@ -1427,7 +1441,7 @@ internal class StateBuilder(
|
|||
AccountTitleUM.Account(
|
||||
prefixText = resourceReference(R.string.common_to),
|
||||
name = toAccount.accountName.toUM().value,
|
||||
icon = toAccount.icon.toUM(),
|
||||
icon = CryptoPortfolioIconConverter.convert(toAccount.icon),
|
||||
)
|
||||
} else {
|
||||
AccountTitleUM.Text(resourceReference(R.string.swapping_to_title))
|
||||
|
|
|
|||
|
|
@ -1,24 +1,27 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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
|
||||
import com.tangem.core.ui.utils.WindowInsetsZero
|
||||
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
|
||||
import com.tangem.feature.swap.models.SwapStateHolder
|
||||
import com.tangem.feature.swap.models.states.ChooseFeeBottomSheetConfig
|
||||
import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
|
||||
@Composable
|
||||
internal fun SwapScreen(stateHolder: SwapStateHolder) {
|
||||
internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: SwapFeeSelectorBlockComponent?) {
|
||||
BackHandler(onBack = stateHolder.onBackClicked)
|
||||
|
||||
Scaffold(
|
||||
|
|
@ -36,6 +39,17 @@ internal fun SwapScreen(stateHolder: SwapStateHolder) {
|
|||
|
||||
SwapScreenContent(
|
||||
state = stateHolder,
|
||||
feeBlock = if (feeSelectorBlockComponent != null) {
|
||||
@Composable { modifier: Modifier ->
|
||||
feeSelectorBlockComponent.Content(
|
||||
modifier = Modifier
|
||||
.clip(TangemTheme.shapes.roundedCornersXMedium)
|
||||
.background(TangemTheme.colors.background.action),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
modifier = Modifier.padding(scaffoldPaddings),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,11 @@ import kotlinx.collections.immutable.persistentListOf
|
|||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modifier) {
|
||||
internal fun SwapScreenContent(
|
||||
state: SwapStateHolder,
|
||||
modifier: Modifier = Modifier,
|
||||
feeBlock: @Composable ((Modifier) -> Unit)? = null,
|
||||
) {
|
||||
val keyboard by keyboardAsState()
|
||||
|
||||
Box(
|
||||
|
|
@ -74,7 +78,11 @@ internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modi
|
|||
|
||||
ProviderItemBlock(state = state.providerState)
|
||||
|
||||
FeeItemBlock(state = state.fee)
|
||||
if (feeBlock != null) {
|
||||
feeBlock(Modifier.fillMaxWidth())
|
||||
} else {
|
||||
FeeItemBlock(state = state.fee)
|
||||
}
|
||||
|
||||
if (state.notifications.isNotEmpty()) SwapNotifications(notifications = state.notifications)
|
||||
|
||||
|
|
@ -362,21 +370,42 @@ private fun SwapNotifications(notifications: List<NotificationUM>) {
|
|||
|
||||
@Composable
|
||||
private fun MainButton(state: SwapStateHolder) {
|
||||
if (state.isInsufficientFunds) {
|
||||
PrimaryButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResourceSafe(id = R.string.swapping_insufficient_funds),
|
||||
enabled = false,
|
||||
onClick = state.swapButton.onClick,
|
||||
)
|
||||
} else {
|
||||
PrimaryButtonIconEnd(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResourceSafe(id = R.string.swapping_swap_action),
|
||||
iconResId = state.swapButton.walletInteractionIcon,
|
||||
enabled = state.swapButton.isEnabled,
|
||||
onClick = state.swapButton.onClick,
|
||||
)
|
||||
when {
|
||||
state.isInsufficientFunds -> {
|
||||
PrimaryButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResourceSafe(id = R.string.swapping_insufficient_funds),
|
||||
enabled = false,
|
||||
onClick = state.swapButton.onClick,
|
||||
)
|
||||
}
|
||||
|
||||
state.swapButton.isHoldToConfirm -> {
|
||||
HoldToConfirmButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResourceSafe(
|
||||
R.string.common_hold_to,
|
||||
stringResourceSafe(id = R.string.swapping_swap_action),
|
||||
),
|
||||
enabled = state.swapButton.isEnabled,
|
||||
onConfirm = state.swapButton.onClick,
|
||||
isLoading = state.swapButton.isInProgress,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
PrimaryButtonIconEnd(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = if (state.swapButton.isInProgress) {
|
||||
stringResourceSafe(id = R.string.swapping_swap_action_in_progress)
|
||||
} else {
|
||||
stringResourceSafe(id = R.string.swapping_swap_action)
|
||||
},
|
||||
iconResId = state.swapButton.walletInteractionIcon,
|
||||
enabled = state.swapButton.isEnabled,
|
||||
onClick = state.swapButton.onClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,26 +7,38 @@ import androidx.compose.foundation.clickable
|
|||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
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 com.tangem.core.ui.components.SpacerH12
|
||||
import com.tangem.core.ui.components.SpacerH32
|
||||
import com.tangem.core.ui.components.SpacerW2
|
||||
import com.tangem.core.ui.components.appbar.ExpandableSearchView
|
||||
import com.tangem.core.ui.components.list.InfiniteListHandler
|
||||
import com.tangem.core.ui.components.atoms.text.EllipsisText
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.tokenlist.PortfolioListItem
|
||||
import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem
|
||||
import com.tangem.core.ui.components.tokenlist.TokenListItem
|
||||
|
|
@ -38,12 +50,18 @@ import com.tangem.core.ui.res.TangemThemePreview
|
|||
import com.tangem.core.ui.test.BuyTokenScreenTestTags
|
||||
import com.tangem.core.ui.utils.lazyListItemPosition
|
||||
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
|
||||
import com.tangem.feature.swap.models.TokenBalanceData
|
||||
import com.tangem.feature.swap.models.TokenListUMData
|
||||
import com.tangem.feature.swap.models.TokenToSelectState
|
||||
import com.tangem.feature.swap.models.isEmptyState
|
||||
import com.tangem.feature.swap.models.isNotFoundState
|
||||
import com.tangem.feature.swap.models.market.state.SwapMarketState
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import com.tangem.feature.swap.ui.market.swapMarketsListItems
|
||||
import com.tangem.feature.swap.ui.preview.SwapSelectTokenPreviewProvider
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
private const val LOAD_MORE_BUFFER = 25
|
||||
|
||||
@Composable
|
||||
internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) {
|
||||
|
|
@ -56,17 +74,14 @@ internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: ()
|
|||
content = { padding ->
|
||||
val modifier = Modifier.padding(padding)
|
||||
when {
|
||||
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() &&
|
||||
state.tokensListData.tokensList.isEmpty() && state.isAfterSearch -> {
|
||||
TokensNotFound(modifier)
|
||||
}
|
||||
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() &&
|
||||
state.tokensListData.tokensList.isEmpty() && !state.isAfterSearch -> {
|
||||
EmptyTokensList(modifier)
|
||||
}
|
||||
else -> {
|
||||
ListOfTokens(state = state, modifier = modifier)
|
||||
}
|
||||
state.isNotFoundState -> TokensNotFound(modifier)
|
||||
state.isEmptyState -> EmptyTokensList(modifier)
|
||||
state.marketsState != null -> ListOfTokensWithMarkets(
|
||||
state = state,
|
||||
marketsState = state.marketsState,
|
||||
modifier = modifier,
|
||||
)
|
||||
else -> ListOfTokens(state = state, modifier = modifier)
|
||||
}
|
||||
},
|
||||
topBar = {
|
||||
|
|
@ -135,6 +150,7 @@ private fun TokensNotFound(modifier: Modifier = Modifier) {
|
|||
@Composable
|
||||
private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = Modifier) {
|
||||
val screenBackgroundColor = TangemTheme.colors.background.secondary
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.background(color = screenBackgroundColor)
|
||||
|
|
@ -142,29 +158,129 @@ private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier =
|
|||
.imePadding(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
when (val list = state.tokensListData) {
|
||||
is TokenListUMData.AccountList -> list.tokensList.forEach { item ->
|
||||
portfolioTokensList(
|
||||
portfolio = item,
|
||||
isBalanceHidden = state.isBalanceHidden,
|
||||
)
|
||||
}
|
||||
is TokenListUMData.TokenList -> {
|
||||
tokensList(
|
||||
items = list.tokensList,
|
||||
isBalanceHidden = state.isBalanceHidden,
|
||||
)
|
||||
}
|
||||
TokenListUMData.EmptyList -> Unit
|
||||
}
|
||||
tokensListItems(
|
||||
tokensListData = state.tokensListData,
|
||||
isBalanceHidden = state.isBalanceHidden,
|
||||
)
|
||||
|
||||
tokensToSelectItems(state.availableTokens, state.onTokenSelected)
|
||||
|
||||
item { SpacerH12() }
|
||||
|
||||
tokensToSelectItems(state.unavailableTokens, state.onTokenSelected)
|
||||
}
|
||||
}
|
||||
|
||||
item { SpacerH12() }
|
||||
@Composable
|
||||
private fun ListOfTokensWithMarkets(
|
||||
state: SwapSelectTokenStateHolder,
|
||||
marketsState: SwapMarketState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val screenBackgroundColor = TangemTheme.colors.background.secondary
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.background(color = screenBackgroundColor)
|
||||
.fillMaxSize()
|
||||
.imePadding(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
state = lazyListState,
|
||||
) {
|
||||
if (state.tokensListData !is TokenListUMData.EmptyList) {
|
||||
assetsTitle(count = state.tokensListData.totalTokensCount)
|
||||
}
|
||||
|
||||
tokensListItems(
|
||||
tokensListData = state.tokensListData,
|
||||
isBalanceHidden = state.isBalanceHidden,
|
||||
)
|
||||
|
||||
if (state.tokensListData is TokenListUMData.EmptyList) {
|
||||
item { SpacerH12() }
|
||||
} else {
|
||||
item { SpacerH32() }
|
||||
}
|
||||
|
||||
swapMarketsListItems(marketsState)
|
||||
}
|
||||
|
||||
(marketsState as? SwapMarketState.Content)?.let { content ->
|
||||
VisibleItemsTracker(
|
||||
lazyListState = lazyListState,
|
||||
marketState = content,
|
||||
)
|
||||
|
||||
InfiniteListHandler(
|
||||
listState = lazyListState,
|
||||
buffer = LOAD_MORE_BUFFER,
|
||||
triggerLoadMoreCheckOnItemsCountChange = true,
|
||||
onLoadMore = remember(content) {
|
||||
{
|
||||
content.loadMore()
|
||||
true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VisibleItemsTracker(lazyListState: LazyListState, marketState: SwapMarketState.Content) {
|
||||
val visibleItems by remember {
|
||||
derivedStateOf {
|
||||
lazyListState.layoutInfo.visibleItemsInfo
|
||||
.mapNotNull { itemInfo ->
|
||||
marketState.items.find { it.getComposeKey() == itemInfo.key }?.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(visibleItems) {
|
||||
marketState.visibleIdsChanged(visibleItems)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.assetsTitle(count: Int) {
|
||||
item(key = "assets_title") {
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
append(stringResourceSafe(R.string.swap_your_assets_title))
|
||||
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
|
||||
append(" $count")
|
||||
}
|
||||
},
|
||||
style = TangemTheme.typography.h3,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = TangemTheme.dimens.spacing16,
|
||||
end = TangemTheme.dimens.spacing16,
|
||||
top = TangemTheme.dimens.spacing12,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.tokensListItems(tokensListData: TokenListUMData, isBalanceHidden: Boolean) {
|
||||
when (tokensListData) {
|
||||
is TokenListUMData.AccountList -> {
|
||||
tokensListData.tokensList.forEach { item ->
|
||||
portfolioTokensList(
|
||||
portfolio = item,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
)
|
||||
}
|
||||
}
|
||||
is TokenListUMData.TokenList -> {
|
||||
tokensList(
|
||||
items = tokensListData.tokensList,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
)
|
||||
}
|
||||
TokenListUMData.EmptyList -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -387,50 +503,45 @@ private fun TokenItem(
|
|||
}
|
||||
}
|
||||
|
||||
private val token = TokenToSelectState.TokenToSelect(
|
||||
tokenIcon = CurrencyIconState.CoinIcon(
|
||||
url = "",
|
||||
fallbackResId = 0,
|
||||
isGrayscale = false,
|
||||
shouldShowCustomBadge = false,
|
||||
),
|
||||
id = "",
|
||||
name = "Optimistic Ethereum (ETH)",
|
||||
symbol = "USDC",
|
||||
addedTokenBalanceData = TokenBalanceData(
|
||||
amount = "15 000 $",
|
||||
amountEquivalent = "15 000 USDT",
|
||||
isBalanceHidden = false,
|
||||
),
|
||||
)
|
||||
|
||||
private val title = TokenToSelectState.Title(
|
||||
title = stringReference("MY TOKENS"),
|
||||
)
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun TokenScreenPreview() {
|
||||
TangemThemePreview {
|
||||
SwapSelectTokenScreen(
|
||||
state = SwapSelectTokenStateHolder(
|
||||
availableTokens = listOf(title, token, token, token).toImmutableList(),
|
||||
unavailableTokens = listOf(title, token, token, token).toImmutableList(),
|
||||
tokensListData = TokenListUMData.EmptyList,
|
||||
isAfterSearch = false,
|
||||
isBalanceHidden = false,
|
||||
onSearchEntered = {},
|
||||
onTokenSelected = {},
|
||||
),
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
private class SwapSelectTokenScreenPreviewProvider : PreviewParameterProvider<SwapSelectTokenStateHolder> {
|
||||
override val values: Sequence<SwapSelectTokenStateHolder> = sequenceOf(
|
||||
// Content state with tokens and markets
|
||||
SwapSelectTokenPreviewProvider().provideSwapSelectTokenState(),
|
||||
// Empty state
|
||||
SwapSelectTokenStateHolder(
|
||||
availableTokens = persistentListOf(),
|
||||
unavailableTokens = persistentListOf(),
|
||||
tokensListData = TokenListUMData.EmptyList,
|
||||
marketsState = null,
|
||||
isAfterSearch = false,
|
||||
isBalanceHidden = false,
|
||||
onSearchEntered = {},
|
||||
onTokenSelected = {},
|
||||
),
|
||||
// Not found state
|
||||
SwapSelectTokenStateHolder(
|
||||
availableTokens = persistentListOf(),
|
||||
unavailableTokens = persistentListOf(),
|
||||
tokensListData = TokenListUMData.EmptyList,
|
||||
marketsState = null,
|
||||
isAfterSearch = true,
|
||||
isBalanceHidden = false,
|
||||
onSearchEntered = {},
|
||||
onTokenSelected = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun EmptyTokensListPreview() {
|
||||
private fun TokenScreenPreview(
|
||||
@PreviewParameter(SwapSelectTokenScreenPreviewProvider::class)
|
||||
state: SwapSelectTokenStateHolder,
|
||||
) {
|
||||
TangemThemePreview {
|
||||
EmptyTokensList()
|
||||
SwapSelectTokenScreen(
|
||||
state = state,
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -29,14 +29,16 @@ import com.tangem.core.ui.utils.toTimeFormat
|
|||
import com.tangem.feature.swap.models.SwapSuccessStateHolder
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import com.tangem.feature.swap.preview.SwapSuccessStatePreview
|
||||
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.v2.common.ui.FeeBlockSuccess
|
||||
|
||||
@Composable
|
||||
fun SwapSuccessScreen(state: SwapSuccessStateHolder, onBack: () -> Unit) {
|
||||
fun SwapSuccessScreen(state: SwapSuccessStateHolder, feeSelectorUM: FeeSelectorUM?, onBack: () -> Unit) {
|
||||
Scaffold(
|
||||
modifier = Modifier.systemBarsPadding(),
|
||||
containerColor = TangemTheme.colors.background.secondary,
|
||||
content = { padding ->
|
||||
SwapSuccessScreenContent(padding = padding, state = state)
|
||||
SwapSuccessScreenContent(padding = padding, feeSelectorUM = feeSelectorUM, state = state)
|
||||
},
|
||||
topBar = {
|
||||
AppBarWithBackButton(
|
||||
|
|
@ -58,7 +60,11 @@ fun SwapSuccessScreen(state: SwapSuccessStateHolder, onBack: () -> Unit) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: PaddingValues) {
|
||||
private fun SwapSuccessScreenContent(
|
||||
state: SwapSuccessStateHolder,
|
||||
feeSelectorUM: FeeSelectorUM?,
|
||||
padding: PaddingValues,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -101,7 +107,10 @@ private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: Pad
|
|||
.background(TangemTheme.colors.background.action),
|
||||
)
|
||||
SpacerH16()
|
||||
if (state.fee != TextReference.EMPTY) {
|
||||
|
||||
if (feeSelectorUM != null) {
|
||||
FeeBlockSuccess(feeSelectorUM)
|
||||
} else if (state.fee != null && state.fee != TextReference.EMPTY) {
|
||||
InputRowDefault(
|
||||
title = TextReference.Res(R.string.common_network_fee_title),
|
||||
text = state.fee,
|
||||
|
|
@ -205,7 +214,7 @@ private fun SwapSuccessScreenButtons(
|
|||
@Composable
|
||||
private fun Preview_Success() {
|
||||
TangemThemePreview {
|
||||
SwapSuccessScreen(SwapSuccessStatePreview.state) {}
|
||||
SwapSuccessScreen(SwapSuccessStatePreview.state, null) {}
|
||||
}
|
||||
}
|
||||
// endregion preview
|
||||
|
|
@ -10,6 +10,7 @@ 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
|
||||
|
|
@ -37,7 +38,7 @@ 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.toUM
|
||||
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.*
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
|
|
@ -266,11 +267,15 @@ private fun Content(
|
|||
when (type) {
|
||||
is TransactionCardType.ReadOnly -> {
|
||||
if (textFieldValue != null) {
|
||||
ResizableText(
|
||||
Text(
|
||||
text = textFieldValue.text,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
style = TangemTheme.typography.h2,
|
||||
fontSizeRange = FontSizeRange(min = 16.sp, max = TangemTheme.typography.h2.fontSize),
|
||||
autoSize = TextAutoSize.StepBased(
|
||||
minFontSize = 16.sp,
|
||||
maxFontSize = TangemTheme.typography.h2.fontSize,
|
||||
),
|
||||
maxLines = 1,
|
||||
modifier = sumTextModifier.testTag(SwapTokenScreenTestTags.RECEIVE_TEXT_FIELD),
|
||||
)
|
||||
} else {
|
||||
|
|
@ -599,7 +604,7 @@ private fun TransactionCardPreviewWithPriceImpact() {
|
|||
accountTitleUM = AccountTitleUM.Account(
|
||||
prefixText = resourceReference(R.string.common_from),
|
||||
name = AccountNameUM.DefaultMain.value,
|
||||
icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(),
|
||||
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
|
||||
),
|
||||
),
|
||||
amountEquivalent = "1 000 000",
|
||||
|
|
@ -608,7 +613,7 @@ private fun TransactionCardPreviewWithPriceImpact() {
|
|||
networkIconRes = R.drawable.img_polygon_22,
|
||||
onChangeTokenClick = {},
|
||||
balance = "123",
|
||||
textFieldValue = TextFieldValue(),
|
||||
textFieldValue = TextFieldValue("1000000.0000000000000000000000000"),
|
||||
priceImpact = PriceImpact.Value(0.15F),
|
||||
)
|
||||
}
|
||||
|
|
@ -621,7 +626,7 @@ private fun TransactionCardPreviewWithoutPriceImpact() {
|
|||
accountTitleUM = AccountTitleUM.Account(
|
||||
prefixText = resourceReference(R.string.common_from),
|
||||
name = AccountNameUM.DefaultMain.value,
|
||||
icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(),
|
||||
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
|
||||
),
|
||||
),
|
||||
amountEquivalent = "1 000 000",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
package com.tangem.feature.swap.ui.market
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import com.tangem.common.ui.markets.MarketsListItem
|
||||
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
|
||||
import com.tangem.core.ui.components.UnableToLoadData
|
||||
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.feature.swap.models.market.state.SwapMarketState
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
|
||||
internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) {
|
||||
item {
|
||||
val totalCount = (state as? SwapMarketState.Content)?.total
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
append(stringResourceSafe(R.string.markets_common_title))
|
||||
if (totalCount != null) {
|
||||
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
|
||||
append(" $totalCount")
|
||||
}
|
||||
}
|
||||
},
|
||||
style = TangemTheme.typography.h3,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = TangemTheme.dimens.spacing16),
|
||||
)
|
||||
}
|
||||
when (state) {
|
||||
is SwapMarketState.Loading -> {
|
||||
items(count = 100, key = { "market_placeholder_$it" }) {
|
||||
MarketsListItemPlaceholder()
|
||||
}
|
||||
}
|
||||
is SwapMarketState.LoadingError -> {
|
||||
item(key = "market_loading_error") {
|
||||
LoadingErrorItem(
|
||||
modifier = Modifier.fillParentMaxWidth(),
|
||||
onTryAgain = state.onRetryClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
SwapMarketState.SearchNothingFound -> {
|
||||
item(key = "market_not_found") {
|
||||
SearchNothingFoundText(
|
||||
modifier = Modifier.fillParentMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
is SwapMarketState.Content -> {
|
||||
itemsIndexed(
|
||||
items = state.items,
|
||||
key = { _, item -> item.getComposeKey() },
|
||||
) { index, item ->
|
||||
MarketsListItem(
|
||||
model = item,
|
||||
onClick = { state.onItemClick(item) },
|
||||
modifier = Modifier.roundedShapeItemDecoration(
|
||||
currentIndex = index,
|
||||
lastIndex = state.items.lastIndex,
|
||||
backgroundColor = TangemTheme.colors.background.action,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier
|
||||
.padding(
|
||||
horizontal = TangemTheme.dimens.spacing16,
|
||||
vertical = TangemTheme.dimens.spacing12,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
UnableToLoadData(onRetryClick = onTryAgain)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchNothingFoundText(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.padding(TangemTheme.dimens.spacing16),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.markets_search_token_no_result_title),
|
||||
style = TangemTheme.typography.caption1,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
package com.tangem.feature.swap.ui.preview
|
||||
|
||||
import com.tangem.common.ui.charts.state.MarketChartRawData
|
||||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.marketprice.PriceChangeType
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
|
||||
import com.tangem.feature.swap.models.TokenBalanceData
|
||||
import com.tangem.feature.swap.models.TokenListUMData
|
||||
import com.tangem.feature.swap.models.TokenToSelectState
|
||||
import com.tangem.feature.swap.models.market.state.SwapMarketState
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
internal class SwapSelectTokenPreviewProvider {
|
||||
|
||||
fun provideSwapSelectTokenState(): SwapSelectTokenStateHolder {
|
||||
return SwapSelectTokenStateHolder(
|
||||
availableTokens = listOf(previewTitle, previewToken, previewToken, previewToken).toImmutableList(),
|
||||
unavailableTokens = listOf(previewTitle, previewToken, previewToken, previewToken).toImmutableList(),
|
||||
tokensListData = TokenListUMData.EmptyList,
|
||||
isAfterSearch = false,
|
||||
isBalanceHidden = false,
|
||||
onSearchEntered = {},
|
||||
onTokenSelected = {},
|
||||
marketsState = createPreviewMarketsState(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createPreviewMarketsState() = SwapMarketState.Content(
|
||||
items = createPreviewMarketItems(),
|
||||
loadMore = { },
|
||||
onItemClick = { },
|
||||
visibleIdsChanged = { },
|
||||
total = TOTAL_ITEMS,
|
||||
)
|
||||
|
||||
private fun createPreviewMarketItems() = listOf(
|
||||
createMarketItem(
|
||||
id = "1",
|
||||
iconUrl = "",
|
||||
ratingPosition = "10",
|
||||
marketCap = "$6.233 B",
|
||||
trendType = PriceChangeType.UP,
|
||||
chartData = PREVIEW_CHART_DATA,
|
||||
),
|
||||
createMarketItem(
|
||||
id = "2",
|
||||
ratingPosition = "10",
|
||||
marketCap = "$6.233 B",
|
||||
trendType = PriceChangeType.NEUTRAL,
|
||||
chartData = null,
|
||||
),
|
||||
createMarketItem(
|
||||
id = "3",
|
||||
name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin",
|
||||
ratingPosition = "10",
|
||||
marketCap = "$6.23348172384781234 B",
|
||||
trendType = PriceChangeType.DOWN,
|
||||
chartData = PREVIEW_CHART_DATA,
|
||||
),
|
||||
createMarketItem(
|
||||
id = "4",
|
||||
ratingPosition = "10",
|
||||
marketCap = null,
|
||||
trendType = PriceChangeType.UP,
|
||||
chartData = PREVIEW_CHART_DATA,
|
||||
),
|
||||
createMarketItem(
|
||||
id = "5",
|
||||
ratingPosition = null,
|
||||
marketCap = "$6.233 B",
|
||||
trendType = PriceChangeType.UP,
|
||||
chartData = PREVIEW_CHART_DATA,
|
||||
),
|
||||
createMarketItem(
|
||||
id = "6",
|
||||
ratingPosition = null,
|
||||
marketCap = null,
|
||||
trendType = PriceChangeType.UP,
|
||||
chartData = PREVIEW_CHART_DATA,
|
||||
),
|
||||
).toImmutableList()
|
||||
|
||||
private fun createMarketItem(
|
||||
id: String,
|
||||
name: String = "Bitcoin",
|
||||
iconUrl: String? = null,
|
||||
ratingPosition: String?,
|
||||
marketCap: String?,
|
||||
trendType: PriceChangeType,
|
||||
chartData: MarketChartRawData?,
|
||||
) = MarketsListItemUM(
|
||||
id = CryptoCurrency.RawID(id),
|
||||
name = name,
|
||||
currencySymbol = "BTC",
|
||||
iconUrl = iconUrl,
|
||||
ratingPosition = ratingPosition,
|
||||
marketCap = marketCap,
|
||||
price = MarketsListItemUM.Price(text = "31 285.72$"),
|
||||
trendPercentText = "12.43%",
|
||||
trendType = trendType,
|
||||
chartData = chartData,
|
||||
isUnder100kMarketCap = false,
|
||||
stakingRate = stringReference("APY 12.34%"),
|
||||
updateTimestamp = 0,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val CHART_VALUE_1 = 0.4
|
||||
private const val CHART_VALUE_2 = 0.2
|
||||
private const val CHART_VALUE_3 = 0.1
|
||||
private const val CHART_VALUE_4 = 2.0
|
||||
private const val CHART_VALUE_5 = 5.0
|
||||
private const val CHART_VALUE_6 = 3.0
|
||||
private const val TOTAL_ITEMS = 322
|
||||
|
||||
private val PREVIEW_CHART_DATA = MarketChartRawData(
|
||||
y = persistentListOf(
|
||||
CHART_VALUE_1,
|
||||
CHART_VALUE_2,
|
||||
CHART_VALUE_1,
|
||||
CHART_VALUE_3,
|
||||
CHART_VALUE_1,
|
||||
CHART_VALUE_4,
|
||||
CHART_VALUE_5,
|
||||
CHART_VALUE_3,
|
||||
CHART_VALUE_4,
|
||||
CHART_VALUE_4,
|
||||
CHART_VALUE_6,
|
||||
),
|
||||
)
|
||||
|
||||
private val previewToken = TokenToSelectState.TokenToSelect(
|
||||
tokenIcon = CurrencyIconState.CoinIcon(
|
||||
url = "",
|
||||
fallbackResId = 0,
|
||||
isGrayscale = false,
|
||||
shouldShowCustomBadge = false,
|
||||
),
|
||||
id = "",
|
||||
name = "Optimistic Ethereum (ETH)",
|
||||
symbol = "USDC",
|
||||
addedTokenBalanceData = TokenBalanceData(
|
||||
amount = "15 000 $",
|
||||
amountEquivalent = "15 000 USDT",
|
||||
isBalanceHidden = false,
|
||||
),
|
||||
)
|
||||
|
||||
private val previewTitle = TokenToSelectState.Title(
|
||||
title = stringReference("MY TOKENS"),
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue