Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-24 00:07:05 +03:00
commit edec3fcdff
391 changed files with 10988 additions and 3221 deletions

View file

@ -4,6 +4,7 @@ import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
@ -14,6 +15,7 @@ import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns.MINUS
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
@ -23,42 +25,64 @@ internal class AllOffersStateFactory(
private val allOffersIntents: AllOffersIntents,
) {
fun getLoadedPaymentsState(methodGroups: List<OnrampPaymentMethodGroup>): AllOffersStateUM {
fun getLoadedPaymentsState(methodGroups: List<OnrampPaymentMethodGroup>, currencyCode: String): AllOffersStateUM {
return AllOffersStateUM.Content(
methods = methodGroups.map { methodGroup ->
AllOffersPaymentMethodUM(
offers = mapOffersToUM(methodGroup.offers).toPersistentList(),
methodConfig = OnrampPaymentMethodConfig(
method = methodGroup.paymentMethod,
onClick = { allOffersIntents.onPaymentMethodClicked(methodGroup.paymentMethod.id) },
),
diff = methodGroup
.bestRateOffer
?.rateDif
?.takeIf { it > BigDecimal.ZERO }
?.let { diff ->
stringReference("$MINUS${diff.format { percent() }}")
},
rate = methodGroup.bestRateOffer?.let { offer ->
when (val quote = offer.quote) {
is OnrampQuote.Data -> quote.toAmount.value.format {
crypto(
symbol = quote.toAmount.symbol,
decimals = quote.toAmount.decimals,
)
}
else -> ""
}
} ?: "",
providersCount = methodGroup.providerCount,
isBestRate = methodGroup.isBestPaymentMethod,
)
methods = methodGroups.map { group ->
createPaymentMethodUM(group, currencyCode)
}.toPersistentList(),
currentMethod = null,
currentMethod = replaceOffersForCurrentMethod(methodGroups, currencyCode),
onBackClicked = { allOffersIntents.onBackClicked() },
)
}
private fun createPaymentMethodUM(
methodGroup: OnrampPaymentMethodGroup,
currencyCode: String,
): AllOffersPaymentMethodUM {
return AllOffersPaymentMethodUM(
offers = mapOffersToUM(methodGroup.offers, currencyCode).toPersistentList(),
methodConfig = createMethodConfig(methodGroup.paymentMethod),
diff = formatRateDiff(methodGroup.bestRateOffer?.rateDif),
rate = formatBestRate(methodGroup.bestRateOffer, currencyCode),
providersCount = methodGroup.providerCount,
isBestRate = methodGroup.isBestPaymentMethod,
paymentMethodStatus = methodGroup.methodStatus,
)
}
private fun createMethodConfig(paymentMethod: OnrampPaymentMethod): OnrampPaymentMethodConfig {
return OnrampPaymentMethodConfig(
method = paymentMethod,
onClick = { allOffersIntents.onPaymentMethodClicked(paymentMethod.id) },
)
}
private fun formatRateDiff(rateDif: BigDecimal?) = rateDif
?.takeIf { it > BigDecimal.ZERO }
?.let { diff -> stringReference("$MINUS${diff.format { percent() }}") }
private fun formatBestRate(bestRateOffer: OnrampOffer?, currencyCode: String): String {
return when (val quote = bestRateOffer?.quote) {
is OnrampQuote.Data -> formatCryptoAmount(quote.toAmount)
is OnrampQuote.AmountError -> formatRequiredAmount(quote, currencyCode)
is OnrampQuote.Error,
null,
-> ""
}
}
private fun formatCryptoAmount(amount: OnrampAmount): String {
return amount.value.format {
crypto(symbol = amount.symbol, decimals = amount.decimals)
}
}
private fun formatRequiredAmount(quote: OnrampQuote.AmountError, currencyCode: String): String {
return quote.error.requiredAmount.format {
fiat(fiatCurrencySymbol = quote.fromAmount.symbol, fiatCurrencyCode = currencyCode)
}
}
fun getPaymentsState(): AllOffersStateUM {
return when (val currentState = currentStateProvider.invoke()) {
is AllOffersStateUM.Content -> {
@ -72,34 +96,36 @@ internal class AllOffersStateFactory(
}
fun getOnrampErrorState(onrampError: OnrampError): AllOffersStateUM {
return when (onrampError) {
is OnrampError.DataError -> getErrorState(
errorCode = onrampError.code,
onRefresh = allOffersIntents::onRefresh,
)
OnrampError.PairsNotFound,
is OnrampError.DomainError,
-> getErrorState(onRefresh = allOffersIntents::onRefresh)
is OnrampError.AmountError.TooBigError,
is OnrampError.AmountError.TooSmallError,
OnrampError.RedirectError.VerificationFailed,
OnrampError.RedirectError.WrongRequestId,
-> currentStateProvider()
return if (shouldShowErrorState(onrampError)) {
createErrorState(errorCode = (onrampError as? OnrampError.DataError)?.code)
} else {
currentStateProvider()
}
}
private fun getErrorState(errorCode: String? = null, onRefresh: () -> Unit): AllOffersStateUM {
val state = currentStateProvider()
return when (state) {
private fun shouldShowErrorState(error: OnrampError): Boolean {
return when (error) {
is OnrampError.DataError,
OnrampError.PairsNotFound,
is OnrampError.DomainError,
-> true
is OnrampError.AmountError,
is OnrampError.RedirectError,
-> false
}
}
private fun createErrorState(errorCode: String? = null): AllOffersStateUM {
return when (currentStateProvider()) {
is AllOffersStateUM.Content,
AllOffersStateUM.Loading,
-> AllOffersStateUM.Error(
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
errorCode = errorCode,
onRefresh = onRefresh,
onRefresh = allOffersIntents::onRefresh,
),
)
is AllOffersStateUM.Error -> state
is AllOffersStateUM.Error -> currentStateProvider()
}
}
@ -108,52 +134,70 @@ internal class AllOffersStateFactory(
OnrampOfferAdvantages.Default -> OnrampOfferAdvantagesUM.Default
OnrampOfferAdvantages.BestRate -> OnrampOfferAdvantagesUM.BestRate
OnrampOfferAdvantages.Fastest -> OnrampOfferAdvantagesUM.Fastest
OnrampOfferAdvantages.GreatRate -> OnrampOfferAdvantagesUM.GreatRate
}
}
private fun mapOffersToUM(offers: List<OnrampOffer>): List<OnrampOfferUM> {
return buildList {
offers.forEach { offer ->
when (val quote = offer.quote) {
is OnrampQuote.Data -> {
add(
OnrampOfferUM(
category = OnrampOfferCategoryUM.Recommended,
advantages = mapOfferAdvantagesDTOtoUM(offer.advantages),
paymentMethod = quote.paymentMethod,
providerId = quote.provider.id,
providerName = quote.provider.info.name,
rate = quote.toAmount.value.format {
crypto(
symbol = quote.toAmount.symbol,
decimals = quote.toAmount.decimals,
)
},
diff = offer
.rateDif
?.takeIf { it > BigDecimal.ZERO }
?.let { diff ->
stringReference("$MINUS${diff.format { percent() }}")
},
onBuyClicked = {
allOffersIntents.onBuyClick(
quote = OnrampProviderWithQuote.Data(
provider = quote.provider,
paymentMethod = quote.paymentMethod,
toAmount = quote.toAmount,
fromAmount = quote.fromAmount,
),
onrampOfferAdvantagesUM = mapOfferAdvantagesDTOtoUM(offer.advantages),
)
},
),
)
}
is OnrampQuote.AmountError,
is OnrampQuote.Error,
-> Unit
}
private fun mapOffersToUM(offers: List<OnrampOffer>, currencyCode: String): List<OnrampOfferUM> {
return offers.mapNotNull { offer ->
when (val quote = offer.quote) {
is OnrampQuote.Data -> createDataOfferUM(quote, offer)
is OnrampQuote.AmountError -> createAmountErrorOfferUM(quote, offer, currencyCode)
is OnrampQuote.Error -> null
}
}
}
private fun createDataOfferUM(quote: OnrampQuote.Data, offer: OnrampOffer): OnrampOfferUM {
return OnrampOfferUM(
category = OnrampOfferCategoryUM.Recommended,
advantages = mapOfferAdvantagesDTOtoUM(offer.advantages),
paymentMethod = quote.paymentMethod,
providerName = quote.provider.info.name,
rate = formatCryptoAmount(quote.toAmount),
diff = formatRateDiff(offer.rateDif),
onBuyClicked = {
allOffersIntents.onBuyClick(
quote = OnrampProviderWithQuote.Data(
provider = quote.provider,
paymentMethod = quote.paymentMethod,
toAmount = quote.toAmount,
fromAmount = quote.fromAmount,
),
onrampOfferAdvantagesUM = mapOfferAdvantagesDTOtoUM(offer.advantages),
)
},
)
}
private fun createAmountErrorOfferUM(
quote: OnrampQuote.AmountError,
offer: OnrampOffer,
currencyCode: String,
): OnrampOfferUM {
return OnrampOfferUM(
category = OnrampOfferCategoryUM.Recommended,
advantages = OnrampOfferAdvantagesUM.Unavailable,
paymentMethod = quote.paymentMethod,
providerName = quote.provider.info.name,
rate = formatRequiredAmount(quote, currencyCode),
diff = formatRateDiff(offer.rateDif),
onBuyClicked = {},
)
}
private fun replaceOffersForCurrentMethod(
methodGroups: List<OnrampPaymentMethodGroup>,
currencyCode: String,
): AllOffersPaymentMethodUM? {
val currentMethod = (currentStateProvider() as? AllOffersStateUM.Content)?.currentMethod ?: return null
val updateForCurrentMethod = methodGroups.find { it.paymentMethod.id == currentMethod.methodConfig.method.id }
return updateForCurrentMethod?.let { updatedCurrentMethod ->
currentMethod.copy(
offers = mapOffersToUM(updatedCurrentMethod.offers, currencyCode).toImmutableList(),
)
}
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.onramp.alloffers.entity
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodStatus
import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM
import kotlinx.collections.immutable.ImmutableList
@ -26,6 +27,7 @@ internal data class AllOffersPaymentMethodUM(
val rate: String,
val providersCount: Int,
val isBestRate: Boolean,
val paymentMethodStatus: PaymentMethodStatus,
)
internal data class OnrampPaymentMethodConfig(

View file

@ -74,6 +74,7 @@ internal class AllOffersModel @Inject constructor(
providerName = quote.provider.info.name,
paymentMethodName = quote.paymentMethod.name,
)?.let { analyticsEventHandler::send }
dismiss()
params.openRedirectPage(quote)
}
@ -95,7 +96,12 @@ internal class AllOffersModel @Inject constructor(
maybeOffers.fold(
ifLeft = ::handleOnrampError,
ifRight = { offersGroup ->
_state.update { stateFactory.getLoadedPaymentsState(offersGroup) }
_state.update {
stateFactory.getLoadedPaymentsState(
methodGroups = offersGroup,
currencyCode = params.amountCurrencyCode,
)
}
},
)
}

View file

@ -26,6 +26,7 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodStatus
import com.tangem.domain.onramp.model.PaymentMethodType
import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM
import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM
@ -204,7 +205,6 @@ private fun AllOffersContentSheetPaymentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "providerId1",
providerName = "Simplex",
rate = "0,0245334 BTC",
diff = null,
@ -219,7 +219,6 @@ private fun AllOffersContentSheetPaymentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "providerId2",
providerName = "Simplex",
rate = "0,00145334 BTC",
diff = stringReference("0.07%"),
@ -239,6 +238,7 @@ private fun AllOffersContentSheetPaymentPreview() {
rate = "0,0245334 BTC",
providersCount = 2,
isBestRate = true,
paymentMethodStatus = PaymentMethodStatus.Available,
)
TangemThemePreview {
@ -270,7 +270,6 @@ private fun AllOffersContentSheetOffersPreview() {
"https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "providerId1",
providerName = "Simplex",
rate = "0,0245334 BTC",
diff = null,
@ -286,7 +285,6 @@ private fun AllOffersContentSheetOffersPreview() {
"https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "providerId2",
providerName = "Simplex",
rate = "0,00145334 BTC",
diff = stringReference("0.07%"),
@ -306,6 +304,7 @@ private fun AllOffersContentSheetOffersPreview() {
rate = "0,0245334 BTC",
providersCount = 2,
isBestRate = true,
paymentMethodStatus = PaymentMethodStatus.Available,
)
}

View file

@ -28,6 +28,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SelectPaymentMethodBottomSheetTestTags
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodStatus
import com.tangem.domain.onramp.model.PaymentMethodType
import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM
import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig
@ -89,21 +90,50 @@ private fun PaymentMethod(methodUM: AllOffersPaymentMethodUM, modifier: Modifier
SpacerW(12.dp)
Column {
PaymentMethodInfoBlock(
paymentMethodName = methodUM.methodConfig.method.name,
rate = methodUM.rate,
diff = methodUM.diff,
isBestRate = methodUM.isBestRate,
)
Row(verticalAlignment = Alignment.CenterVertically) {
ProvidersCountBlockInfo(providersCount = methodUM.providersCount)
SpacerW(8.dp)
TimingBlockInfo(speed = methodUM.methodConfig.method.type.getProcessingSpeed())
when (methodUM.paymentMethodStatus) {
PaymentMethodStatus.Available -> {
PaymentMethodInfoBlock(
paymentMethodName = methodUM.methodConfig.method.name,
rate = methodUM.rate,
diff = methodUM.diff,
isBestRate = methodUM.isBestRate,
)
Row(verticalAlignment = Alignment.CenterVertically) {
ProvidersCountBlockInfo(providersCount = methodUM.providersCount)
SpacerW(8.dp)
TimingBlockInfo(speed = methodUM.methodConfig.method.type.getProcessingSpeed())
}
}
is PaymentMethodStatus.Unavailable -> {
UnavailablePaymentMethodInfoBlock(
paymentMethodName = methodUM.methodConfig.method.name,
errorAmount = methodUM.rate,
)
}
}
}
}
}
@Composable
private fun UnavailablePaymentMethodInfoBlock(paymentMethodName: String, errorAmount: String) {
Column(modifier = Modifier.padding(bottom = 14.dp)) {
Text(
text = paymentMethodName,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.testTag(SelectPaymentMethodBottomSheetTestTags.PAYMENT_METHOD_NAME),
)
SpacerH(2.dp)
Text(
text = stringResourceSafe(R.string.onramp_provider_min_amount, errorAmount),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.testTag(SelectPaymentMethodBottomSheetTestTags.UP_TO_TEXT),
)
}
}
@Composable
private fun PaymentMethodInfoBlock(
paymentMethodName: String,
@ -234,7 +264,6 @@ private fun PaymentMethodsContentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "providerId1",
providerName = "Simplex",
rate = "0,0245334 BTC",
diff = null,
@ -249,7 +278,6 @@ private fun PaymentMethodsContentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "providerId2",
providerName = "Simplex",
rate = "0,00145334 BTC",
diff = stringReference("0.07%"),
@ -269,6 +297,7 @@ private fun PaymentMethodsContentPreview() {
rate = "0,0245334 BTC",
providersCount = 2,
isBestRate = true,
paymentMethodStatus = PaymentMethodStatus.Available,
)
TangemThemePreview {
PaymentMethodsContent(persistentListOf(method))

View file

@ -1,12 +1,14 @@
package com.tangem.features.onramp.hottokens.portfolio.model
import arrow.core.getOrElse
import arrow.core.left
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase
import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase
import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.onramp.hottokens.portfolio.OnrampAddToPortfolioComponent
import com.tangem.features.onramp.hottokens.portfolio.entity.OnrampAddToPortfolioUM
@ -35,6 +37,7 @@ internal class OnrampAddToPortfolioModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val derivePublicKeysUseCase: DerivePublicKeysUseCase,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val getUserWalletUseCase: GetUserWalletUseCase,
) : Model() {
@ -73,16 +76,25 @@ internal class OnrampAddToPortfolioModel @Inject constructor(
modelScope.launch {
changeAddButtonProgressStatus(isProgress = true)
derivePublicKeysUseCase(params.userWalletId, listOfNotNull(params.cryptoCurrency)).getOrElse {
Timber.e("Failed to derive public keys: $it")
if (accountsFeatureToggles.isFeatureEnabled) {
// saveCryptoCurrenciesUseCase(
// accountId = params.accountId,
// add = params.cryptoCurrency,
// )
// TODO account
IllegalStateException("Not implemented yet").left()
} else {
derivePublicKeysUseCase(params.userWalletId, listOfNotNull(params.cryptoCurrency)).getOrElse {
Timber.e("Failed to derive public keys: $it")
changeAddButtonProgressStatus(isProgress = false)
changeAddButtonProgressStatus(isProgress = false)
}
addCryptoCurrenciesUseCase(
userWalletId = params.userWalletId,
currency = params.cryptoCurrency,
)
}
addCryptoCurrenciesUseCase(
userWalletId = params.userWalletId,
currency = params.cryptoCurrency,
)
.onRight { params.onSuccessAdding(params.cryptoCurrency.id) }
.onLeft { changeAddButtonProgressStatus(isProgress = false) }
}

View file

@ -67,7 +67,10 @@ internal class DefaultOnrampV2MainComponent @AssistedInject constructor(
cryptoCurrency = params.cryptoCurrency,
country = config.country,
launchSepa = false,
onDismiss = { model.bottomSheetNavigation.dismiss() },
onDismiss = {
model.bottomSheetNavigation.dismiss()
model.handleOnrampAvailable()
},
),
)
is OnrampV2MainBottomSheetConfig.CurrenciesList -> selectCurrencyComponentFactory.create(

View file

@ -9,19 +9,11 @@ import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed interface OnrampOffersBlockUM {
val isBlockVisible: Boolean
data object Empty : OnrampOffersBlockUM
data object Empty : OnrampOffersBlockUM {
override val isBlockVisible: Boolean
get() = false
}
data class Loading(
override val isBlockVisible: Boolean,
) : OnrampOffersBlockUM
data object Loading : OnrampOffersBlockUM
data class Content(
override val isBlockVisible: Boolean,
val recentOffer: OnrampOfferUM?,
val recommended: ImmutableList<OnrampOfferUM>,
val onrampAllOffersButtonConfig: OnrampAllOffersButtonConfig?,
@ -32,7 +24,6 @@ internal data class OnrampOfferUM(
val category: OnrampOfferCategoryUM,
val advantages: OnrampOfferAdvantagesUM,
val paymentMethod: OnrampPaymentMethod,
val providerId: String,
val providerName: String,
val rate: String,
val diff: TextReference?,
@ -44,7 +35,7 @@ internal enum class OnrampOfferCategoryUM {
}
internal enum class OnrampOfferAdvantagesUM {
Default, BestRate, Fastest;
Default, BestRate, GreatRate, Fastest, Unavailable;
fun toAnalyticsEvent(
cryptoCurrencySymbol: String,
@ -52,7 +43,7 @@ internal enum class OnrampOfferAdvantagesUM {
paymentMethodName: String,
): OnrampAnalyticsEvent? {
return when (this) {
BestRate -> OnrampAnalyticsEvent.BestRateClicked(
GreatRate -> OnrampAnalyticsEvent.BestRateClicked(
tokenSymbol = cryptoCurrencySymbol,
providerName = providerName,
paymentMethod = paymentMethodName,
@ -62,7 +53,10 @@ internal enum class OnrampOfferAdvantagesUM {
providerName = providerName,
paymentMethod = paymentMethodName,
)
Default -> null
Default,
BestRate,
Unavailable,
-> null
}
}
}

View file

@ -8,7 +8,7 @@ import kotlinx.collections.immutable.ImmutableList
internal data class OnrampNewAmountBlockUM(
val currencyUM: OnrampNewCurrencyUM,
val amountFieldModel: AmountFieldModel,
val secondaryFieldModel: OnrampNewAmountSecondaryFieldUM,
val secondaryFieldModel: OnrampSecondaryFieldErrorUM,
)
internal data class OnrampNewCurrencyUM(
@ -20,10 +20,9 @@ internal data class OnrampNewCurrencyUM(
)
@Immutable
internal sealed interface OnrampNewAmountSecondaryFieldUM {
data object Loading : OnrampNewAmountSecondaryFieldUM
data class Content(val amount: TextReference) : OnrampNewAmountSecondaryFieldUM
data class Error(val error: TextReference) : OnrampNewAmountSecondaryFieldUM
internal sealed interface OnrampSecondaryFieldErrorUM {
data object Empty : OnrampSecondaryFieldErrorUM
data class Error(val error: TextReference) : OnrampSecondaryFieldErrorUM
}
internal sealed interface OnrampV2AmountButtonUMState {

View file

@ -9,5 +9,4 @@ internal interface OnrampV2Intents {
fun onBuyClick(quote: OnrampProviderWithQuote.Data, onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM)
fun openProviders()
fun onRefresh()
fun onContinueClick()
}

View file

@ -9,33 +9,22 @@ import com.tangem.core.ui.extensions.TextReference
internal sealed interface OnrampV2MainComponentUM {
val topBarConfig: OnrampV2MainTopBarUM
val continueButtonConfig: ContinueButtonUM
val errorNotification: NotificationUM?
data class InitialLoading(
override val topBarConfig: OnrampV2MainTopBarUM,
override val continueButtonConfig: ContinueButtonUM,
override val errorNotification: NotificationUM?,
) : OnrampV2MainComponentUM
data class Content(
override val topBarConfig: OnrampV2MainTopBarUM,
override val continueButtonConfig: ContinueButtonUM,
override val errorNotification: NotificationUM?,
val amountBlockState: OnrampNewAmountBlockUM,
val offersBlockState: OnrampOffersBlockUM,
val onrampAmountButtonUMState: OnrampV2AmountButtonUMState,
val onrampProviderState: OnrampV2ProvidersUM,
) : OnrampV2MainComponentUM
}
internal data class ContinueButtonUM(
val text: TextReference,
val onClick: () -> Unit,
val enabled: Boolean,
val showProgress: Boolean = false,
)
internal data class OnrampV2MainTopBarUM(
val title: TextReference,
val startButtonUM: TopAppBarButtonUM,

View file

@ -3,11 +3,7 @@ package com.tangem.features.onramp.mainv2.entity.converter
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory
import com.tangem.utils.Provider
@ -18,7 +14,6 @@ internal class OnrampV2AmountFieldChangeConverter(
private val currentStateProvider: Provider<OnrampV2MainComponentUM>,
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory,
private val onrampIntents: OnrampV2Intents,
private val cryptoCurrency: CryptoCurrency,
) : Converter<String, OnrampV2MainComponentUM> {
override fun convert(value: String): OnrampV2MainComponentUM {
@ -39,12 +34,11 @@ internal class OnrampV2AmountFieldChangeConverter(
return state.copy(
amountBlockState = amountState.copy(
amountFieldModel = amountFieldModel,
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading,
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
),
continueButtonConfig = state.continueButtonConfig.copy(enabled = false),
onrampProviderState = OnrampV2ProvidersUM.Loading,
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
offersBlockState = OnrampOffersBlockUM.Empty,
offersBlockState = OnrampOffersBlockUM.Loading,
errorNotification = null,
)
}
@ -63,22 +57,14 @@ internal class OnrampV2AmountFieldChangeConverter(
return copy(
amountBlockState = amountBlockState.copy(
amountFieldModel = amountFieldModel,
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content(
stringReference(
BigDecimal.ZERO.format {
crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true)
},
),
),
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
),
continueButtonConfig = continueButtonConfig.copy(enabled = false),
offersBlockState = OnrampOffersBlockUM.Empty,
onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton(
currencySymbol = amountBlockState.currencyUM.unit,
currencyCode = amountBlockState.currencyUM.code,
onAmountValueChanged = onrampIntents::onAmountValueChanged,
),
onrampProviderState = OnrampV2ProvidersUM.Empty,
)
}
}

View file

@ -17,15 +17,12 @@ internal class OnrampOffersStateFactory(
private val onrampIntents: OnrampV2Intents,
) {
fun getOnShowOffersState(offers: List<OnrampOffersBlock>): OnrampV2MainComponentUM {
fun getOffersState(offers: List<OnrampOffersBlock>): OnrampV2MainComponentUM {
val currentState = currentStateProvider.invoke()
return when (currentState) {
is OnrampV2MainComponentUM.Content -> {
currentState.copy(
offersBlockState = mapOnrampOffersBlockToUM(
offersBlocks = offers,
currentState = currentState,
),
offersBlockState = mapOnrampOffersBlockToUM(offersBlocks = offers),
)
}
is OnrampV2MainComponentUM.InitialLoading -> {
@ -34,10 +31,7 @@ internal class OnrampOffersStateFactory(
}
}
private fun mapOnrampOffersBlockToUM(
offersBlocks: List<OnrampOffersBlock>,
currentState: OnrampV2MainComponentUM.Content,
): OnrampOffersBlockUM.Content {
private fun mapOnrampOffersBlockToUM(offersBlocks: List<OnrampOffersBlock>): OnrampOffersBlockUM {
val allOffersUM = mutableListOf<OnrampOfferUM>()
offersBlocks.map { block ->
block.offers.forEach { offer ->
@ -51,7 +45,6 @@ internal class OnrampOffersStateFactory(
category = mapOfferCategoryDTOtoUM(block.category),
advantages = mapOfferAdvantagesDTOtoUM(offer.advantages),
paymentMethod = currentQuote.paymentMethod,
providerId = currentQuote.provider.id,
providerName = currentQuote.provider.info.name,
rate = currentQuote.toAmount.value.format {
crypto(
@ -80,8 +73,9 @@ internal class OnrampOffersStateFactory(
}
}
if (allOffersUM.isEmpty()) return OnrampOffersBlockUM.Empty
return OnrampOffersBlockUM.Content(
isBlockVisible = currentState.offersBlockState.isBlockVisible,
recentOffer = allOffersUM.firstOrNull { it.category == OnrampOfferCategoryUM.RecentlyUsed },
recommended = allOffersUM.filter { it.category == OnrampOfferCategoryUM.Recommended }.toPersistentList(),
onrampAllOffersButtonConfig = if (offersBlocks.any { it.hasMoreOffers }) {
@ -107,6 +101,7 @@ internal class OnrampOffersStateFactory(
OnrampOfferAdvantages.Default -> OnrampOfferAdvantagesUM.Default
OnrampOfferAdvantages.BestRate -> OnrampOfferAdvantagesUM.BestRate
OnrampOfferAdvantages.Fastest -> OnrampOfferAdvantagesUM.Fastest
OnrampOfferAdvantages.GreatRate -> OnrampOfferAdvantagesUM.GreatRate
}
}
}

View file

@ -1,14 +1,10 @@
package com.tangem.features.onramp.mainv2.entity.factory
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.OnrampQuote
@ -18,13 +14,11 @@ import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.mainv2.entity.converter.OnrampV2AmountFieldChangeConverter
import com.tangem.utils.Provider
import java.math.BigDecimal
internal class OnrampV2AmountStateFactory(
private val currentStateProvider: Provider<OnrampV2MainComponentUM>,
private val analyticsEventHandler: AnalyticsEventHandler,
private val onrampIntents: OnrampV2Intents,
private val cryptoCurrency: CryptoCurrency,
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory,
) {
@ -35,7 +29,6 @@ internal class OnrampV2AmountStateFactory(
currentStateProvider = currentStateProvider,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
onrampIntents = onrampIntents,
cryptoCurrency = cryptoCurrency,
)
}
@ -74,127 +67,42 @@ internal class OnrampV2AmountStateFactory(
)
}
fun getAmountSecondaryLoadingState(): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
return currentState.copy(
amountBlockState = amountState.copy(
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading,
),
offersBlockState = OnrampOffersBlockUM.Loading(
isBlockVisible = currentState.offersBlockState.isBlockVisible,
),
continueButtonConfig = currentState.continueButtonConfig.copy(enabled = false),
errorNotification = null,
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
onrampProviderState = OnrampV2ProvidersUM.Loading,
)
}
fun getAmountSecondaryUpdatedState(quote: OnrampQuote): OnrampV2MainComponentUM {
fun getSecondaryFieldAmountErrorState(quotes: List<OnrampQuote>): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
if (amountState.amountFieldModel.fiatValue.isEmpty()) return currentState
val limitedQuote = getLimitFromAmountErrors(quotes)
return currentState.copy(
amountBlockState = amountState.copy(
amountFieldModel = amountState.amountFieldModel.copy(isError = false),
secondaryFieldModel = quote.toSecondaryFieldUiModel(amountState) ?: amountState.secondaryFieldModel,
),
continueButtonConfig = currentState.continueButtonConfig.copy(
enabled = quote is OnrampQuote.Data,
onClick = onrampIntents::onContinueClick,
secondaryFieldModel = limitedQuote?.toSecondaryFieldUiModel(amountState)
?: OnrampSecondaryFieldErrorUM.Empty,
),
errorNotification = null,
offersBlockState = OnrampOffersBlockUM.Empty,
)
}
fun getUpdatedProviderState(selectedQuote: OnrampQuote): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
analyticsEventHandler.send(
OnrampAnalyticsEvent.ProviderCalculated(
providerName = selectedQuote.provider.info.name,
tokenSymbol = cryptoCurrency.symbol,
paymentMethod = selectedQuote.paymentMethod.name,
),
)
return currentState.copy(
onrampProviderState = selectedQuote.toProviderBlockState(),
)
}
fun getAmountSecondaryResetState(): OnrampV2MainComponentUM {
fun getAmountSecondaryFieldResetState(): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
if (amountState.secondaryFieldModel is OnrampNewAmountSecondaryFieldUM.Content) return currentState
if (amountState.secondaryFieldModel is OnrampSecondaryFieldErrorUM.Empty) return currentState
return currentState.copy(
amountBlockState = amountState.copy(
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content(
amount = stringReference(
BigDecimal.ZERO.format {
crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true)
},
),
),
),
amountBlockState = amountState.copy(secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
errorNotification = null,
)
}
fun getShowProvidersState(): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
return when (currentState.offersBlockState) {
is OnrampOffersBlockUM.Content -> {
currentState.copy(
offersBlockState = currentState.offersBlockState.copy(isBlockVisible = true),
)
}
OnrampOffersBlockUM.Empty,
is OnrampOffersBlockUM.Loading,
-> currentState
}
}
private fun OnrampQuote.toProviderBlockState(): OnrampV2ProvidersUM {
return OnrampV2ProvidersUM.Content(
paymentMethod = paymentMethod,
providerId = provider.id,
)
}
private fun OnrampQuote.toSecondaryFieldUiModel(
amountState: OnrampNewAmountBlockUM,
): OnrampNewAmountSecondaryFieldUM? {
return when (this) {
is OnrampQuote.Error -> null
is OnrampQuote.Data -> {
val amount = toAmount.value.format {
crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true)
}
val contentAmount = combinedReference(stringReference("\u007E"), stringReference(amount))
OnrampNewAmountSecondaryFieldUM.Content(contentAmount)
}
is OnrampQuote.AmountError -> this.toSecondaryFieldUiModel(amountState)
}
}
private fun OnrampQuote.AmountError.toSecondaryFieldUiModel(
amountState: OnrampNewAmountBlockUM,
): OnrampNewAmountSecondaryFieldUM.Error {
): OnrampSecondaryFieldErrorUM.Error {
val amount = error.requiredAmount.format {
fiat(
fiatCurrencyCode = amountState.amountFieldModel.fiatAmount.currencySymbol,
@ -213,11 +121,27 @@ internal class OnrampV2AmountStateFactory(
}
}
return OnrampNewAmountSecondaryFieldUM.Error(
return OnrampSecondaryFieldErrorUM.Error(
resourceReference(
errorTextRes,
wrappedList(amount),
),
)
}
private fun getLimitFromAmountErrors(quotes: List<OnrampQuote>): OnrampQuote.AmountError? {
val amountErrorQuotes = quotes.filterIsInstance<OnrampQuote.AmountError>()
if (amountErrorQuotes.isEmpty()) {
return null
}
val tooSmallErrors = amountErrorQuotes.filter { it.error is OnrampError.AmountError.TooSmallError }
val tooBigErrors = amountErrorQuotes.filter { it.error is OnrampError.AmountError.TooBigError }
if (tooSmallErrors.isNotEmpty()) {
return tooSmallErrors.minByOrNull { it.error.requiredAmount }
}
if (tooBigErrors.isNotEmpty()) {
return tooBigErrors.maxByOrNull { it.error.requiredAmount }
}
return null
}
}

View file

@ -11,8 +11,6 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.error.OnrampError
@ -50,11 +48,6 @@ internal class OnrampV2StateFactory(
isEnabled = false,
),
),
continueButtonConfig = ContinueButtonUM(
text = resourceReference(R.string.common_continue),
onClick = {},
enabled = false,
),
)
}
@ -70,11 +63,6 @@ internal class OnrampV2StateFactory(
return OnrampV2MainComponentUM.Content(
topBarConfig = state.topBarConfig.copy(endButtonUM = endButton),
continueButtonConfig = ContinueButtonUM(
text = resourceReference(R.string.common_continue),
onClick = onrampIntents::onContinueClick,
enabled = false,
),
amountBlockState = initialAmountBlockState,
offersBlockState = OnrampOffersBlockUM.Empty,
errorNotification = null,
@ -83,7 +71,6 @@ internal class OnrampV2StateFactory(
currencySymbol = currency.unit,
onAmountValueChanged = onrampIntents::onAmountValueChanged,
),
onrampProviderState = OnrampV2ProvidersUM.Empty,
)
}
@ -103,23 +90,6 @@ internal class OnrampV2StateFactory(
}
}
private fun getNoPairsErrorState(): OnrampV2MainComponentUM {
val state = currentStateProvider()
val contentState = state as? OnrampV2MainComponentUM.Content ?: return state
return contentState.copy(
continueButtonConfig = contentState.continueButtonConfig.copy(enabled = false),
amountBlockState = contentState.amountBlockState.copy(
amountFieldModel = contentState.amountBlockState.amountFieldModel.copy(isError = true),
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Error(
error = resourceReference(R.string.onramp_no_available_providers),
),
),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
offersBlockState = OnrampOffersBlockUM.Empty,
)
}
fun getErrorState(errorCode: String? = null, onRefresh: () -> Unit): OnrampV2MainComponentUM {
val state = currentStateProvider()
val endButton = when (val button = state.topBarConfig.endButtonUM) {
@ -130,23 +100,15 @@ internal class OnrampV2StateFactory(
return when (state) {
is OnrampV2MainComponentUM.Content -> state.copy(
topBarConfig = state.topBarConfig.copy(endButtonUM = endButton),
continueButtonConfig = state.continueButtonConfig.copy(enabled = false),
amountBlockState = state.amountBlockState.copy(
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content(
stringReference(
BigDecimal.ZERO.format {
crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true)
},
),
),
),
offersBlockState = OnrampOffersBlockUM.Empty,
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
errorCode = errorCode,
onRefresh = onRefresh,
),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
onrampProviderState = OnrampV2ProvidersUM.Empty,
amountBlockState = state.amountBlockState.copy(
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
),
)
is OnrampV2MainComponentUM.InitialLoading -> state.copy(
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
@ -157,6 +119,22 @@ internal class OnrampV2StateFactory(
}
}
private fun getNoPairsErrorState(): OnrampV2MainComponentUM {
val state = currentStateProvider()
val contentState = state as? OnrampV2MainComponentUM.Content ?: return state
return contentState.copy(
amountBlockState = contentState.amountBlockState.copy(
amountFieldModel = contentState.amountBlockState.amountFieldModel.copy(isError = true),
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Error(
error = resourceReference(R.string.onramp_no_available_providers),
),
),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
offersBlockState = OnrampOffersBlockUM.Empty,
)
}
private fun getInitialAmountBlockState(currency: OnrampCurrency): OnrampNewAmountBlockUM {
return OnrampNewAmountBlockUM(
currencyUM = OnrampNewCurrencyUM(
@ -185,13 +163,7 @@ internal class OnrampV2StateFactory(
isValuePasted = false,
onValuePastedTriggerDismiss = {},
),
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content(
stringReference(
BigDecimal.ZERO.format {
crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true)
},
),
),
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
)
}

View file

@ -1,6 +1,5 @@
package com.tangem.features.onramp.mainv2.model
import androidx.compose.runtime.mutableStateOf
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -15,7 +14,6 @@ import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.main.entity.OnrampLastUpdate
import com.tangem.features.onramp.mainv2.OnrampV2MainComponent
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory
@ -42,7 +40,7 @@ internal class OnrampV2MainComponentModel @Inject constructor(
private val getOnrampCountryUseCase: GetOnrampCountryUseCase,
private val clearOnrampCacheUseCase: ClearOnrampCacheUseCase,
private val fetchQuotesUseCase: OnrampFetchQuotesUseCase,
private val getOnrampQuotesUseCase: GetOnrampV2QuotesUseCase,
private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase,
private val fetchPairsUseCase: OnrampFetchPairsUseCase,
private val amountInputManager: InputManager,
private val getOnrampOffersUseCase: GetOnrampOffersUseCase,
@ -52,8 +50,6 @@ internal class OnrampV2MainComponentModel @Inject constructor(
val params = paramsContainer.require<OnrampV2MainComponent.Params>()
private val lastUpdateState = mutableStateOf<OnrampLastUpdate?>(null)
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampAmountButtonUMStateFactory()
}
@ -65,20 +61,23 @@ internal class OnrampV2MainComponentModel @Inject constructor(
)
}
private val stateFactory = OnrampV2StateFactory(
currentStateProvider = Provider { _state.value },
cryptoCurrency = params.cryptoCurrency,
onrampIntents = this,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
)
private val stateFactory: OnrampV2StateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampV2StateFactory(
currentStateProvider = Provider { _state.value },
cryptoCurrency = params.cryptoCurrency,
onrampIntents = this,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
)
}
private val amountStateFactory = OnrampV2AmountStateFactory(
currentStateProvider = Provider { _state.value },
analyticsEventHandler = analyticsEventHandler,
onrampIntents = this,
cryptoCurrency = params.cryptoCurrency,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
)
private val amountStateFactory: OnrampV2AmountStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampV2AmountStateFactory(
currentStateProvider = Provider { _state.value },
analyticsEventHandler = analyticsEventHandler,
onrampIntents = this,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
)
}
private val _state: MutableStateFlow<OnrampV2MainComponentUM> = MutableStateFlow(
value = stateFactory.getInitialState(
@ -97,7 +96,6 @@ internal class OnrampV2MainComponentModel @Inject constructor(
modelScope.launch {
clearOnrampCacheUseCase()
}
sendScreenOpenAnalytics()
checkResidenceCountry()
subscribeToAmountChanges()
@ -139,7 +137,7 @@ internal class OnrampV2MainComponentModel @Inject constructor(
cryptoCurrencySymbol = params.cryptoCurrency.symbol,
providerName = quote.provider.info.name,
paymentMethodName = quote.paymentMethod.name,
)?.let { analyticsEventHandler::send }
)?.let(analyticsEventHandler::send)
params.openRedirectPage(quote)
}
@ -160,27 +158,23 @@ internal class OnrampV2MainComponentModel @Inject constructor(
modelScope.launch {
clearOnrampCacheUseCase.invoke()
checkResidenceCountry()
}
}
override fun onContinueClick() {
val currentState = _state.value
if (currentState is OnrampV2MainComponentUM.Content) {
_state.update { amountStateFactory.getShowProvidersState() }
handleOnrampAvailable()
}
}
fun onStart() {
quotesTaskScheduler.scheduleTask(
scope = modelScope,
task = loadQuotesTask(),
)
startLoadingQuotes()
}
fun onStop() {
quotesTaskScheduler.cancelTask()
}
fun handleOnrampAvailable() {
subscribeToCountryAndCurrencyUpdates()
subscribeToQuotesUpdate()
}
private fun startLoadingQuotes() {
quotesTaskScheduler.cancelTask()
quotesTaskScheduler.scheduleTask(scope = modelScope, task = loadQuotesTask())
@ -235,7 +229,9 @@ internal class OnrampV2MainComponentModel @Inject constructor(
maybeOffers.fold(
ifLeft = ::handleOnrampError,
ifRight = { offers ->
_state.update { onrampOffersStateFactory.getOnShowOffersState(offers) }
if (offers.isNotEmpty()) {
_state.update { onrampOffersStateFactory.getOffersState(offers) }
}
},
)
}
@ -245,7 +241,6 @@ internal class OnrampV2MainComponentModel @Inject constructor(
amountInputManager.query
.filter(String::isNotEmpty)
.collectLatest { _ ->
_state.update { amountStateFactory.getAmountSecondaryLoadingState() }
startLoadingQuotes()
}
}
@ -288,74 +283,35 @@ internal class OnrampV2MainComponentModel @Inject constructor(
private fun handleQuoteResult(quotes: List<OnrampQuote>) {
sendOnrampQuotesErrorAnalytic(quotes)
val quote = selectOrUpdateQuote(quotes)
if (quote == null) {
_state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
lastUpdateState.value = null
return
}
_state.update { amountStateFactory.getAmountSecondaryUpdatedState(quote = quote) }
}
private fun selectOrUpdateQuote(quotes: List<OnrampQuote>): OnrampQuote? {
val quoteToCheck = quotes.firstOrNull { it !is OnrampQuote.Error }
// Check if amount, country or currency has changed
val newQuote = if (checkLastInputState(quoteToCheck)) {
quoteToCheck
} else {
val state = state.value as? OnrampV2MainComponentUM.Content
val providerState = state?.onrampProviderState as? OnrampV2ProvidersUM.Content
// Get current selected quote to update
val lastSelectedQuote = quotes.firstOrNull {
it.provider.id == providerState?.providerId &&
it.paymentMethod.id == providerState.paymentMethod.id
when {
quotes.isEmpty() -> {
_state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
}
if (lastSelectedQuote is OnrampQuote.Error) {
quoteToCheck
} else {
lastSelectedQuote
quotes.all { it is OnrampQuote.AmountError } -> {
_state.update { amountStateFactory.getSecondaryFieldAmountErrorState(quotes) }
}
else -> {
_state.update { amountStateFactory.getAmountSecondaryFieldResetState() }
}
}
newQuote?.let { updateProvider(newQuote) }
return newQuote
}
private fun onRetryQuotes() {
_state.update {
(it as? OnrampV2MainComponentUM.Content)?.copy(
errorNotification = null,
onrampProviderState = OnrampV2ProvidersUM.Loading,
offersBlockState = OnrampOffersBlockUM.Loading(isBlockVisible = false),
amountBlockState = it.amountBlockState.copy(
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading,
),
offersBlockState = OnrampOffersBlockUM.Loading,
amountBlockState = it.amountBlockState.copy(secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty),
) ?: it
}
startLoadingQuotes()
}
private suspend fun updatePairsAndQuotes() {
val state = state.value as? OnrampV2MainComponentUM.Content
if (!state?.amountBlockState?.amountFieldModel?.fiatValue.isNullOrEmpty()) {
_state.update { amountStateFactory.getAmountSecondaryLoadingState() }
}
fetchPairsUseCase.invoke(userWallet, params.cryptoCurrency).fold(
ifLeft = ::handleOnrampError,
ifRight = {
_state.update {
if (!state?.amountBlockState?.amountFieldModel?.fiatValue.isNullOrEmpty()) {
return@fold
} else {
amountStateFactory.getAmountSecondaryResetState()
}
}
_state.update { amountStateFactory.getAmountSecondaryFieldResetState() }
},
)
startLoadingQuotes()
@ -366,18 +322,6 @@ internal class OnrampV2MainComponentModel @Inject constructor(
_state.update { stateFactory.getOnrampErrorState(onrampError) }
}
private fun updateProvider(quote: OnrampQuote) {
lastUpdateState.value = OnrampLastUpdate(
fromAmount = quote.fromAmount,
countryCode = quote.countryCode,
paymentMethod = quote.paymentMethod,
)
_state.update {
amountStateFactory.getUpdatedProviderState(selectedQuote = quote)
}
}
private fun sendOnrampQuotesErrorAnalytic(quotes: List<OnrampQuote>) {
quotes.forEach { errorState ->
when (errorState) {
@ -398,11 +342,6 @@ internal class OnrampV2MainComponentModel @Inject constructor(
}
}
private fun checkLastInputState(quote: OnrampQuote?): Boolean {
return lastUpdateState.value?.fromAmount != quote?.fromAmount ||
lastUpdateState.value?.countryCode != quote?.countryCode
}
private fun sendScreenOpenAnalytics() {
analyticsEventHandler.send(
OnrampAnalyticsEvent.ScreenOpened(

View file

@ -14,16 +14,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.Keyboard
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.OnrampAmountButtonUM
import com.tangem.features.onramp.mainv2.entity.OnrampOffersBlockUM
import com.tangem.features.onramp.mainv2.entity.OnrampV2AmountButtonUMState
import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM
@ -33,14 +30,12 @@ internal fun OnrampFooterContent(
boxScope: BoxScope,
modifier: Modifier = Modifier,
) {
val keyboardController = LocalSoftwareKeyboardController.current
boxScope.apply {
AnimatedVisibility(
modifier = Modifier
.imePadding()
.align(Alignment.BottomCenter),
visible = state.offersBlockState.isBlockVisible.not(),
visible = state.offersBlockState is OnrampOffersBlockUM.Empty,
enter = slideInVertically(
initialOffsetY = { it },
animationSpec = tween(durationMillis = 300),
@ -55,17 +50,6 @@ internal fun OnrampFooterContent(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
text = stringResourceSafe(id = R.string.common_continue),
onClick = {
state.continueButtonConfig.onClick()
keyboardController?.hide()
},
enabled = state.continueButtonConfig.enabled,
)
SpacerH(16.dp)
OnrampAmountButtons(state = state.onrampAmountButtonUMState)
}

View file

@ -73,7 +73,8 @@ private fun InitialLoading(state: OnrampV2MainComponentUM.InitialLoading, modifi
Column(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
.wrapContentHeight()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
OnrampAmountContentLoading()

View file

@ -1,13 +1,10 @@
package com.tangem.features.onramp.mainv2.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
@ -43,19 +40,8 @@ import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
AnimatedVisibility(
visible = state.isBlockVisible,
enter = slideInVertically(
initialOffsetY = { it },
animationSpec = tween(durationMillis = 300),
),
exit = slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(durationMillis = 300),
),
label = "Offers block animation",
) {
if (state is OnrampOffersBlockUM.Content) {
when (state) {
is OnrampOffersBlockUM.Content -> {
Column(modifier = Modifier.fillMaxWidth()) {
state.recentOffer?.let { recentOffer ->
Column {
@ -100,6 +86,15 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
}
}
}
OnrampOffersBlockUM.Loading -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
LoadingBlock()
}
}
OnrampOffersBlockUM.Empty -> Unit
}
}
@ -120,13 +115,18 @@ internal fun Offer(onrampOfferUM: OnrampOfferUM, modifier: Modifier = Modifier)
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
OfferHeader(advantage = onrampOfferUM.advantages)
RateBlock(rate = onrampOfferUM.rate, diff = onrampOfferUM.diff)
RateBlock(
rate = onrampOfferUM.rate,
diff = onrampOfferUM.diff,
isOfferUnavailable = onrampOfferUM.advantages == OnrampOfferAdvantagesUM.Unavailable,
)
}
SpacerWMax()
SecondaryButton(
PrimaryButton(
size = TangemButtonSize.RoundedAction,
text = stringResourceSafe(R.string.common_buy),
onClick = onrampOfferUM.onBuyClicked,
enabled = onrampOfferUM.advantages != OnrampOfferAdvantagesUM.Unavailable,
)
}
SpacerH(10.dp)
@ -142,6 +142,14 @@ internal fun Offer(onrampOfferUM: OnrampOfferUM, modifier: Modifier = Modifier)
@Composable
private fun OfferHeader(advantage: OnrampOfferAdvantagesUM) {
when (advantage) {
OnrampOfferAdvantagesUM.GreatRate -> {
Text(
text = stringResourceSafe(R.string.express_provider_great_rate),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.accent,
modifier = Modifier.testTag(OnrampOffersBlockTestTags.BEST_RATE_TITLE),
)
}
OnrampOfferAdvantagesUM.Default -> {
Text(
text = stringResourceSafe(R.string.onramp_title_you_get),
@ -169,27 +177,24 @@ private fun OfferHeader(advantage: OnrampOfferAdvantagesUM) {
}
}
OnrampOfferAdvantagesUM.Fastest -> {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_fastest_16),
tint = TangemTheme.colors.icon.attention,
contentDescription = null,
)
Text(
text = stringResourceSafe(R.string.onramp_offer_type_fastet),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.attention,
)
}
Text(
text = stringResourceSafe(R.string.onramp_offer_type_fastet),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.attention,
)
}
OnrampOfferAdvantagesUM.Unavailable -> {
Text(
text = stringResourceSafe(R.string.onramp_title_available_from),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
@Composable
private fun RateBlock(rate: String, diff: TextReference?) {
private fun RateBlock(rate: String, diff: TextReference?, isOfferUnavailable: Boolean) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
@ -197,7 +202,7 @@ private fun RateBlock(rate: String, diff: TextReference?) {
Text(
text = rate,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
color = if (isOfferUnavailable) TangemTheme.colors.text.tertiary else TangemTheme.colors.text.primary1,
modifier = Modifier.testTag(OnrampOffersBlockTestTags.OFFER_TOKEN_AMOUNT),
)
diff?.let {
@ -314,7 +319,7 @@ internal fun TimingBlock(speed: PaymentMethodType.PaymentSpeed) {
}
@Composable
fun DrawDot(color: Color) {
private fun DrawDot(color: Color) {
Spacer(
modifier = Modifier
.size(4.dp)
@ -330,6 +335,30 @@ fun DrawDot(color: Color) {
)
}
@Composable
private fun LoadingBlock(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
SpacerH(100.dp)
Text(
text = stringResourceSafe(R.string.onramp_fetching_best_rates),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
SpacerH(8.dp)
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = TangemTheme.colors.icon.informative,
strokeWidth = TangemTheme.dimens.size2,
)
}
}
// will be hardcoded till server will be ready to provide this values
private const val FEW_MINS_VALUE = "3-5"
private const val FEW_DAYS_VALUE = 3
@ -340,7 +369,6 @@ private const val PLENTY_DAYS_VALUE = 5
@Composable
private fun OnrampOffersContentPreview() {
val state = OnrampOffersBlockUM.Content(
isBlockVisible = true,
recentOffer = OnrampOfferUM(
category = OnrampOfferCategoryUM.RecentlyUsed,
advantages = OnrampOfferAdvantagesUM.Default,
@ -350,7 +378,6 @@ private fun OnrampOffersContentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "providerId3",
providerName = "Simplex",
rate = "0,00045334 BTC",
diff = stringReference("27%"),
@ -359,14 +386,13 @@ private fun OnrampOffersContentPreview() {
recommended = persistentListOf(
OnrampOfferUM(
category = OnrampOfferCategoryUM.Recommended,
advantages = OnrampOfferAdvantagesUM.BestRate,
advantages = OnrampOfferAdvantagesUM.GreatRate,
paymentMethod = OnrampPaymentMethod(
id = "card",
name = "Card",
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "providerId1",
providerName = "Simplex",
rate = "0,0245334 BTC",
diff = null,
@ -381,7 +407,6 @@ private fun OnrampOffersContentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "providerId2",
providerName = "Simplex",
rate = "0,00145334 BTC",
diff = stringReference("0.07%"),
@ -393,4 +418,13 @@ private fun OnrampOffersContentPreview() {
TangemThemePreview {
OnrampOffersContent(state)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun OnrampOffersLoadingPreview() {
TangemThemePreview {
OnrampOffersContent(OnrampOffersBlockUM.Loading)
}
}

View file

@ -2,7 +2,6 @@ package com.tangem.features.onramp.mainv2.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@ -22,12 +21,10 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.extensions.resolveReference
@ -36,20 +33,12 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.BuyTokenDetailsScreenTestTags
import com.tangem.core.ui.utils.rememberDecimalFormat
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.OnrampNewAmountSecondaryFieldUM
import com.tangem.features.onramp.mainv2.entity.OnrampNewCurrencyUM
import com.tangem.features.onramp.mainv2.entity.OnrampSecondaryFieldErrorUM
import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM
@Composable
internal fun OnrampV2AmountContent(state: OnrampV2MainComponentUM.Content, modifier: Modifier = Modifier) {
val padding = remember(state.offersBlockState.isBlockVisible) {
if (state.offersBlockState.isBlockVisible) {
22.dp
} else {
46.dp
}
}
Column(
modifier = modifier
.fillMaxWidth()
@ -57,8 +46,8 @@ internal fun OnrampV2AmountContent(state: OnrampV2MainComponentUM.Content, modif
color = TangemTheme.colors.background.action,
shape = RoundedCornerShape(size = TangemTheme.dimens.radius16),
)
.padding(vertical = padding)
.animateContentSize(animationSpec = tween(durationMillis = 300)),
.padding(vertical = 24.dp, horizontal = 16.dp)
.animateContentSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
OnrampHeaderTitle()
@ -68,8 +57,12 @@ internal fun OnrampV2AmountContent(state: OnrampV2MainComponentUM.Content, modif
currencyCode = state.amountBlockState.currencyUM.code,
)
AnimatedVisibility(!state.offersBlockState.isBlockVisible) {
OnrampAmountSecondary(state = state.amountBlockState.secondaryFieldModel)
AnimatedVisibility(
visible = state.amountBlockState.secondaryFieldModel !is OnrampSecondaryFieldErrorUM.Empty,
) {
if (state.amountBlockState.secondaryFieldModel is OnrampSecondaryFieldErrorUM.Error) {
OnrampAmountSecondary(state = state.amountBlockState.secondaryFieldModel)
}
}
SpacerH(20.dp)
@ -130,7 +123,7 @@ private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: Strin
}
@Composable
private fun OnrampAmountSecondary(state: OnrampNewAmountSecondaryFieldUM) {
private fun OnrampAmountSecondary(state: OnrampSecondaryFieldErrorUM.Error) {
Box(
modifier = Modifier
.fillMaxWidth()
@ -142,24 +135,12 @@ private fun OnrampAmountSecondary(state: OnrampNewAmountSecondaryFieldUM) {
.testTag(BuyTokenDetailsScreenTestTags.TOKEN_AMOUNT),
contentAlignment = Alignment.Center,
) {
when (state) {
is OnrampNewAmountSecondaryFieldUM.Content -> Text(
text = state.amount.resolveReference(),
style = TangemTheme.typography.caption2.copy(textDirection = TextDirection.ContentOrLtr),
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
is OnrampNewAmountSecondaryFieldUM.Error -> Text(
text = state.error.resolveReference(),
color = TangemTheme.colors.text.warning,
style = TangemTheme.typography.caption2,
textAlign = TextAlign.Center,
)
is OnrampNewAmountSecondaryFieldUM.Loading -> TextShimmer(
style = TangemTheme.typography.caption2,
modifier = Modifier.width(TangemTheme.dimens.size62),
)
}
Text(
text = state.error.resolveReference(),
color = TangemTheme.colors.text.warning,
style = TangemTheme.typography.caption2,
textAlign = TextAlign.Center,
)
}
}

View file

@ -21,7 +21,7 @@ internal fun PaymentMethodIcon(imageUrl: String, modifier: Modifier = Modifier)
.size(TangemTheme.dimens.size40)
.clip(TangemTheme.shapes.roundedCorners8)
.background(TangemColorPalette.Light1)
.padding(TangemTheme.dimens.spacing6)
.padding(TangemTheme.dimens.spacing4)
.testTag(SelectProviderBottomSheetTestTags.PAYMENT_METHOD_ICON),
model = ImageRequest.Builder(context = LocalContext.current)
.data(imageUrl)

View file

@ -4,14 +4,17 @@ import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUMData
import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer
import com.tangem.features.onramp.tokenlist.entity.utils.OnrampTokenItemStateConverterFactory
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.plus
import kotlinx.collections.immutable.toPersistentList
internal class UpdateAccountTokenListTransformer(
@ -46,7 +49,12 @@ internal class UpdateAccountTokenListTransformer(
)
} else {
TokenListUMData.TokenList(
tokensList = accountList.flatMap { (_, currencyList) ->
tokensList = persistentListOf(
TokensListItemUM.GroupTitle(
id = "available_tokens_title",
text = resourceReference(R.string.exchange_tokens_available_tokens_header),
),
) + accountList.flatMap { (_, currencyList) ->
currencyList.asSequence().map { (isAvailable, status) ->
if (isAvailable) {
availableConverter.convert(status)