Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-05 14:58:00 +02:00
parent 94a4486443
commit 96ce2fc718
22 changed files with 784 additions and 57 deletions

View file

@ -1,11 +1,7 @@
package com.tangem.tap.di.domain
import com.tangem.domain.onramp.repositories.LegacyTopUpRepository
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.repositories.HotCryptoRepository
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.onramp.repositories.*
import com.tangem.domain.settings.repositories.SettingsRepository
import dagger.Module
import dagger.Provides
@ -237,4 +233,32 @@ internal object OnrampDomainModule {
fun provideGetLegacyTopUpUrlUseCase(legacyTopUpRepository: LegacyTopUpRepository): GetLegacyTopUpUrlUseCase {
return GetLegacyTopUpUrlUseCase(legacyTopUpRepository)
}
@Provides
@Singleton
fun provideGetOnrampAllOffersUseCase(
onrampRepository: OnrampRepository,
onrampErrorResolver: OnrampErrorResolver,
settingsRepository: SettingsRepository,
): GetOnrampAllOffersUseCase {
return GetOnrampAllOffersUseCase(
onrampRepository = onrampRepository,
errorResolver = onrampErrorResolver,
settingsRepository = settingsRepository,
)
}
@Provides
@Singleton
fun provideGetOnrampOffersUseCase(
onrampRepository: OnrampRepository,
onrampErrorResolver: OnrampErrorResolver,
onrampTransactionRepository: OnrampTransactionRepository,
): GetOnrampOffersUseCase {
return GetOnrampOffersUseCase(
onrampRepository = onrampRepository,
errorResolver = onrampErrorResolver,
onrampTransactionRepository = onrampTransactionRepository,
)
}
}

View file

@ -876,7 +876,10 @@
<string name="onramp_payment_method_subtitle">Payment method</string>
<string name="onramp_provider_max_amount">Available up to %s</string>
<string name="onramp_provider_min_amount">Available from %s</string>
<string name="onramp_providers_count">%d providers</string>
<plurals name="onramp_providers_count">
<item quantity="one">%d provider</item>
<item quantity="other">%d providers</item>
</plurals>
<string name="onramp_providers_subtitle">Providers</string>
<string name="onramp_recently_used_title">Recently used</string>
<string name="onramp_recommended_title">Recommended</string>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M8,11.848L11.332,13.901C11.857,14.225 12.506,13.744 12.366,13.134L11.482,9.264L14.426,6.66C14.89,6.25 14.642,5.472 14.031,5.419L10.155,5.084L8.639,1.432C8.4,0.856 7.6,0.856 7.361,1.432L5.845,5.084L1.969,5.419C1.358,5.472 1.11,6.25 1.574,6.66L4.518,9.264L3.634,13.134C3.494,13.744 4.142,14.225 4.668,13.901L8,11.848Z"
android:fillColor="#0099FF"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M10.495,1.6C10.535,1.462 10.357,1.368 10.265,1.478L3.851,9.114C3.778,9.2 3.84,9.333 3.953,9.333H6.953L5.505,14.398C5.466,14.537 5.643,14.631 5.735,14.521L12.149,6.885C12.222,6.798 12.161,6.666 12.047,6.666H9.048L10.495,1.6Z"
android:fillColor="#FFB71B"
android:fillType="evenOdd"/>
</vector>

View file

@ -1,13 +1,16 @@
package com.tangem.domain.onramp.model
import java.math.BigDecimal
data class OnrampOffersBlock(
val category: OnrampOfferCategory,
val offers: List<OnrampOffer>,
val isVisible: Boolean = offers.isNotEmpty(),
val hasMoreOffers: Boolean,
)
data class OnrampOffer(
val quote: OnrampQuote,
val rateDif: BigDecimal?,
val advantages: OnrampOfferAdvantages = OnrampOfferAdvantages.Default,
)

View file

