Updated on 2026-08-14

This commit is contained in:
Tangem 2025-02-10 21:38:58 +03:00
parent 46bf08cb8b
commit e94298dd23
48 changed files with 68 additions and 2 deletions

View file

@ -0,0 +1,26 @@
package com.tangem.feature.swap
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
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 {
@Composable
override fun Content(modifier: Modifier) {
TODO("Not yet implemented")
}
@AssistedFactory
interface Factory : SwapComponent.Factory {
override fun create(context: AppComponentContext, params: SwapComponent.Params): DefaultSwapComponent
}
}

View file

@ -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")
}

View file

@ -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")
}
}
}

View file

@ -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)
}
}
}

View file

@ -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,
)
}
}

View file

@ -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
}

View file

@ -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()
}
}

View file

@ -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) }
}
}

View file

@ -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,
)

View file

@ -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)
}
}

View file

@ -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,
)

View file

@ -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
}

View file

@ -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,
)

View file

@ -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: () -> 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,
)

View file

@ -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

View file

@ -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

View file

@ -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()
}

View file

@ -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
}
}
}

View file

@ -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 amount: String,
val token: String,
) : Warning(
title = resourceReference(
id = R.string.send_notification_invalid_reserve_amount_title,
formatArgs = wrappedList("$amount $token"),
),
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,
)
}
}

View file

@ -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()
}

View file

@ -0,0 +1,82 @@
package com.tangem.feature.swap.presentation
import android.os.Bundle
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.fragment.app.viewModels
import com.tangem.common.ui.swapStoriesScreen.SwapStoriesScreen
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeFragment
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.feature.swap.viewmodels.SwapViewModel
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class SwapFragment : ComposeFragment() {
@Inject
override lateinit var uiDependencies: UiDependencies
private val viewModel by viewModels<SwapViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycle.addObserver(viewModel)
}
@Composable
override fun ScreenContent(modifier: Modifier) {
viewModel.onScreenOpened()
ScreenContent(viewModel = viewModel)
}
@Suppress("TopLevelComposableFunctions")
@Composable
private fun ScreenContent(viewModel: SwapViewModel) {
Crossfade(
modifier = Modifier.background(TangemTheme.colors.background.secondary),
targetState = viewModel.currentScreen,
label = "",
) { screen ->
when (screen) {
SwapNavScreen.PromoStories -> {
val storiesConfig = viewModel.uiState.storiesConfig
if (storiesConfig != null) {
SwapStoriesScreen(config = storiesConfig)
} else {
SwapScreen(stateHolder = viewModel.uiState)
}
}
SwapNavScreen.Main -> SwapScreen(stateHolder = viewModel.uiState)
SwapNavScreen.Success -> {
val successState = viewModel.uiState.successState
if (successState != null) {
SwapSuccessScreen(state = successState, viewModel.uiState.onBackClicked)
} else {
SwapScreen(stateHolder = viewModel.uiState)
}
}
SwapNavScreen.SelectToken -> {
val tokenState = viewModel.uiState.selectTokenState
if (tokenState != null) {
SwapSelectTokenScreen(state = tokenState, onBack = viewModel.uiState.onBackClicked)
} else {
SwapScreen(stateHolder = viewModel.uiState)
}
}
}
}
}
override fun onDestroy() {
lifecycle.removeObserver(viewModel)
super.onDestroy()
}
}

View file

@ -0,0 +1,353 @@
package com.tangem.feature.swap.presentation
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 = amount.value,
cryptoCurrencyStatus = fromCurrencyStatus,
onReduceClick = { reduceBy, reduceByDiff, _ ->
actions.onReduceByAmount(
amount.copy(value = amount.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.fromTokenInfo.cryptoCurrencyStatus.currency
if (amount < amountToCreateAccount) {
add(
SwapNotificationUM.Warning.NeedReserveToCreateAccount(
status.amountToCreateAccount.parseBigDecimal(currencyTo.decimals),
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
}
}

View file

@ -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)
}

View file

@ -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 = {},
)
}

View file

@ -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
}

View file

@ -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),
)
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,
)
}

View file

@ -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)
}
}
}
}

View file

@ -0,0 +1,474 @@
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.*
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.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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) {
Card(
elevation = CardDefaults.cardElevation(
defaultElevation = 2.dp,
pressedElevation = 2.dp,
),
shape = CircleShape,
colors = CardDefaults.cardColors(
containerColor = TangemTheme.colors.background.action,
disabledContainerColor = TangemTheme.colors.background.action,
contentColor = TangemTheme.colors.text.primary1,
disabledContentColor = TangemTheme.colors.text.primary1,
),
modifier = modifier.size(TangemTheme.dimens.size48),
onClick = state.onChangeCardsClicked,
enabled = state.changeCardsButtonState == ChangeCardsButtonState.ENABLED,
) {
when (state.changeCardsButtonState) {
ChangeCardsButtonState.UPDATE_IN_PROGRESS -> {
CircularProgressIndicator(
modifier = Modifier
.size(TangemTheme.dimens.size16)
.padding(TangemTheme.dimens.spacing14),
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

View file

@ -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()
}
}

View file

@ -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

View file

@ -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

View file

@ -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) }
}

View file

@ -0,0 +1,31 @@
package com.tangem.feature.swap.viewmodels
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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB