Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-30 15:58:51 +03:00
commit acf110dbcc
30 changed files with 171 additions and 63 deletions

View file

@ -12,6 +12,7 @@ internal interface ConfirmResidencyComponent : ComposableBottomSheetComponent {
val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
val country: OnrampCountry,
val launchSepa: Boolean,
val onDismiss: () -> Unit,
)

View file

@ -9,12 +9,14 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.onramp.OnrampSaveDefaultCountryUseCase
import com.tangem.domain.onramp.OnrampSaveDefaultCurrencyUseCase
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.features.onramp.confirmresidency.ConfirmResidencyComponent
import com.tangem.features.onramp.confirmresidency.entity.ConfirmResidencyBottomSheetConfig
import com.tangem.features.onramp.confirmresidency.entity.ConfirmResidencyUM
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.utils.model.EUR_CURRENCY
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
@ -26,6 +28,7 @@ internal class ConfirmResidencyModel @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val router: Router,
private val saveDefaultCountryUseCase: OnrampSaveDefaultCountryUseCase,
private val onrampSaveDefaultCurrencyUseCase: OnrampSaveDefaultCurrencyUseCase,
paramsContainer: ParamsContainer,
) : Model() {
@ -54,6 +57,10 @@ internal class ConfirmResidencyModel @Inject constructor(
analyticsEventHandler.send(OnrampAnalyticsEvent.OnResidenceConfirm(country.name))
modelScope.launch {
saveDefaultCountryUseCase.invoke(country)
if (params.launchSepa) {
onrampSaveDefaultCurrencyUseCase.invoke(EUR_CURRENCY)
}
params.onDismiss()
}
},

View file

@ -57,6 +57,7 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
country = config.country,
launchSepa = params.launchSepa,
onDismiss = {
model.bottomSheetNavigation.dismiss()
model.handleOnrampAvailable()

View file

@ -3,7 +3,7 @@ package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
interface OnrampIntents {
fun onAmountValueChanged(value: String)
fun onAmountValueChanged(value: String, isValuePasted: Boolean)
fun openSettings()
fun openCurrenciesList()
fun onBuyClick(quote: OnrampProviderWithQuote.Data)

View file

@ -1,8 +1,10 @@
package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampAmount
import com.tangem.domain.onramp.model.OnrampPaymentMethod
data class OnrampLastUpdate(
val lastAmount: OnrampAmount,
val lastCountryString: String,
val fromAmount: OnrampAmount,
val countryCode: String,
val paymentMethod: OnrampPaymentMethod,
)

View file

@ -122,7 +122,7 @@ internal class OnrampStateFactory(
amountFieldModel = AmountFieldModel(
value = "",
fiatValue = "",
onValueChange = onrampIntents::onAmountValueChanged,
onValueChange = { onrampIntents.onAmountValueChanged(value = it, isValuePasted = false) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.None,
keyboardType = KeyboardType.Number,

View file

@ -15,9 +15,12 @@ import java.math.BigDecimal
internal class OnrampAmountFieldChangeConverter(
private val currentStateProvider: Provider<OnrampMainComponentUM>,
) : Converter<String, OnrampMainComponentUM> {
) : Converter<OnrampAmountFieldChangeConverter.Input, OnrampMainComponentUM> {
override fun convert(input: Input): OnrampMainComponentUM {
val value = input.value
val isValuePasted = input.isValuePasted
override fun convert(value: String): OnrampMainComponentUM {
val state = currentStateProvider()
if (state !is OnrampMainComponentUM.Content) return state
@ -34,6 +37,7 @@ internal class OnrampAmountFieldChangeConverter(
imeAction = if (isDoneActionEnabled) ImeAction.Done else ImeAction.None,
keyboardType = KeyboardType.Number,
),
isValuePasted = isValuePasted,
)
return state.copy(
@ -66,4 +70,6 @@ internal class OnrampAmountFieldChangeConverter(
providerBlockState = OnrampProviderBlockUM.Empty,
)
}
data class Input(val value: String, val isValuePasted: Boolean)
}

View file

@ -33,7 +33,8 @@ internal class OnrampAmountStateFactory(
currentStateProvider = currentStateProvider,
)
fun getOnAmountValueChange(value: String) = onrampAmountFieldChangeConverter.convert(value)
fun getOnAmountValueChange(value: String, isValuePasted: Boolean) =
onrampAmountFieldChangeConverter.convert(OnrampAmountFieldChangeConverter.Input(value, isValuePasted))
fun getUpdatedCurrencyState(currency: OnrampCurrency): OnrampMainComponentUM {
val currentState = currentStateProvider()

View file

@ -32,6 +32,7 @@ import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory
import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory.Companion.PREDEFINED_SEPA_AMOUNT
import com.tangem.features.onramp.main.entity.factory.amount.OnrampAmountStateFactory
import com.tangem.features.onramp.providers.entity.SelectProviderResult
import com.tangem.features.onramp.utils.model.EUR_CURRENCY
import com.tangem.features.onramp.utils.sendOnrampErrorEvent
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -68,7 +69,7 @@ internal class OnrampMainComponentModel @Inject constructor(
private val params: OnrampMainComponent.Params = paramsContainer.require()
private var isSepaLaunched = false
private var shouldForceChooseSepa = params.launchSepa
private var currencyToRestore: OnrampCurrency? = null
val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId }
@ -132,6 +133,10 @@ internal class OnrampMainComponentModel @Inject constructor(
fun onProviderSelected(result: SelectProviderResult, isBestRate: Boolean) {
_state.update { amountStateFactory.getAmountSecondaryUpdatedState(result, isBestRate) }
if (result.paymentMethod.id != SEPA_METHOD_ID) {
shouldForceChooseSepa = false
}
}
private fun checkResidenceCountry() {
@ -171,7 +176,7 @@ internal class OnrampMainComponentModel @Inject constructor(
updatePairsAndQuotes()
if (wasInitialLoading && params.launchSepa) {
onAmountValueChanged(PREDEFINED_SEPA_AMOUNT)
onAmountValueChanged(value = PREDEFINED_SEPA_AMOUNT, isValuePasted = true)
}
},
)
@ -243,8 +248,8 @@ internal class OnrampMainComponentModel @Inject constructor(
.launchIn(modelScope)
}
override fun onAmountValueChanged(value: String) {
_state.update { amountStateFactory.getOnAmountValueChange(value) }
override fun onAmountValueChanged(value: String, isValuePasted: Boolean) {
_state.update { amountStateFactory.getOnAmountValueChange(value, isValuePasted) }
modelScope.launch { amountInputManager.update(value) }
}
@ -341,9 +346,7 @@ internal class OnrampMainComponentModel @Inject constructor(
private fun selectOrUpdateQuote(quotes: List<OnrampQuote>): OnrampQuote? {
val quoteToCheck = quotes.firstOrNull { it !is OnrampQuote.Error }
val sepaQuote = if (params.launchSepa && !isSepaLaunched) {
isSepaLaunched = true
val bestSepaQuote = if (params.launchSepa && shouldForceChooseSepa) {
quotes.filterIsInstance<OnrampQuote.Data>()
.filter { it.paymentMethod.id == SEPA_METHOD_ID }
.maxByOrNull { it.toAmount.value }
@ -352,7 +355,7 @@ internal class OnrampMainComponentModel @Inject constructor(
}
// Check if amount, country or currency has changed
val newQuote = sepaQuote ?: if (checkLastInputState(quoteToCheck)) {
val newQuote = bestSepaQuote ?: if (isAmountOrCountryChanged(quoteToCheck)) {
quoteToCheck
} else {
val state = state.value as? OnrampMainComponentUM.Content
@ -371,7 +374,9 @@ internal class OnrampMainComponentModel @Inject constructor(
lastSelectedQuote
}
}
newQuote?.let { updateProvider(newQuote, quotes) }
if (newQuote != null) {
updateProvider(newQuote, quotes)
}
return newQuote
}
@ -380,8 +385,13 @@ internal class OnrampMainComponentModel @Inject constructor(
lastUpdateState.value = OnrampLastUpdate(
quote.fromAmount,
quote.countryCode,
quote.paymentMethod,
)
if (quote.paymentMethod.id != SEPA_METHOD_ID) {
shouldForceChooseSepa = false
}
_state.update {
amountStateFactory.getUpdatedProviderState(selectedQuote = quote, quotes = quotes)
}
@ -447,22 +457,14 @@ internal class OnrampMainComponentModel @Inject constructor(
}
}
private fun checkLastInputState(quote: OnrampQuote?): Boolean {
return lastUpdateState.value?.lastAmount != quote?.fromAmount ||
lastUpdateState.value?.lastCountryString != quote?.countryCode
private fun isAmountOrCountryChanged(quote: OnrampQuote?): Boolean {
return lastUpdateState.value?.fromAmount != quote?.fromAmount ||
lastUpdateState.value?.countryCode != quote?.countryCode
}
private companion object {
const val UPDATE_DELAY = 10_000L
const val SEPA_METHOD_ID = "sepa"
val EUR_CURRENCY = OnrampCurrency(
code = "EUR",
name = "Euro",
unit = "",
precision = 2,
image = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/Currencies/EUR.png",
)
}
}

View file

@ -58,6 +58,7 @@ internal class DefaultOnrampV2MainComponent @AssistedInject constructor(
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
country = config.country,
launchSepa = false,
onDismiss = { model.bottomSheetNavigation.dismiss() },
),
)

View file

@ -348,8 +348,9 @@ internal class OnrampV2MainComponentModel @Inject constructor(
private fun updateProvider(quote: OnrampQuote) {
lastUpdateState.value = OnrampLastUpdate(
quote.fromAmount,
quote.countryCode,
fromAmount = quote.fromAmount,
countryCode = quote.countryCode,
paymentMethod = quote.paymentMethod,
)
_state.update {
@ -378,8 +379,8 @@ internal class OnrampV2MainComponentModel @Inject constructor(
}
private fun checkLastInputState(quote: OnrampQuote?): Boolean {
return lastUpdateState.value?.lastAmount != quote?.fromAmount ||
lastUpdateState.value?.lastCountryString != quote?.countryCode
return lastUpdateState.value?.fromAmount != quote?.fromAmount ||
lastUpdateState.value?.countryCode != quote?.countryCode
}
private fun sendScreenOpenAnalytics() {

View file

@ -0,0 +1,11 @@
package com.tangem.features.onramp.utils.model
import com.tangem.domain.onramp.model.OnrampCurrency
internal val EUR_CURRENCY = OnrampCurrency(
code = "EUR",
name = "Euro",
unit = "",
precision = 2,
image = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/Currencies/EUR.png",
)

View file

@ -510,7 +510,7 @@ internal class SendConfirmModel @Inject constructor(
val txUrl = getExplorerTransactionUrlUseCase(
txHash = txData.hash.orEmpty(),
networkId = cryptoCurrency.network.id,
).getOrElse { "" }
).getOrNull().orEmpty()
_uiState.update(SendConfirmSentStateTransformer(txData, txUrl))
}

View file

@ -76,7 +76,7 @@ internal class SendConfirmSuccessModel @Inject constructor(
},
),
prevButton = null,
secondaryPairButtonsUM = NavigationButton(
secondaryPairButtonsUM = (NavigationButton(
textReference = resourceReference(R.string.common_explore),
iconRes = R.drawable.ic_web_24,
onClick = ::onExploreClick,
@ -84,7 +84,7 @@ internal class SendConfirmSuccessModel @Inject constructor(
textReference = resourceReference(R.string.common_share),
iconRes = R.drawable.ic_share_24,
onClick = ::onShareClick,
),
)).takeIf { params.txUrl.isNotEmpty() },
),
),
)

View file

@ -498,7 +498,7 @@ internal class NFTSendConfirmModel @Inject constructor(
iconRes = R.drawable.ic_share_24,
onClick = ::onShareClick,
)
).takeIf { confirmUM is ConfirmUM.Success },
).takeUnless { (confirmUM as? ConfirmUM.Success)?.txUrl.isNullOrBlank() },
),
),
)

View file

@ -52,7 +52,7 @@ internal class SendWithSwapSuccessModel @Inject constructor(
onClick = appRouter::pop,
),
prevButton = null,
secondaryPairButtonsUM = NavigationButton(
secondaryPairButtonsUM = (NavigationButton(
textReference = resourceReference(R.string.common_explore),
iconRes = R.drawable.ic_web_24,
onClick = ::onExploreClick,
@ -60,7 +60,7 @@ internal class SendWithSwapSuccessModel @Inject constructor(
textReference = resourceReference(R.string.common_share),
iconRes = R.drawable.ic_share_24,
onClick = ::onShareClick,
),
)).takeUnless { confirmUM?.txUrl.isNullOrBlank() },
),
)
}

View file

@ -410,7 +410,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
rateType = RateType.FLOAT,
)
return if (isBalanceWithoutFeeEnough) {
return if (isBalanceWithoutFeeEnough && maybeQuotes.isRight()) {
provider to loadDexSwapData(
provider = provider,
networkId = networkId,

View file

@ -32,6 +32,7 @@ import com.tangem.domain.transaction.error.SendTransactionError.UserCancelledErr
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.walletconnect.WcAnalyticEvents
import com.tangem.domain.walletconnect.WcAnalyticEvents.SignatureRequestReceived.EmulationStatus
import com.tangem.domain.walletconnect.WcAnalyticEvents.SolanaLargeTransaction
import com.tangem.domain.walletconnect.WcRequestUseCaseFactory
import com.tangem.domain.walletconnect.model.WcRequestError
import com.tangem.domain.walletconnect.model.WcRequestError.Companion.message
@ -143,6 +144,7 @@ internal class WcSendTransactionModel @Inject constructor(
wcApproval = useCase as? WcApproval
sign = {
if (isMultipleSignRequired(useCase)) {
analytics.send(SolanaLargeTransaction(useCase.rawSdkRequest.dAppMetaData.name))
openMultipleTransaction(useCase)
} else {
useCase.sign()