Updated on 2026-08-14
This commit is contained in:
parent
330ae73357
commit
e7efe7b76a
4636 changed files with 234864 additions and 63507 deletions
1
features/swap/impl/.gitignore
vendored
Normal file
1
features/swap/impl/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
84
features/swap/impl/build.gradle.kts
Normal file
84
features/swap/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.kotlin.serialization)
|
||||
alias(deps.plugins.hilt.android)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.feature.swap.presentation"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Core modules */
|
||||
implementation(projects.core.analytics)
|
||||
implementation(projects.core.analytics.models)
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.core.navigation)
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.common.routing)
|
||||
implementation(projects.common.ui)
|
||||
implementation(projects.core.decompose) // For Route supertype
|
||||
|
||||
/** Domain modules **/
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.appCurrency)
|
||||
implementation(projects.domain.appCurrency.models)
|
||||
implementation(projects.domain.balanceHiding)
|
||||
implementation(projects.domain.balanceHiding.models)
|
||||
implementation(projects.domain.tokens)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.transaction)
|
||||
implementation(projects.domain.transaction.models)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.settings)
|
||||
implementation(projects.domain.staking)
|
||||
implementation(projects.domain.feedback)
|
||||
implementation(projects.domain.promo)
|
||||
implementation(projects.domain.promo.models)
|
||||
|
||||
/** Feature modules */
|
||||
implementation(projects.features.swap.domain)
|
||||
implementation(projects.features.swap.domain.api)
|
||||
implementation(projects.features.swap.domain.models)
|
||||
implementation(projects.features.wallet.api)
|
||||
implementation(projects.features.swap.api)
|
||||
|
||||
/** AndroidX */
|
||||
implementation(deps.androidx.activity.compose)
|
||||
implementation(deps.androidx.appCompat)
|
||||
implementation(deps.androidx.fragment.ktx)
|
||||
implementation(deps.lifecycle.viewModel.ktx)
|
||||
implementation(deps.androidx.browser)
|
||||
|
||||
/** Compose */
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.compose.foundation)
|
||||
implementation(deps.compose.material)
|
||||
implementation(deps.compose.material3)
|
||||
implementation(deps.compose.ui.tooling)
|
||||
implementation(deps.compose.coil)
|
||||
implementation(deps.compose.constraintLayout)
|
||||
|
||||
/** Api */
|
||||
implementation(projects.features.swap.api)
|
||||
implementation(projects.features.tokendetails.api)
|
||||
|
||||
/** Libs */
|
||||
implementation(projects.libs.crypto)
|
||||
|
||||
/** Other libraries */
|
||||
implementation(deps.compose.shimmer)
|
||||
implementation(deps.compose.accompanist.systemUiController)
|
||||
implementation(deps.kotlin.serialization)
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
implementation(deps.timber)
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package com.tangem.feature.swap
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
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.model.getOrCreateModel
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.feature.swap.model.SwapModel
|
||||
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.swap.SwapComponent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
@Suppress("UnusedPrivateMember")
|
||||
internal class DefaultSwapComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
@Assisted params: SwapComponent.Params,
|
||||
) : SwapComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: SwapModel = getOrCreateModel(params)
|
||||
|
||||
init {
|
||||
lifecycle.subscribe(
|
||||
onStart = model::onStart,
|
||||
onStop = model::onStop,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
Crossfade(
|
||||
modifier = Modifier.background(TangemTheme.colors.background.secondary),
|
||||
targetState = model.currentScreen,
|
||||
label = "",
|
||||
) { screen ->
|
||||
when (screen) {
|
||||
SwapNavScreen.PromoStories -> {
|
||||
val storiesConfig = model.uiState.storiesConfig
|
||||
if (storiesConfig != null) {
|
||||
SwapStoriesScreen(config = storiesConfig)
|
||||
} else {
|
||||
SwapScreen(stateHolder = model.uiState)
|
||||
}
|
||||
}
|
||||
SwapNavScreen.Main -> SwapScreen(stateHolder = model.uiState)
|
||||
SwapNavScreen.Success -> {
|
||||
val successState = model.uiState.successState
|
||||
if (successState != null) {
|
||||
SwapSuccessScreen(state = successState, model.uiState.onBackClicked)
|
||||
} else {
|
||||
SwapScreen(stateHolder = model.uiState)
|
||||
}
|
||||
}
|
||||
SwapNavScreen.SelectToken -> {
|
||||
val tokenState = model.uiState.selectTokenState
|
||||
if (tokenState != null) {
|
||||
SwapSelectTokenScreen(state = tokenState, onBack = model.uiState.onBackClicked)
|
||||
} else {
|
||||
SwapScreen(stateHolder = model.uiState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : SwapComponent.Factory {
|
||||
override fun create(context: AppComponentContext, params: SwapComponent.Params): DefaultSwapComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.tangem.feature.swap
|
||||
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
|
||||
internal class DefaultSwapFeatureToggles(
|
||||
private val featureToggles: FeatureTogglesManager,
|
||||
) : SwapFeatureToggles {
|
||||
override val isPromoStoriesEnabled: Boolean
|
||||
get() = featureToggles.isFeatureEnabled("SWAP_STORIES_ENABLED")
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.tangem.feature.swap.analytics
|
||||
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.WATCHED
|
||||
|
||||
sealed class StoriesEvents(
|
||||
event: String,
|
||||
params: Map<String, String> = mapOf(),
|
||||
) : AnalyticsEvent("Stories", event, params) {
|
||||
|
||||
data class SwapStories(
|
||||
val source: String,
|
||||
val watchCount: String,
|
||||
) : StoriesEvents(
|
||||
event = "Swap Stories",
|
||||
params = mapOf(
|
||||
AnalyticsParam.SOURCE to source,
|
||||
WATCHED to watchCount,
|
||||
),
|
||||
)
|
||||
|
||||
data class Error(
|
||||
val type: String,
|
||||
) : StoriesEvents(
|
||||
event = "Error",
|
||||
params = mapOf(
|
||||
AnalyticsParam.TYPE to type,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
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.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.FeeType
|
||||
|
||||
private const val SWAP_CATEGORY = "Swap"
|
||||
private const val PROMO_CATEGORY = "Promo"
|
||||
|
||||
sealed class SwapEvents(
|
||||
event: String,
|
||||
params: Map<String, String> = mapOf(),
|
||||
) : AnalyticsEvent(SWAP_CATEGORY, event, params) {
|
||||
|
||||
data class SwapScreenOpened(val token: String) : SwapEvents(
|
||||
event = "Swap Screen Opened",
|
||||
params = mapOf("Token" to token),
|
||||
)
|
||||
|
||||
data object SendTokenBalanceClicked : SwapEvents(event = "Send Token Balance Clicked")
|
||||
|
||||
data class ChooseTokenScreenOpened(val availableTokens: Boolean) : SwapEvents(
|
||||
event = "Choose Token Screen Opened",
|
||||
params = mapOf("Available tokens" to if (availableTokens) "Yes" else "No"),
|
||||
)
|
||||
|
||||
data class ChooseTokenScreenResult(val tokenChosen: Boolean, val token: String? = null) : SwapEvents(
|
||||
event = "Choose Token Screen Result",
|
||||
params = buildMap {
|
||||
put("Token Chosen", if (tokenChosen) "Yes" else "No")
|
||||
token?.let { put("Token", it) }
|
||||
},
|
||||
)
|
||||
|
||||
data class ButtonSwapClicked(val sendToken: String, val receiveToken: String) : SwapEvents(
|
||||
event = "Button - Swap",
|
||||
params = mapOf("Send Token" to sendToken, "Receive Token" to receiveToken),
|
||||
)
|
||||
|
||||
data object ButtonGivePermissionClicked : SwapEvents(event = "Button - Give permission")
|
||||
|
||||
data class ButtonPermissionApproveClicked(
|
||||
val sendToken: String,
|
||||
val receiveToken: String,
|
||||
val approveType: ApproveType,
|
||||
) : SwapEvents(
|
||||
event = "Button - Permission Approve",
|
||||
params = mapOf(
|
||||
"Send Token" to sendToken,
|
||||
"Receive Token" to receiveToken,
|
||||
"Type" to if (approveType == ApproveType.LIMITED) "Current Transaction" else "Unlimited",
|
||||
),
|
||||
)
|
||||
|
||||
data object ButtonPermissionCancelClicked : SwapEvents(event = "Button - Permission Cancel")
|
||||
|
||||
data object ButtonSwipeClicked : SwapEvents(event = "Button - Swipe")
|
||||
|
||||
data class SwapInProgressScreen(
|
||||
val provider: SwapProvider,
|
||||
val commission: FeeType, // Market / Fast
|
||||
val sendBlockchain: String,
|
||||
val receiveBlockchain: String,
|
||||
val sendToken: String,
|
||||
val receiveToken: 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,
|
||||
),
|
||||
)
|
||||
|
||||
data object ProviderClicked : SwapEvents("Provider Clicked")
|
||||
|
||||
data class ProviderChosen(val provider: SwapProvider) : SwapEvents(
|
||||
event = "Provider Chosen",
|
||||
params = mapOf("Provider" to provider.name),
|
||||
)
|
||||
|
||||
data class ButtonStatus(val token: String) : SwapEvents(
|
||||
event = "Button - Status",
|
||||
params = mapOf("Token" to token),
|
||||
)
|
||||
|
||||
data class ButtonExplore(val token: String) : SwapEvents(
|
||||
event = "Button - Explore",
|
||||
params = mapOf("Token" to token),
|
||||
)
|
||||
|
||||
data object NoticeNoAvailableTokensToSwap : SwapEvents("Notice - No Available Tokens To Swap")
|
||||
|
||||
data class NoticeNotEnoughFee(val token: String, val blockchain: String) : SwapEvents(
|
||||
event = "Notice - Not Enough Fee",
|
||||
params = mapOf(
|
||||
"Token" to token,
|
||||
"Blockchain" to blockchain,
|
||||
),
|
||||
)
|
||||
|
||||
data class NoticeProviderError(
|
||||
val sendToken: String,
|
||||
val receiveToken: String,
|
||||
val provider: SwapProvider,
|
||||
val errorCode: Int,
|
||||
) : SwapEvents(
|
||||
event = "Notice - Express Error",
|
||||
params = mapOf(
|
||||
"Send Token" to sendToken,
|
||||
"Receive Token" to receiveToken,
|
||||
"Provider" to provider.name,
|
||||
"Error Code" to errorCode.toString(),
|
||||
),
|
||||
)
|
||||
|
||||
// region Promo activity
|
||||
data class ChangellyActivity(
|
||||
val promoState: PromoState,
|
||||
) : AnalyticsEvent(
|
||||
category = PROMO_CATEGORY,
|
||||
event = "Changelly Activity",
|
||||
params = mapOf(
|
||||
"State" to promoState.name,
|
||||
),
|
||||
) {
|
||||
sealed class PromoState(val name: String) {
|
||||
data object Native : PromoState("Native")
|
||||
data object Recommended : PromoState("Recommended")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.tangem.feature.swap.converters
|
||||
|
||||
import com.tangem.common.ui.alerts.TransactionErrorAlertConverter
|
||||
import com.tangem.common.ui.alerts.models.AlertUM
|
||||
import com.tangem.domain.transaction.error.SendTransactionError
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapTransactionState
|
||||
import com.tangem.feature.swap.models.SwapAlertUM
|
||||
import com.tangem.feature.swap.utils.getExpressErrorMessage
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal class SwapTransactionErrorStateConverter(
|
||||
private val onDismiss: () -> Unit,
|
||||
private val onSupportClick: (String) -> Unit,
|
||||
) : Converter<SwapTransactionState.Error, AlertUM?> {
|
||||
override fun convert(value: SwapTransactionState.Error): AlertUM? {
|
||||
return when (value) {
|
||||
is SwapTransactionState.Error.TransactionError -> {
|
||||
when (val error = value.error) {
|
||||
is SendTransactionError.UserCancelledError -> return null
|
||||
null -> SwapAlertUM.GenericError(onDismiss)
|
||||
else -> TransactionErrorAlertConverter(onDismiss, onSupportClick).convert(error)
|
||||
}
|
||||
}
|
||||
is SwapTransactionState.Error.ExpressError -> {
|
||||
SwapAlertUM.ExpressErrorAlert(
|
||||
message = getExpressErrorMessage(value.error),
|
||||
onConfirmClick = { onSupportClick(value.error.code.toString()) },
|
||||
)
|
||||
}
|
||||
SwapTransactionState.Error.UnknownError -> SwapAlertUM.GenericError(onDismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
package com.tangem.feature.swap.converters
|
||||
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.utils.BigDecimalFormatter
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.models.domain.CryptoCurrencySwapInfo
|
||||
import com.tangem.feature.swap.models.CurrenciesGroupWithFromCurrency
|
||||
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
|
||||
import com.tangem.feature.swap.models.TokenBalanceData
|
||||
import com.tangem.feature.swap.models.TokenToSelectState
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import com.tangem.utils.Provider
|
||||
import com.tangem.utils.converter.Converter
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
class TokensDataConverter(
|
||||
private val onSearchEntered: (String) -> Unit,
|
||||
private val onTokenSelected: (String) -> Unit,
|
||||
private val isBalanceHiddenProvider: Provider<Boolean>,
|
||||
private val appCurrencyProvider: Provider<AppCurrency>,
|
||||
) : Converter<CurrenciesGroupWithFromCurrency, SwapSelectTokenStateHolder> {
|
||||
|
||||
override fun convert(value: CurrenciesGroupWithFromCurrency): SwapSelectTokenStateHolder {
|
||||
val group = value.group
|
||||
val availableTitle = TokenToSelectState.Title(
|
||||
resourceReference(R.string.exchange_tokens_available_tokens_header),
|
||||
)
|
||||
val unavailableTitle = TokenToSelectState.Title(
|
||||
resourceReference(
|
||||
R.string.exchange_tokens_unavailable_tokens_header,
|
||||
wrappedList(value.fromCurrency.name),
|
||||
),
|
||||
)
|
||||
return SwapSelectTokenStateHolder(
|
||||
availableTokens = group.available.map { tokenWithBalanceToTokenToSelect(it, true) }
|
||||
.toMutableList()
|
||||
.apply {
|
||||
if (this.isNotEmpty()) {
|
||||
this.add(0, availableTitle)
|
||||
}
|
||||
}
|
||||
.toImmutableList(),
|
||||
unavailableTokens = group.unavailable.map { tokenWithBalanceToTokenToSelect(it, false) }
|
||||
.toMutableList()
|
||||
.apply {
|
||||
if (this.isNotEmpty()) {
|
||||
this.add(0, unavailableTitle)
|
||||
}
|
||||
}
|
||||
.toImmutableList(),
|
||||
onSearchEntered = onSearchEntered,
|
||||
onTokenSelected = onTokenSelected,
|
||||
afterSearch = group.afterSearch,
|
||||
)
|
||||
}
|
||||
|
||||
private fun tokenWithBalanceToTokenToSelect(
|
||||
cryptoCurrencySwapInfo: CryptoCurrencySwapInfo,
|
||||
isAvailable: Boolean,
|
||||
): TokenToSelectState {
|
||||
val cryptoCurrencyStatus = cryptoCurrencySwapInfo.currencyStatus
|
||||
return TokenToSelectState.TokenToSelect(
|
||||
id = cryptoCurrencyStatus.currency.id.value,
|
||||
name = cryptoCurrencyStatus.currency.name,
|
||||
symbol = cryptoCurrencyStatus.currency.symbol,
|
||||
available = isAvailable,
|
||||
tokenIcon = convertIcon(cryptoCurrencyStatus.currency, isAvailable),
|
||||
addedTokenBalanceData = TokenBalanceData(
|
||||
amount = formatCryptoAmount(cryptoCurrencyStatus),
|
||||
amountEquivalent = formatFiatAmount(cryptoCurrencyStatus, appCurrencyProvider.invoke()),
|
||||
isBalanceHidden = isBalanceHiddenProvider.invoke(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertIcon(currency: CryptoCurrency, isAvailable: Boolean): CurrencyIconState {
|
||||
return when (currency) {
|
||||
is CryptoCurrency.Coin -> {
|
||||
CurrencyIconState.CoinIcon(
|
||||
url = currency.iconUrl,
|
||||
fallbackResId = currency.networkIconResId,
|
||||
isGrayscale = !isAvailable,
|
||||
showCustomBadge = currency.isCustom,
|
||||
)
|
||||
}
|
||||
is CryptoCurrency.Token -> {
|
||||
val isGrayscale = currency.network.isTestnet
|
||||
val background = currency.tryGetBackgroundForTokenIcon(isGrayscale)
|
||||
val tint = getTintForTokenIcon(background)
|
||||
CurrencyIconState.TokenIcon(
|
||||
url = currency.iconUrl,
|
||||
isGrayscale = !isAvailable,
|
||||
showCustomBadge = currency.isCustom,
|
||||
topBadgeIconResId = currency.networkIconResId,
|
||||
fallbackTint = tint,
|
||||
fallbackBackground = background,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCryptoAmount(cryptoCurrencyStatus: CryptoCurrencyStatus): String {
|
||||
return cryptoCurrencyStatus.value.amount.format {
|
||||
crypto(cryptoCurrencyStatus.currency)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFiatAmount(cryptoCurrencyStatus: CryptoCurrencyStatus, appCurrency: AppCurrency): String {
|
||||
return BigDecimalFormatter.formatFiatAmount(
|
||||
fiatAmount = cryptoCurrencyStatus.value.fiatAmount,
|
||||
fiatCurrencyCode = appCurrency.code,
|
||||
fiatCurrencySymbol = appCurrency.symbol,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
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
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object SwapFeatureModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSwapFeatureToggles(featureToggles: FeatureTogglesManager): SwapFeatureToggles {
|
||||
return DefaultSwapFeatureToggles(featureToggles)
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface SwapFeatureModuleBinds {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun provideSwapComponentFactory(impl: DefaultSwapComponent.Factory): SwapComponent.Factory
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.feature.swap.di
|
||||
|
||||
import com.tangem.core.decompose.di.DecomposeComponent
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.feature.swap.model.SwapModel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.multibindings.ClassKey
|
||||
import dagger.multibindings.IntoMap
|
||||
|
||||
@Module
|
||||
@InstallIn(DecomposeComponent::class)
|
||||
internal interface SwapModelModule {
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(SwapModel::class)
|
||||
fun provideSwapModel(model: SwapModel): Model
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.tangem.feature.swap.di
|
||||
|
||||
import com.tangem.feature.swap.di.impl.DefaultAmountFormatter
|
||||
import com.tangem.feature.swap.domain.models.ui.AmountFormatter
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class SwapSingletonModule {
|
||||
|
||||
@Provides
|
||||
fun provideAmountFormatter(): AmountFormatter {
|
||||
return DefaultAmountFormatter()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.tangem.feature.swap.di.impl
|
||||
|
||||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.ui.AmountFormatter
|
||||
import java.math.BigDecimal
|
||||
|
||||
class DefaultAmountFormatter : AmountFormatter {
|
||||
|
||||
override fun formatSwapAmountToUI(swapAmount: SwapAmount, currency: String): String {
|
||||
return swapAmount.value.format { crypto(symbol = currency, decimals = swapAmount.decimals) }
|
||||
}
|
||||
|
||||
override fun formatBigDecimalAmountToUI(amount: BigDecimal, decimals: Int, currency: String?): String {
|
||||
return amount.format { crypto(symbol = currency.orEmpty(), decimals = decimals) }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,354 @@
|
|||
package com.tangem.feature.swap.model
|
||||
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.utils.parseBigDecimal
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapFeeState
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import com.tangem.feature.swap.models.UiActions
|
||||
import com.tangem.feature.swap.models.states.FeeItemState
|
||||
import com.tangem.feature.swap.models.states.SwapNotificationUM
|
||||
import com.tangem.lib.crypto.BlockchainUtils
|
||||
import com.tangem.lib.crypto.BlockchainUtils.getTezosThreshold
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isTezos
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Suppress("LargeClass")
|
||||
internal class SwapNotificationsFactory(
|
||||
private val actions: UiActions,
|
||||
) {
|
||||
|
||||
fun getInitialErrorStateNotifications(code: Int, onRefreshClick: () -> Unit): ImmutableList<NotificationUM> {
|
||||
return persistentListOf(
|
||||
SwapNotificationUM.Warning.ExpressGeneralError(
|
||||
code = code,
|
||||
onConfirmClick = onRefreshClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun getGeneralErrorStateNotifications(
|
||||
message: TextReference?,
|
||||
onClick: () -> Unit,
|
||||
): ImmutableList<NotificationUM> {
|
||||
return persistentListOf(
|
||||
SwapNotificationUM.Error.GenericError(
|
||||
subtitle = message,
|
||||
onConfirmClick = onClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun getNotAvailableStateNotifications(fromCurrencyName: String): ImmutableList<NotificationUM> {
|
||||
return persistentListOf(
|
||||
SwapNotificationUM.Warning.NoAvailableTokensToSwap(fromCurrencyName),
|
||||
)
|
||||
}
|
||||
|
||||
fun getQuotesErrorStateNotifications(
|
||||
expressDataError: ExpressDataError,
|
||||
fromToken: CryptoCurrency,
|
||||
feeItem: FeeItemState,
|
||||
includeFeeInAmount: IncludeFeeInAmount,
|
||||
): ImmutableList<NotificationUM> {
|
||||
return buildList {
|
||||
add(getWarningForError(expressDataError, fromToken, actions.onRetryClick))
|
||||
if (includeFeeInAmount is IncludeFeeInAmount.Included && feeItem is FeeItemState.Content) {
|
||||
add(
|
||||
NotificationUM.Warning.FeeCoverageNotification(
|
||||
feeItem.amountCrypto,
|
||||
feeItem.amountFiatFormatted,
|
||||
),
|
||||
)
|
||||
}
|
||||
}.toPersistentList()
|
||||
}
|
||||
|
||||
fun getApprovalInProgressStateNotification(
|
||||
notifications: ImmutableList<NotificationUM>,
|
||||
): ImmutableList<NotificationUM> {
|
||||
val updatedNotifications = notifications
|
||||
.filterNot { it is SwapNotificationUM.Info.PermissionNeeded }
|
||||
.toMutableList()
|
||||
|
||||
updatedNotifications.add(0, SwapNotificationUM.Error.ApprovalInProgressWarning)
|
||||
|
||||
return updatedNotifications.toPersistentList()
|
||||
}
|
||||
|
||||
fun getConfirmationStateNotifications(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
fromToken: CryptoCurrency,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
selectedFeeType: FeeType,
|
||||
providerName: String,
|
||||
): ImmutableList<NotificationUM> {
|
||||
val warnings = buildList {
|
||||
maybeAddRentExemptionError(quoteModel)
|
||||
maybeAddDomainWarnings(quoteModel, feeCryptoCurrencyStatus, selectedFeeType)
|
||||
maybeAddNeedReserveToCreateAccountWarning(quoteModel)
|
||||
maybeAddPermissionNeededWarning(quoteModel, fromToken, providerName)
|
||||
maybeAddNetworkFeeCoverageWarning(quoteModel, selectedFeeType)
|
||||
maybeAddUnableCoverFeeWarning(quoteModel, fromToken)
|
||||
maybeAddTransactionInProgressWarning(quoteModel)
|
||||
}
|
||||
return warnings.toPersistentList()
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.maybeAddRentExemptionError(quoteModel: SwapState.QuotesLoadedState) {
|
||||
quoteModel.currencyCheck?.rentWarning?.let {
|
||||
add(NotificationUM.Solana.RentInfo(it))
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.maybeAddTransactionInProgressWarning(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
) {
|
||||
if (quoteModel.permissionState is PermissionDataState.PermissionLoading) {
|
||||
add(SwapNotificationUM.Error.ApprovalInProgressWarning)
|
||||
} else if (quoteModel.preparedSwapConfigState.hasOutgoingTransaction) {
|
||||
add(
|
||||
SwapNotificationUM.Error.TransactionInProgressWarning(
|
||||
currencySymbol = quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency.network.currencySymbol,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun MutableList<NotificationUM>.maybeAddDomainWarnings(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
selectedFeeType: FeeType,
|
||||
) {
|
||||
val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus
|
||||
val includeFeeInAmount = quoteModel.preparedSwapConfigState.includeFeeInAmount
|
||||
val amount = quoteModel.fromTokenInfo.tokenAmount
|
||||
val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) {
|
||||
includeFeeInAmount.amountSubtractFee
|
||||
} else {
|
||||
amount
|
||||
}
|
||||
val fee = when (val feeState = quoteModel.txFee) {
|
||||
TxFeeState.Empty -> null
|
||||
is TxFeeState.MultipleFeeState -> if (feeState.normalFee.feeType == selectedFeeType) {
|
||||
feeState.normalFee
|
||||
} else {
|
||||
feeState.priorityFee
|
||||
}
|
||||
is TxFeeState.SingleFeeState -> feeState.fee
|
||||
}
|
||||
val isCardano = BlockchainUtils.isCardano(fromCurrencyStatus.currency.network.id.value)
|
||||
// blockchain specific
|
||||
|
||||
addExistentialWarningNotification(
|
||||
existentialDeposit = quoteModel.currencyCheck?.existentialDeposit,
|
||||
feeAmount = fee?.feeValue.orZero(),
|
||||
sendingAmount = amountToRequest.value,
|
||||
cryptoCurrencyStatus = fromCurrencyStatus,
|
||||
onReduceClick = { reduceBy, reduceByDiff, _ ->
|
||||
actions.onReduceByAmount(
|
||||
// use in swap notification amountToRequest because fee is already subtracted
|
||||
amountToRequest.copy(value = amountToRequest.value.minus(reduceByDiff)),
|
||||
reduceBy,
|
||||
)
|
||||
},
|
||||
)
|
||||
addValidateTransactionNotifications(
|
||||
dustValue = quoteModel.currencyCheck?.dustValue.orZero(),
|
||||
validationError = quoteModel.validationResult,
|
||||
cryptoCurrency = fromCurrencyStatus.currency,
|
||||
minAdaValue = quoteModel.minAdaValue,
|
||||
onReduceClick = { reduceTo, _ ->
|
||||
actions.onReduceToAmount(amount.copy(value = reduceTo))
|
||||
},
|
||||
)
|
||||
if (!isCardano) {
|
||||
addDustWarningNotification(
|
||||
dustValue = quoteModel.currencyCheck?.dustValue,
|
||||
feeValue = fee?.feeValue.orZero(),
|
||||
sendingAmount = amountToRequest.value,
|
||||
cryptoCurrencyStatus = fromCurrencyStatus,
|
||||
feeCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
)
|
||||
}
|
||||
addReserveAmountErrorNotification(
|
||||
reserveAmount = quoteModel.currencyCheck?.reserveAmount,
|
||||
sendingAmount = amountToRequest.value,
|
||||
cryptoCurrency = fromCurrencyStatus.currency,
|
||||
isAccountFunded = false,
|
||||
)
|
||||
addReduceAmountNotification(
|
||||
cryptoCurrencyStatus = fromCurrencyStatus,
|
||||
fromAmount = quoteModel.fromTokenInfo.tokenAmount,
|
||||
onReduceByAmount = actions.onReduceByAmount,
|
||||
)
|
||||
addTransactionLimitErrorNotification(
|
||||
currencyCheck = quoteModel.currencyCheck,
|
||||
sendingAmount = amountToRequest.value,
|
||||
cryptoCurrencyStatus = fromCurrencyStatus,
|
||||
feeCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
feeValue = fee?.feeValue.orZero(),
|
||||
onReduceClick = { reduceTo, _ ->
|
||||
actions.onReduceToAmount(amountToRequest.copy(value = reduceTo))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.maybeAddNeedReserveToCreateAccountWarning(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
) {
|
||||
val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value
|
||||
if (status is CryptoCurrencyStatus.NoAccount) {
|
||||
val amount = quoteModel.toTokenInfo.tokenAmount.value
|
||||
val amountToCreateAccount = status.amountToCreateAccount
|
||||
val currencyTo = quoteModel.toTokenInfo.cryptoCurrencyStatus.currency
|
||||
if (amount < amountToCreateAccount) {
|
||||
add(
|
||||
SwapNotificationUM.Warning.NeedReserveToCreateAccount(
|
||||
receiveAmount = status.amountToCreateAccount.parseBigDecimal(currencyTo.decimals),
|
||||
receiveToken = currencyTo.symbol,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.maybeAddPermissionNeededWarning(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
fromToken: CryptoCurrency,
|
||||
providerName: String,
|
||||
) {
|
||||
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend &&
|
||||
quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough &&
|
||||
quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest
|
||||
) {
|
||||
add(
|
||||
SwapNotificationUM.Info.PermissionNeeded(
|
||||
providerName = fromToken.symbol,
|
||||
fromTokenSymbol = providerName,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.maybeAddNetworkFeeCoverageWarning(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
selectedFeeType: FeeType,
|
||||
) {
|
||||
when (quoteModel.preparedSwapConfigState.includeFeeInAmount) {
|
||||
is IncludeFeeInAmount.Included -> {
|
||||
val fee = selectFeeByType(selectedFeeType, quoteModel.txFee) ?: return
|
||||
if (needShowNetworkFeeCoverageWarningShow(quoteModel)) {
|
||||
add(
|
||||
NotificationUM.Warning.FeeCoverageNotification(
|
||||
fee.feeCryptoFormattedWithNative,
|
||||
fee.feeFiatFormattedWithNative,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): TxFee? {
|
||||
return when (txFeeState) {
|
||||
TxFeeState.Empty -> null
|
||||
is TxFeeState.SingleFeeState -> txFeeState.fee
|
||||
is TxFeeState.MultipleFeeState -> when (feeType) {
|
||||
FeeType.NORMAL -> txFeeState.normalFee
|
||||
FeeType.PRIORITY -> txFeeState.priorityFee
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.maybeAddUnableCoverFeeWarning(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
fromToken: CryptoCurrency,
|
||||
) {
|
||||
val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough ?: return
|
||||
val needShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough &&
|
||||
quoteModel.permissionState !is PermissionDataState.PermissionLoading &&
|
||||
feeEnoughState.feeCurrency != fromToken
|
||||
if (needShowCoverWarning) {
|
||||
add(
|
||||
SwapNotificationUM.Error.UnableToCoverFeeWarning(
|
||||
fromToken = fromToken,
|
||||
feeCurrency = feeEnoughState.feeCurrency,
|
||||
currencyName = feeEnoughState.currencyName ?: fromToken.network.name,
|
||||
currencySymbol = feeEnoughState.currencySymbol ?: fromToken.network.currencySymbol,
|
||||
onConfirmClick = actions.onBuyClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.addReduceAmountNotification(
|
||||
cryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
fromAmount: SwapAmount,
|
||||
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
|
||||
) {
|
||||
val isTezos = isTezos(cryptoCurrencyStatus.currency.network.id.value)
|
||||
val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
|
||||
val threshold = getTezosThreshold()
|
||||
val isTotalBalance = fromAmount.value >= balance && balance > threshold
|
||||
if (isTezos && isTotalBalance) {
|
||||
add(
|
||||
SwapNotificationUM.Warning.ReduceAmount(
|
||||
currencyName = cryptoCurrencyStatus.currency.name,
|
||||
amount = threshold.toPlainString(),
|
||||
onConfirmClick = {
|
||||
val patchedAmount = fromAmount.copy(
|
||||
value = fromAmount.value - threshold,
|
||||
)
|
||||
onReduceByAmount(patchedAmount, threshold)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getWarningForError(
|
||||
expressDataError: ExpressDataError,
|
||||
fromToken: CryptoCurrency,
|
||||
onRetryClick: () -> Unit,
|
||||
): NotificationUM {
|
||||
return when (expressDataError) {
|
||||
is ExpressDataError.ExchangeTooSmallAmountError -> SwapNotificationUM.Error.MinimalAmountError(
|
||||
expressDataError.amount.value.format {
|
||||
crypto(fromToken.symbol, fromToken.decimals)
|
||||
},
|
||||
)
|
||||
is ExpressDataError.ExchangeTooBigAmountError -> SwapNotificationUM.Error.MaximumAmountError(
|
||||
expressDataError.amount.value.format {
|
||||
crypto(fromToken.symbol, fromToken.decimals)
|
||||
},
|
||||
)
|
||||
else -> SwapNotificationUM.Warning.ExpressError(
|
||||
expressDataError,
|
||||
onConfirmClick = onRetryClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun needShowNetworkFeeCoverageWarningShow(quoteModel: SwapState.QuotesLoadedState): Boolean {
|
||||
return quoteModel.currencyCheck?.existentialDeposit == null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.tangem.feature.swap.model
|
||||
|
||||
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.RequestApproveStateData
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress
|
||||
import com.tangem.feature.swap.domain.models.ui.TxFee
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class SwapProcessDataState(
|
||||
// Initial network id
|
||||
val fromCryptoCurrency: CryptoCurrencyStatus? = null,
|
||||
val toCryptoCurrency: CryptoCurrencyStatus? = null,
|
||||
val feePaidCryptoCurrency: CryptoCurrencyStatus? = null,
|
||||
// Amount from input
|
||||
val amount: String? = null,
|
||||
val reduceBalanceBy: BigDecimal = BigDecimal.ZERO,
|
||||
val approveDataModel: RequestApproveStateData? = null,
|
||||
val swapDataModel: SwapDataModel? = null,
|
||||
val selectedFee: TxFee? = null,
|
||||
val tokensDataState: TokensDataStateExpress? = null,
|
||||
val selectedProvider: SwapProvider? = null,
|
||||
val lastLoadedSwapStates: Map<SwapProvider, SwapState> = emptyMap(),
|
||||
) {
|
||||
|
||||
fun getCurrentLoadedSwapState(): SwapState.QuotesLoadedState? {
|
||||
return lastLoadedSwapStates[selectedProvider] as? SwapState.QuotesLoadedState
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.tangem.feature.swap.models
|
||||
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup
|
||||
|
||||
data class CurrenciesGroupWithFromCurrency(
|
||||
val group: CurrenciesGroup,
|
||||
val fromCurrency: CryptoCurrency,
|
||||
)
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.tangem.feature.swap.models
|
||||
|
||||
import com.tangem.common.ui.alerts.models.AlertUM
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
|
||||
sealed class SwapAlertUM : AlertUM {
|
||||
|
||||
data class GenericError(
|
||||
override val onConfirmClick: (() -> Unit),
|
||||
override val message: TextReference = resourceReference(R.string.common_unknown_error),
|
||||
) : SwapAlertUM() {
|
||||
override val title: TextReference? = null
|
||||
override val confirmButtonText: TextReference =
|
||||
resourceReference(id = R.string.common_support)
|
||||
}
|
||||
|
||||
data class ExpressErrorAlert(
|
||||
override val message: TextReference = resourceReference(R.string.common_unknown_error),
|
||||
override val onConfirmClick: (() -> Unit),
|
||||
) : SwapAlertUM() {
|
||||
override val title: TextReference? = null
|
||||
override val confirmButtonText: TextReference =
|
||||
resourceReference(id = R.string.common_support)
|
||||
}
|
||||
|
||||
data class InformationAlert(
|
||||
override val message: TextReference,
|
||||
override val onConfirmClick: (() -> Unit),
|
||||
) : SwapAlertUM() {
|
||||
override val title: TextReference = resourceReference(
|
||||
R.string.swapping_alert_title,
|
||||
)
|
||||
override val confirmButtonText: TextReference =
|
||||
resourceReference(id = R.string.common_ok)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.tangem.feature.swap.models
|
||||
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class SwapSelectTokenStateHolder(
|
||||
val availableTokens: ImmutableList<TokenToSelectState>,
|
||||
val unavailableTokens: ImmutableList<TokenToSelectState>,
|
||||
val afterSearch: Boolean,
|
||||
val onSearchEntered: (String) -> Unit,
|
||||
val onTokenSelected: (String) -> Unit,
|
||||
)
|
||||
|
||||
sealed class TokenToSelectState {
|
||||
|
||||
data class Title(val title: TextReference) : TokenToSelectState()
|
||||
|
||||
data class TokenToSelect(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val symbol: String,
|
||||
val tokenIcon: CurrencyIconState,
|
||||
val available: Boolean = true,
|
||||
val addedTokenBalanceData: TokenBalanceData? = null,
|
||||
) : TokenToSelectState()
|
||||
}
|
||||
|
||||
data class TokenBalanceData(
|
||||
val amount: String?,
|
||||
val amountEquivalent: String?,
|
||||
val isBalanceHidden: Boolean,
|
||||
)
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
package com.tangem.feature.swap.models
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.common.ui.swapStoriesScreen.SwapStoriesUM
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.event.StateEvent
|
||||
import com.tangem.core.ui.event.consumedEvent
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
import com.tangem.feature.swap.models.states.FeeItemState
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
import com.tangem.feature.swap.models.states.events.SwapEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
internal data class SwapStateHolder(
|
||||
val sendCardData: SwapCardState,
|
||||
val receiveCardData: SwapCardState,
|
||||
val blockchainId: String, // not the same as networkId, its local id in app
|
||||
val notifications: ImmutableList<NotificationUM> = persistentListOf(),
|
||||
val isInsufficientFunds: Boolean,
|
||||
val event: StateEvent<SwapEvent> = consumedEvent(),
|
||||
val changeCardsButtonState: ChangeCardsButtonState = ChangeCardsButtonState.ENABLED,
|
||||
val providerState: ProviderState,
|
||||
|
||||
val fee: FeeItemState = FeeItemState.Empty,
|
||||
val permissionState: GiveTxPermissionState = GiveTxPermissionState.Empty,
|
||||
val priceImpact: PriceImpact,
|
||||
|
||||
val successState: SwapSuccessStateHolder? = null,
|
||||
val selectTokenState: SwapSelectTokenStateHolder? = null,
|
||||
val bottomSheetConfig: TangemBottomSheetConfig? = null,
|
||||
val storiesConfig: SwapStoriesUM? = null,
|
||||
|
||||
val swapButton: SwapButton,
|
||||
val shouldShowMaxAmount: Boolean,
|
||||
val tosState: TosState? = null,
|
||||
|
||||
val onRefresh: () -> Unit,
|
||||
val onBackClicked: () -> Unit,
|
||||
val onChangeCardsClicked: () -> Unit,
|
||||
val onSelectTokenClick: (() -> Unit),
|
||||
val onSuccess: (() -> Unit),
|
||||
val onMaxAmountSelected: (() -> Unit)? = null,
|
||||
val onShowPermissionBottomSheet: () -> Unit = {},
|
||||
)
|
||||
|
||||
sealed class SwapCardState {
|
||||
|
||||
data class SwapCardData(
|
||||
@DrawableRes val networkIconRes: Int?,
|
||||
val type: TransactionCardType,
|
||||
val amountEquivalent: String?,
|
||||
val token: CryptoCurrencyStatus?,
|
||||
val coinId: String?,
|
||||
val amountTextFieldValue: TextFieldValue?,
|
||||
val tokenIconUrl: String?,
|
||||
val tokenCurrency: String,
|
||||
val balance: String,
|
||||
val isBalanceHidden: Boolean,
|
||||
val isNotNativeToken: Boolean,
|
||||
val canSelectAnotherToken: Boolean = false,
|
||||
) : SwapCardState()
|
||||
|
||||
data class Empty(
|
||||
val type: TransactionCardType,
|
||||
val amountEquivalent: String?,
|
||||
val amountTextFieldValue: TextFieldValue?,
|
||||
val canSelectAnotherToken: Boolean = false,
|
||||
) : SwapCardState()
|
||||
}
|
||||
|
||||
data class SwapButton(
|
||||
val enabled: Boolean,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
sealed interface TransactionCardType {
|
||||
|
||||
val header: TextReference
|
||||
val inputError: InputError
|
||||
|
||||
data class Inputtable(
|
||||
val onAmountChanged: ((String) -> Unit),
|
||||
val onFocusChanged: ((Boolean) -> Unit),
|
||||
override val inputError: InputError,
|
||||
override val header: TextReference = TextReference.Res(R.string.swapping_from_title),
|
||||
) : TransactionCardType
|
||||
|
||||
data class ReadOnly(
|
||||
val showWarning: Boolean = false,
|
||||
val onWarningClick: (() -> Unit)? = null,
|
||||
override val inputError: InputError = InputError.Empty,
|
||||
override val header: TextReference = TextReference.Res(R.string.swapping_to_title),
|
||||
) : TransactionCardType
|
||||
|
||||
sealed interface InputError {
|
||||
data object Empty : InputError
|
||||
data object InsufficientFunds : InputError
|
||||
data object WrongAmount : InputError
|
||||
}
|
||||
}
|
||||
|
||||
data class TosState(
|
||||
val tosLink: LegalState?,
|
||||
val policyLink: LegalState?,
|
||||
)
|
||||
|
||||
data class LegalState(
|
||||
val title: TextReference,
|
||||
val link: String,
|
||||
val onClick: (String) -> Unit,
|
||||
)
|
||||
|
||||
enum class ChangeCardsButtonState {
|
||||
ENABLED, DISABLED, UPDATE_IN_PROGRESS
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.tangem.feature.swap.models
|
||||
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
||||
data class SwapSuccessStateHolder(
|
||||
val timestamp: Long,
|
||||
val txUrl: String,
|
||||
val fee: TextReference,
|
||||
val rate: TextReference,
|
||||
val showStatusButton: Boolean,
|
||||
val providerName: TextReference,
|
||||
val providerType: TextReference,
|
||||
val providerIcon: String,
|
||||
val fromTokenAmount: TextReference,
|
||||
val toTokenAmount: TextReference,
|
||||
val fromTokenFiatAmount: TextReference,
|
||||
val toTokenFiatAmount: TextReference,
|
||||
val fromTokenIconState: CurrencyIconState?,
|
||||
val toTokenIconState: CurrencyIconState?,
|
||||
val onExploreButtonClick: () -> Unit,
|
||||
val onStatusButtonClick: () -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.tangem.feature.swap.models
|
||||
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.ui.TxFee
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class UiActions(
|
||||
val onSearchEntered: (String) -> Unit,
|
||||
val onTokenSelected: (String) -> Unit,
|
||||
val onAmountChanged: (String) -> Unit,
|
||||
val onAmountSelected: (Boolean) -> Unit,
|
||||
val onSwapClick: () -> Unit,
|
||||
val onGivePermissionClick: () -> Unit,
|
||||
val onChangeCardsClicked: () -> Unit,
|
||||
val onBackClicked: () -> Unit,
|
||||
val onMaxAmountSelected: () -> Unit,
|
||||
val onReduceToAmount: (SwapAmount) -> Unit,
|
||||
val onReduceByAmount: (SwapAmount, reduceBy: BigDecimal) -> Unit,
|
||||
val openPermissionBottomSheet: () -> Unit,
|
||||
val onChangeApproveType: (ApproveType) -> Unit,
|
||||
// region new actions
|
||||
val onStoriesClose: (Int) -> Unit,
|
||||
val onRetryClick: () -> Unit,
|
||||
val onClickFee: () -> Unit,
|
||||
val onSelectFeeType: (TxFee) -> Unit,
|
||||
val onProviderClick: (String) -> Unit,
|
||||
val onProviderSelect: (String) -> Unit,
|
||||
val onBuyClick: (CryptoCurrency) -> Unit,
|
||||
val onSelectTokenClick: () -> Unit,
|
||||
val onSuccess: () -> Unit,
|
||||
val onLinkClick: (String) -> Unit,
|
||||
val onReceiveCardWarningClick: () -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.feature.swap.models.states
|
||||
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.feature.swap.domain.models.ui.FeeType
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class ChooseFeeBottomSheetConfig(
|
||||
val selectedFee: FeeType,
|
||||
val onSelectFeeType: (FeeType) -> Unit,
|
||||
val feeItems: ImmutableList<FeeItemState.Content>,
|
||||
val readMoreUrl: String,
|
||||
val readMore: TextReference,
|
||||
val onReadMoreClick: (String) -> Unit,
|
||||
) : TangemBottomSheetConfigContent
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.tangem.feature.swap.models.states
|
||||
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class ChooseProviderBottomSheetConfig(
|
||||
val selectedProviderId: String,
|
||||
val providers: ImmutableList<ProviderState>,
|
||||
) : TangemBottomSheetConfigContent
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.tangem.feature.swap.models.states
|
||||
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.feature.swap.domain.models.ui.FeeType
|
||||
|
||||
sealed class FeeItemState {
|
||||
|
||||
/**
|
||||
* @param amountCrypto - crypto amount formatted with symbol
|
||||
* @param amountFiatFormatted - formatted fiat amount
|
||||
*/
|
||||
data class Content(
|
||||
val feeType: FeeType,
|
||||
val title: TextReference,
|
||||
val amountCrypto: String,
|
||||
val symbolCrypto: String,
|
||||
val amountFiatFormatted: String,
|
||||
val isClickable: Boolean,
|
||||
val onClick: () -> Unit,
|
||||
) : FeeItemState()
|
||||
|
||||
object Empty : FeeItemState()
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
package com.tangem.feature.swap.models.states
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
||||
@Immutable
|
||||
sealed class ProviderState {
|
||||
|
||||
abstract val onProviderClick: ((String) -> Unit)?
|
||||
abstract val id: String
|
||||
|
||||
data class Empty(
|
||||
override val id: String = "",
|
||||
override val onProviderClick: ((String) -> Unit)? = null,
|
||||
) : ProviderState()
|
||||
|
||||
data class Loading(
|
||||
override val id: String = "",
|
||||
override val onProviderClick: ((String) -> Unit)? = null,
|
||||
) : ProviderState()
|
||||
|
||||
data class Content(
|
||||
override val id: String,
|
||||
val name: String,
|
||||
val type: String,
|
||||
val iconUrl: String,
|
||||
val subtitle: TextReference,
|
||||
val selectionType: SelectionType,
|
||||
val additionalBadge: AdditionalBadge,
|
||||
val percentLowerThenBest: PercentDifference = PercentDifference.Empty,
|
||||
val namePrefix: PrefixType,
|
||||
override val onProviderClick: (String) -> Unit,
|
||||
) : ProviderState()
|
||||
|
||||
data class Unavailable(
|
||||
override val id: String,
|
||||
val name: String,
|
||||
val type: String,
|
||||
val iconUrl: String,
|
||||
val alertText: TextReference,
|
||||
val selectionType: SelectionType,
|
||||
override val onProviderClick: ((String) -> Unit)? = null,
|
||||
) : ProviderState()
|
||||
|
||||
@Immutable
|
||||
sealed class AdditionalBadge {
|
||||
data object BestTrade : AdditionalBadge()
|
||||
data object Empty : AdditionalBadge()
|
||||
data object PermissionRequired : AdditionalBadge()
|
||||
data object Recommended : AdditionalBadge()
|
||||
}
|
||||
|
||||
@Immutable
|
||||
enum class SelectionType {
|
||||
NONE, CLICK, SELECT
|
||||
}
|
||||
|
||||
// Prefix will be disabled in 5.12 but mechanism is still implemented
|
||||
@Immutable
|
||||
enum class PrefixType {
|
||||
NONE, PROVIDED_BY
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed class PercentDifference {
|
||||
data class Value(val value: Float) : PercentDifference()
|
||||
object Empty : PercentDifference()
|
||||
}
|
||||
|
||||
object ProviderPercentDiffComparator : Comparator<ProviderState> {
|
||||
override fun compare(o1: ProviderState, o2: ProviderState): Int {
|
||||
if (o1 is ProviderState.Content && o2 !is ProviderState.Content) {
|
||||
return -1
|
||||
}
|
||||
if (o1 !is ProviderState.Content && o2 is ProviderState.Content) {
|
||||
return 1
|
||||
}
|
||||
if (o1 is ProviderState.Content && o2 is ProviderState.Content) {
|
||||
val o1Percent = o1.percentLowerThenBest
|
||||
val o2Percent = o2.percentLowerThenBest
|
||||
if (o1Percent is PercentDifference.Value && o2Percent !is PercentDifference.Value) {
|
||||
return -1
|
||||
}
|
||||
if (o1Percent !is PercentDifference.Value && o2Percent is PercentDifference.Value) {
|
||||
return 1
|
||||
}
|
||||
return if (o1Percent is PercentDifference.Value && o2Percent is PercentDifference.Value) {
|
||||
o2Percent.value.compareTo(o1Percent.value)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
package com.tangem.feature.swap.models.states
|
||||
|
||||
import com.tangem.common.ui.R
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.core.ui.components.notifications.NotificationConfig
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.networkIconResId
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.utils.getExpressErrorMessage
|
||||
import com.tangem.feature.swap.utils.getExpressErrorTitle
|
||||
|
||||
internal object SwapNotificationUM {
|
||||
|
||||
sealed class Error(
|
||||
title: TextReference,
|
||||
subtitle: TextReference,
|
||||
iconResId: Int = R.drawable.ic_alert_24,
|
||||
buttonState: NotificationConfig.ButtonsState? = null,
|
||||
onCloseClick: (() -> Unit)? = null,
|
||||
) : NotificationUM.Error(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
iconResId = iconResId,
|
||||
buttonState = buttonState,
|
||||
onCloseClick = onCloseClick,
|
||||
) {
|
||||
data class GenericError(
|
||||
val title: TextReference = resourceReference(id = R.string.common_warning),
|
||||
val subtitle: TextReference?,
|
||||
val onConfirmClick: () -> Unit,
|
||||
) : Error(
|
||||
title = title,
|
||||
subtitle = subtitle ?: resourceReference(id = R.string.common_unknown_error),
|
||||
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
|
||||
text = resourceReference(R.string.common_retry),
|
||||
onClick = onConfirmClick,
|
||||
),
|
||||
)
|
||||
|
||||
data object ApprovalInProgressWarning : Error(
|
||||
title = resourceReference(R.string.warning_express_approval_in_progress_title),
|
||||
subtitle = resourceReference(R.string.warning_express_approval_in_progress_message),
|
||||
)
|
||||
|
||||
data class TransactionInProgressWarning(
|
||||
val currencySymbol: String,
|
||||
) : Error(
|
||||
title = resourceReference(R.string.warning_express_active_transaction_title),
|
||||
subtitle = resourceReference(
|
||||
id = R.string.warning_express_active_transaction_message,
|
||||
formatArgs = wrappedList(currencySymbol),
|
||||
),
|
||||
)
|
||||
|
||||
data class UnableToCoverFeeWarning(
|
||||
val fromToken: CryptoCurrency,
|
||||
val currencyName: String,
|
||||
val currencySymbol: String,
|
||||
val feeCurrency: CryptoCurrency?,
|
||||
val onConfirmClick: (CryptoCurrency) -> Unit,
|
||||
) : Error(
|
||||
title = resourceReference(
|
||||
R.string.warning_express_not_enough_fee_for_token_tx_title,
|
||||
wrappedList(fromToken.network.name),
|
||||
),
|
||||
subtitle = resourceReference(
|
||||
R.string.warning_express_not_enough_fee_for_token_tx_description,
|
||||
wrappedList(currencyName, currencySymbol),
|
||||
),
|
||||
iconResId = fromToken.networkIconResId,
|
||||
buttonState = feeCurrency?.let {
|
||||
NotificationConfig.ButtonsState.SecondaryButtonConfig(
|
||||
text = resourceReference(R.string.common_buy_currency, wrappedList(currencySymbol)),
|
||||
onClick = { onConfirmClick(it) },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
data class MinimalAmountError(
|
||||
val amount: String,
|
||||
) : Error(
|
||||
title = resourceReference(
|
||||
id = R.string.warning_express_too_minimal_amount_title,
|
||||
formatArgs = wrappedList(amount),
|
||||
),
|
||||
subtitle = resourceReference(R.string.warning_express_wrong_amount_description),
|
||||
)
|
||||
|
||||
data class MaximumAmountError(
|
||||
val amount: String,
|
||||
) : Error(
|
||||
title = resourceReference(
|
||||
id = R.string.warning_express_too_maximum_amount_title,
|
||||
formatArgs = wrappedList(amount),
|
||||
),
|
||||
subtitle = resourceReference(R.string.warning_express_wrong_amount_description),
|
||||
iconResId = R.drawable.ic_alert_circle_24,
|
||||
)
|
||||
}
|
||||
|
||||
sealed class Warning(
|
||||
title: TextReference,
|
||||
subtitle: TextReference,
|
||||
iconResId: Int = R.drawable.img_attention_20,
|
||||
buttonsState: NotificationConfig.ButtonsState? = null,
|
||||
onCloseClick: (() -> Unit)? = null,
|
||||
) : NotificationUM.Warning(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
iconResId = iconResId,
|
||||
buttonsState = buttonsState,
|
||||
onCloseClick = onCloseClick,
|
||||
) {
|
||||
data class NoAvailableTokensToSwap(
|
||||
val tokenName: String,
|
||||
) : Warning(
|
||||
title = resourceReference(
|
||||
com.tangem.feature.swap.presentation.R.string.warning_express_no_exchangeable_coins_title,
|
||||
),
|
||||
subtitle = resourceReference(
|
||||
id = com.tangem.feature.swap.presentation.R.string.warning_express_no_exchangeable_coins_description,
|
||||
formatArgs = wrappedList(tokenName),
|
||||
),
|
||||
)
|
||||
|
||||
data class NeedReserveToCreateAccount(
|
||||
val receiveAmount: String,
|
||||
val receiveToken: String,
|
||||
) : Warning(
|
||||
title = resourceReference(
|
||||
id = R.string.warning_express_notification_invalid_reserve_amount_title,
|
||||
formatArgs = wrappedList("$receiveAmount $receiveToken"),
|
||||
),
|
||||
subtitle = resourceReference(R.string.send_notification_invalid_reserve_amount_text),
|
||||
)
|
||||
|
||||
data class ReduceAmount(
|
||||
val currencyName: String,
|
||||
val amount: String,
|
||||
val onConfirmClick: () -> Unit,
|
||||
) : Warning(
|
||||
title = resourceReference(R.string.send_notification_high_fee_title),
|
||||
subtitle = resourceReference(
|
||||
R.string.send_notification_high_fee_text,
|
||||
wrappedList(currencyName, amount),
|
||||
),
|
||||
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
|
||||
text = resourceReference(
|
||||
R.string.xtz_withdrawal_message_reduce,
|
||||
wrappedList(amount),
|
||||
),
|
||||
onClick = onConfirmClick,
|
||||
),
|
||||
)
|
||||
|
||||
data class ExpressError(
|
||||
val expressDataError: ExpressDataError,
|
||||
val onConfirmClick: () -> Unit,
|
||||
) : Warning(
|
||||
title = getExpressErrorTitle(expressDataError),
|
||||
subtitle = getExpressErrorMessage(expressDataError),
|
||||
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
|
||||
text = resourceReference(R.string.warning_button_refresh),
|
||||
onClick = onConfirmClick,
|
||||
),
|
||||
)
|
||||
|
||||
data class ExpressGeneralError(
|
||||
val code: Int,
|
||||
val onConfirmClick: () -> Unit,
|
||||
) : Warning(
|
||||
title = TextReference.Res(R.string.warning_express_refresh_required_title),
|
||||
subtitle = TextReference.Res(R.string.express_error_code, wrappedList(code)),
|
||||
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
|
||||
text = TextReference.Res(R.string.warning_button_refresh),
|
||||
onClick = onConfirmClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
sealed class Info(
|
||||
title: TextReference,
|
||||
subtitle: TextReference,
|
||||
iconResId: Int = R.drawable.ic_alert_circle_24,
|
||||
) : NotificationUM.Info(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
iconResId = iconResId,
|
||||
) {
|
||||
data class PermissionNeeded(
|
||||
val providerName: String,
|
||||
val fromTokenSymbol: String,
|
||||
) : Info(
|
||||
title = resourceReference(R.string.express_provider_permission_needed),
|
||||
subtitle = resourceReference(
|
||||
id = R.string.give_permission_swap_subtitle,
|
||||
formatArgs = wrappedList(providerName, fromTokenSymbol),
|
||||
),
|
||||
iconResId = R.drawable.ic_locked_24,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.tangem.feature.swap.models.states.events
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.common.ui.alerts.models.AlertUM
|
||||
|
||||
@Immutable
|
||||
internal sealed class SwapEvent {
|
||||
data class ShowAlert(val alert: AlertUM) : SwapEvent()
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.feature.swap.preview
|
||||
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.feature.swap.domain.models.ui.FeeType
|
||||
import com.tangem.feature.swap.models.states.FeeItemState
|
||||
|
||||
object FeeItemStatePreview {
|
||||
|
||||
val state = FeeItemState.Content(
|
||||
feeType = FeeType.NORMAL,
|
||||
title = stringReference("Fee"),
|
||||
amountCrypto = "1000",
|
||||
symbolCrypto = "MATIC",
|
||||
amountFiatFormatted = "(1000$)",
|
||||
isClickable = false,
|
||||
onClick = {},
|
||||
)
|
||||
|
||||
val stateClickable = state.copy(isClickable = true)
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package com.tangem.feature.swap.preview
|
||||
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.models.SwapSuccessStateHolder
|
||||
|
||||
internal data object SwapSuccessStatePreview {
|
||||
val state = SwapSuccessStateHolder(
|
||||
timestamp = 0L,
|
||||
txUrl = "https://www.google.com/#q=nam",
|
||||
fee = TextReference.Str("1 000 DAI ~ 1 000 MATIC"),
|
||||
providerName = TextReference.Str("1inch"),
|
||||
providerType = TextReference.Str(ExchangeProviderType.DEX.providerName),
|
||||
showStatusButton = false,
|
||||
providerIcon = "",
|
||||
fromTokenAmount = TextReference.Str("1 000 DAI"),
|
||||
toTokenAmount = TextReference.Str("1 000 MATIC"),
|
||||
fromTokenFiatAmount = TextReference.Str("1 000 $"),
|
||||
toTokenFiatAmount = TextReference.Str("1 000 $"),
|
||||
fromTokenIconState = CurrencyIconState.Loading,
|
||||
toTokenIconState = CurrencyIconState.Loading,
|
||||
rate = TextReference.Str("1 000 DAI ~ 1 000 MATIC"),
|
||||
onExploreButtonClick = {},
|
||||
onStatusButtonClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
package com.tangem.feature.swap.router
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
|
||||
internal class SwapRouter(
|
||||
private val router: AppRouter,
|
||||
) {
|
||||
|
||||
var currentScreen by mutableStateOf(SwapNavScreen.Main)
|
||||
private set
|
||||
|
||||
fun openScreen(screen: SwapNavScreen) {
|
||||
currentScreen = screen
|
||||
}
|
||||
|
||||
fun back() {
|
||||
if (currentScreen == SwapNavScreen.SelectToken) {
|
||||
currentScreen = SwapNavScreen.Main
|
||||
} else {
|
||||
val selectTokensIndex = router.stack.getSelectTokensRouteIndexOrNull()
|
||||
|
||||
/*
|
||||
* If select token screen is not in stack, then just pop to previous screen.
|
||||
* Otherwise, pop to previous screen that was before select token screen.
|
||||
*/
|
||||
if (currentScreen == SwapNavScreen.Success && selectTokensIndex != null) {
|
||||
// find previous screen that was before select token
|
||||
val prevRoute = router.stack.getOrNull(index = selectTokensIndex - 1)
|
||||
|
||||
if (prevRoute != null) {
|
||||
router.popTo(prevRoute)
|
||||
} else {
|
||||
router.pop()
|
||||
}
|
||||
} else {
|
||||
router.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency) {
|
||||
val route = AppRoute.CurrencyDetails(
|
||||
userWalletId = userWalletId,
|
||||
currency = currency,
|
||||
)
|
||||
|
||||
if (route in router.stack) {
|
||||
router.popTo(route)
|
||||
} else {
|
||||
router.pop {
|
||||
router.push(route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<AppRoute>.getSelectTokensRouteIndexOrNull(): Int? {
|
||||
return this
|
||||
.indexOfFirst { it::class == AppRoute.SwapCrypto::class }
|
||||
.takeIf { it != -1 }
|
||||
}
|
||||
}
|
||||
|
||||
enum class SwapNavScreen {
|
||||
Main, Success, SelectToken, PromoStories
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
|
||||
import androidx.compose.foundation.text.selection.TextSelectionColors
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.ParagraphIntrinsics
|
||||
import androidx.compose.ui.text.font.createFontFamilyResolver
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
||||
@Suppress("MagicNumber", "LongMethod")
|
||||
@Composable
|
||||
internal fun AutoSizeTextField(
|
||||
textFieldValue: TextFieldValue,
|
||||
focusRequester: FocusRequester,
|
||||
onAmountChange: (String) -> Unit,
|
||||
onFocusChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
|
||||
var shrunkFontSize = TangemTheme.typography.h2.fontSize
|
||||
val calculateIntrinsics = @Composable {
|
||||
ParagraphIntrinsics(
|
||||
text = textFieldValue.text,
|
||||
style = TangemTheme.typography.h2.copy(
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
fontSize = shrunkFontSize,
|
||||
),
|
||||
density = LocalDensity.current,
|
||||
fontFamilyResolver = createFontFamilyResolver(LocalContext.current),
|
||||
)
|
||||
}
|
||||
|
||||
var intrinsics = calculateIntrinsics()
|
||||
with(LocalDensity.current) {
|
||||
while (intrinsics.maxIntrinsicWidth > maxWidth.toPx()) {
|
||||
shrunkFontSize *= 0.9f
|
||||
intrinsics = calculateIntrinsics()
|
||||
}
|
||||
}
|
||||
val customTextSelectionColors = TextSelectionColors(
|
||||
handleColor = Color.Transparent,
|
||||
backgroundColor = TangemTheme.colors.text.secondary.copy(alpha = 0.4f),
|
||||
)
|
||||
CompositionLocalProvider(LocalTextSelectionColors provides customTextSelectionColors) {
|
||||
BasicTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = {
|
||||
onAmountChange.invoke(it.text)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { onFocusChange(it.hasFocus) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
keyboardType = KeyboardType.Decimal,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
decorationBox = { innerTextField ->
|
||||
if (textFieldValue.text.isBlank()) {
|
||||
Text(
|
||||
text = "0",
|
||||
color = TangemTheme.colors.text.disabled,
|
||||
style = TangemTheme.typography.h2,
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
},
|
||||
textStyle = TangemTheme.typography.h2.copy(
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
fontSize = shrunkFontSize,
|
||||
),
|
||||
cursorBrush = SolidColor(TangemTheme.colors.text.primary1),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
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.feature.swap.domain.models.ui.FeeType
|
||||
import com.tangem.feature.swap.models.states.ChooseFeeBottomSheetConfig
|
||||
import com.tangem.feature.swap.models.states.FeeItemState
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Composable
|
||||
fun ChooseFeeBottomSheet(config: TangemBottomSheetConfig) {
|
||||
TangemBottomSheet(
|
||||
config = config,
|
||||
containerColor = TangemTheme.colors.background.tertiary,
|
||||
titleText = resourceReference(R.string.common_fee_selector_title),
|
||||
) { content: ChooseFeeBottomSheetConfig ->
|
||||
ChooseFeeBottomSheetContent(content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChooseFeeBottomSheetContent(content: ChooseFeeBottomSheetConfig) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors.background.tertiary)
|
||||
.padding(bottom = TangemTheme.dimens.spacing8),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(TangemTheme.dimens.spacing16)
|
||||
.clip(TangemTheme.shapes.roundedCornersXMedium)
|
||||
.background(
|
||||
color = TangemTheme.colors.background.action,
|
||||
shape = TangemTheme.shapes.roundedCornersXMedium,
|
||||
),
|
||||
) {
|
||||
FeeItemsBlock(content)
|
||||
}
|
||||
FooterBlock(
|
||||
readMore = content.readMore,
|
||||
readMoreUrl = content.readMoreUrl,
|
||||
onReadMoreClick = content.onReadMoreClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FooterBlock(readMore: TextReference, readMoreUrl: String, onReadMoreClick: (String) -> Unit) {
|
||||
val linkText = readMore.resolveReference()
|
||||
val fullString = stringResourceSafe(R.string.common_fee_selector_footer, linkText)
|
||||
val linkTextPosition = fullString.length - linkText.length
|
||||
val annotatedString = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
|
||||
append(fullString.substring(0, linkTextPosition))
|
||||
}
|
||||
withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) {
|
||||
append(fullString.substring(linkTextPosition, fullString.length))
|
||||
}
|
||||
}
|
||||
|
||||
val click = { i: Int ->
|
||||
val readMoreStyle = requireNotNull(annotatedString.spanStyles.getOrNull(1))
|
||||
if (i in readMoreStyle.start..readMoreStyle.end) {
|
||||
onReadMoreClick(readMoreUrl)
|
||||
}
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = annotatedString,
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
vertical = TangemTheme.dimens.spacing8,
|
||||
horizontal = TangemTheme.dimens.spacing16,
|
||||
),
|
||||
style = TangemTheme.typography.caption2.copy(textAlign = TextAlign.Start),
|
||||
onClick = click,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeeItemsBlock(content: ChooseFeeBottomSheetConfig) {
|
||||
content.feeItems.forEachIndexed { index, feeItem ->
|
||||
val isSelected = feeItem.feeType == content.selectedFee
|
||||
val showDivider = content.feeItems.lastIndex != index
|
||||
val symbol = " ${feeItem.symbolCrypto}"
|
||||
val preDotText = "${feeItem.amountCrypto}$symbol"
|
||||
val postDot = feeItem.amountFiatFormatted
|
||||
val ellipsizeOffset = symbol.length
|
||||
when (feeItem.feeType) {
|
||||
FeeType.NORMAL -> {
|
||||
SelectorRowItem(
|
||||
titleRes = R.string.common_fee_selector_option_market,
|
||||
iconRes = R.drawable.ic_bird_24,
|
||||
preDot = TextReference.Str(preDotText),
|
||||
postDot = TextReference.Str(postDot),
|
||||
ellipsizeOffset = ellipsizeOffset,
|
||||
isSelected = isSelected,
|
||||
onSelect = { content.onSelectFeeType(feeItem.feeType) },
|
||||
showDivider = showDivider,
|
||||
)
|
||||
}
|
||||
FeeType.PRIORITY -> {
|
||||
SelectorRowItem(
|
||||
titleRes = R.string.common_fee_selector_option_fast,
|
||||
iconRes = R.drawable.ic_hare_24,
|
||||
preDot = TextReference.Str(preDotText),
|
||||
postDot = TextReference.Str(postDot),
|
||||
ellipsizeOffset = ellipsizeOffset,
|
||||
isSelected = isSelected,
|
||||
onSelect = { content.onSelectFeeType(feeItem.feeType) },
|
||||
showDivider = showDivider,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
private fun Preview_ChooseFeeBottomSheet() {
|
||||
val feeItems = listOf(
|
||||
FeeItemState.Content(
|
||||
feeType = FeeType.NORMAL,
|
||||
title = stringReference("Fee"),
|
||||
amountCrypto = "1000",
|
||||
symbolCrypto = "MATIC",
|
||||
amountFiatFormatted = "(10$)",
|
||||
isClickable = false,
|
||||
onClick = {},
|
||||
),
|
||||
FeeItemState.Content(
|
||||
feeType = FeeType.PRIORITY,
|
||||
title = stringReference("Fee"),
|
||||
amountCrypto = "2000",
|
||||
symbolCrypto = "MATIC",
|
||||
amountFiatFormatted = "(10$)",
|
||||
isClickable = false,
|
||||
onClick = {},
|
||||
),
|
||||
).toImmutableList()
|
||||
val content = ChooseFeeBottomSheetConfig(
|
||||
selectedFee = FeeType.NORMAL,
|
||||
onSelectFeeType = {},
|
||||
feeItems = feeItems,
|
||||
readMore = stringReference("Read more"),
|
||||
readMoreUrl = "",
|
||||
onReadMoreClick = {},
|
||||
)
|
||||
|
||||
TangemThemePreview {
|
||||
ChooseFeeBottomSheet(
|
||||
config = TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = {},
|
||||
content = content,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
// endregion Preview
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
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.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig
|
||||
import com.tangem.feature.swap.models.states.PercentDifference
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
fun ChooseProviderBottomSheet(config: TangemBottomSheetConfig) {
|
||||
TangemBottomSheet(
|
||||
config = config,
|
||||
containerColor = TangemTheme.colors.background.tertiary,
|
||||
titleText = resourceReference(R.string.express_choose_providers_title),
|
||||
) { content: ChooseProviderBottomSheetConfig ->
|
||||
ChooseProviderBottomSheetContent(content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetConfig) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.express_choose_providers_subtitle),
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.secondary,
|
||||
modifier = Modifier
|
||||
.padding(top = TangemTheme.dimens.spacing10)
|
||||
.padding(horizontal = TangemTheme.dimens.spacing56),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
top = TangemTheme.dimens.spacing16,
|
||||
start = TangemTheme.dimens.spacing16,
|
||||
end = TangemTheme.dimens.spacing16,
|
||||
bottom = TangemTheme.dimens.spacing14,
|
||||
)
|
||||
.background(
|
||||
color = TangemTheme.colors.background.action,
|
||||
shape = TangemTheme.shapes.roundedCornersXMedium,
|
||||
)
|
||||
.clip(shape = TangemTheme.shapes.roundedCornersXMedium),
|
||||
) {
|
||||
content.providers.forEach { provider ->
|
||||
val isSelected = provider.id == content.selectedProviderId
|
||||
ProviderItem(
|
||||
state = provider,
|
||||
isSelected = isSelected,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
enabled = provider.onProviderClick != null,
|
||||
onClick = { provider.onProviderClick?.invoke(provider.id) },
|
||||
)
|
||||
.padding(
|
||||
top = TangemTheme.dimens.spacing12,
|
||||
bottom = TangemTheme.dimens.spacing12,
|
||||
end = TangemTheme.dimens.spacing12,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
painterResource(id = R.drawable.ic_lightning_16),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.icon.informative,
|
||||
)
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.express_more_providers_soon),
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.icon.informative,
|
||||
modifier = Modifier
|
||||
.padding(top = TangemTheme.dimens.spacing6, bottom = TangemTheme.dimens.spacing16)
|
||||
.padding(horizontal = TangemTheme.dimens.spacing56),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
private fun Preview_ChooseProviderBottomSheet() {
|
||||
val providers = persistentListOf(
|
||||
ProviderState.Content(
|
||||
id = "1",
|
||||
name = "1inch",
|
||||
type = "DEX",
|
||||
iconUrl = "",
|
||||
subtitle = stringReference("1 000 000"),
|
||||
additionalBadge = ProviderState.AdditionalBadge.BestTrade,
|
||||
percentLowerThenBest = PercentDifference.Value(-1.0f),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
namePrefix = ProviderState.PrefixType.NONE,
|
||||
onProviderClick = {},
|
||||
),
|
||||
ProviderState.Unavailable(
|
||||
id = "2",
|
||||
name = "1inch",
|
||||
type = "DEX",
|
||||
iconUrl = "",
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
alertText = stringReference("Unavailable"),
|
||||
),
|
||||
)
|
||||
val content = ChooseProviderBottomSheetConfig(
|
||||
selectedProviderId = "1",
|
||||
providers = providers,
|
||||
)
|
||||
TangemThemePreview {
|
||||
ChooseProviderBottomSheet(
|
||||
TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = {},
|
||||
content = content,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
// endregion Preview
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import com.tangem.core.ui.components.inputrow.InputRowDefault
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.feature.swap.models.states.FeeItemState
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import com.tangem.feature.swap.preview.FeeItemStatePreview
|
||||
|
||||
@Composable
|
||||
fun FeeItemBlock(state: FeeItemState) {
|
||||
if (state is FeeItemState.Content) {
|
||||
FeeItem(state = state)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FeeItem(state: FeeItemState.Content) {
|
||||
val description = "${state.amountCrypto} ${state.symbolCrypto} (${state.amountFiatFormatted})"
|
||||
val icon = R.drawable.ic_chevron_right_24.takeIf { state.isClickable }
|
||||
InputRowDefault(
|
||||
title = state.title,
|
||||
text = stringReference(description),
|
||||
iconRes = icon,
|
||||
modifier = Modifier
|
||||
.clip(shape = TangemTheme.shapes.roundedCornersXMedium)
|
||||
.background(color = TangemTheme.colors.background.action)
|
||||
.clickable(
|
||||
enabled = state.isClickable,
|
||||
onClick = state.onClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun FeeItem_Preview(@PreviewParameter(FeeItemPreviewProvider::class) data: FeeItemState.Content) {
|
||||
TangemThemePreview {
|
||||
FeeItem(data)
|
||||
}
|
||||
}
|
||||
|
||||
private class FeeItemPreviewProvider : PreviewParameterProvider<FeeItemState.Content> {
|
||||
override val values: Sequence<FeeItemState.Content>
|
||||
get() = sequenceOf(
|
||||
FeeItemStatePreview.state,
|
||||
FeeItemStatePreview.state.copy(isClickable = true),
|
||||
)
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,461 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.RectangleShimmer
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.utils.GRAY_SCALE_ALPHA
|
||||
import com.tangem.core.ui.utils.GrayscaleColorFilter
|
||||
import com.tangem.feature.swap.models.states.PercentDifference
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
|
||||
/**
|
||||
* UI Item for swap provider wrapped with rounded corners
|
||||
*
|
||||
* https://www.figma.com/file/Vs6SkVsFnUPsSCNwlnVf5U/Android-%E2%80%93-UI?type=design&node-id=7856-41909&mode=design&t=vo7dyElitnzSPSW3-4
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun ProviderItemBlock(state: ProviderState, modifier: Modifier = Modifier) {
|
||||
if (state !is ProviderState.Empty) {
|
||||
ProviderItem(
|
||||
state = state,
|
||||
modifier = modifier
|
||||
.clip(shape = TangemTheme.shapes.roundedCornersXMedium)
|
||||
.background(
|
||||
color = TangemTheme.colors.background.action,
|
||||
shape = TangemTheme.shapes.roundedCornersXMedium,
|
||||
)
|
||||
.clickable(
|
||||
enabled = state.onProviderClick != null,
|
||||
onClick = { state.onProviderClick?.invoke(state.id) },
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = TangemTheme.dimens.spacing12),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProviderItem(state: ProviderState, modifier: Modifier = Modifier, isSelected: Boolean = false) {
|
||||
when (state) {
|
||||
is ProviderState.Content -> {
|
||||
ProviderContentState(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
isSelected = isSelected,
|
||||
)
|
||||
}
|
||||
is ProviderState.Loading -> {
|
||||
ProviderLoadingState(
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
is ProviderState.Unavailable -> {
|
||||
ProviderUnavailableState(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
isSelected = isSelected,
|
||||
)
|
||||
}
|
||||
is ProviderState.Empty -> {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Be careful when will replace with InputRowBestRate, because RecommendedBadge was added
|
||||
@Deprecated("Replace with InputRowBestRate")
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun ProviderContentState(
|
||||
state: ProviderState.Content,
|
||||
modifier: Modifier = Modifier,
|
||||
isSelected: Boolean = false,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
Row {
|
||||
SubcomposeAsyncImage(
|
||||
modifier = Modifier
|
||||
.padding(start = TangemTheme.dimens.spacing12)
|
||||
.size(size = TangemTheme.dimens.size40)
|
||||
.clip(TangemTheme.shapes.roundedCorners8),
|
||||
model = ImageRequest.Builder(context = LocalContext.current).data(state.iconUrl)
|
||||
.crossfade(enable = true).allowHardware(false).build(),
|
||||
loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
|
||||
error = {
|
||||
ErrorProviderIcon(
|
||||
Modifier.size(
|
||||
size = TangemTheme.dimens.size40,
|
||||
),
|
||||
)
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(start = TangemTheme.dimens.spacing12),
|
||||
) {
|
||||
Row {
|
||||
if (state.namePrefix == ProviderState.PrefixType.PROVIDED_BY) {
|
||||
Text(
|
||||
text = stringResourceSafe(id = R.string.express_by_provider),
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
modifier = Modifier.padding(end = TangemTheme.dimens.spacing4),
|
||||
)
|
||||
}
|
||||
AnimatedContent(targetState = state.name, label = "") {
|
||||
Text(
|
||||
text = it,
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
}
|
||||
AnimatedContent(targetState = state.type, label = "") {
|
||||
Text(
|
||||
text = it,
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
|
||||
)
|
||||
}
|
||||
val badgeModifier = Modifier.padding(start = TangemTheme.dimens.spacing4)
|
||||
when (state.additionalBadge) {
|
||||
ProviderState.AdditionalBadge.BestTrade -> BestTradeItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.PermissionRequired -> PermissionBadgeItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.Recommended -> RecommendedItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.Empty -> Unit
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(
|
||||
top = TangemTheme.dimens.spacing6,
|
||||
end = TangemTheme.dimens.spacing56,
|
||||
),
|
||||
) {
|
||||
AnimatedContent(targetState = state.subtitle, label = "") {
|
||||
Text(
|
||||
text = it.resolveReference(),
|
||||
style = TangemTheme.typography.body2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (state.percentLowerThenBest is PercentDifference.Value &&
|
||||
state.percentLowerThenBest.value != 0f
|
||||
) {
|
||||
val textColor = if (state.percentLowerThenBest.value > 0) {
|
||||
TangemTheme.colors.icon.accent
|
||||
} else {
|
||||
TangemTheme.colors.text.warning
|
||||
}
|
||||
AnimatedContent(targetState = state.percentLowerThenBest.value, label = "") {
|
||||
Text(
|
||||
text = if (it > 0) "+$it%" else "$it%",
|
||||
style = TangemTheme.typography.body2,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProviderChevron(selectionType = state.selectionType, isSelected = isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderUnavailableState(
|
||||
state: ProviderState.Unavailable,
|
||||
isSelected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
Row {
|
||||
val (alpha, colorFilter) = GRAY_SCALE_ALPHA to GrayscaleColorFilter
|
||||
SubcomposeAsyncImage(
|
||||
modifier = Modifier
|
||||
.padding(start = TangemTheme.dimens.spacing12)
|
||||
.size(size = TangemTheme.dimens.size40)
|
||||
.clip(TangemTheme.shapes.roundedCorners8),
|
||||
model = ImageRequest.Builder(context = LocalContext.current).data(state.iconUrl)
|
||||
.crossfade(enable = true).allowHardware(false).build(),
|
||||
loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
|
||||
error = {
|
||||
ErrorProviderIcon(
|
||||
Modifier.size(
|
||||
size = TangemTheme.dimens.size40,
|
||||
),
|
||||
)
|
||||
},
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
contentDescription = null,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(start = TangemTheme.dimens.spacing12),
|
||||
) {
|
||||
Row {
|
||||
AnimatedContent(targetState = state.name, label = "") {
|
||||
Text(
|
||||
text = it,
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
}
|
||||
AnimatedContent(targetState = state.type, label = "") {
|
||||
Text(
|
||||
text = it,
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedContent(targetState = state.alertText, label = "") {
|
||||
Text(
|
||||
text = it.resolveReference(),
|
||||
style = TangemTheme.typography.body2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
modifier = Modifier.padding(top = TangemTheme.dimens.spacing6),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProviderChevron(selectionType = state.selectionType, isSelected = isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderLoadingState(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.express_provider),
|
||||
style = TangemTheme.typography.subtitle2,
|
||||
color = TangemTheme.colors.text.secondary,
|
||||
modifier = Modifier.padding(start = TangemTheme.dimens.spacing12),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(
|
||||
top = TangemTheme.dimens.spacing8,
|
||||
start = TangemTheme.dimens.spacing12,
|
||||
),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.size(TangemTheme.dimens.size16)
|
||||
.align(Alignment.CenterVertically),
|
||||
color = TangemTheme.colors.icon.informative,
|
||||
strokeWidth = TangemTheme.dimens.size2,
|
||||
)
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.express_fetch_best_rates),
|
||||
style = TangemTheme.typography.body2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_chevron_right_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.align(alignment = Alignment.CenterEnd)
|
||||
.padding(end = TangemTheme.dimens.spacing12),
|
||||
tint = TangemTheme.colors.icon.informative,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.ProviderChevron(selectionType: ProviderState.SelectionType, isSelected: Boolean) {
|
||||
when (selectionType) {
|
||||
ProviderState.SelectionType.NONE -> { /* no-op */
|
||||
}
|
||||
ProviderState.SelectionType.CLICK -> {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_chevron_right_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.align(alignment = Alignment.CenterEnd)
|
||||
.padding(end = TangemTheme.dimens.spacing12),
|
||||
tint = TangemTheme.colors.icon.informative,
|
||||
)
|
||||
}
|
||||
ProviderState.SelectionType.SELECT -> {
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_check_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.align(alignment = Alignment.CenterEnd)
|
||||
.padding(end = TangemTheme.dimens.spacing12),
|
||||
tint = TangemTheme.colors.icon.accent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorProviderIcon(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.background(
|
||||
color = TangemTheme.colors.background.secondary,
|
||||
shape = TangemTheme.shapes.roundedCorners8,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.matchParentSize(),
|
||||
painter = painterResource(id = R.drawable.ic_custom_token_44),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BestTradeItem(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.background(
|
||||
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
|
||||
shape = TangemTheme.shapes.roundedCornersLarge,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.express_provider_best_rate),
|
||||
style = TangemTheme.typography.caption1,
|
||||
color = TangemTheme.colors.icon.accent,
|
||||
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionBadgeItem(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.background(
|
||||
color = TangemTheme.colors.background.secondary,
|
||||
shape = TangemTheme.shapes.roundedCornersLarge,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(id = R.string.express_provider_permission_needed),
|
||||
style = TangemTheme.typography.caption1,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecommendedItem(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.background(
|
||||
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
|
||||
shape = TangemTheme.shapes.roundedCornersLarge,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(id = R.string.express_provider_recommended),
|
||||
style = TangemTheme.typography.caption1,
|
||||
color = TangemTheme.colors.icon.accent,
|
||||
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun ProviderItemPreview(
|
||||
@PreviewParameter(ProviderItemParameterProvider::class) state: Pair<ProviderState, Boolean>,
|
||||
) {
|
||||
TangemThemePreview {
|
||||
ProviderItem(
|
||||
modifier = Modifier.background(TangemTheme.colors.background.action),
|
||||
state = state.first,
|
||||
isSelected = state.second,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class ProviderItemParameterProvider : CollectionPreviewParameterProvider<Pair<ProviderState, Boolean>>(
|
||||
collection = buildList {
|
||||
val contentState = ProviderState.Content(
|
||||
id = "1",
|
||||
name = "1inch",
|
||||
type = "DEX",
|
||||
iconUrl = "",
|
||||
subtitle = stringReference(value = "0,64554846 DAI ≈ 1 MATIC"),
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
percentLowerThenBest = PercentDifference.Value(value = 12.0f),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
namePrefix = ProviderState.PrefixType.PROVIDED_BY,
|
||||
onProviderClick = {},
|
||||
)
|
||||
val contentState2 = contentState.copy(
|
||||
subtitle = stringReference(value = "1 132,46 MATIC"),
|
||||
additionalBadge = ProviderState.AdditionalBadge.PermissionRequired,
|
||||
percentLowerThenBest = PercentDifference.Value(value = 5f),
|
||||
)
|
||||
val unavailableState = ProviderState.Unavailable(
|
||||
id = "1",
|
||||
name = "1inch",
|
||||
type = "DEX",
|
||||
iconUrl = "",
|
||||
alertText = stringReference(value = "Not available"),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
onProviderClick = {},
|
||||
)
|
||||
val loadingState = ProviderState.Loading()
|
||||
|
||||
add(contentState to true)
|
||||
add(contentState to false)
|
||||
|
||||
add(contentState2 to true)
|
||||
add(contentState2 to false)
|
||||
|
||||
add(unavailableState to true)
|
||||
add(unavailableState to false)
|
||||
|
||||
add(loadingState to true)
|
||||
add(loadingState to false)
|
||||
},
|
||||
)
|
||||
// endregion Preview
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,61 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import com.tangem.common.ui.alerts.models.AlertUM
|
||||
import com.tangem.core.ui.components.BasicDialog
|
||||
import com.tangem.core.ui.components.DialogButtonUM
|
||||
import com.tangem.core.ui.event.EventEffect
|
||||
import com.tangem.core.ui.event.StateEvent
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.feature.swap.models.states.events.SwapEvent
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
|
||||
@Composable
|
||||
internal fun SwapEventEffect(event: StateEvent<SwapEvent>) {
|
||||
var alertConfig by remember { mutableStateOf<AlertUM?>(value = null) }
|
||||
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
LaunchedEffect(key1 = alertConfig) {
|
||||
keyboardController?.hide()
|
||||
}
|
||||
|
||||
alertConfig?.let {
|
||||
SwapAlert(state = it, onDismiss = { alertConfig = null })
|
||||
}
|
||||
|
||||
EventEffect(
|
||||
event = event,
|
||||
onTrigger = { value ->
|
||||
when (value) {
|
||||
is SwapEvent.ShowAlert -> {
|
||||
alertConfig = value.alert
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SwapAlert(state: AlertUM, onDismiss: () -> Unit) {
|
||||
val confirmButton = DialogButtonUM(
|
||||
title = state.confirmButtonText.resolveReference(),
|
||||
onClick = {
|
||||
state.onConfirmClick?.invoke()
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
val dismissButton = DialogButtonUM(
|
||||
title = stringResourceSafe(id = R.string.common_cancel),
|
||||
onClick = onDismiss,
|
||||
)
|
||||
|
||||
BasicDialog(
|
||||
message = state.message.resolveReference(),
|
||||
confirmButton = confirmButton,
|
||||
onDismissDialog = onDismiss,
|
||||
title = state.title?.resolveReference(),
|
||||
dismissButton = dismissButton,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
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 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.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) {
|
||||
BackHandler(onBack = stateHolder.onBackClicked)
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.systemBarsPadding(),
|
||||
topBar = {
|
||||
AppBarWithBackButton(
|
||||
text = stringResourceSafe(R.string.common_swap),
|
||||
onBackClick = stateHolder.onBackClicked,
|
||||
iconRes = R.drawable.ic_close_24,
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsetsZero,
|
||||
containerColor = TangemTheme.colors.background.secondary,
|
||||
) { scaffoldPaddings ->
|
||||
|
||||
SwapScreenContent(
|
||||
state = stateHolder,
|
||||
modifier = Modifier.padding(scaffoldPaddings),
|
||||
)
|
||||
|
||||
if (stateHolder.bottomSheetConfig != null) {
|
||||
val config = stateHolder.bottomSheetConfig
|
||||
|
||||
when (config.content) {
|
||||
is GiveTxPermissionBottomSheetConfig -> GiveTxPermissionBottomSheet(config = config)
|
||||
is ChooseProviderBottomSheetConfig -> ChooseProviderBottomSheet(config = config)
|
||||
is ChooseFeeBottomSheetConfig -> ChooseFeeBottomSheet(config = config)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,476 @@
|
|||
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.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.core.ui.components.*
|
||||
import com.tangem.core.ui.components.notifications.Notification
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.feature.swap.domain.models.ui.FeeType
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
import com.tangem.feature.swap.models.*
|
||||
import com.tangem.feature.swap.models.states.FeeItemState
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
import com.tangem.feature.swap.models.states.SwapNotificationUM
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modifier) {
|
||||
val keyboard by keyboardAsState()
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(color = TangemTheme.colors.background.secondary),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(
|
||||
start = TangemTheme.dimens.spacing16,
|
||||
end = TangemTheme.dimens.spacing16,
|
||||
top = TangemTheme.dimens.spacing8,
|
||||
bottom = TangemTheme.dimens.spacing32,
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
|
||||
) {
|
||||
MainInfo(state)
|
||||
|
||||
ProviderItemBlock(state = state.providerState)
|
||||
|
||||
FeeItemBlock(state = state.fee)
|
||||
|
||||
if (state.notifications.isNotEmpty()) SwapNotifications(notifications = state.notifications)
|
||||
|
||||
MainButton(state = state, onPermissionWarningClick = state.onShowPermissionBottomSheet)
|
||||
|
||||
if (state.tosState != null && state.providerState !is ProviderState.Empty) {
|
||||
ProviderTos(
|
||||
tosState = state.tosState,
|
||||
modifier = Modifier
|
||||
.padding(top = TangemTheme.dimens.spacing16),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.shouldShowMaxAmount && keyboard is Keyboard.Opened) {
|
||||
Text(
|
||||
text = stringResourceSafe(id = R.string.send_max_amount_label),
|
||||
style = TangemTheme.typography.button,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.imePadding()
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors.button.secondary)
|
||||
.clickable { state.onMaxAmountSelected?.invoke() }
|
||||
.padding(
|
||||
horizontal = TangemTheme.dimens.spacing14,
|
||||
vertical = TangemTheme.dimens.spacing16,
|
||||
),
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
|
||||
SwapEventEffect(
|
||||
event = state.event,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainInfo(state: SwapStateHolder) {
|
||||
ConstraintLayout(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
val (topCard, bottomCard, button) = createRefs()
|
||||
val priceImpact = state.priceImpact
|
||||
TransactionCardData(
|
||||
priceImpact = priceImpact,
|
||||
swapCardState = state.sendCardData,
|
||||
modifier = Modifier.constrainAs(topCard) {
|
||||
top.linkTo(parent.top)
|
||||
},
|
||||
onSelectTokenClick = state.onSelectTokenClick,
|
||||
)
|
||||
val marginCard = TangemTheme.dimens.spacing12
|
||||
TransactionCardData(
|
||||
priceImpact = priceImpact,
|
||||
swapCardState = state.receiveCardData,
|
||||
modifier = Modifier.constrainAs(bottomCard) {
|
||||
top.linkTo(topCard.bottom, margin = marginCard)
|
||||
},
|
||||
onSelectTokenClick = state.onSelectTokenClick,
|
||||
)
|
||||
val marginButton = TangemTheme.dimens.spacing30
|
||||
SwapButton(
|
||||
state,
|
||||
modifier = Modifier.constrainAs(button) {
|
||||
bottom.linkTo(topCard.bottom, margin = -marginButton)
|
||||
start.linkTo(topCard.start)
|
||||
end.linkTo(topCard.end)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransactionCardData(
|
||||
priceImpact: PriceImpact,
|
||||
swapCardState: SwapCardState,
|
||||
onSelectTokenClick: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (swapCardState) {
|
||||
is SwapCardState.Empty -> {
|
||||
TransactionCardEmpty(
|
||||
type = swapCardState.type,
|
||||
amountEquivalent = swapCardState.amountEquivalent,
|
||||
textFieldValue = swapCardState.amountTextFieldValue,
|
||||
onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
is SwapCardState.SwapCardData -> {
|
||||
TransactionCard(
|
||||
type = swapCardState.type,
|
||||
balance = swapCardState.balance.orMaskWithStars(swapCardState.isBalanceHidden),
|
||||
textFieldValue = swapCardState.amountTextFieldValue,
|
||||
amountEquivalent = swapCardState.amountEquivalent,
|
||||
tokenIconUrl = swapCardState.tokenIconUrl ?: "",
|
||||
tokenCurrency = swapCardState.tokenCurrency,
|
||||
priceImpact = priceImpact,
|
||||
networkIconRes = if (swapCardState.isNotNativeToken) swapCardState.networkIconRes else null,
|
||||
iconPlaceholder = swapCardState.coinId?.let {
|
||||
getActiveIconResByCoinId(it)
|
||||
},
|
||||
onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderTos(tosState: TosState, modifier: Modifier = Modifier) {
|
||||
val tos = tosState.tosLink
|
||||
val policy = tosState.policyLink
|
||||
if (tos == null && policy == null) return
|
||||
|
||||
val (annotatedString, click) = getAnnotatedStringForLegalsWithClick(tos, policy)
|
||||
|
||||
ClickableText(
|
||||
text = annotatedString,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = TangemTheme.dimens.spacing54),
|
||||
style = TangemTheme.typography.caption2.copy(textAlign = TextAlign.Center),
|
||||
onClick = click,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getAnnotatedStringForLegalsWithClick(
|
||||
tos: LegalState?,
|
||||
policy: LegalState?,
|
||||
): Pair<AnnotatedString, (Int) -> Unit> {
|
||||
return if (tos != null && policy != null) {
|
||||
val tosTitle = tos.title.resolveReference()
|
||||
val policyTitle = policy.title.resolveReference()
|
||||
val fullString = stringResourceSafe(id = R.string.express_legal_two_placeholders, tosTitle, policyTitle)
|
||||
val tosIndex = fullString.indexOf(tosTitle)
|
||||
val policyIndex = fullString.indexOf(policyTitle)
|
||||
val string = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
|
||||
append(fullString.substring(0, tosIndex))
|
||||
}
|
||||
withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) {
|
||||
append(fullString.substring(tosIndex, tosIndex + tosTitle.length))
|
||||
}
|
||||
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
|
||||
append(fullString.substring(tosIndex + tosTitle.length, policyIndex))
|
||||
}
|
||||
withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) {
|
||||
append(fullString.substring(policyIndex, policyIndex + policyTitle.length))
|
||||
}
|
||||
}
|
||||
val click = { i: Int ->
|
||||
val tosStyle = requireNotNull(string.spanStyles.getOrNull(1))
|
||||
if (i in tosStyle.start..tosStyle.end) {
|
||||
tos.onClick(tos.link)
|
||||
}
|
||||
val policyStyle = requireNotNull(string.spanStyles.lastOrNull())
|
||||
if (i in policyStyle.start..policyStyle.end) {
|
||||
policy.onClick(policy.link)
|
||||
}
|
||||
}
|
||||
string to click
|
||||
} else {
|
||||
val legal = requireNotNull(tos ?: policy) { "tos or policy must not be null" }
|
||||
val legalTitle = legal.title
|
||||
.resolveReference()
|
||||
val fullString = stringResourceSafe(id = R.string.express_legal_one_placeholder, legal)
|
||||
val legalIndex = fullString.indexOf(legalTitle)
|
||||
val string = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
|
||||
append(fullString.substring(0, legalIndex))
|
||||
}
|
||||
withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) {
|
||||
append(fullString.substring(legalIndex, legalIndex + legalTitle.length))
|
||||
}
|
||||
}
|
||||
val click = { i: Int ->
|
||||
val legalStyle = requireNotNull(string.spanStyles.lastOrNull())
|
||||
if (i in legalStyle.start..legalStyle.end) {
|
||||
legal.onClick(legal.link)
|
||||
}
|
||||
}
|
||||
string to click
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwapButton(state: SwapStateHolder, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(TangemTheme.dimens.size48)
|
||||
.shadow(elevation = 2.dp, shape = CircleShape)
|
||||
.background(TangemTheme.colors.background.action)
|
||||
.clickable(
|
||||
enabled = state.changeCardsButtonState == ChangeCardsButtonState.ENABLED,
|
||||
onClick = state.onChangeCardsClicked,
|
||||
indication = ripple(),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
),
|
||||
) {
|
||||
when (state.changeCardsButtonState) {
|
||||
ChangeCardsButtonState.UPDATE_IN_PROGRESS -> {
|
||||
Box {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
|
||||
color = TangemTheme.colors.icon.primary1,
|
||||
strokeWidth = TangemTheme.dimens.size2,
|
||||
)
|
||||
}
|
||||
}
|
||||
ChangeCardsButtonState.ENABLED -> {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_exchange_vertical_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.text.primary1,
|
||||
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
|
||||
)
|
||||
}
|
||||
ChangeCardsButtonState.DISABLED -> {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_exchange_vertical_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.text.disabled,
|
||||
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
private fun SwapNotifications(notifications: List<NotificationUM>) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(color = TangemTheme.colors.background.secondary)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
notifications.forEach { notification ->
|
||||
when (notification) {
|
||||
is SwapNotificationUM.Error.ApprovalInProgressWarning,
|
||||
is SwapNotificationUM.Error.TransactionInProgressWarning,
|
||||
-> {
|
||||
CardWithIcon(
|
||||
title = notification.config.title?.resolveReference().orEmpty(),
|
||||
description = notification.config.subtitle.resolveReference(),
|
||||
icon = {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.size(TangemTheme.dimens.size16),
|
||||
color = TangemTheme.colors.icon.primary1,
|
||||
strokeWidth = TangemTheme.dimens.size2,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Notification(
|
||||
config = notification.config,
|
||||
iconTint = when (notification) {
|
||||
is SwapNotificationUM.Error.UnableToCoverFeeWarning,
|
||||
is NotificationUM.Error.TokenExceedsBalance,
|
||||
is NotificationUM.Error.ExceedsBalance,
|
||||
is NotificationUM.Info,
|
||||
is NotificationUM.Warning,
|
||||
-> null
|
||||
is SwapNotificationUM.Error.GenericError,
|
||||
is NotificationUM.Error,
|
||||
-> TangemTheme.colors.icon.warning
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
SpacerH8()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainButton(state: SwapStateHolder, onPermissionWarningClick: () -> Unit) {
|
||||
// order is important
|
||||
when {
|
||||
state.isInsufficientFunds -> {
|
||||
PrimaryButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResourceSafe(id = R.string.swapping_insufficient_funds),
|
||||
enabled = false,
|
||||
onClick = state.swapButton.onClick,
|
||||
)
|
||||
}
|
||||
state.notifications.any { it is SwapNotificationUM.Info.PermissionNeeded } -> {
|
||||
PrimaryButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResourceSafe(id = R.string.give_permission_title),
|
||||
enabled = true,
|
||||
onClick = onPermissionWarningClick,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
PrimaryButtonIconEnd(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResourceSafe(id = R.string.swapping_swap_action),
|
||||
iconResId = R.drawable.ic_tangem_24,
|
||||
enabled = state.swapButton.enabled,
|
||||
onClick = state.swapButton.onClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// region preview
|
||||
|
||||
private val sendCard = SwapCardState.SwapCardData(
|
||||
type = TransactionCardType.Inputtable({}, {}, TransactionCardType.InputError.Empty),
|
||||
amountTextFieldValue = TextFieldValue(),
|
||||
amountEquivalent = "1 000 000",
|
||||
tokenIconUrl = "",
|
||||
tokenCurrency = "DAI",
|
||||
isNotNativeToken = true,
|
||||
canSelectAnotherToken = false,
|
||||
balance = "123",
|
||||
coinId = "",
|
||||
token = null,
|
||||
networkIconRes = R.drawable.img_polygon_22,
|
||||
isBalanceHidden = false,
|
||||
)
|
||||
|
||||
private val receiveCard = SwapCardState.SwapCardData(
|
||||
type = TransactionCardType.ReadOnly(),
|
||||
amountTextFieldValue = TextFieldValue(),
|
||||
amountEquivalent = "1 000 000",
|
||||
tokenIconUrl = "",
|
||||
tokenCurrency = "DAI",
|
||||
isNotNativeToken = true,
|
||||
canSelectAnotherToken = true,
|
||||
balance = "33333",
|
||||
coinId = "",
|
||||
token = null,
|
||||
networkIconRes = R.drawable.img_polygon_22,
|
||||
isBalanceHidden = false,
|
||||
)
|
||||
|
||||
private val state = SwapStateHolder(
|
||||
sendCardData = sendCard,
|
||||
receiveCardData = receiveCard,
|
||||
fee = FeeItemState.Content(
|
||||
feeType = FeeType.NORMAL,
|
||||
title = stringReference("Fee"),
|
||||
amountCrypto = "100",
|
||||
symbolCrypto = "1000",
|
||||
amountFiatFormatted = "(100)",
|
||||
isClickable = true,
|
||||
onClick = {},
|
||||
),
|
||||
notifications = persistentListOf(
|
||||
SwapNotificationUM.Info.PermissionNeeded(
|
||||
providerName = "Provider",
|
||||
fromTokenSymbol = "POL",
|
||||
),
|
||||
SwapNotificationUM.Warning.NoAvailableTokensToSwap("POLYGON"),
|
||||
),
|
||||
swapButton = SwapButton(enabled = true, onClick = {}),
|
||||
onRefresh = {},
|
||||
onBackClicked = {},
|
||||
onChangeCardsClicked = {},
|
||||
permissionState = GiveTxPermissionState.InProgress,
|
||||
blockchainId = "POLYGON",
|
||||
providerState = ProviderState.Loading(),
|
||||
priceImpact = PriceImpact.Empty(),
|
||||
shouldShowMaxAmount = true,
|
||||
isInsufficientFunds = false,
|
||||
onSuccess = {},
|
||||
onSelectTokenClick = {},
|
||||
tosState = TosState(
|
||||
tosLink = LegalState(
|
||||
title = stringReference("Terms of Use"),
|
||||
link = "https://tangem.com",
|
||||
onClick = {},
|
||||
),
|
||||
policyLink = LegalState(
|
||||
title = stringReference("Privacy Policy"),
|
||||
link = "https://tangem.com",
|
||||
onClick = {},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@Preview(widthDp = 360, showBackground = true)
|
||||
@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun SwapScreenContentPreview() {
|
||||
TangemThemePreview {
|
||||
SwapScreenContent(state = state, modifier = Modifier)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion preview
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
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.itemsIndexed
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.tangem.core.ui.components.SpacerH12
|
||||
import com.tangem.core.ui.components.SpacerW2
|
||||
import com.tangem.core.ui.components.appbar.ExpandableSearchView
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
|
||||
import com.tangem.core.ui.extensions.orMaskWithStars
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
|
||||
import com.tangem.feature.swap.models.TokenBalanceData
|
||||
import com.tangem.feature.swap.models.TokenToSelectState
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Composable
|
||||
fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) {
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.systemBarsPadding()
|
||||
.background(color = TangemTheme.colors.background.secondary),
|
||||
content = { padding ->
|
||||
val modifier = Modifier.padding(padding)
|
||||
when {
|
||||
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && state.afterSearch -> {
|
||||
TokensNotFound(modifier)
|
||||
}
|
||||
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && !state.afterSearch -> {
|
||||
EmptyTokensList(modifier)
|
||||
}
|
||||
else -> {
|
||||
ListOfTokens(state = state, modifier = modifier)
|
||||
}
|
||||
}
|
||||
},
|
||||
topBar = {
|
||||
ExpandableSearchView(
|
||||
title = stringResourceSafe(R.string.swapping_token_list_title),
|
||||
onBackClick = onBack,
|
||||
placeholderSearchText = stringResourceSafe(id = R.string.common_search_tokens),
|
||||
onSearchChange = state.onSearchEntered,
|
||||
onSearchDisplayClose = { state.onSearchEntered("") },
|
||||
subtitle = stringResourceSafe(id = R.string.express_exchange_token_list_subtitle),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyTokensList(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(TangemTheme.colors.background.secondary)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Column(modifier = Modifier.align(Alignment.Center)) {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.size(TangemTheme.dimens.size64)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
painter = painterResource(id = R.drawable.ic_no_token_44),
|
||||
colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive),
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(top = TangemTheme.dimens.spacing16)
|
||||
.padding(horizontal = TangemTheme.dimens.spacing30)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
text = stringResourceSafe(id = R.string.exchange_tokens_empty_tokens),
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TokensNotFound(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(TangemTheme.colors.background.secondary)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(top = TangemTheme.dimens.spacing32)
|
||||
.padding(horizontal = TangemTheme.dimens.spacing30)
|
||||
.align(Alignment.TopCenter),
|
||||
text = stringResourceSafe(id = R.string.express_token_list_empty_search),
|
||||
style = TangemTheme.typography.subtitle1,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = Modifier) {
|
||||
val screenBackgroundColor = TangemTheme.colors.background.secondary
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.background(color = screenBackgroundColor)
|
||||
.fillMaxSize()
|
||||
.imePadding(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
tokensToSelectItems(state.availableTokens, state.onTokenSelected)
|
||||
|
||||
item { SpacerH12() }
|
||||
|
||||
tokensToSelectItems(state.unavailableTokens, state.onTokenSelected)
|
||||
|
||||
item { SpacerH12() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.tokensToSelectItems(
|
||||
items: ImmutableList<TokenToSelectState>,
|
||||
onTokenClick: (String) -> Unit,
|
||||
) {
|
||||
itemsIndexed(items = items) { index, item ->
|
||||
when (item) {
|
||||
is TokenToSelectState.Title -> {
|
||||
TitleHeader(
|
||||
item = item,
|
||||
modifier = Modifier.roundedShapeItemDecoration(
|
||||
currentIndex = index,
|
||||
lastIndex = items.lastIndex,
|
||||
),
|
||||
)
|
||||
}
|
||||
is TokenToSelectState.TokenToSelect -> {
|
||||
TokenItem(
|
||||
token = item,
|
||||
modifier = Modifier
|
||||
.roundedShapeItemDecoration(
|
||||
currentIndex = index,
|
||||
lastIndex = items.lastIndex,
|
||||
)
|
||||
.background(TangemTheme.colors.background.action),
|
||||
onTokenClick = {
|
||||
onTokenClick(item.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TitleHeader(item: TokenToSelectState.Title, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors.background.action),
|
||||
) {
|
||||
Text(
|
||||
text = item.title.resolveReference().uppercase(),
|
||||
style = TangemTheme.typography.overline,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
top = TangemTheme.dimens.spacing16,
|
||||
start = TangemTheme.dimens.spacing16,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun TokenItem(
|
||||
token: TokenToSelectState.TokenToSelect,
|
||||
onTokenClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(TangemTheme.dimens.size72)
|
||||
.clickable(
|
||||
enabled = token.available,
|
||||
onClick = onTokenClick,
|
||||
)
|
||||
.padding(
|
||||
vertical = TangemTheme.dimens.spacing14,
|
||||
horizontal = TangemTheme.dimens.spacing16,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CurrencyIcon(
|
||||
state = token.tokenIcon,
|
||||
shouldDisplayNetwork = true,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding(start = TangemTheme.dimens.spacing12),
|
||||
) {
|
||||
Text(
|
||||
text = token.name,
|
||||
style = TangemTheme.typography.subtitle1,
|
||||
color = if (token.available) {
|
||||
TangemTheme.colors.text.primary1
|
||||
} else {
|
||||
TangemTheme.colors.text.tertiary
|
||||
},
|
||||
)
|
||||
SpacerW2()
|
||||
Text(
|
||||
text = token.symbol,
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (token.addedTokenBalanceData != null) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(start = TangemTheme.dimens.spacing8),
|
||||
) {
|
||||
Text(
|
||||
text = token.addedTokenBalanceData.amountEquivalent.orEmpty().orMaskWithStars(
|
||||
maskWithStars = token.addedTokenBalanceData.isBalanceHidden &&
|
||||
!token.addedTokenBalanceData.amountEquivalent.isNullOrEmpty(),
|
||||
),
|
||||
style = TangemTheme.typography.subtitle1,
|
||||
color = if (token.available) {
|
||||
TangemTheme.colors.text.primary1
|
||||
} else {
|
||||
TangemTheme.colors.text.tertiary
|
||||
},
|
||||
)
|
||||
SpacerW2()
|
||||
Text(
|
||||
text = token.addedTokenBalanceData.amount.orEmpty().orMaskWithStars(
|
||||
maskWithStars = token.addedTokenBalanceData.isBalanceHidden &&
|
||||
!token.addedTokenBalanceData.amount.isNullOrEmpty(),
|
||||
),
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val token = TokenToSelectState.TokenToSelect(
|
||||
tokenIcon = CurrencyIconState.CoinIcon(
|
||||
url = "",
|
||||
fallbackResId = 0,
|
||||
isGrayscale = false,
|
||||
showCustomBadge = false,
|
||||
),
|
||||
id = "",
|
||||
name = "USDC",
|
||||
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(),
|
||||
afterSearch = false,
|
||||
onSearchEntered = {},
|
||||
onTokenSelected = {},
|
||||
),
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun EmptyTokensListPreview() {
|
||||
TangemThemePreview {
|
||||
EmptyTokensList()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.tangem.core.ui.components.*
|
||||
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.inputrow.InputRowBestRate
|
||||
import com.tangem.core.ui.components.inputrow.InputRowDefault
|
||||
import com.tangem.core.ui.components.inputrow.InputRowImage
|
||||
import com.tangem.core.ui.components.transactions.TransactionDoneTitle
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.utils.DateTimeFormatters
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun SwapSuccessScreen(state: SwapSuccessStateHolder, onBack: () -> Unit) {
|
||||
Scaffold(
|
||||
modifier = Modifier.systemBarsPadding(),
|
||||
containerColor = TangemTheme.colors.background.secondary,
|
||||
content = { padding ->
|
||||
SwapSuccessScreenContent(padding = padding, state = state)
|
||||
},
|
||||
topBar = {
|
||||
AppBarWithBackButton(
|
||||
onBackClick = onBack,
|
||||
iconRes = R.drawable.ic_close_24,
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
SwapSuccessScreenButtons(
|
||||
textRes = R.string.common_close,
|
||||
txUrl = state.txUrl,
|
||||
showStatusButton = state.showStatusButton,
|
||||
onExploreClick = state.onExploreButtonClick,
|
||||
onStatusClick = state.onStatusButtonClick,
|
||||
onDoneClick = onBack,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: PaddingValues) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.background(TangemTheme.colors.background.secondary)
|
||||
.padding(horizontal = TangemTheme.dimens.spacing16),
|
||||
) {
|
||||
TransactionDoneTitle(
|
||||
title = resourceReference(R.string.common_in_progress),
|
||||
subtitle = resourceReference(
|
||||
R.string.send_date_format,
|
||||
wrappedList(
|
||||
state.timestamp.toTimeFormat(DateTimeFormatters.dateFormatter),
|
||||
state.timestamp.toTimeFormat(),
|
||||
),
|
||||
),
|
||||
)
|
||||
SpacerH16()
|
||||
InputRowImage(
|
||||
title = TextReference.Res(R.string.swapping_from_title),
|
||||
subtitle = state.fromTokenAmount,
|
||||
caption = state.fromTokenFiatAmount,
|
||||
tokenIconState = state.fromTokenIconState ?: CurrencyIconState.Loading,
|
||||
modifier = Modifier
|
||||
.clip(TangemTheme.shapes.roundedCornersXMedium)
|
||||
.background(TangemTheme.colors.background.action),
|
||||
showNetworkIcon = true,
|
||||
)
|
||||
SpacerH16()
|
||||
InputRowImage(
|
||||
title = TextReference.Res(R.string.swapping_to_title),
|
||||
subtitle = state.toTokenAmount,
|
||||
caption = state.toTokenFiatAmount,
|
||||
tokenIconState = state.toTokenIconState ?: CurrencyIconState.Loading,
|
||||
modifier = Modifier
|
||||
.clip(TangemTheme.shapes.roundedCornersXMedium)
|
||||
.background(TangemTheme.colors.background.action),
|
||||
showNetworkIcon = true,
|
||||
)
|
||||
SpacerH16()
|
||||
InputRowBestRate(
|
||||
imageUrl = state.providerIcon,
|
||||
title = state.providerName,
|
||||
titleExtra = state.providerType,
|
||||
subtitle = state.rate,
|
||||
modifier = Modifier
|
||||
.clip(TangemTheme.shapes.roundedCornersXMedium)
|
||||
.background(TangemTheme.colors.background.action),
|
||||
)
|
||||
SpacerH16()
|
||||
InputRowDefault(
|
||||
title = TextReference.Res(R.string.common_network_fee_title),
|
||||
text = state.fee,
|
||||
modifier = Modifier
|
||||
.clip(TangemTheme.shapes.roundedCornersXMedium)
|
||||
.background(TangemTheme.colors.background.action),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
private fun SwapSuccessScreenButtons(
|
||||
@StringRes textRes: Int,
|
||||
txUrl: String,
|
||||
showStatusButton: Boolean,
|
||||
onExploreClick: () -> Unit,
|
||||
onStatusClick: () -> Unit,
|
||||
onDoneClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors.background.secondary)
|
||||
.padding(TangemTheme.dimens.spacing16),
|
||||
) {
|
||||
if (txUrl.isNotBlank()) {
|
||||
Row {
|
||||
SecondaryButtonIconStart(
|
||||
text = stringResourceSafe(id = R.string.common_explore),
|
||||
iconResId = R.drawable.ic_web_24,
|
||||
onClick = onExploreClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (showStatusButton) {
|
||||
SpacerW12()
|
||||
SecondaryButtonIconStart(
|
||||
text = stringResourceSafe(id = R.string.express_cex_status_button_title),
|
||||
iconResId = R.drawable.ic_arrow_top_right_24,
|
||||
onClick = onStatusClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
SpacerH12()
|
||||
}
|
||||
PrimaryButton(
|
||||
text = stringResourceSafe(id = textRes),
|
||||
enabled = true,
|
||||
onClick = onDoneClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// region preview
|
||||
@Preview(showBackground = true)
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_Success() {
|
||||
TangemThemePreview {
|
||||
SwapSuccessScreen(SwapSuccessStatePreview.state) {}
|
||||
}
|
||||
}
|
||||
// endregion preview
|
||||
|
|
@ -0,0 +1,598 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.*
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.utils.ImageBackgroundContrastChecker
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
import com.tangem.feature.swap.models.TransactionCardType
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
fun TransactionCard(
|
||||
type: TransactionCardType,
|
||||
balance: String,
|
||||
tokenIconUrl: String,
|
||||
tokenCurrency: String,
|
||||
amountEquivalent: String?,
|
||||
priceImpact: PriceImpact,
|
||||
textFieldValue: TextFieldValue?,
|
||||
modifier: Modifier = Modifier,
|
||||
@DrawableRes iconPlaceholder: Int? = null,
|
||||
@DrawableRes networkIconRes: Int? = null,
|
||||
onChangeTokenClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(
|
||||
shape = RoundedCornerShape(TangemTheme.dimens.radius16),
|
||||
color = TangemTheme.colors.background.primary,
|
||||
)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.Top,
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Header(balance = stringResourceSafe(R.string.common_balance, balance), type = type)
|
||||
|
||||
Content(
|
||||
type = type,
|
||||
amountEquivalent = amountEquivalent,
|
||||
textFieldValue = textFieldValue,
|
||||
priceImpact = priceImpact,
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.align(Alignment.BottomEnd)) {
|
||||
Token(
|
||||
tokenIconUrl = tokenIconUrl,
|
||||
tokenCurrency = tokenCurrency,
|
||||
networkIconRes = networkIconRes,
|
||||
iconPlaceholder = iconPlaceholder,
|
||||
)
|
||||
}
|
||||
|
||||
if (onChangeTokenClick != null) {
|
||||
Box(modifier = Modifier.align(Alignment.CenterEnd)) {
|
||||
ChangeTokenSelector()
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.height(TangemTheme.dimens.size116)
|
||||
.width(TangemTheme.dimens.size102)
|
||||
.clickable(
|
||||
indication = ripple(bounded = false),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) { onChangeTokenClick() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TransactionCardEmpty(
|
||||
type: TransactionCardType,
|
||||
amountEquivalent: String?,
|
||||
textFieldValue: TextFieldValue?,
|
||||
modifier: Modifier = Modifier,
|
||||
onChangeTokenClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(
|
||||
shape = RoundedCornerShape(TangemTheme.dimens.radius12),
|
||||
color = TangemTheme.colors.background.primary,
|
||||
)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.Top,
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Header(
|
||||
balance = stringResourceSafe(id = R.string.swapping_token_not_available),
|
||||
type = type,
|
||||
)
|
||||
|
||||
Content(
|
||||
type = type,
|
||||
amountEquivalent = amountEquivalent,
|
||||
textFieldValue = textFieldValue,
|
||||
priceImpact = PriceImpact.Empty(),
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.align(Alignment.BottomEnd)) {
|
||||
Token(
|
||||
tokenIconUrl = "",
|
||||
tokenCurrency = "",
|
||||
iconPlaceholder = R.drawable.ic_no_token_44,
|
||||
)
|
||||
}
|
||||
|
||||
if (onChangeTokenClick != null) {
|
||||
Box(modifier = Modifier.align(Alignment.CenterEnd)) {
|
||||
ChangeTokenSelector()
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.height(TangemTheme.dimens.size116)
|
||||
.width(TangemTheme.dimens.size102)
|
||||
.clickable(
|
||||
indication = ripple(bounded = false),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) { onChangeTokenClick() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(type: TransactionCardType, balance: String, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
bottom = TangemTheme.dimens.spacing8,
|
||||
top = TangemTheme.dimens.spacing14,
|
||||
start = TangemTheme.dimens.spacing12,
|
||||
end = TangemTheme.dimens.spacing12,
|
||||
),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val titleColor = if (type.inputError is TransactionCardType.InputError.Empty) {
|
||||
TangemTheme.colors.text.tertiary
|
||||
} else {
|
||||
TangemTheme.colors.text.warning
|
||||
}
|
||||
Text(
|
||||
text = type.header.resolveReference(),
|
||||
color = titleColor,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.subtitle2,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically),
|
||||
)
|
||||
SpacerW16()
|
||||
if (balance.isNotBlank()) {
|
||||
AnimatedContent(targetState = balance, label = "") {
|
||||
Text(
|
||||
text = it,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
RectangleShimmer(
|
||||
modifier = Modifier
|
||||
.width(TangemTheme.dimens.size80)
|
||||
.height(TangemTheme.dimens.size12),
|
||||
radius = TangemTheme.dimens.radius3,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun Content(
|
||||
type: TransactionCardType,
|
||||
amountEquivalent: String?,
|
||||
priceImpact: PriceImpact,
|
||||
textFieldValue: TextFieldValue?,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = TangemTheme.dimens.spacing12,
|
||||
bottom = TangemTheme.dimens.spacing16,
|
||||
),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
end = TangemTheme.dimens.spacing92,
|
||||
),
|
||||
verticalArrangement = Arrangement.Top,
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
val sumTextModifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size32)
|
||||
when (type) {
|
||||
is TransactionCardType.ReadOnly -> {
|
||||
if (textFieldValue != null) {
|
||||
ResizableText(
|
||||
text = textFieldValue.text,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
style = TangemTheme.typography.h2,
|
||||
fontSizeRange = FontSizeRange(min = 16.sp, max = TangemTheme.typography.h2.fontSize),
|
||||
modifier = sumTextModifier,
|
||||
)
|
||||
} else {
|
||||
RectangleShimmer(
|
||||
modifier = Modifier
|
||||
.padding(vertical = TangemTheme.dimens.spacing4)
|
||||
.width(TangemTheme.dimens.size102)
|
||||
.height(TangemTheme.dimens.size24),
|
||||
)
|
||||
}
|
||||
}
|
||||
is TransactionCardType.Inputtable -> {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
AutoSizeTextField(
|
||||
modifier = sumTextModifier,
|
||||
focusRequester = focusRequester,
|
||||
textFieldValue = textFieldValue ?: TextFieldValue(),
|
||||
onAmountChange = { type.onAmountChanged(it) },
|
||||
onFocusChange = type.onFocusChanged,
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SpacerH4()
|
||||
|
||||
if (amountEquivalent != null) {
|
||||
if (type is TransactionCardType.ReadOnly) {
|
||||
Row(
|
||||
modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (priceImpact is PriceImpact.Value) {
|
||||
Text(
|
||||
text = makePriceImpactBalanceWarning(
|
||||
amountEquivalent,
|
||||
priceImpact.getIntPercentValue(),
|
||||
),
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
style = TangemTheme.typography.body2,
|
||||
)
|
||||
} else {
|
||||
AnimatedContent(targetState = amountEquivalent, label = "") {
|
||||
Text(
|
||||
text = it,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
style = TangemTheme.typography.body2,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (type.showWarning) {
|
||||
SpacerW4()
|
||||
IconButton(
|
||||
onClick = {
|
||||
type.onWarningClick?.invoke()
|
||||
},
|
||||
modifier = Modifier.size(size = TangemTheme.dimens.size20),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_information_24),
|
||||
contentDescription = null,
|
||||
tint = if (priceImpact is PriceImpact.Value) {
|
||||
TangemTheme.colors.text.attention
|
||||
} else {
|
||||
TangemTheme.colors.text.tertiary
|
||||
},
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AnimatedContent(targetState = amountEquivalent, label = "") {
|
||||
Text(
|
||||
text = it,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
style = TangemTheme.typography.body2,
|
||||
modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
RectangleShimmer(
|
||||
modifier = Modifier
|
||||
.padding(vertical = TangemTheme.dimens.spacing4)
|
||||
.width(TangemTheme.dimens.size40)
|
||||
.height(TangemTheme.dimens.size12),
|
||||
radius = TangemTheme.dimens.radius3,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
fun Token(
|
||||
tokenIconUrl: String,
|
||||
tokenCurrency: String,
|
||||
@DrawableRes iconPlaceholder: Int? = null,
|
||||
@DrawableRes networkIconRes: Int? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
end = TangemTheme.dimens.spacing12,
|
||||
bottom = TangemTheme.dimens.spacing12,
|
||||
),
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
TokenIcon(
|
||||
tokenIconUrl = tokenIconUrl,
|
||||
tokenCurrency = tokenCurrency,
|
||||
iconPlaceholder = iconPlaceholder,
|
||||
networkIconRes = networkIconRes,
|
||||
)
|
||||
SpacerH4()
|
||||
Text(
|
||||
text = tokenCurrency,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
maxLines = 1,
|
||||
style = TangemTheme.typography.subtitle2,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.defaultMinSize(minWidth = TangemTheme.dimens.size80),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TokenIcon(
|
||||
tokenIconUrl: String,
|
||||
tokenCurrency: String,
|
||||
@DrawableRes iconPlaceholder: Int? = null,
|
||||
@DrawableRes networkIconRes: Int? = null,
|
||||
) {
|
||||
var iconBackgroundColor by remember { mutableStateOf(Color.Transparent) }
|
||||
var isBackgroundColorDefined by remember { mutableStateOf(false) }
|
||||
val itemBackgroundColor = TangemTheme.colors.background.primary.toArgb()
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(end = TangemTheme.dimens.spacing16)
|
||||
.size(TangemTheme.dimens.size42),
|
||||
) {
|
||||
val tokenImageModifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.size(TangemTheme.dimens.size36)
|
||||
.background(
|
||||
color = iconBackgroundColor,
|
||||
shape = TangemTheme.shapes.roundedCorners8,
|
||||
)
|
||||
.clip(TangemTheme.shapes.roundedCorners8)
|
||||
|
||||
val data = tokenIconUrl.ifEmpty { iconPlaceholder }
|
||||
|
||||
val pixelsSize = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() }
|
||||
|
||||
SubcomposeAsyncImage(
|
||||
modifier = tokenImageModifier,
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(data)
|
||||
.size(size = pixelsSize)
|
||||
.memoryCacheKey(key = data.toString() + pixelsSize)
|
||||
.crossfade(true)
|
||||
.allowHardware(false)
|
||||
.listener(
|
||||
onSuccess = { _, result ->
|
||||
if (isDarkTheme) {
|
||||
coroutineScope.launch {
|
||||
val color = ImageBackgroundContrastChecker(
|
||||
drawable = result.drawable,
|
||||
backgroundColor = itemBackgroundColor,
|
||||
size = pixelsSize,
|
||||
).getContrastColor(true)
|
||||
iconBackgroundColor = color
|
||||
isBackgroundColorDefined = true
|
||||
}
|
||||
}
|
||||
},
|
||||
).build(),
|
||||
loading = { CircleShimmer(modifier = tokenImageModifier) },
|
||||
contentDescription = tokenCurrency,
|
||||
)
|
||||
|
||||
if (networkIconRes != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.size(TangemTheme.dimens.size18)
|
||||
.background(color = TangemTheme.colors.background.primary, shape = CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier.padding(all = TangemTheme.dimens.spacing2),
|
||||
painter = painterResource(id = networkIconRes),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChangeTokenSelector() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(
|
||||
top = TangemTheme.dimens.spacing12,
|
||||
start = TangemTheme.dimens.spacing24,
|
||||
end = TangemTheme.dimens.spacing12,
|
||||
),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(TangemTheme.dimens.size20),
|
||||
painter = painterResource(id = R.drawable.ic_chevron_24),
|
||||
tint = TangemTheme.colors.icon.secondary,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun makePriceImpactBalanceWarning(value: String, priceImpactPercents: Int): AnnotatedString {
|
||||
val fullValue = "$value (-$priceImpactPercents%)"
|
||||
return buildAnnotatedString {
|
||||
append(fullValue)
|
||||
addStyle(
|
||||
style = SpanStyle(color = TangemTheme.colors.text.attention),
|
||||
start = value.length,
|
||||
end = fullValue.length,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// region preview
|
||||
|
||||
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
|
||||
@Composable
|
||||
private fun Preview_TransactionCard_InLightTheme() {
|
||||
TangemThemePreview(isDark = false) {
|
||||
TransactionCardPreview()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
|
||||
@Composable
|
||||
private fun Preview_TransactionCardWithPriceImpact_InLightTheme() {
|
||||
TangemThemePreview(isDark = false) {
|
||||
TransactionCardPreviewWithPriceImpact()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
|
||||
@Composable
|
||||
private fun Preview_TransactionCardWithoutPriceImpact_InLightTheme() {
|
||||
TangemThemePreview(isDark = false) {
|
||||
TransactionCardPreviewWithoutPriceImpact()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
|
||||
@Composable
|
||||
private fun Preview_TransactionCard_InDarkTheme() {
|
||||
TangemThemePreview(isDark = false) {
|
||||
TransactionCardPreview()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
|
||||
@Composable
|
||||
private fun Preview_TransactionCardWithPriceImpact_InDarkTheme() {
|
||||
TangemThemePreview(isDark = false) {
|
||||
TransactionCardPreviewWithPriceImpact()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
|
||||
@Composable
|
||||
private fun Preview_TransactionCardWithoutPriceImpact_InDarkTheme() {
|
||||
TangemThemePreview(isDark = false) {
|
||||
TransactionCardPreviewWithoutPriceImpact()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransactionCardPreview() {
|
||||
TransactionCard(
|
||||
type = TransactionCardType.Inputtable({}, {}, TransactionCardType.InputError.Empty),
|
||||
amountEquivalent = "1 000 000",
|
||||
tokenIconUrl = "",
|
||||
tokenCurrency = "DAI",
|
||||
networkIconRes = R.drawable.img_polygon_22,
|
||||
onChangeTokenClick = {},
|
||||
balance = "123",
|
||||
textFieldValue = TextFieldValue(),
|
||||
priceImpact = PriceImpact.Empty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("MagicNumber")
|
||||
private fun TransactionCardPreviewWithPriceImpact() {
|
||||
TransactionCard(
|
||||
type = TransactionCardType.ReadOnly(showWarning = true),
|
||||
amountEquivalent = "1 000 000",
|
||||
tokenIconUrl = "",
|
||||
tokenCurrency = "DAI",
|
||||
networkIconRes = R.drawable.img_polygon_22,
|
||||
onChangeTokenClick = {},
|
||||
balance = "123",
|
||||
textFieldValue = TextFieldValue(),
|
||||
priceImpact = PriceImpact.Value(0.15F),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("MagicNumber")
|
||||
private fun TransactionCardPreviewWithoutPriceImpact() {
|
||||
TransactionCard(
|
||||
type = TransactionCardType.ReadOnly(),
|
||||
amountEquivalent = "1 000 000",
|
||||
tokenIconUrl = "",
|
||||
tokenCurrency = "DAI",
|
||||
networkIconRes = R.drawable.img_polygon_22,
|
||||
onChangeTokenClick = {},
|
||||
balance = "123",
|
||||
textFieldValue = TextFieldValue(),
|
||||
priceImpact = PriceImpact.Empty(),
|
||||
)
|
||||
}
|
||||
|
||||
// endregion preview
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.tangem.feature.swap.utils
|
||||
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.format.bigdecimal.simple
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
|
||||
internal fun getExpressErrorMessage(expressDataError: ExpressDataError): TextReference {
|
||||
return when (expressDataError) {
|
||||
is ExpressDataError.SwapsAreUnavailableNowError -> resourceReference(
|
||||
id = R.string.express_error_swap_unavailable,
|
||||
formatArgs = wrappedList(expressDataError.code),
|
||||
)
|
||||
is ExpressDataError.ExchangeNotPossibleError -> resourceReference(
|
||||
id = R.string.warning_express_pair_unavailable_message,
|
||||
formatArgs = wrappedList(expressDataError.code),
|
||||
)
|
||||
is ExpressDataError.UnknownError -> resourceReference(R.string.common_unknown_error)
|
||||
is ExpressDataError.ExchangeProviderNotActiveError,
|
||||
is ExpressDataError.ExchangeProviderNotFoundError,
|
||||
is ExpressDataError.ExchangeProviderNotAvailableError,
|
||||
is ExpressDataError.ExchangeProviderProviderInternalError,
|
||||
-> resourceReference(
|
||||
id = R.string.express_error_swap_pair_unavailable,
|
||||
formatArgs = wrappedList(expressDataError.code),
|
||||
)
|
||||
is ExpressDataError.ProviderDifferentAmountError -> resourceReference(
|
||||
R.string.express_error_provider_amount_roundup,
|
||||
formatArgs = wrappedList(
|
||||
expressDataError.code,
|
||||
expressDataError.fromProviderAmount.format { simple(decimals = expressDataError.decimals) },
|
||||
),
|
||||
)
|
||||
else -> resourceReference(R.string.express_error_code, wrappedList(expressDataError.code.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun getExpressErrorTitle(expressDataError: ExpressDataError): TextReference {
|
||||
return when (expressDataError) {
|
||||
is ExpressDataError.ExchangeNotPossibleError -> resourceReference(
|
||||
id = R.string.warning_express_pair_unavailable_title,
|
||||
formatArgs = wrappedList(expressDataError.code),
|
||||
)
|
||||
is ExpressDataError.UnknownError -> resourceReference(R.string.common_error)
|
||||
else -> resourceReference(R.string.warning_express_refresh_required_title)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun SwapAmount.formatToUIRepresentation(): String {
|
||||
return value.format { simple(decimals = decimals) }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Loading…
Add table
Add a link
Reference in a new issue