Updated on 2026-08-14

This commit is contained in:
Tangem 2024-11-28 18:35:01 +05:00
parent 38276b01ea
commit 9378e4fa31
63 changed files with 1434 additions and 75 deletions

View file

@ -10,6 +10,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Suppress("TooManyFunctions")
@Module
@InstallIn(SingletonComponent::class)
internal object OnrampDomainModule {
@ -125,4 +126,35 @@ internal object OnrampDomainModule {
fun provideGetOnrampQuotesUseCase(onrampRepository: OnrampRepository): GetOnrampQuotesUseCase {
return GetOnrampQuotesUseCase(onrampRepository)
}
@Provides
@Singleton
fun provideGetOnrampSelectedPaymentMethodUseCase(
onrampRepository: OnrampRepository,
): GetOnrampSelectedPaymentMethodUseCase {
return GetOnrampSelectedPaymentMethodUseCase(onrampRepository)
}
@Provides
@Singleton
fun provideGetOnrampProviderWithQuoteUseCase(
onrampRepository: OnrampRepository,
): GetOnrampProviderWithQuoteUseCase {
return GetOnrampProviderWithQuoteUseCase(onrampRepository)
}
@Provides
@Singleton
fun provideOnrampSaveSelectedPaymentMethod(onrampRepository: OnrampRepository): OnrampSaveSelectedPaymentMethod {
return OnrampSaveSelectedPaymentMethod(onrampRepository)
}
@Provides
@Singleton
fun provideGetOnrampRedirectUrlUseCase(
onrampRepository: OnrampRepository,
transactionRepository: OnrampTransactionRepository,
): GetOnrampRedirectUrlUseCase {
return GetOnrampRedirectUrlUseCase(onrampRepository, transactionRepository)
}
}

View file

@ -56,14 +56,14 @@ object TradeCryptoMiddleware {
private fun proceedBuyAction(state: () -> AppState?, action: TradeCryptoAction.Buy) {
val isOnrampEnabled = store.inject(DaggerGraphState::onrampFeatureToggles).isFeatureEnabled
if (isOnrampEnabled) {
proceedWithOnramp(action.cryptoCurrencyStatus.currency)
proceedWithOnramp(action.userWallet.walletId, action.cryptoCurrencyStatus.currency)
} else {
proceedWithLegacyBuyAction(state, action)
}
}
private fun proceedWithOnramp(cryptoCurrency: CryptoCurrency) {
store.dispatchNavigationAction { push(AppRoute.Onramp(cryptoCurrency)) }
private fun proceedWithOnramp(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency) {
store.dispatchNavigationAction { push(AppRoute.Onramp(userWalletId = userWalletId, currency = cryptoCurrency)) }
}
private fun proceedWithLegacyBuyAction(state: () -> AppState?, action: TradeCryptoAction.Buy) {

View file

@ -197,7 +197,7 @@ internal class ChildFactory @Inject constructor(
is AppRoute.Onramp -> {
route.asComponentChild(
contextProvider = contextProvider(route, contextFactory),
params = OnrampComponent.Params(route.currency),
params = OnrampComponent.Params(userWalletId = route.userWalletId, cryptoCurrency = route.currency),
componentFactory = onrampComponentFactory,
)
}

View file

@ -295,7 +295,10 @@ sealed class AppRoute(val path: String) : Route {
}
@Serializable
data class Onramp(val currency: CryptoCurrency) : AppRoute(path = "/onramp/${currency.symbol}"), RouteBundleParams {
data class Onramp(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
) : AppRoute(path = "/onramp/${userWalletId.stringValue}/${currency.symbol}"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
}

View file

@ -20,6 +20,18 @@ data class OnrampDataResponse(
val signature: String,
)
@JsonClass(generateAdapter = true)
data class OnrampDataJson(
@Json(name = "widgetUrl")
val widgetUrl: String,
@Json(name = "externalTxId")
val externalTxId: String,
@Json(name = "externalTxUrl")
val externalTxUrl: String?,
)
@JsonClass(generateAdapter = true)
data class OnrampTxDetails(
@Json(name = "fromCurrencyCode")

View file

@ -39,8 +39,8 @@ data class OnrampQuoteResponse(
val maxFromAmount: String,
@Json(name = "minToAmount")
val minToAmount: String,
val minToAmount: String?,
@Json(name = "maxToAmount")
val maxToAmount: String,
val maxToAmount: String?,
)

View file

@ -1,11 +1,14 @@
package com.tangem.datasource.local.onramp.paymentmethods
import com.tangem.datasource.api.onramp.models.response.model.PaymentMethodDTO
import kotlinx.coroutines.flow.Flow
interface OnrampPaymentMethodsStore {
suspend fun getSyncOrNull(key: String): List<PaymentMethodDTO>?
fun get(key: String): Flow<List<PaymentMethodDTO>>
suspend fun store(key: String, value: List<PaymentMethodDTO>)
suspend fun contains(key: String): Boolean

View file

@ -296,7 +296,7 @@
<string name="express_provider_recommended">Empfohlen</string>
<string name="express_status_bought">Gekauft %s</string>
<string name="express_status_buying">Kauf %s</string>
<string name="express_status_buying_active">Kauf %s...</string>
<string name="express_status_buying_active">Kauf %s</string>
<string name="express_token_list_empty_search">Keine Token gefunden. Bitte versuche eine andere Anfrage</string>
<string name="express_transaction_id">ID: %s</string>
<string name="express_transaction_id_copied">Transaktions-ID kopiert</string>
@ -590,8 +590,11 @@
<string name="qr_scanner_camera_denied_settings_button">Einstellungen</string>
<string name="qr_scanner_camera_denied_text">Du hast keinen Zugriff auf deine Kamera gewährt</string>
<string name="qr_scanner_camera_denied_title">Kamerazugriff verweigert</string>
<string name="receive_bottom_sheet_no_memo_required_message">Kein Memo erforderlich</string>
<string name="receive_bottom_sheet_warning_message">%1$s ( %2$s ) im %3$s Netzwerk</string>
<string name="receive_bottom_sheet_warning_message_description">Das Senden einer anderen Währung führt zu deren irreversiblem Verlust.</string>
<string name="receive_bottom_sheet_warning_message_full">Sende nur %s an diese Adresse. Der Versand einer anderen Währung führt zu ihrem unwiderruflichen Verlust.</string>
<string name="receive_bottom_sheet_warning_message_title">Sende nur %s an diese Adresse.</string>
<string name="receive_token_description">Überweise Geld von einem anderen Wallet oder einer anderen Börse</string>
<string name="referral_button_participate">Teilnehmen</string>
<string name="referral_error_failed_to_load_info">Die Informationen zum Empfehlungsprogramm konnten nicht geladen werden. Bitte versuche es später noch einmal.</string>

View file

@ -432,7 +432,6 @@
<string name="markets_token_details_fully_diluted_valuation_description">El valor teórico total de una criptomoneda si todas las monedas que podrían existir estuvieran en circulación, incluidas las que actualmente no circulan.</string>
<string name="markets_token_details_fully_diluted_valuation_full">Valoración totalmente diluida</string>
<string name="markets_token_details_genesis_date">Dato de Genesis</string>
<string name="markets_token_details_genesis_date_description">Vacío</string>
<string name="markets_token_details_high">Alto</string>
<string name="markets_token_details_holders">Titulares</string>
<string name="markets_token_details_holders_description">El cambio en el número de poseedores de tokens dentro de un período de tiempo específico</string>
@ -443,7 +442,6 @@
<string name="markets_token_details_liquidity_description">El cambio en la cantidad de liquidez disponible para el token durante el período de tiempo especificado</string>
<string name="markets_token_details_liquidity_full">Liquidez</string>
<string name="markets_token_details_liquidity_index">Índice de liquidez</string>
<string name="markets_token_details_liquidity_index_description">Vacío</string>
<string name="markets_token_details_listed_on">Listado en</string>
<string name="markets_token_details_low">Bajo</string>
<string name="markets_token_details_market_capitalization">Capital. de mercado</string>
@ -517,12 +515,12 @@
<string name="onboarding_navbar_title_creating_backup">Creando un backup</string>
<string name="onboarding_seed_button_read_more">Leer más sobre seed phrase</string>
<plurals name="onboarding_seed_generate_message_words_count">
<item quantity="one">Vacío</item>
<item quantity="one"></item>
<item quantity="other">Escriba estas %d palabras en el orden que se indica a continuación y guárdelas en un lugar seguro y secreto.</item>
</plurals>
<string name="onboarding_seed_generate_title">Su seed phrase</string>
<plurals name="onboarding_seed_generate_words_count">
<item quantity="one">Vacío</item>
<item quantity="one"></item>
<item quantity="other">%d palabras</item>
</plurals>
<string name="onboarding_seed_import_message">Para importar su billetera, ingrese su seed phrase en el campo de abajo</string>

View file

@ -370,7 +370,7 @@
<string name="manage_tokens_wallet_selector_title">Choisissez un portefeuille</string>
<string name="manage_tokens_wallet_support_only_one_network_title">Le portefeuille ne prend pas en charge plus d\'un réseau</string>
<string name="markets_add_to_my_portfolio_description">Pour acheter, échanger ou recevoir cet actif, ajoutez-le à votre portefeuille</string>
<string name="markets_add_to_my_portfolio_unavailable_description">Cet actif n\'est plus pris en charge dans le portefeuille</string>
<string name="markets_add_to_my_portfolio_unavailable_description">Cet actif n\'est pas pris en charge dans le portefeuille</string>
<string name="markets_add_to_my_portfolio_unavailable_for_wallet_description">Cet actif n\'est pas disponible pour ce portefeuille</string>
<string name="markets_add_to_portfolio_button">Ajouter au portfolio</string>
<string name="markets_add_token">Ajoutez un jeton</string>
@ -432,7 +432,6 @@
<string name="markets_token_details_fully_diluted_valuation_description">La valeur théorique totale d\'une crypto-monnaie si toutes les pièces qui pourraient exister étaient en circulation, y compris celles qui ne circulent pas actuellement</string>
<string name="markets_token_details_fully_diluted_valuation_full">Valorisation entièrement diluée</string>
<string name="markets_token_details_genesis_date">Date de la Genesis</string>
<string name="markets_token_details_genesis_date_description">Vide</string>
<string name="markets_token_details_high">Haut</string>
<string name="markets_token_details_holders">Détenteurs</string>
<string name="markets_token_details_holders_description">L\'évolution du nombre de détenteurs de jetons au cours d\'une période donnée</string>
@ -443,7 +442,6 @@
<string name="markets_token_details_liquidity_description">Le changement dans la quantité de liquidité disponible pour le jeton pendant la période spécifiée</string>
<string name="markets_token_details_liquidity_full">Liquidité</string>
<string name="markets_token_details_liquidity_index">Indice de liquidité</string>
<string name="markets_token_details_liquidity_index_description">Vide</string>
<string name="markets_token_details_listed_on">Listé sur</string>
<string name="markets_token_details_low">Faible</string>
<string name="markets_token_details_market_capitalization">Cap. boursière</string>
@ -517,12 +515,12 @@
<string name="onboarding_navbar_title_creating_backup">Sauvegarde en cours</string>
<string name="onboarding_seed_button_read_more">En savoir plus sur les seed phrases</string>
<plurals name="onboarding_seed_generate_message_words_count">
<item quantity="one">Vide</item>
<item quantity="one"></item>
<item quantity="other">Écrivez ces %d mots dans l\'ordre indiqué ci-dessous et conservez-les dans un endroit sûr et secret.</item>
</plurals>
<string name="onboarding_seed_generate_title">Votre seed phrase</string>
<plurals name="onboarding_seed_generate_words_count">
<item quantity="one">Vide</item>
<item quantity="one"></item>
<item quantity="other">%d mots</item>
</plurals>
<string name="onboarding_seed_import_message">Pour importer votre portefeuille, entrez votre seed phrase dans le champ ci-dessous</string>
@ -607,9 +605,9 @@
<item quantity="one">pour le portefeuille %d</item>
<item quantity="other">pour les portefeuilles %d </item>
</plurals>
<string name="referral_point_currencies_description">Obtiendras ^^%1$s^^ pour chaque portefeuille acheté par votre ami sur votre adresse de réseau %2$s %3$s ^^30 jours après^^</string>
<string name="referral_point_currencies_description">Obtiendrez ^^%1$s^^ pour chaque portefeuille acheté par votre ami sur votre adresse de réseau %2$s %3$s ^^30 jours après^^</string>
<string name="referral_point_currencies_title">Vous</string>
<string name="referral_point_discount_description_prefix">Obtiendrez</string>
<string name="referral_point_discount_description_prefix">Obtiendra</string>
<string name="referral_point_discount_description_suffix">lors de l\'achat d\'un portefeuille sur tangem.com</string>
<string name="referral_point_discount_description_value">%s réduction</string>
<string name="referral_point_discount_title">Votre ami</string>

View file

@ -582,8 +582,11 @@
<string name="qr_scanner_camera_denied_settings_button">設定</string>
<string name="qr_scanner_camera_denied_text">カメラへのアクセスを許可していません。</string>
<string name="qr_scanner_camera_denied_title">カメラへのアクセスが拒否されました</string>
<string name="receive_bottom_sheet_no_memo_required_message">メモ不要</string>
<string name="receive_bottom_sheet_warning_message">%3$sネットワーク上の%1$s ( %2$s )</string>
<string name="receive_bottom_sheet_warning_message_description">他の暗号資産を送信すると、取り返しのつかない損失が発生します。</string>
<string name="receive_bottom_sheet_warning_message_full">このアドレスには%sのみを送金してください。他のトークンを送信すると、取り返しのつかない損失が発生します。</string>
<string name="receive_bottom_sheet_warning_message_title">このアドレスには%sのみを送信してください。</string>
<string name="receive_token_description">ウォレットや取引所から資金を送金する</string>
<string name="referral_button_participate">参加する</string>
<string name="referral_error_failed_to_load_info">紹介プログラムに関する情報を読み込めませんでした。しばらくしてからもう一度お試しください。</string>

View file

@ -297,6 +297,8 @@
<string name="express_provider_not_available">Недоступно для этой пары</string>
<string name="express_provider_permission_needed">Требуется разрешение</string>
<string name="express_provider_recommended">Рекомендовано</string>
<string name="express_status_buying">Покупка %s</string>
<string name="express_status_buying_active">Покупка %s...</string>
<string name="express_token_list_empty_search">Токены не найдены. Пожалуйста, попробуйте другой запрос</string>
<string name="express_transaction_id">ID: %s</string>
<string name="express_transaction_id_copied">ID транзакции скопирован</string>
@ -889,6 +891,7 @@
<string name="token_swap_promotion_button">Обменять</string>
<string name="tokens_list_unavailable_to_purchase_header">Недоступно для покупки</string>
<string name="tokens_list_unavailable_to_sell_header">Недоступно для продажи</string>
<string name="tokens_list_unavailable_to_swap_header">Недоступно для обмена с %s</string>
<string name="transaction_history_contract_address">контракт: %s</string>
<string name="transaction_history_empty_transactions">У вас еще нет транзакций</string>
<string name="transaction_history_error_failed_to_load">Не удалось загрузить историю транзакций.\nНажмите на кнопку перезагрузки, чтобы обновить информацию.</string>

View file

@ -301,7 +301,7 @@
<string name="express_provider_permission_needed">Потрібен дозвіл</string>
<string name="express_provider_recommended">Рекомендовано</string>
<string name="express_status_bought">Купив %s</string>
<string name="express_status_buying">Купівля %s...</string>
<string name="express_status_buying">Купівля %s</string>
<string name="express_status_buying_active">Купівля %s...</string>
<string name="express_token_list_empty_search">Токенів не знайдено. Будь ласка, спробуйте інший запит</string>
<string name="express_transaction_id">ID: %s</string>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<string name="action_buttons_buy_empty_search_message">Token not found in your portfolio? Check the Markets to find and add it for purchase</string>
<string name="action_buttons_sell_empty_search_message">Token not found in your portfolio? Check the Markets to find and add it for sell</string>
<string name="action_buttons_sell_empty_search_message">Token not found in your portfolio? Check the Markets to find and add it for selling</string>
<string name="action_buttons_swap_choose_token">Choose the Token</string>
<string name="action_buttons_swap_empty_search_message">Token not found in your portfolio? Check the Markets to find and add it for swap</string>
<string name="action_buttons_swap_no_available_pair_notification_message">There are no available tokens to swap with the selected token. Please choose another one.</string>
@ -565,6 +565,7 @@
<string name="onboarding_wallet_info_title_fourth">Access code restore</string>
<string name="onboarding_wallet_info_title_second">Identical cards</string>
<string name="onboarding_wallet_info_title_third">Access code</string>
<string name="onramp_choose_provider_title_hint">Providers facilitate transactions, ensuring smooth and efficient token exchanges</string>
<string name="onramp_country_search">Search by country</string>
<string name="onramp_country_unavailable">Unavailable</string>
<string name="onramp_currency_search">Search by currency</string>
@ -590,8 +591,11 @@
<string name="qr_scanner_camera_denied_settings_button">Settings</string>
<string name="qr_scanner_camera_denied_text">You have not given access to your camera</string>
<string name="qr_scanner_camera_denied_title">Camera access denied</string>
<string name="receive_bottom_sheet_no_memo_required_message">No memo required</string>
<string name="receive_bottom_sheet_warning_message">%1$s (%2$s) on %3$s network</string>
<string name="receive_bottom_sheet_warning_message_description">Sending any other currency will result in its irreversible loss.</string>
<string name="receive_bottom_sheet_warning_message_full">Send only %s to this address. Sending any other currency will result in its irreversible loss.</string>
<string name="receive_bottom_sheet_warning_message_title">Send only %s to this address.</string>
<string name="receive_token_description">Transfer funds from any wallet or exchange</string>
<string name="referral_button_participate">Participate</string>
<string name="referral_error_failed_to_load_info">Failed to load the information about the referral program. Please try again later.</string>

View file

@ -20,6 +20,7 @@ dependencies {
/** Domain modules */
implementation(projects.domain.onramp)
implementation(projects.domain.legacy)
// region DI

View file

@ -1,5 +1,7 @@
package com.tangem.data.onramp
import com.squareup.moshi.Moshi
import com.tangem.blockchain.extensions.toBigDecimalOrDefault
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.onramp.converters.CountryConverter
import com.tangem.data.onramp.converters.CurrencyConverter
@ -12,13 +14,16 @@ import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.TangemExpressValues
import com.tangem.datasource.api.express.models.response.ExchangeProvider
import com.tangem.datasource.api.express.models.response.ExchangeProviderType
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.onramp.models.common.OnrampDestinationDTO
import com.tangem.datasource.api.onramp.models.request.OnrampPairsRequest
import com.tangem.datasource.api.onramp.models.response.OnrampDataJson
import com.tangem.datasource.api.onramp.models.response.model.OnrampCountryDTO
import com.tangem.datasource.api.onramp.models.response.model.OnrampCurrencyDTO
import com.tangem.datasource.api.onramp.models.response.model.OnrampPairDTO
import com.tangem.datasource.api.onramp.models.response.model.PaymentMethodDTO
import com.tangem.datasource.crypto.DataSignatureVerifier
import com.tangem.datasource.local.onramp.pairs.OnrampPairsStore
import com.tangem.datasource.local.onramp.paymentmethods.OnrampPaymentMethodsStore
import com.tangem.datasource.local.onramp.quotes.OnrampQuotesStore
@ -28,20 +33,26 @@ import com.tangem.datasource.local.preferences.utils.getObject
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.storeObject
import com.tangem.domain.onramp.model.*
import com.tangem.domain.onramp.model.cache.OnrampTransaction
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.withContext
import org.joda.time.DateTime
import timber.log.Timber
import java.math.BigDecimal
import java.util.UUID
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass", "TooManyFunctions")
internal class DefaultOnrampRepository(
private val onrampApi: OnrampApi,
private val expressApi: TangemExpressApi,
@ -51,12 +62,16 @@ internal class DefaultOnrampRepository(
private val pairsStore: OnrampPairsStore,
private val quotesStore: OnrampQuotesStore,
private val quotesErrorConverter: OnrampQuotesErrorConverter,
private val walletManagersFacade: WalletManagersFacade,
private val dataSignatureVerifier: DataSignatureVerifier,
moshi: Moshi,
) : OnrampRepository {
private val currencyConverter = CurrencyConverter()
private val countryConverter = CountryConverter(currencyConverter)
private val statusConverter = StatusConverter()
private val paymentMethodsConverter = PaymentMethodConverter()
private val onrampDataAdapter = moshi.adapter(OnrampDataJson::class.java)
override suspend fun getCurrencies(): List<OnrampCurrency> = withContext(dispatchers.io) {
onrampApi.getCurrencies()
@ -194,14 +209,19 @@ internal class DefaultOnrampRepository(
providerId = provider.id,
).bind()
OnrampQuote.Data(
fromAmount = convertToBigDecimal(response.fromAmount),
toAmount = convertToBigDecimal(response.toAmount),
minFromAmount = convertToBigDecimal(response.minFromAmount),
maxFromAmount = convertToBigDecimal(response.maxFromAmount),
fromAmount = OnrampAmount(
value = response.fromAmount
.toBigDecimalOrDefault()
.movePointLeft(currency.precision),
decimals = currency.precision,
symbol = amount.currencySymbol,
),
toAmount = convertToAmount(response.toAmount, cryptoCurrency),
minFromAmount = convertToAmount(response.minFromAmount, cryptoCurrency),
maxFromAmount = convertToAmount(response.maxFromAmount, cryptoCurrency),
paymentMethod = paymentMethod,
provider = provider,
)
null
},
onError = { error ->
if (error is ApiResponseError.HttpException) {
@ -230,6 +250,69 @@ internal class DefaultOnrampRepository(
return quotesStore.get(QUOTES_KEY)
}
override suspend fun getQuotesSync(): List<OnrampQuote>? {
return quotesStore.getSyncOrNull(QUOTES_KEY)
}
override suspend fun getOnrampData(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
quote: OnrampProviderWithQuote.Data,
): OnrampTransaction = withContext(dispatchers.io) {
try {
val address = requireNotNull(
value = walletManagersFacade.getDefaultAddress(userWalletId, cryptoCurrency.network),
lazyMessage = { "Address must not be null" },
)
val fromAmountString = quote.fromAmount.value.movePointRight(quote.fromAmount.decimals).toString()
val currency = requireNotNull(getDefaultCurrencySync()) { "Default currency must not be null" }
val country = requireNotNull(getDefaultCountrySync()) { "Default country must not be null" }
val data = safeApiCall(
call = {
onrampApi.getData(
fromCurrencyCode = currency.code,
toContractAddress = cryptoCurrency.getContractAddress(),
toNetwork = cryptoCurrency.network.backendId,
paymentMethod = quote.paymentMethod.id,
countryCode = country.code,
fromAmount = fromAmountString,
toDecimals = cryptoCurrency.decimals,
providerId = quote.provider.id,
toAddress = address,
redirectUrl = "tangem://redirect?action=dismissBrowser",
language = null,
theme = null,
requestId = UUID.randomUUID().toString(),
).bind()
},
onError = { e ->
Timber.e(e)
throw e
},
)
if (dataSignatureVerifier.verifySignature(data.signature, data.dataJson)) {
val dataJson = requireNotNull(onrampDataAdapter.fromJson(data.dataJson)) {
"Can not parse dataJson. ${data.dataJson}"
}
createOnrampTransaction(
txId = data.txId,
onrampDataJson = dataJson,
userWalletId = userWalletId,
currency = currency,
provider = quote.provider,
cryptoCurrency = cryptoCurrency,
fromAmount = quote.fromAmount.value,
toAmount = quote.toAmount.value,
)
} else {
error("Express signature invalid")
}
} catch (e: Exception) {
Timber.e(e)
throw e
}
}
override suspend fun getPaymentMethods(): List<OnrampPaymentMethod> {
val paymentMethods = requireNotNull(paymentMethodsStore.getSyncOrNull(PAYMENT_METHODS_KEY)) {
"Onramp payment methods is absent in storage"
@ -237,6 +320,17 @@ internal class DefaultOnrampRepository(
return paymentMethodsConverter.convertList(paymentMethods)
}
override suspend fun saveSelectedPaymentMethod(paymentMethod: OnrampPaymentMethod) {
val dto = paymentMethodsConverter.convertBack(paymentMethod)
paymentMethodsStore.store(SELECTED_PAYMENT_METHOD_KEY, listOf(dto))
}
override fun getSelectedPaymentMethod(): Flow<OnrampPaymentMethod> {
return paymentMethodsStore
.get(SELECTED_PAYMENT_METHOD_KEY)
.mapNotNull { paymentMethods -> paymentMethods.firstOrNull()?.let(paymentMethodsConverter::convert) }
}
override suspend fun clearCache() = withContext(NonCancellable) {
paymentMethodsStore.clear()
pairsStore.clear()
@ -264,18 +358,53 @@ internal class DefaultOnrampRepository(
pairsStore.store(PAIRS_KEY, onrampPairs)
}
private fun createOnrampTransaction(
txId: String,
onrampDataJson: OnrampDataJson,
userWalletId: UserWalletId,
currency: OnrampCurrency,
provider: OnrampProvider,
cryptoCurrency: CryptoCurrency,
fromAmount: BigDecimal,
toAmount: BigDecimal,
): OnrampTransaction {
return OnrampTransaction(
txId = txId,
userWalletId = userWalletId,
fromAmount = fromAmount,
fromCurrency = currency,
toAmount = toAmount,
toCurrencyId = cryptoCurrency.id.value,
status = OnrampStatus.Status.Expired,
externalTxUrl = onrampDataJson.externalTxUrl,
externalTxId = onrampDataJson.externalTxId,
timestamp = DateTime.now().millis,
providerName = provider.info.name,
providerImageUrl = provider.info.imageLarge,
providerType = ExchangeProviderType.ONRAMP.name,
redirectUrl = onrampDataJson.widgetUrl,
)
}
private fun CryptoCurrency.getContractAddress(): String = when (this) {
is CryptoCurrency.Coin -> TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE
is CryptoCurrency.Token -> this.contractAddress
}
private fun convertToBigDecimal(value: String): BigDecimal =
requireNotNull(value.toBigDecimalOrNull()) { "Can not parse $value to BigDecimal" }
private fun convertToAmount(value: String, cryptoCurrency: CryptoCurrency): OnrampAmount {
val cryptoValue = requireNotNull(value.toBigDecimalOrNull()) { "Can not parse $value to BigDecimal" }
return OnrampAmount(
value = cryptoValue.movePointLeft(cryptoCurrency.decimals),
symbol = cryptoCurrency.symbol,
decimals = cryptoCurrency.decimals,
)
}
private fun List<PaymentMethodDTO>.removeApplePay(): List<PaymentMethodDTO> = filterNot { it.id == "apple-pay" }
private companion object {
const val PAYMENT_METHODS_KEY = "onramp_payment_methods"
const val SELECTED_PAYMENT_METHOD_KEY = "onramp_selected_payment_method"
const val PAIRS_KEY = "onramp_pairs"
const val QUOTES_KEY = "onramp_quotes"
}

View file

@ -2,12 +2,18 @@ package com.tangem.data.onramp.converters
import com.tangem.datasource.api.onramp.models.response.model.PaymentMethodDTO
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.utils.converter.Converter
import com.tangem.utils.converter.TwoWayConverter
internal class PaymentMethodConverter : Converter<PaymentMethodDTO, OnrampPaymentMethod> {
internal class PaymentMethodConverter : TwoWayConverter<PaymentMethodDTO, OnrampPaymentMethod> {
override fun convert(value: PaymentMethodDTO): OnrampPaymentMethod = OnrampPaymentMethod(
id = value.id,
name = value.name,
imageUrl = value.image,
)
override fun convertBack(value: OnrampPaymentMethod): PaymentMethodDTO = PaymentMethodDTO(
id = value.id,
name = value.name,
image = value.imageUrl,
)
}

View file

@ -25,7 +25,7 @@ internal class TransactionConverter : TwoWayConverter<OnrampTransactionDTO, Onra
providerName = value.providerName,
providerImageUrl = value.providerImageUrl,
providerType = value.providerType,
redirectUrl = "", // not necessary field
)
}

View file

@ -3,9 +3,8 @@ package com.tangem.data.onramp.converters.error
import com.squareup.moshi.JsonAdapter
import com.tangem.datasource.api.express.models.response.ExpressError
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
import com.tangem.domain.onramp.model.OnrampAmount
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.utils.converter.Converter
internal class OnrampQuotesErrorConverter(
@ -40,7 +39,6 @@ internal class OnrampQuotesErrorConverter(
amountWithOffset = minAmount,
decimals = decimals,
symbol = input.amount.currencySymbol,
type = input.amount.type,
),
)
}
@ -59,24 +57,17 @@ internal class OnrampQuotesErrorConverter(
amountWithOffset = maxAmount,
decimals = decimals,
symbol = input.amount.currencySymbol,
type = input.amount.type,
),
)
}
private fun createFromAmountWithOffset(
amountWithOffset: String,
decimals: Int,
symbol: String,
type: AmountType,
): Amount {
return Amount(
private fun createFromAmountWithOffset(amountWithOffset: String, decimals: Int, symbol: String): OnrampAmount {
return OnrampAmount(
value = requireNotNull(
amountWithOffset.toBigDecimalOrNull()?.movePointLeft(decimals),
) { "wrong amount format, use only digits" },
decimals = decimals,
currencySymbol = symbol,
type = type,
symbol = symbol,
)
}
}

View file

@ -8,6 +8,7 @@ import com.tangem.data.onramp.converters.error.OnrampQuotesErrorConverter
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.crypto.DataSignatureVerifier
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.onramp.pairs.OnrampPairsStore
import com.tangem.datasource.local.onramp.paymentmethods.OnrampPaymentMethodsStore
@ -16,6 +17,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.onramp.repositories.OnrampErrorResolver
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.onramp.repositories.OnrampTransactionRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -38,6 +40,9 @@ internal object OnrampDataModule {
pairsStore: OnrampPairsStore,
quotesStore: OnrampQuotesStore,
quotesErrorConverter: OnrampQuotesErrorConverter,
walletManagersFacade: WalletManagersFacade,
dataSignatureVerifier: DataSignatureVerifier,
@NetworkMoshi moshi: Moshi,
): OnrampRepository {
return DefaultOnrampRepository(
onrampApi = onrampApi,
@ -48,6 +53,9 @@ internal object OnrampDataModule {
pairsStore = pairsStore,
quotesStore = quotesStore,
quotesErrorConverter = quotesErrorConverter,
walletManagersFacade = walletManagersFacade,
dataSignatureVerifier = dataSignatureVerifier,
moshi = moshi,
)
}

View file

@ -0,0 +1,11 @@
package com.tangem.domain.onramp.model
import com.tangem.domain.core.serialization.SerializedBigDecimal
import kotlinx.serialization.Serializable
@Serializable
data class OnrampAmount(
val symbol: String,
val value: SerializedBigDecimal,
val decimals: Int,
)

View file

@ -1,9 +1,13 @@
package com.tangem.domain.onramp.model
import kotlinx.serialization.Serializable
@Serializable
data class OnrampProvider(
val id: String,
val info: OnrampProviderInfo,
val paymentMethods: List<OnrampPaymentMethod>,
)
@Serializable
data class OnrampProviderInfo(val name: String, val imageLarge: String)

View file

@ -0,0 +1,27 @@
package com.tangem.domain.onramp.model
import kotlinx.serialization.Serializable
sealed interface OnrampProviderWithQuote {
val provider: OnrampProvider
@Serializable
data class Data(
override val provider: OnrampProvider,
val paymentMethod: OnrampPaymentMethod,
val toAmount: OnrampAmount,
val fromAmount: OnrampAmount,
) : OnrampProviderWithQuote
sealed interface Unavailable : OnrampProviderWithQuote {
data class NotSupportedPaymentMethod(
override val provider: OnrampProvider,
val availablePaymentMethods: List<OnrampPaymentMethod>,
) : Unavailable
data class AvailableFrom(override val provider: OnrampProvider, val amount: OnrampAmount) : Unavailable
data class AvailableUpTo(override val provider: OnrampProvider, val amount: OnrampAmount) : Unavailable
}
}

View file

@ -1,8 +1,5 @@
package com.tangem.domain.onramp.model
import com.tangem.domain.tokens.model.Amount
import java.math.BigDecimal
sealed class OnrampQuote {
abstract val paymentMethod: OnrampPaymentMethod
@ -11,10 +8,10 @@ sealed class OnrampQuote {
data class Data(
override val paymentMethod: OnrampPaymentMethod,
override val provider: OnrampProvider,
val fromAmount: BigDecimal,
val toAmount: BigDecimal,
val minFromAmount: BigDecimal,
val maxFromAmount: BigDecimal,
val fromAmount: OnrampAmount,
val toAmount: OnrampAmount,
val minFromAmount: OnrampAmount,
val maxFromAmount: OnrampAmount,
) : OnrampQuote()
sealed class Error : OnrampQuote() {
@ -22,13 +19,13 @@ sealed class OnrampQuote {
data class AmountTooSmallError(
override val paymentMethod: OnrampPaymentMethod,
override val provider: OnrampProvider,
val amount: Amount,
val amount: OnrampAmount,
) : Error()
data class AmountTooBigError(
override val paymentMethod: OnrampPaymentMethod,
override val provider: OnrampProvider,
val amount: Amount,
val amount: OnrampAmount,
) : Error()
}
}

View file

@ -36,4 +36,5 @@ data class OnrampTransaction(
val providerName: String, // todo onramp fix after SwapProvider moved to own module
val providerImageUrl: String, // todo onramp fix after SwapProvider moved to own module
val providerType: String, // todo onramp fix after SwapProvider moved to own module
val redirectUrl: String,
)

View file

@ -0,0 +1,68 @@
package com.tangem.domain.onramp
import arrow.core.Either
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.OnrampProvider
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampProviderWithQuote.Unavailable
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.repositories.OnrampRepository
class GetOnrampProviderWithQuoteUseCase(private val repository: OnrampRepository) {
suspend operator fun invoke(paymentMethod: OnrampPaymentMethod): Either<Throwable, List<OnrampProviderWithQuote>> {
return Either.catch {
val quotes: List<OnrampQuote> = requireNotNull(repository.getQuotesSync()) { "Quotes must not be null" }
quotes
.groupBy { it.provider }
.mapNotNull { (provider, quotes) ->
quotes.quoteWithProvider(provider = provider, selectedPaymentMethod = paymentMethod)
}
}
}
private fun List<OnrampQuote>.quoteWithProvider(
provider: OnrampProvider,
selectedPaymentMethod: OnrampPaymentMethod,
): OnrampProviderWithQuote? {
val quoteData = this.filterIsInstance<OnrampQuote.Data>()
val matchedPaymentMethodQuote = quoteData.firstOrNull { it.paymentMethod == selectedPaymentMethod }
if (matchedPaymentMethodQuote != null) {
return OnrampProviderWithQuote.Data(
provider = matchedPaymentMethodQuote.provider,
paymentMethod = matchedPaymentMethodQuote.paymentMethod,
toAmount = matchedPaymentMethodQuote.toAmount,
fromAmount = matchedPaymentMethodQuote.fromAmount,
)
}
val paymentMethodNotSupported = quoteData.filterNot { it.paymentMethod == selectedPaymentMethod }
if (paymentMethodNotSupported.isNotEmpty()) {
return Unavailable.NotSupportedPaymentMethod(
provider = provider,
availablePaymentMethods = paymentMethodNotSupported.map(OnrampQuote.Data::paymentMethod),
)
}
val amountError = this
.filterIsInstance<OnrampQuote.Error>()
.firstOrNull { it.paymentMethod == selectedPaymentMethod }
return when {
amountError != null -> {
when (amountError) {
is OnrampQuote.Error.AmountTooBigError -> Unavailable.AvailableUpTo(
provider = amountError.provider,
amount = amountError.amount,
)
is OnrampQuote.Error.AmountTooSmallError -> Unavailable.AvailableFrom(
provider = amountError.provider,
amount = amountError.amount,
)
}
}
else -> null
}
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.domain.onramp
import arrow.core.Either
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.onramp.repositories.OnrampTransactionRepository
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
class GetOnrampRedirectUrlUseCase(
private val repository: OnrampRepository,
private val transactionRepository: OnrampTransactionRepository,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
quote: OnrampProviderWithQuote.Data,
cryptoCurrency: CryptoCurrency,
): Either<Throwable, String> {
return Either.catch {
val transaction = repository.getOnrampData(
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrency,
quote = quote,
)
transactionRepository.storeTransaction(transaction)
transaction.redirectUrl
}
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.domain.onramp
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.domain.core.utils.EitherFlow
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.repositories.OnrampRepository
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
class GetOnrampSelectedPaymentMethodUseCase(private val repository: OnrampRepository) {
operator fun invoke(): EitherFlow<Throwable, OnrampPaymentMethod> {
return repository.getSelectedPaymentMethod()
.map<OnrampPaymentMethod, Either<Throwable, OnrampPaymentMethod>> { it.right() }
.catch { emit(it.left()) }
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.domain.onramp
import arrow.core.Either
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.repositories.OnrampRepository
class OnrampSaveSelectedPaymentMethod(private val repository: OnrampRepository) {
suspend operator fun invoke(paymentMethod: OnrampPaymentMethod): Either<Throwable, Unit> {
return Either.catch { repository.saveSelectedPaymentMethod(paymentMethod) }
}
}

View file

@ -1,10 +1,13 @@
package com.tangem.domain.onramp.repositories
import com.tangem.domain.onramp.model.*
import com.tangem.domain.onramp.model.cache.OnrampTransaction
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow
@Suppress("TooManyFunctions")
interface OnrampRepository {
// api
suspend fun getCurrencies(): List<OnrampCurrency>
@ -14,6 +17,11 @@ interface OnrampRepository {
suspend fun fetchPaymentMethodsIfAbsent()
suspend fun fetchPairs(currency: OnrampCurrency, country: OnrampCountry, cryptoCurrency: CryptoCurrency)
suspend fun fetchQuotes(cryptoCurrency: CryptoCurrency, amount: Amount)
suspend fun getOnrampData(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
quote: OnrampProviderWithQuote.Data,
): OnrampTransaction
// cache
suspend fun saveDefaultCurrency(currency: OnrampCurrency)
@ -23,6 +31,9 @@ interface OnrampRepository {
suspend fun getDefaultCountrySync(): OnrampCountry?
fun getDefaultCountry(): Flow<OnrampCountry?>
suspend fun getPaymentMethods(): List<OnrampPaymentMethod>
suspend fun saveSelectedPaymentMethod(paymentMethod: OnrampPaymentMethod)
fun getSelectedPaymentMethod(): Flow<OnrampPaymentMethod>
fun getQuotes(): Flow<List<OnrampQuote>>
suspend fun getQuotesSync(): List<OnrampQuote>?
suspend fun clearCache()
}

View file

@ -3,10 +3,11 @@ package com.tangem.features.onramp.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
interface OnrampComponent : ComposableContentComponent {
data class Params(val cryptoCurrency: CryptoCurrency)
data class Params(val userWalletId: UserWalletId, val cryptoCurrency: CryptoCurrency)
interface Factory : ComponentFactory<Params, OnrampComponent>
}

View file

@ -16,6 +16,7 @@ import com.tangem.features.onramp.confirmresidency.ConfirmResidencyComponent
import com.tangem.features.onramp.main.entity.OnrampMainBottomSheetConfig
import com.tangem.features.onramp.main.model.OnrampMainComponentModel
import com.tangem.features.onramp.main.ui.OnrampMainComponentContent
import com.tangem.features.onramp.providers.SelectProviderComponent
import com.tangem.features.onramp.selectcurrency.SelectCurrencyComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -26,6 +27,7 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
@Assisted params: OnrampMainComponent.Params,
private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory,
private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory,
private val selectProviderComponentFactory: SelectProviderComponent.Factory,
) : OnrampMainComponent, AppComponentContext by appComponentContext {
private val model: OnrampMainComponentModel = getOrCreateModel(params)
@ -60,6 +62,14 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
context = childByContext(componentContext),
params = SelectCurrencyComponent.Params(model.bottomSheetNavigation::dismiss),
)
is OnrampMainBottomSheetConfig.ProvidersList -> selectProviderComponentFactory.create(
context = childByContext(componentContext),
params = SelectProviderComponent.Params(
onProviderClick = model::onProviderSelected,
onDismiss = model.bottomSheetNavigation::dismiss,
selectedPaymentMethod = config.selectedPaymentMethod,
),
)
}
@AssistedFactory

View file

@ -2,11 +2,16 @@ package com.tangem.features.onramp.main
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.tokens.model.CryptoCurrency
internal interface OnrampMainComponent : ComposableContentComponent {
data class Params(val cryptoCurrency: CryptoCurrency, val openSettings: () -> Unit)
data class Params(
val cryptoCurrency: CryptoCurrency,
val openSettings: () -> Unit,
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
)
interface Factory : ComponentFactory<Params, OnrampMainComponent>
}

View file

@ -1,9 +1,11 @@
package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
interface OnrampIntents {
fun onAmountValueChanged(value: String)
fun openSettings()
fun openCurrenciesList()
fun onBuyClick()
fun onBuyClick(quote: OnrampProviderWithQuote.Data)
fun openProviders()
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import kotlinx.serialization.Serializable
@Serializable
@ -10,4 +11,7 @@ internal sealed interface OnrampMainBottomSheetConfig {
@Serializable
data object CurrenciesList : OnrampMainBottomSheetConfig
@Serializable
data class ProvidersList(val selectedPaymentMethod: OnrampPaymentMethod) : OnrampMainBottomSheetConfig
}

View file

@ -18,7 +18,6 @@ internal sealed interface OnrampMainComponentUM {
val currency: String,
val onClose: () -> Unit,
val openSettings: () -> Unit,
val onBuyClick: () -> Unit,
) : OnrampMainComponentUM {
override val topBarConfig: OnrampMainTopBarUM = OnrampMainTopBarUM(
title = combinedReference(resourceReference(R.string.common_buy), stringReference(" $currency")),
@ -36,7 +35,7 @@ internal sealed interface OnrampMainComponentUM {
override val buyButtonConfig: BuyButtonConfig = BuyButtonConfig(
text = resourceReference(R.string.common_buy),
onClick = onBuyClick,
onClick = {},
enabled = false,
)
}

View file

@ -3,7 +3,7 @@ package com.tangem.features.onramp.main.entity
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
data class OnrampMainTopBarUM(
internal data class OnrampMainTopBarUM(
val title: TextReference,
val startButtonUM: TopAppBarButtonUM,
val endButtonUM: TopAppBarButtonUM,

View file

@ -1,11 +1,12 @@
package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampPaymentMethod
sealed class OnrampProviderBlockUM {
data object Empty : OnrampProviderBlockUM()
data object Loading : OnrampProviderBlockUM()
data class Content(
val paymentMethodIconUrl: String,
val paymentMethodName: String,
val paymentMethod: OnrampPaymentMethod,
val providerName: String,
val isBestRate: Boolean,
val onClick: () -> Unit,

View file

@ -30,7 +30,6 @@ internal class OnrampStateFactory(
currency = currency,
onClose = onClose,
openSettings = onrampIntents::openSettings,
onBuyClick = onrampIntents::onBuyClick,
)
}

View file

@ -7,6 +7,7 @@ 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.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.tokens.model.AmountType
import com.tangem.features.onramp.impl.R
@ -55,6 +56,7 @@ internal class OnrampAmountStateFactory(
return currentState.copy(
amountBlockState = amountState.copy(secondaryFieldModel = OnrampAmountSecondaryFieldUM.Loading),
buyButtonConfig = currentState.buyButtonConfig.copy(enabled = false),
)
}
@ -68,13 +70,52 @@ internal class OnrampAmountStateFactory(
secondaryFieldModel = quote.toSecondaryFieldUiModel(amountState),
),
providerBlockState = quote.toProviderBlockState(),
buyButtonConfig = currentState.buyButtonConfig.copy(
enabled = quote is OnrampQuote.Data,
onClick = {
if (quote is OnrampQuote.Data) {
onrampIntents.onBuyClick(
OnrampProviderWithQuote.Data(
provider = quote.provider,
paymentMethod = quote.paymentMethod,
toAmount = quote.toAmount,
fromAmount = quote.fromAmount,
),
)
}
},
),
)
}
fun getAmountSecondaryUpdatedState(quoteWithProvider: OnrampProviderWithQuote.Data): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
val amount = quoteWithProvider.toAmount.value.format {
crypto(symbol = quoteWithProvider.toAmount.symbol, decimals = quoteWithProvider.toAmount.decimals)
}
return currentState.copy(
amountBlockState = amountState.copy(
secondaryFieldModel = OnrampAmountSecondaryFieldUM.Content(stringReference(amount)),
),
providerBlockState = OnrampProviderBlockUM.Content(
paymentMethod = quoteWithProvider.paymentMethod,
providerName = quoteWithProvider.provider.info.name,
isBestRate = true,
onClick = onrampIntents::openProviders,
),
buyButtonConfig = currentState.buyButtonConfig.copy(
enabled = true,
onClick = { onrampIntents.onBuyClick(quoteWithProvider) },
),
)
}
private fun OnrampQuote.toProviderBlockState(): OnrampProviderBlockUM {
return OnrampProviderBlockUM.Content(
paymentMethodIconUrl = paymentMethod.imageUrl,
paymentMethodName = paymentMethod.name,
paymentMethod = paymentMethod,
providerName = provider.info.name,
isBestRate = true,
onClick = onrampIntents::openProviders,
@ -84,11 +125,8 @@ internal class OnrampAmountStateFactory(
private fun OnrampQuote.toSecondaryFieldUiModel(amountState: OnrampAmountBlockUM): OnrampAmountSecondaryFieldUM {
return when (this) {
is OnrampQuote.Data -> {
val amount = toAmount.format {
crypto(
symbol = amountState.amountFieldModel.cryptoAmount.currencySymbol,
decimals = amountState.amountFieldModel.cryptoAmount.decimals,
)
val amount = toAmount.value.format {
crypto(symbol = toAmount.symbol, decimals = toAmount.decimals)
}
OnrampAmountSecondaryFieldUM.Content(stringReference(amount))
}

View file

@ -8,10 +8,12 @@ import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.model.OnrampAvailability
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.features.onramp.main.OnrampMainComponent
import com.tangem.features.onramp.main.entity.OnrampIntents
import com.tangem.features.onramp.main.entity.OnrampMainBottomSheetConfig
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
import com.tangem.features.onramp.main.entity.OnrampProviderBlockUM
import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory
import com.tangem.features.onramp.main.entity.factory.amount.OnrampAmountStateFactory
import com.tangem.features.onramp.utils.InputManager
@ -60,6 +62,10 @@ internal class OnrampMainComponentModel @Inject constructor(
subscribeToAmountChanges()
}
fun onProviderSelected(providerWithQuote: OnrampProviderWithQuote.Data) {
_state.update { amountStateFactory.getAmountSecondaryUpdatedState(providerWithQuote) }
}
private fun checkResidenceCountry() {
modelScope.launch {
checkOnrampAvailabilityUseCase.invoke(params.cryptoCurrency)
@ -125,8 +131,8 @@ internal class OnrampMainComponentModel @Inject constructor(
params.openSettings()
}
override fun onBuyClick() {
TODO("Not yet implemented")
override fun onBuyClick(quote: OnrampProviderWithQuote.Data) {
params.openRedirectPage(quote)
}
override fun openCurrenciesList() {
@ -134,6 +140,13 @@ internal class OnrampMainComponentModel @Inject constructor(
}
override fun openProviders() {
val providerState = (state.value as? OnrampMainComponentUM.Content)?.providerBlockState ?: return
val providerContentState = providerState as? OnrampProviderBlockUM.Content ?: return
bottomSheetNavigation.activate(
OnrampMainBottomSheetConfig.ProvidersList(
selectedPaymentMethod = providerContentState.paymentMethod,
),
)
}
override fun onDestroy() {

View file

@ -49,7 +49,7 @@ private fun OnrampProviderBlock(state: OnrampProviderBlockUM.Content, modifier:
.size(size = TangemTheme.dimens.size40)
.clip(TangemTheme.shapes.roundedCorners8),
model = ImageRequest.Builder(context = LocalContext.current)
.data(state.paymentMethodIconUrl)
.data(state.paymentMethod.imageUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
@ -67,7 +67,7 @@ private fun OnrampProviderBlock(state: OnrampProviderBlockUM.Content, modifier:
color = TangemTheme.colors.text.primary1,
),
) {
append(state.paymentMethodName)
append(state.paymentMethod.name)
}
},
color = TangemTheme.colors.text.tertiary,

View file

@ -1,6 +1,9 @@
package com.tangem.features.onramp.paymentmethod.entity
data class PaymentMethodUM(
import kotlinx.serialization.Serializable
@Serializable
internal data class PaymentMethodUM(
val id: String,
val imageUrl: String,
val name: String,

View file

@ -0,0 +1,95 @@
package com.tangem.features.onramp.providers
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.jetpack.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.paymentmethod.SelectPaymentMethodComponent
import com.tangem.features.onramp.providers.entity.ProviderListBottomSheetConfig
import com.tangem.features.onramp.providers.model.SelectProviderModel
import com.tangem.features.onramp.providers.ui.SelectProviderBottomSheet
import com.tangem.features.onramp.providers.ui.SelectProviderBottomSheetContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultSelectProviderComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: SelectProviderComponent.Params,
private val selectPaymentMethodComponentFactory: SelectPaymentMethodComponent.Factory,
) : SelectProviderComponent, AppComponentContext by context {
private val model: SelectProviderModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
override fun dismiss() {
params.onDismiss()
}
@Composable
override fun BottomSheet() {
val state by model.state.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
val bottomSheetConfig = remember(key1 = this) {
TangemBottomSheetConfig(
isShow = true,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
)
}
SelectProviderBottomSheet(
config = bottomSheetConfig,
content = {
SelectProviderBottomSheetContent(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = TangemTheme.dimens.spacing16),
state = state,
)
},
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: ProviderListBottomSheetConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
is ProviderListBottomSheetConfig.PaymentMethods -> selectPaymentMethodComponentFactory.create(
context = childByContext(componentContext),
params = SelectPaymentMethodComponent.Params(
selectedMethodId = config.selectedMethodId,
paymentMethods = config.paymentMethodsUM,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
}
@AssistedFactory
interface Factory : SelectProviderComponent.Factory {
override fun create(
context: AppComponentContext,
params: SelectProviderComponent.Params,
): DefaultSelectProviderComponent
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.onramp.providers
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
internal interface SelectProviderComponent : ComposableBottomSheetComponent {
data class Params(
val onProviderClick: (OnrampProviderWithQuote.Data) -> Unit,
val onDismiss: () -> Unit,
val selectedPaymentMethod: OnrampPaymentMethod,
)
interface Factory : ComponentFactory<Params, SelectProviderComponent>
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.onramp.providers.di
import com.tangem.features.onramp.providers.DefaultSelectProviderComponent
import com.tangem.features.onramp.providers.SelectProviderComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface SelectProviderComponentModule {
@Binds
@Singleton
fun bindSelectProviderComponentFactory(
factory: DefaultSelectProviderComponent.Factory,
): SelectProviderComponent.Factory
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.onramp.providers.di
import com.tangem.core.decompose.di.DecomposeComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.onramp.providers.model.SelectProviderModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(DecomposeComponent::class)
internal interface SelectProviderModelModule {
@Binds
@IntoMap
@ClassKey(SelectProviderModel::class)
fun bindSelectProviderModel(model: SelectProviderModel): Model
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.onramp.providers.entity
import com.tangem.features.onramp.paymentmethod.entity.PaymentMethodUM
import kotlinx.serialization.Serializable
@Serializable
internal sealed interface ProviderListBottomSheetConfig {
@Serializable
data class PaymentMethods(
val selectedMethodId: String,
val paymentMethodsUM: List<PaymentMethodUM>,
) : ProviderListBottomSheetConfig
}

View file

@ -0,0 +1,24 @@
package com.tangem.features.onramp.providers.entity
import com.tangem.core.ui.extensions.TextReference
internal sealed interface ProviderListItemUM {
val providerId: String
data class Available(
override val providerId: String,
val imageUrl: String,
val name: String,
val rate: String,
val isSelected: Boolean,
val onClick: () -> Unit,
) : ProviderListItemUM
data class Unavailable(
override val providerId: String,
val imageUrl: String,
val name: String,
val subtitle: TextReference,
) : ProviderListItemUM
}

View file

@ -0,0 +1,8 @@
package com.tangem.features.onramp.providers.entity
internal data class ProviderListPaymentMethodUM(
val id: String,
val name: String,
val imageUrl: String,
val onClick: () -> Unit,
)

View file

@ -0,0 +1,8 @@
package com.tangem.features.onramp.providers.entity
import kotlinx.collections.immutable.ImmutableList
internal data class ProviderListUM(
val paymentMethod: ProviderListPaymentMethodUM,
val providers: ImmutableList<ProviderListItemUM>,
)

View file

@ -0,0 +1,170 @@
package com.tangem.features.onramp.providers.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
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.format
import com.tangem.domain.onramp.GetOnrampPaymentMethodsUseCase
import com.tangem.domain.onramp.GetOnrampProviderWithQuoteUseCase
import com.tangem.domain.onramp.GetOnrampSelectedPaymentMethodUseCase
import com.tangem.domain.onramp.OnrampSaveSelectedPaymentMethod
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.paymentmethod.entity.PaymentMethodUM
import com.tangem.features.onramp.providers.SelectProviderComponent
import com.tangem.features.onramp.providers.entity.ProviderListBottomSheetConfig
import com.tangem.features.onramp.providers.entity.ProviderListItemUM
import com.tangem.features.onramp.providers.entity.ProviderListPaymentMethodUM
import com.tangem.features.onramp.providers.entity.ProviderListUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ComponentScoped
internal class SelectProviderModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val getSelectedPaymentMethodsUseCase: GetOnrampPaymentMethodsUseCase,
private val getOnrampSelectedPaymentMethodUseCase: GetOnrampSelectedPaymentMethodUseCase,
private val getOnrampProviderWithQuoteUseCase: GetOnrampProviderWithQuoteUseCase,
private val saveSelectedPaymentMethod: OnrampSaveSelectedPaymentMethod,
paramsContainer: ParamsContainer,
) : Model() {
val state: StateFlow<ProviderListUM> get() = _state.asStateFlow()
val bottomSheetNavigation: SlotNavigation<ProviderListBottomSheetConfig> = SlotNavigation()
private val params: SelectProviderComponent.Params = paramsContainer.require()
private val _state = MutableStateFlow(getInitialState())
init {
getProviders(params.selectedPaymentMethod)
subscribeToPaymentMethodUpdates()
}
private fun subscribeToPaymentMethodUpdates() {
getOnrampSelectedPaymentMethodUseCase.invoke()
.onEach { it.getOrNull()?.let(::getProviders) }
.launchIn(modelScope)
}
private fun getProviders(paymentMethod: OnrampPaymentMethod) {
modelScope.launch {
getOnrampProviderWithQuoteUseCase.invoke(paymentMethod)
.onRight { quotes ->
_state.update { state -> state.copy(providers = quotes.toProvidersListItems()) }
}
.onLeft { Timber.e(it) }
}
}
private fun getInitialState(): ProviderListUM {
return ProviderListUM(
paymentMethod = ProviderListPaymentMethodUM(
id = params.selectedPaymentMethod.id,
name = params.selectedPaymentMethod.name,
imageUrl = params.selectedPaymentMethod.imageUrl,
onClick = ::openPaymentMethods,
),
providers = emptyList<ProviderListItemUM>().toImmutableList(),
)
}
private fun openPaymentMethods() {
modelScope.launch {
val methods = getSelectedPaymentMethodsUseCase.invoke().getOrNull().orEmpty()
bottomSheetNavigation.activate(
ProviderListBottomSheetConfig.PaymentMethods(
selectedMethodId = _state.value.paymentMethod.id,
paymentMethodsUM = methods.toPaymentUMList(),
),
)
}
}
private fun List<OnrampPaymentMethod>.toPaymentUMList(): List<PaymentMethodUM> = map { method ->
PaymentMethodUM(
id = method.id,
imageUrl = method.imageUrl,
name = method.name,
onSelect = { onPaymentMethodSelected(method) },
)
}
private fun onPaymentMethodSelected(paymentMethod: OnrampPaymentMethod) {
modelScope.launch {
saveSelectedPaymentMethod.invoke(paymentMethod)
_state.update { state ->
val paymentMethodUM = state.paymentMethod.copy(
id = paymentMethod.id,
name = paymentMethod.name,
imageUrl = paymentMethod.imageUrl,
)
state.copy(paymentMethod = paymentMethodUM)
}
bottomSheetNavigation.dismiss()
}
}
private fun List<OnrampProviderWithQuote>.toProvidersListItems() = mapIndexed { index, quote ->
when (quote) {
is OnrampProviderWithQuote.Data -> {
val rate = quote.toAmount.value.format {
crypto(symbol = quote.toAmount.symbol, decimals = quote.toAmount.decimals)
}
ProviderListItemUM.Available(
providerId = quote.provider.id,
imageUrl = quote.provider.info.imageLarge,
name = quote.provider.info.name,
rate = rate,
isSelected = index == 0,
onClick = {
params.onProviderClick(quote)
params.onDismiss()
},
)
}
is OnrampProviderWithQuote.Unavailable.AvailableFrom -> {
val amount = quote.amount.value.format {
crypto(symbol = quote.amount.symbol, decimals = quote.amount.decimals)
}
ProviderListItemUM.Unavailable(
providerId = quote.provider.id,
imageUrl = quote.provider.info.imageLarge,
name = quote.provider.info.name,
subtitle = resourceReference(R.string.express_provider_min_amount, wrappedList(amount)),
)
}
is OnrampProviderWithQuote.Unavailable.AvailableUpTo -> {
val amount = quote.amount.value.format {
crypto(symbol = quote.amount.symbol, decimals = quote.amount.decimals)
}
ProviderListItemUM.Unavailable(
providerId = quote.provider.id,
imageUrl = quote.provider.info.imageLarge,
name = quote.provider.info.name,
subtitle = resourceReference(R.string.express_provider_max_amount, wrappedList(amount)),
)
}
is OnrampProviderWithQuote.Unavailable.NotSupportedPaymentMethod -> {
ProviderListItemUM.Unavailable(
providerId = quote.provider.id,
imageUrl = quote.provider.info.imageLarge,
name = quote.provider.info.name,
subtitle = stringReference(
"Available with ${quote.availablePaymentMethods.joinToString { it.name }}",
),
)
}
}
}.toImmutableList()
}

View file

@ -0,0 +1,199 @@
package com.tangem.features.onramp.providers.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.util.fastForEach
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.providers.entity.ProviderListItemUM
import com.tangem.features.onramp.providers.entity.ProviderListPaymentMethodUM
import com.tangem.features.onramp.providers.entity.ProviderListUM
@Composable
internal fun SelectProviderBottomSheet(config: TangemBottomSheetConfig, content: @Composable () -> Unit) {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = config,
titleText = resourceReference(R.string.express_choose_providers_title),
content = { content() },
)
}
@Composable
internal fun SelectProviderBottomSheetContent(state: ProviderListUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(id = R.string.onramp_choose_provider_title_hint),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
PaymentMethodBlock(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing20, bottom = TangemTheme.dimens.spacing16),
state = state.paymentMethod,
)
state.providers.fastForEach { provider ->
key(provider.providerId) { ProviderItem(state = provider) }
}
}
}
@Composable
private fun PaymentMethodBlock(state: ProviderListPaymentMethodUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius16))
.border(
width = TangemTheme.dimens.size1,
color = TangemTheme.colors.stroke.secondary,
shape = RoundedCornerShape(TangemTheme.dimens.radius16),
)
.clickable(onClick = state.onClick)
.padding(all = TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
SubcomposeAsyncImage(
modifier = Modifier
.size(size = TangemTheme.dimens.size40)
.clip(TangemTheme.shapes.roundedCorners8),
model = ImageRequest.Builder(context = LocalContext.current)
.data(state.imageUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
contentDescription = null,
)
Column(modifier = Modifier.weight(1F)) {
Text(
text = stringResource(id = R.string.onramp_pay_with),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
Text(
text = state.name,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.primary1,
)
}
Icon(
modifier = Modifier.size(TangemTheme.dimens.size24),
painter = painterResource(id = R.drawable.ic_chevron_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
}
@Composable
private fun ProviderItem(state: ProviderListItemUM, modifier: Modifier = Modifier) {
when (state) {
is ProviderListItemUM.Available -> AvailableProviderItem(
modifier = modifier
.clickable(onClick = state.onClick)
.padding(all = TangemTheme.dimens.spacing12),
state = state,
)
is ProviderListItemUM.Unavailable -> UnavailableProviderItem(
modifier = modifier.padding(all = TangemTheme.dimens.spacing12),
state = state,
)
}
}
@Composable
private fun AvailableProviderItem(state: ProviderListItemUM.Available, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
SubcomposeAsyncImage(
modifier = Modifier
.size(size = TangemTheme.dimens.size40)
.clip(TangemTheme.shapes.roundedCorners8),
model = ImageRequest.Builder(context = LocalContext.current)
.data(state.imageUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
contentDescription = null,
)
Text(
modifier = Modifier.weight(1F),
text = state.name,
style = TangemTheme.typography.subtitle2,
color = if (state.isSelected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary,
)
Text(
text = state.rate,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
}
}
private const val UNAVAILABLE_ALPHA = 0.4F
@Composable
private fun UnavailableProviderItem(state: ProviderListItemUM.Unavailable, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
SubcomposeAsyncImage(
modifier = Modifier
.size(size = TangemTheme.dimens.size40)
.clip(TangemTheme.shapes.roundedCorners8)
.alpha(UNAVAILABLE_ALPHA),
model = ImageRequest.Builder(context = LocalContext.current)
.data(state.imageUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
contentDescription = null,
)
Column(modifier = Modifier.weight(1F)) {
Text(
text = state.name,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.secondary,
)
Text(
text = state.subtitle.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.features.onramp.redirect
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.onramp.redirect.model.OnrampRedirectModel
import com.tangem.features.onramp.redirect.ui.OnrampRedirectContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultOnrampRedirectComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: OnrampRedirectComponent.Params,
) : OnrampRedirectComponent, AppComponentContext by appComponentContext {
private val model: OnrampRedirectModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
BackHandler(onBack = params.onBack)
OnrampRedirectContent(modifier = modifier, state = model.state)
}
@AssistedFactory
interface Factory : OnrampRedirectComponent.Factory {
override fun create(
context: AppComponentContext,
params: OnrampRedirectComponent.Params,
): DefaultOnrampRedirectComponent
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.onramp.redirect
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
internal interface OnrampRedirectComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val onBack: () -> Unit,
val cryptoCurrency: CryptoCurrency,
val onrampProviderWithQuote: OnrampProviderWithQuote.Data,
)
interface Factory : ComponentFactory<Params, OnrampRedirectComponent>
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.onramp.redirect.di
import com.tangem.features.onramp.redirect.DefaultOnrampRedirectComponent
import com.tangem.features.onramp.redirect.OnrampRedirectComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface OnrampRedirectComponentModule {
@Binds
@Singleton
fun bindOnrampRedirectComponentFactory(
factory: DefaultOnrampRedirectComponent.Factory,
): OnrampRedirectComponent.Factory
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.onramp.redirect.di
import com.tangem.core.decompose.di.DecomposeComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.onramp.redirect.model.OnrampRedirectModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(DecomposeComponent::class)
internal interface OnrampRedirectModelModule {
@Binds
@IntoMap
@ClassKey(OnrampRedirectModel::class)
fun bindOnrampRedirectModel(model: OnrampRedirectModel): Model
}

View file

@ -0,0 +1,6 @@
package com.tangem.features.onramp.redirect.entity
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
data class OnrampRedirectTopBarUM(val title: TextReference, val startButtonUM: TopAppBarButtonUM)

View file

@ -0,0 +1,10 @@
package com.tangem.features.onramp.redirect.entity
import com.tangem.core.ui.extensions.TextReference
internal data class OnrampRedirectUM(
val topBarConfig: OnrampRedirectTopBarUM,
val providerImageUrl: String,
val title: TextReference,
val subtitle: TextReference,
)

View file

@ -0,0 +1,69 @@
package com.tangem.features.onramp.redirect.model
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
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.domain.onramp.GetOnrampRedirectUrlUseCase
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.redirect.OnrampRedirectComponent
import com.tangem.features.onramp.redirect.entity.OnrampRedirectTopBarUM
import com.tangem.features.onramp.redirect.entity.OnrampRedirectUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
internal class OnrampRedirectModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val urlOpener: UrlOpener,
private val getOnrampRedirectUrlUseCase: GetOnrampRedirectUrlUseCase,
router: Router,
paramsContainer: ParamsContainer,
) : Model() {
private val params: OnrampRedirectComponent.Params = paramsContainer.require()
val state = OnrampRedirectUM(
topBarConfig = OnrampRedirectTopBarUM(
title = combinedReference(
resourceReference(R.string.common_buy),
stringReference(" ${params.cryptoCurrency.name}"),
),
startButtonUM = TopAppBarButtonUM(
iconRes = R.drawable.ic_close_24,
onIconClicked = router::pop,
enabled = true,
),
),
providerImageUrl = params.onrampProviderWithQuote.provider.info.imageLarge,
title = resourceReference(
R.string.onramp_redirecting_to_provider_title,
wrappedList(params.onrampProviderWithQuote.provider.info.name),
),
subtitle = resourceReference(
R.string.onramp_redirecting_to_provider_subtitle,
wrappedList(params.onrampProviderWithQuote.provider.info.name),
),
)
init {
getRedirectUrl()
}
private fun getRedirectUrl() {
modelScope.launch {
getOnrampRedirectUrlUseCase.invoke(
userWalletId = params.userWalletId,
quote = params.onrampProviderWithQuote,
cryptoCurrency = params.cryptoCurrency,
)
.onLeft { Timber.e(it) }
.onRight { urlOpener.openUrl(it) }
}
}
}

View file

@ -0,0 +1,133 @@
package com.tangem.features.onramp.redirect.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Scaffold
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.TextAlign
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.redirect.entity.OnrampRedirectUM
@Composable
internal fun OnrampRedirectContent(state: OnrampRedirectUM, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier.imePadding(),
contentWindowInsets = WindowInsetsZero,
containerColor = TangemTheme.colors.background.secondary,
topBar = {
TangemTopAppBar(
modifier = Modifier.statusBarsPadding(),
startButton = state.topBarConfig.startButtonUM,
title = state.topBarConfig.title.resolveReference(),
)
},
content = { innerPadding ->
Content(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.padding(horizontal = TangemTheme.dimens.spacing16),
state = state,
)
},
)
}
@Composable
private fun Content(state: OnrampRedirectUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
LogoBlock(providerImageUrl = state.providerImageUrl)
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing24),
text = state.title.resolveReference(),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing12),
text = state.subtitle.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun LogoBlock(providerImageUrl: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
modifier = Modifier
.size(TangemTheme.dimens.size64)
.clip(TangemTheme.shapes.roundedCorners8)
.background(TangemTheme.colors.background.primary)
.padding(vertical = TangemTheme.dimens.spacing8, horizontal = TangemTheme.dimens.spacing12),
painter = painterResource(id = R.drawable.ic_tangem_24),
contentDescription = null,
)
LoadingDots()
SubcomposeAsyncImage(
modifier = Modifier
.size(size = TangemTheme.dimens.size64)
.clip(TangemTheme.shapes.roundedCorners8),
model = ImageRequest.Builder(context = LocalContext.current)
.data(providerImageUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
contentDescription = null,
)
}
}
@Composable
private fun LoadingDots(modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing6),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(TangemTheme.dimens.size6)
.clip(CircleShape)
.background(color = TangemTheme.colors.icon.accent.copy(alpha = 0.75F)),
)
Box(
modifier = Modifier
.size(TangemTheme.dimens.size6)
.clip(CircleShape)
.background(color = TangemTheme.colors.icon.accent.copy(alpha = 0.45F)),
)
Box(
modifier = Modifier
.size(TangemTheme.dimens.size8)
.clip(CircleShape)
.background(color = TangemTheme.colors.icon.accent),
)
}
}

View file

@ -17,6 +17,7 @@ import com.tangem.core.decompose.context.childByContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.onramp.component.OnrampComponent
import com.tangem.features.onramp.main.OnrampMainComponent
import com.tangem.features.onramp.redirect.OnrampRedirectComponent
import com.tangem.features.onramp.root.entity.OnrampChild
import com.tangem.features.onramp.settings.OnrampSettingsComponent
import dagger.assisted.Assisted
@ -29,6 +30,7 @@ internal class DefaultOnrampComponent @AssistedInject constructor(
@Assisted private val params: OnrampComponent.Params,
private val settingsComponentFactory: OnrampSettingsComponent.Factory,
private val onrampMainComponentFactory: OnrampMainComponent.Factory,
private val onrampRedirectComponentFactory: OnrampRedirectComponent.Factory,
) : OnrampComponent, AppComponentContext by context {
private val navigation = StackNavigation<OnrampChild>()
@ -65,6 +67,23 @@ internal class DefaultOnrampComponent @AssistedInject constructor(
params = OnrampMainComponent.Params(
cryptoCurrency = params.cryptoCurrency,
openSettings = { navigation.push(OnrampChild.Settings) },
openRedirectPage = {
navigation.push(
OnrampChild.RedirectPage(
quote = it,
cryptoCurrency = params.cryptoCurrency,
),
)
},
),
)
is OnrampChild.RedirectPage -> onrampRedirectComponentFactory.create(
context = childByContext(componentContext),
params = OnrampRedirectComponent.Params(
userWalletId = params.userWalletId,
onBack = navigation::pop,
cryptoCurrency = config.cryptoCurrency,
onrampProviderWithQuote = config.quote,
),
)
}

View file

@ -1,5 +1,7 @@
package com.tangem.features.onramp.root.entity
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.tokens.model.CryptoCurrency
import kotlinx.serialization.Serializable
@Serializable
@ -9,4 +11,7 @@ internal sealed class OnrampChild {
@Serializable
data object Settings : OnrampChild()
@Serializable
data class RedirectPage(val quote: OnrampProviderWithQuote.Data, val cryptoCurrency: CryptoCurrency) : OnrampChild()
}