@ -12,6 +12,7 @@ import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.onramp.repositories.OnrampErrorResolver
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.onramp.utils.calculateRateDif
import com.tangem.domain.settings.repositories.SettingsRepository
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
@ -38,6 +39,7 @@ class GetOnrampAllOffersUseCase(
val isGooglePayAvailable = settingsRepository.isGooglePayAvailability()
val overallBestRateQuote = validQuotes.maxByOrNull { it.toAmount.value }
val bestRate = overallBestRateQuote?.toAmount?.value
val offersByPaymentMethod = validQuotes.groupBy { it.paymentMethod }
@ -48,7 +50,8 @@ class GetOnrampAllOffersUseCase(
} else {
OnrampOfferAdvantages.Default
}
OnrampOffer(quote = quote, advantages = advantages)
val rateDif = calculateRateDif(quote.toAmount.value, bestRate)
OnrampOffer(quote = quote, rateDif = rateDif, advantages = advantages)
}
val groupBestRateOfferData = methodQuotes.maxByOrNull { it.toAmount.value }

View file

@ -11,6 +11,7 @@ import com.tangem.domain.onramp.model.error.OnrampError
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.onramp.utils.calculateRateDif
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
@ -43,8 +44,12 @@ class GetOnrampOffersUseCase(
val validQuotes = quotes.filterIsInstance<OnrampQuote.Data>()
if (validQuotes.isEmpty()) return emptyList()
val bestRateQuote = validQuotes.maxByOrNull { it.toAmount.value }
val bestRate = bestRateQuote?.toAmount?.value
val offers = validQuotes.map { quote ->
OnrampOffer(quote = quote)
val rateDif = calculateRateDif(quote.toAmount.value, bestRate)
OnrampOffer(quote = quote, rateDif = rateDif)
}
val recentOffer = findRecentOffer(offers, transactions)
@ -107,6 +112,15 @@ class GetOnrampOffersUseCase(
fastestOffer: OnrampOffer?,
allOffers: List<OnrampOffer>,
): List<OnrampOffersBlock> {
val recommendedOffers = buildRecommendedOffers(
recentOffer = recentOffer,
bestRateOffer = bestRateOffer,
fastestOffer = fastestOffer,
)
val shownOffersCount = (if (recentOffer != null) 1 else 0) + recommendedOffers.size
val hasMoreOffers = allOffers.size > shownOffersCount
return buildList {
if (recentOffer != null) {
add(
@ -119,23 +133,20 @@ class GetOnrampOffersUseCase(
bestRateOffer,
fastestOffer,
),
rateDif = if (bestRateOffer != null) recentOffer.rateDif else null,
),
),
hasMoreOffers = false,
),
)
}
val recommendedOffers = buildRecommendedOffers(
recentOffer = recentOffer,
bestRateOffer = bestRateOffer,
fastestOffer = fastestOffer,
)
if (recommendedOffers.isNotEmpty() && hasOnlyOneMethodAndProvider(allOffers).not()) {
add(
OnrampOffersBlock(
category = OnrampOfferCategory.Recommended,
offers = recommendedOffers,
hasMoreOffers = hasMoreOffers,
),
)
}
@ -167,17 +178,32 @@ class GetOnrampOffersUseCase(
return buildList {
if (isSameOffer(bestRateOffer, fastestOffer)) {
bestRateOffer?.let { offer ->
add(offer.copy(advantages = OnrampOfferAdvantages.BestRate))
add(
offer.copy(
advantages = OnrampOfferAdvantages.BestRate,
rateDif = null,
),
)
}
} else {
if (bestRateOffer != null && !isSameOffer(bestRateOffer, recentOffer)) {
add(bestRateOffer.copy(advantages = OnrampOfferAdvantages.BestRate))
add(
bestRateOffer.copy(
advantages = OnrampOfferAdvantages.BestRate,
rateDif = null,
),
)
}
if (fastestOffer != null && !isSameOffer(fastestOffer, recentOffer) &&
!isSameOffer(fastestOffer, bestRateOffer)
) {
add(fastestOffer.copy(advantages = OnrampOfferAdvantages.Fastest))
add(
fastestOffer.copy(
advantages = OnrampOfferAdvantages.Fastest,
rateDif = if (bestRateOffer != null) fastestOffer.rateDif else null,
),
)
}
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.domain.onramp.utils
import java.math.BigDecimal
internal fun calculateRateDif(currentTokenRate: BigDecimal, bestRate: BigDecimal?): BigDecimal? {
if (bestRate == null) return null
return BigDecimal.ONE - currentTokenRate / bestRate
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.onramp.alloffers
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
internal interface AllOffersComponent : ComposableBottomSheetComponent {
data class Params(
val userWallet: UserWallet,
val cryptoCurrency: CryptoCurrency,
val onDismiss: () -> Unit,
)
interface Factory : ComponentFactory<Params, AllOffersComponent>
}

View file

@ -0,0 +1,31 @@
package com.tangem.features.onramp.alloffers
import androidx.compose.runtime.Composable
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.onramp.alloffers.model.AllOffersModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAllOffersComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: AllOffersComponent.Params,
) : AllOffersComponent, AppComponentContext by context {
private val model: AllOffersModel = getOrCreateModel(params)
override fun dismiss() {
model.dismiss()
}
@Composable
override fun BottomSheet() {
// TODO to be continued in [REDACTED_TASK_KEY]
}
@AssistedFactory
interface Factory : AllOffersComponent.Factory {
override fun create(context: AppComponentContext, params: AllOffersComponent.Params): DefaultAllOffersComponent
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.onramp.alloffers.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.onramp.alloffers.model.AllOffersModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface AllOffersComponentModelModule {
@Binds
@IntoMap
@ClassKey(AllOffersModel::class)
fun bindAllOffersModel(model: AllOffersModel): Model
}

View file

@ -0,0 +1,18 @@
package com.tangem.features.onramp.alloffers.di
import com.tangem.features.onramp.alloffers.AllOffersComponent
import com.tangem.features.onramp.alloffers.DefaultAllOffersComponent
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 AllOffersComponentModule {
@Binds
@Singleton
fun bindAllOffersComponentFactory(factory: DefaultAllOffersComponent.Factory): AllOffersComponent.Factory
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.onramp.alloffers.model
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.features.onramp.alloffers.AllOffersComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import javax.inject.Inject
internal class AllOffersModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
) : Model() {
private val params: AllOffersComponent.Params = paramsContainer.require()
fun dismiss() {
params.onDismiss()
}
}

View file

@ -12,6 +12,7 @@ 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.decompose.ComposableBottomSheetComponent
import com.tangem.features.onramp.alloffers.AllOffersComponent
import com.tangem.features.onramp.confirmresidency.ConfirmResidencyComponent
import com.tangem.features.onramp.mainv2.entity.OnrampV2MainBottomSheetConfig
import com.tangem.features.onramp.mainv2.model.OnrampV2MainComponentModel
@ -26,6 +27,7 @@ internal class DefaultOnrampV2MainComponent @AssistedInject constructor(
@Assisted private val params: OnrampV2MainComponent.Params,
private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory,
private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory,
private val allOffersComponentFactory: AllOffersComponent.Factory,
) : OnrampV2MainComponent, AppComponentContext by appComponentContext {
private val model: OnrampV2MainComponentModel = getOrCreateModel(params)
@ -67,6 +69,14 @@ internal class DefaultOnrampV2MainComponent @AssistedInject constructor(
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
OnrampV2MainBottomSheetConfig.AllOffers -> allOffersComponentFactory.create(
context = childByContext(componentContext),
params = AllOffersComponent.Params(
userWallet = model.userWallet,
cryptoCurrency = params.cryptoCurrency,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
}
@AssistedFactory

View file

@ -6,5 +6,5 @@ class DefaultOnrampV2MainFeatureToggle(
private val featureTogglesManager: FeatureTogglesManager,
) : OnrampV2MainFeatureToggle {
override val isOnrampNewMainEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("NEW_ONRAMP_RECEIVE_ENABLED")
get() = featureTogglesManager.isFeatureEnabled("NEW_ONRAMP_MAIN_ENABLED")
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.onramp.mainv2.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import kotlinx.collections.immutable.ImmutableList
@ -20,22 +21,32 @@ internal sealed interface OnrampOffersBlockUM {
data class Content(
override val isBlockVisible: Boolean,
val offers: ImmutableList<OnrampOfferUM>,
val recentOffer: OnrampOfferUM?,
val recommended: ImmutableList<OnrampOfferUM>,
val onrampAllOffersButtonConfig: OnrampAllOffersButtonConfig?,
) : OnrampOffersBlockUM
}
internal data class OnrampOfferUM(
val category: OnrampOfferCategory,
val advantages: OnrampOfferAdvantages,
val category: OnrampOfferCategoryUM,
val advantages: OnrampOfferAdvantagesUM,
val paymentMethod: OnrampPaymentMethod,
val providerId: String,
val providerName: String,
val rate: String,
val diff: TextReference?,
val onBuyClicked: () -> Unit,
)
internal enum class OnrampOfferCategory {
internal enum class OnrampOfferCategoryUM {
RecentlyUsed, Recommended
}
internal enum class OnrampOfferAdvantages {
internal enum class OnrampOfferAdvantagesUM {
Default, BestRate, Fastest
}
}
internal data class OnrampAllOffersButtonConfig(
val title: TextReference,
val onClick: () -> Unit,
)

View file

@ -10,4 +10,7 @@ sealed interface OnrampV2MainBottomSheetConfig {
@Serializable
data object CurrenciesList : OnrampV2MainBottomSheetConfig
@Serializable
data object AllOffers : OnrampV2MainBottomSheetConfig
}

View file

@ -0,0 +1,106 @@
package com.tangem.features.onramp.mainv2.entity.factory
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.onramp.model.*
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.OnrampIntents
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns.MINUS
import kotlinx.collections.immutable.toPersistentList
internal class OnrampOffersStateFactory(
private val currentStateProvider: Provider<OnrampV2MainComponentUM>,
private val onrampIntents: OnrampIntents,
) {
fun getOnShowOffersState(offers: List<OnrampOffersBlock>): OnrampV2MainComponentUM {
val currentState = currentStateProvider.invoke()
return when (currentState) {
is OnrampV2MainComponentUM.Content -> {
currentState.copy(
offersBlockState = mapOnrampOffersBlockToUM(offers),
)
}
is OnrampV2MainComponentUM.InitialLoading -> {
currentState
}
}
}
private fun mapOnrampOffersBlockToUM(offersBlocks: List<OnrampOffersBlock>): OnrampOffersBlockUM.Content {
val allOffersUM = mutableListOf<OnrampOfferUM>()
offersBlocks.map { block ->
block.offers.forEach { offer ->
when (val currentQuote = offer.quote) {
is OnrampQuote.AmountError,
is OnrampQuote.Error,
-> Unit
is OnrampQuote.Data -> {
allOffersUM.add(
OnrampOfferUM(
category = mapOfferCategoryDTOtoUM(block.category),
advantages = mapOfferAdvantagesDTOtoUM(offer.advantages),
paymentMethod = currentQuote.paymentMethod,
providerId = currentQuote.provider.id,
providerName = currentQuote.provider.info.name,
rate = currentQuote.toAmount.value.format {
crypto(
symbol = currentQuote.toAmount.symbol,
decimals = currentQuote.toAmount.decimals,
)
},
diff = offer.rateDif?.let { diff ->
stringReference("$MINUS${diff.format { percent() }}")
},
onBuyClicked = {
onrampIntents.onBuyClick(
quote = OnrampProviderWithQuote.Data(
provider = offer.quote.provider,
paymentMethod = currentQuote.paymentMethod,
toAmount = currentQuote.toAmount,
fromAmount = currentQuote.fromAmount,
),
)
},
),
)
}
}
}
}
return OnrampOffersBlockUM.Content(
isBlockVisible = false,
recentOffer = allOffersUM.firstOrNull { it.category == OnrampOfferCategoryUM.RecentlyUsed },
recommended = allOffersUM.filter { it.category == OnrampOfferCategoryUM.Recommended }.toPersistentList(),
onrampAllOffersButtonConfig = if (offersBlocks.any { it.hasMoreOffers }) {
OnrampAllOffersButtonConfig(
title = TextReference.Res(R.string.onramp_all_offers_button_title),
onClick = { onrampIntents.openProviders() },
)
} else {
null
},
)
}
private fun mapOfferCategoryDTOtoUM(category: OnrampOfferCategory): OnrampOfferCategoryUM {
return when (category) {
OnrampOfferCategory.Recent -> OnrampOfferCategoryUM.RecentlyUsed
OnrampOfferCategory.Recommended -> OnrampOfferCategoryUM.Recommended
}
}
private fun mapOfferAdvantagesDTOtoUM(advantages: OnrampOfferAdvantages): OnrampOfferAdvantagesUM {
return when (advantages) {
OnrampOfferAdvantages.Default -> OnrampOfferAdvantagesUM.Default
OnrampOfferAdvantages.BestRate -> OnrampOfferAdvantagesUM.BestRate
OnrampOfferAdvantages.Fastest -> OnrampOfferAdvantagesUM.Fastest
}
}
}

View file

@ -20,6 +20,7 @@ import com.tangem.features.onramp.main.entity.OnrampLastUpdate
import com.tangem.features.onramp.mainv2.OnrampV2MainComponent
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory
import com.tangem.features.onramp.mainv2.entity.factory.OnrampOffersStateFactory
import com.tangem.features.onramp.mainv2.entity.factory.OnrampV2AmountStateFactory
import com.tangem.features.onramp.mainv2.entity.factory.OnrampV2StateFactory
import com.tangem.features.onramp.utils.sendOnrampErrorEvent
@ -32,7 +33,7 @@ import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
internal class OnrampV2MainComponentModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
@ -44,6 +45,7 @@ internal class OnrampV2MainComponentModel @Inject constructor(
private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase,
private val fetchPairsUseCase: OnrampFetchPairsUseCase,
private val amountInputManager: InputManager,
private val getOnrampOffersUseCase: GetOnrampOffersUseCase,
paramsContainer: ParamsContainer,
getWalletsUseCase: GetWalletsUseCase,
) : Model(), OnrampIntents {
@ -58,6 +60,13 @@ internal class OnrampV2MainComponentModel @Inject constructor(
OnrampAmountButtonUMStateFactory()
}
private val onrampOffersStateFactory: OnrampOffersStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampOffersStateFactory(
currentStateProvider = Provider { _state.value },
onrampIntents = this,
)
}
private val stateFactory = OnrampV2StateFactory(
currentStateProvider = Provider { _state.value },
cryptoCurrency = params.cryptoCurrency,
@ -94,6 +103,7 @@ internal class OnrampV2MainComponentModel @Inject constructor(
subscribeToAmountChanges()
subscribeToCountryAndCurrencyUpdates()
subscribeToQuotesUpdate()
subscribeOnOffers()
}
override fun onDestroy() {
@ -117,11 +127,19 @@ internal class OnrampV2MainComponentModel @Inject constructor(
}
override fun onBuyClick(quote: OnrampProviderWithQuote.Data) {
// TODO in [REDACTED_TASK_KEY] to be continued
val currentContentState = state.value as? OnrampV2MainComponentUM.Content ?: return
analyticsEventHandler.send(
OnrampAnalyticsEvent.OnBuyClick(
providerName = quote.provider.info.name,
currency = currentContentState.amountBlockState.currencyUM.code,
tokenSymbol = params.cryptoCurrency.symbol,
),
)
params.openRedirectPage(quote)
}
override fun openProviders() {
// TODO in [REDACTED_TASK_KEY] to be continued
bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.AllOffers)
}
override fun onRefresh() {
@ -172,6 +190,20 @@ internal class OnrampV2MainComponentModel @Inject constructor(
router.pop()
}
private fun subscribeOnOffers() = modelScope.launch {
getOnrampOffersUseCase.invoke(
userWalletId = userWallet.walletId,
cryptoCurrencyId = params.cryptoCurrency.id,
).collectLatest { maybeOffers ->
maybeOffers.fold(
ifLeft = ::handleOnrampError,
ifRight = { offers ->
_state.update { onrampOffersStateFactory.getOnShowOffersState(offers) }
},
)
}
}
private fun subscribeToAmountChanges() = modelScope.launch {
amountInputManager.query
.filter(String::isNotEmpty)

View file

@ -1,13 +1,42 @@
package com.tangem.features.onramp.mainv2.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodType
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM
import com.tangem.features.onramp.mainv2.entity.OnrampOffersBlockUM
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
@ -23,10 +52,330 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
),
label = "Offers block animation",
) {
Text(
text = "Some offers",
style = TangemTheme.typography.head,
if (state is OnrampOffersBlockUM.Content) {
Column(modifier = Modifier.fillMaxWidth()) {
state.recentOffer?.let { recentOffer ->
Column {
Text(
modifier = Modifier.padding(start = 12.dp),
text = stringResourceSafe(R.string.onramp_recently_used_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
SpacerH(8.dp)
Offer(recentOffer)
SpacerH(16.dp)
}
}
Text(
modifier = Modifier.padding(start = 12.dp),
text = stringResourceSafe(R.string.onramp_recommended_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
SpacerH(8.dp)
state.recommended.fastForEach {
Offer(it)
SpacerH(8.dp)
}
state.onrampAllOffersButtonConfig?.let {
SpacerH(12.dp)
SecondaryButton(
modifier = Modifier.fillMaxWidth(),
text = it.title.resolveReference(),
onClick = it.onClick,
)
}
}
}
}
}
@Composable
private fun Offer(onrampOfferUM: OnrampOfferUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.background(
color = TangemTheme.colors.background.action,
shape = RoundedCornerShape(14.dp),
)
.padding(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
OfferHeader(advantage = onrampOfferUM.advantages)
RateBlock(rate = onrampOfferUM.rate, diff = onrampOfferUM.diff)
}
SpacerWMax()
SecondaryButton(
size = TangemButtonSize.RoundedAction,
text = stringResourceSafe(R.string.common_buy),
onClick = onrampOfferUM.onBuyClicked,
)
}
SpacerH(10.dp)
HorizontalDivider(color = TangemTheme.colors.stroke.primary)
SpacerH(12.dp)
PaymentBlockInOffer(
paymentMethod = onrampOfferUM.paymentMethod,
providerName = onrampOfferUM.providerName,
)
// TODO in [REDACTED_TASK_KEY] to be continued
}
}
@Composable
private fun OfferHeader(advantage: OnrampOfferAdvantagesUM) {
when (advantage) {
OnrampOfferAdvantagesUM.Default -> {
Text(
text = stringResourceSafe(R.string.onramp_title_you_get),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
OnrampOfferAdvantagesUM.BestRate -> {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_best_rate_16),
tint = TangemTheme.colors.icon.accent,
contentDescription = null,
)
Text(
text = stringResourceSafe(R.string.express_provider_best_rate),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.accent,
)
}
}
OnrampOfferAdvantagesUM.Fastest -> {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_fastest_16),
tint = TangemTheme.colors.icon.attention,
contentDescription = null,
)
Text(
text = stringResourceSafe(R.string.onramp_offer_type_fastet),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.attention,
)
}
}
}
}
@Composable
private fun RateBlock(rate: String, diff: TextReference?) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = rate,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
diff?.let {
Text(
modifier = Modifier
.background(
color = TangemTheme.colors.text.warning.copy(alpha = 0.1f),
shape = RoundedCornerShape(4.dp),
)
.padding(horizontal = 4.dp),
text = it.resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.warning,
)
}
}
}
@Composable
private fun PaymentBlockInOffer(paymentMethod: OnrampPaymentMethod, providerName: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = ImageVector.vectorResource(R.drawable.ic_clock_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
SpacerW(2.dp)
TimingBlock(speed = paymentMethod.type.getProcessingSpeed())
SpacerW(6.dp)
DrawDot(color = TangemTheme.colors.text.tertiary)
SpacerW(6.dp)
Text(
text = providerName,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
SpacerWMax()
Text(
text = stringResourceSafe(R.string.onramp_pay_with),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
SpacerW(4.dp)
SubcomposeAsyncImage(
modifier = Modifier.sizeIn(maxWidth = 38.dp, maxHeight = 16.dp),
model = ImageRequest.Builder(context = LocalContext.current)
.data(paymentMethod.imageUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
loading = {
TextShimmer(
style = TangemTheme.typography.body1,
modifier = Modifier.width(40.dp),
)
},
contentDescription = null,
)
}
}
@Composable
private fun TimingBlock(speed: PaymentMethodType.PaymentSpeed) {
val timingText = when (speed) {
PaymentMethodType.PaymentSpeed.Instant -> {
stringResourceSafe(id = R.string.onramp_instant_status)
}
PaymentMethodType.PaymentSpeed.FewMin -> {
stringResourceSafe(
id = R.string.onramp_timing_minutes,
FEW_MINS_VALUE,
)
}
PaymentMethodType.PaymentSpeed.FewDays -> {
pluralStringResourceSafe(
id = R.plurals.onramp_timing_days,
count = FEW_DAYS_VALUE,
FEW_DAYS_VALUE,
)
}
PaymentMethodType.PaymentSpeed.PlentyDays -> {
pluralStringResourceSafe(
id = R.plurals.onramp_timing_days,
count = PLENTY_DAYS_VALUE,
PLENTY_DAYS_VALUE,
)
}
PaymentMethodType.PaymentSpeed.Unknown -> ""
}
Text(
text = timingText,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
@Composable
fun DrawDot(color: Color) {
Spacer(
modifier = Modifier
.size(4.dp)
.drawWithCache {
val radius = size.minDimension / 2f
onDrawBehind {
drawCircle(
color = color,
radius = radius,
)
}
},
)
}
// will be hardcoded till server will be ready to provide this values
private const val FEW_MINS_VALUE = "3-5"
private const val FEW_DAYS_VALUE = 3
private const val PLENTY_DAYS_VALUE = 5
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun OnrampOffersContentPreview() {
val state = OnrampOffersBlockUM.Content(
isBlockVisible = true,
recentOffer = OnrampOfferUM(
category = OnrampOfferCategoryUM.RecentlyUsed,
advantages = OnrampOfferAdvantagesUM.Default,
paymentMethod = OnrampPaymentMethod(
id = "card",
name = "Card",
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "providerId3",
providerName = "Simplex",
rate = "0,00045334 BTC",
diff = stringReference("27%"),
onBuyClicked = {},
),
recommended = persistentListOf(
OnrampOfferUM(
category = OnrampOfferCategoryUM.Recommended,
advantages = OnrampOfferAdvantagesUM.BestRate,
paymentMethod = OnrampPaymentMethod(
id = "card",
name = "Card",
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "providerId1",
providerName = "Simplex",
rate = "0,0245334 BTC",
diff = null,
onBuyClicked = {},
),
OnrampOfferUM(
category = OnrampOfferCategoryUM.Recommended,
advantages = OnrampOfferAdvantagesUM.Fastest,
paymentMethod = OnrampPaymentMethod(
id = "card",
name = "Card",
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "providerId2",
providerName = "Simplex",
rate = "0,00145334 BTC",
diff = stringReference("0.07%"),
onBuyClicked = {},
),
),
onrampAllOffersButtonConfig = null,
)
TangemThemePreview {
OnrampOffersContent(state)
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.onramp.mainv2.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@ -51,6 +52,7 @@ internal fun OnrampV2AmountContent(state: OnrampV2MainComponentUM.Content, modif
Column(
modifier = modifier
.fillMaxWidth()
.background(
color = TangemTheme.colors.background.action,
shape = RoundedCornerShape(size = TangemTheme.dimens.radius16),
@ -61,16 +63,14 @@ internal fun OnrampV2AmountContent(state: OnrampV2MainComponentUM.Content, modif
) {
OnrampHeaderTitle()
SpacerH(12.dp)
OnrampAmountField(
amountField = state.amountBlockState.amountFieldModel,
currencyCode = state.amountBlockState.currencyUM.code,
)
SpacerH(8.dp)
OnrampAmountSecondary(state = state.amountBlockState.secondaryFieldModel)
AnimatedVisibility(!state.offersBlockState.isBlockVisible) {
OnrampAmountSecondary(state = state.amountBlockState.secondaryFieldModel)
}
SpacerH(20.dp)
@ -115,7 +115,8 @@ private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: Strin
modifier = Modifier
.focusRequester(requester)
.padding(
top = TangemTheme.dimens.spacing24,
top = TangemTheme.dimens.spacing8,
bottom = TangemTheme.dimens.spacing4,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
)

View file

@ -17,6 +17,8 @@ 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.mainv2.OnrampV2MainComponent
import com.tangem.features.onramp.mainv2.OnrampV2MainFeatureToggle
import com.tangem.features.onramp.redirect.OnrampRedirectComponent
import com.tangem.features.onramp.root.entity.OnrampChild
import com.tangem.features.onramp.settings.OnrampSettingsComponent
@ -24,13 +26,15 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Suppress("UnusedPrivateMember")
@Suppress("LongParameterList")
internal class DefaultOnrampComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: OnrampComponent.Params,
private val settingsComponentFactory: OnrampSettingsComponent.Factory,
private val onrampMainComponentFactory: OnrampMainComponent.Factory,
private val onrampMainV2ComponentFactory: OnrampV2MainComponent.Factory,
private val onrampRedirectComponentFactory: OnrampRedirectComponent.Factory,
private val onrampV2MainFeatureToggle: OnrampV2MainFeatureToggle,
) : OnrampComponent, AppComponentContext by context {
private val navigation = StackNavigation<OnrampChild>()
@ -66,24 +70,44 @@ internal class DefaultOnrampComponent @AssistedInject constructor(
onBack = navigation::pop,
),
)
OnrampChild.Main -> onrampMainComponentFactory.create(
context = childByContext(componentContext),
params = OnrampMainComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
openSettings = { navigation.push(OnrampChild.Settings) },
source = params.source,
openRedirectPage = {
navigation.push(
OnrampChild.RedirectPage(
quote = it,
cryptoCurrency = params.cryptoCurrency,
),
)
},
launchSepa = params.launchSepa,
),
)
OnrampChild.Main -> if (onrampV2MainFeatureToggle.isOnrampNewMainEnabled) {
onrampMainV2ComponentFactory.create(
context = childByContext(componentContext),
params = OnrampV2MainComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
openSettings = { navigation.push(OnrampChild.Settings) },
source = params.source,
openRedirectPage = {
navigation.push(
OnrampChild.RedirectPage(
quote = it,
cryptoCurrency = params.cryptoCurrency,
),
)
},
),
)
} else {
onrampMainComponentFactory.create(
context = childByContext(componentContext),
params = OnrampMainComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
openSettings = { navigation.push(OnrampChild.Settings) },
source = params.source,
openRedirectPage = {
navigation.push(
OnrampChild.RedirectPage(
quote = it,
cryptoCurrency = params.cryptoCurrency,
),
)
},
launchSepa = params.launchSepa,
),
)
}
is OnrampChild.RedirectPage -> onrampRedirectComponentFactory.create(
context = childByContext(componentContext),
params = OnrampRedirectComponent.Params(